Skip to content

Commit f4258e1

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 f4258e1

4 files changed

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

144155
upload = parser.add_argument_group("upload")
145156
upload.add_argument(
@@ -157,9 +168,24 @@ def parse_args() -> argparse.Namespace:
157168
args = parser.parse_args()
158169
if args.upload and not args.remote:
159170
parser.error("--upload requires --remote")
171+
if args.export_only and args.upload:
172+
parser.error("--export-only cannot be combined with --upload")
160173
return args
161174

162175

176+
def export_all(args: argparse.Namespace, recipes: list[Recipe]):
177+
count = 0
178+
for recipe in recipes:
179+
if not recipe.versions:
180+
sys.exit(f"error: could not determine versions from {recipe.conandata}")
181+
for version in recipe.versions:
182+
cmd = [args.conan, "export", f"{recipe.dir_name}/conanfile.py", "--version", version]
183+
if run(cmd, cwd=args.recipes_root) != 0:
184+
sys.exit(f"error: failed to export {recipe.name}/{version}")
185+
count += 1
186+
print(f"\nExported {count} recipe version(s).", flush=True)
187+
188+
163189
def build_all(args: argparse.Namespace, recipes: list[Recipe], host: str):
164190
built: list[Recipe] = []
165191
skipped: list[tuple[Recipe, str]] = []
@@ -250,9 +276,6 @@ def main():
250276
if not root.is_dir():
251277
sys.exit(f"error: recipes root not found: {root}")
252278

253-
host = current_platform()
254-
print(f"Host platform: {host}", flush=True)
255-
256279
recipes = discover_recipes(root)
257280
if args.only:
258281
recipes = [r for r in recipes if r.name in args.only or r.dir_name in args.only]
@@ -261,6 +284,13 @@ def main():
261284
if not recipes:
262285
sys.exit("error: no recipes to build")
263286

287+
if args.export_only:
288+
export_all(args, recipes)
289+
return
290+
291+
host = current_platform()
292+
print(f"Host platform: {host}", flush=True)
293+
264294
recipes = order_by_dependencies(recipes)
265295
print("Build order: " + ", ".join(r.name for r in recipes), flush=True)
266296

0 commit comments

Comments
 (0)