diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 96849357..4c2be89c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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: @@ -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 diff --git a/.github/workflows/publish-conan-recipes.yml b/.github/workflows/publish-conan-recipes.yml new file mode 100644 index 00000000..02c35184 --- /dev/null +++ b/.github/workflows/publish-conan-recipes.yml @@ -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 }} diff --git a/.gitignore b/.gitignore index 5c47ab4f..b73ee422 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,7 @@ TestProject/.idea TestProject/.vscode TestProject/cmake-build* TestProject/build* + +# macOS +.DS_Store +**/.DS_Store diff --git a/CMake/Common.cmake b/CMake/Common.cmake index c2e33c98..731ab792 100644 --- a/CMake/Common.cmake +++ b/CMake/Common.cmake @@ -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 " 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) diff --git a/ThirdParty/ConanRecipes/README.md b/ThirdParty/ConanRecipes/README.md index 1338e4fb..74c331d8 100644 --- a/ThirdParty/ConanRecipes/README.md +++ b/ThirdParty/ConanRecipes/README.md @@ -45,4 +45,28 @@ python build_recipes.py --upload \ --remote \ --remote-url \ --remote-user --remote-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. diff --git a/ThirdParty/ConanRecipes/build_recipes.py b/ThirdParty/ConanRecipes/build_recipes.py index 03079993..384f1daa 100644 --- a/ThirdParty/ConanRecipes/build_recipes.py +++ b/ThirdParty/ConanRecipes/build_recipes.py @@ -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 @@ -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 [] @@ -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]: @@ -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 @@ -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( @@ -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]] = [] @@ -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: @@ -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] @@ -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)