Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ jobs:
with:
node-version: 24

- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.12'

- name: Setup CMake
uses: jwlawson/actions-setup-cmake@v2
with:
Expand All @@ -57,6 +62,14 @@ jobs:
- name: Config Conan Remote
run: conan remote add explosion https://conan.kindem.online/artifactory/api/conan/conan

# Register the in-repo recipes in the local cache so the engine's 'conan install --build=missing'
# resolves them from this checkout: unchanged recipes hash to the revision already published on the
# remote (binaries are downloaded), changed ones get built locally, making recipe PRs self-contained.
- name: Export Conan Recipes
run: |
pip install pyyaml
python ThirdParty/ConanRecipes/build_recipes.py --export-only

- name: Configure CMake
run: cmake -B ${{github.workspace}}/build -G=Ninja -DCMAKE_BUILD_TYPE=${{env.BUILD_TYPE}} -DCI=ON

Expand Down
82 changes: 82 additions & 0 deletions .github/workflows/publish-conan-recipes.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
name: Publish Conan Recipes

on:
push:
branches: [master]
paths:
- 'ThirdParty/ConanRecipes/**'
- '.github/workflows/publish-conan-recipes.yml'
workflow_dispatch:

concurrency:
group: publish-conan-recipes

env:
CONAN_REMOTE_NAME: explosion
CONAN_REMOTE_URL: https://conan.kindem.online/artifactory/api/conan/conan
CMAKE_VERSION: '4.1.2'

jobs:
publish:
# Guard against forks: pushes to a fork's master must not run (and could not upload anyway,
# since secrets are only configured on the canonical repository).
if: github.repository == 'ExplosionEngine/Explosion'

strategy:
# Platforms publish independently: a failure on one OS must not cancel the other mid-upload.
fail-fast: false
matrix:
# cppstd is pinned per platform to match the binaries already on the remote; consumers on a
# higher standard (the engine uses C++20) still match via Conan's default compatibility plugin.
include:
- os: windows-latest
cppstd: '17'
- os: macOS-latest
cppstd: gnu17

runs-on: ${{ matrix.os }}

steps:
- name: Set XCode Version
run: sudo xcode-select -s /Applications/Xcode.app/Contents/Developer
if: runner.os == 'macOS'

- name: Setup MSVC
uses: ilammy/msvc-dev-cmd@v1
if: runner.os == 'Windows'

- name: Checkout Repo
uses: actions/checkout@v4

- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.12'

- name: Setup CMake
uses: jwlawson/actions-setup-cmake@v2
with:
cmake-version: ${{env.CMAKE_VERSION}}

- name: Setup Conan
uses: conan-io/setup-conan@v1

- name: Detect Conan Profile
run: conan profile detect --force

- name: Config Conan Remote
run: conan remote add ${{ env.CONAN_REMOTE_NAME }} ${{ env.CONAN_REMOTE_URL }}

- name: Install Python Dependencies
run: pip install pyyaml

# --build=missing (the script default) downloads binaries the remote already has for the same
# recipe revision, so only recipes actually changed by the triggering push get rebuilt.
- name: Build And Upload Recipes
run: >-
python ThirdParty/ConanRecipes/build_recipes.py
--conan-arg=--settings=compiler.cppstd=${{ matrix.cppstd }}
--upload
--remote ${{ env.CONAN_REMOTE_NAME }}
--remote-user ${{ secrets.CONAN_REMOTE_USER }}
--remote-password ${{ secrets.CONAN_REMOTE_PASSWORD }}
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,7 @@ TestProject/.idea
TestProject/.vscode
TestProject/cmake-build*
TestProject/build*

# macOS
.DS_Store
**/.DS_Store
6 changes: 6 additions & 0 deletions CMake/Common.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,9 @@ if (${MSVC})
NOMINMAX=1
)
endif ()

# This project and its downstream consumers reference Qt only through the versioned Qt6:: targets. Suppressing the
# versionless Qt:: aliases stops the single-config Qt host tools from triggering "IMPORTED_LOCATION not set ...
# configuration <cfg>" errors in the IDE file-API codemodel on non-Release builds, where they only carry a Release
# import while Debug/RelWithDebInfo/MinSizeRel are mapped onto it.
set(QT_NO_CREATE_VERSIONLESS_TARGETS TRUE)
24 changes: 24 additions & 0 deletions ThirdParty/ConanRecipes/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,28 @@ python build_recipes.py --upload \
--remote <remote> \
--remote-url <remote-url> \
--remote-user <user> --remote-password <password>

# just register every recipe version in the local conan cache (no build);
# a later 'conan install --build=missing' then builds whatever the remote
# has no binaries for
python build_recipes.py --export-only
```

## CI

Recipe changes are validated and published automatically:

- Every pull request runs the engine build workflow, which first runs
`build_recipes.py --export-only`. Unchanged recipes hash to the same
revision already published on the remote so their binaries are simply
downloaded, while recipes changed by the PR are built from source inside
the job. A PR can therefore change recipes and engine code together and
be validated atomically.
- After a push to `master` that touches `ThirdParty/ConanRecipes`, the
`Publish Conan Recipes` workflow builds the changed recipes on Windows
and macOS and uploads them to the remote (credentials come from the
`CONAN_REMOTE_USER` / `CONAN_REMOTE_PASSWORD` repository secrets). It can
also be re-run manually via `workflow_dispatch` if an upload failed.

To keep remote revisions in sync with git, avoid uploading from local
machines; let the publish workflow be the only writer.
55 changes: 44 additions & 11 deletions ThirdParty/ConanRecipes/build_recipes.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
top entry in conandata.yml), filtered by the recipe's `platforms` list. The
first failure stops the run and prints a summary. Uploading is opt-in and only
runs once every recipe has built successfully.

With --export-only the script just runs 'conan export' for every version of
every recipe, so a subsequent 'conan install --build=missing' resolves recipes
from the local cache and builds whatever the remote has no binaries for. CI
uses this to validate recipe changes together with the engine build.
"""

from __future__ import annotations
Expand Down Expand Up @@ -45,7 +50,8 @@ def __init__(self, path: Path):

data = yaml.safe_load(self.conandata.read_text(encoding="utf-8")) or {}
self.name = self._parse_name() or self.dir_name
self.version = latest_version(data)
self.versions = list_versions(data)
self.version = self.versions[0] if self.versions else None
self.platforms = data.get("platforms") or []
self.requires = data.get("requires") or []

Expand All @@ -62,14 +68,14 @@ def reference(self) -> str:
return f"{self.name}/{self.version}"


def latest_version(data: dict) -> str | None:
def list_versions(data: dict) -> list[str]:
sources = data.get("sources")
if isinstance(sources, dict) and sources:
return next(iter(sources))
return [str(v) for v in sources]
versions = data.get("versions")
if isinstance(versions, list) and versions:
return versions[0]
return None
return [str(v) for v in versions]
return []


def discover_recipes(root: Path) -> list[Recipe]:
Expand Down Expand Up @@ -104,8 +110,9 @@ def visit(recipe: Recipe):
return ordered


def run(cmd: list[str], cwd: Path | None = None) -> int:
print(f"\n$ {' '.join(cmd)}", flush=True)
def run(cmd: list[str], cwd: Path | None = None, redact: set[str] | None = None) -> int:
shown = " ".join("***" if redact and arg in redact else arg for arg in cmd)
print(f"\n$ {shown}", flush=True)
return subprocess.run(cmd, cwd=str(cwd) if cwd else None).returncode


Expand Down Expand Up @@ -140,6 +147,11 @@ def parse_args() -> argparse.Namespace:
parser.add_argument(
"--skip", action="append", default=[], help="skip these recipe names (repeatable)"
)
parser.add_argument(
"--export-only",
action="store_true",
help="only 'conan export' every version of every recipe; no build, no platform filter",
)

upload = parser.add_argument_group("upload")
upload.add_argument(
Expand All @@ -157,9 +169,24 @@ def parse_args() -> argparse.Namespace:
args = parser.parse_args()
if args.upload and not args.remote:
parser.error("--upload requires --remote")
if args.export_only and args.upload:
parser.error("--export-only cannot be combined with --upload")
return args


def export_all(args: argparse.Namespace, recipes: list[Recipe]):
count = 0
for recipe in recipes:
if not recipe.versions:
sys.exit(f"error: could not determine versions from {recipe.conandata}")
for version in recipe.versions:
cmd = [args.conan, "export", f"{recipe.dir_name}/conanfile.py", "--version", version]
if run(cmd, cwd=args.recipes_root) != 0:
sys.exit(f"error: failed to export {recipe.name}/{version}")
count += 1
print(f"\nExported {count} recipe version(s).", flush=True)


def build_all(args: argparse.Namespace, recipes: list[Recipe], host: str):
built: list[Recipe] = []
skipped: list[tuple[Recipe, str]] = []
Expand Down Expand Up @@ -215,9 +242,11 @@ def upload_all(args: argparse.Namespace, built: list[Recipe]):

if args.remote_user is not None:
login = [args.conan, "remote", "login", args.remote, args.remote_user]
redact: set[str] = set()
if args.remote_password is not None:
login += ["-p", args.remote_password]
if run(login) != 0:
redact.add(args.remote_password)
if run(login, redact=redact) != 0:
sys.exit("error: failed to log in to remote")

for recipe in built:
Expand Down Expand Up @@ -250,9 +279,6 @@ def main():
if not root.is_dir():
sys.exit(f"error: recipes root not found: {root}")

host = current_platform()
print(f"Host platform: {host}", flush=True)

recipes = discover_recipes(root)
if args.only:
recipes = [r for r in recipes if r.name in args.only or r.dir_name in args.only]
Expand All @@ -261,6 +287,13 @@ def main():
if not recipes:
sys.exit("error: no recipes to build")

if args.export_only:
export_all(args, recipes)
return

host = current_platform()
print(f"Host platform: {host}", flush=True)

recipes = order_by_dependencies(recipes)
print("Build order: " + ", ".join(r.name for r in recipes), flush=True)

Expand Down
Loading