diff --git a/.github/workflows/ci_nightly.yml b/.github/workflows/ci_nightly.yml index 45b3cfc..79ca80e 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,18 @@ 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)" + - 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/.github/workflows/ci_run.yml b/.github/workflows/ci_run.yml index 4931e1a..507e254 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 8a1de7d..b02c5f2 100644 --- a/README_ADVANCED.md +++ b/README_ADVANCED.md @@ -1333,10 +1333,14 @@ 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. In `stability`, PDP comes from the stable -filecoin-services checkout's bundled submodule; in `frontier`, PDP is pinned as -an independent repo. 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 `PDP_SOURCE`, `PDP_REF`, and `PDP_COMMIT` -when dependency profiles are resolved. +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 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 +`PDP_SOURCE`, `PDP_REF`, and `PDP_COMMIT` when dependency profiles are resolved. diff --git a/ci/README.md b/ci/README.md index e25c259..cc18d3b 100644 --- a/ci/README.md +++ b/ci/README.md @@ -5,13 +5,42 @@ 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`. +- `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 +`default`, `stability`, and `frontier`. + +Top-level profile definitions have a `base` component profile and can override +specific components: -Each component must define a selection for each profile. +```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 +49,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. @@ -91,6 +120,25 @@ By default, pattern selections exclude prerelease tags such as `-rc`, `-alpha`, } ``` +### `git_submodule` + +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": "v*", + "path": "service_contracts/lib/pdp" +} +``` + +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` 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 86d3ab8..bd824d9 100644 --- a/ci/dependency-profiles.json +++ b/ci/dependency-profiles.json @@ -1,5 +1,40 @@ { - "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" + } + }, + "stability-frontier-pdp": { + "base": "stability", + "components": { + "pdp": "frontier" + } + } + }, "components": { "lotus": { "repository": "https://github.com/filecoin-project/lotus.git", @@ -49,7 +84,10 @@ "strategy": "config_default" }, "stability": { - "strategy": "config_default" + "strategy": "git_submodule", + "repository": "https://github.com/FilOzone/filecoin-services.git", + "tag": "v*", + "path": "service_contracts/lib/pdp" }, "frontier": { "strategy": "git_branch", diff --git a/scripts/resolve-ci-dependencies.py b/scripts/resolve-ci-dependencies.py index 066c4d4..0c87082 100644 --- a/scripts/resolve-ci-dependencies.py +++ b/scripts/resolve-ci-dependencies.py @@ -11,9 +11,10 @@ import re import shlex import subprocess +import tempfile from pathlib import Path -PROFILES = {"default", "stability", "frontier"} +MANIFEST_SCHEMA_VERSION = 2 INIT_COMPONENT_FLAGS = { "lotus": "--lotus", "curio": "--curio", @@ -45,19 +46,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(): @@ -187,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 {} @@ -226,6 +306,7 @@ def resolve_component( resolved = { "name": name, "repository": repository, + "selection_profile": profile, "strategy": strategy, } @@ -251,6 +332,36 @@ 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"] + 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") + 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", + 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) @@ -314,11 +425,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], 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 1302dca..c009924 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,120 @@ 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"), + "pdp": self.component("5", "6"), + "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"}, + }, + "stability-frontier-pdp": { + "base": "stability", + "components": {"pdp": "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 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": "v*", + "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( [ @@ -90,28 +206,227 @@ 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["pdp"]["selection_profile"], "stability") + 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") + + @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", + "--tags", + "https://example.test/filecoin-services.git", + "v*", + ), + 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", + "--tags", + "https://example.test/filecoin-services.git", + "v*", + ), + 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"}, + "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",