diff --git a/.github/workflows/versioned-circular-dependency.yml b/.github/workflows/versioned-circular-dependency.yml new file mode 100644 index 00000000..354a9e61 --- /dev/null +++ b/.github/workflows/versioned-circular-dependency.yml @@ -0,0 +1,141 @@ +name: versioned circular dependency acceptance + +on: + pull_request: + paths: + - ".github/workflows/versioned-circular-dependency.yml" + - "scripts/versioned-circular-dependency.sh" + - "pins/den-3488.json" + - "docs/versioned-circular-dependency-canary.md" + push: + branches: + - main + - feat/den-3488-versioned-cycle-e2e + paths: + - ".github/workflows/versioned-circular-dependency.yml" + - "scripts/versioned-circular-dependency.sh" + - "pins/den-3488.json" + - "docs/versioned-circular-dependency-canary.md" + +permissions: + contents: read + +concurrency: + group: versioned-cycle-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash + +jobs: + preflight: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + outputs: + ready: ${{ steps.pin.outputs.ready }} + zed_cli_sha: ${{ steps.pin.outputs.zed_cli_sha }} + steps: + - name: Check out acceptance harness + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + fetch-depth: 1 + show-progress: false + - name: Validate shell and immutable source pin + id: pin + run: | + set -euo pipefail + bash -n scripts/versioned-circular-dependency.sh + python3 - <<'PY' >> "$GITHUB_OUTPUT" + import json + import re + + pin = json.load(open("pins/den-3488.json")) + sha = pin["zed-pkg/zed-cli"] + if re.fullmatch(r"[0-9a-f]{40}", sha) is None: + raise SystemExit("DEN-3488 zed-cli pin must be a full lowercase commit SHA") + print(f"ready={str(bool(pin['ready'])).lower()}") + print(f"zed_cli_sha={sha}") + PY + - name: Explain staged product validation + if: steps.pin.outputs.ready != 'true' + run: | + echo "The harness is valid; product execution remains staged until the tested repair SHA is pinned." + + exact-cycle: + needs: preflight + if: needs.preflight.outputs.ready == 'true' + name: public executable / ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, macos-15] + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: "1" + steps: + - name: Check out acceptance harness + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + path: harness + persist-credentials: false + fetch-depth: 1 + show-progress: false + - name: Check out exact zed-cli implementation + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: zed-pkg/zed-cli + ref: ${{ needs.preflight.outputs.zed_cli_sha }} + path: zed-cli + persist-credentials: false + fetch-depth: 1 + show-progress: false + - name: Verify immutable product checkout + run: | + set -euo pipefail + test "$(git -C zed-cli rev-parse HEAD)" = "${{ needs.preflight.outputs.zed_cli_sha }}" + - name: Install Rust toolchain + run: | + set -euo pipefail + rustup toolchain install stable --profile minimal + rustup default stable + rustc --version --verbose + cargo --version --verbose + - name: Build the exact release binary + run: | + set -euo pipefail + cargo build --locked --release \ + --manifest-path zed-cli/Cargo.toml \ + --bin zed + test -x zed-cli/target/release/zed + zed-cli/target/release/zed --version + - name: Run independent public-CLI cycle canary + run: | + set -o pipefail + bash harness/scripts/versioned-circular-dependency.sh \ + "$GITHUB_WORKSPACE/zed-cli/target/release/zed" \ + "$RUNNER_TEMP/den-3488-cycle" \ + 2>&1 | tee "$RUNNER_TEMP/den-3488-cycle.log" + - name: Re-run product executable regressions at the same SHA + run: | + cargo test --locked \ + --manifest-path zed-cli/Cargo.toml \ + --test versioned_dependency_cycles \ + -- --nocapture + - name: Upload bounded failure diagnostics + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: den-3488-cycle-${{ matrix.os }}-${{ github.run_id }}-${{ github.run_attempt }} + path: | + ${{ runner.temp }}/den-3488-cycle.log + ${{ runner.temp }}/den-3488-cycle/first.log + ${{ runner.temp }}/den-3488-cycle/second.log + ${{ runner.temp }}/den-3488-cycle/copy.stderr + ${{ runner.temp }}/den-3488-cycle/plan.json + if-no-files-found: warn + retention-days: 7 + include-hidden-files: true diff --git a/docs/versioned-circular-dependency-canary.md b/docs/versioned-circular-dependency-canary.md new file mode 100644 index 00000000..c45b4742 --- /dev/null +++ b/docs/versioned-circular-dependency-canary.md @@ -0,0 +1,46 @@ +# DEN-3488 exact-version circular dependency canary + +This acceptance canary checks the public `zed` executable against the exact +multiversion graph requested by DEN-3488: + +```text +A@1 -> B@1 -> A@2 -> B@0 -> A@2 +``` + +The CLI source is pinned to one immutable `zed-pkg/zed-cli` commit in +`pins/den-3488.json`. The workflow refuses a non-40-character pin and verifies +the checkout SHA before building. It never reads a token or writes to a remote +registry. + +## Black-box assertions + +`scripts/versioned-circular-dependency.sh` creates four independent payload +sources and invokes: + +```text +zed graph materialize --plan --project +``` + +The canary verifies that: + +- `A@1`, `A@2`, `B@1`, and `B@0` remain four distinct exact graph nodes; +- the terminal diagnostic names `A@2`, `B@0`, and the closing back-edge; +- only four canonical node directories are created; +- each dependency edge is a symlink to an existing canonical node; +- `B@0 -> A@2` closes the cycle by pointing to the first canonical `A@2` node; +- source payloads are linked rather than recursively copied; +- a second materialization reuses the same generation and digests; +- copy mode rejects the cycle before creating a module tree; and +- the entire check passes on both Linux and macOS. + +The black-box suite is separate from product unit tests. CI also reruns the +product's focused executable cycle test at the exact same commit so a mismatch +between the independent canary and the checked-in product regression is visible. + +## Current product boundary + +The exact graph materializer consumes a canonical resolved +`zpkg/dependency-graph/v1` document or a local source-binding plan. Automatic +conversion from ordinary `.zpkg.toml` constraints into this exact graph and the +corresponding lockfile-v2 integration remain follow-up work; this canary does +not claim that ordinary `zed install` has completed that migration. diff --git a/pins/den-3488.json b/pins/den-3488.json new file mode 100644 index 00000000..c827f6a1 --- /dev/null +++ b/pins/den-3488.json @@ -0,0 +1,6 @@ +{ + "schemaVersion": 1, + "ready": true, + "zed-pkg/zed-cli": "86d499aff19807cee3500777ee4a9e48ea646821", + "note": "Immutable product head for zed-pkg/zed-cli#315 after semantic integration with main through 5d0cbc0." +} diff --git a/scripts/versioned-circular-dependency.sh b/scripts/versioned-circular-dependency.sh new file mode 100644 index 00000000..7ec13726 --- /dev/null +++ b/scripts/versioned-circular-dependency.sh @@ -0,0 +1,220 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 2 ]]; then + echo "usage: $0 " >&2 + exit 64 +fi + +zed_bin=$(cd "$(dirname "$1")" && pwd -P)/$(basename "$1") +work_root=$2 + +if [[ ! -x "$zed_bin" ]]; then + echo "zed binary is not executable: $zed_bin" >&2 + exit 66 +fi +if [[ -e "$work_root" ]]; then + echo "work root must not already exist: $work_root" >&2 + exit 73 +fi + +mkdir -p "$work_root"/{sources,home,project} +for node in a-1 b-1 a-2 b-0; do + mkdir -p "$work_root/sources/$node" + printf '%s\n' "$node" >"$work_root/sources/$node/payload.txt" +done + +python3 - "$work_root" <<'PY' +import json +import pathlib +import sys + +root = pathlib.Path(sys.argv[1]).resolve() +registry = "fixture-registry" +org = "fixture" + + +def identity(name: str, version: str) -> dict[str, str]: + return { + "registry_id": registry, + "org": org, + "name": name, + "version": version, + } + + +def node(name: str, version: str, seed: str) -> dict[str, object]: + return { + "id": identity(name, version), + "artifact_digest": f"sha256:{seed * 64}", + "features": [], + } + + +def edge( + from_name: str, + from_version: str, + to_name: str, + to_version: str, +) -> dict[str, object]: + return { + "from": identity(from_name, from_version), + "to": identity(to_name, to_version), + "kind": "runtime", + "requirement": f"={to_version}", + "target": None, + "optional": False, + "features": [], + } + + +a1 = identity("a", "1") +b1 = identity("b", "1") +a2 = identity("a", "2") +b0 = identity("b", "0") +graph = { + "schema": "zpkg/dependency-graph/v1", + "view": "resolved", + "completeness": "complete", + "roots": [a1], + "nodes": [ + node("a", "1", "1"), + node("b", "1", "2"), + node("a", "2", "3"), + node("b", "0", "4"), + ], + "edges": [ + edge("a", "1", "b", "1"), + edge("b", "1", "a", "2"), + edge("a", "2", "b", "0"), + edge("b", "0", "a", "2"), + ], + "provenance": { + "resolver_version": "zed-pkg-test/den-3488", + "target": "host", + "enabled_features": [], + "registry_snapshots": [ + { + "registry_id": registry, + "checkpoint_digest": f"sha256:{'e' * 64}", + } + ], + "lock_digest": f"sha256:{'d' * 64}", + }, + "parent_graph_digest": None, + "projection": None, + "graph_digest": None, +} +plan = { + "schema": "zpkg/local-graph-materialization/v1", + "graph": graph, + "sources": [ + {"id": a1, "source": str(root / "sources" / "a-1")}, + {"id": b1, "source": str(root / "sources" / "b-1")}, + {"id": a2, "source": str(root / "sources" / "a-2")}, + {"id": b0, "source": str(root / "sources" / "b-0")}, + ], +} +(root / "plan.json").write_text(json.dumps(plan, indent=2) + "\n") +PY + +run_materialize() { + local output=$1 + "$zed_bin" \ + --home "$work_root/home" \ + graph materialize \ + --plan "$work_root/plan.json" \ + --project "$work_root/project" \ + 2>&1 | tee "$output" +} + +run_materialize "$work_root/first.log" + +grep -Fq 'circular dependency detected:' "$work_root/first.log" +grep -Fq 'fixture-registry::fixture/a@2' "$work_root/first.log" +grep -Fq 'fixture-registry::fixture/b@0' "$work_root/first.log" +grep -Fq 'reuses the existing exact node through a symlink' "$work_root/first.log" + +python3 - "$work_root" <<'PY' +import json +import pathlib +import sys + +root = pathlib.Path(sys.argv[1]).resolve() + + +def report(path: pathlib.Path) -> dict[str, object]: + candidates = [line for line in path.read_text().splitlines() if line.startswith("{")] + if not candidates: + raise SystemExit(f"no JSON report found in {path}") + return json.loads(candidates[-1]) + + +first = report(root / "first.log") +assert first["nodes"] == 4, first +assert first["edges"] == 4, first +assert len(first["cycles"]) == 1, first +assert first["reused_generation"] is False, first + +generation = pathlib.Path(first["generation_dir"]) +node_dirs = sorted((generation / "nodes").iterdir()) +assert len(node_dirs) == 4, node_dirs +for node_dir in node_dirs: + payload = node_dir / "root" / "payload.txt" + assert payload.is_symlink(), payload + +project_a1 = root / "project" / "zed_modules" / "fixture" / "a" +b1 = project_a1 / "zed_modules" / "fixture" / "b" +a2 = b1 / "zed_modules" / "fixture" / "a" +b0 = a2 / "zed_modules" / "fixture" / "b" +closing_a2 = b0 / "zed_modules" / "fixture" / "a" +for link in (project_a1, b1, a2, b0, closing_a2): + assert link.is_symlink(), link +assert closing_a2.readlink() == a2.resolve(), (closing_a2.readlink(), a2.resolve()) + +node_labels = { + json.loads((node_dir / "node.json").read_text())["id"]["name"] + + "@" + + json.loads((node_dir / "node.json").read_text())["id"]["version"] + for node_dir in node_dirs +} +assert node_labels == {"a@1", "b@1", "a@2", "b@0"}, node_labels + +(root / "first-report.json").write_text(json.dumps(first, indent=2) + "\n") +PY + +run_materialize "$work_root/second.log" + +python3 - "$work_root" <<'PY' +import json +import pathlib +import sys + +root = pathlib.Path(sys.argv[1]).resolve() +first = json.loads((root / "first-report.json").read_text()) +lines = [line for line in (root / "second.log").read_text().splitlines() if line.startswith("{")] +assert lines, "second run emitted no JSON report" +second = json.loads(lines[-1]) +assert second["reused_generation"] is True, second +assert second["generation_dir"] == first["generation_dir"], (first, second) +assert second["graph_digest"] == first["graph_digest"], (first, second) +assert second["materialization_digest"] == first["materialization_digest"], (first, second) +PY + +if "$zed_bin" \ + --home "$work_root/home" \ + graph materialize \ + --plan "$work_root/plan.json" \ + --project "$work_root/copy-project" \ + --mode copy \ + >"$work_root/copy.stdout" \ + 2>"$work_root/copy.stderr"; then + echo "copy mode unexpectedly accepted a circular exact-version graph" >&2 + exit 1 +fi +grep -Fq \ + 'copy mode cannot represent a circular exact-version dependency graph' \ + "$work_root/copy.stderr" + +test ! -e "$work_root/copy-project/zed_modules" +printf 'DEN-3488 black-box cycle canary passed: 4 exact nodes, 4 symlink edges, 1 finite back-edge\n'