From da076d69fe504766e2304c16fe705f95fab8e910 Mon Sep 17 00:00:00 2001 From: Piotr Galar Date: Thu, 16 Jul 2026 13:57:39 +0100 Subject: [PATCH] Add manifest-declared mixed nightly profiles --- .github/workflows/ci_nightly.yml | 16 +- .github/workflows/ci_run.yml | 6 +- README_ADVANCED.md | 10 +- ci/README.md | 33 +++- ci/dependency-profiles.json | 31 +++- scripts/resolve-ci-dependencies.py | 70 +++++++- scripts/tests/test_resolve_ci_dependencies.py | 152 +++++++++++++++++- 7 files changed, 294 insertions(+), 24 deletions(-) diff --git a/.github/workflows/ci_nightly.yml b/.github/workflows/ci_nightly.yml index 45b3cfc..10f7d40 100644 --- a/.github/workflows/ci_nightly.yml +++ b/.github/workflows/ci_nightly.yml @@ -1,9 +1,10 @@ --- # Runs on a nightly schedule (and optionally via manual dispatch). # -# Executes the full CI matrix: stability (latest tags) and frontier -# (latest branches). Both runs file GitHub issues with their results -# unless manually suppressed via workflow_dispatch inputs. +# Executes the full CI matrix: stability (latest tags), frontier +# (latest branches), and manifest-declared mixed profiles. Runs file GitHub +# issues with their results unless manually suppressed via workflow_dispatch +# inputs. name: CI (Nightly) @@ -35,6 +36,15 @@ jobs: - name: frontier issue_label: scenarios-run-frontier issue_title: "FOC Devnet scenarios run report (frontier)" + - name: stability-frontier-lotus + issue_label: scenarios-run-stability-frontier-lotus + issue_title: "FOC Devnet scenarios run report (stability-frontier-lotus)" + - name: stability-frontier-curio + issue_label: scenarios-run-stability-frontier-curio + issue_title: "FOC Devnet scenarios run report (stability-frontier-curio)" + - name: stability-frontier-filecoin-services + issue_label: scenarios-run-stability-frontier-filecoin-services + issue_title: "FOC Devnet scenarios run report (stability-frontier-filecoin-services)" uses: ./.github/workflows/ci_run.yml with: name: ${{ matrix.name }} diff --git a/.github/workflows/ci_run.yml b/.github/workflows/ci_run.yml index 6574c45..17f5d89 100644 --- a/.github/workflows/ci_run.yml +++ b/.github/workflows/ci_run.yml @@ -2,7 +2,7 @@ # Reusable workflow: a single foc-devnet CI run. # # Called by ci_pull_request.yml (default config, no reporting) and -# ci_nightly.yml (stability / frontier matrix, issue reporting enabled). +# ci_nightly.yml (manifest-declared nightly profiles, issue reporting enabled). # # The dependency profile controls the versions of compatibility-sensitive # server and client components used by the run. @@ -13,11 +13,11 @@ on: workflow_call: inputs: name: - description: 'Human-readable run name (e.g. default, stability, frontier)' + description: 'Human-readable run name (e.g. default, stability, frontier, stability-frontier-curio)' required: true type: string profile: - description: 'Dependency profile: default, stability, or frontier' + description: 'Dependency profile declared in ci/dependency-profiles.json' required: true type: string enable_reporting: diff --git a/README_ADVANCED.md b/README_ADVANCED.md index 4d089a7..935ab48 100644 --- a/README_ADVANCED.md +++ b/README_ADVANCED.md @@ -1324,7 +1324,9 @@ Scenarios run automatically in CI after the devnet starts. On nightly runs (or m CI resolves compatibility-sensitive dependencies from `ci/dependency-profiles.json`. Pull requests use the pinned `default` profile, while nightly `stability` runs use the latest final releases and nightly `frontier` runs pin current development -branch heads to immutable commits. The resolved metadata path is exposed to -scenarios as `CI_DEPENDENCY_METADATA`; Synapse SDK and filecoin-pin also receive -their exact source, version/ref, and commit through `SYNAPSE_SDK_*` and -`FILECOIN_PIN_*` environment variables. +branch heads to immutable commits. Nightly CI also runs manifest-declared mixed +profiles such as `stability-frontier-curio`, where all dependencies come from +`stability` except the named component, which comes from `frontier`. The resolved +metadata path is exposed to scenarios as `CI_DEPENDENCY_METADATA`; Synapse SDK +and filecoin-pin also receive their exact source, version/ref, and commit +through `SYNAPSE_SDK_*` and `FILECOIN_PIN_*` environment variables. diff --git a/ci/README.md b/ci/README.md index 7dc40dd..135c62d 100644 --- a/ci/README.md +++ b/ci/README.md @@ -5,13 +5,40 @@ Its resolver is located in `scripts/resolve-ci-dependencies.py`. ## Profiles -The manifest currently supports three profiles: +The manifest declares valid profiles in its top-level `profiles` object: - `default`: used by PR CI, a known-working set of client versions. - `stability`: used by nightly CI to test stable releases. - `frontier`: used by nightly CI to test branch heads. +- `stability-frontier-lotus`: used by nightly CI to test stable releases + except Lotus, which is resolved from `frontier`. +- `stability-frontier-curio`: used by nightly CI to test stable releases + except Curio, which is resolved from `frontier`. +- `stability-frontier-filecoin-services`: used by nightly CI to test stable + releases except filecoin-services, which is resolved from `frontier`. -Each component must define a selection for each profile. +Each component must define a selection for every component profile referenced by +the top-level profile definitions. Today those component selections are +`default`, `stability`, and `frontier`. + +Top-level profile definitions have a `base` component profile and can override +specific components: + +```json +{ + "stability-frontier-curio": { + "base": "stability", + "components": { + "curio": "frontier" + } + } +} +``` + +In that example, Curio resolves from its `frontier` selection while every other +component resolves from `stability`. A profile is valid only if it is explicitly +declared in `profiles`; for example, `stability-frontier-filecoin-pin` does not +exist unless added there. ## Component Fields @@ -20,7 +47,7 @@ Top-level component fields: - `repository`: Git repository URL. - `npm_package`: npm package name, for components that are resolved through npm metadata. -- `default`, `stability`, `frontier`: profile selections. +- `default`, `stability`, `frontier`: component profile selections. Profile selections always have a `strategy`. Some strategies require additional fields. diff --git a/ci/dependency-profiles.json b/ci/dependency-profiles.json index f487414..ac29d19 100644 --- a/ci/dependency-profiles.json +++ b/ci/dependency-profiles.json @@ -1,5 +1,34 @@ { - "schema_version": 1, + "schema_version": 2, + "profiles": { + "default": { + "base": "default" + }, + "stability": { + "base": "stability" + }, + "frontier": { + "base": "frontier" + }, + "stability-frontier-lotus": { + "base": "stability", + "components": { + "lotus": "frontier" + } + }, + "stability-frontier-curio": { + "base": "stability", + "components": { + "curio": "frontier" + } + }, + "stability-frontier-filecoin-services": { + "base": "stability", + "components": { + "filecoin-services": "frontier" + } + } + }, "components": { "lotus": { "repository": "https://github.com/filecoin-project/lotus.git", diff --git a/scripts/resolve-ci-dependencies.py b/scripts/resolve-ci-dependencies.py index 56ddcad..cd03a0b 100644 --- a/scripts/resolve-ci-dependencies.py +++ b/scripts/resolve-ci-dependencies.py @@ -13,7 +13,7 @@ import subprocess from pathlib import Path -PROFILES = {"default", "stability", "frontier"} +MANIFEST_SCHEMA_VERSION = 2 INIT_COMPONENT_FLAGS = { "lotus": "--lotus", "curio": "--curio", @@ -44,19 +44,77 @@ def load_manifest(path: Path) -> dict: f"Cannot load dependency manifest {path}: {error}" ) from error - if manifest.get("schema_version") != 1: - raise ResolutionError("Dependency manifest schema_version must be 1") + if manifest.get("schema_version") != MANIFEST_SCHEMA_VERSION: + raise ResolutionError( + f"Dependency manifest schema_version must be {MANIFEST_SCHEMA_VERSION}" + ) components = manifest.get("components") if not isinstance(components, dict): raise ResolutionError("Dependency manifest must contain a components object") + profiles = manifest.get("profiles") + if not isinstance(profiles, dict): + raise ResolutionError("Dependency manifest must contain a profiles object") required = set(INIT_COMPONENT_FLAGS) | {"synapse-sdk", "filecoin-pin"} missing = sorted(required - set(components)) if missing: raise ResolutionError(f"Dependency manifest is missing: {', '.join(missing)}") + validate_profiles(profiles, components) return manifest +def validate_profiles(profiles: dict, components: dict) -> None: + for profile_name, profile in profiles.items(): + if not isinstance(profile_name, str) or not profile_name: + raise ResolutionError("Profile names must be non-empty strings") + if not isinstance(profile, dict): + raise ResolutionError(f"Profile {profile_name!r} must be an object") + + base = profile.get("base") + if not isinstance(base, str) or not base: + raise ResolutionError(f"Profile {profile_name!r} base must be a string") + + component_overrides = profile.get("components", {}) + if not isinstance(component_overrides, dict): + raise ResolutionError( + f"Profile {profile_name!r} components must be an object" + ) + + unknown_components = sorted(set(component_overrides) - set(components)) + if unknown_components: + raise ResolutionError( + f"Profile {profile_name!r} references unknown components: " + f"{', '.join(unknown_components)}" + ) + + for component_name, component in components.items(): + selection_profile = component_overrides.get(component_name, base) + if not isinstance(selection_profile, str) or not selection_profile: + raise ResolutionError( + f"Profile {profile_name!r} selection for {component_name!r} " + "must be a string" + ) + if selection_profile not in component: + raise ResolutionError( + f"Profile {profile_name!r} selects {selection_profile!r} for " + f"{component_name}, but that component has no such selection" + ) + + +def component_profile_map(manifest: dict, profile_name: str) -> dict[str, str]: + profiles = manifest["profiles"] + if profile_name not in profiles: + raise ResolutionError(f"Unknown profile {profile_name!r}") + + profile = profiles[profile_name] + base = profile["base"] + component_overrides = profile.get("components", {}) + return { + component_name: component_overrides.get(component_name, base) + for component_name in manifest["components"] + } + + def parse_ls_remote(output: str) -> list[tuple[str, str]]: refs = [] for line in output.splitlines(): @@ -225,6 +283,7 @@ def resolve_component( resolved = { "name": name, "repository": repository, + "selection_profile": profile, "strategy": strategy, } @@ -310,11 +369,10 @@ def scenario_environment(metadata_path: Path, components: dict) -> dict: def resolve(args) -> None: - if args.profile not in PROFILES: - raise ResolutionError(f"Unknown profile {args.profile!r}") manifest = load_manifest(args.manifest) + component_profiles = component_profile_map(manifest, args.profile) components = { - name: resolve_component(name, component, args.profile) + name: resolve_component(name, component, component_profiles[name]) for name, component in manifest["components"].items() } metadata = { diff --git a/scripts/tests/test_resolve_ci_dependencies.py b/scripts/tests/test_resolve_ci_dependencies.py index c44f3cb..0137a0d 100644 --- a/scripts/tests/test_resolve_ci_dependencies.py +++ b/scripts/tests/test_resolve_ci_dependencies.py @@ -2,6 +2,8 @@ import json import tempfile import unittest +from contextlib import redirect_stdout +from io import StringIO from pathlib import Path from unittest.mock import patch @@ -29,6 +31,74 @@ def __call__(self, command): class ResolverTests(unittest.TestCase): + def manifest(self, profiles=None, components=None): + if components is None: + components = { + "lotus": self.component("a", "b"), + "curio": self.component("c", "d"), + "filecoin-services": self.component("e", "f"), + "synapse-sdk": self.component("1", "2"), + "filecoin-pin": self.component("3", "4"), + } + if profiles is None: + profiles = { + "default": {"base": "default"}, + "stability": {"base": "stability"}, + "frontier": {"base": "frontier"}, + "stability-frontier-lotus": { + "base": "stability", + "components": {"lotus": "frontier"}, + }, + "stability-frontier-curio": { + "base": "stability", + "components": {"curio": "frontier"}, + }, + "stability-frontier-filecoin-services": { + "base": "stability", + "components": {"filecoin-services": "frontier"}, + }, + } + return { + "schema_version": 2, + "profiles": profiles, + "components": components, + } + + def component(self, stability_prefix, frontier_prefix): + return { + "repository": "https://example.test/project.git", + "default": {"strategy": "config_default"}, + "stability": { + "strategy": "git_commit", + "commit": stability_prefix * 40, + }, + "frontier": { + "strategy": "git_commit", + "commit": frontier_prefix * 40, + }, + } + + def resolve_manifest(self, manifest, profile): + with tempfile.TemporaryDirectory() as directory: + directory = Path(directory) + manifest_path = directory / "manifest.json" + output_path = directory / "resolved.json" + manifest_path.write_text(json.dumps(manifest)) + args = type( + "Args", + (), + { + "profile": profile, + "manifest": manifest_path, + "output": output_path, + "github_output": None, + "github_env": None, + }, + ) + with redirect_stdout(StringIO()): + resolver.resolve(args) + return json.loads(output_path.read_text()) + def test_latest_non_prerelease_tag_excludes_prereleases_and_annotated_refs(self): output = "\n".join( [ @@ -90,28 +160,102 @@ def test_git_tag_pattern_can_include_prereleases(self): self.assertEqual(resolved["commit"], "bbb") def test_unknown_profile_fails(self): + manifest = self.manifest() with tempfile.TemporaryDirectory() as directory: + directory = Path(directory) + manifest_path = directory / "manifest.json" + output_path = directory / "resolved.json" + manifest_path.write_text(json.dumps(manifest)) args = type( "Args", (), { "profile": "unknown", - "manifest": Path(directory) / "manifest.json", - "output": Path(directory) / "resolved.json", + "manifest": manifest_path, + "output": output_path, "github_output": None, "github_env": None, }, ) - with self.assertRaises(resolver.ResolutionError): + with self.assertRaisesRegex(resolver.ResolutionError, "Unknown profile"): resolver.resolve(args) def test_manifest_missing_component_fails(self): with tempfile.TemporaryDirectory() as directory: path = Path(directory) / "manifest.json" - path.write_text(json.dumps({"schema_version": 1, "components": {}})) + path.write_text( + json.dumps( + { + "schema_version": 2, + "profiles": {"default": {"base": "default"}}, + "components": {}, + } + ) + ) with self.assertRaisesRegex(resolver.ResolutionError, "missing"): resolver.load_manifest(path) + def test_mixed_profile_resolves_only_selected_component_from_frontier(self): + metadata = self.resolve_manifest(self.manifest(), "stability-frontier-curio") + components = metadata["components"] + + self.assertEqual(metadata["profile"], "stability-frontier-curio") + self.assertEqual(components["lotus"]["selection_profile"], "stability") + self.assertEqual(components["lotus"]["commit"], "a" * 40) + self.assertEqual(components["curio"]["selection_profile"], "frontier") + self.assertEqual(components["curio"]["commit"], "d" * 40) + self.assertEqual( + components["filecoin-services"]["selection_profile"], "stability" + ) + self.assertEqual(components["synapse-sdk"]["selection_profile"], "stability") + self.assertEqual(components["filecoin-pin"]["selection_profile"], "stability") + + def test_absent_mixed_profile_is_rejected(self): + profiles = { + "default": {"base": "default"}, + "stability": {"base": "stability"}, + "frontier": {"base": "frontier"}, + "stability-frontier-curio": { + "base": "stability", + "components": {"curio": "frontier"}, + }, + } + manifest = self.manifest(profiles=profiles) + with self.assertRaisesRegex(resolver.ResolutionError, "Unknown profile"): + self.resolve_manifest(manifest, "stability-frontier-filecoin-pin") + + def test_manifest_profile_rejects_unknown_component_override(self): + manifest = self.manifest( + profiles={ + "default": {"base": "default"}, + "bad": { + "base": "stability", + "components": {"missing": "frontier"}, + }, + } + ) + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "manifest.json" + path.write_text(json.dumps(manifest)) + with self.assertRaisesRegex(resolver.ResolutionError, "unknown components"): + resolver.load_manifest(path) + + def test_manifest_profile_rejects_missing_component_selection(self): + manifest = self.manifest( + profiles={ + "default": {"base": "default"}, + "bad": { + "base": "stability", + "components": {"lotus": "not-a-selection"}, + }, + } + ) + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "manifest.json" + path.write_text(json.dumps(manifest)) + with self.assertRaisesRegex(resolver.ResolutionError, "no such selection"): + resolver.load_manifest(path) + def test_npm_version_resolves_dist_tag_to_npm_version(self): component = { "repository": "https://example.test/filecoin-pin.git",