From be477b6da18b6f395141b852e97692bf3df4c71b Mon Sep 17 00:00:00 2001 From: James Ross Date: Mon, 7 Sep 2026 10:52:50 -0700 Subject: [PATCH 1/7] docs: define registry-backed release closure Refs #866, #876. --- .github/RELEASE.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/.github/RELEASE.md b/.github/RELEASE.md index 6f1d81634..9587161eb 100644 --- a/.github/RELEASE.md +++ b/.github/RELEASE.md @@ -370,6 +370,40 @@ Stop publication, rotate or fix identity, then rerun from the same tag. ## Release Evidence +### Registry-backed closure contract + +Issue [#866](https://github.com/git-stunts/git-warp/issues/866) adds a +post-publication verification job. Until that implementation lands, perform +these checks manually and record their results in the release issue. + +The verifier must read the immutable tag and its reviewed source commit, +then independently observe the exact version in npm and JSR. It must check +npm `gitHead` against that commit and the publishing workflow commit, retain +both registries' integrity values, and record npm provenance availability, +the observed dist-tag, and the GitHub Release identity. + +Registry propagation has a finite retry budget and each external command +has a timeout. A visible version with conflicting identity is a failure, +not a reason to retry or publish again. Existing versions can be verified +again without changing tags, dist-tags, registry artifacts, or release notes. +An older immutable release may remain valid after its dist-tag advances; +the receipt must distinguish exact-version visibility from current dist-tag +ownership. + +Consumer verification installs the exact public npm version into a fresh +directory outside the checkout. It proves the supported root import, the +private-storage export firewall, installed CLI startup, and the resolved +git-cas and Plumbing versions. Registry signature and attestation checking +must run where npm supports it; a failed check must never become an +unqualified success. + +The job leaves a small JSON closure receipt and uploads it even on failure. +Only a receipt whose required checks all succeeded may report `verified`. +Tests must exercise delayed visibility, exhausted retries, identity and +integrity mismatch, consumer failure, signature failure, and safe reruns. +A read-only rehearsal against an existing public release precedes use in a +new publication workflow. + Record release evidence in the release tracking issue or retrospective: - tag name and commit SHA; From b9f63a26488afa378e5581ed709b23e7ccb9d47d Mon Sep 17 00:00:00 2001 From: James Ross Date: Mon, 7 Sep 2026 10:53:10 -0700 Subject: [PATCH 2/7] fix: update vulnerable humanfs development dependency Update @humanfs/node to 0.16.8 and its required core/types packages for GHSA-p498-v437-472g. The locked audit reports zero vulnerabilities. Refs #876. --- package-lock.json | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index d9690e1d8..8f9b87618 100644 --- a/package-lock.json +++ b/package-lock.json @@ -734,29 +734,43 @@ } }, "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, "engines": { "node": ">=18.18.0" } }, "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@humanfs/core": "^0.19.1", + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", From b9d50b18cbfd44f779db4b06383e5110b293a453 Mon Sep 17 00:00:00 2001 From: James Ross Date: Mon, 7 Sep 2026 11:32:59 -0700 Subject: [PATCH 3/7] feat: verify published release identity and usability --- .github/RELEASE.md | 77 ++++++-- .github/workflows/release.yml | 51 +++++ scripts/release-closure/calibrate.sh | 131 +++++++++++++ scripts/release-closure/consumer.sh | 71 +++++++ scripts/verify-published-release.sh | 156 +++++++++++++++ test/bats/fixtures/release-closure-command.sh | 97 ++++++++++ test/bats/release-closure.bats | 183 ++++++++++++++++++ 7 files changed, 753 insertions(+), 13 deletions(-) create mode 100644 scripts/release-closure/calibrate.sh create mode 100644 scripts/release-closure/consumer.sh create mode 100644 scripts/verify-published-release.sh create mode 100644 test/bats/fixtures/release-closure-command.sh create mode 100644 test/bats/release-closure.bats diff --git a/.github/RELEASE.md b/.github/RELEASE.md index 9587161eb..c6ec17b69 100644 --- a/.github/RELEASE.md +++ b/.github/RELEASE.md @@ -372,14 +372,15 @@ Stop publication, rotate or fix identity, then rerun from the same tag. ### Registry-backed closure contract -Issue [#866](https://github.com/git-stunts/git-warp/issues/866) adds a -post-publication verification job. Until that implementation lands, perform -these checks manually and record their results in the release issue. - -The verifier must read the immutable tag and its reviewed source commit, -then independently observe the exact version in npm and JSR. It must check -npm `gitHead` against that commit and the publishing workflow commit, retain -both registries' integrity values, and record npm provenance availability, +The `Verify public registry closure` job implements +[#866](https://github.com/git-stunts/git-warp/issues/866) after the registry +publish and GitHub Release jobs. It runs even when a publication job failed, +so a partial publication cannot finish with an unqualified green workflow. + +The verifier reads the immutable tag and its reviewed source commit, +then independently observes the exact version in npm and JSR. It checks +npm `gitHead` against that commit and the publishing workflow commit, retains +both registries' integrity values, and records npm provenance availability, the observed dist-tag, and the GitHub Release identity. Registry propagation has a finite retry budget and each external command @@ -398,11 +399,61 @@ must run where npm supports it; a failed check must never become an unqualified success. The job leaves a small JSON closure receipt and uploads it even on failure. -Only a receipt whose required checks all succeeded may report `verified`. -Tests must exercise delayed visibility, exhausted retries, identity and -integrity mismatch, consumer failure, signature failure, and safe reruns. -A read-only rehearsal against an existing public release precedes use in a -new publication workflow. +Only a receipt whose required checks all succeeded reports `verified`. +The JSR archive is downloaded and hashed against its advertised SHA-512 +integrity; the npm installation's lockfile must match npm's advertised +integrity. npm verifies registry signatures and provenance attestations. + +The same read-only check can rehearse or re-verify an existing release: + +```bash +bash scripts/verify-published-release.sh \ + --tag v19.1.0 \ + --commit 20a9b7f76d07195de0782f0852102434bdd200d3 \ + --run-id 32825544033 \ + --dist-tag latest \ + --output /tmp/git-warp-release-closure.json +``` + +It requires Bash, Node/npm with `npm audit signatures` support, Git, `gh`, +`jq`, GNU `timeout`, `curl`, and OpenSSL. GitHub access is read-only. The +checkout must contain the immutable tag; installation occurs outside it. +Add `--require-dist-tag` when verifying a new publication. Historical reruns +record the current dist-tag owner without claiming that the old version is +still latest. + +The default registry budget is six attempts with ten-second delays and +180-second command timeouts. Diagnostic overrides are +`GIT_WARP_CLOSURE_ATTEMPTS` (1–10), +`GIT_WARP_CLOSURE_DELAY_SECONDS` (0–30), and +`GIT_WARP_CLOSURE_COMMAND_TIMEOUT_SECONDS` (1–180). Consumer install, CLI, and +signature commands retain separate 180-second limits; the workflow has a +15-minute ceiling. Run the adversarial contract suite with +`bats test/bats/release-closure.bats`. + +The contract suite is medium-sized: it owns scratch state and controls GitHub +and registry command responses while executing real Node imports, CLI processes, +hashing, and OS timeouts. Its specified oracle is this release contract. The +command fixtures reject unexpected operations; they do not establish npm or +GitHub conformance. A real public-registry rehearsal supplies separate integration +evidence. That deliberately non-hermetic verification controls versions, source +identity, integrity, and a finite propagation budget; it cannot make registry +availability deterministic. + +At assertion authoring or material change, run: + +```bash +bash scripts/release-closure/calibrate.sh /tmp/git-warp-release-calibration.json +``` + +Calibration first requires the ordinary suite to pass. It then applies named +contract violations to disposable copies and requires the corresponding test +to fail at its stated assertion. Setup failures, missing selected tests, syntax +errors, and outer watchdog expiries do not count. The JSON receipt retains each +named experiment and failure output. This is selective falsification evidence, +not a mutation score or a proof that all possible bugs are detected. The command +deadline test additionally distinguishes the verifier's own timeout from its +independent outer watchdog. Record release evidence in the release tracking issue or retrospective: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1a7c18829..42eeb28fa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -439,3 +439,54 @@ jobs: run: | echo "Both npm and JSR publish failed." exit 1 + + verify_registry_closure: + name: Verify public registry closure + runs-on: ubuntu-latest + timeout-minutes: 15 + needs: [verify, publish_npm, publish_jsr, github_release] + if: ${{ always() && needs.verify.result == 'success' }} + permissions: + actions: read + contents: read + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ needs.verify.outputs.tag }} + fetch-depth: 0 + persist-credentials: false + + - name: Setup Node 24 + uses: actions/setup-node@v6 + with: + node-version: '24' + package-manager-cache: false + + - name: Verify tagged public artifacts and a fresh consumer + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ needs.verify.outputs.tag }} + RELEASE_COMMIT: ${{ github.sha }} + RELEASE_RUN_ID: ${{ github.run_id }} + RELEASE_DIST_TAG: ${{ needs.verify.outputs.npm_dist_tag }} + NPM_PUBLISH_STATUS: ${{ needs.publish_npm.outputs.status }} + CLOSURE_RECEIPT: ${{ runner.temp }}/git-warp-release-closure.json + shell: bash + run: | + set -euo pipefail + args=(--tag "$RELEASE_TAG" --commit "$RELEASE_COMMIT" + --run-id "$RELEASE_RUN_ID" --dist-tag "$RELEASE_DIST_TAG" + --output "$CLOSURE_RECEIPT") + if [ "$NPM_PUBLISH_STATUS" = published ]; then + args+=(--require-dist-tag) + fi + bash scripts/verify-published-release.sh "${args[@]}" + + - name: Retain release closure receipt + if: ${{ always() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: release-closure-${{ needs.verify.outputs.tag }} + path: ${{ runner.temp }}/git-warp-release-closure.json + if-no-files-found: error + retention-days: 90 diff --git a/scripts/release-closure/calibrate.sh b/scripts/release-closure/calibrate.sh new file mode 100644 index 000000000..c13419f3e --- /dev/null +++ b/scripts/release-closure/calibrate.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# Calibrate release assertions against named contract violations in owned copies. +# Mutation anchors are literal program text; expanding them would execute the +# subject while constructing the experiment, so SC2016 does not apply here. +# shellcheck disable=SC2016 +set -euo pipefail + +ROOT=$(cd "$(dirname "$0")/../.." && pwd) +OUTPUT="${1:?usage: calibrate.sh }" +mkdir -p "$(dirname "$OUTPUT")" +OUTPUT=$(cd "$(dirname "$OUTPUT")" && pwd)/$(basename "$OUTPUT") +WORK=$(mktemp -d "${TMPDIR:-/tmp}/git-warp-closure-calibration.XXXXXX") +trap 'rm -rf "$WORK"' EXIT +mkdir -p "$WORK/tree/scripts/release-closure" "$WORK/tree/test/bats/fixtures" +cp "$ROOT/test/bats/release-closure.bats" "$WORK/tree/test/bats/" +cp "$ROOT/test/bats/fixtures/release-closure-command.sh" "$WORK/tree/test/bats/fixtures/" +export LC_ALL=C TZ=UTC +RESULTS="$WORK/results.jsonl" +: > "$RESULTS" +INPUT_DIGEST=$(cat "$ROOT/scripts/verify-published-release.sh" \ + "$ROOT/scripts/release-closure/consumer.sh" "$ROOT/scripts/release-closure/calibrate.sh" \ + "$ROOT/test/bats/release-closure.bats" "$ROOT/test/bats/fixtures/release-closure-command.sh" | + openssl dgst -sha256 -binary | openssl base64 -A) +NODE_VERSION=$(node --version) +BATS_VERSION=$(bats --version) + +reset_subject() { + cp "$ROOT/scripts/verify-published-release.sh" "$WORK/tree/scripts/" + cp "$ROOT/scripts/release-closure/consumer.sh" "$WORK/tree/scripts/release-closure/" +} + +replace_once() { + local path="$WORK/tree/$1" before="$2" after="$3" + jq -Rsj --arg before "$before" --arg after "$after" ' + split($before)|if length==2 then .[0]+$after+.[1] + else error("mutation anchor must occur exactly once") end + ' "$path" > "$WORK/replacement" + mv "$WORK/replacement" "$path" +} + +record() { + # Logs must be shareable: scratch and checkout paths carry no release evidence. + jq -n --arg mutation "$1" --arg test "$2" --arg verdict "$3" \ + --arg root "$ROOT" --arg work "$WORK" --rawfile log "$WORK/run.log" ' + {mutation:$mutation,test:$test,verdict:$verdict, + output:($log|split($root)|join("")|split($work)|join(""))} + ' >> "$RESULTS" + jq -s --arg revision "$(git -C "$ROOT" rev-parse HEAD)" \ + --arg inputs "$INPUT_DIGEST" --arg bash "$BASH_VERSION" \ + --arg node "$NODE_VERSION" --arg bats "$BATS_VERSION" ' + {schema:"git-warp/release-calibration@1",revision:$revision,inputDigest:$inputs, + tools:{bash:$bash,node:$node,bats:$bats}, + results:.,status:(if all(.[];.verdict=="killed" or .verdict=="baseline-passed") + then "incomplete" else "failed" end)}' "$RESULTS" > "$OUTPUT" +} + +reset_subject +if timeout --kill-after=5s 180s bats --formatter tap "$WORK/tree/test/bats/release-closure.bats" > "$WORK/run.log" 2>&1; then + record baseline all baseline-passed +else + record baseline all failed + cat "$WORK/run.log" + exit 1 +fi + +calibrate() { + local name="$1" test="$2" assertion="$3" path="$4" before="$5" after="$6" code=0 + reset_subject + replace_once "$path" "$before" "$after" + bash -n "$WORK/tree/scripts/verify-published-release.sh" + bash -n "$WORK/tree/scripts/release-closure/consumer.sh" + timeout --kill-after=5s 30s bats --formatter tap --filter "^$test$" \ + "$WORK/tree/test/bats/release-closure.bats" > "$WORK/run.log" 2>&1 || code=$? + # Exit 1 alone could be a setup crash or no selected test. Require the exact + # test and failed assertion, excluding harness failures and external timeouts. + if [ "$code" -eq 1 ] && rg -Fx "not ok 1 $test" "$WORK/run.log" >/dev/null && + rg -F "$assertion" "$WORK/run.log" >/dev/null; then + record "$name" "$test" killed + echo "killed: $name ($test)" + else + record "$name" "$test" uncalibrated + cat "$WORK/run.log" + echo "calibration failed: $name (BATS exit $code; expected assertion failure exit 1)" >&2 + exit 1 + fi +} + +DRIVER=scripts/verify-published-release.sh +CONSUMER=scripts/release-closure/consumer.sh +NONZERO='assertion: invalid release must exit nonzero' +SUCCESS='[ "$status" -eq 0 ]' +calibrate receipt-claim 'release closure proves public identity and an independent consumer' \ + 'jq -e' "$CONSUMER" 'registrySignatures:"verified"' 'registrySignatures:"unverified"' +calibrate propagation-budget 'release closure retries delayed visibility in both registries' \ + "$SUCCESS" "$DRIVER" 'attempt=$((attempt + 1))' 'attempt=$((attempt + 2))' +calibrate finite-budget 'release closure exhausts a finite visibility budget' \ + 'npm-attempts' "$DRIVER" 'local attempt=1' 'local attempt=0' +calibrate command-deadline 'release closure terminates a stalled external command' \ + '[ "$status" -ne 124 ]' "$DRIVER" \ + 'bounded() { timeout --kill-after=5s "${COMMAND_TIMEOUT}s" "$@"; }' 'bounded() { "$@"; }' +calibrate public-tag 'release closure rejects a changed public tag' \ + "$NONZERO" "$DRIVER" '[ "$(cat "$WORK/remote-commit")" = "$EXPECTED_COMMIT" ]' ':' +calibrate publishing-run 'release closure rejects a publishing run from a different commit' \ + "$NONZERO" "$DRIVER" '.head_sha==$commit and .repository' 'true and .repository' +calibrate failed-receipt 'release closure retains a failed receipt after malformed GitHub transport' \ + 'assertion: failed release must retain a valid failed receipt' "$DRIVER" '> "$WORK/github-pending.json"' '> "$2"' +calibrate npm-identity 'release closure rejects conflicting npm identity without retrying' \ + "$NONZERO" "$DRIVER" "'.gitHead==\$commit'" "'true'" +calibrate provenance 'release closure rejects absent provenance' \ + "$NONZERO" "$DRIVER" ".dist.attestations.provenance.predicateType==\"https://slsa.dev/provenance/v1\"" 'true' +calibrate jsr-integrity 'release closure hashes the JSR archive against registry integrity' \ + "$NONZERO" "$DRIVER" '[ "$JSR_INTEGRITY" = "$(jq -r .dist.integrity "$WORK/jsr.json")" ]' ':' +calibrate npm-integrity 'release closure rejects npm consumer integrity mismatch' \ + "$NONZERO" "$CONSUMER" '.integrity==$integrity' 'true' +calibrate installation-failure 'release closure rejects consumer installation failure' \ + 'jq -r .consumer.stage' "$CONSUMER" '> "$WORK/install.log" 2>&1' '> "$WORK/install.log" 2>&1 || true' +calibrate root-export 'release closure executes the public import rather than checking metadata' \ + "$NONZERO" "$CONSUMER" "if (names.length !== 1 || names[0] !== 'Runtime' || typeof values.Runtime !== 'function')" 'if (false)' +calibrate private-export 'release closure rejects a newly exposed private storage import' \ + "$NONZERO" "$CONSUMER" 'if (!denied) throw' 'if (false) throw' +calibrate cli-execution 'release closure executes the installed CLI' \ + "$NONZERO" "$CONSUMER" '> "$WORK/cli.log" 2>&1' '> "$WORK/cli.log" 2>&1 || true' +calibrate signature-verification 'release closure rejects failed signature or attestation verification' \ + "$NONZERO" "$CONSUMER" '> "$WORK/signatures.log" 2>&1' '> "$WORK/signatures.log" 2>&1 || true' +calibrate dist-tag-ownership 'new publication must own its intended dist-tag' \ + "$NONZERO" "$DRIVER" '[ "$REQUIRE_DIST_TAG" -eq 1 ]' '[ "$REQUIRE_DIST_TAG" -eq 2 ]' +calibrate historical-dist-tag 'immutable historical verification records an advanced dist-tag honestly' \ + 'jq -e' "$DRIVER" 'ownsTag:($owner[0]==$version)' 'ownsTag:true' +jq '.status="verified"' "$OUTPUT" > "$WORK/complete.json" +mv "$WORK/complete.json" "$OUTPUT" +echo 'release calibration: all named violations detected at their target assertions' diff --git a/scripts/release-closure/consumer.sh b/scripts/release-closure/consumer.sh new file mode 100644 index 000000000..753b0542f --- /dev/null +++ b/scripts/release-closure/consumer.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# Install only public registry packages in a disposable, independent consumer. +set -euo pipefail + +WORK="$1" +PACKAGE="$2" +VERSION="$3" +CONSUMER_STATUS="failed" +CONSUMER_STAGE="install" +consumer_failure() { + local code=$? + if [ "$CONSUMER_STATUS" != "verified" ]; then + jq -n --arg stage "$CONSUMER_STAGE" '{status:"failed",stage:$stage}' > "$WORK/consumer.json" + echo "release consumer failed: $CONSUMER_STAGE" >&2 + fi + exit "$code" +} +trap consumer_failure EXIT +mkdir "$WORK/consumer" +cd "$WORK/consumer" +printf '{"name":"git-warp-release-consumer","version":"1.0.0","private":true}\n' > package.json +bounded() { timeout --kill-after=5s 180s "$@"; } +bounded npm install --save-exact --ignore-scripts --no-audit --no-fund \ + --registry=https://registry.npmjs.org --fetch-retries=0 --fetch-timeout=15000 \ + "$PACKAGE@$VERSION" > "$WORK/install.log" 2>&1 + +CONSUMER_STAGE="integrity" +jq -e --arg version "$VERSION" --arg integrity "$(jq -r .dist.integrity "$WORK/npm.json")" \ + '.packages["node_modules/@git-stunts/git-warp"]| + .version==$version and .integrity==$integrity and + (.resolved|startswith("https://registry.npmjs.org/")) and (.link!=true)' \ + package-lock.json >/dev/null + +CONSUMER_STAGE="imports" +bounded node --input-type=module <<'NODE' +class ReleaseConsumerError extends Error {} +const values = await import('@git-stunts/git-warp'); +const names = Object.keys(values); +if (names.length !== 1 || names[0] !== 'Runtime' || typeof values.Runtime !== 'function') { + throw new ReleaseConsumerError('public root must export exactly Runtime'); +} +let denied = false; +try { + await import('@git-stunts/git-warp/storage'); +} catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ERR_PACKAGE_PATH_NOT_EXPORTED') { + denied = true; + } else { + throw error; + } +} +if (!denied) throw new ReleaseConsumerError('private storage import was admitted'); +NODE + +CONSUMER_STAGE="cli" +test -x node_modules/.bin/git-warp +bounded node_modules/.bin/git-warp --help > "$WORK/cli.log" 2>&1 +CONSUMER_STAGE="signatures" +bounded npm audit signatures --registry=https://registry.npmjs.org \ + --fetch-retries=0 --fetch-timeout=15000 > "$WORK/signatures.log" 2>&1 + +CONSUMER_STAGE="dependencies" +jq -e '.packages|to_entries|map(select( + .key|test("node_modules/@git-stunts/(git-cas|plumbing)$")))| + map({package:(.key|capture("(?@git-stunts/(git-cas|plumbing))$").name), + version:.value.version})|unique|sort_by(.package,.version)| + select(any(.[];.package=="@git-stunts/git-cas") and any(.[];.package=="@git-stunts/plumbing"))| + {status:"verified",rootImport:"passed",privateStorageFirewall:"passed", + cli:"passed",registrySignatures:"verified",dependencies:.}' package-lock.json > "$WORK/consumer-pending.json" +mv "$WORK/consumer-pending.json" "$WORK/consumer.json" +CONSUMER_STATUS="verified" diff --git a/scripts/verify-published-release.sh b/scripts/verify-published-release.sh new file mode 100644 index 000000000..5f80b2d6c --- /dev/null +++ b/scripts/verify-published-release.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +# Verify public artifacts without publishing or changing a public identity. +set -euo pipefail + +ROOT=$(cd "$(dirname "$0")/.." && pwd) +REPO="git-stunts/git-warp" +TAG="" +EXPECTED_COMMIT="" +RUN_ID="" +DIST_TAG="latest" +OUTPUT="" +REQUIRE_DIST_TAG=0 +ATTEMPTS="${GIT_WARP_CLOSURE_ATTEMPTS:-6}" +DELAY="${GIT_WARP_CLOSURE_DELAY_SECONDS:-10}" +COMMAND_TIMEOUT="${GIT_WARP_CLOSURE_COMMAND_TIMEOUT_SECONDS:-180}" + +while [ "$#" -gt 0 ]; do + case "$1" in + --require-dist-tag) REQUIRE_DIST_TAG=1; shift; continue ;; + --tag | --commit | --run-id | --dist-tag | --output) + [ "$#" -ge 2 ] || { echo "missing value for $1" >&2; exit 2; } + case "$1" in + --tag) TAG="$2" ;; + --commit) EXPECTED_COMMIT="$2" ;; + --run-id) RUN_ID="$2" ;; + --dist-tag) DIST_TAG="$2" ;; + --output) OUTPUT="$2" ;; + esac + shift 2 ;; + *) echo "unexpected argument: $1" >&2; exit 2 ;; + esac +done + +[[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta|rc)\.[0-9]+)?$ ]] || exit 2 +[[ "$EXPECTED_COMMIT" =~ ^[0-9a-f]{40}$ ]] || exit 2 +[[ "$RUN_ID" =~ ^[1-9][0-9]*$ ]] || exit 2 +[[ "$DIST_TAG" =~ ^(latest|alpha|beta|next)$ ]] || exit 2 +[[ "$ATTEMPTS" =~ ^[1-9][0-9]*$ ]] && [ "$ATTEMPTS" -le 10 ] || exit 2 +[[ "$DELAY" =~ ^[0-9]+$ ]] && [ "$DELAY" -le 30 ] || exit 2 +[[ "$COMMAND_TIMEOUT" =~ ^[1-9][0-9]*$ ]] && [ "$COMMAND_TIMEOUT" -le 180 ] || exit 2 +[ -n "$OUTPUT" ] || exit 2 +for tool in jq timeout git gh npm curl openssl node; do command -v "$tool" >/dev/null; done + +VERSION="${TAG#v}" +WORK=$(mktemp -d "${TMPDIR:-/tmp}/git-warp-release-closure.XXXXXX") +mkdir -p "$(dirname "$OUTPUT")" +OUTPUT=$(cd "$(dirname "$OUTPUT")" && pwd)/$(basename "$OUTPUT") +STAGE="source" +STATUS="failed" +for document in npm jsr run release consumer; do printf '{}\n' > "$WORK/$document.json"; done +printf 'null\n' > "$WORK/dist-tag.json" + +finish() { + local code=$? + trap - EXIT + jq -n --arg status "$STATUS" --arg stage "$STAGE" --arg tag "$TAG" \ + --arg version "$VERSION" --arg commit "$EXPECTED_COMMIT" --arg distTag "$DIST_TAG" \ + --argjson requireDistTag "$REQUIRE_DIST_TAG" \ + --slurpfile npm "$WORK/npm.json" --slurpfile jsr "$WORK/jsr.json" \ + --slurpfile run "$WORK/run.json" --slurpfile release "$WORK/release.json" \ + --slurpfile consumer "$WORK/consumer.json" --slurpfile owner "$WORK/dist-tag.json" \ + '{schema:"git-warp/release-closure@1",status:$status,stage:$stage,tag:$tag, + version:$version,sourceCommit:$commit, + publishRun:($run[0]|{id,head_sha,html_url,path}), + githubRelease:($release[0]|{id,tag_name,html_url}), + npm:($npm[0]|{name,version,gitHead,integrity:.dist.integrity, + provenance:.dist.attestations.provenance,signatures:.dist.signatures}), + jsr:($jsr[0]|{name,version,integrity:.dist.integrity}), + distTag:{name:$distTag,observedVersion:$owner[0], + ownsTag:($owner[0]==$version),ownershipRequired:($requireDistTag==1)}, + consumer:$consumer[0]}' > "$OUTPUT" + rm -rf "$WORK" + echo "release closure: $STATUS ($STAGE)" + exit "$code" +} +trap finish EXIT + +fail() { echo "release closure failed: $1" >&2; exit 1; } +bounded() { timeout --kill-after=5s "${COMMAND_TIMEOUT}s" "$@"; } + +github_metadata() { + bounded gh api "repos/$REPO/$1" > "$WORK/github-pending.json" + jq -e 'type=="object"' "$WORK/github-pending.json" >/dev/null + mv "$WORK/github-pending.json" "$2" +} + +registry_metadata() { + local package="$1" registry="$2" destination="$3" + local attempt=1 + while [ "$attempt" -le "$ATTEMPTS" ]; do + if bounded npm view "$package@$VERSION" name version gitHead dist --json \ + --registry="$registry" --fetch-retries=0 --fetch-timeout=15000 > "$WORK/pending.json" 2> "$WORK/registry-error.log"; then + jq -e --arg package "$package" --arg version "$VERSION" ' + type=="object" and .name==$package and .version==$version and + (.dist.integrity|type=="string" and startswith("sha512-")) and + (.dist.tarball|type=="string" and startswith("https://")) + ' "$WORK/pending.json" >/dev/null || fail "invalid registry identity or integrity" + mv "$WORK/pending.json" "$destination" + return + fi + [ "$attempt" -lt "$ATTEMPTS" ] || fail "registry visibility exhausted $ATTEMPTS attempts" + echo "registry visibility pending: attempt $attempt/$ATTEMPTS" + sleep "$DELAY" + attempt=$((attempt + 1)) + done +} + +LOCAL_COMMIT=$(git -C "$ROOT" rev-parse "$TAG^{commit}") +[ "$LOCAL_COMMIT" = "$EXPECTED_COMMIT" ] || fail "local tag commit mismatch" +bounded gh api "repos/$REPO/commits/$TAG" --jq .sha > "$WORK/remote-commit" +[ "$(cat "$WORK/remote-commit")" = "$EXPECTED_COMMIT" ] || fail "public tag commit mismatch" +git -C "$ROOT" show "$TAG:package.json" > "$WORK/package.json" +git -C "$ROOT" show "$TAG:jsr.json" > "$WORK/jsr-package.json" +PACKAGE=$(jq -er --arg version "$VERSION" 'select(.version==$version)|.name' "$WORK/package.json") +JSR_NAME=$(jq -er --arg version "$VERSION" 'select(.version==$version)|.name' "$WORK/jsr-package.json") +JSR_SCOPE="${JSR_NAME%%/*}" +JSR_LEAF="${JSR_NAME#*/}" +JSR_PACKAGE="@jsr/${JSR_SCOPE#@}__${JSR_LEAF}" + +github_metadata "actions/runs/$RUN_ID" "$WORK/run.json" +jq -e --arg commit "$EXPECTED_COMMIT" --arg repo "$REPO" \ + '.head_sha==$commit and .repository.full_name==$repo and .path==".github/workflows/release.yml"' \ + "$WORK/run.json" >/dev/null || fail "publishing workflow identity mismatch" +github_metadata "releases/tags/$TAG" "$WORK/release.json" +jq -e --arg tag "$TAG" '.tag_name==$tag and .draft==false and (.id|type=="number")' \ + "$WORK/release.json" >/dev/null || fail "GitHub Release identity mismatch" + +STAGE="registry" +registry_metadata "$PACKAGE" https://registry.npmjs.org "$WORK/npm.json" +jq -e --arg commit "$EXPECTED_COMMIT" '.gitHead==$commit' "$WORK/npm.json" >/dev/null \ + || fail "npm gitHead mismatch" +jq -e '.dist.attestations.provenance.predicateType=="https://slsa.dev/provenance/v1"' \ + "$WORK/npm.json" >/dev/null || fail "npm provenance is unavailable" +registry_metadata "$JSR_PACKAGE" https://npm.jsr.io "$WORK/jsr.json" +bounded npm view "$PACKAGE" dist-tags --json --registry=https://registry.npmjs.org \ + --fetch-retries=0 --fetch-timeout=15000 > "$WORK/dist-tags.json" +jq -e 'type=="object"' "$WORK/dist-tags.json" >/dev/null +jq --arg name "$DIST_TAG" '(.[$name] // null)| + if .==null or type=="string" then . else error("invalid dist-tag value") end' \ + "$WORK/dist-tags.json" > "$WORK/dist-tag.json" +if [ "$REQUIRE_DIST_TAG" -eq 1 ]; then + jq -e --arg version "$VERSION" '.==$version' "$WORK/dist-tag.json" >/dev/null \ + || fail "new publication does not own its intended dist-tag" +fi + +STAGE="jsr-integrity" +JSR_TARBALL=$(jq -er '.dist.tarball|select(startswith("https://npm.jsr.io/"))' "$WORK/jsr.json") +bounded curl --fail --silent --show-error --location --proto '=https' --proto-redir '=https' \ + --connect-timeout 10 --max-time 90 "$JSR_TARBALL" -o "$WORK/jsr.tgz" +JSR_INTEGRITY="sha512-$(openssl dgst -sha512 -binary "$WORK/jsr.tgz" | openssl base64 -A)" +[ "$JSR_INTEGRITY" = "$(jq -r .dist.integrity "$WORK/jsr.json")" ] || fail "JSR tarball integrity mismatch" + +STAGE="consumer" +bash "$ROOT/scripts/release-closure/consumer.sh" "$WORK" "$PACKAGE" "$VERSION" +STAGE="complete" +STATUS="verified" diff --git a/test/bats/fixtures/release-closure-command.sh b/test/bats/fixtures/release-closure-command.sh new file mode 100644 index 000000000..caca3bc19 --- /dev/null +++ b/test/bats/fixtures/release-closure-command.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# Deterministic public-boundary fixture. Unexpected or mutating commands fail. +set -euo pipefail +tool=$(basename "$0") +printf '%s %s\n' "$tool" "$*" >> "$CLOSURE_FIXTURE_DIR/commands" +case "$tool" in + git) + [ "$1" = -C ] && shift 2 + case "$1" in + rev-parse) printf '%s\n' "$CLOSURE_FIXTURE_COMMIT" ;; + show) printf '{"name":"@git-stunts/git-warp","version":"19.1.0"}\n' ;; + *) exit 77 ;; + esac ;; + gh) + [ "$1" = api ] || exit 77 + case "$2" in + */commits/*) + if [ "$CLOSURE_FIXTURE_MODE" = wrong-tag ]; then echo bad; else echo "$CLOSURE_FIXTURE_COMMIT"; fi ;; + */actions/runs/*) + commit="$CLOSURE_FIXTURE_COMMIT" + [ "$CLOSURE_FIXTURE_MODE" != wrong-run ] || commit=bad + if [ "$CLOSURE_FIXTURE_MODE" = malformed-github ]; then echo '{'; exit 1; fi + jq -n --arg commit "$commit" '{id:123,head_sha:$commit, + path:".github/workflows/release.yml",repository:{full_name:"git-stunts/git-warp"}, + html_url:"https://github.com/git-stunts/git-warp/actions/runs/123"}' ;; + */releases/tags/*) + jq -n '{id:456,tag_name:"v19.1.0",draft:false, + html_url:"https://github.com/git-stunts/git-warp/releases/tag/v19.1.0"}' ;; + *) exit 77 ;; + esac ;; + npm) + case "$1" in + view) + if [ "$3" = dist-tags ]; then + if [ "$CLOSURE_FIXTURE_MODE" = advanced-tag ]; then + echo '{"latest":"19.2.0"}' + else echo '{"latest":"19.1.0"}'; fi + exit + fi + registry=npm + [[ "$2" != @jsr/* ]] || registry=jsr + attempts=0 + [ ! -f "$CLOSURE_FIXTURE_DIR/$registry-attempts" ] || attempts=$(cat "$CLOSURE_FIXTURE_DIR/$registry-attempts") + attempts=$((attempts + 1)) + echo "$attempts" > "$CLOSURE_FIXTURE_DIR/$registry-attempts" + [ "$CLOSURE_FIXTURE_MODE" != unavailable ] || exit 1 + [ "$CLOSURE_FIXTURE_MODE" != hang ] || exec sleep 30 + if [ "$CLOSURE_FIXTURE_MODE" = delayed ] && [ "$attempts" -lt 3 ]; then exit 1; fi + name="@git-stunts/git-warp" + [ "$registry" != jsr ] || name="@jsr/git-stunts__git-warp" + commit="$CLOSURE_FIXTURE_COMMIT" + [ "$CLOSURE_FIXTURE_MODE" != wrong-npm ] || commit=bad + integrity="$CLOSURE_FIXTURE_INTEGRITY" + if [ "$CLOSURE_FIXTURE_MODE" = corrupt-jsr ] && [ "$registry" = jsr ]; then integrity=sha512-bad; fi + jq -n --arg name "$name" --arg commit "$commit" --arg integrity "$integrity" \ + --arg registry "$registry" --arg mode "$CLOSURE_FIXTURE_MODE" ' + {name:$name,version:"19.1.0",gitHead:$commit,dist:{integrity:$integrity, + tarball:(if $registry=="jsr" then "https://npm.jsr.io/fixture.tgz" else "https://registry.npmjs.org/fixture.tgz" end), + attestations:{provenance:{predicateType:"https://slsa.dev/provenance/v1"}},signatures:[]}}| + if $mode=="no-provenance" then del(.dist.attestations) else . end' ;; + install) + [ "$CLOSURE_FIXTURE_MODE" != install-failed ] || exit 1 + case "$PWD" in "$CLOSURE_FIXTURE_DIR"/*/consumer) ;; *) exit 77 ;; esac + mkdir -p node_modules/@git-stunts/git-warp node_modules/.bin + exports='{".":"./index.js"}' + [ "$CLOSURE_FIXTURE_MODE" != private-leak ] || exports='{".":"./index.js","./storage":"./index.js"}' + jq -n --argjson exports "$exports" '{name:"@git-stunts/git-warp",version:"19.1.0",type:"module",exports:$exports}' \ + > node_modules/@git-stunts/git-warp/package.json + if [ "$CLOSURE_FIXTURE_MODE" = broken-import ]; then + echo 'export const Wrong = true;' > node_modules/@git-stunts/git-warp/index.js + else echo 'export class Runtime {}' > node_modules/@git-stunts/git-warp/index.js; fi + cat > node_modules/.bin/git-warp <<'CLI' +#!/usr/bin/env bash +[ "$CLOSURE_FIXTURE_MODE" != cli-failed ] +CLI + chmod +x node_modules/.bin/git-warp + integrity="$CLOSURE_FIXTURE_INTEGRITY" + [ "$CLOSURE_FIXTURE_MODE" != corrupt-npm ] || integrity=sha512-bad + jq -n --arg integrity "$integrity" '{packages:{ + "node_modules/@git-stunts/git-warp":{version:"19.1.0",integrity:$integrity,resolved:"https://registry.npmjs.org/fixture.tgz"}, + "node_modules/@git-stunts/git-cas":{version:"6.5.10"}, + "node_modules/@git-stunts/plumbing":{version:"3.3.0"}}}' > package-lock.json ;; + audit) + [ "$2" = signatures ] || exit 77 + [ "$CLOSURE_FIXTURE_MODE" != signatures-failed ] || exit 1 + echo 'registry signatures and attestations verified' ;; + *) exit 77 ;; + esac ;; + curl) + destination="" + while [ "$#" -gt 0 ]; do + if [ "$1" = -o ]; then destination="$2"; break; fi + shift + done + printf 'fixture archive' > "$destination" ;; + *) exit 77 ;; +esac diff --git a/test/bats/release-closure.bats b/test/bats/release-closure.bats new file mode 100644 index 000000000..a4517d3ac --- /dev/null +++ b/test/bats/release-closure.bats @@ -0,0 +1,183 @@ +#!/usr/bin/env bats +# Size: medium; real subprocesses/filesystem, controlled transport, no network. +# Oracle: .github/RELEASE.md, Registry-backed closure contract. Fixtures violate +# one promise at a time; success requires the public CLI's receipt and exit code. + +setup() { + REPO_ROOT=$(cd "$BATS_TEST_DIRNAME/../.." && pwd) + export CLOSURE_FIXTURE_DIR + CLOSURE_FIXTURE_DIR=$(mktemp -d) + export CLOSURE_FIXTURE_COMMIT=1111111111111111111111111111111111111111 + export CLOSURE_FIXTURE_MODE=success + export CLOSURE_FIXTURE_INTEGRITY + CLOSURE_FIXTURE_INTEGRITY="sha512-$(printf 'fixture archive' | openssl dgst -sha512 -binary | openssl base64 -A)" + export GIT_WARP_CLOSURE_ATTEMPTS=3 + export GIT_WARP_CLOSURE_DELAY_SECONDS=0 + export LC_ALL=C TZ=UTC + export TMPDIR="$CLOSURE_FIXTURE_DIR" + mkdir "$CLOSURE_FIXTURE_DIR/bin" + for tool in git gh npm curl; do + cp "$BATS_TEST_DIRNAME/fixtures/release-closure-command.sh" "$CLOSURE_FIXTURE_DIR/bin/$tool" + chmod +x "$CLOSURE_FIXTURE_DIR/bin/$tool" + done + export PATH="$CLOSURE_FIXTURE_DIR/bin:$PATH" + RECEIPT="$CLOSURE_FIXTURE_DIR/receipt.json" +} + +teardown() { rm -rf "$CLOSURE_FIXTURE_DIR"; } + +verify_release() { + run bash "$REPO_ROOT/scripts/verify-published-release.sh" \ + --tag v19.1.0 --commit "$CLOSURE_FIXTURE_COMMIT" --run-id 123 \ + --output "$RECEIPT" "$@" +} + +assert_failed() { + [ "$status" -ne 0 ] || { + echo "assertion: invalid release must exit nonzero; actual exit $status" >&2 + return 1 + } + [ "$(jq -r .status "$RECEIPT")" = failed ] || { + echo 'assertion: failed release must retain a valid failed receipt' >&2 + return 1 + } + [ "$(jq -r .stage "$RECEIPT")" = "$1" ] || { + echo "assertion: failed release must name stage $1" >&2 + return 1 + } +} + +@test "release closure proves public identity and an independent consumer" { + verify_release --require-dist-tag + [ "$status" -eq 0 ] + jq -e '.status=="verified" and .consumer.registrySignatures=="verified" and + .consumer.privateStorageFirewall=="passed" and .distTag.ownsTag==true and + (.consumer.dependencies|length)==2' "$RECEIPT" + ! grep -E '^(npm (publish|dist-tag)|git (push|tag)|gh (release|workflow)) ' "$CLOSURE_FIXTURE_DIR/commands" +} + +@test "release closure retries delayed visibility in both registries" { + export CLOSURE_FIXTURE_MODE=delayed + verify_release + [ "$status" -eq 0 ] + [ "$(cat "$CLOSURE_FIXTURE_DIR/npm-attempts")" = 3 ] + [ "$(cat "$CLOSURE_FIXTURE_DIR/jsr-attempts")" = 3 ] +} + +@test "release closure exhausts a finite visibility budget" { + export CLOSURE_FIXTURE_MODE=unavailable + verify_release + assert_failed registry + [ "$(cat "$CLOSURE_FIXTURE_DIR/npm-attempts")" = 3 ] +} + +@test "release closure terminates a stalled external command" { + export CLOSURE_FIXTURE_MODE=hang + export GIT_WARP_CLOSURE_ATTEMPTS=1 + export GIT_WARP_CLOSURE_COMMAND_TIMEOUT_SECONDS=1 + # This OS-level timeout contract has an independent outer watchdog. Its + # timeout is a failure, never evidence that the verifier enforced its limit. + run timeout --kill-after=1s 8s bash "$REPO_ROOT/scripts/verify-published-release.sh" \ + --tag v19.1.0 --commit "$CLOSURE_FIXTURE_COMMIT" --run-id 123 --output "$RECEIPT" + [ "$status" -ne 124 ] + [ "$status" -ne 137 ] + assert_failed registry + [ "$(cat "$CLOSURE_FIXTURE_DIR/npm-attempts")" = 1 ] +} + +@test "release closure rejects a changed public tag" { + export CLOSURE_FIXTURE_MODE=wrong-tag + verify_release + assert_failed source +} + +@test "release closure rejects a publishing run from a different commit" { + export CLOSURE_FIXTURE_MODE=wrong-run + verify_release + assert_failed source +} + +@test "release closure retains a failed receipt after malformed GitHub transport" { + export CLOSURE_FIXTURE_MODE=malformed-github + verify_release + assert_failed source +} + +@test "release closure rejects conflicting npm identity without retrying" { + export CLOSURE_FIXTURE_MODE=wrong-npm + verify_release + assert_failed registry + [ "$(cat "$CLOSURE_FIXTURE_DIR/npm-attempts")" = 1 ] +} + +@test "release closure rejects absent provenance" { + export CLOSURE_FIXTURE_MODE=no-provenance + verify_release + assert_failed registry +} + +@test "release closure hashes the JSR archive against registry integrity" { + export CLOSURE_FIXTURE_MODE=corrupt-jsr + verify_release + assert_failed jsr-integrity +} + +@test "release closure rejects npm consumer integrity mismatch" { + export CLOSURE_FIXTURE_MODE=corrupt-npm + verify_release + assert_failed consumer + [ "$(jq -r .consumer.stage "$RECEIPT")" = integrity ] +} + +@test "release closure rejects consumer installation failure" { + export CLOSURE_FIXTURE_MODE=install-failed + verify_release + assert_failed consumer + [ "$(jq -r .consumer.stage "$RECEIPT")" = install ] +} + +@test "release closure executes the public import rather than checking metadata" { + export CLOSURE_FIXTURE_MODE=broken-import + verify_release + assert_failed consumer + [ "$(jq -r .consumer.stage "$RECEIPT")" = imports ] +} + +@test "release closure rejects a newly exposed private storage import" { + export CLOSURE_FIXTURE_MODE=private-leak + verify_release + assert_failed consumer + [ "$(jq -r .consumer.stage "$RECEIPT")" = imports ] +} + +@test "release closure executes the installed CLI" { + export CLOSURE_FIXTURE_MODE=cli-failed + verify_release + assert_failed consumer + [ "$(jq -r .consumer.stage "$RECEIPT")" = cli ] +} + +@test "release closure rejects failed signature or attestation verification" { + export CLOSURE_FIXTURE_MODE=signatures-failed + verify_release + assert_failed consumer + [ "$(jq -r .consumer.stage "$RECEIPT")" = signatures ] +} + +@test "new publication must own its intended dist-tag" { + export CLOSURE_FIXTURE_MODE=advanced-tag + verify_release --require-dist-tag + assert_failed registry +} + +@test "immutable historical verification records an advanced dist-tag honestly" { + export CLOSURE_FIXTURE_MODE=advanced-tag + verify_release + [ "$status" -eq 0 ] + jq -e '.status=="verified" and .distTag.ownsTag==false and + .distTag.observedVersion=="19.2.0" and .distTag.ownershipRequired==false' "$RECEIPT" + cp "$RECEIPT" "$CLOSURE_FIXTURE_DIR/first.json" + verify_release + [ "$status" -eq 0 ] + cmp "$RECEIPT" "$CLOSURE_FIXTURE_DIR/first.json" +} From 90d0ead7fd239009865e2fba02933fc5fb18a8a8 Mon Sep 17 00:00:00 2001 From: James Ross Date: Mon, 7 Sep 2026 12:00:38 -0700 Subject: [PATCH 4/7] fix: pin release closure verification actions --- .github/workflows/release.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 42eeb28fa..0adaece4b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -447,17 +447,18 @@ jobs: needs: [verify, publish_npm, publish_jsr, github_release] if: ${{ always() && needs.verify.result == 'success' }} permissions: + # Observe the publishing run and tagged source without publication rights. actions: read contents: read steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: ref: ${{ needs.verify.outputs.tag }} fetch-depth: 0 persist-credentials: false - name: Setup Node 24 - uses: actions/setup-node@v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e with: node-version: '24' package-manager-cache: false From 0210b75c20c9e3b5fe6ef69c68cd2884b8c954fe Mon Sep 17 00:00:00 2001 From: James Ross Date: Mon, 7 Sep 2026 12:03:40 -0700 Subject: [PATCH 5/7] fix: verify signatures before executing release packages --- scripts/release-closure/consumer.sh | 7 ++++--- test/bats/fixtures/release-closure-command.sh | 4 ++++ test/bats/release-closure.bats | 2 ++ 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/scripts/release-closure/consumer.sh b/scripts/release-closure/consumer.sh index 753b0542f..0fb311e6d 100644 --- a/scripts/release-closure/consumer.sh +++ b/scripts/release-closure/consumer.sh @@ -31,6 +31,10 @@ jq -e --arg version "$VERSION" --arg integrity "$(jq -r .dist.integrity "$WORK/n (.resolved|startswith("https://registry.npmjs.org/")) and (.link!=true)' \ package-lock.json >/dev/null +CONSUMER_STAGE="signatures" +bounded npm audit signatures --registry=https://registry.npmjs.org \ + --fetch-retries=0 --fetch-timeout=15000 > "$WORK/signatures.log" 2>&1 + CONSUMER_STAGE="imports" bounded node --input-type=module <<'NODE' class ReleaseConsumerError extends Error {} @@ -55,9 +59,6 @@ NODE CONSUMER_STAGE="cli" test -x node_modules/.bin/git-warp bounded node_modules/.bin/git-warp --help > "$WORK/cli.log" 2>&1 -CONSUMER_STAGE="signatures" -bounded npm audit signatures --registry=https://registry.npmjs.org \ - --fetch-retries=0 --fetch-timeout=15000 > "$WORK/signatures.log" 2>&1 CONSUMER_STAGE="dependencies" jq -e '.packages|to_entries|map(select( diff --git a/test/bats/fixtures/release-closure-command.sh b/test/bats/fixtures/release-closure-command.sh index caca3bc19..7ddbd3734 100644 --- a/test/bats/fixtures/release-closure-command.sh +++ b/test/bats/fixtures/release-closure-command.sh @@ -69,8 +69,12 @@ case "$tool" in if [ "$CLOSURE_FIXTURE_MODE" = broken-import ]; then echo 'export const Wrong = true;' > node_modules/@git-stunts/git-warp/index.js else echo 'export class Runtime {}' > node_modules/@git-stunts/git-warp/index.js; fi + jq -nr --arg marker "$CLOSURE_FIXTURE_DIR/executed-code" ' + "import {appendFileSync} from \"node:fs\";\nappendFileSync(\($marker|tojson), \"import\\n\");" + ' >> node_modules/@git-stunts/git-warp/index.js cat > node_modules/.bin/git-warp <<'CLI' #!/usr/bin/env bash +printf 'cli\n' >> "$CLOSURE_FIXTURE_DIR/executed-code" [ "$CLOSURE_FIXTURE_MODE" != cli-failed ] CLI chmod +x node_modules/.bin/git-warp diff --git a/test/bats/release-closure.bats b/test/bats/release-closure.bats index a4517d3ac..64251705b 100644 --- a/test/bats/release-closure.bats +++ b/test/bats/release-closure.bats @@ -53,6 +53,7 @@ assert_failed() { jq -e '.status=="verified" and .consumer.registrySignatures=="verified" and .consumer.privateStorageFirewall=="passed" and .distTag.ownsTag==true and (.consumer.dependencies|length)==2' "$RECEIPT" + [ "$(cat "$CLOSURE_FIXTURE_DIR/executed-code")" = "$(printf 'import\ncli')" ] ! grep -E '^(npm (publish|dist-tag)|git (push|tag)|gh (release|workflow)) ' "$CLOSURE_FIXTURE_DIR/commands" } @@ -162,6 +163,7 @@ assert_failed() { verify_release assert_failed consumer [ "$(jq -r .consumer.stage "$RECEIPT")" = signatures ] + [ ! -e "$CLOSURE_FIXTURE_DIR/executed-code" ] } @test "new publication must own its intended dist-tag" { From 7d8a5bd255ec3c4f9cf432196e0bae74277002ff Mon Sep 17 00:00:00 2001 From: James Ross Date: Mon, 7 Sep 2026 12:05:51 -0700 Subject: [PATCH 6/7] test: directly calibrate failed release receipt creation --- scripts/release-closure/calibrate.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/release-closure/calibrate.sh b/scripts/release-closure/calibrate.sh index c13419f3e..129c5a301 100644 --- a/scripts/release-closure/calibrate.sh +++ b/scripts/release-closure/calibrate.sh @@ -103,7 +103,8 @@ calibrate public-tag 'release closure rejects a changed public tag' \ calibrate publishing-run 'release closure rejects a publishing run from a different commit' \ "$NONZERO" "$DRIVER" '.head_sha==$commit and .repository' 'true and .repository' calibrate failed-receipt 'release closure retains a failed receipt after malformed GitHub transport' \ - 'assertion: failed release must retain a valid failed receipt' "$DRIVER" '> "$WORK/github-pending.json"' '> "$2"' + 'assertion: failed release must retain a valid failed receipt' "$DRIVER" \ + '> "$OUTPUT"' '> "$WORK/discarded-receipt.json"' calibrate npm-identity 'release closure rejects conflicting npm identity without retrying' \ "$NONZERO" "$DRIVER" "'.gitHead==\$commit'" "'true'" calibrate provenance 'release closure rejects absent provenance' \ From df05e9fe6819e9ab9b2cc3e493d7bc20172a4e1f Mon Sep 17 00:00:00 2001 From: James Ross Date: Mon, 7 Sep 2026 12:21:15 -0700 Subject: [PATCH 7/7] fix: bound aggregate release verification time --- .github/RELEASE.md | 19 ++++++++++---- scripts/release-closure/budget.sh | 25 +++++++++++++++++++ scripts/release-closure/calibrate.sh | 12 +++++++-- scripts/release-closure/consumer.sh | 4 ++- scripts/verify-published-release.sh | 22 ++++++++++------ test/bats/fixtures/release-closure-command.sh | 5 ++++ test/bats/release-closure.bats | 19 ++++++++++++++ 7 files changed, 91 insertions(+), 15 deletions(-) create mode 100644 scripts/release-closure/budget.sh diff --git a/.github/RELEASE.md b/.github/RELEASE.md index c6ec17b69..8bdc970d1 100644 --- a/.github/RELEASE.md +++ b/.github/RELEASE.md @@ -422,13 +422,22 @@ Add `--require-dist-tag` when verifying a new publication. Historical reruns record the current dist-tag owner without claiming that the old version is still latest. -The default registry budget is six attempts with ten-second delays and -180-second command timeouts. Diagnostic overrides are +The verifier has a 720-second aggregate work budget. Each command and propagation +delay is capped by the remaining budget, and the consumer inherits only the +remaining time. Commands allow at most five additional seconds to terminate; +the 15-minute job ceiling leaves headroom for setup and receipt upload. The +receipt records the budget limit and whether it was exhausted. + +Within that aggregate budget, the default registry policy is six attempts with +ten-second delays and 180-second command limits. Diagnostic overrides are `GIT_WARP_CLOSURE_ATTEMPTS` (1–10), `GIT_WARP_CLOSURE_DELAY_SECONDS` (0–30), and -`GIT_WARP_CLOSURE_COMMAND_TIMEOUT_SECONDS` (1–180). Consumer install, CLI, and -signature commands retain separate 180-second limits; the workflow has a -15-minute ceiling. Run the adversarial contract suite with +`GIT_WARP_CLOSURE_COMMAND_TIMEOUT_SECONDS` (1–180). +`GIT_WARP_CLOSURE_TOTAL_TIMEOUT_SECONDS` may reduce the aggregate budget (1–720). +Consumer installation and verification commands retain 180-second ceilings, +further capped by their remaining aggregate budget. Signature and attestation +verification precedes execution of the imported package and installed CLI. +Run the adversarial contract suite with `bats test/bats/release-closure.bats`. The contract suite is medium-sized: it owns scratch state and controls GitHub diff --git a/scripts/release-closure/budget.sh b/scripts/release-closure/budget.sh new file mode 100644 index 000000000..44c15b7be --- /dev/null +++ b/scripts/release-closure/budget.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Shared elapsed-time budget for the registry driver and independent consumer. + +start_budget() { + [[ "$1" =~ ^[1-9][0-9]*$ ]] && [ "$1" -le 720 ] || return 2 + [[ "$2" =~ ^[1-9][0-9]*$ ]] && [ "$2" -le 180 ] || return 2 + CLOSURE_DEADLINE=$((SECONDS + $1)) + CLOSURE_COMMAND_LIMIT="$2" +} + +budget_remaining() { + local remaining=$((CLOSURE_DEADLINE - SECONDS)) + if [ "$remaining" -le 0 ]; then + echo 'release closure aggregate time budget exhausted' >&2 + return 124 + fi + printf '%s\n' "$remaining" +} + +bounded() { + local limit + limit=$(budget_remaining) || return $? + if [ "$limit" -gt "$CLOSURE_COMMAND_LIMIT" ]; then limit="$CLOSURE_COMMAND_LIMIT"; fi + timeout --kill-after=5s "${limit}s" "$@" +} diff --git a/scripts/release-closure/calibrate.sh b/scripts/release-closure/calibrate.sh index 129c5a301..04f92bd1e 100644 --- a/scripts/release-closure/calibrate.sh +++ b/scripts/release-closure/calibrate.sh @@ -19,6 +19,7 @@ RESULTS="$WORK/results.jsonl" : > "$RESULTS" INPUT_DIGEST=$(cat "$ROOT/scripts/verify-published-release.sh" \ "$ROOT/scripts/release-closure/consumer.sh" "$ROOT/scripts/release-closure/calibrate.sh" \ + "$ROOT/scripts/release-closure/budget.sh" \ "$ROOT/test/bats/release-closure.bats" "$ROOT/test/bats/fixtures/release-closure-command.sh" | openssl dgst -sha256 -binary | openssl base64 -A) NODE_VERSION=$(node --version) @@ -27,6 +28,7 @@ BATS_VERSION=$(bats --version) reset_subject() { cp "$ROOT/scripts/verify-published-release.sh" "$WORK/tree/scripts/" cp "$ROOT/scripts/release-closure/consumer.sh" "$WORK/tree/scripts/release-closure/" + cp "$ROOT/scripts/release-closure/budget.sh" "$WORK/tree/scripts/release-closure/" } replace_once() { @@ -69,6 +71,7 @@ calibrate() { replace_once "$path" "$before" "$after" bash -n "$WORK/tree/scripts/verify-published-release.sh" bash -n "$WORK/tree/scripts/release-closure/consumer.sh" + bash -n "$WORK/tree/scripts/release-closure/budget.sh" timeout --kill-after=5s 30s bats --formatter tap --filter "^$test$" \ "$WORK/tree/test/bats/release-closure.bats" > "$WORK/run.log" 2>&1 || code=$? # Exit 1 alone could be a setup crash or no selected test. Require the exact @@ -87,6 +90,7 @@ calibrate() { DRIVER=scripts/verify-published-release.sh CONSUMER=scripts/release-closure/consumer.sh +BUDGET=scripts/release-closure/budget.sh NONZERO='assertion: invalid release must exit nonzero' SUCCESS='[ "$status" -eq 0 ]' calibrate receipt-claim 'release closure proves public identity and an independent consumer' \ @@ -96,8 +100,12 @@ calibrate propagation-budget 'release closure retries delayed visibility in both calibrate finite-budget 'release closure exhausts a finite visibility budget' \ 'npm-attempts' "$DRIVER" 'local attempt=1' 'local attempt=0' calibrate command-deadline 'release closure terminates a stalled external command' \ - '[ "$status" -ne 124 ]' "$DRIVER" \ - 'bounded() { timeout --kill-after=5s "${COMMAND_TIMEOUT}s" "$@"; }' 'bounded() { "$@"; }' + '[ "$status" -ne 124 ]' "$BUDGET" \ + 'timeout --kill-after=5s "${limit}s" "$@"' '"$@"' +calibrate total-deadline 'release closure bounds the total verification time and retains a failed receipt' \ + "$NONZERO" "$BUDGET" 'CLOSURE_DEADLINE=$((SECONDS + $1))' 'CLOSURE_DEADLINE=$((SECONDS + 720))' +calibrate consumer-deadline 'release closure includes consumer installation in its aggregate time budget' \ + "$NONZERO" "$BUDGET" 'CLOSURE_DEADLINE=$((SECONDS + $1))' 'CLOSURE_DEADLINE=$((SECONDS + 720))' calibrate public-tag 'release closure rejects a changed public tag' \ "$NONZERO" "$DRIVER" '[ "$(cat "$WORK/remote-commit")" = "$EXPECTED_COMMIT" ]' ':' calibrate publishing-run 'release closure rejects a publishing run from a different commit' \ diff --git a/scripts/release-closure/consumer.sh b/scripts/release-closure/consumer.sh index 0fb311e6d..ad2f712e0 100644 --- a/scripts/release-closure/consumer.sh +++ b/scripts/release-closure/consumer.sh @@ -5,6 +5,9 @@ set -euo pipefail WORK="$1" PACKAGE="$2" VERSION="$3" +# shellcheck source=scripts/release-closure/budget.sh +source "$(cd "$(dirname "$0")" && pwd)/budget.sh" +start_budget "$4" 180 CONSUMER_STATUS="failed" CONSUMER_STAGE="install" consumer_failure() { @@ -19,7 +22,6 @@ trap consumer_failure EXIT mkdir "$WORK/consumer" cd "$WORK/consumer" printf '{"name":"git-warp-release-consumer","version":"1.0.0","private":true}\n' > package.json -bounded() { timeout --kill-after=5s 180s "$@"; } bounded npm install --save-exact --ignore-scripts --no-audit --no-fund \ --registry=https://registry.npmjs.org --fetch-retries=0 --fetch-timeout=15000 \ "$PACKAGE@$VERSION" > "$WORK/install.log" 2>&1 diff --git a/scripts/verify-published-release.sh b/scripts/verify-published-release.sh index 5f80b2d6c..9cce8dc58 100644 --- a/scripts/verify-published-release.sh +++ b/scripts/verify-published-release.sh @@ -13,6 +13,7 @@ REQUIRE_DIST_TAG=0 ATTEMPTS="${GIT_WARP_CLOSURE_ATTEMPTS:-6}" DELAY="${GIT_WARP_CLOSURE_DELAY_SECONDS:-10}" COMMAND_TIMEOUT="${GIT_WARP_CLOSURE_COMMAND_TIMEOUT_SECONDS:-180}" +TOTAL_TIMEOUT="${GIT_WARP_CLOSURE_TOTAL_TIMEOUT_SECONDS:-720}" while [ "$#" -gt 0 ]; do case "$1" in @@ -40,6 +41,9 @@ done [[ "$COMMAND_TIMEOUT" =~ ^[1-9][0-9]*$ ]] && [ "$COMMAND_TIMEOUT" -le 180 ] || exit 2 [ -n "$OUTPUT" ] || exit 2 for tool in jq timeout git gh npm curl openssl node; do command -v "$tool" >/dev/null; done +# shellcheck source=scripts/release-closure/budget.sh +source "$ROOT/scripts/release-closure/budget.sh" +start_budget "$TOTAL_TIMEOUT" "$COMMAND_TIMEOUT" VERSION="${TAG#v}" WORK=$(mktemp -d "${TMPDIR:-/tmp}/git-warp-release-closure.XXXXXX") @@ -55,12 +59,14 @@ finish() { trap - EXIT jq -n --arg status "$STATUS" --arg stage "$STAGE" --arg tag "$TAG" \ --arg version "$VERSION" --arg commit "$EXPECTED_COMMIT" --arg distTag "$DIST_TAG" \ + --argjson limit "$TOTAL_TIMEOUT" --argjson remaining "$((CLOSURE_DEADLINE - SECONDS))" \ --argjson requireDistTag "$REQUIRE_DIST_TAG" \ --slurpfile npm "$WORK/npm.json" --slurpfile jsr "$WORK/jsr.json" \ --slurpfile run "$WORK/run.json" --slurpfile release "$WORK/release.json" \ --slurpfile consumer "$WORK/consumer.json" --slurpfile owner "$WORK/dist-tag.json" \ '{schema:"git-warp/release-closure@1",status:$status,stage:$stage,tag:$tag, version:$version,sourceCommit:$commit, + budget:{limitSeconds:$limit,exhausted:($remaining<=0)}, publishRun:($run[0]|{id,head_sha,html_url,path}), githubRelease:($release[0]|{id,tag_name,html_url}), npm:($npm[0]|{name,version,gitHead,integrity:.dist.integrity, @@ -76,7 +82,6 @@ finish() { trap finish EXIT fail() { echo "release closure failed: $1" >&2; exit 1; } -bounded() { timeout --kill-after=5s "${COMMAND_TIMEOUT}s" "$@"; } github_metadata() { bounded gh api "repos/$REPO/$1" > "$WORK/github-pending.json" @@ -100,17 +105,17 @@ registry_metadata() { fi [ "$attempt" -lt "$ATTEMPTS" ] || fail "registry visibility exhausted $ATTEMPTS attempts" echo "registry visibility pending: attempt $attempt/$ATTEMPTS" - sleep "$DELAY" + bounded sleep "$DELAY" attempt=$((attempt + 1)) done } -LOCAL_COMMIT=$(git -C "$ROOT" rev-parse "$TAG^{commit}") +LOCAL_COMMIT=$(bounded git -C "$ROOT" rev-parse "$TAG^{commit}") [ "$LOCAL_COMMIT" = "$EXPECTED_COMMIT" ] || fail "local tag commit mismatch" bounded gh api "repos/$REPO/commits/$TAG" --jq .sha > "$WORK/remote-commit" [ "$(cat "$WORK/remote-commit")" = "$EXPECTED_COMMIT" ] || fail "public tag commit mismatch" -git -C "$ROOT" show "$TAG:package.json" > "$WORK/package.json" -git -C "$ROOT" show "$TAG:jsr.json" > "$WORK/jsr-package.json" +bounded git -C "$ROOT" show "$TAG:package.json" > "$WORK/package.json" +bounded git -C "$ROOT" show "$TAG:jsr.json" > "$WORK/jsr-package.json" PACKAGE=$(jq -er --arg version "$VERSION" 'select(.version==$version)|.name' "$WORK/package.json") JSR_NAME=$(jq -er --arg version "$VERSION" 'select(.version==$version)|.name' "$WORK/jsr-package.json") JSR_SCOPE="${JSR_NAME%%/*}" @@ -147,10 +152,13 @@ STAGE="jsr-integrity" JSR_TARBALL=$(jq -er '.dist.tarball|select(startswith("https://npm.jsr.io/"))' "$WORK/jsr.json") bounded curl --fail --silent --show-error --location --proto '=https' --proto-redir '=https' \ --connect-timeout 10 --max-time 90 "$JSR_TARBALL" -o "$WORK/jsr.tgz" -JSR_INTEGRITY="sha512-$(openssl dgst -sha512 -binary "$WORK/jsr.tgz" | openssl base64 -A)" +JSR_INTEGRITY="sha512-$(bounded openssl dgst -sha512 -binary "$WORK/jsr.tgz" | openssl base64 -A)" [ "$JSR_INTEGRITY" = "$(jq -r .dist.integrity "$WORK/jsr.json")" ] || fail "JSR tarball integrity mismatch" STAGE="consumer" -bash "$ROOT/scripts/release-closure/consumer.sh" "$WORK" "$PACKAGE" "$VERSION" +CONSUMER_BUDGET=$(budget_remaining) +timeout --kill-after=5s "${CONSUMER_BUDGET}s" \ + bash "$ROOT/scripts/release-closure/consumer.sh" "$WORK" "$PACKAGE" "$VERSION" "$CONSUMER_BUDGET" +budget_remaining >/dev/null STAGE="complete" STATUS="verified" diff --git a/test/bats/fixtures/release-closure-command.sh b/test/bats/fixtures/release-closure-command.sh index 7ddbd3734..6db9e6b25 100644 --- a/test/bats/fixtures/release-closure-command.sh +++ b/test/bats/fixtures/release-closure-command.sh @@ -13,6 +13,7 @@ case "$tool" in esac ;; gh) [ "$1" = api ] || exit 77 + [ "$CLOSURE_FIXTURE_MODE" != slow-chain ] || sleep 1 case "$2" in */commits/*) if [ "$CLOSURE_FIXTURE_MODE" = wrong-tag ]; then echo bad; else echo "$CLOSURE_FIXTURE_COMMIT"; fi ;; @@ -59,6 +60,10 @@ case "$tool" in attestations:{provenance:{predicateType:"https://slsa.dev/provenance/v1"}},signatures:[]}}| if $mode=="no-provenance" then del(.dist.attestations) else . end' ;; install) + if [ "$CLOSURE_FIXTURE_MODE" = slow-consumer ]; then + touch "$CLOSURE_FIXTURE_DIR/consumer-install-started" + sleep 10 + fi [ "$CLOSURE_FIXTURE_MODE" != install-failed ] || exit 1 case "$PWD" in "$CLOSURE_FIXTURE_DIR"/*/consumer) ;; *) exit 77 ;; esac mkdir -p node_modules/@git-stunts/git-warp node_modules/.bin diff --git a/test/bats/release-closure.bats b/test/bats/release-closure.bats index 64251705b..7f203a640 100644 --- a/test/bats/release-closure.bats +++ b/test/bats/release-closure.bats @@ -86,6 +86,25 @@ assert_failed() { [ "$(cat "$CLOSURE_FIXTURE_DIR/npm-attempts")" = 1 ] } +@test "release closure bounds the total verification time and retains a failed receipt" { + export CLOSURE_FIXTURE_MODE=slow-chain + export GIT_WARP_CLOSURE_TOTAL_TIMEOUT_SECONDS=2 + export GIT_WARP_CLOSURE_COMMAND_TIMEOUT_SECONDS=5 + verify_release + assert_failed source + jq -e '.budget.limitSeconds==2 and .budget.exhausted==true' "$RECEIPT" +} + +@test "release closure includes consumer installation in its aggregate time budget" { + export CLOSURE_FIXTURE_MODE=slow-consumer + export GIT_WARP_CLOSURE_TOTAL_TIMEOUT_SECONDS=5 + verify_release + assert_failed consumer + [ -e "$CLOSURE_FIXTURE_DIR/consumer-install-started" ] + [ ! -e "$CLOSURE_FIXTURE_DIR/executed-code" ] + jq -e '.budget.limitSeconds==5 and .budget.exhausted==true' "$RECEIPT" +} + @test "release closure rejects a changed public tag" { export CLOSURE_FIXTURE_MODE=wrong-tag verify_release