Skip to content

Commit cb45162

Browse files
committed
ci: validate and publish conan recipes automatically
PR validation: build.yml now exports every in-repo recipe into the local conan cache before configuring, so 'conan install --build=missing' builds recipes changed by the PR from source and downloads the rest from the remote. Recipe and engine changes are validated atomically in one PR, and master stays buildable during the publish window after a merge. Publishing: a new workflow builds and uploads recipes on pushes to master that touch ThirdParty/ConanRecipes (plus manual workflow_dispatch). It is guarded to the canonical repo so forks don't run it, and pins cppstd to 17/gnu17 to match the binaries already on the remote. build_recipes.py gains an --export-only mode that exports every version of every recipe without building or platform filtering.
1 parent e248570 commit cb45162

4 files changed

Lines changed: 163 additions & 11 deletions

File tree

.github/workflows/build.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ jobs:
4646
with:
4747
node-version: 24
4848

49+
- name: Setup Python
50+
uses: actions/setup-python@v5
51+
with:
52+
python-version: '3.12'
53+
4954
- name: Setup CMake
5055
uses: jwlawson/actions-setup-cmake@v2
5156
with:
@@ -57,6 +62,14 @@ jobs:
5762
- name: Config Conan Remote
5863
run: conan remote add explosion https://conan.kindem.online/artifactory/api/conan/conan
5964

65+
# Register the in-repo recipes in the local cache so the engine's 'conan install --build=missing'
66+
# resolves them from this checkout: unchanged recipes hash to the revision already published on the
67+
# remote (binaries are downloaded), changed ones get built locally, making recipe PRs self-contained.
68+
- name: Export Conan Recipes
69+
run: |
70+
pip install pyyaml
71+
python ThirdParty/ConanRecipes/build_recipes.py --export-only
72+
6073
- name: Configure CMake
6174
run: cmake -B ${{github.workspace}}/build -G=Ninja -DCMAKE_BUILD_TYPE=${{env.BUILD_TYPE}} -DCI=ON
6275

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
name: Publish Conan Recipes
2+
3+
on:
4+
push:
5+
branches: [master]
6+
paths:
7+
- 'ThirdParty/ConanRecipes/**'
8+
- '.github/workflows/publish-conan-recipes.yml'
9+
workflow_dispatch:
10+
11+
concurrency:
12+
group: publish-conan-recipes
13+
14+
env:
15+
CONAN_REMOTE_NAME: explosion
16+
CONAN_REMOTE_URL: https://conan.kindem.online/artifactory/api/conan/conan
17+
CMAKE_VERSION: '4.1.2'
18+
19+
jobs:
20+
publish:
21+
# Guard against forks: pushes to a fork's master must not run (and could not upload anyway,
22+
# since secrets are only configured on the canonical repository).
23+
if: github.repository == 'ExplosionEngine/Explosion'
24+
25+
strategy:
26+
# Platforms publish independently: a failure on one OS must not cancel the other mid-upload.
27+
fail-fast: false
28+
matrix:
29+
# cppstd is pinned per platform to match the binaries already on the remote; consumers on a
30+
# higher standard (the engine uses C++20) still match via Conan's default compatibility plugin.
31+
include:
32+
- os: windows-latest
33+
cppstd: '17'
34+
- os: macOS-latest
35+
cppstd: gnu17
36+
37+
runs-on: ${{ matrix.os }}
38+
39+
steps:
40+
- name: Set XCode Version
41+
run: sudo xcode-select -s /Applications/Xcode.app/Contents/Developer
42+
if: runner.os == 'macOS'
43+
44+
- name: Setup MSVC
45+
uses: ilammy/msvc-dev-cmd@v1
46+
if: runner.os == 'Windows'
47+
48+
- name: Checkout Repo
49+
uses: actions/checkout@v4
50+
51+
- name: Setup Python
52+
uses: actions/setup-python@v5
53+
with:
54+
python-version: '3.12'
55+
56+
- name: Setup CMake
57+
uses: jwlawson/actions-setup-cmake@v2
58+
with:
59+
cmake-version: ${{env.CMAKE_VERSION}}
60+
61+
- name: Setup Conan
62+
uses: conan-io/setup-conan@v1
63+
64+
- name: Detect Conan Profile
65+
run: conan profile detect --force
66+
67+
- name: Config Conan Remote
68+
run: conan remote add ${{ env.CONAN_REMOTE_NAME }} ${{ env.CONAN_REMOTE_URL }}
69+
70+
- name: Install Python Dependencies
71+
run: pip install pyyaml
72+
73+
# --build=missing (the script default) downloads binaries the remote already has for the same
74+
# recipe revision, so only recipes actually changed by the triggering push get rebuilt.
75+
- name: Build And Upload Recipes
76+
run: >-
77+
python ThirdParty/ConanRecipes/build_recipes.py
78+
--conan-arg=--settings=compiler.cppstd=${{ matrix.cppstd }}
79+
--upload
80+
--remote ${{ env.CONAN_REMOTE_NAME }}
81+
--remote-user ${{ secrets.CONAN_REMOTE_USER }}
82+
--remote-password ${{ secrets.CONAN_REMOTE_PASSWORD }}

ThirdParty/ConanRecipes/README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,4 +45,28 @@ python build_recipes.py --upload \
4545
--remote <remote> \
4646
--remote-url <remote-url> \
4747
--remote-user <user> --remote-password <password>
48+
49+
# just register every recipe version in the local conan cache (no build);
50+
# a later 'conan install --build=missing' then builds whatever the remote
51+
# has no binaries for
52+
python build_recipes.py --export-only
4853
```
54+
55+
## CI
56+
57+
Recipe changes are validated and published automatically:
58+
59+
- Every pull request runs the engine build workflow, which first runs
60+
`build_recipes.py --export-only`. Unchanged recipes hash to the same
61+
revision already published on the remote so their binaries are simply
62+
downloaded, while recipes changed by the PR are built from source inside
63+
the job. A PR can therefore change recipes and engine code together and
64+
be validated atomically.
65+
- After a push to `master` that touches `ThirdParty/ConanRecipes`, the
66+
`Publish Conan Recipes` workflow builds the changed recipes on Windows
67+
and macOS and uploads them to the remote (credentials come from the
68+
`CONAN_REMOTE_USER` / `CONAN_REMOTE_PASSWORD` repository secrets). It can
69+
also be re-run manually via `workflow_dispatch` if an upload failed.
70+
71+
To keep remote revisions in sync with git, avoid uploading from local
72+
machines; let the publish workflow be the only writer.

ThirdParty/ConanRecipes/build_recipes.py

Lines changed: 44 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@
55
top entry in conandata.yml), filtered by the recipe's `platforms` list. The
66
first failure stops the run and prints a summary. Uploading is opt-in and only
77
runs once every recipe has built successfully.
8+
9+
With --export-only the script just runs 'conan export' for every version of
10+
every recipe, so a subsequent 'conan install --build=missing' resolves recipes
11+
from the local cache and builds whatever the remote has no binaries for. CI
12+
uses this to validate recipe changes together with the engine build.
813
"""
914

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

4651
data = yaml.safe_load(self.conandata.read_text(encoding="utf-8")) or {}
4752
self.name = self._parse_name() or self.dir_name
48-
self.version = latest_version(data)
53+
self.versions = list_versions(data)
54+
self.version = self.versions[0] if self.versions else None
4955
self.platforms = data.get("platforms") or []
5056
self.requires = data.get("requires") or []
5157

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

6470

65-
def latest_version(data: dict) -> str | None:
71+
def list_versions(data: dict) -> list[str]:
6672
sources = data.get("sources")
6773
if isinstance(sources, dict) and sources:
68-
return next(iter(sources))
74+
return [str(v) for v in sources]
6975
versions = data.get("versions")
7076
if isinstance(versions, list) and versions:
71-
return versions[0]
72-
return None
77+
return [str(v) for v in versions]
78+
return []
7379

7480

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

106112

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

111118

@@ -140,6 +147,11 @@ def parse_args() -> argparse.Namespace:
140147
parser.add_argument(
141148
"--skip", action="append", default=[], help="skip these recipe names (repeatable)"
142149
)
150+
parser.add_argument(
151+
"--export-only",
152+
action="store_true",
153+
help="only 'conan export' every version of every recipe; no build, no platform filter",
154+
)
143155

144156
upload = parser.add_argument_group("upload")
145157
upload.add_argument(
@@ -157,9 +169,24 @@ def parse_args() -> argparse.Namespace:
157169
args = parser.parse_args()
158170
if args.upload and not args.remote:
159171
parser.error("--upload requires --remote")
172+
if args.export_only and args.upload:
173+
parser.error("--export-only cannot be combined with --upload")
160174
return args
161175

162176

177+
def export_all(args: argparse.Namespace, recipes: list[Recipe]):
178+
count = 0
179+
for recipe in recipes:
180+
if not recipe.versions:
181+
sys.exit(f"error: could not determine versions from {recipe.conandata}")
182+
for version in recipe.versions:
183+
cmd = [args.conan, "export", f"{recipe.dir_name}/conanfile.py", "--version", version]
184+
if run(cmd, cwd=args.recipes_root) != 0:
185+
sys.exit(f"error: failed to export {recipe.name}/{version}")
186+
count += 1
187+
print(f"\nExported {count} recipe version(s).", flush=True)
188+
189+
163190
def build_all(args: argparse.Namespace, recipes: list[Recipe], host: str):
164191
built: list[Recipe] = []
165192
skipped: list[tuple[Recipe, str]] = []
@@ -215,9 +242,11 @@ def upload_all(args: argparse.Namespace, built: list[Recipe]):
215242

216243
if args.remote_user is not None:
217244
login = [args.conan, "remote", "login", args.remote, args.remote_user]
245+
redact: set[str] = set()
218246
if args.remote_password is not None:
219247
login += ["-p", args.remote_password]
220-
if run(login) != 0:
248+
redact.add(args.remote_password)
249+
if run(login, redact=redact) != 0:
221250
sys.exit("error: failed to log in to remote")
222251

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

253-
host = current_platform()
254-
print(f"Host platform: {host}", flush=True)
255-
256282
recipes = discover_recipes(root)
257283
if args.only:
258284
recipes = [r for r in recipes if r.name in args.only or r.dir_name in args.only]
@@ -261,6 +287,13 @@ def main():
261287
if not recipes:
262288
sys.exit("error: no recipes to build")
263289

290+
if args.export_only:
291+
export_all(args, recipes)
292+
return
293+
294+
host = current_platform()
295+
print(f"Host platform: {host}", flush=True)
296+
264297
recipes = order_by_dependencies(recipes)
265298
print("Build order: " + ", ".join(r.name for r in recipes), flush=True)
266299

0 commit comments

Comments
 (0)