From a464054b6048559dca5e4762e380dbcd1a5729ed Mon Sep 17 00:00:00 2001 From: galargh Date: Sun, 7 Jun 2026 22:45:11 +0200 Subject: [PATCH 1/3] ci: unify dependency profiles --- .github/workflows/ci_nightly.yml | 4 +- .github/workflows/ci_pull_request.yml | 4 +- .github/workflows/ci_run.yml | 79 +++-- README_ADVANCED.md | 7 + ci/dependency-profiles.json | 83 +++++ scenarios/dependencies.py | 73 ++++ scenarios/report.py | 10 +- scenarios/synapse.py | 19 +- scenarios/test_multi_copy_upload.py | 75 ++-- scripts/lint.sh | 9 +- scripts/resolve-ci-dependencies.py | 323 ++++++++++++++++++ scripts/tests/test_resolve_ci_dependencies.py | 209 ++++++++++++ scripts/tests/test_scenario_dependencies.py | 91 +++++ 13 files changed, 919 insertions(+), 67 deletions(-) create mode 100644 ci/dependency-profiles.json create mode 100644 scenarios/dependencies.py create mode 100644 scripts/resolve-ci-dependencies.py create mode 100644 scripts/tests/test_resolve_ci_dependencies.py create mode 100644 scripts/tests/test_scenario_dependencies.py diff --git a/.github/workflows/ci_nightly.yml b/.github/workflows/ci_nightly.yml index b5bbb42..45b3cfc 100644 --- a/.github/workflows/ci_nightly.yml +++ b/.github/workflows/ci_nightly.yml @@ -30,17 +30,15 @@ jobs: matrix: include: - name: stability - init_flags: "--curio latesttag:pdp/v* --filecoin-services latesttag:v*" issue_label: scenarios-run-stability issue_title: "FOC Devnet scenarios run report (stability)" - name: frontier - init_flags: "--curio gitbranch:main --filecoin-services gitbranch:main" issue_label: scenarios-run-frontier issue_title: "FOC Devnet scenarios run report (frontier)" uses: ./.github/workflows/ci_run.yml with: name: ${{ matrix.name }} - init_flags: ${{ matrix.init_flags }} + profile: ${{ matrix.name }} # Reporting is always on for scheduled runs; for manual dispatch it follows the input. enable_reporting: ${{ github.event_name == 'schedule' || inputs.reporting == true }} # On scheduled runs, such as nightly `inputs.skip_report_on_pass` is absent (empty string), so we cannot rely diff --git a/.github/workflows/ci_pull_request.yml b/.github/workflows/ci_pull_request.yml index d911cde..f324d77 100644 --- a/.github/workflows/ci_pull_request.yml +++ b/.github/workflows/ci_pull_request.yml @@ -2,7 +2,7 @@ # Runs on every pull request targeting main, and on every merge to main. # # Executes lint checks, cargo tests, and a single CI run with the default -# config (no special init_flags). Issue reporting is disabled — that is +# dependency profile. Issue reporting is disabled — that is # reserved for the nightly schedule run. name: CI (Pull Request) @@ -58,6 +58,6 @@ jobs: uses: ./.github/workflows/ci_run.yml with: name: default - init_flags: '' + profile: default enable_reporting: false secrets: inherit diff --git a/.github/workflows/ci_run.yml b/.github/workflows/ci_run.yml index 32fcfc9..2ad5d63 100644 --- a/.github/workflows/ci_run.yml +++ b/.github/workflows/ci_run.yml @@ -4,8 +4,8 @@ # Called by ci_pull_request.yml (default config, no reporting) and # ci_nightly.yml (stability / frontier matrix, issue reporting enabled). # -# The only behavioural difference between callers is the `init_flags` input, -# which controls which versions of Curio / filecoin-services are used. +# The dependency profile controls the versions of compatibility-sensitive +# server and client components used by the run. name: CI Run @@ -16,11 +16,10 @@ on: description: 'Human-readable run name (e.g. default, stability, frontier)' required: true type: string - init_flags: - description: 'Extra flags forwarded to `foc-devnet init`' - required: false + profile: + description: 'Dependency profile: default, stability, or frontier' + required: true type: string - default: '' enable_reporting: description: 'When true, file a GitHub issue with the scenario report' required: false @@ -123,22 +122,21 @@ jobs: rm -rf target/ df -h - # Compute cache keys based on version info and source files - # - CODE_HASH: Changes when Lotus/Curio versions change (for build artifacts cache) - # - DOCKER_HASH: Changes when Dockerfiles change (for Docker images cache) - - name: "CHECK: {Compute version hashes}" - id: version-hashes - run: | - # Get version output - VERSION_OUTPUT=$(./foc-devnet version 2>&1) - - # Compute CODE_HASH from Lotus/Curio versions only (filecoin-services contains contracts/scripts - # that are compiled by Foundry at deploy time, not compiled into Lotus/Curio binaries) - CODE_HASH=$(echo "$VERSION_OUTPUT" | grep -E 'default:code:(lotus|curio)' | sha256sum | cut -d' ' -f1) - echo "code-hash=$CODE_HASH" >> $GITHUB_OUTPUT - echo "CODE_HASH: $CODE_HASH" - - # Compute DOCKER_HASH from docker/ directory (Dockerfile changes) + - name: "CHECK: {Resolve dependency profile}" + id: dependencies + run: | + METADATA="$RUNNER_TEMP/ci-dependencies.json" + python3 scripts/resolve-ci-dependencies.py resolve \ + --profile '${{ inputs.profile }}' \ + --output "$METADATA" \ + --github-output "$GITHUB_OUTPUT" \ + --github-env "$GITHUB_ENV" + + # Docker images do not contain Lotus/Curio binaries, so their cache identity + # depends only on the Docker build inputs. + - name: "CHECK: {Compute Docker hash}" + id: docker-hash + run: | DOCKER_HASH=$(find docker -type f -exec sha256sum {} \; | sort | sha256sum | cut -d' ' -f1) echo "docker-hash=$DOCKER_HASH" >> $GITHUB_OUTPUT echo "DOCKER_HASH: $DOCKER_HASH" @@ -150,7 +148,7 @@ jobs: uses: actions/cache/restore@v4 with: path: ~/.docker-images-cache - key: ${{ runner.os }}-docker-images-${{ steps.version-hashes.outputs.docker-hash }} + key: ${{ runner.os }}-docker-images-${{ steps.docker-hash.outputs.docker-hash }} # CACHE-DOCKER: If Docker images are cached, load them from tarballs - name: "EXEC: {Load Docker images}, DEP: {C-docker-images-cache}" @@ -174,14 +172,14 @@ jobs: if: steps.cache-docker-images.outputs.cache-hit == 'true' run: | ./foc-devnet clean --all - ./foc-devnet init --no-docker-build ${{ inputs.init_flags }} + ./foc-devnet init --no-docker-build ${{ steps.dependencies.outputs.init-args }} # If Docker images are not cached, do full init (downloads YugabyteDB and builds all images) - name: "EXEC: {Initialize without cache}, independent" if: steps.cache-docker-images.outputs.cache-hit != 'true' run: | ./foc-devnet clean --all - ./foc-devnet init ${{ inputs.init_flags }} + ./foc-devnet init ${{ steps.dependencies.outputs.init-args }} # CACHE-DOCKER: Save Docker images as tarballs for caching - name: "EXEC: {Save Docker images for cache}, DEP: {C-docker-images-cache}" @@ -204,7 +202,17 @@ jobs: uses: actions/cache/save@v4 with: path: ~/.docker-images-cache - key: ${{ runner.os }}-docker-images-${{ steps.version-hashes.outputs.docker-hash }} + key: ${{ runner.os }}-docker-images-${{ steps.docker-hash.outputs.docker-hash }} + + # Verify the exact repositories cloned by init and derive source-based cache + # identities. filecoin-services is intentionally excluded because it does + # not contribute to the Lotus/Curio binaries. + - name: "CHECK: {Verify dependency checkouts}" + id: verified-dependencies + run: | + python3 scripts/resolve-ci-dependencies.py verify \ + --metadata "$CI_DEPENDENCY_METADATA" \ + --github-output "$GITHUB_OUTPUT" # CACHE-BINARIES: Try to restore previously built Lotus/Curio binaries - name: "CACHE_RESTORE: {C-build-artifacts-cache}" @@ -212,7 +220,7 @@ jobs: uses: actions/cache/restore@v4 with: path: ~/.foc-devnet/bin - key: ${{ runner.os }}-binaries-${{ inputs.name }}-${{ steps.version-hashes.outputs.code-hash }} + key: ${{ runner.os }}-binaries-${{ steps.verified-dependencies.outputs.source-hash }} - name: "EXEC: {Ensure permissions on binaries}, DEP: {C-build-artifacts-cache}" if: steps.cache-binaries.outputs.cache-hit == 'true' @@ -225,9 +233,9 @@ jobs: uses: actions/cache/restore@v4 with: path: ~/.foc-devnet/docker/volumes/cache/foc-builder - key: ${{ runner.os }}-foc-builder-cache-${{ inputs.name }}-${{ hashFiles('docker/**') }}-${{ hashFiles('src/config.rs') }} + key: ${{ runner.os }}-foc-builder-cache-${{ steps.verified-dependencies.outputs.go-cache-key }}-${{ hashFiles('docker/**') }}-${{ hashFiles('src/config.rs') }} restore-keys: | - ${{ runner.os }}-foc-builder-cache-${{ inputs.name }}- + ${{ runner.os }}-foc-builder-cache-${{ steps.verified-dependencies.outputs.go-cache-key }}- - name: "EXEC: {Ensure permissions}, DEP: {C-foc-builder-cache}" if: steps.cache-binaries.outputs.cache-hit != 'true' && @@ -253,7 +261,7 @@ jobs: uses: actions/cache/save@v4 with: path: ~/.foc-devnet/docker/volumes/cache/foc-builder - key: ${{ runner.os }}-foc-builder-cache-${{ inputs.name }}-${{ hashFiles('docker/**') }}-${{ hashFiles('src/config.rs') }} + key: ${{ runner.os }}-foc-builder-cache-${{ steps.verified-dependencies.outputs.go-cache-key }}-${{ hashFiles('docker/**') }}-${{ hashFiles('src/config.rs') }} # CACHE-BINARIES: Save built Lotus/Curio binaries for future runs - name: "CACHE_SAVE: {C-build-artifacts-cache}" @@ -261,7 +269,7 @@ jobs: uses: actions/cache/save@v4 with: path: ~/.foc-devnet/bin - key: ${{ runner.os }}-binaries-${{ inputs.name }}-${{ steps.version-hashes.outputs.code-hash }} + key: ${{ runner.os }}-binaries-${{ steps.verified-dependencies.outputs.source-hash }} # Disk free-up - name: "EXEC: {Clean up Go modules}, DEP: {C-build-artifacts-cache}" @@ -439,6 +447,15 @@ jobs: echo '```' ./foc-devnet version 2>&1 || echo "version command failed" echo '```' + echo "" + echo "## Resolved dependencies" + if [ -f "$CI_DEPENDENCY_METADATA" ]; then + echo '```json' + cat "$CI_DEPENDENCY_METADATA" + echo '```' + else + echo "Dependency metadata is unavailable." + fi } > "$REPORT" fi diff --git a/README_ADVANCED.md b/README_ADVANCED.md index 41dec55..172c79b 100644 --- a/README_ADVANCED.md +++ b/README_ADVANCED.md @@ -1327,3 +1327,10 @@ Reports are written to `~/.foc-devnet/state/latest/scenario_report.md`. Scenarios run automatically in CI after the devnet starts. On nightly runs (or manual dispatch with `reporting` enabled), failures automatically create a GitHub issue with a full report. +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. diff --git a/ci/dependency-profiles.json b/ci/dependency-profiles.json new file mode 100644 index 0000000..81f76d8 --- /dev/null +++ b/ci/dependency-profiles.json @@ -0,0 +1,83 @@ +{ + "schema_version": 1, + "components": { + "lotus": { + "kind": "git", + "repository": "https://github.com/filecoin-project/lotus.git", + "default": { + "strategy": "config_default" + }, + "stability": { + "strategy": "latest_stable_tag", + "tag_pattern": "v*" + }, + "frontier": { + "strategy": "branch", + "branch": "master" + } + }, + "curio": { + "kind": "git", + "repository": "https://github.com/filecoin-project/curio.git", + "default": { + "strategy": "config_default" + }, + "stability": { + "strategy": "latest_stable_tag", + "tag_pattern": "pdp/v*" + }, + "frontier": { + "strategy": "branch", + "branch": "main" + } + }, + "filecoin-services": { + "kind": "git", + "repository": "https://github.com/FilOzone/filecoin-services.git", + "default": { + "strategy": "config_default" + }, + "stability": { + "strategy": "latest_stable_tag", + "tag_pattern": "v*" + }, + "frontier": { + "strategy": "branch", + "branch": "main" + } + }, + "synapse-sdk": { + "kind": "git_client", + "repository": "https://github.com/FilOzone/synapse-sdk.git", + "npm_package": "@filoz/synapse-sdk", + "default": { + "strategy": "tag", + "tag": "synapse-sdk-v0.41.0" + }, + "stability": { + "strategy": "npm_latest_tag", + "tag_prefix": "synapse-sdk-v" + }, + "frontier": { + "strategy": "branch", + "branch": "master" + } + }, + "filecoin-pin": { + "kind": "npm_client", + "repository": "https://github.com/filecoin-project/filecoin-pin.git", + "npm_package": "filecoin-pin", + "default": { + "strategy": "npm_version", + "version": "0.22.3" + }, + "stability": { + "strategy": "npm_latest" + }, + "frontier": { + "strategy": "branch", + "branch": "master" + } + } + } +} diff --git a/scenarios/dependencies.py b/scenarios/dependencies.py new file mode 100644 index 0000000..bfec9c6 --- /dev/null +++ b/scenarios/dependencies.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Access resolved CI dependency metadata from scenario tests.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + + +def metadata_path() -> Path | None: + value = os.environ.get("CI_DEPENDENCY_METADATA") + return Path(value) if value else None + + +def load_metadata() -> dict: + path = metadata_path() + if path is None or not path.is_file(): + return {} + with path.open() as file: + return json.load(file) + + +def component(name: str) -> dict: + components = load_metadata().get("components", {}) + value = components.get(name) + if isinstance(value, dict): + return value + + manifest_path = Path(__file__).parents[1] / "ci" / "dependency-profiles.json" + manifest = json.loads(manifest_path.read_text()) + definition = manifest["components"].get(name) + if not definition: + raise RuntimeError(f"Dependency manifest has no {name!r} component") + selection = definition["default"] + fallback = { + "name": name, + "kind": definition["kind"], + "repository": definition["repository"], + "strategy": selection["strategy"], + } + if selection["strategy"] == "tag": + fallback.update(source="git", ref=selection["tag"]) + elif selection["strategy"] == "npm_version": + fallback.update( + source="npm", + package=definition["npm_package"], + version=selection["version"], + ) + else: + raise RuntimeError( + f"Local scenario fallback does not support {selection['strategy']!r}" + ) + return fallback + + +def format_markdown_table(metadata: dict | None = None) -> str: + if metadata is None: + metadata = load_metadata() + components = metadata.get("components", {}) + if not components: + return "Resolved dependency metadata is unavailable." + + rows = [ + "| Dependency | Source | Version/ref | Commit |", + "|---|---|---|---|", + ] + for name, value in components.items(): + source = value.get("source", value.get("strategy", "-")) + version = value.get("version") or value.get("ref") or "-" + commit = value.get("commit") or "-" + rows.append(f"| {name} | {source} | `{version}` | `{commit}` |") + return "\n".join(rows) diff --git a/scenarios/report.py b/scenarios/report.py index c094593..facab24 100644 --- a/scenarios/report.py +++ b/scenarios/report.py @@ -1,12 +1,16 @@ #!/usr/bin/env python3 """Report generation and version info for scenario test results.""" +from __future__ import annotations + import datetime import os import subprocess from dataclasses import dataclass from string import Template +from scenarios.dependencies import format_markdown_table + REPORT_MD = os.environ.get( "REPORT_FILE", os.path.expanduser("~/.foc-devnet/state/latest/scenario_report.md") ) @@ -27,7 +31,7 @@ def get_version_info(): for binary in ["./foc-devnet", "foc-devnet"]: try: result = subprocess.run( - [binary, "version", "--noterminal"], + [binary, "version", "--notty"], capture_output=True, text=True, timeout=10, @@ -52,6 +56,9 @@ def get_version_info(): ## Versions info $version_info +## Resolved dependencies +$dependency_table + ## Tests summary $test_summary """) @@ -100,6 +107,7 @@ def write_report(results: list[TestResult] | None = None, elapsed: int = 0): total_count=total, ci_run_link=_build_ci_run_link(), version_info=f"```\n{get_version_info()}\n```", + dependency_table=format_markdown_table(), test_summary=_build_test_summary(results), ) with open(REPORT_MD, "w") as fh: diff --git a/scenarios/synapse.py b/scenarios/synapse.py index 530aac1..29a4743 100644 --- a/scenarios/synapse.py +++ b/scenarios/synapse.py @@ -1,26 +1,37 @@ #!/usr/bin/env python3 """Shared helpers for cloning, building, and uploading via synapse-sdk.""" +from __future__ import annotations + import os from pathlib import Path +from scenarios.dependencies import component from scenarios.helpers import info, run_cmd, sh -SYNAPSE_SDK_REPO = "https://github.com/FilOzone/synapse-sdk/" - def clone_and_build(tmp_dir: Path) -> Path | None: """Clone synapse-sdk into tmp_dir, install deps, build. Returns sdk_dir or None on failure.""" + dependency = component("synapse-sdk") + repository = dependency["repository"] + checkout = dependency.get("commit") or dependency["ref"] sdk_dir = tmp_dir / "synapse-sdk" if not run_cmd( - ["git", "clone", SYNAPSE_SDK_REPO, str(sdk_dir)], label="clone synapse-sdk" + ["git", "clone", repository, str(sdk_dir)], label="clone synapse-sdk" ): return None if not run_cmd( - ["git", "checkout", "master"], cwd=str(sdk_dir), label="checkout master HEAD" + ["git", "checkout", "--detach", checkout], + cwd=str(sdk_dir), + label=f"checkout synapse-sdk {checkout}", ): return None sdk_commit = sh(f"git -C {sdk_dir} rev-parse HEAD") + expected_commit = dependency.get("commit") + if expected_commit and sdk_commit != expected_commit: + raise RuntimeError( + f"synapse-sdk checkout is {sdk_commit}, expected {expected_commit}" + ) info(f"synapse-sdk commit: {sdk_commit}") if not run_cmd(["pnpm", "install"], cwd=str(sdk_dir), label="pnpm install"): return None diff --git a/scenarios/test_multi_copy_upload.py b/scenarios/test_multi_copy_upload.py index 4f5c625..6389552 100644 --- a/scenarios/test_multi_copy_upload.py +++ b/scenarios/test_multi_copy_upload.py @@ -1,6 +1,8 @@ #!/usr/bin/env python3 """Multi-copy upload test: upload a random file via filecoin-pin against the devnet.""" +from __future__ import annotations + import os import re import subprocess @@ -13,6 +15,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from scenarios.dependencies import component from scenarios.helpers import ( assert_eq, assert_ok, @@ -119,13 +122,13 @@ def patch_synapse_core_streaming_upload(npm_dir: Path) -> bool: def run_filecoin_pin_add( - filecoin_pin_bin: Path, + filecoin_pin_command: list[str], upload_dir: Path, extra_args: list[str], label: str, ): cmd = [ - str(filecoin_pin_bin), + *filecoin_pin_command, "add", "--network", "devnet", @@ -171,6 +174,49 @@ def run_filecoin_pin_add( return last_result +def setup_filecoin_pin(work_dir: Path) -> tuple[list[str], Path]: + dependency = component("filecoin-pin") + source = dependency["source"] + + if source == "npm": + run_cmd(["npm", "init", "-y"], label="npm init", cwd=work_dir) + run_cmd( + [ + "npm", + "pkg", + "set", + "type=module", + f"dependencies.filecoin-pin={dependency['version']}", + "dependencies.multiformats=13.4.2", + ], + label=f"pin filecoin-pin {dependency['version']}", + cwd=work_dir, + ) + run_cmd(["npm", "install"], label="npm install", cwd=work_dir) + return [str(work_dir / "node_modules" / ".bin" / "filecoin-pin")], work_dir + + if source == "git": + repository_dir = work_dir / "filecoin-pin" + run_cmd( + ["git", "clone", dependency["repository"], str(repository_dir)], + label="clone filecoin-pin", + ) + run_cmd( + ["git", "checkout", "--detach", dependency["commit"]], + cwd=repository_dir, + label=f"checkout filecoin-pin {dependency['commit']}", + ) + run_cmd( + ["pnpm", "install", "--frozen-lockfile"], + cwd=repository_dir, + label="pnpm install", + ) + run_cmd(["pnpm", "build"], cwd=repository_dir, label="pnpm build") + return ["node", str(repository_dir / "dist" / "cli.js")], repository_dir + + raise RuntimeError(f"Unsupported filecoin-pin source: {source}") + + def download_and_verify( url: str, file: Path, root_cid: str, npm_dir: Path ) -> str | None: @@ -238,24 +284,9 @@ def run(): npm_dir = Path(npm_tmp) download_dir = Path(download_tmp) - run_cmd(["npm", "init", "-y"], label="npm init", cwd=npm_dir) - - run_cmd( - [ - "npm", - "pkg", - "set", - "type=module", - "dependencies.filecoin-pin=0.22.3", - "dependencies.multiformats=13.4.2", - ], - label="pin filecoin-pin dependencies", - cwd=npm_dir, - ) - - run_cmd(["npm", "install"], label="npm install", cwd=npm_dir) + filecoin_pin_command, dependency_dir = setup_filecoin_pin(npm_dir) - if not patch_synapse_core_streaming_upload(npm_dir): + if not patch_synapse_core_streaming_upload(dependency_dir): fail( "Could not locate @filoz/synapse-core streaming upload " "to patch (file missing under node_modules)" @@ -273,10 +304,8 @@ def run(): info("Running filecoin-pin multi-copy upload script against devnet") - filecoin_pin_bin = npm_dir / "node_modules" / ".bin" / "filecoin-pin" - add_result = run_filecoin_pin_add( - filecoin_pin_bin, + filecoin_pin_command, upload_dir, [], "default multi-copy", @@ -355,7 +384,7 @@ def run(): for i, url in enumerate(root_retrieval_urls, start=1): file = download_dir / f"{root_cid}_{i}.bin" - error = download_and_verify(url, file, root_cid, npm_dir) + error = download_and_verify(url, file, root_cid, dependency_dir) if error: fail(error) return diff --git a/scripts/lint.sh b/scripts/lint.sh index 054eb22..36380cd 100755 --- a/scripts/lint.sh +++ b/scripts/lint.sh @@ -22,16 +22,19 @@ else skip "cargo" fi -if find scenarios -name '*.py' 2>/dev/null | grep -q .; then +if find scenarios scripts -name '*.py' 2>/dev/null | grep -q .; then if command -v black &>/dev/null; then if [[ "$FIX" == "1" ]]; then - black scenarios/ && pass "black" || fail "black" + black scenarios/ scripts/ && pass "black" || fail "black" else - black --check scenarios/ && pass "black" || fail "black (run lint.sh to fix)" + black --check scenarios/ scripts/ && pass "black" || fail "black (run lint.sh to fix)" fi else skip "black (pip install black)" fi fi +python3 -m unittest discover -s scripts/tests -p 'test_*.py' && + pass "dependency resolver tests" || fail "dependency resolver tests" + [[ $FAIL -eq 0 ]] && printf "\033[32m✓ All checks passed.\033[0m\n" || { printf "\033[31m✗ Linting failed.\033[0m\n"; exit 1; } diff --git a/scripts/resolve-ci-dependencies.py b/scripts/resolve-ci-dependencies.py new file mode 100644 index 0000000..1054e54 --- /dev/null +++ b/scripts/resolve-ci-dependencies.py @@ -0,0 +1,323 @@ +#!/usr/bin/env python3 +"""Resolve CI dependency profiles to immutable versions and verify checkouts.""" + +from __future__ import annotations + +import argparse +import fnmatch +import hashlib +import json +import os +import re +import shlex +import subprocess +from pathlib import Path + +PROFILES = {"default", "stability", "frontier"} +INIT_COMPONENT_FLAGS = { + "lotus": "--lotus", + "curio": "--curio", + "filecoin-services": "--filecoin-services", +} +STABLE_VERSION_RE = re.compile(r"^\d+(?:\.\d+)*$") + + +class ResolutionError(RuntimeError): + pass + + +def run_command(command: list[str]) -> str: + result = subprocess.run(command, text=True, capture_output=True) + if result.returncode != 0: + details = (result.stderr or result.stdout).strip() + raise ResolutionError(f"{shlex.join(command)} failed: {details}") + return result.stdout.strip() + + +def load_manifest(path: Path) -> dict: + try: + manifest = json.loads(path.read_text()) + except (OSError, json.JSONDecodeError) as error: + raise ResolutionError( + f"Cannot load dependency manifest {path}: {error}" + ) from error + + if manifest.get("schema_version") != 1: + raise ResolutionError("Dependency manifest schema_version must be 1") + components = manifest.get("components") + if not isinstance(components, dict): + raise ResolutionError("Dependency manifest must contain a components 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)}") + return manifest + + +def parse_ls_remote(output: str) -> list[tuple[str, str]]: + refs = [] + for line in output.splitlines(): + fields = line.split() + if len(fields) == 2: + refs.append((fields[0], fields[1])) + return refs + + +def resolve_ref(repository: str, ref: str, runner=run_command) -> str: + refs = parse_ls_remote(runner(["git", "ls-remote", repository, ref])) + if not refs: + raise ResolutionError(f"No ref {ref} found in {repository}") + return refs[0][0] + + +def resolve_tag(repository: str, tag: str, runner=run_command) -> str: + refs = parse_ls_remote( + runner(["git", "ls-remote", repository, f"refs/tags/{tag}*"]) + ) + target = f"refs/tags/{tag}" + exact_refs = [ + (commit, ref) for commit, ref in refs if ref in {target, f"{target}^{{}}"} + ] + if not exact_refs: + raise ResolutionError(f"No tag {tag} found in {repository}") + peeled = next((commit for commit, ref in exact_refs if ref.endswith("^{}")), None) + return peeled or exact_refs[0][0] + + +def stable_version(tag: str, pattern: str) -> tuple[int, ...] | None: + if not fnmatch.fnmatchcase(tag, pattern): + return None + star = pattern.find("*") + version = tag + if star >= 0: + prefix = pattern[:star] + suffix = pattern[star + 1 :] + version = tag[len(prefix) :] + if suffix: + version = version[: -len(suffix)] + version = version.removeprefix("v") + if not STABLE_VERSION_RE.fullmatch(version): + return None + return tuple(int(part) for part in version.split(".")) + + +def select_latest_stable_tag(output: str, pattern: str) -> tuple[str, str]: + tag_commits = {} + peeled_commits = {} + for commit, ref in parse_ls_remote(output): + if not ref.startswith("refs/tags/"): + continue + tag = ref.removeprefix("refs/tags/").removesuffix("^{}") + if ref.endswith("^{}"): + peeled_commits[tag] = commit + else: + tag_commits[tag] = commit + + candidates = [] + for tag, commit in tag_commits.items(): + version = stable_version(tag, pattern) + if version is not None: + candidates.append((version, tag, peeled_commits.get(tag, commit))) + if not candidates: + raise ResolutionError(f"No stable tags match {pattern}") + _, tag, commit = max(candidates) + return tag, commit + + +def npm_metadata(package: str, version: str, runner=run_command) -> dict: + resolved_version = json.loads( + runner(["npm", "view", f"{package}@{version}", "version", "--json"]) + ) + if not resolved_version: + raise ResolutionError(f"npm returned no version for {package}@{version}") + git_head_output = runner( + ["npm", "view", f"{package}@{resolved_version}", "gitHead", "--json"] + ) + git_head = json.loads(git_head_output) if git_head_output else "" + return {"version": resolved_version, "gitHead": git_head} + + +def resolve_component( + name: str, component: dict, profile: str, runner=run_command +) -> dict: + try: + selection = component[profile] + strategy = selection["strategy"] + repository = component["repository"] + kind = component["kind"] + except KeyError as error: + raise ResolutionError(f"{name} is missing required field {error}") from error + + resolved = { + "name": name, + "kind": kind, + "repository": repository, + "strategy": strategy, + } + + if strategy == "config_default": + resolved["source"] = "config_default" + elif strategy == "latest_stable_tag": + output = runner( + ["git", "ls-remote", "--tags", repository, selection["tag_pattern"]] + ) + tag, commit = select_latest_stable_tag(output, selection["tag_pattern"]) + resolved.update(source="git", ref_type="tag", ref=tag, commit=commit) + elif strategy == "tag": + tag = selection["tag"] + commit = resolve_tag(repository, tag, runner) + resolved.update(source="git", ref_type="tag", ref=tag, commit=commit) + elif strategy == "branch": + 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 in {"npm_version", "npm_latest"}: + requested = selection.get("version", "latest") + data = npm_metadata(component["npm_package"], requested, runner) + resolved.update( + source="npm", + package=component["npm_package"], + version=data["version"], + commit=data.get("gitHead", ""), + ) + elif strategy == "npm_latest_tag": + data = npm_metadata(component["npm_package"], "latest", runner) + tag = f"{selection['tag_prefix']}{data['version']}" + commit = resolve_tag(repository, tag, runner) + if data.get("gitHead") and data["gitHead"] != commit: + raise ResolutionError( + f"{name} npm gitHead {data['gitHead']} does not match {tag} ({commit})" + ) + resolved.update( + source="git", + ref_type="tag", + ref=tag, + commit=commit, + package=component["npm_package"], + version=data["version"], + ) + else: + raise ResolutionError(f"Unsupported strategy {strategy!r} for {name}") + return resolved + + +def build_init_args(components: dict) -> list[str]: + args = [] + for name, flag in INIT_COMPONENT_FLAGS.items(): + component = components[name] + if component["source"] == "config_default": + continue + args.extend( + [ + flag, + f"gitcommit:{component['repository']}:{component['commit']}", + ] + ) + return args + + +def cache_hash(components: dict) -> str: + values = [f"{name}:{components[name]['commit']}" for name in ("lotus", "curio")] + return hashlib.sha256("\n".join(values).encode()).hexdigest() + + +def write_github_file(path: str | None, values: dict) -> None: + if not path: + return + with open(path, "a") as output: + for key, value in values.items(): + output.write(f"{key}={value}\n") + + +def scenario_environment(metadata_path: Path, components: dict) -> dict: + synapse = components["synapse-sdk"] + filecoin_pin = components["filecoin-pin"] + return { + "CI_DEPENDENCY_METADATA": str(metadata_path.resolve()), + "SYNAPSE_SDK_SOURCE": synapse["source"], + "SYNAPSE_SDK_REF": synapse.get("ref", ""), + "SYNAPSE_SDK_COMMIT": synapse.get("commit", ""), + "FILECOIN_PIN_SOURCE": filecoin_pin["source"], + "FILECOIN_PIN_VERSION": filecoin_pin.get("version", ""), + "FILECOIN_PIN_COMMIT": filecoin_pin.get("commit", ""), + } + + +def resolve(args) -> None: + if args.profile not in PROFILES: + raise ResolutionError(f"Unknown profile {args.profile!r}") + manifest = load_manifest(args.manifest) + components = { + name: resolve_component(name, component, args.profile) + for name, component in manifest["components"].items() + } + metadata = { + "schema_version": 1, + "profile": args.profile, + "components": components, + } + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(metadata, indent=2, sort_keys=True) + "\n") + + init_args = shlex.join(build_init_args(components)) + write_github_file(args.github_output, {"init-args": init_args}) + write_github_file(args.github_env, scenario_environment(args.output, components)) + print(json.dumps(metadata, indent=2, sort_keys=True)) + + +def verify(args) -> None: + metadata = json.loads(args.metadata.read_text()) + components = metadata["components"] + for name in INIT_COMPONENT_FLAGS: + repository_path = args.code_dir / name + actual = run_command(["git", "-C", str(repository_path), "rev-parse", "HEAD"]) + expected = components[name].get("commit") + if expected and actual != expected: + raise ResolutionError(f"{name} checkout is {actual}, expected {expected}") + components[name]["commit"] = actual + components[name]["verified"] = True + + args.metadata.write_text(json.dumps(metadata, indent=2, sort_keys=True) + "\n") + source_hash = cache_hash(components) + write_github_file( + args.github_output, + {"source-hash": source_hash, "go-cache-key": source_hash}, + ) + print(f"Verified dependency checkouts; source hash: {source_hash}") + + +def parser() -> argparse.ArgumentParser: + root = argparse.ArgumentParser() + subparsers = root.add_subparsers(dest="operation", required=True) + + resolve_parser = subparsers.add_parser("resolve") + resolve_parser.add_argument("--profile", required=True) + resolve_parser.add_argument( + "--manifest", type=Path, default=Path("ci/dependency-profiles.json") + ) + resolve_parser.add_argument("--output", type=Path, required=True) + resolve_parser.add_argument("--github-output") + resolve_parser.add_argument("--github-env") + resolve_parser.set_defaults(handler=resolve) + + verify_parser = subparsers.add_parser("verify") + verify_parser.add_argument("--metadata", type=Path, required=True) + verify_parser.add_argument( + "--code-dir", type=Path, default=Path.home() / ".foc-devnet" / "code" + ) + verify_parser.add_argument("--github-output") + verify_parser.set_defaults(handler=verify) + return root + + +def main() -> None: + args = parser().parse_args() + try: + args.handler(args) + except (ResolutionError, KeyError, json.JSONDecodeError) as error: + raise SystemExit(f"dependency resolution failed: {error}") from error + + +if __name__ == "__main__": + main() diff --git a/scripts/tests/test_resolve_ci_dependencies.py b/scripts/tests/test_resolve_ci_dependencies.py new file mode 100644 index 0000000..597f181 --- /dev/null +++ b/scripts/tests/test_resolve_ci_dependencies.py @@ -0,0 +1,209 @@ +import importlib.util +import json +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +SCRIPT = Path(__file__).parents[1] / "resolve-ci-dependencies.py" +SPEC = importlib.util.spec_from_file_location("dependency_resolver", SCRIPT) +resolver = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(resolver) + + +class FakeRunner: + def __init__(self, responses): + self.responses = responses + + def __call__(self, command): + key = tuple(command) + if key not in self.responses: + raise AssertionError(f"Unexpected command: {command}") + return self.responses[key] + + +class ResolverTests(unittest.TestCase): + def test_latest_stable_tag_excludes_prereleases_and_annotated_refs(self): + output = "\n".join( + [ + "aaa refs/tags/v1.9.0", + "bbb refs/tags/v2.0.0-rc1", + "ccc refs/tags/v1.10.0", + "ddd refs/tags/v1.10.0^{}", + "eee refs/tags/not-a-version", + ] + ) + self.assertEqual( + resolver.select_latest_stable_tag(output, "v*"), + ("v1.10.0", "ddd"), + ) + + def test_prefixed_stable_tag(self): + output = "\n".join( + [ + "aaa refs/tags/pdp/v1.2.0", + "bbb refs/tags/pdp/v1.11.0", + "ccc refs/tags/v9.0.0", + ] + ) + self.assertEqual( + resolver.select_latest_stable_tag(output, "pdp/v*"), + ("pdp/v1.11.0", "bbb"), + ) + + def test_unknown_profile_fails(self): + with tempfile.TemporaryDirectory() as directory: + args = type( + "Args", + (), + { + "profile": "unknown", + "manifest": Path(directory) / "manifest.json", + "output": Path(directory) / "resolved.json", + "github_output": None, + "github_env": None, + }, + ) + with self.assertRaises(resolver.ResolutionError): + 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": {}})) + with self.assertRaisesRegex(resolver.ResolutionError, "missing"): + resolver.load_manifest(path) + + def test_npm_latest_tag_maps_version_to_git_tag(self): + component = { + "kind": "git_client", + "repository": "https://example.test/synapse.git", + "npm_package": "@example/sdk", + "stability": { + "strategy": "npm_latest_tag", + "tag_prefix": "sdk-v", + }, + } + runner = FakeRunner( + { + ( + "npm", + "view", + "@example/sdk@latest", + "version", + "--json", + ): '"1.2.3"', + ( + "npm", + "view", + "@example/sdk@1.2.3", + "gitHead", + "--json", + ): '"abc"', + ( + "git", + "ls-remote", + "https://example.test/synapse.git", + "refs/tags/sdk-v1.2.3*", + ): "abc refs/tags/sdk-v1.2.3", + } + ) + resolved = resolver.resolve_component( + "synapse-sdk", component, "stability", runner + ) + self.assertEqual(resolved["ref"], "sdk-v1.2.3") + self.assertEqual(resolved["commit"], "abc") + + def test_frontier_branch_resolves_to_commit(self): + component = { + "kind": "git", + "repository": "https://example.test/project.git", + "frontier": {"strategy": "branch", "branch": "main"}, + } + runner = FakeRunner( + { + ( + "git", + "ls-remote", + "https://example.test/project.git", + "refs/heads/main", + ): "deadbeef refs/heads/main", + } + ) + resolved = resolver.resolve_component("project", component, "frontier", runner) + self.assertEqual(resolved["commit"], "deadbeef") + + def test_init_args_skip_config_defaults_and_pin_other_sources(self): + components = { + "lotus": {"source": "config_default"}, + "curio": { + "source": "git", + "repository": "https://example.test/curio.git", + "commit": "abc", + }, + "filecoin-services": { + "source": "git", + "repository": "https://example.test/services.git", + "commit": "def", + }, + } + self.assertEqual( + resolver.build_init_args(components), + [ + "--curio", + "gitcommit:https://example.test/curio.git:abc", + "--filecoin-services", + "gitcommit:https://example.test/services.git:def", + ], + ) + + def test_cache_hash_depends_only_on_lotus_and_curio_commits(self): + base = { + "lotus": {"commit": "aaa"}, + "curio": {"commit": "bbb"}, + "filecoin-services": {"commit": "ccc"}, + } + first = resolver.cache_hash(base) + base["filecoin-services"]["commit"] = "changed" + self.assertEqual(first, resolver.cache_hash(base)) + base["curio"]["commit"] = "changed" + self.assertNotEqual(first, resolver.cache_hash(base)) + + @patch.object(resolver, "run_command") + def test_verify_records_checkouts_and_writes_cache_key(self, run_command): + commits = { + "lotus": "aaa", + "curio": "bbb", + "filecoin-services": "ccc", + } + run_command.side_effect = lambda command: commits[Path(command[2]).name] + metadata = { + "schema_version": 1, + "profile": "default", + "components": {name: {"source": "config_default"} for name in commits}, + } + with tempfile.TemporaryDirectory() as directory: + directory = Path(directory) + metadata_path = directory / "metadata.json" + output_path = directory / "output" + metadata_path.write_text(json.dumps(metadata)) + args = type( + "Args", + (), + { + "metadata": metadata_path, + "code_dir": directory / "code", + "github_output": str(output_path), + }, + ) + resolver.verify(args) + verified = json.loads(metadata_path.read_text()) + outputs = output_path.read_text() + + self.assertEqual(verified["components"]["lotus"]["commit"], "aaa") + self.assertTrue(verified["components"]["curio"]["verified"]) + self.assertIn("source-hash=", outputs) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_scenario_dependencies.py b/scripts/tests/test_scenario_dependencies.py new file mode 100644 index 0000000..5e6ae8e --- /dev/null +++ b/scripts/tests/test_scenario_dependencies.py @@ -0,0 +1,91 @@ +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from scenarios.dependencies import format_markdown_table +from scenarios.synapse import clone_and_build +from scenarios.test_multi_copy_upload import setup_filecoin_pin + + +class ScenarioDependencyTests(unittest.TestCase): + def test_dependency_table_contains_all_resolved_components(self): + metadata = { + "components": { + "lotus": { + "source": "git", + "ref": "v1.2.3", + "commit": "aaa", + }, + "synapse-sdk": { + "source": "git", + "version": "0.41.0", + "commit": "bbb", + }, + "filecoin-pin": { + "source": "npm", + "version": "0.22.3", + "commit": "ccc", + }, + } + } + table = format_markdown_table(metadata) + for expected in ("lotus", "synapse-sdk", "filecoin-pin", "aaa", "0.22.3"): + self.assertIn(expected, table) + + @patch("scenarios.synapse.sh", return_value="deadbeef") + @patch("scenarios.synapse.run_cmd", return_value=True) + @patch( + "scenarios.synapse.component", + return_value={ + "repository": "https://example.test/synapse.git", + "ref": "sdk-v1.0.0", + "commit": "deadbeef", + }, + ) + def test_synapse_checkout_uses_resolved_commit(self, _component, run_cmd, _sh): + with tempfile.TemporaryDirectory() as directory: + clone_and_build(Path(directory)) + checkout = run_cmd.call_args_list[1] + self.assertEqual( + checkout.args[0], + ["git", "checkout", "--detach", "deadbeef"], + ) + + @patch("scenarios.test_multi_copy_upload.run_cmd", return_value=True) + @patch( + "scenarios.test_multi_copy_upload.component", + return_value={"source": "npm", "version": "0.22.3"}, + ) + def test_filecoin_pin_npm_install_path(self, _component, run_cmd): + with tempfile.TemporaryDirectory() as directory: + command, dependency_dir = setup_filecoin_pin(Path(directory)) + self.assertTrue(command[0].endswith("node_modules/.bin/filecoin-pin")) + self.assertEqual(dependency_dir, Path(directory)) + self.assertIn( + "dependencies.filecoin-pin=0.22.3", + run_cmd.call_args_list[1].args[0], + ) + + @patch("scenarios.test_multi_copy_upload.run_cmd", return_value=True) + @patch( + "scenarios.test_multi_copy_upload.component", + return_value={ + "source": "git", + "repository": "https://example.test/filecoin-pin.git", + "commit": "deadbeef", + }, + ) + def test_filecoin_pin_frontier_build_path(self, _component, run_cmd): + with tempfile.TemporaryDirectory() as directory: + command, dependency_dir = setup_filecoin_pin(Path(directory)) + self.assertEqual(command[0], "node") + self.assertTrue(command[1].endswith("filecoin-pin/dist/cli.js")) + self.assertTrue(str(dependency_dir).endswith("filecoin-pin")) + commands = [call.args[0] for call in run_cmd.call_args_list] + self.assertIn(["git", "checkout", "--detach", "deadbeef"], commands) + self.assertIn(["pnpm", "build"], commands) + + +if __name__ == "__main__": + unittest.main() From 0c37543bae7efc280e5df4047e22336835e294c4 Mon Sep 17 00:00:00 2001 From: galargh Date: Sun, 7 Jun 2026 22:54:44 +0200 Subject: [PATCH 2/3] fix(ci): install node before profile resolution --- .github/workflows/ci_run.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci_run.yml b/.github/workflows/ci_run.yml index 2ad5d63..28f38de 100644 --- a/.github/workflows/ci_run.yml +++ b/.github/workflows/ci_run.yml @@ -122,6 +122,12 @@ jobs: rm -rf target/ df -h + # Dependency profile resolution queries npm release metadata. + - name: "EXEC: {Setup Node.js}, independent" + uses: actions/setup-node@v4 + with: + node-version: 'lts/*' + - name: "CHECK: {Resolve dependency profile}" id: dependencies run: | @@ -344,6 +350,7 @@ jobs: # Verify cluster is running correctly - name: "EXEC: {Check cluster status}, independent" if: always() + continue-on-error: true run: ./foc-devnet status - name: "EXEC: {List foc-* containers}, independent" @@ -361,13 +368,6 @@ jobs: echo "✓ devnet-info.json created" jq '.version' "$DEVNET_INFO" - # Setup Node.js for JavaScript examples - - name: "EXEC: {Setup Node.js}, independent" - if: steps.start_cluster.outcome == 'success' - uses: actions/setup-node@v4 - with: - node-version: 'lts/*' - # Setup pnpm (required by scenario tests) - name: "EXEC: {Setup pnpm}, independent" if: steps.start_cluster.outcome == 'success' From f9e52c26e403a41ce9b0080e951402ee0ca8d5c0 Mon Sep 17 00:00:00 2001 From: galargh Date: Sun, 7 Jun 2026 23:24:25 +0200 Subject: [PATCH 3/3] fix(ci): pin working synapse checkout --- ci/dependency-profiles.json | 4 +-- scenarios/dependencies.py | 8 +++++- scripts/resolve-ci-dependencies.py | 6 +++++ scripts/tests/test_resolve_ci_dependencies.py | 25 +++++++++++++++++++ 4 files changed, 40 insertions(+), 3 deletions(-) diff --git a/ci/dependency-profiles.json b/ci/dependency-profiles.json index 81f76d8..0f3c9f1 100644 --- a/ci/dependency-profiles.json +++ b/ci/dependency-profiles.json @@ -51,8 +51,8 @@ "repository": "https://github.com/FilOzone/synapse-sdk.git", "npm_package": "@filoz/synapse-sdk", "default": { - "strategy": "tag", - "tag": "synapse-sdk-v0.41.0" + "strategy": "commit", + "commit": "fadc836e65804311aca3bd2276861acabe42313f" }, "stability": { "strategy": "npm_latest_tag", diff --git a/scenarios/dependencies.py b/scenarios/dependencies.py index bfec9c6..c1a59b0 100644 --- a/scenarios/dependencies.py +++ b/scenarios/dependencies.py @@ -39,7 +39,13 @@ def component(name: str) -> dict: "repository": definition["repository"], "strategy": selection["strategy"], } - if selection["strategy"] == "tag": + if selection["strategy"] == "commit": + fallback.update( + source="git", + ref=selection["commit"], + commit=selection["commit"], + ) + elif selection["strategy"] == "tag": fallback.update(source="git", ref=selection["tag"]) elif selection["strategy"] == "npm_version": fallback.update( diff --git a/scripts/resolve-ci-dependencies.py b/scripts/resolve-ci-dependencies.py index 1054e54..d0a8f5f 100644 --- a/scripts/resolve-ci-dependencies.py +++ b/scripts/resolve-ci-dependencies.py @@ -20,6 +20,7 @@ "filecoin-services": "--filecoin-services", } STABLE_VERSION_RE = re.compile(r"^\d+(?:\.\d+)*$") +COMMIT_RE = re.compile(r"^[0-9a-f]{40}$") class ResolutionError(RuntimeError): @@ -158,6 +159,11 @@ def resolve_component( if strategy == "config_default": resolved["source"] = "config_default" + elif strategy == "commit": + commit = 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) elif strategy == "latest_stable_tag": output = runner( ["git", "ls-remote", "--tags", repository, selection["tag_pattern"]] diff --git a/scripts/tests/test_resolve_ci_dependencies.py b/scripts/tests/test_resolve_ci_dependencies.py index 597f181..fdab2a3 100644 --- a/scripts/tests/test_resolve_ci_dependencies.py +++ b/scripts/tests/test_resolve_ci_dependencies.py @@ -133,6 +133,31 @@ def test_frontier_branch_resolves_to_commit(self): resolved = resolver.resolve_component("project", component, "frontier", runner) self.assertEqual(resolved["commit"], "deadbeef") + def test_commit_strategy_uses_exact_sha_without_resolution(self): + commit = "fadc836e65804311aca3bd2276861acabe42313f" + component = { + "kind": "git_client", + "repository": "https://example.test/synapse.git", + "default": {"strategy": "commit", "commit": commit}, + } + resolved = resolver.resolve_component( + "synapse-sdk", component, "default", FakeRunner({}) + ) + self.assertEqual(resolved["ref_type"], "commit") + self.assertEqual(resolved["ref"], commit) + self.assertEqual(resolved["commit"], commit) + + def test_commit_strategy_rejects_non_sha(self): + component = { + "kind": "git_client", + "repository": "https://example.test/synapse.git", + "default": {"strategy": "commit", "commit": "master"}, + } + with self.assertRaisesRegex(resolver.ResolutionError, "invalid commit SHA"): + resolver.resolve_component( + "synapse-sdk", component, "default", FakeRunner({}) + ) + def test_init_args_skip_config_defaults_and_pin_other_sources(self): components = { "lotus": {"source": "config_default"},