From 11ea0cc2a0e3c5cc0b2dc0c753264b62832e14df Mon Sep 17 00:00:00 2001 From: Piotr Galar Date: Thu, 16 Jul 2026 13:57:39 +0100 Subject: [PATCH 1/5] ci: 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", From 7ccfe12fc141d525b8d8feaccbfca128d0eae540 Mon Sep 17 00:00:00 2001 From: Piotr Galar Date: Wed, 29 Jul 2026 21:10:54 +0100 Subject: [PATCH 2/5] ci: add PDP mixed nightly profile --- .github/workflows/ci_nightly.yml | 3 +++ ci/README.md | 2 ++ ci/dependency-profiles.json | 6 ++++++ scripts/tests/test_resolve_ci_dependencies.py | 19 +++++++++++++++++++ 4 files changed, 30 insertions(+) diff --git a/.github/workflows/ci_nightly.yml b/.github/workflows/ci_nightly.yml index 10f7d40..79ca80e 100644 --- a/.github/workflows/ci_nightly.yml +++ b/.github/workflows/ci_nightly.yml @@ -45,6 +45,9 @@ jobs: - name: stability-frontier-filecoin-services issue_label: scenarios-run-stability-frontier-filecoin-services issue_title: "FOC Devnet scenarios run report (stability-frontier-filecoin-services)" + - name: stability-frontier-pdp + issue_label: scenarios-run-stability-frontier-pdp + issue_title: "FOC Devnet scenarios run report (stability-frontier-pdp)" uses: ./.github/workflows/ci_run.yml with: name: ${{ matrix.name }} diff --git a/ci/README.md b/ci/README.md index ea7a812..9cf3f8a 100644 --- a/ci/README.md +++ b/ci/README.md @@ -16,6 +16,8 @@ The manifest declares valid profiles in its top-level `profiles` object: 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`. +- `stability-frontier-pdp`: used by nightly CI to test stable releases except + PDP, which is resolved from `frontier`. Each component must define a selection for every component profile referenced by the top-level profile definitions. Today those component selections are diff --git a/ci/dependency-profiles.json b/ci/dependency-profiles.json index 81fd180..175dba1 100644 --- a/ci/dependency-profiles.json +++ b/ci/dependency-profiles.json @@ -27,6 +27,12 @@ "components": { "filecoin-services": "frontier" } + }, + "stability-frontier-pdp": { + "base": "stability", + "components": { + "pdp": "frontier" + } } }, "components": { diff --git a/scripts/tests/test_resolve_ci_dependencies.py b/scripts/tests/test_resolve_ci_dependencies.py index 1ebbac0..26bd147 100644 --- a/scripts/tests/test_resolve_ci_dependencies.py +++ b/scripts/tests/test_resolve_ci_dependencies.py @@ -58,6 +58,10 @@ def manifest(self, profiles=None, components=None): "base": "stability", "components": {"filecoin-services": "frontier"}, }, + "stability-frontier-pdp": { + "base": "stability", + "components": {"pdp": "frontier"}, + }, } return { "schema_version": 2, @@ -212,6 +216,21 @@ def test_mixed_profile_resolves_only_selected_component_from_frontier(self): self.assertEqual(components["synapse-sdk"]["selection_profile"], "stability") self.assertEqual(components["filecoin-pin"]["selection_profile"], "stability") + def test_pdp_mixed_profile_resolves_only_pdp_from_frontier(self): + metadata = self.resolve_manifest(self.manifest(), "stability-frontier-pdp") + components = metadata["components"] + + self.assertEqual(metadata["profile"], "stability-frontier-pdp") + self.assertEqual(components["lotus"]["selection_profile"], "stability") + self.assertEqual(components["curio"]["selection_profile"], "stability") + self.assertEqual( + components["filecoin-services"]["selection_profile"], "stability" + ) + self.assertEqual(components["pdp"]["selection_profile"], "frontier") + self.assertEqual(components["pdp"]["commit"], "6" * 40) + 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"}, From fc33b46bd647982ac54126671a8f554e29bf53fc Mon Sep 17 00:00:00 2001 From: galargh Date: Sat, 1 Aug 2026 21:35:47 +0200 Subject: [PATCH 3/5] ci: resolve pdp from filecoin-services submodule --- README_ADVANCED.md | 6 +- ci/README.md | 19 +++ ci/dependency-profiles.json | 5 +- renovate.json | 11 ++ scripts/resolve-ci-dependencies.py | 49 +++++- scripts/tests/test_resolve_ci_dependencies.py | 148 ++++++++++++++++++ 6 files changed, 234 insertions(+), 4 deletions(-) diff --git a/README_ADVANCED.md b/README_ADVANCED.md index 161c3b2..7366cb0 100644 --- a/README_ADVANCED.md +++ b/README_ADVANCED.md @@ -1336,8 +1336,10 @@ the latest final releases and nightly `frontier` runs pin current development 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`. In -`stability`, PDP comes from the stable filecoin-services checkout's bundled -submodule; in `frontier`, PDP is pinned as an independent repo. The resolved +`stability`, PDP is pinned to the git submodule bundled with the filecoin-services +tag used by Rust `Config::default()`; in `frontier`, PDP is pinned as an +independent repo. Mixed profiles that override filecoin-services keep that PDP +pin instead of implicitly taking the overridden filecoin-services submodule. 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. PDP receives diff --git a/ci/README.md b/ci/README.md index 9cf3f8a..b921ccd 100644 --- a/ci/README.md +++ b/ci/README.md @@ -120,6 +120,25 @@ By default, pattern selections exclude prerelease tags such as `-rc`, `-alpha`, } ``` +### `git_submodule` + +Resolve a git submodule gitlink from an exact tag in another repository. + +```json +{ + "strategy": "git_submodule", + "repository": "https://github.com/FilOzone/filecoin-services.git", + "tag": "v1.3.0", + "path": "service_contracts/lib/pdp" +} +``` + +The resolver first resolves `repository` and `tag` to an immutable parent +commit, then reads `path` from that tree and records the submodule gitlink SHA as +the selected component commit. PDP uses this to pin the same bundled PDP gitlink +as the filecoin-services tag in Rust `Config::default()`, even in mixed profiles +that override filecoin-services itself. + ### `npm_version` Resolve an npm version, range, or dist-tag to a concrete package version. diff --git a/ci/dependency-profiles.json b/ci/dependency-profiles.json index 175dba1..8861b6d 100644 --- a/ci/dependency-profiles.json +++ b/ci/dependency-profiles.json @@ -84,7 +84,10 @@ "strategy": "config_default" }, "stability": { - "strategy": "config_default" + "strategy": "git_submodule", + "repository": "https://github.com/FilOzone/filecoin-services.git", + "tag": "v1.3.0", + "path": "service_contracts/lib/pdp" }, "frontier": { "strategy": "git_branch", diff --git a/renovate.json b/renovate.json index 34ed648..0537e44 100644 --- a/renovate.json +++ b/renovate.json @@ -15,6 +15,17 @@ "datasourceTemplate": "github-tags", "versioningTemplate": "semver" }, + { + "customType": "regex", + "managerFilePatterns": [ + "/^ci/dependency-profiles\\.json$/" + ], + "matchStrings": [ + "\"strategy\":\\s*\"git_submodule\",\\s*\"repository\":\\s*\"https://github\\.com/(?[^\"]+?)(?:\\.git)?\",\\s*\"tag\":\\s*\"(?v[0-9][^\"]*)\"" + ], + "datasourceTemplate": "github-tags", + "versioningTemplate": "semver" + }, { "customType": "regex", "managerFilePatterns": [ diff --git a/scripts/resolve-ci-dependencies.py b/scripts/resolve-ci-dependencies.py index 474ad2d..6f16939 100644 --- a/scripts/resolve-ci-dependencies.py +++ b/scripts/resolve-ci-dependencies.py @@ -11,6 +11,7 @@ import re import shlex import subprocess +import tempfile from pathlib import Path MANIFEST_SCHEMA_VERSION = 2 @@ -245,6 +246,27 @@ def npm_metadata(package: str, version: str, runner=run_command) -> dict: return {"version": resolved_version, "gitHead": git_head} +def read_gitlink(repository: str, commit: str, path: str, runner=run_command) -> str: + with tempfile.TemporaryDirectory(prefix="foc-devnet-ci-deps-") as directory: + repo_dir = Path(directory) / "repo" + runner(["git", "-C", directory, "init", "repo"]) + runner(["git", "-C", str(repo_dir), "remote", "add", "origin", repository]) + runner(["git", "-C", str(repo_dir), "fetch", "--depth=1", "origin", commit]) + output = runner(["git", "-C", str(repo_dir), "ls-tree", "FETCH_HEAD", path]) + + fields = output.split() + if len(fields) < 4 or fields[0] != "160000" or fields[1] != "commit": + raise ResolutionError( + f"{path} in {repository}@{commit} is not a git submodule gitlink" + ) + gitlink = fields[2] + if not COMMIT_RE.fullmatch(gitlink): + raise ResolutionError( + f"{path} in {repository}@{commit} has invalid gitlink SHA {gitlink!r}" + ) + return gitlink + + def validate_overrides(name: str, strategy: str, overrides) -> dict: if overrides is None: return {} @@ -310,6 +332,31 @@ def resolve_component( branch = selection["branch"] commit = resolve_ref(repository, f"refs/heads/{branch}", runner) resolved.update(source="git", ref_type="branch", ref=branch, commit=commit) + elif strategy == "git_submodule": + parent_repository = selection["repository"] + tag = selection["tag"] + if any(char in tag for char in "*?["): + raise ResolutionError(f"{name} git_submodule tag must be exact") + path = selection["path"] + if not isinstance(path, str) or not path: + raise ResolutionError(f"{name} git_submodule path must be a string") + if path.startswith("/") or ".." in Path(path).parts: + raise ResolutionError(f"{name} git_submodule path must be relative") + parent_commit = resolve_tag(parent_repository, tag, runner) + commit = read_gitlink(parent_repository, parent_commit, path, runner) + resolved.update( + source="git_submodule", + ref_type="commit", + ref=commit, + commit=commit, + submodule_from={ + "repository": parent_repository, + "ref_type": "tag", + "ref": tag, + "commit": parent_commit, + "path": path, + }, + ) elif strategy == "npm_version": requested = selection["version"] data = npm_metadata(component["npm_package"], requested, runner) @@ -376,7 +423,7 @@ def resolve(args) -> None: manifest = load_manifest(args.manifest) component_profiles = component_profile_map(manifest, args.profile) components = { - name: resolve_component(name, component, component_profiles[name]) + name: resolve_component(name, component, component_profiles[name], run_command) 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 26bd147..ce39a84 100644 --- a/scripts/tests/test_resolve_ci_dependencies.py +++ b/scripts/tests/test_resolve_ci_dependencies.py @@ -104,6 +104,47 @@ def resolve_manifest(self, manifest, profile): resolver.resolve(args) return json.loads(output_path.read_text()) + def resolve_manifest_with_github_output(self, manifest, profile): + with tempfile.TemporaryDirectory() as directory: + directory = Path(directory) + manifest_path = directory / "manifest.json" + output_path = directory / "resolved.json" + github_output_path = directory / "github-output" + manifest_path.write_text(json.dumps(manifest)) + args = type( + "Args", + (), + { + "profile": profile, + "manifest": manifest_path, + "output": output_path, + "github_output": str(github_output_path), + "github_env": None, + }, + ) + with redirect_stdout(StringIO()): + resolver.resolve(args) + return ( + json.loads(output_path.read_text()), + github_output_path.read_text(), + ) + + def pdp_git_submodule_component(self): + return { + "repository": "https://example.test/pdp.git", + "default": {"strategy": "config_default"}, + "stability": { + "strategy": "git_submodule", + "repository": "https://example.test/filecoin-services.git", + "tag": "v1.3.0", + "path": "service_contracts/lib/pdp", + }, + "frontier": { + "strategy": "git_commit", + "commit": "6" * 40, + }, + } + def test_latest_non_prerelease_tag_excludes_prereleases_and_annotated_refs(self): output = "\n".join( [ @@ -231,6 +272,113 @@ def test_pdp_mixed_profile_resolves_only_pdp_from_frontier(self): self.assertEqual(components["synapse-sdk"]["selection_profile"], "stability") self.assertEqual(components["filecoin-pin"]["selection_profile"], "stability") + @patch.object(resolver, "run_command") + def test_filecoin_services_mixed_profile_emits_pdp_git_submodule(self, run_command): + parent_commit = "8" * 40 + pdp_commit = "7" * 40 + manifest = self.manifest() + manifest["components"]["pdp"] = self.pdp_git_submodule_component() + run_command.side_effect = FakeRunner( + [ + ( + ( + "git", + "ls-remote", + "https://example.test/filecoin-services.git", + "refs/tags/v1.3.0*", + ), + f"{parent_commit} refs/tags/v1.3.0", + ), + ( + lambda command: command[:3] == ["git", "-C", command[2]] + and command[3:] == ["init", "repo"], + "", + ), + ( + lambda command: command[:5] + == ["git", "-C", command[2], "remote", "add"], + "", + ), + ( + lambda command: command[:4] == ["git", "-C", command[2], "fetch"], + "", + ), + ( + lambda command: command[:4] == ["git", "-C", command[2], "ls-tree"], + f"160000 commit {pdp_commit}\tservice_contracts/lib/pdp", + ), + ] + ) + + metadata, github_output = self.resolve_manifest_with_github_output( + manifest, "stability-frontier-filecoin-services" + ) + components = metadata["components"] + + self.assertEqual( + components["filecoin-services"]["selection_profile"], "frontier" + ) + self.assertEqual(components["pdp"]["source"], "git_submodule") + self.assertEqual(components["pdp"]["commit"], pdp_commit) + self.assertEqual( + components["pdp"]["submodule_from"], + { + "repository": "https://example.test/filecoin-services.git", + "ref_type": "tag", + "ref": "v1.3.0", + "commit": parent_commit, + "path": "service_contracts/lib/pdp", + }, + ) + self.assertIn( + "--filecoin-services gitcommit:https://example.test/project.git:" + + "f" * 40, + github_output, + ) + self.assertIn( + "--pdp gitcommit:https://example.test/pdp.git:" + pdp_commit, + github_output, + ) + + @patch.object(resolver, "run_command") + def test_git_submodule_rejects_missing_gitlink(self, run_command): + parent_commit = "8" * 40 + component = self.pdp_git_submodule_component() + run_command.side_effect = FakeRunner( + [ + ( + ( + "git", + "ls-remote", + "https://example.test/filecoin-services.git", + "refs/tags/v1.3.0*", + ), + f"{parent_commit} refs/tags/v1.3.0", + ), + ( + lambda command: command[:3] == ["git", "-C", command[2]] + and command[3:] == ["init", "repo"], + "", + ), + ( + lambda command: command[:5] + == ["git", "-C", command[2], "remote", "add"], + "", + ), + ( + lambda command: command[:4] == ["git", "-C", command[2], "fetch"], + "", + ), + ( + lambda command: command[:4] == ["git", "-C", command[2], "ls-tree"], + "100644 blob abcdef\tservice_contracts/lib/pdp", + ), + ] + ) + + with self.assertRaisesRegex(resolver.ResolutionError, "not a git submodule"): + resolver.resolve_component("pdp", component, "stability", run_command) + def test_absent_mixed_profile_is_rejected(self): profiles = { "default": {"base": "default"}, From 3b0594b89ec8abed5e16e0581f449ee90b608e3f Mon Sep 17 00:00:00 2001 From: galargh Date: Sun, 2 Aug 2026 12:37:40 +0200 Subject: [PATCH 4/5] ci: resolve pdp submodule from stable filecoin-services tag --- README_ADVANCED.md | 8 ++++---- ci/README.md | 12 ++++++------ ci/dependency-profiles.json | 2 +- renovate.json | 11 ----------- scripts/resolve-ci-dependencies.py | 11 ++++++++--- scripts/tests/test_resolve_ci_dependencies.py | 8 +++++--- 6 files changed, 24 insertions(+), 28 deletions(-) diff --git a/README_ADVANCED.md b/README_ADVANCED.md index 7366cb0..b02c5f2 100644 --- a/README_ADVANCED.md +++ b/README_ADVANCED.md @@ -1336,10 +1336,10 @@ the latest final releases and nightly `frontier` runs pin current development 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`. In -`stability`, PDP is pinned to the git submodule bundled with the filecoin-services -tag used by Rust `Config::default()`; in `frontier`, PDP is pinned as an -independent repo. Mixed profiles that override filecoin-services keep that PDP -pin instead of implicitly taking the overridden filecoin-services submodule. The resolved +`stability`, PDP is pinned to the git submodule bundled with the selected +filecoin-services stability tag; in `frontier`, PDP is pinned as an independent +repo. Mixed profiles that override filecoin-services keep that PDP pin instead +of implicitly taking the overridden filecoin-services submodule. 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. PDP receives diff --git a/ci/README.md b/ci/README.md index b921ccd..cc18d3b 100644 --- a/ci/README.md +++ b/ci/README.md @@ -122,21 +122,21 @@ By default, pattern selections exclude prerelease tags such as `-rc`, `-alpha`, ### `git_submodule` -Resolve a git submodule gitlink from an exact tag in another repository. +Resolve a git submodule gitlink from a tag or tag pattern in another repository. ```json { "strategy": "git_submodule", "repository": "https://github.com/FilOzone/filecoin-services.git", - "tag": "v1.3.0", + "tag": "v*", "path": "service_contracts/lib/pdp" } ``` -The resolver first resolves `repository` and `tag` to an immutable parent -commit, then reads `path` from that tree and records the submodule gitlink SHA as -the selected component commit. PDP uses this to pin the same bundled PDP gitlink -as the filecoin-services tag in Rust `Config::default()`, even in mixed profiles +The resolver first resolves `repository` and `tag` with the same rules as +`git_tag`, then reads `path` from that tree and records the submodule gitlink SHA +as the selected component commit. PDP uses this to pin the same bundled PDP +gitlink as the selected filecoin-services stability tag, even in mixed profiles that override filecoin-services itself. ### `npm_version` diff --git a/ci/dependency-profiles.json b/ci/dependency-profiles.json index 8861b6d..bd824d9 100644 --- a/ci/dependency-profiles.json +++ b/ci/dependency-profiles.json @@ -86,7 +86,7 @@ "stability": { "strategy": "git_submodule", "repository": "https://github.com/FilOzone/filecoin-services.git", - "tag": "v1.3.0", + "tag": "v*", "path": "service_contracts/lib/pdp" }, "frontier": { diff --git a/renovate.json b/renovate.json index 0537e44..34ed648 100644 --- a/renovate.json +++ b/renovate.json @@ -15,17 +15,6 @@ "datasourceTemplate": "github-tags", "versioningTemplate": "semver" }, - { - "customType": "regex", - "managerFilePatterns": [ - "/^ci/dependency-profiles\\.json$/" - ], - "matchStrings": [ - "\"strategy\":\\s*\"git_submodule\",\\s*\"repository\":\\s*\"https://github\\.com/(?[^\"]+?)(?:\\.git)?\",\\s*\"tag\":\\s*\"(?v[0-9][^\"]*)\"" - ], - "datasourceTemplate": "github-tags", - "versioningTemplate": "semver" - }, { "customType": "regex", "managerFilePatterns": [ diff --git a/scripts/resolve-ci-dependencies.py b/scripts/resolve-ci-dependencies.py index 6f16939..0c87082 100644 --- a/scripts/resolve-ci-dependencies.py +++ b/scripts/resolve-ci-dependencies.py @@ -335,14 +335,19 @@ def resolve_component( elif strategy == "git_submodule": parent_repository = selection["repository"] tag = selection["tag"] - if any(char in tag for char in "*?["): - raise ResolutionError(f"{name} git_submodule tag must be exact") path = selection["path"] if not isinstance(path, str) or not path: raise ResolutionError(f"{name} git_submodule path must be a string") if path.startswith("/") or ".." in Path(path).parts: raise ResolutionError(f"{name} git_submodule path must be relative") - parent_commit = resolve_tag(parent_repository, tag, runner) + if any(char in tag for char in "*?["): + include_prereleases = selection.get("include_prereleases", False) + if not isinstance(include_prereleases, bool): + raise ResolutionError(f"{name} include_prereleases must be a boolean") + output = runner(["git", "ls-remote", "--tags", parent_repository, tag]) + tag, parent_commit = select_latest_tag(output, tag, include_prereleases) + else: + parent_commit = resolve_tag(parent_repository, tag, runner) commit = read_gitlink(parent_repository, parent_commit, path, runner) resolved.update( source="git_submodule", diff --git a/scripts/tests/test_resolve_ci_dependencies.py b/scripts/tests/test_resolve_ci_dependencies.py index ce39a84..c009924 100644 --- a/scripts/tests/test_resolve_ci_dependencies.py +++ b/scripts/tests/test_resolve_ci_dependencies.py @@ -136,7 +136,7 @@ def pdp_git_submodule_component(self): "stability": { "strategy": "git_submodule", "repository": "https://example.test/filecoin-services.git", - "tag": "v1.3.0", + "tag": "v*", "path": "service_contracts/lib/pdp", }, "frontier": { @@ -284,8 +284,9 @@ def test_filecoin_services_mixed_profile_emits_pdp_git_submodule(self, run_comma ( "git", "ls-remote", + "--tags", "https://example.test/filecoin-services.git", - "refs/tags/v1.3.0*", + "v*", ), f"{parent_commit} refs/tags/v1.3.0", ), @@ -350,8 +351,9 @@ def test_git_submodule_rejects_missing_gitlink(self, run_command): ( "git", "ls-remote", + "--tags", "https://example.test/filecoin-services.git", - "refs/tags/v1.3.0*", + "v*", ), f"{parent_commit} refs/tags/v1.3.0", ), From beae1c6919207e285de7b391fc2bf025f44cb8dd Mon Sep 17 00:00:00 2001 From: Phi Date: Mon, 3 Aug 2026 11:38:35 +0200 Subject: [PATCH 5/5] ci: test exact filecoin-services release candidates --- .github/workflows/ci_nightly.yml | 19 ++++++ .github/workflows/ci_run.yml | 14 ++++- ci/README.md | 14 +++++ scripts/resolve-ci-dependencies.py | 47 +++++++++++++- scripts/tests/test_resolve_ci_dependencies.py | 63 ++++++++++++++++++- 5 files changed, 151 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci_nightly.yml b/.github/workflows/ci_nightly.yml index 79ca80e..299867e 100644 --- a/.github/workflows/ci_nightly.yml +++ b/.github/workflows/ci_nightly.yml @@ -14,6 +14,11 @@ on: - cron: '0 3 * * *' workflow_dispatch: inputs: + filecoin_services_commit: + description: 'Exact filecoin-services commit to test with the stability-frontier-filecoin-services profile' + type: string + required: false + default: '' reporting: description: 'Create GitHub issue with scenario report' type: boolean @@ -25,6 +30,7 @@ on: jobs: foc-devnet-test: + if: github.event_name != 'workflow_dispatch' || inputs.filecoin_services_commit == '' strategy: fail-fast: false max-parallel: 1 @@ -62,3 +68,16 @@ jobs: issue_label: ${{ matrix.issue_label }} issue_title: ${{ matrix.issue_title }} secrets: inherit + + filecoin-services-release-candidate: + if: github.event_name == 'workflow_dispatch' && inputs.filecoin_services_commit != '' + uses: ./.github/workflows/ci_run.yml + with: + name: filecoin-services-release-candidate + profile: stability-frontier-filecoin-services + filecoin_services_commit: ${{ inputs.filecoin_services_commit }} + enable_reporting: ${{ inputs.reporting }} + skip_report_on_pass: ${{ inputs.skip_report_on_pass }} + issue_label: scenarios-run-stability-frontier-filecoin-services + issue_title: "FOC Devnet scenarios run report (filecoin-services release candidate)" + secrets: inherit diff --git a/.github/workflows/ci_run.yml b/.github/workflows/ci_run.yml index 507e254..d20fe61 100644 --- a/.github/workflows/ci_run.yml +++ b/.github/workflows/ci_run.yml @@ -20,6 +20,11 @@ on: description: 'Dependency profile declared in ci/dependency-profiles.json' required: true type: string + filecoin_services_commit: + description: 'Optional exact filecoin-services commit for release-candidate validation' + required: false + type: string + default: '' enable_reporting: description: 'When true, file a GitHub issue with the scenario report' required: false @@ -130,13 +135,20 @@ jobs: - name: "CHECK: {Resolve dependency profile}" id: dependencies + env: + FILECOIN_SERVICES_COMMIT: ${{ inputs.filecoin_services_commit }} run: | METADATA="$RUNNER_TEMP/ci-dependencies.json" + CANDIDATE_ARGS=() + if [[ -n "$FILECOIN_SERVICES_COMMIT" ]]; then + CANDIDATE_ARGS+=(--filecoin-services-commit "$FILECOIN_SERVICES_COMMIT") + fi python3 scripts/resolve-ci-dependencies.py resolve \ --profile '${{ inputs.profile }}' \ --output "$METADATA" \ --github-output "$GITHUB_OUTPUT" \ - --github-env "$GITHUB_ENV" + --github-env "$GITHUB_ENV" \ + "${CANDIDATE_ARGS[@]}" # Docker images do not contain Lotus/Curio binaries, so their cache identity # depends only on the Docker build inputs. diff --git a/ci/README.md b/ci/README.md index cc18d3b..c20b980 100644 --- a/ci/README.md +++ b/ci/README.md @@ -42,6 +42,20 @@ 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. +### Exact filecoin-services release candidates + +Before tagging a filecoin-services release, manually dispatch `CI (Nightly)` +with `filecoin_services_commit` set to the full 40-character lowercase commit +SHA. When this input is set, the workflow runs only the +`stability-frontier-filecoin-services` profile and replaces that profile's +filecoin-services branch selection with the requested immutable commit. All +other selections remain on `stability`, including the independently resolved +PDP gitlink. + +Run this validation before creating the new filecoin-services tag. The stable +PDP selection is derived from the latest stable filecoin-services tag, so +tagging first would change the compatibility baseline being tested. + ## Component Fields Top-level component fields: diff --git a/scripts/resolve-ci-dependencies.py b/scripts/resolve-ci-dependencies.py index 0c87082..8e7d16e 100644 --- a/scripts/resolve-ci-dependencies.py +++ b/scripts/resolve-ci-dependencies.py @@ -294,7 +294,11 @@ def validate_overrides(name: str, strategy: str, overrides) -> dict: def resolve_component( - name: str, component: dict, profile: str, runner=run_command + name: str, + component: dict, + profile: str, + runner=run_command, + commit_override: str | None = None, ) -> dict: try: selection = component[profile] @@ -303,6 +307,17 @@ def resolve_component( except KeyError as error: raise ResolutionError(f"{name} is missing required field {error}") from error + configured_strategy = strategy + if commit_override is not None: + if name != "filecoin-services": + raise ResolutionError(f"Commit overrides are not supported for {name}") + if not COMMIT_RE.fullmatch(commit_override): + raise ResolutionError( + "filecoin-services override has invalid commit SHA " + f"{commit_override!r}" + ) + strategy = "git_commit" + resolved = { "name": name, "repository": repository, @@ -313,7 +328,7 @@ def resolve_component( if strategy == "config_default": resolved["source"] = "config_default" elif strategy == "git_commit": - commit = selection["commit"] + commit = commit_override if commit_override is not None else selection["commit"] if not COMMIT_RE.fullmatch(commit): raise ResolutionError(f"{name} has invalid commit SHA {commit!r}") resolved.update(source="git", ref_type="commit", ref=commit, commit=commit) @@ -376,6 +391,11 @@ def resolve_component( overrides = selection.get("overrides") if overrides is not None: resolved["overrides"] = validate_overrides(name, strategy, overrides) + if commit_override is not None: + resolved["override"] = { + "configured_strategy": configured_strategy, + "requested_commit": commit_override, + } return resolved @@ -426,9 +446,29 @@ def scenario_environment(metadata_path: Path, components: dict) -> dict: def resolve(args) -> None: manifest = load_manifest(args.manifest) + filecoin_services_commit = getattr(args, "filecoin_services_commit", None) or None + if filecoin_services_commit is not None: + if not COMMIT_RE.fullmatch(filecoin_services_commit): + raise ResolutionError( + "filecoin-services override has invalid commit SHA " + f"{filecoin_services_commit!r}" + ) + if args.profile != "stability-frontier-filecoin-services": + raise ResolutionError( + "--filecoin-services-commit requires profile " + "'stability-frontier-filecoin-services'" + ) component_profiles = component_profile_map(manifest, args.profile) components = { - name: resolve_component(name, component, component_profiles[name], run_command) + name: resolve_component( + name, + component, + component_profiles[name], + run_command, + commit_override=( + filecoin_services_commit if name == "filecoin-services" else None + ), + ) for name, component in manifest["components"].items() } metadata = { @@ -480,6 +520,7 @@ def parser() -> argparse.ArgumentParser: resolve_parser.add_argument("--output", type=Path, required=True) resolve_parser.add_argument("--github-output") resolve_parser.add_argument("--github-env") + resolve_parser.add_argument("--filecoin-services-commit") resolve_parser.set_defaults(handler=resolve) verify_parser = subparsers.add_parser("verify") diff --git a/scripts/tests/test_resolve_ci_dependencies.py b/scripts/tests/test_resolve_ci_dependencies.py index c009924..163ddb7 100644 --- a/scripts/tests/test_resolve_ci_dependencies.py +++ b/scripts/tests/test_resolve_ci_dependencies.py @@ -83,7 +83,7 @@ def component(self, stability_prefix, frontier_prefix): }, } - def resolve_manifest(self, manifest, profile): + def resolve_manifest(self, manifest, profile, filecoin_services_commit=None): with tempfile.TemporaryDirectory() as directory: directory = Path(directory) manifest_path = directory / "manifest.json" @@ -98,13 +98,16 @@ def resolve_manifest(self, manifest, profile): "output": output_path, "github_output": None, "github_env": None, + "filecoin_services_commit": filecoin_services_commit, }, ) with redirect_stdout(StringIO()): resolver.resolve(args) return json.loads(output_path.read_text()) - def resolve_manifest_with_github_output(self, manifest, profile): + def resolve_manifest_with_github_output( + self, manifest, profile, filecoin_services_commit=None + ): with tempfile.TemporaryDirectory() as directory: directory = Path(directory) manifest_path = directory / "manifest.json" @@ -120,6 +123,7 @@ def resolve_manifest_with_github_output(self, manifest, profile): "output": output_path, "github_output": str(github_output_path), "github_env": None, + "filecoin_services_commit": filecoin_services_commit, }, ) with redirect_stdout(StringIO()): @@ -272,6 +276,61 @@ def test_pdp_mixed_profile_resolves_only_pdp_from_frontier(self): self.assertEqual(components["synapse-sdk"]["selection_profile"], "stability") self.assertEqual(components["filecoin-pin"]["selection_profile"], "stability") + def test_filecoin_services_release_candidate_uses_exact_commit(self): + candidate = "9" * 40 + manifest = self.manifest() + manifest["components"]["filecoin-services"]["frontier"] = { + "strategy": "git_branch", + "branch": "main", + } + + metadata, github_output = self.resolve_manifest_with_github_output( + manifest, + "stability-frontier-filecoin-services", + filecoin_services_commit=candidate, + ) + components = metadata["components"] + + self.assertEqual(components["filecoin-services"]["strategy"], "git_commit") + self.assertEqual(components["filecoin-services"]["commit"], candidate) + self.assertEqual( + components["filecoin-services"]["override"], + { + "configured_strategy": "git_branch", + "requested_commit": candidate, + }, + ) + self.assertEqual(components["pdp"]["selection_profile"], "stability") + self.assertEqual(components["pdp"]["commit"], "5" * 40) + self.assertIn( + "--filecoin-services gitcommit:https://example.test/project.git:" + + candidate, + github_output, + ) + + def test_filecoin_services_release_candidate_rejects_invalid_commit(self): + for candidate in ("main", "a" * 39, "A" * 40, "a" * 40 + ";echo"): + with self.subTest(candidate=candidate): + with self.assertRaisesRegex( + resolver.ResolutionError, "invalid commit SHA" + ): + self.resolve_manifest( + self.manifest(), + "stability-frontier-filecoin-services", + filecoin_services_commit=candidate, + ) + + def test_filecoin_services_release_candidate_requires_mixed_profile(self): + with self.assertRaisesRegex( + resolver.ResolutionError, + "requires profile 'stability-frontier-filecoin-services'", + ): + self.resolve_manifest( + self.manifest(), + "frontier", + filecoin_services_commit="9" * 40, + ) + @patch.object(resolver, "run_command") def test_filecoin_services_mixed_profile_emits_pdp_git_submodule(self, run_command): parent_commit = "8" * 40