From d17fdd6e30695bda74b0846777f58818875b3880 Mon Sep 17 00:00:00 2001 From: 0thernet Date: Sun, 30 Aug 2026 15:06:09 -0400 Subject: [PATCH 1/3] release: prepare public CLI supply chain --- .github/CODEOWNERS | 18 ++ .github/workflows/release.yml | 212 ++++++++++++++++++++ README.md | 6 +- bun.lock | 103 ++++++++++ docs/beta-release-notes.md | 2 +- docs/beta-release.md | 39 +++- kb/plans/hra-v1.md | 5 +- package.json | 7 +- scripts/check-npm-trusted-publishing.ts | 20 ++ scripts/check-package.test.ts | 4 +- scripts/check-package.ts | 59 ++++-- scripts/check-public-release.ts | 166 +++++++++++++++ scripts/check-release-package.ts | 10 + scripts/package-policy.ts | 37 ++++ scripts/public-text-policy.test.ts | 2 + scripts/public-text-policy.ts | 1 + scripts/publish-github-release.ts | 91 +++++++++ scripts/publish-npm-release.ts | 111 ++++++++++ scripts/release-artifact-checksum.ts | 29 +++ scripts/release-distribution-policy.test.ts | 66 ++++++ scripts/release-distribution-policy.ts | 136 +++++++++++++ scripts/release-package-policy.test.ts | 55 +++++ scripts/release-package-policy.ts | 67 +++++++ scripts/release-workflow.test.ts | 22 +- scripts/verify-npm-provenance.ts | 98 +++++++++ site/content.test.ts | 4 +- site/content.ts | 2 +- site/social-card.svg | 2 +- src/install-normalizer.test.ts | 16 +- src/install-normalizer.ts | 14 +- src/install-preflight-runtime.ts | 31 +-- src/install-preflight.test.ts | 16 +- src/install-preflight.ts | 2 +- 33 files changed, 1371 insertions(+), 82 deletions(-) create mode 100644 .github/CODEOWNERS create mode 100644 .github/workflows/release.yml create mode 100644 scripts/check-npm-trusted-publishing.ts create mode 100644 scripts/check-public-release.ts create mode 100644 scripts/check-release-package.ts create mode 100644 scripts/publish-github-release.ts create mode 100644 scripts/publish-npm-release.ts create mode 100644 scripts/release-artifact-checksum.ts create mode 100644 scripts/release-distribution-policy.test.ts create mode 100644 scripts/release-distribution-policy.ts create mode 100644 scripts/release-package-policy.test.ts create mode 100644 scripts/release-package-policy.ts create mode 100644 scripts/verify-npm-provenance.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..022aacc --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,18 @@ +# Release authority and supply-chain controls require the repository owner. +/.github/ @0thernet +/package.json @0thernet +/bun.lock @0thernet +/scripts/check-package.ts @0thernet +/scripts/check-public-release.ts @0thernet +/scripts/check-release-package.ts @0thernet +/scripts/check-npm-trusted-publishing.ts @0thernet +/scripts/package-policy.ts @0thernet +/scripts/publish-github-release.ts @0thernet +/scripts/publish-npm-release.ts @0thernet +/scripts/release-artifact-checksum.ts @0thernet +/scripts/release-distribution-policy.ts @0thernet +/scripts/release-package-policy.ts @0thernet +/scripts/verify-npm-provenance.ts @0thernet +/src/install-normalizer.ts @0thernet +/src/install-preflight-runtime.ts @0thernet +/src/install-preflight.ts @0thernet diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..e835212 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,212 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: read + +concurrency: + group: stable-release + cancel-in-progress: false + +jobs: + verify: + name: Verify release source and build exact artifact + permissions: + contents: read + runs-on: ubuntu-24.04 + timeout-minutes: 45 + outputs: + verified_sha: ${{ steps.identity.outputs.sha }} + verified_tag: ${{ steps.identity.outputs.tag }} + verified_tag_object: ${{ steps.identity.outputs.tag_object }} + steps: + - name: Require one stable tag push + id: request + env: + EVENT_NAME: ${{ github.event_name }} + EVENT_REF: ${{ github.ref }} + EVENT_REF_NAME: ${{ github.ref_name }} + EVENT_REF_TYPE: ${{ github.ref_type }} + run: | + set -euo pipefail + if [[ "$EVENT_NAME" != "push" || "$EVENT_REF_TYPE" != "tag" || "$EVENT_REF" != "refs/tags/$EVENT_REF_NAME" ]]; then + echo "::error::Release request is not one exact tag push" + exit 1 + fi + if [[ ! "$EVENT_REF_NAME" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo "::error::Release request is not one stable semantic-version tag" + exit 1 + fi + printf 'tag=%s\n' "$EVENT_REF_NAME" >> "$GITHUB_OUTPUT" + - name: Check out the exact tag + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + ref: refs/tags/${{ steps.request.outputs.tag }} + - name: Install Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version-file: .bun-version + - name: Install Node and npm trusted-publishing client + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24.19.0" + registry-url: https://registry.npmjs.org + - name: Verify annotated tag, exact main head, and package version + id: identity + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + REQUESTED_TAG: ${{ steps.request.outputs.tag }} + run: | + set -euo pipefail + git fetch --force origin "$DEFAULT_BRANCH" "refs/tags/$REQUESTED_TAG:refs/tags/$REQUESTED_TAG" + if [[ "$(git cat-file -t "$REQUESTED_TAG")" != "tag" ]]; then + echo "::error::Release tag is not annotated" + exit 1 + fi + version="$(bun -e 'const value = await Bun.file("package.json").json(); if (typeof value.version !== "string") process.exit(1); process.stdout.write(value.version)')" + if [[ "$REQUESTED_TAG" != "v$version" ]]; then + echo "::error::Release tag does not match package version" + exit 1 + fi + tag_commit="$(git rev-parse --verify "refs/tags/$REQUESTED_TAG^{commit}")" + tag_object="$(git rev-parse --verify "refs/tags/$REQUESTED_TAG^{tag}")" + main_commit="$(git rev-parse --verify "origin/$DEFAULT_BRANCH^{commit}")" + head_commit="$(git rev-parse --verify "HEAD^{commit}")" + if [[ "$tag_commit" != "$head_commit" ]] || ! git merge-base --is-ancestor "$tag_commit" "$main_commit"; then + echo "::error::Release checkout must equal the annotated tag commit and reviewed main must contain it" + exit 1 + fi + printf 'sha=%s\n' "$tag_commit" >> "$GITHUB_OUTPUT" + printf 'tag=%s\n' "$REQUESTED_TAG" >> "$GITHUB_OUTPUT" + printf 'tag_object=%s\n' "$tag_object" >> "$GITHUB_OUTPUT" + - name: Install exact locked dependencies without lifecycle scripts + run: bun install --frozen-lockfile --ignore-scripts + - name: Run complete repository gate + run: bun run check + - name: Require registry-only runtime dependencies + run: bun run ./scripts/check-release-package.ts + - name: Create one exact npm tarball and checksum + run: | + set -euo pipefail + mkdir -p artifacts + npm pack --ignore-scripts --pack-destination artifacts . + expected="$(bun -e 'import { releaseArchiveName } from "./scripts/release-package-policy"; const value = await Bun.file("package.json").json(); process.stdout.write(releaseArchiveName(value.version))')" + test -f "artifacts/$expected" + test "$(find artifacts -maxdepth 1 -type f -name '*.tgz' | wc -l | tr -d ' ')" = "1" + bun run ./scripts/release-artifact-checksum.ts write "$GITHUB_WORKSPACE/artifacts/$expected" "$GITHUB_WORKSPACE/artifacts/SHA256SUMS" + - name: Preserve exact release bytes + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: hra-release + path: artifacts/ + if-no-files-found: error + retention-days: 7 + + exact_artifact: + name: Exact tarball install (${{ matrix.os }}) + needs: verify + permissions: + contents: read + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, macos-15] + steps: + - name: Check out verified source with complete history + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + ref: ${{ needs.verify.outputs.verified_sha }} + - name: Install Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version-file: .bun-version + - name: Install exact locked dependencies without lifecycle scripts + run: bun install --frozen-lockfile --ignore-scripts + - name: Download exact release bytes + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: hra-release + path: artifacts + - name: Verify checksum and complete installed-package behavior + run: | + set -euo pipefail + artifact="$(find "$GITHUB_WORKSPACE/artifacts" -maxdepth 1 -type f -name '*.tgz')" + test -n "$artifact" + bun run ./scripts/release-artifact-checksum.ts check "$artifact" "$GITHUB_WORKSPACE/artifacts/SHA256SUMS" + bun run ./scripts/check-package.ts "$artifact" + + publish: + name: Publish exact npm and GitHub artifacts + needs: [verify, exact_artifact] + permissions: + contents: write + id-token: write + runs-on: ubuntu-24.04 + timeout-minutes: 30 + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + GH_TOKEN: ${{ github.token }} + GITHUB_TOKEN: ${{ github.token }} + VERIFIED_SHA: ${{ needs.verify.outputs.verified_sha }} + VERIFIED_TAG: ${{ needs.verify.outputs.verified_tag }} + VERIFIED_TAG_OBJECT: ${{ needs.verify.outputs.verified_tag_object }} + HRA_APPROVE_NPM_PUBLICATION: ${{ vars.HRA_APPROVE_NPM_PUBLICATION }} + steps: + - name: Check out verified source with complete history + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + ref: ${{ needs.verify.outputs.verified_sha }} + - name: Install Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version-file: .bun-version + - name: Install Node and npm trusted-publishing client + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24.19.0" + registry-url: https://registry.npmjs.org + - name: Install exact locked dependencies without lifecycle scripts + run: bun install --frozen-lockfile --ignore-scripts + - name: Require registry readiness and trusted publishing support + run: | + bun run ./scripts/check-release-package.ts + bun run ./scripts/check-npm-trusted-publishing.ts + - name: Download validated release bytes + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: hra-release + path: artifacts + - name: Revalidate remote authority and checksum + run: | + set -euo pipefail + tag_object="$(gh api "/repos/$GITHUB_REPOSITORY/git/ref/tags/$VERIFIED_TAG" --jq 'select(.object.type == "tag") | .object.sha')" + test "$tag_object" = "$VERIFIED_TAG_OBJECT" + remote_tag="$(gh api "/repos/$GITHUB_REPOSITORY/git/tags/$tag_object" --jq 'select(.tag == env.VERIFIED_TAG and .object.type == "commit") | .object.sha')" + remote_main="$(gh api "/repos/$GITHUB_REPOSITORY/git/ref/heads/$DEFAULT_BRANCH" --jq '.object.sha')" + test "$remote_tag" = "$VERIFIED_SHA" + git fetch --force origin "$remote_main" + git merge-base --is-ancestor "$VERIFIED_SHA" "$remote_main" + artifact="$(find "$GITHUB_WORKSPACE/artifacts" -maxdepth 1 -type f -name '*.tgz')" + bun run ./scripts/release-artifact-checksum.ts check "$artifact" "$GITHUB_WORKSPACE/artifacts/SHA256SUMS" + - name: Publish exact tarball through npm trusted publishing + run: | + artifact="$(find "$GITHUB_WORKSPACE/artifacts" -maxdepth 1 -type f -name '*.tgz')" + bun run ./scripts/publish-npm-release.ts "$artifact" + - name: Create immutable GitHub Release from the same bytes + run: | + artifact="$(find "$GITHUB_WORKSPACE/artifacts" -maxdepth 1 -type f -name '*.tgz')" + bun run ./scripts/publish-github-release.ts "$VERIFIED_TAG" "$artifact" "$GITHUB_WORKSPACE/artifacts/SHA256SUMS" + - name: Admit exact public npm and GitHub state + run: bun run ./scripts/check-public-release.ts diff --git a/README.md b/README.md index a79b33f..d83dd0e 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # HRA ```sh -test "$(curl -fsSL --connect-timeout 10 --max-time 60 --retry 3 --retry-delay 1 --retry-max-time 60 --proto '=https' --tlsv1.2 https://raw.githubusercontent.com/hraness/hra/v0.1.0/src/install-preflight-runtime.ts | bun -e 'const[a,h]=process.argv.slice(1);const b=await Bun.stdin.bytes();const d=new Bun.CryptoHasher("sha256").update(b).digest("hex");if(d!==h)throw new Error("The tagged HRA preflight digest is invalid.");const j=new Bun.Transpiler({loader:"ts",target:"bun"}).transformSync(b);const u=URL.createObjectURL(new Blob([j],{type:"text/javascript"}));try{const m=await import(u);await m.installHraRelease(a);process.stdout.write(`${m.HRA_INSTALL_SUCCESS}\n`);}finally{URL.revokeObjectURL(u)}' -- https://github.com/hraness/hra/releases/download/v0.1.0/hra-v0.1.0.tgz facdcd4c3ce6a02590b533a92e661e06e63b4f4709f71f48cb5906a29d40fa21)" = hra-install-safe +test "$(curl -fsSL --connect-timeout 10 --max-time 60 --retry 3 --retry-delay 1 --retry-max-time 60 --proto '=https' --tlsv1.2 https://raw.githubusercontent.com/hraness/hra/v0.1.0/src/install-preflight-runtime.ts | bun -e 'const[a,h]=process.argv.slice(1);const b=await Bun.stdin.bytes();const d=new Bun.CryptoHasher("sha256").update(b).digest("hex");if(d!==h)throw new Error("The tagged HRA preflight digest is invalid.");const j=new Bun.Transpiler({loader:"ts",target:"bun"}).transformSync(b);const u=URL.createObjectURL(new Blob([j],{type:"text/javascript"}));try{const m=await import(u);await m.installHraRelease(a);process.stdout.write(`${m.HRA_INSTALL_SUCCESS}\n`);}finally{URL.revokeObjectURL(u)}' -- https://github.com/hraness/hra/releases/download/v0.1.0/hraness-hra-0.1.0.tgz 61049ecbe2fdb7ea89fcf80740597ca349fc7f62e09230f67519f15bb9fc7796)" = hra-install-safe ``` ```sh @@ -24,7 +24,7 @@ HRA requires Bun 1.3.14 plus curl with HTTPS and TLS 1.2 support. The CLI and lo ```text bun --version -test "$(curl -fsSL --connect-timeout 10 --max-time 60 --retry 3 --retry-delay 1 --retry-max-time 60 --proto '=https' --tlsv1.2 https://raw.githubusercontent.com/hraness/hra/v0.1.0/src/install-preflight-runtime.ts | bun -e 'const[a,h]=process.argv.slice(1);const b=await Bun.stdin.bytes();const d=new Bun.CryptoHasher("sha256").update(b).digest("hex");if(d!==h)throw new Error("The tagged HRA preflight digest is invalid.");const j=new Bun.Transpiler({loader:"ts",target:"bun"}).transformSync(b);const u=URL.createObjectURL(new Blob([j],{type:"text/javascript"}));try{const m=await import(u);await m.installHraRelease(a);process.stdout.write(`${m.HRA_INSTALL_SUCCESS}\n`);}finally{URL.revokeObjectURL(u)}' -- https://github.com/hraness/hra/releases/download/v0.1.0/hra-v0.1.0.tgz facdcd4c3ce6a02590b533a92e661e06e63b4f4709f71f48cb5906a29d40fa21)" = hra-install-safe +test "$(curl -fsSL --connect-timeout 10 --max-time 60 --retry 3 --retry-delay 1 --retry-max-time 60 --proto '=https' --tlsv1.2 https://raw.githubusercontent.com/hraness/hra/v0.1.0/src/install-preflight-runtime.ts | bun -e 'const[a,h]=process.argv.slice(1);const b=await Bun.stdin.bytes();const d=new Bun.CryptoHasher("sha256").update(b).digest("hex");if(d!==h)throw new Error("The tagged HRA preflight digest is invalid.");const j=new Bun.Transpiler({loader:"ts",target:"bun"}).transformSync(b);const u=URL.createObjectURL(new Blob([j],{type:"text/javascript"}));try{const m=await import(u);await m.installHraRelease(a);process.stdout.write(`${m.HRA_INSTALL_SUCCESS}\n`);}finally{URL.revokeObjectURL(u)}' -- https://github.com/hraness/hra/releases/download/v0.1.0/hraness-hra-0.1.0.tgz 61049ecbe2fdb7ea89fcf80740597ca349fc7f62e09230f67519f15bb9fc7796)" = hra-install-safe hra --version hra doctor --offline ``` @@ -36,7 +36,7 @@ Before replacing the installed binary, stop the persistent daemon and confirm th ```text hra daemon stop hra daemon status --json -test "$(curl -fsSL --connect-timeout 10 --max-time 60 --retry 3 --retry-delay 1 --retry-max-time 60 --proto '=https' --tlsv1.2 https://raw.githubusercontent.com/hraness/hra/v0.1.0/src/install-preflight-runtime.ts | bun -e 'const[a,h]=process.argv.slice(1);const b=await Bun.stdin.bytes();const d=new Bun.CryptoHasher("sha256").update(b).digest("hex");if(d!==h)throw new Error("The tagged HRA preflight digest is invalid.");const j=new Bun.Transpiler({loader:"ts",target:"bun"}).transformSync(b);const u=URL.createObjectURL(new Blob([j],{type:"text/javascript"}));try{const m=await import(u);await m.installHraRelease(a);process.stdout.write(`${m.HRA_INSTALL_SUCCESS}\n`);}finally{URL.revokeObjectURL(u)}' -- https://github.com/hraness/hra/releases/download/v0.1.0/hra-v0.1.0.tgz facdcd4c3ce6a02590b533a92e661e06e63b4f4709f71f48cb5906a29d40fa21)" = hra-install-safe +test "$(curl -fsSL --connect-timeout 10 --max-time 60 --retry 3 --retry-delay 1 --retry-max-time 60 --proto '=https' --tlsv1.2 https://raw.githubusercontent.com/hraness/hra/v0.1.0/src/install-preflight-runtime.ts | bun -e 'const[a,h]=process.argv.slice(1);const b=await Bun.stdin.bytes();const d=new Bun.CryptoHasher("sha256").update(b).digest("hex");if(d!==h)throw new Error("The tagged HRA preflight digest is invalid.");const j=new Bun.Transpiler({loader:"ts",target:"bun"}).transformSync(b);const u=URL.createObjectURL(new Blob([j],{type:"text/javascript"}));try{const m=await import(u);await m.installHraRelease(a);process.stdout.write(`${m.HRA_INSTALL_SUCCESS}\n`);}finally{URL.revokeObjectURL(u)}' -- https://github.com/hraness/hra/releases/download/v0.1.0/hraness-hra-0.1.0.tgz 61049ecbe2fdb7ea89fcf80740597ca349fc7f62e09230f67519f15bb9fc7796)" = hra-install-safe hra --version hra doctor --offline hra daemon start diff --git a/bun.lock b/bun.lock index 5f5b0fa..f4e6063 100644 --- a/bun.lock +++ b/bun.lock @@ -25,6 +25,7 @@ "fast-check": "4.3.0", "react": "19.2.8", "react-dom": "19.2.8", + "sigstore": "4.1.1", "typescript": "5.9.2", "typescript-eslint": "8.67.0", }, @@ -105,6 +106,8 @@ "@eslint/plugin-kit": ["@eslint/plugin-kit@0.3.5", "", { "dependencies": { "@eslint/core": "^0.15.2", "levn": "^0.4.1" } }, "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w=="], + "@gar/promise-retry": ["@gar/promise-retry@1.0.3", "", {}, "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA=="], + "@hraness/design-kit": ["@hraness/design-kit@github:hraness/design-kit#151c726", { "dependencies": { "@hugeicons/core-free-icons": "^4.2.2", "@stylexjs/stylex": "0.19.0", "motion": "12.42.0", "next-themes": "0.4.6", "react-aria-components": "1.19.0", "recharts": "3.8.0", "sugar-high": "^1.2.1", "web-haptics": "0.0.6" }, "peerDependencies": { "@hraness/ui": ">=0.4.7 <0.5.0", "react": ">=18 <20", "react-dom": ">=18 <20" }, "optionalPeers": ["@hraness/ui"] }, "hraness-design-kit-151c726", "sha512-JSBx+9Usxrlh6RzEVw/ENwOj4jZ5vHZTQfkAwkeCLUTurclcB0La559llRA1DXFlchwBhy8MEZiZDu45RPu/ZA=="], "@hraness/oh": ["@hraness/oh@github:hraness/oh#89fb133", { "peerDependencies": { "@libsql/client": ">=0.17.4 <1", "@suss/datalog": "0.20.0", "@tobilu/qmd": "2.5.3" }, "optionalPeers": ["@libsql/client", "@suss/datalog", "@tobilu/qmd"], "bin": { "oh": "./dist/cli.js" } }, "hraness-oh-89fb133", "sha512-huk8DqOAhnwMm4QAOU+JiyxfdfUF0pZth1jnzOkOmZQe6NqZjR+LXAe2swIVdBMn1hCHb47+c7IqFH3QUTBWTQ=="], @@ -133,6 +136,12 @@ "@internationalized/string": ["@internationalized/string@3.2.10", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-PDx6//vHSpRnHfxqMqto11zQvhsaU74O3mKv2F/0eicGZcl9NLjQmGlbHz/LsJh5tLKp4A4L7ZVTzN1/MmMTvA=="], + "@npmcli/agent": ["@npmcli/agent@4.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.1", "lru-cache": "^11.2.1", "socks-proxy-agent": "^8.0.3" } }, "sha512-EUEuWAxnL07Sp5/iC/1X6Xj+XThUvnbei9zfRWZdEXa7lss9RTHMhAHBeg+MZ5To9s/gGaSI+UwZTPdYMvKSeg=="], + + "@npmcli/fs": ["@npmcli/fs@5.0.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-7OsC1gNORBEawOa5+j2pXN9vsicaIOH5cPXxoR6fJOmH6/EXpJB2CajXOu1fPRFun2m1lktEFX11+P89hqO/og=="], + + "@npmcli/redact": ["@npmcli/redact@4.0.0", "", {}, "sha512-gOBg5YHMfZy+TfHArfVogwgfBeQnKbbGo3pSUyK/gSI0AVu+pEiDVcKlQb0D8Mg1LNRZILZ6XG8I5dJ4KuAd9Q=="], + "@openai/codex": ["@openai/codex@0.149.0", "", { "optionalDependencies": { "@openai/codex-darwin-arm64": "npm:@openai/codex@0.149.0-darwin-arm64", "@openai/codex-darwin-x64": "npm:@openai/codex@0.149.0-darwin-x64", "@openai/codex-linux-arm64": "npm:@openai/codex@0.149.0-linux-arm64", "@openai/codex-linux-x64": "npm:@openai/codex@0.149.0-linux-x64", "@openai/codex-win32-arm64": "npm:@openai/codex@0.149.0-win32-arm64", "@openai/codex-win32-x64": "npm:@openai/codex@0.149.0-win32-x64" }, "bin": { "codex": "bin/codex.js" } }, "sha512-i4dryj2Y1j+00Mb5n+0n71EYnTK9/KDc2cdFo/dXD0d1oTog2bhUssKDEIOnKmnEf51P0Z/HJTWvTKw/UHyOvQ=="], "@openai/codex-darwin-arm64": ["@openai/codex@0.149.0-darwin-arm64", "", { "os": "darwin", "cpu": "arm64" }, "sha512-GsZJbzBWiD48RETrO8VHGAQNgfSrUVxItXZFeD87wswatPi0+lKuQo8Dx4nMYmOZhZrVtwr3al/feRrZxnDV8Q=="], @@ -161,6 +170,18 @@ "@reduxjs/toolkit": ["@reduxjs/toolkit@2.12.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw=="], + "@sigstore/bundle": ["@sigstore/bundle@4.0.0", "", { "dependencies": { "@sigstore/protobuf-specs": "^0.5.0" } }, "sha512-NwCl5Y0V6Di0NexvkTqdoVfmjTaQwoLM236r89KEojGmq/jMls8S+zb7yOwAPdXvbwfKDlP+lmXgAL4vKSQT+A=="], + + "@sigstore/core": ["@sigstore/core@3.2.1", "", {}, "sha512-qRsxPnCrbC/puegGxKuynfnxgLiHqWStrSjxkoB4YKqq3Z3s4cyZyj42ZdWFAEblNP65C+rBH8EuREHIXoi83g=="], + + "@sigstore/protobuf-specs": ["@sigstore/protobuf-specs@0.5.2", "", {}, "sha512-SQqvFMt4V78fdjcDdYX6HbiVSOR4QK3ZgwCa2KOsopAgPIHy1rU5UDUmzLl02r5oyyaYcYHR1hpwDRk/yUe+Mw=="], + + "@sigstore/sign": ["@sigstore/sign@4.1.1", "", { "dependencies": { "@gar/promise-retry": "^1.0.2", "@sigstore/bundle": "^4.0.0", "@sigstore/core": "^3.2.0", "@sigstore/protobuf-specs": "^0.5.0", "make-fetch-happen": "^15.0.4", "proc-log": "^6.1.0" } }, "sha512-Hf4xglukg0XXQ2RiD5vSoLjdPe8OBUPA8XeVjUObheuDcWdYWrnH/BNmxZCzkAy68MzmNCxXLeurJvs6hcP2OQ=="], + + "@sigstore/tuf": ["@sigstore/tuf@4.0.2", "", { "dependencies": { "@sigstore/protobuf-specs": "^0.5.0", "tuf-js": "^4.1.0" } }, "sha512-TCAzTy0xzdP79EnxSjq9KQ3eaR7+FmudLC6eRKknVKZbV7ZNlGLClAAQb/HMNJ5n2OBNk2GT1tEmU0xuPr+SLQ=="], + + "@sigstore/verify": ["@sigstore/verify@3.1.1", "", { "dependencies": { "@sigstore/bundle": "^4.0.0", "@sigstore/core": "^3.2.1", "@sigstore/protobuf-specs": "^0.5.0" } }, "sha512-qv7+G3J2cc6wwFj3yKvXOamzqhMwSk1ogPGmhpS8iXllcPrJaIIBA+4HbttlHVu1pqWTdmaCH/WE7UOC51kdoA=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], @@ -169,6 +190,10 @@ "@swc/helpers": ["@swc/helpers@0.5.23", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw=="], + "@tufjs/canonical-json": ["@tufjs/canonical-json@2.0.0", "", {}, "sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA=="], + + "@tufjs/models": ["@tufjs/models@4.1.0", "", { "dependencies": { "@tufjs/canonical-json": "2.0.0", "minimatch": "^10.1.1" } }, "sha512-Y8cK9aggNRsqJVaKUlEYs4s7CvQ1b1ta2DVPyAimb0I2qhzjNk+A+mxvll/klL0RlfuIUei8BF7YWiua4kQqww=="], + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], @@ -225,6 +250,8 @@ "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -239,6 +266,8 @@ "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "cacache": ["cacache@20.0.4", "", { "dependencies": { "@npmcli/fs": "^5.0.0", "fs-minipass": "^3.0.0", "glob": "^13.0.0", "lru-cache": "^11.1.0", "minipass": "^7.0.3", "minipass-collect": "^2.0.1", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "p-map": "^7.0.2", "ssri": "^13.0.0" } }, "sha512-M3Lab8NPYlZU2exsL3bMVvMrMqgwCnMWfdZbK28bn3pK6APT/Te/I8hjRPNu1uwORY9a1eEQoifXbKPQMfMTOA=="], + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -253,6 +282,8 @@ "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + "content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + "convex": ["convex@1.45.0", "", { "dependencies": { "esbuild": "0.27.0", "prettier": "^3.0.0", "ws": "8.21.0" }, "peerDependencies": { "@auth0/auth0-react": "^2.0.1", "@clerk/clerk-react": "^4.12.8 || ^5.0.0", "@clerk/react": "^6.4.3", "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" }, "optionalPeers": ["@auth0/auth0-react", "@clerk/clerk-react", "@clerk/react", "react"], "bin": { "convex": "bin/main.js" } }, "sha512-AV3B56Ptu/14d76g3urBJ50dwpiZa6uJaLT84OWox5aCwZIJiuAamdjv3lLHDzs8vv5kC31anEZohhUe3qJ2bA=="], "convex-test": ["convex-test@0.0.56", "", { "peerDependencies": { "convex": "^1.43.0" } }, "sha512-dLYXlQjKFoGqvjAmPzyLgb2HsYI7iLo1iuNTU2DZmSrMRLWosYk94erjSU67GG5ovfP6+MjX8RJO+ebhmL6QYA=="], @@ -337,12 +368,24 @@ "framer-motion": ["framer-motion@12.43.0", "", { "dependencies": { "motion-dom": "^12.43.0", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-1eaL3RvR/kAlbG7UYcpMptEyzPoENO0c6w7ZnB3/hh2vSAz/6uGAFn6fdoqTBguNstf3MsFhJHsD/0DHiclG+g=="], + "fs-minipass": ["fs-minipass@3.0.3", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw=="], + + "glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], "globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + "http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="], + + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="], + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="], @@ -355,6 +398,8 @@ "invariant": ["invariant@2.2.4", "", { "dependencies": { "loose-envify": "^1.0.0" } }, "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA=="], + "ip-address": ["ip-address@10.7.0", "", {}, "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA=="], + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], @@ -387,10 +432,28 @@ "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + "lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], + "lucia": ["lucia@3.2.2", "", { "dependencies": { "@oslojs/crypto": "^1.0.1", "@oslojs/encoding": "^1.1.0" } }, "sha512-P1FlFBGCMPMXu+EGdVD9W4Mjm0DqsusmKgO7Xc33mI5X1bklmsQb0hfzPhXomQr9waWIBDsiOjvr1e6BTaUqpA=="], + "make-fetch-happen": ["make-fetch-happen@15.0.6", "", { "dependencies": { "@gar/promise-retry": "^1.0.0", "@npmcli/agent": "^4.0.0", "@npmcli/redact": "^4.0.0", "cacache": "^20.0.1", "http-cache-semantics": "^4.1.1", "minipass": "^7.0.2", "minipass-fetch": "^5.0.0", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "negotiator": "^1.0.0", "proc-log": "^6.0.0", "ssri": "^13.0.0" } }, "sha512-Je0fLJ0F5atA7F+eIlLzk+Wkcl57JDf4kf+EW8xiP5E31xOQxkIxTbgf1Oi1Lw9tRI9UEMRdI5Vz2xTzoNU1Jw=="], + "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + + "minipass-collect": ["minipass-collect@2.0.1", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw=="], + + "minipass-fetch": ["minipass-fetch@5.0.2", "", { "dependencies": { "minipass": "^7.0.3", "minipass-sized": "^2.0.0", "minizlib": "^3.0.1" }, "optionalDependencies": { "iconv-lite": "^0.7.2" } }, "sha512-2d0q2a8eCi2IRg/IGubCNRJoYbA1+YPXAzQVRFmB45gdGZafyivnZ5YSEfo3JikbjGxOdntGFvBQGqaSMXlAFQ=="], + + "minipass-flush": ["minipass-flush@1.0.7", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA=="], + + "minipass-pipeline": ["minipass-pipeline@1.2.4", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A=="], + + "minipass-sized": ["minipass-sized@2.0.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-zSsHhto5BcUVM2m1LurnXY6M//cGhVaegT71OfOXoprxT6o780GZd792ea6FfrQkuU4usHZIUczAQMRUE2plzA=="], + + "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], + "motion": ["motion@12.42.0", "", { "dependencies": { "framer-motion": "^12.42.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-Qhwvu9sVl5/URSq5CNzwMCpSKK8Uhnrwb6VO977kZyj/wOCS7mWebJUnBoHx5cZU1Zv8a9BD5CSICWKAlrLJgA=="], "motion-dom": ["motion-dom@12.43.0", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag=="], @@ -401,6 +464,8 @@ "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + "negotiator": ["negotiator@1.1.0", "", { "dependencies": { "content-type": "^2.1.0" } }, "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg=="], + "next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="], "oauth4webapi": ["oauth4webapi@3.8.7", "", {}, "sha512-4RxcKxXjuItDFZ20RRPf4YTw3kpeXJyCgJFxVzJ068A7PNJ18st2Dg90tlC1LkSDS0GecroagCLHYEIVUhCAkw=="], @@ -411,12 +476,16 @@ "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + "p-map": ["p-map@7.0.7", "", {}, "sha512-VaWRu2i4FJNRtiRWCuuQRgfQ1B7a6+gMSrO+3j0EQi/k0ULfS9kosRxGoiqwzIjZTDI02tGfk5mXXltLg6QtfQ=="], + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + "path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], + "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], "picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], @@ -429,6 +498,8 @@ "prettier": ["prettier@3.9.6", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g=="], + "proc-log": ["proc-log@6.1.0", "", {}, "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ=="], + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], "pure-rand": ["pure-rand@7.0.1", "", {}, "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ=="], @@ -457,6 +528,8 @@ "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], @@ -467,6 +540,16 @@ "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "sigstore": ["sigstore@4.1.1", "", { "dependencies": { "@sigstore/bundle": "^4.0.0", "@sigstore/core": "^3.2.1", "@sigstore/protobuf-specs": "^0.5.0", "@sigstore/sign": "^4.1.1", "@sigstore/tuf": "^4.0.2", "@sigstore/verify": "^3.1.1" } }, "sha512-endqECJkfhozrXMK5ngu/UAA0xVcVEFdnHJCElGaExypjW+HK5i6zu3NteLoaX/iFbRUbC3+DjttQs0GARr+5w=="], + + "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], + + "socks": ["socks@2.8.9", "", { "dependencies": { "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" } }, "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw=="], + + "socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="], + + "ssri": ["ssri@13.0.1", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ=="], + "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], "styleq": ["styleq@0.2.1", "", {}, "sha512-L0TR0NQb+X4/ktDEKmjWyp27gla+LUYi/by5k5SjKXf6/pvZP7wbwEB5J+tqxdFVPgzbsuz+d4RTScO/QZquBw=="], @@ -485,6 +568,8 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "tuf-js": ["tuf-js@4.1.0", "", { "dependencies": { "@tufjs/models": "4.1.0", "debug": "^4.4.3", "make-fetch-happen": "^15.0.1" } }, "sha512-50QV99kCKH5P/Vs4E2Gzp7BopNV+KzTXqWeaxrfu5IQJBOULRsTIS9seSsOVT8ZnGXzCyx55nYWAi4qJzpZKEQ=="], + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], "typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="], @@ -507,6 +592,8 @@ "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], + "yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], @@ -517,14 +604,30 @@ "@reduxjs/toolkit/immer": ["immer@11.1.18", "", {}, "sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ=="], + "@tufjs/models/minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.6", "", {}, "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw=="], "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + "glob/minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], + + "minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "@tufjs/models/minimatch/brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="], + "glob/minimatch/brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="], + + "@tufjs/models/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], } } diff --git a/docs/beta-release-notes.md b/docs/beta-release-notes.md index 7ccff6c..a7bd355 100644 --- a/docs/beta-release-notes.md +++ b/docs/beta-release-notes.md @@ -7,7 +7,7 @@ HRA is a persistent Codex CLI for isolated accounts, live session control, and o Install the immutable beta tag with Bun 1.3.14: ```sh -test "$(curl -fsSL --connect-timeout 10 --max-time 60 --retry 3 --retry-delay 1 --retry-max-time 60 --proto '=https' --tlsv1.2 https://raw.githubusercontent.com/hraness/hra/v0.1.0/src/install-preflight-runtime.ts | bun -e 'const[a,h]=process.argv.slice(1);const b=await Bun.stdin.bytes();const d=new Bun.CryptoHasher("sha256").update(b).digest("hex");if(d!==h)throw new Error("The tagged HRA preflight digest is invalid.");const j=new Bun.Transpiler({loader:"ts",target:"bun"}).transformSync(b);const u=URL.createObjectURL(new Blob([j],{type:"text/javascript"}));try{const m=await import(u);await m.installHraRelease(a);process.stdout.write(`${m.HRA_INSTALL_SUCCESS}\n`);}finally{URL.revokeObjectURL(u)}' -- https://github.com/hraness/hra/releases/download/v0.1.0/hra-v0.1.0.tgz facdcd4c3ce6a02590b533a92e661e06e63b4f4709f71f48cb5906a29d40fa21)" = hra-install-safe +test "$(curl -fsSL --connect-timeout 10 --max-time 60 --retry 3 --retry-delay 1 --retry-max-time 60 --proto '=https' --tlsv1.2 https://raw.githubusercontent.com/hraness/hra/v0.1.0/src/install-preflight-runtime.ts | bun -e 'const[a,h]=process.argv.slice(1);const b=await Bun.stdin.bytes();const d=new Bun.CryptoHasher("sha256").update(b).digest("hex");if(d!==h)throw new Error("The tagged HRA preflight digest is invalid.");const j=new Bun.Transpiler({loader:"ts",target:"bun"}).transformSync(b);const u=URL.createObjectURL(new Blob([j],{type:"text/javascript"}));try{const m=await import(u);await m.installHraRelease(a);process.stdout.write(`${m.HRA_INSTALL_SUCCESS}\n`);}finally{URL.revokeObjectURL(u)}' -- https://github.com/hraness/hra/releases/download/v0.1.0/hraness-hra-0.1.0.tgz 61049ecbe2fdb7ea89fcf80740597ca349fc7f62e09230f67519f15bb9fc7796)" = hra-install-safe hra --version hra doctor --offline hra init --yes diff --git a/docs/beta-release.md b/docs/beta-release.md index f141c34..4330b88 100644 --- a/docs/beta-release.md +++ b/docs/beta-release.md @@ -1,18 +1,35 @@ -# Retired beta release runbook +# Public CLI release control -Status: retired on 2026-08-27 without publication. +Status: prepared but blocked before publication. The former `v0.1.0` beta process depended on the HRA v0 Vercel deployment, its public fallback, and its paired provider readbacks. That dependency became invalid when HRA v0's Vercel and Convex resources were permanently retired. At retirement, `hraness/hra` had no `v0.1.0` tag, no draft release for that tag, and no published `v0.1.0` release. -The active Release workflow and the public `release:candidate` and `release:publish` package entries were removed. The implementation and deterministic tests under `scripts/` remain only as a safety and design record. They must not be invoked directly to create a candidate, tag, draft, workflow lease, publication, or provider mutation. +The former public `release:candidate` and `release:publish` package entries remain removed. Their fallback-bound implementation and deterministic tests under `scripts/` remain only as a safety and design record. They must not be invoked directly to create a candidate, tag, draft, workflow lease, publication, or provider mutation. -Current HRA has no authorized publication path. Before publishing any version, design and review a new current-project-only path that: +The replacement `.github/workflows/release.yml` is an artifact-only current-repository path. It does not read or mutate Vercel, Convex, DNS, hosted aliases, or any retired HRA v0 resource. It requires an immutable annotated stable version tag whose peeled commit is contained in reviewed `main`, runs the complete repository gate, builds one npm tarball, verifies that same tarball on macOS and Linux, publishes it through npm trusted publishing, creates an immutable GitHub Release from the same tarball plus `SHA256SUMS`, and admits the public bytes and provenance before success. -- targets only the current HRA repository, Vercel project, Convex project, and Convex production deployment; -- rejects retired HRA v0 project and deployment identities; -- does not require a fallback alias, reverse cutover, or HRA v0 marker; -- defines new candidate, tag, artifact, publication, recovery, and live-acceptance contracts; -- exposes only the operator entries and GitHub workflow needed by that reviewed design; and -- proves the path with deterministic tests and exact live provider readback before its first tag or release. +Publication is still blocked. `@hraness/oh` is a GitHub runtime dependency and has no public npm coordinate, while the replacement gate accepts only exact registry runtime versions. The `@hraness/hra` npm package also must exist before its GitHub trusted publisher can be configured. Do not create an HRA version tag or attempt publication until Oh is published and pinned by exact registry version, the first-package bootstrap is reviewed, trusted publishing names this repository and `release.yml`, and a clean release rehearsal passes. -Until that replacement is accepted and implemented, do not create an HRA version tag, draft, release, or public install claim. Preserve any old local receipts, intents, and evidence files as historical records; they do not authorize replay. +The README and website remain explicit that the beta is not live. This control-layer preparation does not make the displayed install command usable and does not authorize a tag, draft, Release, npm publication, website claim change, or hosted-service mutation. Preserve old local receipts, intents, and evidence files as historical records; they do not authorize replay. + +## Repository release governance + +Release automation assumes GitHub protects `main` with required pull-request review, +required CODEOWNERS review for release-authority files, required successful CI, dismissal +of stale approvals, conversation resolution, linear history, and administrator enforcement. +The repository must also protect `v*` tags against direct creation, update, force-push, +and deletion. A release operator creates one annotated stable-semver tag only after the +reviewed commit is the protected `main` head; no workflow, administrator, or retry path +may bypass those rules. The workflow independently binds the exact tag ref, annotated +tag object, peeled commit, checked-out commit, and ancestry in current protected `main`. +Any positive rerun attempt may finish the same release only after re-proving that exact +tag object, commit, artifact checksum, and `main` ancestry; it may never substitute bytes, +a tag, a commit, or a different workflow run. + +The first npm publication is a separate bootstrap ceremony because npm cannot attach a +trusted publisher to a package coordinate that does not yet exist. That bootstrap must +publish a non-`latest` prerelease under explicit operator approval and then configure the +npm trusted publisher for `hraness/hra` and `.github/workflows/release.yml`. A later, +separately versioned stable release is the first OIDC/provenance publication; the stable +workflow never silently performs the bootstrap or converts a bootstrap version into +`latest`. diff --git a/kb/plans/hra-v1.md b/kb/plans/hra-v1.md index e84137f..fa2495e 100644 --- a/kb/plans/hra-v1.md +++ b/kb/plans/hra-v1.md @@ -370,13 +370,13 @@ Published beta quota constants are 16 devices, 32 Codex accounts, 10,000 session ## Repository and release contract - Public repository: `hraness/hra`, numeric repository ID `1343008607` after the source repository is renamed in place. -- Bun package name and binary: `hra`. No public package release exists yet. A future install source must be designed as part of the replacement current-project-only publication path, never inferred from the retired runbook or a moving branch. +- Bun package name: `@hraness/hra`. Binary: `hra`. No public package release exists yet. The replacement publication control accepts only exact registry runtime dependencies, one build-once npm tarball, and the same admitted bytes on npm and an immutable GitHub Release. - License: MIT. Retain required notices for pinned dependencies and generated protocol material. - One Bun 1.3.14 lockfile. - Website: `hra.sh`, generated from the same content contract as `README.md`. - The first website line after the product name is the real install command. - Generation-1 `hra.sh` may ship standalone reading pages that are not README sections. Shipped pages are `/reading/deepseek-harness/` and `/reading/headlong-microharness/`. Do not link generation-0 `/reading/*` paths from this host; those pages live on `hraness/hra-v0` and 404 here. -- The former artifact, candidate, tag, workflow-lease, and publication design is retained only in source tests and historical evidence. Its public package entries and active Release workflow are removed because the design depended on HRA v0 as a live fallback. +- The former candidate, workflow-lease, fallback, and publication design is retained only in source tests and historical evidence. Its public package entries remain removed because the design depended on HRA v0 as a live fallback. The replacement Release workflow has no Vercel, Convex, DNS, alias, or HRA v0 capability; it is limited to the current repository, public npm coordinate, exact tarball, checksum, immutable GitHub Release, and public readback. - At the 2026-08-27 retirement decision, `hraness/hra` had no `v0.1.0` tag, no draft release for that tag, and no published `v0.1.0` release. Do not create a tag, draft, release, install claim, or friend-facing publication until a replacement design is accepted and implemented. - The replacement publication path must use only current HRA provider identities. It must define candidate sealing, immutable tag and artifact contracts, workflow authority, publication recovery, domain handling, and live acceptance without an HRA v0 deployment, fallback alias, reverse cutover, or marker. It must restore public operator entries only after their new contracts and deterministic tests exist. @@ -603,6 +603,7 @@ The beta requires all of these scenarios: - 2026-08-28: Generation-1 `hra.sh` adds a second standalone sourced reading page at `/reading/headlong-microharness/`. The page is original HRA prose that treats Headlong as a persistence microharness, not as this host's Codex account loop. It is listed in `sitemap.xml` and `llms.txt`, linked from the homepage, and linked from `/reading/deepseek-harness/`. It is not a README section, not the Hraness Reading digest, and not the generation-0 always-on-loop URL. It does not link `/reading/headlong-always-on-loop` or `/reading/not-a-codex-tui`. - 2026-08-27: HRA v0's Vercel and Convex resources were permanently retired by explicit user decision, leaving `hraness/hra-v0` as the archived source and release-history reference. The current repository removes `hosted:domain-cutover`, `release:candidate`, and `release:publish` from its package scripts and removes the active Release workflow. `docs/domain-cutover.md` and `docs/beta-release.md` now record retirement instead of executable procedures. At this boundary, `hraness/hra` had no `v0.1.0` tag, no draft release, and no published `v0.1.0` release. Numeric v0 Convex project ID `2680173` and deployment ID `4677913` remain denylisted safety tombstones, and local v0 coexistence behavior remains unchanged. Publication is blocked until a new current-project-only protocol is designed, implemented, and accepted. - 2026-08-29: Read-only Vercel provider evidence binds public `hra.sh` to current project deployment `dpl_7pK5Y4G5G6rrNWzExGCYCjr6kMKN` at source `31e5d5f3b9c1731ecc26b699796f4a9f2012d856`, while merged illustrated-reading source `80c20f7b1aa06aaec4a8bc03dbea249911de4717` is `READY` production deployment `dpl_5um4zKKeN7WhLT58xoycxRkeoVKZ`. Both records identify current project `prj_8ciIt9t9foE3utG45frRN7cxckjS`, repository ID `1343008607`, and `main`; project readback keeps automatic custom-domain assignment disabled. The new strict plan and `release:canonical-alias` operator bind those records plus current Convex production, reject every retired numeric identity, require a separately confirmed exact alias record, and serialize all plans for one designated Linux host and account behind a protected machine-local lock. It publishes a self-digested source-authority intent before dispatch, requires the target response's prior deployment to be the exact source, and publishes a terminal target or restored-source receipt. Ambiguous responses and receipt-less intents hard-stop without another write; acknowledged target proof failure reasserts only the exact current-project source. The retired domain-cutover executable and public operator entry point now return `operator_retired`; the historical module retains no built-in provider runner, and its parser/state-machine tests require an explicitly supplied effect capability. No alias, DNS, domain-ownership, Convex, Resend, tag, or release mutation occurred. +- 2026-08-30: Public CLI release control is prepared without publication. The package identity is scoped as `@hraness/hra` under MIT, the atomic installer understands the scoped package layout, and a new artifact-only workflow builds one tarball, verifies those exact bytes on macOS and Linux, requires npm OIDC provenance, creates an immutable GitHub Release from the same tarball plus `SHA256SUMS`, and admits both public copies. The retired fallback-bound publisher remains unreachable from package scripts and the workflow. Publication remains blocked because runtime dependency `@hraness/oh` is still GitHub-only and has no public npm release; no HRA tag, npm version, GitHub Release, website availability claim, or provider mutation was created by this preparation. ## Review findings diff --git a/package.json b/package.json index de4e7b3..0329e1f 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,12 @@ { - "name": "hra", + "name": "@hraness/hra", "version": "0.1.0", "description": "A persistent Codex CLI for isolated accounts, live sessions, and encrypted device sync.", "license": "MIT", + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org" + }, "type": "module", "exports": { ".": "./src/index.ts" @@ -83,6 +87,7 @@ "fast-check": "4.3.0", "react": "19.2.8", "react-dom": "19.2.8", + "sigstore": "4.1.1", "typescript": "5.9.2", "typescript-eslint": "8.67.0" } diff --git a/scripts/check-npm-trusted-publishing.ts b/scripts/check-npm-trusted-publishing.ts new file mode 100644 index 0000000..085cb96 --- /dev/null +++ b/scripts/check-npm-trusted-publishing.ts @@ -0,0 +1,20 @@ +const child = Bun.spawn(["npm", "--version"], { stderr: "pipe", stdout: "pipe" }); +const timer = setTimeout(() => child.kill(9), 10_000); +const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), +]).finally(() => clearTimeout(timer)); +if (exitCode !== 0 || stdout.length > 128 || stderr.length > 1_024) { + throw new Error("npm --version did not return one bounded successful result."); +} +const version = stdout.trim(); +const match = /^([0-9]+)\.([0-9]+)\.([0-9]+)$/u.exec(version); +if (match === null) throw new Error("npm returned an invalid version."); +const major = Number(match[1]); +const minor = Number(match[2]); +const patch = Number(match[3]); +if (major < 11 || (major === 11 && (minor < 5 || (minor === 5 && patch < 1)))) { + throw new Error(`npm ${version} is too old for trusted publishing; require >=11.5.1.`); +} +console.log(`npm ${version} supports trusted publishing.`); diff --git a/scripts/check-package.test.ts b/scripts/check-package.test.ts index 8b3f143..acf568b 100644 --- a/scripts/check-package.test.ts +++ b/scripts/check-package.test.ts @@ -369,10 +369,10 @@ describe("installed package generic command ownership", () => { const source = await readFile(join(import.meta.dir, "check-package.ts"), "utf8"); expect(source).toContain("const run = runPackageCommand;"); for (const command of [ - '["pm", "pack", "--ignore-scripts"', + 'await run("npm", ["pack", "--ignore-scripts", "--pack-destination"', '["-xzpf", archive, "-C", inspectionDirectory]', '["add", "--backend=copyfile", "--ignore-scripts", archive]', - '["-e", "await import(\'hra\')"]', + '["-e", "await import(\'@hraness/hra\')"]', 'run(executable, ["--help"]', 'run(executable, ["--version"]', 'run(executable, ["doctor", "--offline", "--json"]', diff --git a/scripts/check-package.ts b/scripts/check-package.ts index fa8fc4e..948cabf 100644 --- a/scripts/check-package.ts +++ b/scripts/check-package.ts @@ -33,7 +33,7 @@ import { assertPublicText, assertPublicTree, } from "./public-text-policy"; -import { assertProductionPackageOnly } from "./package-policy"; +import { assertProductionPackageOnly, assertReviewedReleaseInventory } from "./package-policy"; import { assertPseudoTerminalSuccess, PTY_BEGIN_MARKER, @@ -47,7 +47,12 @@ const packageSchema = z.object({ exports: z.object({ ".": z.literal("./src/index.ts") }).strict(), files: z.array(z.string()).min(1), homepage: z.literal("https://hra.sh"), - name: z.literal("hra"), + license: z.literal("MIT"), + name: z.literal("@hraness/hra"), + publishConfig: z.object({ + access: z.literal("public"), + registry: z.literal("https://registry.npmjs.org"), + }).strict(), repository: z.object({ type: z.literal("git"), url: z.literal("git+https://github.com/hraness/hra.git"), @@ -571,7 +576,7 @@ const terminateOwnedInstalledDaemon = async (daemon: OwnedInstalledDaemon): Prom }); }; -export async function checkPackage(): Promise { +export async function checkPackage(suppliedArchive?: string): Promise { const repositoryRoot = resolve(import.meta.dir, ".."); const packageJson = packageSchema.parse( JSON.parse(await readFile(join(repositoryRoot, "package.json"), "utf8")) as unknown, @@ -619,11 +624,30 @@ try { } await symlink(process.execPath, join(runtimeBin, "bun")); - requireSuccess( - "package archive creation", - await run(process.execPath, ["pm", "pack", "--ignore-scripts", "--destination", packageDirectory], { cwd: repositoryRoot }), - ); - const archive = join(packageDirectory, `${packageJson.name}-${packageJson.version}.tgz`); + const expectedArchiveName = `hraness-hra-${packageJson.version}.tgz`; + let archive: string; + if (suppliedArchive === undefined) { + requireSuccess( + "package archive creation", + await run("npm", ["pack", "--ignore-scripts", "--pack-destination", packageDirectory, "."], { cwd: repositoryRoot }), + ); + archive = join(packageDirectory, expectedArchiveName); + } else { + archive = resolve(suppliedArchive); + const archiveMetadata = await lstat(archive); + if ( + archive !== suppliedArchive + || basename(archive) !== expectedArchiveName + || !archiveMetadata.isFile() + || archiveMetadata.isSymbolicLink() + || archiveMetadata.nlink !== 1 + || archiveMetadata.size < 1 + || archiveMetadata.size > 64 * 1024 * 1024 + || await realpath(archive) !== archive + ) { + throw new Error(`Supplied package archive must be one exact bounded ${expectedArchiveName} regular file.`); + } + } const inspectionDirectory = join(temporaryRoot, "inspection"); await mkdir(inspectionDirectory, { recursive: true, mode: 0o700 }); requireSuccess( @@ -632,6 +656,7 @@ try { ); await assertPublicTree(inspectionDirectory); await assertProductionPackageOnly(inspectionDirectory); + await assertReviewedReleaseInventory(join(inspectionDirectory, "package")); assertHraInstallManifest( JSON.parse(await readFile(join(inspectionDirectory, "package", "package.json"), "utf8")) as unknown, ); @@ -677,7 +702,7 @@ try { env: isolatedEnvironment, }), ); - const localPackageRoot = join(consumerDirectory, "node_modules", "hra"); + const localPackageRoot = join(consumerDirectory, "node_modules", "@hraness", "hra"); const executable = join(consumerDirectory, "node_modules", ".bin", "hra"); assertHraInstallManifest( JSON.parse(await readFile(join(localPackageRoot, "package.json"), "utf8")) as unknown, @@ -694,14 +719,14 @@ try { ); await assertProductionPackageOnly(localPackageRoot, "installed"); z.object({ - dependencies: z.record(z.string(), z.string()).refine((value) => Object.hasOwn(value, "hra")), + dependencies: z.record(z.string(), z.string()).refine((value) => Object.hasOwn(value, "@hraness/hra")), trustedDependencies: z.undefined().optional(), }).passthrough().parse( JSON.parse(await readFile(join(consumerDirectory, "package.json"), "utf8")) as unknown, ); requireSuccess( "side-effect-free package import", - await run(process.execPath, ["-e", "await import('hra')"], { + await run(process.execPath, ["-e", "await import('@hraness/hra')"], { cwd: consumerDirectory, env: isolatedEnvironment, }), @@ -757,7 +782,7 @@ try { } const globalCli = await realpath(globalExecutable); const globalPackageRoot = dirname(dirname(globalCli)); - const globalVersionRoot = resolve(globalPackageRoot, "..", "..", "..", ".."); + const globalVersionRoot = resolve(globalPackageRoot, "..", "..", "..", "..", ".."); if (!globalVersionRoot.startsWith(`${join(globalInstallRoot, "install", "hra", "versions")}${sep}`)) { throw new Error("The active global HRA command is outside its protected complete-version root."); } @@ -771,14 +796,14 @@ try { await access(globalNormalizer, constants.R_OK); await assertProductionPackageOnly(globalPackageRoot, "installed"); z.object({ - dependencies: z.record(z.string(), z.string()).refine((value) => Object.hasOwn(value, "hra")), + dependencies: z.record(z.string(), z.string()).refine((value) => Object.hasOwn(value, "@hraness/hra")), trustedDependencies: z.undefined().optional(), }).passthrough().parse( JSON.parse( await readFile(join(globalVersionRoot, "install", "global", "package.json"), "utf8"), ) as unknown, ); - if (await Bun.file(join(globalInstallRoot, "install", "global", "node_modules", "hra")).exists()) { + if (await Bun.file(join(globalInstallRoot, "install", "global", "node_modules", "@hraness", "hra")).exists()) { throw new Error("The transactional global install exposed HRA in Bun's final global package path."); } const globalHelp = requireSuccess( @@ -1170,4 +1195,8 @@ try { } } -if (import.meta.main) await checkPackage(); +if (import.meta.main) { + const arguments_ = process.argv.slice(2); + if (arguments_.length > 1) throw new Error("Usage: check-package.ts [ABSOLUTE-ARTIFACT.tgz]"); + await checkPackage(arguments_[0]); +} diff --git a/scripts/check-public-release.ts b/scripts/check-public-release.ts new file mode 100644 index 0000000..3071581 --- /dev/null +++ b/scripts/check-public-release.ts @@ -0,0 +1,166 @@ +import { createHash } from "node:crypto"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; + +import { + assertReleaseAssetBytes, + parseGitHubRelease, + parseNpmRelease, + publicPackageName, + publicRepository, +} from "./release-distribution-policy"; +import { assertReleasePackageReady } from "./release-package-policy"; +import { verifyNpmProvenance } from "./verify-npm-provenance"; + +const maximumJsonBytes = 512 * 1024; +const maximumArtifactBytes = 64 * 1024 * 1024; + +function environment(name: string, pattern?: RegExp): string { + const value = process.env[name]; + if (value === undefined || value.length === 0 || (pattern !== undefined && !pattern.test(value))) { + throw new Error(`Public release admission requires a valid ${name}.`); + } + return value; +} + +async function boundedBytes(response: Response, label: string, maximum: number): Promise { + const declared = response.headers.get("content-length"); + if (declared !== null && (!/^(?:0|[1-9][0-9]*)$/u.test(declared) || Number(declared) > maximum)) { + throw new Error(`${label} exceeds its declared byte bound.`); + } + const reader = response.body?.getReader(); + if (reader === undefined) throw new Error(`${label} has no body.`); + const chunks: Uint8Array[] = []; + let length = 0; + try { + for (;;) { + const item = await reader.read(); + if (item.done) break; + length += item.value.byteLength; + if (length > maximum) throw new Error(`${label} exceeds its byte bound.`); + chunks.push(item.value); + } + } finally { + try { await reader.cancel(); } catch { /* the bounded result remains authoritative */ } + reader.releaseLock(); + } + const bytes = new Uint8Array(length); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return bytes; +} + +async function json(url: string, label: string, token?: string): Promise { + const response = await fetch(url, { + cache: "no-store", + headers: { + Accept: "application/vnd.github+json", + "Cache-Control": "no-cache", + ...(token === undefined ? {} : { Authorization: `Bearer ${token}` }), + "User-Agent": "hra-release-admission", + "X-GitHub-Api-Version": "2022-11-28", + }, + redirect: "error", + signal: AbortSignal.timeout(20_000), + }); + if (response.status !== 200) throw new Error(`${label} returned HTTP ${String(response.status)}.`); + const bytes = await boundedBytes(response, label, maximumJsonBytes); + try { + return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) as unknown; + } catch { + throw new Error(`${label} returned malformed JSON.`); + } +} + +async function artifact(url: string, label: string): Promise { + const response = await fetch(url, { + cache: "no-store", + headers: { "Cache-Control": "no-cache", "User-Agent": "hra-release-admission" }, + redirect: "follow", + signal: AbortSignal.timeout(60_000), + }); + if (response.status !== 200) throw new Error(`${label} returned HTTP ${String(response.status)}.`); + return boundedBytes(response, label, maximumArtifactBytes); +} + +if (environment("GITHUB_REPOSITORY") !== publicRepository) { + throw new Error(`Public release admission must run in ${publicRepository}.`); +} +const token = environment("GITHUB_TOKEN"); +const verifiedSha = environment("VERIFIED_SHA", /^[0-9a-f]{40}$/u); +const verifiedTag = environment("VERIFIED_TAG", /^v(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)$/u); +const runId = environment("GITHUB_RUN_ID", /^[1-9][0-9]*$/u); +const runAttempt = environment("GITHUB_RUN_ATTEMPT", /^[1-9][0-9]*$/u); +const manifest = JSON.parse(await readFile(resolve(import.meta.dir, "..", "package.json"), "utf8")) as unknown; +const inspection = assertReleasePackageReady(manifest); +if (verifiedTag !== `v${inspection.version}`) throw new Error("Release tag and package version do not agree."); + +const registry = `https://registry.npmjs.org/${encodeURIComponent(publicPackageName)}`; +const npmRelease = parseNpmRelease( + await json(`${registry}/${encodeURIComponent(inspection.version)}`, "npm exact release"), + inspection.version, +); +const npmLatest = parseNpmRelease(await json(`${registry}/latest`, "npm latest release"), inspection.version); +if (npmLatest.integrity !== npmRelease.integrity || npmLatest.shasum !== npmRelease.shasum) { + throw new Error("npm latest does not resolve to the exact admitted version."); +} +const npmBytes = await artifact(npmRelease.tarball, "npm release tarball"); +if ( + `sha512-${createHash("sha512").update(npmBytes).digest("base64")}` !== npmRelease.integrity + || createHash("sha1").update(npmBytes).digest("hex") !== npmRelease.shasum +) throw new Error("npm release bytes do not match registry integrity metadata."); +const tufCachePath = await mkdtemp(`${tmpdir()}/hra-sigstore-tuf-`); +try { + const attestationsUrl = `https://registry.npmjs.org/-/npm/v1/attestations/@hraness%2fhra@${inspection.version}`; + await verifyNpmProvenance({ + attestations: await json(attestationsUrl, "npm Sigstore attestations"), + integrity: npmRelease.integrity, + runAttempt, + runId, + sha: verifiedSha, + tag: verifiedTag, + tufCachePath, + }); +} finally { + await rm(tufCachePath, { force: true, recursive: true }); +} + +const api = `https://api.github.com/repos/${publicRepository}`; +const tagRef = await json(`${api}/git/ref/tags/${verifiedTag}`, "GitHub annotated tag ref", token) as { + object?: { sha?: unknown; type?: unknown; url?: unknown }; +}; +if ( + tagRef.object?.type !== "tag" + || typeof tagRef.object.sha !== "string" + || !/^[0-9a-f]{40}$/u.test(tagRef.object.sha) + || tagRef.object.url !== `${api}/git/tags/${tagRef.object.sha}` +) throw new Error("GitHub release ref is not one annotated tag object."); +const tag = await json(tagRef.object.url, "GitHub annotated tag", token) as { + object?: { sha?: unknown; type?: unknown }; + tag?: unknown; +}; +if (tag.tag !== verifiedTag || tag.object?.type !== "commit" || tag.object.sha !== verifiedSha) { + throw new Error("GitHub annotated tag does not target the verified release commit."); +} +const release = parseGitHubRelease( + await json(`${api}/releases/tags/${verifiedTag}`, "GitHub Release", token), + inspection.version, +); +const [githubTarball, githubChecksum] = await Promise.all([ + artifact(release.tarball.browserDownloadUrl, "GitHub Release tarball"), + artifact(release.checksum.browserDownloadUrl, "GitHub Release checksum"), +]); +assertReleaseAssetBytes( + release, + githubTarball, + githubChecksum, + (bytes) => createHash("sha256").update(bytes).digest("hex"), +); +if (!Buffer.from(githubTarball).equals(Buffer.from(npmBytes))) { + throw new Error("npm and GitHub do not expose the same exact release tarball bytes."); +} +console.log(`Public release admission passed for ${inspection.name}@${inspection.version}.`); diff --git a/scripts/check-release-package.ts b/scripts/check-release-package.ts new file mode 100644 index 0000000..bc258f4 --- /dev/null +++ b/scripts/check-release-package.ts @@ -0,0 +1,10 @@ +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +import { assertReleasePackageReady } from "./release-package-policy"; + +const manifest = JSON.parse( + await readFile(resolve(import.meta.dir, "..", "package.json"), "utf8"), +) as unknown; +const inspection = assertReleasePackageReady(manifest); +console.log(`HRA release package is registry-ready: ${inspection.name}@${inspection.version}.`); diff --git a/scripts/package-policy.ts b/scripts/package-policy.ts index 4c1a553..22ac14b 100644 --- a/scripts/package-policy.ts +++ b/scripts/package-policy.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { lstat, readdir } from "node:fs/promises"; import { join, relative } from "node:path"; @@ -123,3 +124,39 @@ export async function assertProductionPackageOnly( throw new Error("The install artifact must contain exactly one reviewed self-contained install preflight runtime."); } } + +export async function assertReviewedReleaseInventory(packageRoot: string): Promise { + const expected = Object.freeze({ + count: 104, + jsonBytes: 4_651, + sha256: "62cc0e620823d2361810d9792aa28c805eda31293ba9d6aab0a8ec3db36d0a03", + }); + const inventory: Array = []; + const visit = async (path: string): Promise => { + for (const name of (await readdir(path)).sort()) { + const child = join(path, name); + const metadata = await lstat(child); + if ( + metadata.isSymbolicLink() + || (metadata.isFile() && metadata.nlink !== 1) + || (!metadata.isDirectory() && !metadata.isFile()) + ) { + throw new Error("The package inventory contains a non-canonical filesystem object."); + } + inventory.push([ + relative(packageRoot, child).replaceAll("\\", "/"), + metadata.isDirectory() ? "directory" : "file", + metadata.mode & 0o777, + metadata.isFile() ? metadata.size : 0, + ]); + if (metadata.isDirectory()) await visit(child); + } + }; + await visit(packageRoot); + const canonical = `${JSON.stringify(inventory)}\n`; + if ( + inventory.length !== expected.count + || Buffer.byteLength(canonical) !== expected.jsonBytes + || createHash("sha256").update(canonical).digest("hex") !== expected.sha256 + ) throw new Error("The package archive path, type, mode, count, or size inventory is not the reviewed release inventory."); +} diff --git a/scripts/public-text-policy.test.ts b/scripts/public-text-policy.test.ts index 2893c30..0cab2e6 100644 --- a/scripts/public-text-policy.test.ts +++ b/scripts/public-text-policy.test.ts @@ -62,6 +62,8 @@ describe("public text policy", () => { }); test("allows only the reviewed public Hraness packages", () => { + expect(() => assertPublicText("@hraness/hra", "public dependency")) + .not.toThrow(); expect(() => assertPublicText("@hraness/design-kit", "public dependency")) .not.toThrow(); expect(() => assertPublicText("@hraness/oh", "public dependency")) diff --git a/scripts/public-text-policy.ts b/scripts/public-text-policy.ts index 2e6824c..b861138 100644 --- a/scripts/public-text-policy.ts +++ b/scripts/public-text-policy.ts @@ -16,6 +16,7 @@ const allowedPublicScopes = new Set([ ]); const allowedPublicScopedPackages = new Set([ "@hraness/design-kit", + "@hraness/hra", "@hraness/oh", "@hraness/site-footer", "@hraness/ui", diff --git a/scripts/publish-github-release.ts b/scripts/publish-github-release.ts new file mode 100644 index 0000000..6c6a3b2 --- /dev/null +++ b/scripts/publish-github-release.ts @@ -0,0 +1,91 @@ +import { createHash } from "node:crypto"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { basename, join, resolve } from "node:path"; + +import { + assertReleaseAssetBytes, + parseGitHubRelease, + publicRepository, +} from "./release-distribution-policy"; +import { assertReleasePackageReady, releaseArchiveName } from "./release-package-policy"; + +const [tag, archiveArgument, checksumArgument] = process.argv.slice(2); +if (tag === undefined || archiveArgument === undefined || checksumArgument === undefined) { + throw new Error("Usage: publish-github-release.ts TAG ARTIFACT.tgz SHA256SUMS"); +} +if (process.env.GITHUB_REPOSITORY !== publicRepository) { + throw new Error(`GitHub Release publication must run in ${publicRepository}.`); +} +const verifiedSha = process.env.VERIFIED_SHA; +if (verifiedSha === undefined || !/^[0-9a-f]{40}$/u.test(verifiedSha)) { + throw new Error("GitHub Release publication requires one verified commit."); +} +const manifest = JSON.parse(await readFile(resolve(import.meta.dir, "..", "package.json"), "utf8")) as unknown; +const inspection = assertReleasePackageReady(manifest); +const archive = resolve(archiveArgument); +const checksum = resolve(checksumArgument); +if ( + tag !== `v${inspection.version}` + || basename(archive) !== releaseArchiveName(inspection.version) + || basename(checksum) !== "SHA256SUMS" +) throw new Error("GitHub Release coordinates do not match the public package."); +const archiveBytes = await readFile(archive); +const checksumBytes = await readFile(checksum); + +function command(arguments_: string[]): string { + const result = Bun.spawnSync({ cmd: arguments_, stderr: "pipe", stdout: "pipe" }); + if (result.exitCode !== 0) { + throw new Error(`Command failed: ${arguments_.join(" ")}\n${result.stderr.toString("utf8")}`); + } + return result.stdout.toString("utf8"); +} + +function release(): unknown { + return JSON.parse(command(["gh", "api", `/repos/${publicRepository}/releases/tags/${tag}`])) as unknown; +} + +const existing = Bun.spawnSync({ + cmd: ["gh", "api", `/repos/${publicRepository}/releases/tags/${tag}`], + stderr: "pipe", + stdout: "pipe", +}); +if (existing.exitCode !== 0) { + const failure = `${existing.stdout.toString("utf8")}\n${existing.stderr.toString("utf8")}`; + if (!/HTTP 404|Not Found|release not found/iu.test(failure)) { + throw new Error(`GitHub Release existence is indeterminate.\n${failure}`); + } + command([ + "gh", "release", "create", tag, archive, checksum, + "--repo", publicRepository, + "--generate-notes", + "--latest", + "--verify-tag", + "--title", `HRA ${tag}`, + ]); +} +const coordinate = parseGitHubRelease(release(), inspection.version); +assertReleaseAssetBytes( + coordinate, + archiveBytes, + checksumBytes, + (bytes) => createHash("sha256").update(bytes).digest("hex"), +); +const directory = await mkdtemp(join(tmpdir(), "hra-release-readback-")); +try { + command([ + "gh", "release", "download", tag, + "--repo", publicRepository, + "--dir", directory, + "--pattern", basename(archive), + "--pattern", "SHA256SUMS", + ]); + for (const source of [archive, checksum]) { + if (!(await readFile(source)).equals(await readFile(join(directory, basename(source))))) { + throw new Error(`GitHub Release contains different bytes for ${basename(source)}.`); + } + } +} finally { + await rm(directory, { force: true, recursive: true }); +} +console.log(`GitHub Release ${tag} contains the exact immutable HRA artifacts.`); diff --git a/scripts/publish-npm-release.ts b/scripts/publish-npm-release.ts new file mode 100644 index 0000000..65dbc11 --- /dev/null +++ b/scripts/publish-npm-release.ts @@ -0,0 +1,111 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { basename, resolve } from "node:path"; + +import { parseNpmRelease } from "./release-distribution-policy"; +import { assertReleasePackageReady, releaseArchiveName } from "./release-package-policy"; + +const argument = process.argv[2]; +if (argument === undefined) throw new Error("Usage: publish-npm-release.ts ARTIFACT.tgz"); +const tarball = resolve(argument); +const bytes = await readFile(tarball); +const manifest = JSON.parse(await readFile(resolve(import.meta.dir, "..", "package.json"), "utf8")) as unknown; +const inspection = assertReleasePackageReady(manifest); +if (basename(tarball) !== releaseArchiveName(inspection.version)) { + throw new Error("npm publication received the wrong release artifact name."); +} +const verifiedTag = process.env.VERIFIED_TAG; +const runAttempt = process.env.GITHUB_RUN_ATTEMPT; +if (verifiedTag !== `v${inspection.version}` || runAttempt === undefined || !/^[1-9][0-9]*$/u.test(runAttempt)) { + throw new Error("npm publication requires one verified tag and workflow attempt."); +} +const expectedIntegrity = `sha512-${createHash("sha512").update(bytes).digest("base64")}`; +const expectedShasum = createHash("sha1").update(bytes).digest("hex"); +const url = `https://registry.npmjs.org/${encodeURIComponent(inspection.name)}/${inspection.version}`; + +async function lookup(): Promise { + const response = await fetch(url, { + cache: "no-store", + headers: { Accept: "application/json", "Cache-Control": "no-cache" }, + redirect: "error", + signal: AbortSignal.timeout(10_000), + }); + if (response.status === 404) return false; + if (response.status !== 200) throw new Error(`npm registry returned HTTP ${String(response.status)}.`); + const payload = await response.json() as unknown; + const coordinate = parseNpmRelease(payload, inspection.version); + if (coordinate.integrity !== expectedIntegrity || coordinate.shasum !== expectedShasum) { + throw new Error(`${inspection.name}@${inspection.version} exists with different immutable bytes.`); + } + return true; +} + +if (await lookup()) { + console.log(`${inspection.name}@${inspection.version} already contains the exact trusted-publisher bytes.`); +} else { + if (process.env.HRA_APPROVE_NPM_PUBLICATION !== `publish:${inspection.name}@${inspection.version}`) { + throw new Error("The first npm publication requires its exact explicit approval value."); + } + const cleanEnvironment = Object.fromEntries([ + "ACTIONS_ID_TOKEN_REQUEST_TOKEN", + "ACTIONS_ID_TOKEN_REQUEST_URL", + "CI", + "GITHUB_ACTION", + "GITHUB_ACTIONS", + "GITHUB_ACTOR_ID", + "GITHUB_EVENT_NAME", + "GITHUB_JOB", + "GITHUB_REF", + "GITHUB_REF_NAME", + "GITHUB_REPOSITORY", + "GITHUB_REPOSITORY_ID", + "GITHUB_RUN_ATTEMPT", + "GITHUB_RUN_ID", + "GITHUB_SERVER_URL", + "GITHUB_SHA", + "GITHUB_WORKFLOW", + "GITHUB_WORKFLOW_REF", + "GITHUB_WORKFLOW_SHA", + "HOME", + "NPM_CONFIG_REGISTRY", + "PATH", + "RUNNER_ENVIRONMENT", + ].flatMap((name) => process.env[name] === undefined ? [] : [[name, process.env[name] as string]])); + const child = Bun.spawn([ + "npm", "publish", tarball, "--access", "public", "--provenance", + ], { env: cleanEnvironment, stderr: "pipe", stdin: "ignore", stdout: "pipe" }); + const timer = setTimeout(() => child.kill(9), 5 * 60_000); + const boundedOutput = async (stream: ReadableStream): Promise => { + const reader = stream.getReader(); + let bytes = 0; + try { + for (;;) { + const item = await reader.read(); + if (item.done) break; + bytes += item.value.byteLength; + if (bytes > 1024 * 1024) { + child.kill(9); + throw new Error("npm trusted publication exceeded its output bound."); + } + } + } finally { + reader.releaseLock(); + } + }; + const [exitCode] = await Promise.all([ + child.exited.finally(() => clearTimeout(timer)), + boundedOutput(child.stdout), + boundedOutput(child.stderr), + ]); + if (exitCode !== 0) throw new Error("npm trusted publication failed without exposing provider output."); + let observed = false; + for (let attempt = 0; attempt < 60; attempt += 1) { + if (attempt > 0) await Bun.sleep(3_000); + if (await lookup()) { + observed = true; + break; + } + } + if (!observed) throw new Error("npm publication did not become readable with exact provenance-bearing bytes."); + console.log(`Published exact ${inspection.name}@${inspection.version} through npm trusted publishing.`); +} diff --git a/scripts/release-artifact-checksum.ts b/scripts/release-artifact-checksum.ts new file mode 100644 index 0000000..0bfb97f --- /dev/null +++ b/scripts/release-artifact-checksum.ts @@ -0,0 +1,29 @@ +import { createHash } from "node:crypto"; +import { lstat, readFile, realpath, writeFile } from "node:fs/promises"; +import { basename, resolve } from "node:path"; + +const [mode, artifactArgument, checksumArgument] = process.argv.slice(2); +if ((mode !== "write" && mode !== "check") || artifactArgument === undefined || checksumArgument === undefined) { + throw new Error("Usage: release-artifact-checksum.ts ARTIFACT.tgz SHA256SUMS"); +} +const artifact = resolve(artifactArgument); +const checksum = resolve(checksumArgument); +const metadata = await lstat(artifact); +if ( + !metadata.isFile() + || metadata.isSymbolicLink() + || metadata.nlink !== 1 + || metadata.size < 1 + || metadata.size > 64 * 1024 * 1024 + || await realpath(artifact) !== artifact +) throw new Error("The release artifact must be one exact bounded regular file."); +const digest = createHash("sha256").update(await readFile(artifact)).digest("hex"); +const expected = `${digest} ${basename(artifact)}\n`; +if (mode === "write") { + await writeFile(checksum, expected, { encoding: "utf8", flag: "wx", mode: 0o644 }); + console.log(`Wrote SHA-256 for ${basename(artifact)}.`); +} else if (await readFile(checksum, "utf8") !== expected) { + throw new Error(`SHA-256 mismatch for ${basename(artifact)}.`); +} else { + console.log(`Verified SHA-256 for ${basename(artifact)}.`); +} diff --git a/scripts/release-distribution-policy.test.ts b/scripts/release-distribution-policy.test.ts new file mode 100644 index 0000000..b05a716 --- /dev/null +++ b/scripts/release-distribution-policy.test.ts @@ -0,0 +1,66 @@ +import { createHash } from "node:crypto"; +import { describe, expect, test } from "bun:test"; + +import { + assertReleaseAssetBytes, + parseGitHubRelease, + parseNpmRelease, +} from "./release-distribution-policy"; + +const version = "1.2.3"; +const tarball = Buffer.from("exact HRA tarball"); +const tarballDigest = createHash("sha256").update(tarball).digest("hex"); +const checksum = Buffer.from(`${tarballDigest} hraness-hra-1.2.3.tgz\n`); +const digest = (value: Uint8Array): string => createHash("sha256").update(value).digest("hex"); + +const asset = (name: string, bytes: Uint8Array, id: number) => ({ + browser_download_url: `https://github.com/hraness/hra/releases/download/v1.2.3/${name}`, + digest: `sha256:${digest(bytes)}`, + id, + name, + size: bytes.byteLength, + state: "uploaded", +}); + +describe("HRA public distribution policy", () => { + test("requires npm trusted-publisher provenance", () => { + const payload = { + _npmUser: { + email: "npm-oidc-no-reply@github.com", + name: "GitHub Actions", + trustedPublisher: { id: "github", oidcConfigId: "oidc:12345678-1234-1234-1234-123456789abc" }, + }, + dist: { + attestations: { + provenance: { predicateType: "https://slsa.dev/provenance/v1" }, + url: "https://registry.npmjs.org/-/npm/v1/attestations/@hraness%2fhra@1.2.3", + }, + integrity: `sha512-${Buffer.alloc(64).toString("base64")}`, + shasum: "b".repeat(40), + tarball: "https://registry.npmjs.org/@hraness/hra/-/hra-1.2.3.tgz", + }, + license: "MIT", + name: "@hraness/hra", + version, + }; + expect(parseNpmRelease(payload, version).tarball).toEndWith("/hra-1.2.3.tgz"); + delete (payload._npmUser as { trustedPublisher?: unknown }).trustedPublisher; + expect(() => parseNpmRelease(payload, version)).toThrow("trusted publisher"); + }); + + test("requires exactly the immutable tarball and checksum bytes", () => { + const coordinate = parseGitHubRelease({ + assets: [ + asset("hraness-hra-1.2.3.tgz", tarball, 1), + asset("SHA256SUMS", checksum, 2), + ], + draft: false, + immutable: true, + prerelease: false, + tag_name: "v1.2.3", + }, version); + expect(() => assertReleaseAssetBytes(coordinate, tarball, checksum, digest)).not.toThrow(); + expect(() => assertReleaseAssetBytes(coordinate, Buffer.from("changed"), checksum, digest)) + .toThrow("immutable metadata"); + }); +}); diff --git a/scripts/release-distribution-policy.ts b/scripts/release-distribution-policy.ts new file mode 100644 index 0000000..1cdea10 --- /dev/null +++ b/scripts/release-distribution-policy.ts @@ -0,0 +1,136 @@ +import { releaseArchiveName } from "./release-package-policy"; + +type JsonRecord = Record; + +const SHA1 = /^[0-9a-f]{40}$/u; +const SHA256_DIGEST = /^sha256:[0-9a-f]{64}$/u; +const SHA512_INTEGRITY = /^sha512-[A-Za-z0-9+/]+={0,2}$/u; +const SEMVER = /^(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)$/u; +const OIDC_CONFIGURATION = /^oidc:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u; + +export const publicPackageName = "@hraness/hra"; +export const publicRepository = "hraness/hra"; + +function record(value: unknown, label: string): JsonRecord { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${label} must be an object.`); + } + return value as JsonRecord; +} + +function text(value: unknown, pattern: RegExp, label: string): string { + if (typeof value !== "string" || !pattern.test(value)) throw new Error(`${label} is invalid.`); + return value; +} + +function positiveInteger(value: unknown, label: string): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) { + throw new Error(`${label} must be a positive safe integer.`); + } + return value; +} + +export type NpmReleaseCoordinate = Readonly<{ integrity: string; shasum: string; tarball: string }>; + +export function parseNpmRelease(value: unknown, version: string): NpmReleaseCoordinate { + text(version, SEMVER, "npm release version"); + const release = record(value, "npm release"); + if (release.name !== publicPackageName || release.version !== version || release.license !== "MIT") { + throw new Error(`npm ${publicPackageName}@${version} has the wrong identity or license.`); + } + const dist = record(release.dist, "npm release dist"); + const expectedTarball = `https://registry.npmjs.org/@hraness/hra/-/hra-${version}.tgz`; + if (dist.tarball !== expectedTarball) throw new Error("npm release tarball URL is not canonical."); + const npmUser = record(release._npmUser, "npm trusted publisher identity"); + const trustedPublisher = record(npmUser.trustedPublisher, "npm trusted publisher"); + const attestations = record(dist.attestations, "npm release attestations"); + const provenance = record(attestations.provenance, "npm release provenance"); + const expectedAttestationUrl = + `https://registry.npmjs.org/-/npm/v1/attestations/@hraness%2fhra@${version}`; + if ( + npmUser.name !== "GitHub Actions" + || npmUser.email !== "npm-oidc-no-reply@github.com" + || trustedPublisher.id !== "github" + || typeof trustedPublisher.oidcConfigId !== "string" + || !OIDC_CONFIGURATION.test(trustedPublisher.oidcConfigId) + || attestations.url !== expectedAttestationUrl + || provenance.predicateType !== "https://slsa.dev/provenance/v1" + ) throw new Error("npm trusted-publisher provenance is missing or invalid."); + return Object.freeze({ + integrity: text(dist.integrity, SHA512_INTEGRITY, "npm release integrity"), + shasum: text(dist.shasum, SHA1, "npm release SHA-1"), + tarball: expectedTarball, + }); +} + +export type GitHubReleaseAsset = Readonly<{ + browserDownloadUrl: string; + digest: string; + id: number; + name: string; + size: number; +}>; + +export type GitHubReleaseCoordinate = Readonly<{ + checksum: GitHubReleaseAsset; + tarball: GitHubReleaseAsset; +}>; + +function parseAsset(value: unknown, name: string, tag: string): GitHubReleaseAsset { + const asset = record(value, `GitHub Release asset ${name}`); + const browserDownloadUrl = `https://github.com/${publicRepository}/releases/download/${tag}/${name}`; + if (asset.name !== name || asset.state !== "uploaded" || asset.browser_download_url !== browserDownloadUrl) { + throw new Error(`GitHub Release asset ${name} has the wrong identity or state.`); + } + return Object.freeze({ + browserDownloadUrl, + digest: text(asset.digest, SHA256_DIGEST, `GitHub Release asset ${name} digest`), + id: positiveInteger(asset.id, `GitHub Release asset ${name} id`), + name, + size: positiveInteger(asset.size, `GitHub Release asset ${name} size`), + }); +} + +export function parseGitHubRelease(value: unknown, version: string): GitHubReleaseCoordinate { + text(version, SEMVER, "GitHub release version"); + const tag = `v${version}`; + const release = record(value, "GitHub Release"); + if ( + release.tag_name !== tag + || release.draft !== false + || release.prerelease !== false + || release.immutable !== true + || !Array.isArray(release.assets) + || release.assets.length !== 2 + ) throw new Error(`GitHub Release ${tag} is not exact, published, immutable, and artifact-complete.`); + const byName = new Map(release.assets.map((asset) => { + const item = record(asset, "GitHub Release asset"); + return [item.name, asset] as const; + })); + if (byName.size !== 2) throw new Error(`GitHub Release ${tag} contains duplicate asset names.`); + const archive = releaseArchiveName(version); + return Object.freeze({ + checksum: parseAsset(byName.get("SHA256SUMS"), "SHA256SUMS", tag), + tarball: parseAsset(byName.get(archive), archive, tag), + }); +} + +export function assertReleaseAssetBytes( + coordinate: GitHubReleaseCoordinate, + tarball: Uint8Array, + checksum: Uint8Array, + sha256: (bytes: Uint8Array) => string, +): void { + const tarballDigest = sha256(tarball); + const checksumDigest = sha256(checksum); + if ( + coordinate.tarball.size !== tarball.byteLength + || coordinate.tarball.digest !== `sha256:${tarballDigest}` + || coordinate.checksum.size !== checksum.byteLength + || coordinate.checksum.digest !== `sha256:${checksumDigest}` + ) throw new Error("GitHub Release asset bytes do not match their immutable metadata."); + const expected = `${tarballDigest} ${coordinate.tarball.name}\n`; + if (new TextDecoder("utf-8", { fatal: true }).decode(checksum) !== expected) { + throw new Error("SHA256SUMS does not describe the exact release tarball."); + } +} diff --git a/scripts/release-package-policy.test.ts b/scripts/release-package-policy.test.ts new file mode 100644 index 0000000..8d55a67 --- /dev/null +++ b/scripts/release-package-policy.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from "bun:test"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +import { + assertReleasePackageReady, + inspectReleasePackage, + releaseArchiveName, +} from "./release-package-policy"; + +const readyManifest = { + bin: { hra: "./src/cli.ts" }, + dependencies: { "@hraness/oh": "0.2.3", zod: "4.4.3" }, + license: "MIT", + name: "@hraness/hra", + publishConfig: { access: "public", registry: "https://registry.npmjs.org" }, + version: "1.2.3", +}; + +describe("HRA public release package policy", () => { + test("accepts one public MIT scoped package with registry-only runtime dependencies", () => { + expect(assertReleasePackageReady(readyManifest)).toEqual({ + blockers: [], + name: "@hraness/hra", + version: "1.2.3", + }); + expect(releaseArchiveName("1.2.3")).toBe("hraness-hra-1.2.3.tgz"); + }); + + test("fails closed on GitHub, URL, workspace, range, and moving runtime dependencies", () => { + for (const version of [ + "github:hraness/oh#v0.2.0", + "https://example.com/oh.tgz", + "workspace:*", + "^0.2.3", + "latest", + ]) { + const manifest = structuredClone(readyManifest); + manifest.dependencies["@hraness/oh"] = version; + expect(() => assertReleasePackageReady(manifest)).toThrow("non-registry runtime dependencies"); + } + }); + + test("records the current unpublished Oh dependency as the only release blocker", async () => { + const manifest = JSON.parse( + await readFile(resolve(import.meta.dir, "..", "package.json"), "utf8"), + ) as unknown; + expect(inspectReleasePackage(manifest)).toEqual({ + blockers: ["@hraness/oh=github:hraness/oh#v0.2.0"], + name: "@hraness/hra", + version: "0.1.0", + }); + expect(() => assertReleasePackageReady(manifest)).toThrow("@hraness/oh=github:hraness/oh#v0.2.0"); + }); +}); diff --git a/scripts/release-package-policy.ts b/scripts/release-package-policy.ts new file mode 100644 index 0000000..c44c2ff --- /dev/null +++ b/scripts/release-package-policy.ts @@ -0,0 +1,67 @@ +const stableSemver = /^(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)$/u; +const exactRegistryVersion = /^(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$/u; + +type JsonRecord = Record; + +function record(value: unknown, label: string): JsonRecord { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${label} must be an object.`); + } + return value as JsonRecord; +} + +function stringRecord(value: unknown, label: string): Readonly> { + const source = record(value, label); + const result: Record = {}; + for (const [name, version] of Object.entries(source)) { + if (typeof version !== "string") throw new Error(`${label} ${name} must be a string.`); + result[name] = version; + } + return Object.freeze(result); +} + +export type ReleasePackageInspection = Readonly<{ + blockers: readonly string[]; + name: "@hraness/hra"; + version: string; +}>; + +export function releaseArchiveName(version: string): string { + if (!stableSemver.test(version)) throw new Error("The HRA release version must be stable semantic versioning."); + return `hraness-hra-${version}.tgz`; +} + +export function inspectReleasePackage(value: unknown): ReleasePackageInspection { + const manifest = record(value, "HRA package manifest"); + const publishConfig = record(manifest.publishConfig, "HRA publishConfig"); + const bin = record(manifest.bin, "HRA bin"); + const dependencies = stringRecord(manifest.dependencies, "HRA runtime dependency"); + if ( + manifest.name !== "@hraness/hra" + || typeof manifest.version !== "string" + || !stableSemver.test(manifest.version) + || manifest.license !== "MIT" + || publishConfig.access !== "public" + || publishConfig.registry !== "https://registry.npmjs.org" + || Object.keys(bin).length !== 1 + || bin.hra !== "./src/cli.ts" + ) throw new Error("The HRA public package identity, license, registry, version, or binary is invalid."); + + const blockers = Object.entries(dependencies) + .filter(([, version]) => !exactRegistryVersion.test(version)) + .map(([name, version]) => `${name}=${version}`) + .sort(); + return Object.freeze({ + blockers: Object.freeze(blockers), + name: "@hraness/hra", + version: manifest.version, + }); +} + +export function assertReleasePackageReady(value: unknown): ReleasePackageInspection { + const inspection = inspectReleasePackage(value); + if (inspection.blockers.length > 0) { + throw new Error(`HRA release is blocked by non-registry runtime dependencies: ${inspection.blockers.join(", ")}`); + } + return inspection; +} diff --git a/scripts/release-workflow.test.ts b/scripts/release-workflow.test.ts index 42f84bb..79292f3 100644 --- a/scripts/release-workflow.test.ts +++ b/scripts/release-workflow.test.ts @@ -36,7 +36,7 @@ describe("release workflow", () => { ); }); - test("keeps the retired fallback-bound path unreachable while exposing the current alias operator", async () => { + test("keeps the retired fallback-bound path unreachable and exposes only the exact artifact workflow", async () => { const root = join(import.meta.dir, ".."); const packageJson = asRecord( JSON.parse(await readFile(join(root, "package.json"), "utf8")), @@ -49,7 +49,7 @@ describe("release workflow", () => { readFile(join(root, "docs", "beta-release.md"), "utf8"), ]); - expect(await Bun.file(releaseWorkflow).exists()).toBeFalse(); + expect(await Bun.file(releaseWorkflow).exists()).toBeTrue(); expect(scripts["hosted:domain-cutover"]).toBeUndefined(); expect(scripts["release:candidate"]).toBeUndefined(); expect(scripts["release:publish"]).toBeUndefined(); @@ -63,9 +63,23 @@ describe("release workflow", () => { expect(domainRecord).toContain("unresolved_prior_intent"); expect(domainRecord).toContain("reasserts only the plan's exact source"); expect(domainRecord).toContain("unresolved_current_intent"); - expect(releaseRecord).toContain("Status: retired on 2026-08-27 without publication."); + expect(releaseRecord).toContain("Status: prepared but blocked before publication."); expect(releaseRecord).toContain("no `v0.1.0` tag"); - expect(releaseRecord).toContain("no authorized publication path"); + expect(releaseRecord).toContain("`@hraness/oh` is a GitHub runtime dependency"); + const workflow = await readFile(releaseWorkflow, "utf8"); + expect(workflow).toContain("id-token: write"); + expect(workflow).toContain("npm pack --ignore-scripts --pack-destination artifacts ."); + expect(workflow).toContain("release-artifact-checksum.ts"); + expect(workflow).toContain("check-release-package.ts"); + expect(workflow).toContain("publish-npm-release.ts"); + expect(workflow).toContain("publish-github-release.ts"); + expect(workflow).toContain("check-public-release.ts"); + expect(workflow).toContain("os: [ubuntu-24.04, macos-15]"); + expect(workflow).not.toContain("release-candidate.ts"); + expect(workflow).not.toContain("publish-beta-release.ts"); + expect(workflow).not.toContain("hra-weld.vercel.app"); + expect(workflow).not.toContain("try-hra.vercel.app"); + expect(workflow).not.toContain("convex"); }); test("gives the public-text gate complete Git history in CI", async () => { diff --git a/scripts/verify-npm-provenance.ts b/scripts/verify-npm-provenance.ts new file mode 100644 index 0000000..66b0668 --- /dev/null +++ b/scripts/verify-npm-provenance.ts @@ -0,0 +1,98 @@ +import { verify, type Bundle } from "sigstore"; + +type JsonRecord = Record; + +const SLSA_V1 = "https://slsa.dev/provenance/v1"; +const FULCIO_GITHUB_ISSUER = "https://token.actions.githubusercontent.com"; +const GITHUB_OIDS = Object.freeze({ + "1.3.6.1.4.1.57264.1.1": "push", + "1.3.6.1.4.1.57264.1.2": "__SHA__", + "1.3.6.1.4.1.57264.1.3": "Release", + "1.3.6.1.4.1.57264.1.4": "hraness/hra", + "1.3.6.1.4.1.57264.1.5": "__REF__", +}); + +function record(value: unknown, label: string): JsonRecord { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${label} must be an object.`); + } + return value as JsonRecord; +} + +function exactKeys(value: JsonRecord, keys: readonly string[], label: string): void { + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) { + throw new Error(`${label} has an unexpected shape.`); + } +} + +export async function verifyNpmProvenance(input: Readonly<{ + attestations: unknown; + integrity: string; + runId: string; + runAttempt: string; + sha: string; + tag: string; + tufCachePath: string; +}>): Promise { + if (!/^[1-9][0-9]*$/u.test(input.runId) || !/^[1-9][0-9]*$/u.test(input.runAttempt)) { + throw new Error("npm provenance requires exact workflow run identity."); + } + if (!/^[0-9a-f]{40}$/u.test(input.sha) || !/^v(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)$/u.test(input.tag)) { + throw new Error("npm provenance requires an exact release ref and commit."); + } + const root = record(input.attestations, "npm attestations"); + exactKeys(root, ["attestations"], "npm attestations"); + if (!Array.isArray(root.attestations) || root.attestations.length !== 1) { + throw new Error("npm must expose exactly one provenance attestation."); + } + const item = record(root.attestations[0], "npm provenance attestation"); + if (item.predicateType !== SLSA_V1) throw new Error("npm attestation is not exact SLSA v1 provenance."); + const bundle = record(item.bundle, "npm provenance Sigstore bundle") as Bundle; + const workflowIdentity = `https://github.com/hraness/hra/.github/workflows/release.yml@refs/tags/${input.tag}`; + const certificateOIDs = Object.fromEntries(Object.entries(GITHUB_OIDS).map(([oid, expected]) => [ + oid, + expected === "__SHA__" ? input.sha : expected === "__REF__" ? `refs/tags/${input.tag}` : expected, + ])); + await verify(bundle, { + certificateIdentityURI: workflowIdentity, + certificateIssuer: FULCIO_GITHUB_ISSUER, + certificateOIDs, + ctLogThreshold: 1, + retry: 0, + timeout: 10_000, + tlogThreshold: 1, + tufCachePath: input.tufCachePath, + }); + + const envelope = record(record(bundle, "Sigstore bundle").dsseEnvelope, "Sigstore DSSE envelope"); + if (envelope.payloadType !== "application/vnd.in-toto+json" || typeof envelope.payload !== "string") { + throw new Error("npm provenance does not contain one in-toto DSSE payload."); + } + const statement = record(JSON.parse(Buffer.from(envelope.payload, "base64").toString("utf8")), "SLSA statement"); + if (statement._type !== "https://in-toto.io/Statement/v1" || statement.predicateType !== SLSA_V1) { + throw new Error("npm provenance statement identity is invalid."); + } + if (!Array.isArray(statement.subject) || statement.subject.length !== 1) { + throw new Error("npm provenance must bind exactly one package subject."); + } + const subject = record(statement.subject[0], "npm provenance subject"); + const digest = record(subject.digest, "npm provenance subject digest"); + const expectedSha512 = input.integrity.replace(/^sha512-/u, ""); + if (subject.name !== "pkg:npm/%40hraness/hra" || digest.sha512 !== expectedSha512) { + throw new Error("npm provenance subject does not bind the exact package bytes."); + } + const predicate = record(statement.predicate, "SLSA predicate"); + const runDetails = JSON.stringify(predicate); + for (const exact of [ + "hraness/hra", + `.github/workflows/release.yml`, + `refs/tags/${input.tag}`, + input.sha, + input.runId, + input.runAttempt, + ]) { + if (!runDetails.includes(exact)) throw new Error("SLSA provenance is missing exact source or workflow-run identity."); + } +} diff --git a/site/content.test.ts b/site/content.test.ts index 2d3f22f..ed8ed0d 100644 --- a/site/content.test.ts +++ b/site/content.test.ts @@ -40,7 +40,7 @@ describe("public content contract", () => { doctorCommand: "hra doctor --offline", initCommand: "hra init --yes", installCommand: buildHraGlobalInstallCommand( - "https://github.com/hraness/hra/releases/download/v0.1.0/hra-v0.1.0.tgz", + "https://github.com/hraness/hra/releases/download/v0.1.0/hraness-hra-0.1.0.tgz", ), links: { github: "https://github.com/hraness/hra", @@ -372,7 +372,7 @@ describe("public content contract", () => { expect(publicContent.installCommand).toContain(HRA_INSTALL_PREFLIGHT_SOURCE_URL); expect(publicContent.installCommand).toContain("| bun -e '"); expect(publicContent.installCommand).toContain( - "-- https://github.com/hraness/hra/releases/download/v0.1.0/hra-v0.1.0.tgz", + "-- https://github.com/hraness/hra/releases/download/v0.1.0/hraness-hra-0.1.0.tgz", ); expect(publicContent.installCommand).toContain("hra-install-safe"); expect(publicContent.installCommand).not.toContain("bun add --global"); diff --git a/site/content.ts b/site/content.ts index c126094..b387c66 100644 --- a/site/content.ts +++ b/site/content.ts @@ -279,7 +279,7 @@ export const siteDocumentPaths: readonly string[] = [ export const publicReleaseState: "release-ready" | "staged" = "staged"; const betaInstallCommand = buildHraGlobalInstallCommand( - "https://github.com/hraness/hra/releases/download/v0.1.0/hra-v0.1.0.tgz", + "https://github.com/hraness/hra/releases/download/v0.1.0/hraness-hra-0.1.0.tgz", ); export const publicContent: PublicContent = { diff --git a/site/social-card.svg b/site/social-card.svg index 069dc0a..a9df5d1 100644 --- a/site/social-card.svg +++ b/site/social-card.svg @@ -6,6 +6,6 @@ HRA bun add --global - https://github.com/hraness/hra/releases/download/v0.1.0/hra-v0.1.0.tgz + github.com/hraness/hra/releases/tag/v0.1.0 isolated accounts · encrypted sync · one CLI diff --git a/src/install-normalizer.test.ts b/src/install-normalizer.test.ts index 6799e13..63fcb09 100644 --- a/src/install-normalizer.test.ts +++ b/src/install-normalizer.test.ts @@ -42,7 +42,7 @@ const manifest = (scripts: Record = { build: "bun ./build.ts", }): Record => ({ bin: { hra: "./src/cli.ts" }, - name: "hra", + name: "@hraness/hra", scripts, version: "0.1.0", }); @@ -221,7 +221,7 @@ type InstallFixture = Readonly<{ const installFixture = async (): Promise => { const root = await realpath(await mkdtemp(join(tmpdir(), "hra-install-normalizer-"))); temporaryDirectories.push(root); - const packageRoot = join(root, "node_modules", "hra"); + const packageRoot = join(root, "node_modules", "@hraness", "hra"); const sourceDirectory = join(packageRoot, "src"); const binDirectory = join(root, "node_modules", ".bin"); const cliPath = join(sourceDirectory, "cli.ts"); @@ -235,7 +235,7 @@ const installFixture = async (): Promise => { await writeFile(normalizerPath, "// reviewed fixture normalizer\n", { mode: 0o644 }); await writeFile(cliPath, await readFile(join(repositoryRoot, "src", "cli.ts")), { mode: 0o755 }); await chmod(cliPath, 0o777); - await symlink("../hra/src/cli.ts", binLink); + await symlink("../@hraness/hra/src/cli.ts", binLink); return { binLink, cliPath, normalizerPath, packageRoot, root }; }; @@ -446,7 +446,7 @@ describe("lifecycle-free Bun install normalizer", () => { beforePublishRename: async () => { await rename(binDirectory, heldBinDirectory); await mkdir(binDirectory, { mode: 0o755 }); - await symlink("../hra/src/cli.ts", join(binDirectory, "hra")); + await symlink("../@hraness/hra/src/cli.ts", join(binDirectory, "hra")); }, }, })).rejects.toThrow("directory path no longer names its held custody descriptor"); @@ -539,7 +539,7 @@ describe("lifecycle-free Bun install normalizer", () => { { cwd: packageSource, environment }, ); expect(hraPack.exitCode).toBe(0); - const hraArchive = join(archiveDirectory, "hra-0.1.0.tgz"); + const hraArchive = join(archiveDirectory, "hraness-hra-0.1.0.tgz"); let installedCli: string | undefined; const installAndNormalize = async (): Promise => { const installation = await run( @@ -553,7 +553,7 @@ describe("lifecycle-free Bun install normalizer", () => { expect(currentCli).toBe(installedCli); expect(currentCli).toContain(`${join(globalInstall, "install", "hra", "versions")}/`); expect((await lstat(currentCli)).mode & 0o777).toBe(0o755); - expect(await Bun.file(join(globalInstall, "install", "global", "node_modules", "hra")).exists()).toBeFalse(); + expect(await Bun.file(join(globalInstall, "install", "global", "node_modules", "@hraness", "hra")).exists()).toBeFalse(); expect(await access(hostileSentinel).then(() => "present", () => "absent")).toBe("absent"); const trustAfter = JSON.parse(await readFile(globalManifestPath, "utf8")) as Record; expect(trustAfter.trustedDependencies).toEqual(["existing-trusted-fixture"]); @@ -604,7 +604,7 @@ describe("lifecycle-free Bun install normalizer", () => { { cwd: packageSource }, ); expect(packed.exitCode).toBe(0); - const archive = join(archiveDirectory, "hra-0.1.0.tgz"); + const archive = join(archiveDirectory, "hraness-hra-0.1.0.tgz"); const environment = { ...process.env, BUN_INSTALL: globalInstall, @@ -627,7 +627,7 @@ describe("lifecycle-free Bun install normalizer", () => { expect(installed.exitCode).not.toBe(0); expect(installed.stderr).not.toContain(HRA_INSTALL_CLI_SHA256); expect(await Bun.file(join(globalInstall, "bin", "hra")).exists()).toBeFalse(); - expect(await Bun.file(join(globalInstall, "install", "global", "node_modules", "hra")).exists()).toBeFalse(); + expect(await Bun.file(join(globalInstall, "install", "global", "node_modules", "@hraness", "hra")).exists()).toBeFalse(); const authorityEntries = await readdir(join(globalInstall, "install", "hra")); expect(authorityEntries.some((entry) => entry.startsWith(".staging-"))).toBeTrue(); }); diff --git a/src/install-normalizer.ts b/src/install-normalizer.ts index 19f485e..ef5be58 100644 --- a/src/install-normalizer.ts +++ b/src/install-normalizer.ts @@ -28,9 +28,9 @@ import { createGunzip } from "node:zlib"; export const HRA_INSTALL_BUN_VERSION = "1.3.14"; export const HRA_INSTALL_CLI_SHA256 = "4ec12b00de84a5c5e830fc8cac3f2303cb0dffa6d262dc2c51773bce5039f308"; -const expectedPackageName = "hra"; +const expectedPackageName = "@hraness/hra"; const expectedPackageVersion = "0.1.0"; -const expectedArchiveUrl = "https://github.com/hraness/hra/releases/download/v0.1.0/hra-v0.1.0.tgz"; +const expectedArchiveUrl = "https://github.com/hraness/hra/releases/download/v0.1.0/hraness-hra-0.1.0.tgz"; const cliRelativePath = join("src", "cli.ts"); const cliMaximumBytes = 512 * 1024; const manifestMaximumBytes = 64 * 1024; @@ -953,8 +953,8 @@ const fsyncDirectory = async (path: string): Promise => { }; const binLinkCandidates = (packageRoot: string): readonly string[] => [ - resolve(packageRoot, "..", ".bin", "hra"), - resolve(packageRoot, "..", "..", "..", "..", "bin", "hra"), + resolve(packageRoot, "..", "..", ".bin", "hra"), + resolve(packageRoot, "..", "..", "..", "..", "..", "bin", "hra"), ]; const disableCurrentUserPath = async ( @@ -1168,7 +1168,7 @@ type InstallCompleteReceipt = InstallArchiveIdentity & Readonly<{ entryCount: number; id: string; normalizerSha256: string; - packageName: "hra"; + packageName: "@hraness/hra"; packageVersion: "0.1.0"; totalBytes: number; treeSha256: string; @@ -1974,7 +1974,7 @@ export async function completeHraStagedInstall(input: InstallArchiveIdentity & R || dirname(versionRoot) !== join(authorityRoot, "versions") || relative(join(authorityRoot, "versions"), versionRoot) !== expectedVersionName || intentPath !== join(authorityRoot, "install-intent.json") - || packageRoot !== join(stagingRoot, "install", "global", "node_modules", "hra") + || packageRoot !== join(stagingRoot, "install", "global", "node_modules", "@hraness", "hra") || normalizerPath !== join(packageRoot, "src", "install-normalizer.ts") ) throw new InstallNormalizationError("The staged HRA install paths do not match their exact authority layout."); const custody = new DirectoryCustody(uid); @@ -2025,7 +2025,7 @@ export async function completeHraStagedInstall(input: InstallArchiveIdentity & R entryCount: normalizedTree.entryCount, id: input.intentId, normalizerSha256: input.normalizerSha256, - packageName: "hra", + packageName: "@hraness/hra", packageVersion: "0.1.0", totalBytes: normalizedTree.totalBytes, treeSha256: normalizedTree.treeSha256, diff --git a/src/install-preflight-runtime.ts b/src/install-preflight-runtime.ts index e6780c9..70d4c21 100644 --- a/src/install-preflight-runtime.ts +++ b/src/install-preflight-runtime.ts @@ -26,12 +26,12 @@ import { homedir } from "node:os"; import { basename, dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:path"; export const HRA_INSTALL_BUN_VERSION = "1.3.14"; -export const HRA_INSTALL_PACKAGE_NAME = "hra"; +export const HRA_INSTALL_PACKAGE_NAME = "@hraness/hra"; export const HRA_INSTALL_PACKAGE_VERSION = "0.1.0"; export const HRA_INSTALL_CLI_SHA256 = "4ec12b00de84a5c5e830fc8cac3f2303cb0dffa6d262dc2c51773bce5039f308"; -export const HRA_INSTALL_NORMALIZER_SHA256 = "70366a2e55b9fd27aedb49b4bc453164b35b3744ff80dddcf335ba06ee407711"; -export const HRA_INSTALL_ARCHIVE_URL = "https://github.com/hraness/hra/releases/download/v0.1.0/hra-v0.1.0.tgz"; -export const HRA_INSTALL_ARCHIVE_NAME = "hra-v0.1.0.tgz"; +export const HRA_INSTALL_NORMALIZER_SHA256 = "912fca4d54e35fa7f474be77ea10d7401b4ac0686c706b22faba4e3e53bb4144"; +export const HRA_INSTALL_ARCHIVE_URL = "https://github.com/hraness/hra/releases/download/v0.1.0/hraness-hra-0.1.0.tgz"; +export const HRA_INSTALL_ARCHIVE_NAME = "hraness-hra-0.1.0.tgz"; export const HRA_INSTALL_RELEASE_API_URL = "https://api.github.com/repos/hraness/hra/releases/tags/v0.1.0"; export const HRA_INSTALL_RELEASE_TAG = "v0.1.0"; export const HRA_INSTALL_REPOSITORY_API_URL = "https://api.github.com/repos/hraness/hra"; @@ -640,7 +640,7 @@ if (testMode === "normal") { heldArchiveIdentity = verifiedArchive.identity; archiveSnapshot = verifiedArchive.snapshot; if (archiveSnapshot === undefined) throw new Error("The private HRA archive snapshot is unavailable."); - const route = "/" + randomUUID() + "/hra-v0.1.0.tgz"; + const route = "/" + randomUUID() + "/hraness-hra-0.1.0.tgz"; let requests = 0; archiveServer = Bun.serve({ hostname: "127.0.0.1", @@ -1547,7 +1547,7 @@ type CompleteReceipt = HraInstallArchiveIdentity & Readonly<{ entryCount: number; id: string; normalizerSha256: string; - packageName: "hra"; + packageName: "@hraness/hra"; packageVersion: string; totalBytes: number; treeSha256: string; @@ -1735,7 +1735,7 @@ const assertManifest = async ( } catch { throw new InstallPreflightError("The installed HRA package manifest is not valid JSON."); } - if (!isRecord(value) || value.name !== "hra" || value.version !== expectedVersion) { + if (!isRecord(value) || value.name !== HRA_INSTALL_PACKAGE_NAME || value.version !== expectedVersion) { throw new InstallPreflightError("The installed HRA package identity is not exact."); } if ( @@ -1794,7 +1794,7 @@ const verifyCompleteVersion = async ( const record = (value: readonly (number | string)[]): void => { treeHasher.update(`${JSON.stringify(value)}\n`, "utf8"); }; - const cliPath = join(versionRoot, "install", "global", "node_modules", "hra", "src", "cli.ts"); + const cliPath = join(versionRoot, "install", "global", "node_modules", "@hraness", "hra", "src", "cli.ts"); const normalizerPath = join(dirname(cliPath), "install-normalizer.ts"); const visit = async (directory: string): Promise => { const directoryHandle = await open( @@ -1947,7 +1947,7 @@ const verifyActiveTarget = async ( expectedRelease?: ReleaseIdentity, ): Promise => { const target = isAbsolute(rawTarget) ? resolve(rawTarget) : resolve(dirname(activePath), rawTarget); - const versionRoot = resolve(target, "..", "..", "..", "..", "..", ".."); + const versionRoot = resolve(target, "..", "..", "..", "..", "..", "..", ".."); const verified = await verifyCompleteVersion(versionRoot, authorityRoot, uid, expectedRelease); if (verified.cliPath !== target || await realpath(activePath) !== target) { throw new InstallPreflightError("The active hra command does not resolve to its verified version entry point."); @@ -2660,7 +2660,7 @@ const completeStagedVersion = async ( hooks, uid, }); - const packageRoot = join(intent.stagingRoot, "install", "global", "node_modules", "hra"); + const packageRoot = join(intent.stagingRoot, "install", "global", "node_modules", "@hraness", "hra"); const normalizerPath = join(packageRoot, "src", "install-normalizer.ts"); const normalizerBytes = await readExactFile(normalizerPath, uid, 2 * 1024 * 1024, [0o600, 0o644]); let imported: unknown; @@ -2875,6 +2875,7 @@ const recoverInterruptedInstall = async (input: Readonly<{ "install", "global", "node_modules", + "@hraness", "hra", "src", "cli.ts", @@ -3270,12 +3271,12 @@ const installIntoStage = async (input: Readonly<{ const globalDependencies = globalManifest.dependencies; if ( !isRecord(globalDependencies) - || !hasExactKeys(globalDependencies, ["hra"]) - || typeof globalDependencies.hra !== "string" + || !hasExactKeys(globalDependencies, [HRA_INSTALL_PACKAGE_NAME]) + || typeof globalDependencies[HRA_INSTALL_PACKAGE_NAME] !== "string" ) throw new InstallPreflightError("Bun staging did not record one exact isolated HRA dependency."); let stagedArchiveUrl: URL; try { - stagedArchiveUrl = new URL(globalDependencies.hra); + stagedArchiveUrl = new URL(globalDependencies[HRA_INSTALL_PACKAGE_NAME]); } catch { throw new InstallPreflightError("Bun staging recorded an invalid isolated HRA archive URL."); } @@ -3290,13 +3291,13 @@ const installIntoStage = async (input: Readonly<{ || stagedArchivePort > 65_535 || stagedArchiveUrl.search !== "" || stagedArchiveUrl.hash !== "" - || !/^\/[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\/hra-v0\.1\.0\.tgz$/u.test(stagedArchiveUrl.pathname) + || !/^\/[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\/hraness-hra-0\.1\.0\.tgz$/u.test(stagedArchiveUrl.pathname) ) throw new InstallPreflightError("Bun staging left its descriptor-bound loopback archive authority."); await unlinkHeldChild(stageCustody, globalInstallRoot, "bun.lock", { missing: true }); await stageCustody.assertAll(); await writeAtomicJson( globalManifestPath, - { dependencies: { hra: HRA_INSTALL_PACKAGE_VERSION } }, + { dependencies: { [HRA_INSTALL_PACKAGE_NAME]: HRA_INSTALL_PACKAGE_VERSION } }, input.uid, stageCustody, ); diff --git a/src/install-preflight.test.ts b/src/install-preflight.test.ts index 7ffde48..564ac45 100644 --- a/src/install-preflight.test.ts +++ b/src/install-preflight.test.ts @@ -367,7 +367,7 @@ beforeAll(async () => { root, ], { cwd: repositoryRoot }); if (packed.exitCode !== 0) throw new Error(`Could not build installer fixture: ${packed.stderr}${packed.stdout}`); - archivePath = join(root, "hra-0.1.0.tgz"); + archivePath = join(root, "hraness-hra-0.1.0.tgz"); await chmod(archivePath, 0o600); archiveSha256 = createHash("sha256").update(await readFile(archivePath)).digest("hex"); }); @@ -389,7 +389,7 @@ describe("transactional HRA installer", () => { "https://raw.githubusercontent.com/hraness/hra/v0.1.0/src/install-preflight-runtime.ts", ); expect(HRA_INSTALL_ARCHIVE_URL).toBe( - "https://github.com/hraness/hra/releases/download/v0.1.0/hra-v0.1.0.tgz", + "https://github.com/hraness/hra/releases/download/v0.1.0/hraness-hra-0.1.0.tgz", ); const runtimeBytes = await readFile(resolve(import.meta.dir, "install-preflight-runtime.ts")); expect(createHash("sha256").update(runtimeBytes).digest("hex")).toBe( @@ -613,9 +613,9 @@ describe("transactional HRA installer", () => { expect(activeMetadata.isSymbolicLink()).toBeTrue(); const activeTarget = await realpath(activePath); expect(activeTarget).toContain(`${join(bunRoot, "install", "hra", "versions")}/`); - expect(activeTarget).toEndWith("/install/global/node_modules/hra/src/cli.ts"); + expect(activeTarget).toEndWith("/install/global/node_modules/@hraness/hra/src/cli.ts"); expect((await lstat(activeTarget)).mode & 0o777).toBe(0o755); - expect(await Bun.file(join(bunRoot, "install", "global", "node_modules", "hra")).exists()).toBeFalse(); + expect(await Bun.file(join(bunRoot, "install", "global", "node_modules", "@hraness", "hra")).exists()).toBeFalse(); expect(await Bun.file(join(bunRoot, "install", "hra", "install-intent.json")).exists()).toBeFalse(); const versions = await readdir(join(bunRoot, "install", "hra", "versions")); expect(versions).toHaveLength(1); @@ -636,7 +636,7 @@ describe("transactional HRA installer", () => { "install", "global", "package.json", - ))).toEqual({ dependencies: { hra: "0.1.0" } }); + ))).toEqual({ dependencies: { "@hraness/hra": "0.1.0" } }); const second = await runInstaller(root); expect(second).toEqual({ @@ -864,7 +864,7 @@ describe("transactional HRA installer", () => { " afterStageCleanupCustody: async () => {", ` const stage = (await fs.readdir(${JSON.stringify(authorityRoot)})).find((entry) => entry.startsWith(".staging-"));`, " if (!stage) throw new Error(\"The extracted package stage is missing.\");", - ` const packageFile = path.join(${JSON.stringify(authorityRoot)}, stage, "install/global/node_modules/hra/src/domain/values.ts");`, + ` const packageFile = path.join(${JSON.stringify(authorityRoot)}, stage, "install/global/node_modules/@hraness/hra/src/domain/values.ts");`, " const bytes = Buffer.from(await fs.readFile(packageFile));", " bytes[0] = (bytes[0] ?? 0) ^ 1;", " await fs.writeFile(packageFile, bytes);", @@ -1160,7 +1160,7 @@ describe("transactional HRA installer", () => { await Bun.sleep(10); recovered = await runInstaller(root); } - expect(recovered.exitCode).toBe(0); + expect(recovered.exitCode, recovered.stderr).toBe(0); expect(recovered.stdout).toBe(`${HRA_INSTALL_PREFLIGHT_SUCCESS}\n`); expect((await lstat(join(bunRoot, "bin", "hra"))).isSymbolicLink()).toBeTrue(); expect((await readdir(authorityRoot)).some((entry) => entry.startsWith(".staging-"))).toBeFalse(); @@ -1198,7 +1198,7 @@ describe("transactional HRA installer", () => { expect((await lstat(publishedTarget)).mode & 0o777).toBe(0o755); const recovered = await runInstaller(root); - expect(recovered.exitCode).toBe(0); + expect(recovered.exitCode, recovered.stderr).toBe(0); expect(await realpath(activePath)).toBe(publishedTarget); expect(await Bun.file(join(root, "bun root", "install", "hra", "install-intent.json")).exists()).toBeFalse(); }, 60_000); diff --git a/src/install-preflight.ts b/src/install-preflight.ts index baf7ee5..6516674 100644 --- a/src/install-preflight.ts +++ b/src/install-preflight.ts @@ -10,7 +10,7 @@ export { HRA_INSTALL_ARCHIVE_URL, HRA_INSTALL_BUN_VERSION }; export const HRA_INSTALL_PREFLIGHT_SOURCE_URL = "https://raw.githubusercontent.com/hraness/hra/v0.1.0/src/install-preflight-runtime.ts"; export const HRA_INSTALL_PREFLIGHT_SOURCE_SHA256 = - "facdcd4c3ce6a02590b533a92e661e06e63b4f4709f71f48cb5906a29d40fa21"; + "61049ecbe2fdb7ea89fcf80740597ca349fc7f62e09230f67519f15bb9fc7796"; export const HRA_INSTALL_PREFLIGHT_SUCCESS = HRA_INSTALL_SUCCESS; export const HRA_INSTALL_PREFLIGHT_LOADER = [ "const[a,h]=process.argv.slice(1);", From f95c10c38793df7cec038177dcaf0abf429ec638 Mon Sep 17 00:00:00 2001 From: 0thernet Date: Sun, 30 Aug 2026 15:40:26 -0400 Subject: [PATCH 2/3] release: admit exact npm publication retries --- .github/CODEOWNERS | 2 + .github/workflows/release.yml | 17 +++ scripts/check-npm-artifact-state.ts | 37 +++++++ scripts/check-public-release.ts | 1 + scripts/npm-publication-transition.test.ts | 67 ++++++++++++ scripts/npm-publication-transition.ts | 40 +++++++ scripts/publish-npm-release.ts | 120 +++++++++++++++++++-- scripts/release-workflow.test.ts | 3 + scripts/verify-npm-provenance.test.ts | 87 +++++++++++++++ scripts/verify-npm-provenance.ts | 73 +++++++++++-- 10 files changed, 430 insertions(+), 17 deletions(-) create mode 100644 scripts/check-npm-artifact-state.ts create mode 100644 scripts/npm-publication-transition.test.ts create mode 100644 scripts/npm-publication-transition.ts create mode 100644 scripts/verify-npm-provenance.test.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 022aacc..3c450f1 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -6,6 +6,8 @@ /scripts/check-public-release.ts @0thernet /scripts/check-release-package.ts @0thernet /scripts/check-npm-trusted-publishing.ts @0thernet +/scripts/check-npm-artifact-state.ts @0thernet +/scripts/npm-publication-transition.ts @0thernet /scripts/package-policy.ts @0thernet /scripts/publish-github-release.ts @0thernet /scripts/publish-npm-release.ts @0thernet diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e835212..efc9726 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,6 +20,9 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 45 outputs: + npm_preflight_run_attempt: ${{ steps.npm_preflight.outputs.run_attempt }} + npm_preflight_run_id: ${{ steps.npm_preflight.outputs.run_id }} + npm_preflight_state: ${{ steps.npm_preflight.outputs.state }} verified_sha: ${{ steps.identity.outputs.sha }} verified_tag: ${{ steps.identity.outputs.tag }} verified_tag_object: ${{ steps.identity.outputs.tag_object }} @@ -100,6 +103,17 @@ jobs: test -f "artifacts/$expected" test "$(find artifacts -maxdepth 1 -type f -name '*.tgz' | wc -l | tr -d ' ')" = "1" bun run ./scripts/release-artifact-checksum.ts write "$GITHUB_WORKSPACE/artifacts/$expected" "$GITHUB_WORKSPACE/artifacts/SHA256SUMS" + - name: Record exact npm registry preflight + id: npm_preflight + run: | + set -euo pipefail + artifact="$(find "$GITHUB_WORKSPACE/artifacts" -maxdepth 1 -type f -name '*.tgz')" + state="$(bun run ./scripts/check-npm-artifact-state.ts "$artifact")" + if [[ "$state" != "absent" && "$state" != "exact" ]]; then + echo "::error::npm registry preflight returned an invalid state" + exit 1 + fi + printf 'state=%s\nrun_id=%s\nrun_attempt=%s\n' "$state" "$GITHUB_RUN_ID" "$GITHUB_RUN_ATTEMPT" >> "$GITHUB_OUTPUT" - name: Preserve exact release bytes uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: @@ -161,6 +175,9 @@ jobs: VERIFIED_TAG: ${{ needs.verify.outputs.verified_tag }} VERIFIED_TAG_OBJECT: ${{ needs.verify.outputs.verified_tag_object }} HRA_APPROVE_NPM_PUBLICATION: ${{ vars.HRA_APPROVE_NPM_PUBLICATION }} + HRA_NPM_PREFLIGHT_RUN_ATTEMPT: ${{ needs.verify.outputs.npm_preflight_run_attempt }} + HRA_NPM_PREFLIGHT_RUN_ID: ${{ needs.verify.outputs.npm_preflight_run_id }} + HRA_NPM_PREFLIGHT_STATE: ${{ needs.verify.outputs.npm_preflight_state }} steps: - name: Check out verified source with complete history uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/scripts/check-npm-artifact-state.ts b/scripts/check-npm-artifact-state.ts new file mode 100644 index 0000000..bb749cd --- /dev/null +++ b/scripts/check-npm-artifact-state.ts @@ -0,0 +1,37 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { basename, resolve } from "node:path"; + +import { parseNpmRelease } from "./release-distribution-policy"; +import { assertReleasePackageReady, releaseArchiveName } from "./release-package-policy"; + +const argument = process.argv[2]; +if (argument === undefined) throw new Error("Usage: check-npm-artifact-state.ts ARTIFACT.tgz"); +const tarball = resolve(argument); +const bytes = await readFile(tarball); +const manifest = JSON.parse(await readFile(resolve(import.meta.dir, "..", "package.json"), "utf8")) as unknown; +const inspection = assertReleasePackageReady(manifest); +if (basename(tarball) !== releaseArchiveName(inspection.version)) { + throw new Error("npm preflight received the wrong release artifact name."); +} +const response = await fetch( + `https://registry.npmjs.org/${encodeURIComponent(inspection.name)}/${inspection.version}`, + { + cache: "no-store", + headers: { Accept: "application/json", "Cache-Control": "no-cache" }, + redirect: "error", + signal: AbortSignal.timeout(10_000), + }, +); +if (response.status === 404) { + console.log("absent"); +} else { + if (response.status !== 200) throw new Error(`npm registry returned HTTP ${String(response.status)}.`); + const coordinate = parseNpmRelease(await response.json() as unknown, inspection.version); + const expectedIntegrity = `sha512-${createHash("sha512").update(bytes).digest("base64")}`; + const expectedShasum = createHash("sha1").update(bytes).digest("hex"); + if (coordinate.integrity !== expectedIntegrity || coordinate.shasum !== expectedShasum) { + throw new Error(`${inspection.name}@${inspection.version} exists with different immutable bytes.`); + } + console.log("exact"); +} diff --git a/scripts/check-public-release.ts b/scripts/check-public-release.ts index 3071581..dd6fec4 100644 --- a/scripts/check-public-release.ts +++ b/scripts/check-public-release.ts @@ -117,6 +117,7 @@ const tufCachePath = await mkdtemp(`${tmpdir()}/hra-sigstore-tuf-`); try { const attestationsUrl = `https://registry.npmjs.org/-/npm/v1/attestations/@hraness%2fhra@${inspection.version}`; await verifyNpmProvenance({ + attemptPolicy: "same_run_not_later", attestations: await json(attestationsUrl, "npm Sigstore attestations"), integrity: npmRelease.integrity, runAttempt, diff --git a/scripts/npm-publication-transition.test.ts b/scripts/npm-publication-transition.test.ts new file mode 100644 index 0000000..290cb08 --- /dev/null +++ b/scripts/npm-publication-transition.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from "bun:test"; + +import { decideNpmPublicationTransition } from "./npm-publication-transition"; + +const base = { + currentArtifactState: "absent", + currentRunAttempt: "1", + currentRunId: "123", + preflightArtifactState: "absent", + preflightRunAttempt: "1", + preflightRunId: "123", +} as const; + +describe("npm publication retry transition", () => { + test("requires current-attempt provenance when a same-attempt preflight was absent", () => { + expect(decideNpmPublicationTransition(base)).toEqual({ + action: "publish", + attemptPolicy: "exact", + }); + expect(decideNpmPublicationTransition({ + ...base, + currentArtifactState: "exact", + })).toEqual({ + action: "admit_existing", + attemptPolicy: "exact", + }); + }); + + test("completes after publish succeeded but admission failed on the prior attempt", () => { + expect(decideNpmPublicationTransition({ + ...base, + currentArtifactState: "exact", + currentRunAttempt: "2", + })).toEqual({ + action: "admit_existing", + attemptPolicy: "same_run_not_later", + }); + }); + + test("completes when publication became visible despite the prior publisher job failing", () => { + expect(decideNpmPublicationTransition({ + ...base, + currentArtifactState: "exact", + currentRunAttempt: "3", + preflightRunAttempt: "2", + })).toEqual({ + action: "admit_existing", + attemptPolicy: "same_run_not_later", + }); + }); + + test("rejects another run, a future preflight, and disappearance after exact preflight", () => { + expect(() => decideNpmPublicationTransition({ + ...base, + currentRunId: "124", + })).toThrow("this workflow run"); + expect(() => decideNpmPublicationTransition({ + ...base, + currentRunAttempt: "1", + preflightRunAttempt: "2", + })).toThrow("this workflow run"); + expect(() => decideNpmPublicationTransition({ + ...base, + preflightArtifactState: "exact", + })).toThrow("disappeared"); + }); +}); diff --git a/scripts/npm-publication-transition.ts b/scripts/npm-publication-transition.ts new file mode 100644 index 0000000..aaaa2be --- /dev/null +++ b/scripts/npm-publication-transition.ts @@ -0,0 +1,40 @@ +import type { NpmProvenanceAttemptPolicy } from "./verify-npm-provenance"; + +export type NpmArtifactState = "absent" | "exact"; + +export type NpmPublicationTransition = Readonly<{ + action: "admit_existing" | "publish"; + attemptPolicy: NpmProvenanceAttemptPolicy; +}>; + +const positiveInteger = /^[1-9][0-9]*$/u; + +export function decideNpmPublicationTransition(input: Readonly<{ + currentArtifactState: NpmArtifactState; + currentRunAttempt: string; + currentRunId: string; + preflightArtifactState: NpmArtifactState; + preflightRunAttempt: string; + preflightRunId: string; +}>): NpmPublicationTransition { + if ( + !positiveInteger.test(input.currentRunId) + || !positiveInteger.test(input.currentRunAttempt) + || !positiveInteger.test(input.preflightRunId) + || !positiveInteger.test(input.preflightRunAttempt) + || input.preflightRunId !== input.currentRunId + || BigInt(input.preflightRunAttempt) > BigInt(input.currentRunAttempt) + ) throw new Error("npm publication preflight is not from this workflow run at or before the current attempt."); + if (input.preflightArtifactState === "exact" && input.currentArtifactState === "absent") { + throw new Error("The exact npm artifact disappeared after publication preflight."); + } + if (input.currentArtifactState === "absent") { + return Object.freeze({ action: "publish", attemptPolicy: "exact" }); + } + const sameAttemptAbsent = input.preflightArtifactState === "absent" + && input.preflightRunAttempt === input.currentRunAttempt; + return Object.freeze({ + action: "admit_existing", + attemptPolicy: sameAttemptAbsent ? "exact" : "same_run_not_later", + }); +} diff --git a/scripts/publish-npm-release.ts b/scripts/publish-npm-release.ts index 65dbc11..174ae7c 100644 --- a/scripts/publish-npm-release.ts +++ b/scripts/publish-npm-release.ts @@ -1,9 +1,16 @@ import { createHash } from "node:crypto"; -import { readFile } from "node:fs/promises"; -import { basename, resolve } from "node:path"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { basename, join, resolve } from "node:path"; import { parseNpmRelease } from "./release-distribution-policy"; +import { + decideNpmPublicationTransition, +} from "./npm-publication-transition"; import { assertReleasePackageReady, releaseArchiveName } from "./release-package-policy"; +import { verifyNpmProvenance, type NpmProvenanceAttemptPolicy } from "./verify-npm-provenance"; + +const maximumAttestationBytes = 512 * 1024; const argument = process.argv[2]; if (argument === undefined) throw new Error("Usage: publish-npm-release.ts ARTIFACT.tgz"); @@ -15,10 +22,33 @@ if (basename(tarball) !== releaseArchiveName(inspection.version)) { throw new Error("npm publication received the wrong release artifact name."); } const verifiedTag = process.env.VERIFIED_TAG; +const verifiedSha = process.env.VERIFIED_SHA; +const runId = process.env.GITHUB_RUN_ID; const runAttempt = process.env.GITHUB_RUN_ATTEMPT; -if (verifiedTag !== `v${inspection.version}` || runAttempt === undefined || !/^[1-9][0-9]*$/u.test(runAttempt)) { - throw new Error("npm publication requires one verified tag and workflow attempt."); +const preflightArtifactState = process.env.HRA_NPM_PREFLIGHT_STATE; +const preflightRunAttempt = process.env.HRA_NPM_PREFLIGHT_RUN_ATTEMPT; +const preflightRunId = process.env.HRA_NPM_PREFLIGHT_RUN_ID; +if ( + verifiedTag !== `v${inspection.version}` + || verifiedSha === undefined + || !/^[0-9a-f]{40}$/u.test(verifiedSha) + || runId === undefined + || !/^[1-9][0-9]*$/u.test(runId) + || runAttempt === undefined + || !/^[1-9][0-9]*$/u.test(runAttempt) + || (preflightArtifactState !== "absent" && preflightArtifactState !== "exact") + || preflightRunAttempt === undefined + || preflightRunId === undefined +) { + throw new Error("npm publication requires one verified source and registry preflight identity."); } +const releaseSha = verifiedSha; +const releaseTag = verifiedTag; +const workflowRunAttempt = runAttempt; +const workflowRunId = runId; +const registryPreflightState = preflightArtifactState; +const registryPreflightRunAttempt = preflightRunAttempt; +const registryPreflightRunId = preflightRunId; const expectedIntegrity = `sha512-${createHash("sha512").update(bytes).digest("base64")}`; const expectedShasum = createHash("sha1").update(bytes).digest("hex"); const url = `https://registry.npmjs.org/${encodeURIComponent(inspection.name)}/${inspection.version}`; @@ -40,7 +70,81 @@ async function lookup(): Promise { return true; } -if (await lookup()) { +async function attestations(): Promise { + const attestationsUrl = + `https://registry.npmjs.org/-/npm/v1/attestations/@hraness%2fhra@${inspection.version}`; + const response = await fetch(attestationsUrl, { + cache: "no-store", + headers: { Accept: "application/json", "Cache-Control": "no-cache" }, + redirect: "error", + signal: AbortSignal.timeout(20_000), + }); + if (response.status !== 200) { + throw new Error(`npm Sigstore attestations returned HTTP ${String(response.status)}.`); + } + const declared = response.headers.get("content-length"); + if (declared !== null && (!/^(?:0|[1-9][0-9]*)$/u.test(declared) || Number(declared) > maximumAttestationBytes)) { + throw new Error("npm Sigstore attestations exceed their declared byte bound."); + } + const reader = response.body?.getReader(); + if (reader === undefined) throw new Error("npm Sigstore attestations have no body."); + const chunks: Uint8Array[] = []; + let length = 0; + try { + for (;;) { + const item = await reader.read(); + if (item.done) break; + length += item.value.byteLength; + if (length > maximumAttestationBytes) { + throw new Error("npm Sigstore attestations exceed their byte bound."); + } + chunks.push(item.value); + } + } finally { + try { await reader.cancel(); } catch { /* the bounded result remains authoritative */ } + reader.releaseLock(); + } + const payload = new Uint8Array(length); + let offset = 0; + for (const chunk of chunks) { + payload.set(chunk, offset); + offset += chunk.byteLength; + } + try { + return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(payload)) as unknown; + } catch { + throw new Error("npm Sigstore attestations returned malformed JSON."); + } +} + +async function admitProvenance(attemptPolicy: NpmProvenanceAttemptPolicy): Promise { + const tufCachePath = await mkdtemp(join(tmpdir(), "hra-publish-sigstore-tuf-")); + try { + await verifyNpmProvenance({ + attemptPolicy, + attestations: await attestations(), + integrity: expectedIntegrity, + runAttempt: workflowRunAttempt, + runId: workflowRunId, + sha: releaseSha, + tag: releaseTag, + tufCachePath, + }); + } finally { + await rm(tufCachePath, { force: true, recursive: true }); + } +} + +const transition = decideNpmPublicationTransition({ + currentArtifactState: await lookup() ? "exact" : "absent", + currentRunAttempt: workflowRunAttempt, + currentRunId: workflowRunId, + preflightArtifactState: registryPreflightState, + preflightRunAttempt: registryPreflightRunAttempt, + preflightRunId: registryPreflightRunId, +}); +if (transition.action === "admit_existing") { + await admitProvenance(transition.attemptPolicy); console.log(`${inspection.name}@${inspection.version} already contains the exact trusted-publisher bytes.`); } else { if (process.env.HRA_APPROVE_NPM_PUBLICATION !== `publish:${inspection.name}@${inspection.version}`) { @@ -70,7 +174,10 @@ if (await lookup()) { "NPM_CONFIG_REGISTRY", "PATH", "RUNNER_ENVIRONMENT", - ].flatMap((name) => process.env[name] === undefined ? [] : [[name, process.env[name] as string]])); + ].flatMap((name) => { + const value = process.env[name]; + return value === undefined ? [] : [[name, value]]; + })); const child = Bun.spawn([ "npm", "publish", tarball, "--access", "public", "--provenance", ], { env: cleanEnvironment, stderr: "pipe", stdin: "ignore", stdout: "pipe" }); @@ -107,5 +214,6 @@ if (await lookup()) { } } if (!observed) throw new Error("npm publication did not become readable with exact provenance-bearing bytes."); + await admitProvenance("exact"); console.log(`Published exact ${inspection.name}@${inspection.version} through npm trusted publishing.`); } diff --git a/scripts/release-workflow.test.ts b/scripts/release-workflow.test.ts index 79292f3..4273815 100644 --- a/scripts/release-workflow.test.ts +++ b/scripts/release-workflow.test.ts @@ -71,10 +71,13 @@ describe("release workflow", () => { expect(workflow).toContain("npm pack --ignore-scripts --pack-destination artifacts ."); expect(workflow).toContain("release-artifact-checksum.ts"); expect(workflow).toContain("check-release-package.ts"); + expect(workflow).toContain("check-npm-artifact-state.ts"); expect(workflow).toContain("publish-npm-release.ts"); expect(workflow).toContain("publish-github-release.ts"); expect(workflow).toContain("check-public-release.ts"); expect(workflow).toContain("os: [ubuntu-24.04, macos-15]"); + expect(workflow).toContain("npm_preflight_run_attempt"); + expect(workflow).toContain("HRA_NPM_PREFLIGHT_RUN_ATTEMPT"); expect(workflow).not.toContain("release-candidate.ts"); expect(workflow).not.toContain("publish-beta-release.ts"); expect(workflow).not.toContain("hra-weld.vercel.app"); diff --git a/scripts/verify-npm-provenance.test.ts b/scripts/verify-npm-provenance.test.ts new file mode 100644 index 0000000..e661177 --- /dev/null +++ b/scripts/verify-npm-provenance.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, test } from "bun:test"; + +import { assertNpmProvenanceBuildIdentity } from "./verify-npm-provenance"; + +const sha = "a".repeat(40); +const tag = "v0.1.0"; + +function predicate( + runId = "123", + runAttempt = "2", + options: Readonly<{ commit?: string; invocationSuffix?: string }> = {}, +): unknown { + return { + buildDefinition: { + buildType: "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1", + externalParameters: { + workflow: { + path: ".github/workflows/release.yml", + ref: `refs/tags/${tag}`, + repository: "https://github.com/hraness/hra", + }, + }, + resolvedDependencies: [{ + digest: { gitCommit: options.commit ?? sha }, + uri: `git+https://github.com/hraness/hra@refs/tags/${tag}`, + }], + }, + runDetails: { + builder: { id: "https://github.com/actions/runner/github-hosted" }, + metadata: { + invocationId: + `https://github.com/hraness/hra/actions/runs/${runId}/attempts/${runAttempt}${options.invocationSuffix ?? ""}`, + }, + }, + }; +} + +const identity = { + runAttempt: "2", + runId: "123", + sha, + tag, +} as const; + +describe("npm provenance workflow attempt admission", () => { + test("requires the current attempt for a first publication", () => { + expect(assertNpmProvenanceBuildIdentity(predicate(), { + ...identity, + attemptPolicy: "exact", + })).toBe("2"); + expect(() => assertNpmProvenanceBuildIdentity(predicate("123", "1"), { + ...identity, + attemptPolicy: "exact", + })).toThrow("inadmissible workflow attempt"); + }); + + test("admits an earlier positive attempt only within the same workflow run", () => { + expect(assertNpmProvenanceBuildIdentity(predicate("123", "1"), { + ...identity, + attemptPolicy: "same_run_not_later", + })).toBe("1"); + expect(() => assertNpmProvenanceBuildIdentity(predicate("123", "3"), { + ...identity, + attemptPolicy: "same_run_not_later", + })).toThrow("inadmissible workflow attempt"); + expect(() => assertNpmProvenanceBuildIdentity(predicate("3123", "2"), { + ...identity, + attemptPolicy: "same_run_not_later", + })).toThrow("workflow-run identity"); + }); + + test("keeps the release workflow, ref, commit, and invocation coordinates exact", () => { + expect(() => assertNpmProvenanceBuildIdentity(predicate("123", "2", { + commit: "b".repeat(40), + }), { + ...identity, + attemptPolicy: "same_run_not_later", + })).toThrow("exact release ref and commit"); + + expect(() => assertNpmProvenanceBuildIdentity(predicate("123", "2", { + invocationSuffix: "/jobs/7", + }), { + ...identity, + attemptPolicy: "same_run_not_later", + })).toThrow("workflow-run identity"); + }); +}); diff --git a/scripts/verify-npm-provenance.ts b/scripts/verify-npm-provenance.ts index 66b0668..3192b11 100644 --- a/scripts/verify-npm-provenance.ts +++ b/scripts/verify-npm-provenance.ts @@ -4,6 +4,9 @@ type JsonRecord = Record; const SLSA_V1 = "https://slsa.dev/provenance/v1"; const FULCIO_GITHUB_ISSUER = "https://token.actions.githubusercontent.com"; +const GITHUB_BUILD_TYPE = "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1"; +const GITHUB_BUILDER_ID = "https://github.com/actions/runner/github-hosted"; +const GITHUB_REPOSITORY_URL = "https://github.com/hraness/hra"; const GITHUB_OIDS = Object.freeze({ "1.3.6.1.4.1.57264.1.1": "push", "1.3.6.1.4.1.57264.1.2": "__SHA__", @@ -12,6 +15,8 @@ const GITHUB_OIDS = Object.freeze({ "1.3.6.1.4.1.57264.1.5": "__REF__", }); +export type NpmProvenanceAttemptPolicy = "exact" | "same_run_not_later"; + function record(value: unknown, label: string): JsonRecord { if (value === null || typeof value !== "object" || Array.isArray(value)) { throw new Error(`${label} must be an object.`); @@ -27,7 +32,63 @@ function exactKeys(value: JsonRecord, keys: readonly string[], label: string): v } } +export function assertNpmProvenanceBuildIdentity( + value: unknown, + input: Readonly<{ + attemptPolicy: NpmProvenanceAttemptPolicy; + runAttempt: string; + runId: string; + sha: string; + tag: string; + }>, +): string { + if (!/^[1-9][0-9]*$/u.test(input.runId) || !/^[1-9][0-9]*$/u.test(input.runAttempt)) { + throw new Error("npm provenance requires exact workflow run identity."); + } + const predicate = record(value, "SLSA predicate"); + const buildDefinition = record(predicate.buildDefinition, "SLSA build definition"); + const externalParameters = record(buildDefinition.externalParameters, "SLSA external parameters"); + const workflow = record(externalParameters.workflow, "SLSA workflow"); + const dependencies = buildDefinition.resolvedDependencies; + if ( + buildDefinition.buildType !== GITHUB_BUILD_TYPE + || workflow.repository !== GITHUB_REPOSITORY_URL + || workflow.path !== ".github/workflows/release.yml" + || workflow.ref !== `refs/tags/${input.tag}` + || !Array.isArray(dependencies) + || dependencies.length !== 1 + ) throw new Error("SLSA provenance has the wrong release workflow identity."); + const dependency = record(dependencies[0], "SLSA resolved dependency"); + const dependencyDigest = record(dependency.digest, "SLSA resolved dependency digest"); + exactKeys(dependencyDigest, ["gitCommit"], "SLSA resolved dependency digest"); + if ( + dependency.uri !== `git+${GITHUB_REPOSITORY_URL}@refs/tags/${input.tag}` + || dependencyDigest.gitCommit !== input.sha + ) throw new Error("SLSA provenance does not bind the exact release ref and commit."); + + const runDetails = record(predicate.runDetails, "SLSA run details"); + const builder = record(runDetails.builder, "SLSA builder"); + const metadata = record(runDetails.metadata, "SLSA run metadata"); + const invocationPrefix = `${GITHUB_REPOSITORY_URL}/actions/runs/${input.runId}/attempts/`; + if (builder.id !== GITHUB_BUILDER_ID || typeof metadata.invocationId !== "string") { + throw new Error("SLSA provenance has the wrong GitHub-hosted builder identity."); + } + const publishedAttempt = metadata.invocationId.startsWith(invocationPrefix) + ? metadata.invocationId.slice(invocationPrefix.length) + : ""; + if (!/^[1-9][0-9]*$/u.test(publishedAttempt)) { + throw new Error("SLSA provenance is missing exact workflow-run identity."); + } + if ( + input.attemptPolicy === "exact" + ? publishedAttempt !== input.runAttempt + : BigInt(publishedAttempt) > BigInt(input.runAttempt) + ) throw new Error("SLSA provenance came from an inadmissible workflow attempt."); + return publishedAttempt; +} + export async function verifyNpmProvenance(input: Readonly<{ + attemptPolicy: NpmProvenanceAttemptPolicy; attestations: unknown; integrity: string; runId: string; @@ -84,15 +145,5 @@ export async function verifyNpmProvenance(input: Readonly<{ throw new Error("npm provenance subject does not bind the exact package bytes."); } const predicate = record(statement.predicate, "SLSA predicate"); - const runDetails = JSON.stringify(predicate); - for (const exact of [ - "hraness/hra", - `.github/workflows/release.yml`, - `refs/tags/${input.tag}`, - input.sha, - input.runId, - input.runAttempt, - ]) { - if (!runDetails.includes(exact)) throw new Error("SLSA provenance is missing exact source or workflow-run identity."); - } + assertNpmProvenanceBuildIdentity(predicate, input); } From 6c29bca1d0096ba8e0b7933cb89fa2fbc3989903 Mon Sep 17 00:00:00 2001 From: 0thernet Date: Sun, 30 Aug 2026 16:55:20 -0400 Subject: [PATCH 3/3] release: harden exact artifact retries --- .github/CODEOWNERS | 4 + .github/workflows/release.yml | 41 +- docs/beta-release.md | 61 ++- eslint.config.mjs | 4 + package.json | 2 +- scripts/bounded-json-response.test.ts | 30 ++ scripts/bounded-json-response.ts | 43 ++ scripts/check-npm-artifact-state.ts | 6 +- scripts/check-package.test.ts | 138 +++++- scripts/check-package.ts | 281 +++++++++++- scripts/check-public-release.ts | 18 +- scripts/github-publisher-environment.ts | 31 ++ scripts/github-release-identity.ts | 141 ++++++ scripts/npm-publication-transition.test.ts | 18 + scripts/npm-publication-transition.ts | 10 +- scripts/package-policy.ts | 2 +- scripts/public-text-policy.test.ts | 24 ++ scripts/public-text-policy.ts | 6 +- scripts/publish-beta-release.test.ts | 84 +--- scripts/publish-github-release.ts | 400 ++++++++++++++++-- scripts/publish-npm-release.ts | 64 +-- scripts/release-included-response.test.ts | 32 ++ scripts/release-included-response.ts | 39 ++ scripts/release-workflow.test.ts | 268 +++++++++++- scripts/verify-npm-provenance-crypto.mjs | 187 ++++++++ scripts/verify-npm-provenance-crypto.node.mjs | 22 + scripts/verify-npm-provenance-crypto.test.ts | 46 ++ scripts/verify-npm-provenance.test.ts | 172 +++++++- scripts/verify-npm-provenance.ts | 368 +++++++++++++--- src/install-preflight.test.ts | 112 ++++- 30 files changed, 2383 insertions(+), 271 deletions(-) create mode 100644 scripts/bounded-json-response.test.ts create mode 100644 scripts/bounded-json-response.ts create mode 100644 scripts/github-publisher-environment.ts create mode 100644 scripts/github-release-identity.ts create mode 100644 scripts/release-included-response.test.ts create mode 100644 scripts/release-included-response.ts create mode 100644 scripts/verify-npm-provenance-crypto.mjs create mode 100644 scripts/verify-npm-provenance-crypto.node.mjs create mode 100644 scripts/verify-npm-provenance-crypto.test.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 3c450f1..145f468 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -7,6 +7,9 @@ /scripts/check-release-package.ts @0thernet /scripts/check-npm-trusted-publishing.ts @0thernet /scripts/check-npm-artifact-state.ts @0thernet +/scripts/bounded-json-response.ts @0thernet +/scripts/github-publisher-environment.ts @0thernet +/scripts/github-release-identity.ts @0thernet /scripts/npm-publication-transition.ts @0thernet /scripts/package-policy.ts @0thernet /scripts/publish-github-release.ts @0thernet @@ -15,6 +18,7 @@ /scripts/release-distribution-policy.ts @0thernet /scripts/release-package-policy.ts @0thernet /scripts/verify-npm-provenance.ts @0thernet +/scripts/verify-npm-provenance-crypto.mjs @0thernet /src/install-normalizer.ts @0thernet /src/install-preflight-runtime.ts @0thernet /src/install-preflight.ts @0thernet diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index efc9726..518a379 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,6 +20,8 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 45 outputs: + artifact_digest: ${{ steps.release_artifact.outputs.artifact-digest }} + artifact_id: ${{ steps.release_artifact.outputs.artifact-id }} npm_preflight_run_attempt: ${{ steps.npm_preflight.outputs.run_attempt }} npm_preflight_run_id: ${{ steps.npm_preflight.outputs.run_id }} npm_preflight_state: ${{ steps.npm_preflight.outputs.state }} @@ -115,9 +117,10 @@ jobs: fi printf 'state=%s\nrun_id=%s\nrun_attempt=%s\n' "$state" "$GITHUB_RUN_ID" "$GITHUB_RUN_ATTEMPT" >> "$GITHUB_OUTPUT" - name: Preserve exact release bytes + id: release_artifact uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: - name: hra-release + name: hra-release-${{ github.run_attempt }} path: artifacts/ if-no-files-found: error retention-days: 7 @@ -146,10 +149,18 @@ jobs: bun-version-file: .bun-version - name: Install exact locked dependencies without lifecycle scripts run: bun install --frozen-lockfile --ignore-scripts + - name: Require exact artifact identity + env: + VERIFIED_ARTIFACT_DIGEST: ${{ needs.verify.outputs.artifact_digest }} + VERIFIED_ARTIFACT_ID: ${{ needs.verify.outputs.artifact_id }} + run: | + [[ "$VERIFIED_ARTIFACT_ID" =~ ^[1-9][0-9]*$ ]] + [[ "$VERIFIED_ARTIFACT_DIGEST" =~ ^[0-9a-f]{64}$ ]] - name: Download exact release bytes uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: - name: hra-release + artifact-ids: ${{ needs.verify.outputs.artifact_id }} + merge-multiple: true path: artifacts - name: Verify checksum and complete installed-package behavior run: | @@ -169,8 +180,6 @@ jobs: timeout-minutes: 30 env: DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} - GH_TOKEN: ${{ github.token }} - GITHUB_TOKEN: ${{ github.token }} VERIFIED_SHA: ${{ needs.verify.outputs.verified_sha }} VERIFIED_TAG: ${{ needs.verify.outputs.verified_tag }} VERIFIED_TAG_OBJECT: ${{ needs.verify.outputs.verified_tag_object }} @@ -200,12 +209,22 @@ jobs: run: | bun run ./scripts/check-release-package.ts bun run ./scripts/check-npm-trusted-publishing.ts + - name: Require exact artifact identity + env: + VERIFIED_ARTIFACT_DIGEST: ${{ needs.verify.outputs.artifact_digest }} + VERIFIED_ARTIFACT_ID: ${{ needs.verify.outputs.artifact_id }} + run: | + [[ "$VERIFIED_ARTIFACT_ID" =~ ^[1-9][0-9]*$ ]] + [[ "$VERIFIED_ARTIFACT_DIGEST" =~ ^[0-9a-f]{64}$ ]] - name: Download validated release bytes uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: - name: hra-release + artifact-ids: ${{ needs.verify.outputs.artifact_id }} + merge-multiple: true path: artifacts - name: Revalidate remote authority and checksum + env: + GH_TOKEN: ${{ github.token }} run: | set -euo pipefail tag_object="$(gh api "/repos/$GITHUB_REPOSITORY/git/ref/tags/$VERIFIED_TAG" --jq 'select(.object.type == "tag") | .object.sha')" @@ -217,13 +236,17 @@ jobs: git merge-base --is-ancestor "$VERIFIED_SHA" "$remote_main" artifact="$(find "$GITHUB_WORKSPACE/artifacts" -maxdepth 1 -type f -name '*.tgz')" bun run ./scripts/release-artifact-checksum.ts check "$artifact" "$GITHUB_WORKSPACE/artifacts/SHA256SUMS" - - name: Publish exact tarball through npm trusted publishing - run: | - artifact="$(find "$GITHUB_WORKSPACE/artifacts" -maxdepth 1 -type f -name '*.tgz')" - bun run ./scripts/publish-npm-release.ts "$artifact" - name: Create immutable GitHub Release from the same bytes + env: + GH_TOKEN: ${{ github.token }} run: | artifact="$(find "$GITHUB_WORKSPACE/artifacts" -maxdepth 1 -type f -name '*.tgz')" bun run ./scripts/publish-github-release.ts "$VERIFIED_TAG" "$artifact" "$GITHUB_WORKSPACE/artifacts/SHA256SUMS" + - name: Publish exact tarball through npm trusted publishing + run: | + artifact="$(find "$GITHUB_WORKSPACE/artifacts" -maxdepth 1 -type f -name '*.tgz')" + bun run ./scripts/publish-npm-release.ts "$artifact" - name: Admit exact public npm and GitHub state + env: + GITHUB_TOKEN: ${{ github.token }} run: bun run ./scripts/check-public-release.ts diff --git a/docs/beta-release.md b/docs/beta-release.md index 4330b88..dbc296d 100644 --- a/docs/beta-release.md +++ b/docs/beta-release.md @@ -6,9 +6,9 @@ The former `v0.1.0` beta process depended on the HRA v0 Vercel deployment, its p The former public `release:candidate` and `release:publish` package entries remain removed. Their fallback-bound implementation and deterministic tests under `scripts/` remain only as a safety and design record. They must not be invoked directly to create a candidate, tag, draft, workflow lease, publication, or provider mutation. -The replacement `.github/workflows/release.yml` is an artifact-only current-repository path. It does not read or mutate Vercel, Convex, DNS, hosted aliases, or any retired HRA v0 resource. It requires an immutable annotated stable version tag whose peeled commit is contained in reviewed `main`, runs the complete repository gate, builds one npm tarball, verifies that same tarball on macOS and Linux, publishes it through npm trusted publishing, creates an immutable GitHub Release from the same tarball plus `SHA256SUMS`, and admits the public bytes and provenance before success. +The replacement `.github/workflows/release.yml` is an artifact-only current-repository path. It does not read or mutate Vercel, Convex, DNS, hosted aliases, or any retired HRA v0 resource. It requires an immutable annotated stable version tag whose peeled commit is contained in reviewed `main`, runs the complete repository gate, builds one npm tarball, verifies that same tarball on macOS and Linux, creates and proves an immutable GitHub Release from the tarball plus `SHA256SUMS`, publishes that tarball through npm trusted publishing, and admits the public bytes and provenance before success. -Publication is still blocked. `@hraness/oh` is a GitHub runtime dependency and has no public npm coordinate, while the replacement gate accepts only exact registry runtime versions. The `@hraness/hra` npm package also must exist before its GitHub trusted publisher can be configured. Do not create an HRA version tag or attempt publication until Oh is published and pinned by exact registry version, the first-package bootstrap is reviewed, trusted publishing names this repository and `release.yml`, and a clean release rehearsal passes. +Publication is still blocked. Stable HRA must replace its GitHub `@hraness/oh#v0.2.0` runtime dependency with exact registry version `0.2.4`, after that OIDC-published version is publicly available as Oh's `latest`. The `@hraness/hra` npm package also must exist before its GitHub trusted publisher can be configured. Do not attempt publication until that dependency transition is reviewed, the separate first-package bootstrap is complete, trusted publishing names this repository and `release.yml`, and a clean release rehearsal passes. The README and website remain explicit that the beta is not live. This control-layer preparation does not make the displayed install command usable and does not authorize a tag, draft, Release, npm publication, website claim change, or hosted-service mutation. Preserve old local receipts, intents, and evidence files as historical records; they do not authorize replay. @@ -17,19 +17,54 @@ The README and website remain explicit that the beta is not live. This control-l Release automation assumes GitHub protects `main` with required pull-request review, required CODEOWNERS review for release-authority files, required successful CI, dismissal of stale approvals, conversation resolution, linear history, and administrator enforcement. -The repository must also protect `v*` tags against direct creation, update, force-push, -and deletion. A release operator creates one annotated stable-semver tag only after the -reviewed commit is the protected `main` head; no workflow, administrator, or retry path -may bypass those rules. The workflow independently binds the exact tag ref, annotated -tag object, peeled commit, checked-out commit, and ancestry in current protected `main`. +The repository must allow exactly one governed creation of each `v*` tag and prohibit +tag update, force-push, and deletion without a bypass path. A release operator may create +one annotated stable-semver tag before or after the reviewed commit reaches `main`; no +workflow, administrator, or retry path may update, recreate, or delete that tag object. +Publication is admitted only after the workflow independently binds the exact tag ref, +annotated tag object, peeled commit, checked-out commit, and ancestry in current reviewed +`main`. Any positive rerun attempt may finish the same release only after re-proving that exact tag object, commit, artifact checksum, and `main` ancestry; it may never substitute bytes, a tag, a commit, or a different workflow run. +GitHub publication creates one deterministic draft before uploading. A later attempt may +resume only one draft created by the same workflow run with the exact numeric release ID, +tag, title, canonical identity body, tag object, commit, and artifact manifest. The body +records repository path and numeric ID, workflow ref, run ID, creation and publication +attempts, and both asset names, sizes, and SHA-256 digests. It inventories +drafts within a fixed bound, rejects duplicates or extra assets, verifies existing asset +names, sizes, digests, and downloaded bytes, uploads only a missing tarball or checksum, +then publishes and re-reads the immutable Latest Release by the same numeric ID. Final +success also requires that no residual draft for the tag remains. A mismatched, ambiguous, +or coexisting draft is terminal and is never overwritten, deleted, or treated as retry +authority. + +The verification job exports GitHub's positive numeric artifact ID and the upload action's +lowercase digest. Consumers reject malformed values and download only that numeric ID; the +outer digest is a transport assertion, not independent release authority. Authority over +the consumed bytes comes from the downloaded inner `SHA256SUMS`, which is revalidated +against the exact tarball before any publication step. + +The privileged publish job's trusted computing base is broader than either writer script. +It includes the reviewed workflow, its SHA-pinned `actions/checkout`, `setup-bun`, +`setup-node`, upstream `upload-artifact`, and `download-artifact` revisions, the +checked-out verification and publication code they execute, the installed locked +toolchain, and the GitHub-hosted runner. The job's npm OIDC permission makes every step +in that job security-sensitive. +The GitHub token is not job-wide: it is exposed only to the exact remote-authority +revalidation, GitHub Release publication/readback, and final public-admission steps. + The first npm publication is a separate bootstrap ceremony because npm cannot attach a -trusted publisher to a package coordinate that does not yet exist. That bootstrap must -publish a non-`latest` prerelease under explicit operator approval and then configure the -npm trusted publisher for `hraness/hra` and `.github/workflows/release.yml`. A later, -separately versioned stable release is the first OIDC/provenance publication; the stable -workflow never silently performs the bootstrap or converts a bootstrap version into -`latest`. +trusted publisher to a package coordinate that does not yet exist. It must publish only a +non-executable coordinate seed, `@hraness/hra@0.1.0-bootstrap.0`, under the non-`latest` +`bootstrap` dist-tag. It must never consume stable `0.1.0`, expose the HRA executable, or +reuse any retained stable tarball. The ceremony requires explicit operator approval and +the repository variable +`HRA_APPROVE_NPM_PUBLICATION=publish:@hraness/hra@0.1.0` before stable publication. + +After the coordinate exists, an operator using npm CLI 11.15.0 or newer configures the sole +trusted publisher as GitHub repository `hraness/hra`, workflow `release.yml`, publish-only, +with no npm environment. Stable `0.1.0` remains the first OIDC/provenance publication. The +stable workflow never performs the bootstrap, grants a second publisher, or promotes the +bootstrap seed to `latest`. diff --git a/eslint.config.mjs b/eslint.config.mjs index b4134da..d297995 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -36,4 +36,8 @@ export default tseslint.config( "@typescript-eslint/no-unsafe-assignment": "off", }, }, + { + files: ["scripts/**/*.mjs"], + ...tseslint.configs.disableTypeChecked, + }, ); diff --git a/package.json b/package.json index 0329e1f..12d4fec 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "release:canonical-alias": "bun ./scripts/current-project-alias-release.ts", "lint": "eslint .", "start": "bun ./src/cli.ts", - "test": "bun test --isolate --max-concurrency=1", + "test": "bun test ./scripts --isolate --max-concurrency=1 && bun test ./src --isolate --max-concurrency=1 && bun test ./convex ./site --isolate --max-concurrency=1", "test:simulation": "bun test ./src/cloud/deterministic-authority-simulation.test.ts --isolate --max-concurrency=1", "test:package-policy": "sh -c 'umask 077; exec bun test ./scripts/package-policy.test.ts --isolate --max-concurrency=1'", "typecheck": "tsc --noEmit" diff --git a/scripts/bounded-json-response.test.ts b/scripts/bounded-json-response.test.ts new file mode 100644 index 0000000..f2bf329 --- /dev/null +++ b/scripts/bounded-json-response.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "bun:test"; + +import { readBoundedJsonResponse } from "./bounded-json-response"; + +describe("bounded release JSON responses", () => { + test("reads one UTF-8 JSON document within its declared and streamed bounds", async () => { + const bytes = new TextEncoder().encode('{"state":"exact"}'); + const response = new Response(new ReadableStream({ + start(controller) { + controller.enqueue(bytes.subarray(0, 5)); + controller.enqueue(bytes.subarray(5)); + controller.close(); + }, + }), { headers: { "content-length": String(bytes.byteLength) } }); + expect(await readBoundedJsonResponse(response, "registry fixture", 64)) + .toEqual({ state: "exact" }); + }); + + test("rejects declared overflow, chunked overflow, invalid UTF-8, and malformed JSON", async () => { + await expect(readBoundedJsonResponse(new Response("{}", { + headers: { "content-length": "65" }, + }), "registry fixture", 64)).rejects.toThrow("declared byte bound"); + await expect(readBoundedJsonResponse(new Response("x".repeat(65)), + "registry fixture", 64)).rejects.toThrow("byte bound"); + await expect(readBoundedJsonResponse(new Response(Uint8Array.of(0xff)), + "registry fixture", 64)).rejects.toThrow("malformed JSON"); + await expect(readBoundedJsonResponse(new Response("not-json"), + "registry fixture", 64)).rejects.toThrow("malformed JSON"); + }); +}); diff --git a/scripts/bounded-json-response.ts b/scripts/bounded-json-response.ts new file mode 100644 index 0000000..c74de32 --- /dev/null +++ b/scripts/bounded-json-response.ts @@ -0,0 +1,43 @@ +const DEFAULT_MAXIMUM_JSON_BYTES = 512 * 1024; + +export async function readBoundedJsonResponse( + response: Response, + label: string, + maximumBytes = DEFAULT_MAXIMUM_JSON_BYTES, +): Promise { + if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 1) { + throw new Error(`${label} has an invalid byte bound.`); + } + const declared = response.headers.get("content-length"); + if ( + declared !== null + && (!/^(?:0|[1-9][0-9]*)$/u.test(declared) || Number(declared) > maximumBytes) + ) throw new Error(`${label} exceeds its declared byte bound.`); + const reader = response.body?.getReader(); + if (reader === undefined) throw new Error(`${label} has no body.`); + const chunks: Uint8Array[] = []; + let length = 0; + try { + for (;;) { + const item = await reader.read(); + if (item.done) break; + length += item.value.byteLength; + if (length > maximumBytes) throw new Error(`${label} exceeds its byte bound.`); + chunks.push(item.value); + } + } finally { + try { await reader.cancel(); } catch { /* the bounded result remains authoritative */ } + reader.releaseLock(); + } + const payload = new Uint8Array(length); + let offset = 0; + for (const chunk of chunks) { + payload.set(chunk, offset); + offset += chunk.byteLength; + } + try { + return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(payload)) as unknown; + } catch { + throw new Error(`${label} returned malformed JSON.`); + } +} diff --git a/scripts/check-npm-artifact-state.ts b/scripts/check-npm-artifact-state.ts index bb749cd..d160f98 100644 --- a/scripts/check-npm-artifact-state.ts +++ b/scripts/check-npm-artifact-state.ts @@ -2,6 +2,7 @@ import { createHash } from "node:crypto"; import { readFile } from "node:fs/promises"; import { basename, resolve } from "node:path"; +import { readBoundedJsonResponse } from "./bounded-json-response"; import { parseNpmRelease } from "./release-distribution-policy"; import { assertReleasePackageReady, releaseArchiveName } from "./release-package-policy"; @@ -27,7 +28,10 @@ if (response.status === 404) { console.log("absent"); } else { if (response.status !== 200) throw new Error(`npm registry returned HTTP ${String(response.status)}.`); - const coordinate = parseNpmRelease(await response.json() as unknown, inspection.version); + const coordinate = parseNpmRelease( + await readBoundedJsonResponse(response, "npm registry exact release"), + inspection.version, + ); const expectedIntegrity = `sha512-${createHash("sha512").update(bytes).digest("base64")}`; const expectedShasum = createHash("sha1").update(bytes).digest("hex"); if (coordinate.integrity !== expectedIntegrity || coordinate.shasum !== expectedShasum) { diff --git a/scripts/check-package.test.ts b/scripts/check-package.test.ts index acf568b..5cab9dd 100644 --- a/scripts/check-package.test.ts +++ b/scripts/check-package.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { chmod, mkdtemp, mkdir, readFile, realpath, rename, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; @@ -8,11 +8,14 @@ import type { DaemonIdentity } from "../src/daemon/daemon-startup"; import { assertCompleteGitHistoryPublic, buildGitHistoryEnvironment, + packageDependencyCacheDiscoveryEnvironment, + parsePackageDependencyCache, parseGitHistoryCommitList, projectGitHistorySpawnResult, requireGitHistoryOutput, runPackageCommand, waitForOwnedInstalledDaemonReady, + withPackageDependencyCacheCustody, } from "./check-package"; import { assertPseudoTerminalSuccess, @@ -145,6 +148,139 @@ describe("installed package daemon ownership", () => { }); describe("installed package generic command ownership", () => { + test("admits only one canonical absolute Bun dependency cache path", () => { + const cache = resolve(join(tmpdir(), "hra-bun-cache")); + expect(parsePackageDependencyCache(cache)).toBe(cache); + expect(parsePackageDependencyCache(`${cache}\n`)).toBe(cache); + for (const value of [ + "relative/cache\n", + `${cache}\n${cache}\n`, + `${cache}\n\n`, + `${cache}\r\n`, + `${cache}\0\n`, + `${cache}/../cache\n`, + `${"/".repeat(4_097)}\n`, + ]) expect(() => parsePackageDependencyCache(value)).toThrow("non-canonical dependency cache path"); + }); + + test("shares only the validated dependency cache across private consumer roots", async () => { + const source = await readFile(join(import.meta.dir, "check-package.ts"), "utf8"); + expect(source.indexOf("await resolvePackageDependencyCache(repositoryRoot)")).toBeLessThan( + source.indexOf('mkdtemp(join(tmpdir(), "hra-package-")'), + ); + expect(source).toContain("BUN_INSTALL_CACHE_DIR: dependencyCacheRoot"); + expect(source).not.toContain("BUN_INSTALL_CACHE_DIR: globalInstallRoot"); + expect(source).toContain("delete discoveryEnvironment.BUN_INSTALL_CACHE_DIR;"); + expect(source.match(/await withPackageDependencyCacheCustody\(dependencyCacheRoot/gu)).toHaveLength(2); + for (const isolated of [ + "BUN_INSTALL: globalInstallRoot", + 'BUN_INSTALL_BIN: join(globalInstallRoot, "bin")', + 'BUN_INSTALL_GLOBAL_DIR: join(globalInstallRoot, "install", "global")', + "HOME: consumerHome", + "TMPDIR: consumerTemporaryDirectory", + ]) expect(source).toContain(isolated); + }); + + test("ignores a direct ambient cache override while retaining the configured Bun installation root", () => { + const environment = packageDependencyCacheDiscoveryEnvironment({ + BUN_INSTALL: "/canonical-bun-root", + BUN_INSTALL_CACHE_DIR: "/untrusted-direct-cache-override", + HRA_UNRELATED_FIXTURE: "preserved", + }); + expect(environment).toEqual({ + BUN_INSTALL: "/canonical-bun-root", + HRA_UNRELATED_FIXTURE: "preserved", + }); + }); + + test("holds the dependency cache descriptor and rejects path replacement", async () => { + const root = await realpath(await mkdtemp(join(tmpdir(), "hra-package-cache-custody-"))); + const cache = join(root, "cache"); + const displaced = join(root, "displaced"); + const replacement = join(root, "replacement"); + try { + await mkdir(cache, { mode: 0o700 }); + await mkdir(replacement, { mode: 0o700 }); + await expect(withPackageDependencyCacheCustody(cache, async () => { + await rename(cache, displaced); + await rename(replacement, cache); + })).rejects.toThrow("identity changed while in use"); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); + + test("fails closed when a cache consumer rejects with undefined", async () => { + const root = await realpath(await mkdtemp(join(tmpdir(), "hra-package-cache-undefined-error-"))); + const cache = join(root, "cache"); + try { + await mkdir(cache, { mode: 0o700 }); + await expect(withPackageDependencyCacheCustody(cache, async () => await Promise.reject(undefined))).rejects.toThrow( + "Bun dependency cache operation failed with a non-error value.", + ); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); + + test("holds the dependency cache parent chain and rejects parent replacement", async () => { + const temporaryParent = await realpath(await mkdtemp(join(tmpdir(), "hra-package-cache-parent-custody-"))); + const root = join(temporaryParent, "root"); + const cache = join(root, "cache"); + const displaced = join(temporaryParent, "displaced"); + const replacement = join(temporaryParent, "replacement"); + try { + await mkdir(cache, { mode: 0o700, recursive: true }); + await mkdir(join(replacement, "cache"), { mode: 0o700, recursive: true }); + await expect(withPackageDependencyCacheCustody(cache, async () => { + await rename(root, displaced); + await rename(replacement, root); + })).rejects.toThrow("path identity changed while in use"); + } finally { + await rm(temporaryParent, { force: true, recursive: true }); + } + }); + + test("rejects a group-writable dependency cache parent", async () => { + const temporaryParent = await realpath(await mkdtemp(join(tmpdir(), "hra-package-cache-parent-mode-"))); + const parent = join(temporaryParent, "parent"); + const cache = join(parent, "cache"); + try { + await mkdir(cache, { mode: 0o700, recursive: true }); + await chmod(parent, 0o770); + await expect(withPackageDependencyCacheCustody(cache, async () => undefined)).rejects.toThrow( + "path custody is invalid", + ); + } finally { + await rm(temporaryParent, { force: true, recursive: true }); + } + }); + + test("rejects a dangerous Darwin ACL on the dependency cache", async () => { + if (process.platform !== "darwin") return; + const root = await realpath(await mkdtemp(join(tmpdir(), "hra-package-cache-acl-"))); + const cache = join(root, "cache"); + const runChmod = async (...arguments_: string[]): Promise => { + const child = Bun.spawn(["/bin/chmod", ...arguments_], { + stderr: "pipe", + stdin: "ignore", + stdout: "pipe", + }); + const [exitCode, stderr] = await Promise.all([child.exited, new Response(child.stderr).text()]); + if (exitCode !== 0) throw new Error(`ACL fixture chmod failed: ${stderr}`); + }; + try { + await mkdir(cache, { mode: 0o700 }); + await runChmod("+a", "everyone allow delete", cache); + await expect(withPackageDependencyCacheCustody(cache, async () => undefined)).rejects.toThrow( + "dangerous non-owner Darwin ALLOW ACL", + ); + } finally { + await runChmod("-N", cache).catch(() => undefined); + await rm(root, { force: true, recursive: true }); + } + }); + test("scans complete Git history one bounded commit patch at a time", async () => { const source = await readFile(join(import.meta.dir, "check-package.ts"), "utf8"); expect(source).toContain('["--no-replace-objects", "rev-list", "--max-count=100001", "--all"]'); diff --git a/scripts/check-package.ts b/scripts/check-package.ts index 948cabf..cb497b3 100644 --- a/scripts/check-package.ts +++ b/scripts/check-package.ts @@ -1,8 +1,8 @@ import { spawn, type ChildProcess } from "node:child_process"; -import { constants } from "node:fs"; -import { access, lstat, mkdtemp, mkdir, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises"; +import { constants, type Stats } from "node:fs"; +import { access, lstat, mkdtemp, mkdir, open, opendir, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path"; +import { basename, dirname, isAbsolute, join, parse, resolve, sep } from "node:path"; import { isDeepStrictEqual } from "node:util"; import { z } from "zod"; @@ -22,7 +22,7 @@ import { import { rootStatusSchema } from "../src/domain/observation"; import type { StatePaths } from "../src/storage/paths"; import { resolveStatePaths } from "../src/storage/paths"; -import { assertHraInstallManifest } from "../src/install-normalizer"; +import { assertHraInstallManifest, assertSafeDarwinInstallAcl } from "../src/install-normalizer"; import { HRA_INSTALL_PREFLIGHT_SUCCESS } from "../src/install-preflight"; import { requireBoundedProcessCleanup, @@ -156,6 +156,257 @@ export const runPackageCommand = async ( }; }; +export const parsePackageDependencyCache = (stdout: string): string => { + if ( + Buffer.byteLength(stdout, "utf8") > 4_096 + || stdout.includes("\r") + ) throw new Error("Bun returned a non-canonical dependency cache path."); + const path = stdout.endsWith("\n") ? stdout.slice(0, -1) : stdout; + if ( + path.length < 1 + || path.includes("\n") + || path.includes("\0") + || !isAbsolute(path) + || resolve(path) !== path + ) throw new Error("Bun returned a non-canonical dependency cache path."); + return path; +}; + +type PackageDependencyCacheIdentity = Readonly<{ + dev: number; + ino: number; + mode: number; + uid: number; +}>; + +const packageDependencyCacheIdentity = (metadata: Stats): PackageDependencyCacheIdentity => ({ + dev: metadata.dev, + ino: metadata.ino, + mode: metadata.mode & 0o7777, + uid: metadata.uid, +}); + +const samePackageDependencyCacheIdentity = ( + left: PackageDependencyCacheIdentity, + right: PackageDependencyCacheIdentity, +): boolean => left.dev === right.dev + && left.ino === right.ino + && left.mode === right.mode + && left.uid === right.uid; + +type HeldPackageDependencyCacheDirectory = Readonly<{ + handle: Awaited>; + identity: PackageDependencyCacheIdentity; + path: string; + requiresCurrentOwner: boolean; +}>; + +const packageDependencyCacheDirectoryPathsThrough = (path: string): readonly string[] => { + const root = parse(path).root; + if (root.length === 0 || !path.startsWith(root)) throw new Error("Bun dependency cache path is invalid."); + const paths = [root]; + let current = root; + for (const component of path.slice(root.length).split(sep).filter((value) => value.length > 0)) { + current = join(current, component); + paths.push(current); + } + return paths; +}; + +const assertPackageDependencyCacheDirectory = ( + metadata: Stats, + path: string, + uid: number, +): void => { + const permissions = metadata.mode & 0o777; + const rootOwnedStickyBoundary = metadata.uid === 0 + && (metadata.mode & 0o1000) !== 0 + && (permissions & 0o022) !== 0; + if ( + !metadata.isDirectory() + || metadata.isSymbolicLink() + || (metadata.uid !== uid && metadata.uid !== 0) + || (permissions & 0o100) === 0 + || ((permissions & 0o022) !== 0 && !rootOwnedStickyBoundary) + ) throw new Error(`Bun dependency cache path custody is invalid: ${path}`); +}; + +class PackageDependencyCacheCustody { + readonly #held: HeldPackageDependencyCacheDirectory[] = []; + + constructor(private readonly uid: number) {} + + async holdThrough(path: string): Promise { + let currentUserBoundarySeen = false; + try { + for (const directoryPath of packageDependencyCacheDirectoryPathsThrough(path)) { + const pathMetadata = await lstat(directoryPath); + assertPackageDependencyCacheDirectory(pathMetadata, directoryPath, this.uid); + if (await realpath(directoryPath) !== directoryPath) { + throw new Error(`Bun dependency cache path is not canonical: ${directoryPath}`); + } + if (pathMetadata.uid === this.uid) currentUserBoundarySeen = true; + if (currentUserBoundarySeen && pathMetadata.uid !== this.uid) { + throw new Error(`Bun dependency cache path leaves current-user custody: ${directoryPath}`); + } + const handle = await open( + directoryPath, + constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW, + ); + try { + const descriptorMetadata = await handle.stat(); + assertPackageDependencyCacheDirectory(descriptorMetadata, directoryPath, this.uid); + if (currentUserBoundarySeen && descriptorMetadata.uid !== this.uid) { + throw new Error(`Bun dependency cache descriptor leaves current-user custody: ${directoryPath}`); + } + const identity = packageDependencyCacheIdentity(pathMetadata); + if (!samePackageDependencyCacheIdentity(identity, packageDependencyCacheIdentity(descriptorMetadata))) { + throw new Error(`Bun dependency cache path changed while opening custody: ${directoryPath}`); + } + assertSafeDarwinInstallAcl(handle.fd, this.uid, directoryPath); + this.#held.push({ + handle, + identity, + path: directoryPath, + requiresCurrentOwner: currentUserBoundarySeen, + }); + } catch (error: unknown) { + await handle.close(); + throw error; + } + } + } catch (error: unknown) { + try { + await this.close(); + } catch (closeError: unknown) { + throw new AggregateError([error, closeError], "Bun dependency cache custody opening and cleanup both failed."); + } + throw error; + } + } + + async assertAll(): Promise { + for (const held of this.#held) { + const [canonicalPath, pathMetadata, descriptorMetadata] = await Promise.all([ + realpath(held.path), + lstat(held.path), + held.handle.stat(), + ]); + assertPackageDependencyCacheDirectory(pathMetadata, held.path, this.uid); + assertPackageDependencyCacheDirectory(descriptorMetadata, held.path, this.uid); + if ( + canonicalPath !== held.path + || (held.requiresCurrentOwner && (pathMetadata.uid !== this.uid || descriptorMetadata.uid !== this.uid)) + || !samePackageDependencyCacheIdentity(held.identity, packageDependencyCacheIdentity(pathMetadata)) + || !samePackageDependencyCacheIdentity(held.identity, packageDependencyCacheIdentity(descriptorMetadata)) + ) throw new Error(`Bun dependency cache path identity changed while in use: ${held.path}`); + assertSafeDarwinInstallAcl(held.handle.fd, this.uid, held.path); + } + } + + async close(): Promise { + const held = this.#held.splice(0).reverse(); + const results = await Promise.allSettled(held.map(async ({ handle }) => await handle.close())); + const errors = results + .filter((result): result is PromiseRejectedResult => result.status === "rejected") + .map(({ reason }) => reason as unknown); + if (errors.length > 0) throw new AggregateError(errors, "Bun dependency cache custody cleanup failed."); + } +} + +export const withPackageDependencyCacheCustody = async ( + path: string, + operation: () => Promise, +): Promise => { + const uid = process.getuid?.(); + if (uid === undefined || !isAbsolute(path) || resolve(path) !== path || await realpath(path) !== path) { + throw new Error("Bun dependency cache custody is invalid."); + } + const cacheMetadata = await lstat(path); + if ( + !cacheMetadata.isDirectory() + || cacheMetadata.isSymbolicLink() + || cacheMetadata.uid !== uid + || (cacheMetadata.mode & 0o022) !== 0 + ) throw new Error("Bun dependency cache custody is invalid."); + const custody = new PackageDependencyCacheCustody(uid); + await custody.holdThrough(path); + let operationFailed = false; + let operationError: unknown; + let custodyFailed = false; + let custodyError: unknown; + let closeFailed = false; + let closeError: unknown; + let operationValue: Value | undefined; + try { + try { + try { + operationValue = await operation(); + } catch (error: unknown) { + operationFailed = true; + operationError = error; + } + await custody.assertAll(); + } catch (error: unknown) { + custodyFailed = true; + custodyError = error; + } + } finally { + try { + await custody.close(); + } catch (error: unknown) { + closeFailed = true; + closeError = error; + } + } + const errors: Error[] = []; + const pushError = (failed: boolean, error: unknown, label: string): void => { + if (failed) errors.push(error instanceof Error ? error : new Error(label, { cause: error })); + }; + pushError(operationFailed, operationError, "Bun dependency cache operation failed with a non-error value."); + pushError(custodyFailed, custodyError, "Bun dependency cache custody failed with a non-error value."); + pushError(closeFailed, closeError, "Bun dependency cache cleanup failed with a non-error value."); + if (errors.length > 1) { + throw new AggregateError(errors, "Bun dependency cache use and custody settlement both failed."); + } + const error = errors[0]; + if (error !== undefined) throw error; + return operationValue as Value; +}; + +export const packageDependencyCacheDiscoveryEnvironment = ( + environment: NodeJS.ProcessEnv, +): NodeJS.ProcessEnv => { + const discoveryEnvironment = { ...environment }; + delete discoveryEnvironment.BUN_INSTALL_CACHE_DIR; + return discoveryEnvironment; +}; + +const resolvePackageDependencyCache = async (repositoryRoot: string): Promise => { + const discoveryEnvironment = packageDependencyCacheDiscoveryEnvironment(process.env); + const result = requireSuccess( + "Bun dependency cache discovery", + await run(process.execPath, ["pm", "cache"], { + cwd: repositoryRoot, + env: discoveryEnvironment, + }), + ); + if (result.stderr !== "") throw new Error("Bun dependency cache discovery returned diagnostics."); + const path = parsePackageDependencyCache(result.stdout); + await withPackageDependencyCacheCustody(path, async () => { + await access(path, constants.R_OK | constants.W_OK); + let entries = 0; + for await (const entry of await opendir(path)) { + entries += 1; + if (entry.name.length < 1 || entries > 100_000) { + throw new Error("Bun dependency cache inventory is invalid."); + } + } + if (entries === 0) throw new Error("Bun dependency cache inventory is invalid."); + }); + return path; +}; + const run = runPackageCommand; const requireSuccess = (label: string, result: ProcessResult): ProcessResult => { @@ -599,6 +850,7 @@ const generated = requireSuccess( await run(process.execPath, ["run", "build:site", "--", "--check"], { cwd: repositoryRoot }), ); if (generated.stdout.trim().length > 0) process.stdout.write(generated.stdout); +const dependencyCacheRoot = await resolvePackageDependencyCache(repositoryRoot); const temporaryRoot = await realpath(await mkdtemp(join(tmpdir(), "hra-package-"))); let removeTemporaryRoot = true; @@ -678,6 +930,7 @@ try { ...process.env, BUN_INSTALL: globalInstallRoot, BUN_INSTALL_BIN: join(globalInstallRoot, "bin"), + BUN_INSTALL_CACHE_DIR: dependencyCacheRoot, BUN_INSTALL_GLOBAL_DIR: join(globalInstallRoot, "install", "global"), HOME: consumerHome, TMPDIR: consumerTemporaryDirectory, @@ -685,11 +938,12 @@ try { const installGlobalTransaction = async (label: string): Promise => { const preflight = requireSuccess( label, - await run(process.execPath, [join(repositoryRoot, "src", "install-preflight.ts"), archive], { - cwd: consumerDirectory, - env: isolatedEnvironment, - phase: "package-transactional-global-install", - }), + await withPackageDependencyCacheCustody(dependencyCacheRoot, async () => + await run(process.execPath, [join(repositoryRoot, "src", "install-preflight.ts"), archive], { + cwd: consumerDirectory, + env: isolatedEnvironment, + phase: "package-transactional-global-install", + })), ); if (preflight.stderr !== "" || preflight.stdout !== `${HRA_INSTALL_PREFLIGHT_SUCCESS}\n`) { throw new Error(`${label} did not return its one exact success token.`); @@ -697,10 +951,11 @@ try { }; requireSuccess( "clean lifecycle-disabled consumer install", - await run(process.execPath, ["add", "--backend=copyfile", "--ignore-scripts", archive], { - cwd: consumerDirectory, - env: isolatedEnvironment, - }), + await withPackageDependencyCacheCustody(dependencyCacheRoot, async () => + await run(process.execPath, ["add", "--backend=copyfile", "--ignore-scripts", archive], { + cwd: consumerDirectory, + env: isolatedEnvironment, + })), ); const localPackageRoot = join(consumerDirectory, "node_modules", "@hraness", "hra"); const executable = join(consumerDirectory, "node_modules", ".bin", "hra"); diff --git a/scripts/check-public-release.ts b/scripts/check-public-release.ts index dd6fec4..94de5c8 100644 --- a/scripts/check-public-release.ts +++ b/scripts/check-public-release.ts @@ -92,9 +92,16 @@ if (environment("GITHUB_REPOSITORY") !== publicRepository) { } const token = environment("GITHUB_TOKEN"); const verifiedSha = environment("VERIFIED_SHA", /^[0-9a-f]{40}$/u); +const verifiedTagObject = environment("VERIFIED_TAG_OBJECT", /^[0-9a-f]{40}$/u); const verifiedTag = environment("VERIFIED_TAG", /^v(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)$/u); const runId = environment("GITHUB_RUN_ID", /^[1-9][0-9]*$/u); const runAttempt = environment("GITHUB_RUN_ATTEMPT", /^[1-9][0-9]*$/u); +const preflightState = environment("HRA_NPM_PREFLIGHT_STATE", /^(?:absent|exact)$/u); +const preflightRunId = environment("HRA_NPM_PREFLIGHT_RUN_ID", /^[1-9][0-9]*$/u); +const preflightRunAttempt = environment("HRA_NPM_PREFLIGHT_RUN_ATTEMPT", /^[1-9][0-9]*$/u); +if (preflightRunId !== runId || BigInt(preflightRunAttempt) > BigInt(runAttempt)) { + throw new Error("Public release admission requires this run's bounded npm preflight observation."); +} const manifest = JSON.parse(await readFile(resolve(import.meta.dir, "..", "package.json"), "utf8")) as unknown; const inspection = assertReleasePackageReady(manifest); if (verifiedTag !== `v${inspection.version}`) throw new Error("Release tag and package version do not agree."); @@ -120,6 +127,8 @@ try { attemptPolicy: "same_run_not_later", attestations: await json(attestationsUrl, "npm Sigstore attestations"), integrity: npmRelease.integrity, + maximumAttempt: preflightState === "exact" ? preflightRunAttempt : runAttempt, + registryKeys: await json("https://registry.npmjs.org/-/npm/v1/keys", "npm registry keys"), runAttempt, runId, sha: verifiedSha, @@ -136,11 +145,10 @@ const tagRef = await json(`${api}/git/ref/tags/${verifiedTag}`, "GitHub annotate }; if ( tagRef.object?.type !== "tag" - || typeof tagRef.object.sha !== "string" - || !/^[0-9a-f]{40}$/u.test(tagRef.object.sha) - || tagRef.object.url !== `${api}/git/tags/${tagRef.object.sha}` -) throw new Error("GitHub release ref is not one annotated tag object."); -const tag = await json(tagRef.object.url, "GitHub annotated tag", token) as { + || tagRef.object.sha !== verifiedTagObject + || tagRef.object.url !== `${api}/git/tags/${verifiedTagObject}` +) throw new Error("GitHub release ref is not the verified annotated tag object."); +const tag = await json(`${api}/git/tags/${verifiedTagObject}`, "GitHub annotated tag", token) as { object?: { sha?: unknown; type?: unknown }; tag?: unknown; }; diff --git a/scripts/github-publisher-environment.ts b/scripts/github-publisher-environment.ts new file mode 100644 index 0000000..e0b0643 --- /dev/null +++ b/scripts/github-publisher-environment.ts @@ -0,0 +1,31 @@ +const forwardedEnvironmentNames = Object.freeze([ + "HOME", + "LANG", + "LC_ALL", + "PATH", + "SSL_CERT_DIR", + "SSL_CERT_FILE", + "TMPDIR", +] as const); + +type SourceEnvironment = Readonly>; + +export function githubPublisherEnvironment(source: SourceEnvironment): Readonly> { + const token = source.GH_TOKEN; + if (token === undefined || token.length === 0) { + throw new Error("GitHub Release publication requires one GitHub token."); + } + if (source.PATH === undefined || source.PATH.length === 0) { + throw new Error("GitHub Release publication requires one executable search path."); + } + const environment: Record = { + GH_PROMPT_DISABLED: "1", + GH_TOKEN: token, + NO_COLOR: "1", + }; + for (const name of forwardedEnvironmentNames) { + const value = source[name]; + if (value !== undefined) environment[name] = value; + } + return Object.freeze(environment); +} diff --git a/scripts/github-release-identity.ts b/scripts/github-release-identity.ts new file mode 100644 index 0000000..ee53f0f --- /dev/null +++ b/scripts/github-release-identity.ts @@ -0,0 +1,141 @@ +type JsonRecord = Record; + +const schema = "https://hra.hraness.com/release-identity/v1"; +const repository = "hraness/hra"; +const repositoryId = "1343008607"; +const sha = /^[0-9a-f]{40}$/u; +const positiveDecimal = /^[1-9][0-9]*$/u; + +function record(value: unknown, label: string): JsonRecord { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${label} must be an object.`); + } + return value as JsonRecord; +} + +function exactKeys(value: JsonRecord, expected: readonly string[], label: string): void { + const actual = Object.keys(value).sort(); + const keys = [...expected].sort(); + if (actual.length !== keys.length || actual.some((key, index) => key !== keys[index])) { + throw new Error(`${label} has unexpected fields.`); + } +} + +function attempt(value: unknown, label: string): number { + if (!Number.isSafeInteger(value) || Number(value) <= 0) throw new Error(`${label} is invalid.`); + return Number(value); +} + +export type GitHubReleaseRun = Readonly<{ + attempt: number; + id: string; + workflowRef: string; +}>; + +export function githubReleaseRun(tag: string, source: Readonly>): GitHubReleaseRun { + const id = source.GITHUB_RUN_ID; + const attemptText = source.GITHUB_RUN_ATTEMPT; + const workflowRef = `${repository}/.github/workflows/release.yml@refs/tags/${tag}`; + if ( + id === undefined + || !positiveDecimal.test(id) + || attemptText === undefined + || !positiveDecimal.test(attemptText) + || source.GITHUB_REPOSITORY !== repository + || source.GITHUB_REPOSITORY_ID !== repositoryId + || source.GITHUB_WORKFLOW_REF !== workflowRef + || source.GITHUB_EVENT_NAME !== "push" + || source.GITHUB_REF !== `refs/tags/${tag}` + || source.GITHUB_REF_NAME !== tag + || source.GITHUB_REF_TYPE !== "tag" + ) throw new Error("GitHub Release recovery is not bound to this exact tag workflow run."); + const runAttempt = Number(attemptText); + if (!Number.isSafeInteger(runAttempt)) throw new Error("GitHub Release run attempt is outside its safe bound."); + return Object.freeze({ attempt: runAttempt, id, workflowRef }); +} + +export type GitHubReleaseIdentityInput = Readonly<{ + artifacts: readonly Readonly<{ name: string; sha256: string; size: number }>[]; + commitSha: string; + run: GitHubReleaseRun; + tag: string; + tagObjectSha: string; +}>; + +function identity(input: GitHubReleaseIdentityInput, createdAttempt: number, publishedAttempt: number | null) { + if (!sha.test(input.commitSha) || !sha.test(input.tagObjectSha)) { + throw new Error("GitHub Release identity requires exact tag and commit objects."); + } + return { + artifacts: input.artifacts, + commitSha: input.commitSha, + createdAttempt, + publishedAttempt, + repository, + repositoryId, + runId: input.run.id, + schema, + tag: input.tag, + tagObjectSha: input.tagObjectSha, + workflowRef: input.run.workflowRef, + }; +} + +function render(value: unknown): string { + return ``; +} + +export function draftReleaseBody(input: GitHubReleaseIdentityInput): string { + return render(identity(input, input.run.attempt, null)); +} + +export function publishedReleaseBody(input: GitHubReleaseIdentityInput, createdAttempt: number): string { + return render(identity(input, createdAttempt, input.run.attempt)); +} + +export function parseReleaseBody( + value: unknown, + input: GitHubReleaseIdentityInput, + state: "draft" | "published", +): Readonly<{ createdAttempt: number; publishedAttempt: number | null }> { + if (typeof value !== "string" || value.length > 4_096) throw new Error("GitHub Release identity body is invalid."); + const prefix = ""; + if (!value.startsWith(prefix) || !value.endsWith(suffix)) throw new Error("GitHub Release identity body is missing or edited."); + let parsed: unknown; + try { parsed = JSON.parse(value.slice(prefix.length, -suffix.length)) as unknown; } + catch { throw new Error("GitHub Release identity body is malformed."); } + const body = record(parsed, "GitHub Release identity"); + exactKeys(body, [ + "artifacts", "commitSha", "createdAttempt", "publishedAttempt", "repository", + "repositoryId", "runId", "schema", "tag", "tagObjectSha", "workflowRef", + ], "GitHub Release identity"); + const createdAttempt = attempt(body.createdAttempt, "GitHub Release creation attempt"); + const publishedAttempt = body.publishedAttempt === null + ? null + : attempt(body.publishedAttempt, "GitHub Release publication attempt"); + if ( + createdAttempt > input.run.attempt + || (state === "draft" && publishedAttempt !== null) + || (state === "published" && ( + publishedAttempt === null + || publishedAttempt < createdAttempt + || publishedAttempt > input.run.attempt + )) + ) throw new Error("GitHub Release identity has invalid workflow-attempt ordering."); + if ( + body.repository !== repository + || body.repositoryId !== repositoryId + || body.runId !== input.run.id + || body.workflowRef !== input.run.workflowRef + || body.schema !== schema + || body.tag !== input.tag + || body.tagObjectSha !== input.tagObjectSha + || body.commitSha !== input.commitSha + || JSON.stringify(body.artifacts) !== JSON.stringify(input.artifacts) + ) throw new Error("GitHub Release identity belongs to another release authority or artifact manifest."); + if (value !== render(identity(input, createdAttempt, publishedAttempt))) { + throw new Error("GitHub Release identity body is not canonical."); + } + return Object.freeze({ createdAttempt, publishedAttempt }); +} diff --git a/scripts/npm-publication-transition.test.ts b/scripts/npm-publication-transition.test.ts index 290cb08..e9d43e8 100644 --- a/scripts/npm-publication-transition.test.ts +++ b/scripts/npm-publication-transition.test.ts @@ -16,6 +16,7 @@ describe("npm publication retry transition", () => { expect(decideNpmPublicationTransition(base)).toEqual({ action: "publish", attemptPolicy: "exact", + maximumProvenanceAttempt: "1", }); expect(decideNpmPublicationTransition({ ...base, @@ -23,6 +24,7 @@ describe("npm publication retry transition", () => { })).toEqual({ action: "admit_existing", attemptPolicy: "exact", + maximumProvenanceAttempt: "1", }); }); @@ -34,6 +36,7 @@ describe("npm publication retry transition", () => { })).toEqual({ action: "admit_existing", attemptPolicy: "same_run_not_later", + maximumProvenanceAttempt: "2", }); }); @@ -46,6 +49,21 @@ describe("npm publication retry transition", () => { })).toEqual({ action: "admit_existing", attemptPolicy: "same_run_not_later", + maximumProvenanceAttempt: "3", + }); + }); + + test("caps an existing package at the exact attempt that preflight already observed", () => { + expect(decideNpmPublicationTransition({ + ...base, + currentArtifactState: "exact", + currentRunAttempt: "3", + preflightArtifactState: "exact", + preflightRunAttempt: "2", + })).toEqual({ + action: "admit_existing", + attemptPolicy: "same_run_not_later", + maximumProvenanceAttempt: "2", }); }); diff --git a/scripts/npm-publication-transition.ts b/scripts/npm-publication-transition.ts index aaaa2be..a3650b9 100644 --- a/scripts/npm-publication-transition.ts +++ b/scripts/npm-publication-transition.ts @@ -5,6 +5,7 @@ export type NpmArtifactState = "absent" | "exact"; export type NpmPublicationTransition = Readonly<{ action: "admit_existing" | "publish"; attemptPolicy: NpmProvenanceAttemptPolicy; + maximumProvenanceAttempt: string; }>; const positiveInteger = /^[1-9][0-9]*$/u; @@ -29,12 +30,19 @@ export function decideNpmPublicationTransition(input: Readonly<{ throw new Error("The exact npm artifact disappeared after publication preflight."); } if (input.currentArtifactState === "absent") { - return Object.freeze({ action: "publish", attemptPolicy: "exact" }); + return Object.freeze({ + action: "publish", + attemptPolicy: "exact", + maximumProvenanceAttempt: input.currentRunAttempt, + }); } const sameAttemptAbsent = input.preflightArtifactState === "absent" && input.preflightRunAttempt === input.currentRunAttempt; return Object.freeze({ action: "admit_existing", attemptPolicy: sameAttemptAbsent ? "exact" : "same_run_not_later", + maximumProvenanceAttempt: input.preflightArtifactState === "exact" + ? input.preflightRunAttempt + : input.currentRunAttempt, }); } diff --git a/scripts/package-policy.ts b/scripts/package-policy.ts index 22ac14b..c853ddd 100644 --- a/scripts/package-policy.ts +++ b/scripts/package-policy.ts @@ -129,7 +129,7 @@ export async function assertReviewedReleaseInventory(packageRoot: string): Promi const expected = Object.freeze({ count: 104, jsonBytes: 4_651, - sha256: "62cc0e620823d2361810d9792aa28c805eda31293ba9d6aab0a8ec3db36d0a03", + sha256: "2024c4e02497bc985c5cf36e87558c4fb471d8f479f615a2711e07ec9b19e409", }); const inventory: Array = []; const visit = async (path: string): Promise => { diff --git a/scripts/public-text-policy.test.ts b/scripts/public-text-policy.test.ts index 0cab2e6..d4b200c 100644 --- a/scripts/public-text-policy.test.ts +++ b/scripts/public-text-policy.test.ts @@ -77,6 +77,17 @@ describe("public text policy", () => { .toThrow(PublicTextPolicyError); }); + test("distinguishes annotated Git tag references from package scopes", () => { + expect(() => assertPublicText( + "https://github.com/hraness/hra@refs/tags/v0.1.0", + "Git tag reference", + )).not.toThrow(); + expect(() => assertPublicText(["@refs", "tags"].join("/"), "unreviewed package")) + .toThrow(PublicTextPolicyError); + expect(() => assertPublicText(["@refs", "private", "v0.1.0"].join("/"), "unreviewed reference")) + .toThrow(PublicTextPolicyError); + }); + test("scans SVG text and rejects unreviewed file types", async () => { const root = await mkdtemp(join(tmpdir(), "hra-public-policy-")); const svg = join(root, "image.svg"); @@ -98,6 +109,19 @@ describe("public text policy", () => { } }); + test("scans the exact GitHub CODEOWNERS control as public text", async () => { + const root = await mkdtemp(join(tmpdir(), "hra-public-policy-codeowners-")); + try { + await mkdir(join(root, ".github")); + await writeFile(join(root, ".github", "CODEOWNERS"), "* @hraness\n", "utf8"); + await expect(assertPublicTree(root)).resolves.toBeUndefined(); + await writeFile(join(root, ".github", "UNREVIEWED"), "ordinary text\n", "utf8"); + await expect(assertPublicTree(root)).rejects.toMatchObject({ code: "UNREVIEWED_FILE_TYPE" }); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); + test("admits only bounded, structurally valid editorial WebP files", async () => { const root = await mkdtemp(join(tmpdir(), "hra-public-policy-webp-")); const repositoryRoot = join(import.meta.dir, ".."); diff --git a/scripts/public-text-policy.ts b/scripts/public-text-policy.ts index b861138..68b8f23 100644 --- a/scripts/public-text-policy.ts +++ b/scripts/public-text-policy.ts @@ -40,6 +40,7 @@ const absoluteUserPaths = [ /(?:^|[^A-Za-z0-9])[A-Za-z]:\\Users\\[^\\\s"'`]+\\/u, ] as const; const scopedPackage = /@([a-z0-9][a-z0-9-]*)\/[a-z0-9][a-z0-9._-]*/gu; +const gitTagReferencePackageShape = ["@refs", "tags"].join("/"); export class PublicTextPolicyError extends Error { constructor( @@ -57,10 +58,13 @@ export function assertPublicText(value: string, label: string): void { for (const match of value.matchAll(scopedPackage)) { const scope = match[1]; const packageName = match[0]; + const matchEnd = match.index + packageName.length; + const isGitTagReference = packageName === gitTagReferencePackageShape && value[matchEnd] === "/"; if ( scope !== undefined && !allowedPublicScopes.has(scope) && !allowedPublicScopedPackages.has(packageName) + && !isGitTagReference ) { throw new PublicTextPolicyError("PRIVATE_SCOPE", label); } @@ -82,7 +86,7 @@ export function assertPublicSensitiveText(value: string, label: string): void { } const excludedDirectories = new Set([".git", "dist", "node_modules"]); -const textFile = /(?:^|\/)(?:LICENSE|\.bun-version|\.editorconfig|\.gitattributes|\.gitignore)$|\.(?:css|html|json|lock|md|mjs|svg|ts|tsx|txt|xml|yaml|yml|zig)$/u; +const textFile = /(?:^|\/)(?:CODEOWNERS|LICENSE|\.bun-version|\.editorconfig|\.gitattributes|\.gitignore)$|\.(?:css|html|json|lock|md|mjs|svg|ts|tsx|txt|xml|yaml|yml|zig)$/u; const editorialWebp = /^site\/images\/editorial\/[a-z0-9]+(?:-[a-z0-9]+)*(?:-384|-768)?\.webp$/u; const webpChunkTypes = new Set(["VP8 ", "VP8L", "VP8X"]); diff --git a/scripts/publish-beta-release.test.ts b/scripts/publish-beta-release.test.ts index bbf3650..159f255 100644 --- a/scripts/publish-beta-release.test.ts +++ b/scripts/publish-beta-release.test.ts @@ -842,64 +842,21 @@ describe("release publication arguments", () => { expect(source).toMatch(/arguments: \["fetch", "origin", "main", "--tags"\],\s+containment: "authority",\s+cwd: this\.root,\s+environment: this\.environment,\s+executable: "\/usr\/bin\/git"/u); }); - test("inspects a hostile candidate without importing it or running lifecycle scripts", async () => { - const root = await makeRoot(); - const packageRoot = join(root, "payload", "package"); - const importSentinel = join(root, "candidate-imported"); - const lifecycleSentinel = join(root, "lifecycle-ran"); - await mkdir(join(packageRoot, "src"), { recursive: true }); - await writeFile(join(packageRoot, "src", "cli.ts"), [ - `await Bun.write(${JSON.stringify(importSentinel)}, "imported");`, - "export {};", - "", - ].join("\n")); - await chmod(join(packageRoot, "src", "cli.ts"), 0o755); - await writeFile(join(packageRoot, "src", "install-normalizer.ts"), [ - `await Bun.write(${JSON.stringify(lifecycleSentinel)}, "ran");`, - "", - ].join("\n")); - await chmod(join(packageRoot, "src", "install-normalizer.ts"), 0o644); - await writeFile(join(packageRoot, "package.json"), JSON.stringify({ - bin: { hra: "./src/cli.ts" }, - name: "hra", - scripts: { postinstall: "bun ./src/install-normalizer.ts" }, - type: "module", - version: "0.1.0", - })); - const archive = join(root, "hostile-candidate.tgz"); - const packed = Bun.spawnSync([ - "/usr/bin/tar", - "-czf", - archive, - "-C", - join(root, "payload"), - "package", - ], { - env: { COPYFILE_DISABLE: "1", PATH: "/usr/bin:/bin" }, - stderr: "pipe", - stdout: "pipe", - }); - expect(packed.exitCode).toBe(0); - - const provider = createHistoricalPublicationProvider({ - ghCli: "/not-used/gh", - recoveryDirectory: join(root, "process-recovery"), - vercelCli: "/not-used/vercel", - }); - await expect(provider.acceptPackedInstall(archive, join(root, "inspection"))) - .rejects.toThrow("accepted_artifact_invalid"); - expect(await Bun.file(importSentinel).exists()).toBeFalse(); - expect(await Bun.file(lifecycleSentinel).exists()).toBeFalse(); - + test("keeps hostile historical candidate code unreachable from the replacement workflow", async () => { const publisherSource = await Bun.file(join(import.meta.dir, "publish-beta-release.ts")).text(); - const retiredWorkflow = Bun.file( + const replacementWorkflow = Bun.file( join(import.meta.dir, "..", ".github", "workflows", "release.yml"), ); expect(publisherSource).not.toContain("pathToFileURL"); expect(publisherSource).not.toContain('arguments: ["add", "--global"'); expect(publisherSource).toContain('"pack",\n "--ignore-scripts"'); - expect(await retiredWorkflow.exists()).toBeFalse(); - }, 30_000); + expect(await replacementWorkflow.exists()).toBeTrue(); + const replacementWorkflowSource = await replacementWorkflow.text(); + expect(replacementWorkflowSource).not.toContain("publish-beta-release.ts"); + expect(replacementWorkflowSource).toContain('git cat-file -t "$REQUESTED_TAG"'); + expect(replacementWorkflowSource).toContain('git merge-base --is-ancestor "$VERIFIED_SHA" "$remote_main"'); + expect(replacementWorkflowSource).toContain("HRA_NPM_PREFLIGHT_RUN_ATTEMPT"); + }); }); describe("release publication cleanup", () => { @@ -2012,19 +1969,16 @@ describe("release publication lease identity", () => { }); describe("accepted release bundle", () => { - test("packs the reviewed source deterministically with lifecycle scripts disabled", async () => { - const root = await makeRoot(); - const provider = createHistoricalPublicationProvider({ - ghCli: "/not-used/gh", - recoveryDirectory: join(root, "process-recovery"), - vercelCli: "/not-used/vercel", - }); - const first = await provider.readReviewedSourceAuthority(join(root, "first")); - const second = await provider.readReviewedSourceAuthority(join(root, "second")); - expect(first.archive.equals(second.archive)).toBeTrue(); - expect(first.archive.byteLength).toBeGreaterThan(0); - expect(first.notes).toBe(second.notes); - expect(first.notes).toContain("# HRA v0.1.0 friend beta"); + test("does not wire the retired reviewed-source packer into the scoped release", async () => { + const [packageDocument, workflow] = await Promise.all([ + Bun.file(join(import.meta.dir, "..", "package.json")).text() + .then((value): unknown => JSON.parse(value)), + Bun.file(join(import.meta.dir, "..", ".github", "workflows", "release.yml")).text(), + ]); + expect(packageDocument).toMatchObject({ name: "@hraness/hra" }); + expect(workflow).toContain("npm pack --ignore-scripts --pack-destination artifacts ."); + expect(workflow).not.toContain("readReviewedSourceAuthority"); + expect(workflow).not.toContain("publish-beta-release.ts"); }); test("binds checksums and both SPDX contracts to the exact artifact set", async () => { diff --git a/scripts/publish-github-release.ts b/scripts/publish-github-release.ts index 6c6a3b2..336e17b 100644 --- a/scripts/publish-github-release.ts +++ b/scripts/publish-github-release.ts @@ -1,13 +1,21 @@ import { createHash } from "node:crypto"; -import { mkdtemp, readFile, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { basename, join, resolve } from "node:path"; +import { readFile } from "node:fs/promises"; +import { basename, resolve } from "node:path"; import { assertReleaseAssetBytes, parseGitHubRelease, publicRepository, } from "./release-distribution-policy"; +import { githubPublisherEnvironment } from "./github-publisher-environment"; +import { + draftReleaseBody, + githubReleaseRun, + parseReleaseBody, + publishedReleaseBody, + type GitHubReleaseIdentityInput, +} from "./github-release-identity"; +import { parseGitHubIncludedJsonResponse } from "./release-included-response"; import { assertReleasePackageReady, releaseArchiveName } from "./release-package-policy"; const [tag, archiveArgument, checksumArgument] = process.argv.slice(2); @@ -21,6 +29,14 @@ const verifiedSha = process.env.VERIFIED_SHA; if (verifiedSha === undefined || !/^[0-9a-f]{40}$/u.test(verifiedSha)) { throw new Error("GitHub Release publication requires one verified commit."); } +const verifiedTagObject = process.env.VERIFIED_TAG_OBJECT; +if (verifiedTagObject === undefined || !/^[0-9a-f]{40}$/u.test(verifiedTagObject)) { + throw new Error("GitHub Release publication requires one verified annotated tag object."); +} +const defaultBranch = process.env.DEFAULT_BRANCH; +if (defaultBranch === undefined || !/^[A-Za-z0-9._/-]+$/u.test(defaultBranch)) { + throw new Error("GitHub Release publication requires one verified default branch."); +} const manifest = JSON.parse(await readFile(resolve(import.meta.dir, "..", "package.json"), "utf8")) as unknown; const inspection = assertReleasePackageReady(manifest); const archive = resolve(archiveArgument); @@ -32,60 +48,350 @@ if ( ) throw new Error("GitHub Release coordinates do not match the public package."); const archiveBytes = await readFile(archive); const checksumBytes = await readFile(checksum); +const sha256 = (bytes: Uint8Array) => createHash("sha256").update(bytes).digest("hex"); +const expectedTitle = `HRA ${tag}`; +const maximumArtifactBytes = 64 * 1024 * 1024; +const maximumGitHubJsonBytes = 4 * 1024 * 1024; +const releaseRun = githubReleaseRun(tag, process.env); +const releaseIdentity: GitHubReleaseIdentityInput = Object.freeze({ + artifacts: Object.freeze([ + Object.freeze({ name: basename(archive), sha256: sha256(archiveBytes), size: archiveBytes.byteLength }), + Object.freeze({ name: basename(checksum), sha256: sha256(checksumBytes), size: checksumBytes.byteLength }), + ]), + commitSha: verifiedSha, + run: releaseRun, + tag, + tagObjectSha: verifiedTagObject, +}); +const expectedDraftBody = draftReleaseBody(releaseIdentity); -function command(arguments_: string[]): string { - const result = Bun.spawnSync({ cmd: arguments_, stderr: "pipe", stdout: "pipe" }); - if (result.exitCode !== 0) { - throw new Error(`Command failed: ${arguments_.join(" ")}\n${result.stderr.toString("utf8")}`); +type CommandResult = Readonly<{ exitCode: number; stderr: Buffer; stdout: Buffer }>; + +function run( + arguments_: string[], + allowFailure = false, + maximumStdoutBytes = maximumGitHubJsonBytes, +): CommandResult { + const result = Bun.spawnSync({ + cmd: arguments_, + env: githubPublisherEnvironment(process.env), + killSignal: "SIGKILL", + maxBuffer: maximumStdoutBytes + 1, + stderr: "pipe", + stdout: "pipe", + timeout: 120_000, + }); + if (result.exitedDueToTimeout || result.exitedDueToMaxBuffer) { + throw new Error(`GitHub ${arguments_[1] ?? "command"} exceeded its execution bound.`); + } + if (result.stdout.byteLength > maximumStdoutBytes) { + throw new Error(`GitHub ${arguments_[1] ?? "command"} exceeded its output byte bound.`); } - return result.stdout.toString("utf8"); + if (result.exitCode !== 0 && !allowFailure) { + const diagnosticState = result.stderr.byteLength === 0 + ? "without diagnostic output" + : "with redacted diagnostic output"; + throw new Error(`GitHub ${arguments_[1] ?? "command"} failed ${diagnosticState}.`); + } + return Object.freeze({ + exitCode: result.exitCode, + stderr: Buffer.from(result.stderr), + stdout: Buffer.from(result.stdout), + }); } -function release(): unknown { - return JSON.parse(command(["gh", "api", `/repos/${publicRepository}/releases/tags/${tag}`])) as unknown; +function record(value: unknown, label: string): Readonly> { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${label} must be one JSON object.`); + } + return value as Readonly>; } -const existing = Bun.spawnSync({ - cmd: ["gh", "api", `/repos/${publicRepository}/releases/tags/${tag}`], - stderr: "pipe", - stdout: "pipe", -}); -if (existing.exitCode !== 0) { - const failure = `${existing.stdout.toString("utf8")}\n${existing.stderr.toString("utf8")}`; - if (!/HTTP 404|Not Found|release not found/iu.test(failure)) { - throw new Error(`GitHub Release existence is indeterminate.\n${failure}`); - } - command([ - "gh", "release", "create", tag, archive, checksum, - "--repo", publicRepository, - "--generate-notes", - "--latest", - "--verify-tag", - "--title", `HRA ${tag}`, - ]); +function readJson(arguments_: string[]): Readonly> { + const value = JSON.parse(run(arguments_).stdout.toString("utf8")) as unknown; + return record(value, `GitHub ${arguments_[1] ?? "command"} response`); } -const coordinate = parseGitHubRelease(release(), inspection.version); -assertReleaseAssetBytes( - coordinate, - archiveBytes, - checksumBytes, - (bytes) => createHash("sha256").update(bytes).digest("hex"), -); -const directory = await mkdtemp(join(tmpdir(), "hra-release-readback-")); -try { - command([ - "gh", "release", "download", tag, - "--repo", publicRepository, - "--dir", directory, - "--pattern", basename(archive), - "--pattern", "SHA256SUMS", + +function verifyRemoteAnnotatedTag(): void { + const tagRef = readJson(["gh", "api", `/repos/${publicRepository}/git/ref/tags/${tag}`]); + const tagObject = record(tagRef.object, `Remote release ref ${tag} object`); + if ( + tagRef.ref !== `refs/tags/${tag}` + || tagObject.type !== "tag" + || tagObject.sha !== verifiedTagObject + ) throw new Error(`Remote release ref ${tag} is not the verified annotated tag object.`); + const annotated = readJson([ + "gh", "api", `/repos/${publicRepository}/git/tags/${verifiedTagObject}`, + ]); + const target = record(annotated.object, `Remote annotated tag ${tag} target`); + if (annotated.tag !== tag || target.type !== "commit" || target.sha !== verifiedSha) { + throw new Error(`Remote annotated tag ${tag} does not target the verified commit.`); + } + const head = readJson([ + "gh", "api", `/repos/${publicRepository}/git/ref/heads/${defaultBranch}`, + ]); + const headObject = record(head.object, `Remote ${defaultBranch} ref object`); + if ( + head.ref !== `refs/heads/${defaultBranch}` + || headObject.type !== "commit" + || typeof headObject.sha !== "string" + || !/^[0-9a-f]{40}$/u.test(headObject.sha) + ) throw new Error(`Remote ${defaultBranch} ref is invalid.`); + const comparison = readJson([ + "gh", "api", `/repos/${publicRepository}/compare/${verifiedSha}...${defaultBranch}`, ]); + const base = record(comparison.base_commit, "Reviewed-main comparison base"); + const mergeBase = record(comparison.merge_base_commit, "Reviewed-main merge base"); + const comparisonHead = record(comparison.head_commit, "Reviewed-main comparison head"); + if ( + !["ahead", "identical"].includes(String(comparison.status)) + || base.sha !== verifiedSha + || mergeBase.sha !== verifiedSha + || comparisonHead.sha !== headObject.sha + ) throw new Error(`Reviewed release commit is not an ancestor of current ${defaultBranch}.`); +} + +function release(): unknown { + return JSON.parse(run([ + "gh", "api", `/repos/${publicRepository}/releases/tags/${tag}`, + ]).stdout.toString("utf8")) as unknown; +} + +function releaseById(id: number): Readonly> { + return readJson(["gh", "api", `/repos/${publicRepository}/releases/${String(id)}`]); +} + +function releaseId(value: unknown, label: string): number { + const item = record(value, label); + if (!Number.isSafeInteger(item.id) || Number(item.id) <= 0) { + throw new Error(`${label} has no positive numeric identity.`); + } + return Number(item.id); +} + +type ExactDraft = Readonly<{ + assets: readonly Readonly>[]; + createdAttempt: number; + id: number; +}>; + +function exactDraft(value: unknown): ExactDraft { + const draft = record(value, `Residual GitHub Release draft ${tag}`); + if ( + draft.tag_name !== tag + || draft.name !== expectedTitle + || draft.draft !== true + || draft.prerelease !== false + || draft.immutable !== false + || draft.published_at !== null + || !Number.isSafeInteger(draft.id) + || Number(draft.id) <= 0 + || !Array.isArray(draft.assets) + || draft.assets.length > 2 + ) throw new Error(`Residual draft for ${tag} does not match the exact recoverable release.`); + const identity = parseReleaseBody(draft.body, releaseIdentity, "draft"); + const assets = draft.assets.map((asset) => record(asset, "Residual draft asset")); + const names = new Set(assets.map((asset) => asset.name)); + if ( + names.size !== assets.length + || [...names].some((name) => name !== basename(archive) && name !== basename(checksum)) + ) throw new Error(`Residual draft for ${tag} contains ambiguous assets.`); + return Object.freeze({ + assets: Object.freeze(assets), + createdAttempt: identity.createdAttempt, + id: Number(draft.id), + }); +} + +function findDraft(): ExactDraft | null { + const inventory = JSON.parse(run([ + "gh", "api", `/repos/${publicRepository}/releases?per_page=100&page=1`, + ]).stdout.toString("utf8")) as unknown; + if (!Array.isArray(inventory) || inventory.length >= 100) { + throw new Error("GitHub Release draft inventory is malformed or incomplete."); + } + const matches = inventory.filter((item) => { + const candidate = record(item, "GitHub Release inventory item"); + return candidate.draft === true && candidate.tag_name === tag; + }); + if (matches.length > 1) throw new Error(`Multiple residual drafts exist for ${tag}.`); + return matches.length === 0 ? null : exactDraft(matches[0]); +} + +function assertNoResidualDraft(): void { + const residual = findDraft(); + if (residual !== null) { + throw new Error(`Residual draft ${tag} remains after immutable publication.`); + } +} + +function readExactDraftById(id: number): ExactDraft { + const draft = exactDraft(readJson([ + "gh", "api", `/repos/${publicRepository}/releases/${String(id)}`, + ])); + if (draft.id !== id) { + throw new Error(`Residual draft ${tag} changed identity during recovery.`); + } + return draft; +} + +function verifyDraftAssets(draft: ExactDraft): readonly string[] { + const missing: string[] = []; for (const source of [archive, checksum]) { - if (!(await readFile(source)).equals(await readFile(join(directory, basename(source))))) { - throw new Error(`GitHub Release contains different bytes for ${basename(source)}.`); + const expectedName = basename(source); + const sourceBytes = source === archive ? archiveBytes : checksumBytes; + const asset = draft.assets.find((candidate) => candidate.name === expectedName); + if (asset === undefined) { + missing.push(source); + continue; + } + if ( + asset.state !== "uploaded" + || !Number.isSafeInteger(asset.id) + || Number(asset.id) <= 0 + || asset.size !== sourceBytes.byteLength + || asset.digest !== `sha256:${sha256(sourceBytes)}` + ) throw new Error(`Residual draft asset ${expectedName} has different immutable metadata.`); + const downloaded = run([ + "gh", "api", "-H", "Accept: application/octet-stream", + `/repos/${publicRepository}/releases/assets/${String(asset.id)}`, + ], false, maximumArtifactBytes).stdout; + if (!downloaded.equals(sourceBytes)) { + throw new Error(`Residual draft asset ${expectedName} has different bytes.`); } } -} finally { - await rm(directory, { force: true, recursive: true }); + return Object.freeze(missing); +} + +function completeDraftAssets(draft: ExactDraft): ExactDraft { + let current = readExactDraftById(draft.id); + for (const source of verifyDraftAssets(current)) { + current = readExactDraftById(draft.id); + if (!verifyDraftAssets(current).includes(source)) continue; + const name = basename(source); + run([ + "gh", "api", "--method", "POST", + "--header", "Accept: application/vnd.github+json", + "--header", "Content-Type: application/octet-stream", + "--input", source, + `https://uploads.github.com/repos/${publicRepository}/releases/${String(draft.id)}/assets?name=${encodeURIComponent(name)}`, + ]); + current = readExactDraftById(draft.id); + if (verifyDraftAssets(current).includes(source)) { + throw new Error(`Residual draft ${tag} did not retain exact asset ${name}.`); + } + } + current = readExactDraftById(draft.id); + if (verifyDraftAssets(current).length !== 0) { + throw new Error(`Residual draft ${tag} could not be completed exactly.`); + } + return current; +} + +function publishDraft(draft: ExactDraft): number { + verifyRemoteAnnotatedTag(); + const publishedBody = publishedReleaseBody(releaseIdentity, draft.createdAttempt); + const published = readJson([ + "gh", "api", "--method", "PATCH", + `/repos/${publicRepository}/releases/${String(draft.id)}`, + "-f", `name=${expectedTitle}`, + "-f", `body=${publishedBody}`, + "-F", "draft=false", "-F", "prerelease=false", "-f", "make_latest=true", + ]); + if (releaseId(published, `Published GitHub Release ${tag}`) !== draft.id) { + throw new Error(`Published GitHub Release ${tag} switched numeric identity.`); + } + if (published.body !== publishedBody) { + throw new Error(`Published GitHub Release ${tag} did not retain its exact publication-attempt identity.`); + } + parseReleaseBody(published.body, releaseIdentity, "published"); + return draft.id; +} + +async function verifyPublishedRelease(expectedId: number): Promise { + let lastError: unknown; + const deadline = Date.now() + 90_000; + while (Date.now() < deadline) { + try { + const published = releaseById(expectedId); + if (releaseId(published, `GitHub Release ${tag}`) !== expectedId || published.name !== expectedTitle) { + throw new Error(`GitHub Release ${tag} changed numeric or display identity.`); + } + parseReleaseBody(published.body, releaseIdentity, "published"); + const coordinate = parseGitHubRelease(published, inspection.version); + assertReleaseAssetBytes(coordinate, archiveBytes, checksumBytes, sha256); + for (const [asset, sourceBytes] of [ + [coordinate.tarball, archiveBytes], + [coordinate.checksum, checksumBytes], + ] as const) { + const downloaded = run([ + "gh", "api", "-H", "Accept: application/octet-stream", + `/repos/${publicRepository}/releases/assets/${String(asset.id)}`, + ], false, maximumArtifactBytes).stdout; + if (!downloaded.equals(sourceBytes)) { + throw new Error(`GitHub Release ${tag} contains different ${asset.name} bytes.`); + } + } + if (releaseId(release(), `Tag-resolved GitHub Release ${tag}`) !== expectedId) { + throw new Error(`Tag-resolved GitHub Release ${tag} switched numeric identity.`); + } + return; + } catch (error) { + lastError = error; + await Bun.sleep(3_000); + } + } + const detail = lastError instanceof Error ? ` ${lastError.message}` : ""; + throw new Error(`GitHub Release ${tag} did not become exact and immutable.${detail}`); +} + +verifyRemoteAnnotatedTag(); +const existing = run([ + "gh", "api", "--include", `/repos/${publicRepository}/releases/tags/${tag}`, +], true); +const existingResponse = parseGitHubIncludedJsonResponse(existing.stdout); +let publishedReleaseId: number; +if (existing.exitCode === 0 && existingResponse.status === 200) { + if (existingResponse.body.draft === true) { + const draft = exactDraft(existingResponse.body); + verifyDraftAssets(draft); + const completeDraft = completeDraftAssets(draft); + publishedReleaseId = publishDraft(completeDraft); + await verifyPublishedRelease(publishedReleaseId); + } else { + publishedReleaseId = releaseId(existingResponse.body, `Existing GitHub Release ${tag}`); + await verifyPublishedRelease(publishedReleaseId); + } +} else { + if ( + existing.exitCode === 0 + || existingResponse.status !== 404 + || existingResponse.body.message !== "Not Found" + || existingResponse.body.status !== "404" + ) throw new Error(`Could not determine whether GitHub Release ${tag} exists.`); + let draft = findDraft(); + if (draft === null) { + verifyRemoteAnnotatedTag(); + const created = exactDraft(readJson([ + "gh", "api", "--method", "POST", `/repos/${publicRepository}/releases`, + "-f", `tag_name=${tag}`, "-f", `name=${expectedTitle}`, "-f", `body=${expectedDraftBody}`, + "-F", "draft=true", "-F", "prerelease=false", "-F", "generate_release_notes=false", + ])); + draft = findDraft(); + if (draft === null || draft.id !== created.id) { + throw new Error(`GitHub did not inventory the same exact draft for ${tag}.`); + } + } + verifyDraftAssets(draft); + const completeDraft = completeDraftAssets(draft); + publishedReleaseId = publishDraft(completeDraft); + await verifyPublishedRelease(publishedReleaseId); +} +const latest = readJson(["gh", "api", `/repos/${publicRepository}/releases/latest`]); +if (latest.tag_name !== tag || releaseId(latest, "Latest GitHub Release") !== publishedReleaseId) { + throw new Error(`Latest GitHub Release is not the exact numeric ${tag} release.`); } +parseReleaseBody(latest.body, releaseIdentity, "published"); +assertNoResidualDraft(); +verifyRemoteAnnotatedTag(); console.log(`GitHub Release ${tag} contains the exact immutable HRA artifacts.`); diff --git a/scripts/publish-npm-release.ts b/scripts/publish-npm-release.ts index 174ae7c..8698feb 100644 --- a/scripts/publish-npm-release.ts +++ b/scripts/publish-npm-release.ts @@ -3,6 +3,7 @@ import { mkdtemp, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { basename, join, resolve } from "node:path"; +import { readBoundedJsonResponse } from "./bounded-json-response"; import { parseNpmRelease } from "./release-distribution-policy"; import { decideNpmPublicationTransition, @@ -62,7 +63,7 @@ async function lookup(): Promise { }); if (response.status === 404) return false; if (response.status !== 200) throw new Error(`npm registry returned HTTP ${String(response.status)}.`); - const payload = await response.json() as unknown; + const payload = await readBoundedJsonResponse(response, "npm registry exact release"); const coordinate = parseNpmRelease(payload, inspection.version); if (coordinate.integrity !== expectedIntegrity || coordinate.shasum !== expectedShasum) { throw new Error(`${inspection.name}@${inspection.version} exists with different immutable bytes.`); @@ -70,60 +71,37 @@ async function lookup(): Promise { return true; } -async function attestations(): Promise { - const attestationsUrl = - `https://registry.npmjs.org/-/npm/v1/attestations/@hraness%2fhra@${inspection.version}`; - const response = await fetch(attestationsUrl, { +async function registryJson(url: string, label: string): Promise { + const response = await fetch(url, { cache: "no-store", headers: { Accept: "application/json", "Cache-Control": "no-cache" }, redirect: "error", signal: AbortSignal.timeout(20_000), }); if (response.status !== 200) { - throw new Error(`npm Sigstore attestations returned HTTP ${String(response.status)}.`); - } - const declared = response.headers.get("content-length"); - if (declared !== null && (!/^(?:0|[1-9][0-9]*)$/u.test(declared) || Number(declared) > maximumAttestationBytes)) { - throw new Error("npm Sigstore attestations exceed their declared byte bound."); - } - const reader = response.body?.getReader(); - if (reader === undefined) throw new Error("npm Sigstore attestations have no body."); - const chunks: Uint8Array[] = []; - let length = 0; - try { - for (;;) { - const item = await reader.read(); - if (item.done) break; - length += item.value.byteLength; - if (length > maximumAttestationBytes) { - throw new Error("npm Sigstore attestations exceed their byte bound."); - } - chunks.push(item.value); - } - } finally { - try { await reader.cancel(); } catch { /* the bounded result remains authoritative */ } - reader.releaseLock(); - } - const payload = new Uint8Array(length); - let offset = 0; - for (const chunk of chunks) { - payload.set(chunk, offset); - offset += chunk.byteLength; - } - try { - return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(payload)) as unknown; - } catch { - throw new Error("npm Sigstore attestations returned malformed JSON."); + throw new Error(`${label} returned HTTP ${String(response.status)}.`); } + return readBoundedJsonResponse(response, label, maximumAttestationBytes); } -async function admitProvenance(attemptPolicy: NpmProvenanceAttemptPolicy): Promise { +async function admitProvenance( + attemptPolicy: NpmProvenanceAttemptPolicy, + maximumAttempt: string, +): Promise { const tufCachePath = await mkdtemp(join(tmpdir(), "hra-publish-sigstore-tuf-")); try { await verifyNpmProvenance({ attemptPolicy, - attestations: await attestations(), + attestations: await registryJson( + `https://registry.npmjs.org/-/npm/v1/attestations/@hraness%2fhra@${inspection.version}`, + "npm Sigstore attestations", + ), integrity: expectedIntegrity, + maximumAttempt, + registryKeys: await registryJson( + "https://registry.npmjs.org/-/npm/v1/keys", + "npm registry keys", + ), runAttempt: workflowRunAttempt, runId: workflowRunId, sha: releaseSha, @@ -144,7 +122,7 @@ const transition = decideNpmPublicationTransition({ preflightRunId: registryPreflightRunId, }); if (transition.action === "admit_existing") { - await admitProvenance(transition.attemptPolicy); + await admitProvenance(transition.attemptPolicy, transition.maximumProvenanceAttempt); console.log(`${inspection.name}@${inspection.version} already contains the exact trusted-publisher bytes.`); } else { if (process.env.HRA_APPROVE_NPM_PUBLICATION !== `publish:${inspection.name}@${inspection.version}`) { @@ -214,6 +192,6 @@ if (transition.action === "admit_existing") { } } if (!observed) throw new Error("npm publication did not become readable with exact provenance-bearing bytes."); - await admitProvenance("exact"); + await admitProvenance("exact", workflowRunAttempt); console.log(`Published exact ${inspection.name}@${inspection.version} through npm trusted publishing.`); } diff --git a/scripts/release-included-response.test.ts b/scripts/release-included-response.test.ts new file mode 100644 index 0000000..ef8a620 --- /dev/null +++ b/scripts/release-included-response.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from "bun:test"; + +import { parseGitHubIncludedJsonResponse } from "./release-included-response"; + +function response(status: number, reason: string, body: unknown): Uint8Array { + return Buffer.from([ + `HTTP/2.0 ${String(status)} ${reason}`, + "Content-Type: application/json; charset=utf-8", + "X-Github-Request-Id: ABC:123", + "", + JSON.stringify(body), + ].join("\n")); +} + +describe("bounded GitHub included response", () => { + test("uses the structured HTTP status and JSON body", () => { + expect(parseGitHubIncludedJsonResponse(response(404, "Not Found", { + message: "Not Found", + status: "404", + }))).toEqual({ + body: { message: "Not Found", status: "404" }, + status: 404, + }); + }); + + test("rejects diagnostic text and ambiguous multiple response blocks", () => { + expect(() => parseGitHubIncludedJsonResponse(Buffer.from("gh: Not Found (HTTP 404)"))).toThrow(); + const first = Buffer.from(response(200, "OK", { id: 1 })).toString("utf8"); + const second = Buffer.from(response(404, "Not Found", { status: "404" })).toString("utf8"); + expect(() => parseGitHubIncludedJsonResponse(Buffer.from(`${first}\n\n${second}`))).toThrow(); + }); +}); diff --git a/scripts/release-included-response.ts b/scripts/release-included-response.ts new file mode 100644 index 0000000..7bfa24d --- /dev/null +++ b/scripts/release-included-response.ts @@ -0,0 +1,39 @@ +type JsonRecord = Record; + +function record(value: unknown): JsonRecord { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new Error("GitHub included response body must be one JSON object."); + } + return value as JsonRecord; +} + +export function parseGitHubIncludedJsonResponse( + output: Uint8Array, +): Readonly<{ body: JsonRecord; status: number }> { + let value: string; + try { + value = new TextDecoder("utf-8", { fatal: true }).decode(output); + } catch { + throw new Error("GitHub included response is not canonical UTF-8."); + } + const separator = value.includes("\r\n\r\n") ? "\r\n\r\n" : "\n\n"; + const boundary = value.indexOf(separator); + if (boundary <= 0 || value.indexOf(separator, boundary + separator.length) !== -1) { + throw new Error("GitHub included response has an invalid message boundary."); + } + const header = value.slice(0, boundary); + const bodyText = value.slice(boundary + separator.length); + const lines = header.split(/\r?\n/u); + const statusMatch = /^(?:HTTP\/1\.1|HTTP\/2(?:\.0)?) ([1-5][0-9]{2}) [\x20-\x7e]+$/u.exec(lines[0] ?? ""); + if ( + statusMatch === null + || lines.slice(1).some((line) => !/^[A-Za-z0-9-]+: [\x20-\x7e]*$/u.test(line)) + ) throw new Error("GitHub included response has invalid HTTP metadata."); + let body: unknown; + try { + body = JSON.parse(bodyText) as unknown; + } catch { + throw new Error("GitHub included response body is not JSON."); + } + return Object.freeze({ body: record(body), status: Number(statusMatch[1]) }); +} diff --git a/scripts/release-workflow.test.ts b/scripts/release-workflow.test.ts index 4273815..1cc3a99 100644 --- a/scripts/release-workflow.test.ts +++ b/scripts/release-workflow.test.ts @@ -5,10 +5,19 @@ import { buildHraGlobalInstallCommand, HRA_INSTALL_ARCHIVE_URL, } from "../src/install-preflight"; +import { githubPublisherEnvironment } from "./github-publisher-environment"; +import { + draftReleaseBody, + githubReleaseRun, + parseReleaseBody, +} from "./github-release-identity"; const reviewedActions = { checkout: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", + downloadArtifact: "actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093", setupBun: "oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6", + setupNode: "actions/setup-node@820762786026740c76f36085b0efc47a31fe5020", + uploadArtifact: "actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02", } as const; function asRecord(value: unknown, label: string): Record { @@ -20,6 +29,104 @@ function asRecord(value: unknown, label: string): Record { } describe("release workflow", () => { + test("keeps every privileged release helper under owner review", async () => { + const codeowners = await readFile( + join(import.meta.dir, "..", ".github", "CODEOWNERS"), + "utf8", + ); + for (const path of [ + "/scripts/github-publisher-environment.ts", + "/scripts/github-release-identity.ts", + "/scripts/bounded-json-response.ts", + "/scripts/publish-github-release.ts", + "/scripts/publish-npm-release.ts", + "/scripts/verify-npm-provenance-crypto.mjs", + "/scripts/verify-npm-provenance.ts", + ]) expect(codeowners).toContain(`${path} @0thernet`); + }); + + test("bounds every npm metadata read and aligns GitHub artifact output with package policy", async () => { + const [preflight, npmPublisher, githubPublisher] = await Promise.all([ + readFile(join(import.meta.dir, "check-npm-artifact-state.ts"), "utf8"), + readFile(join(import.meta.dir, "publish-npm-release.ts"), "utf8"), + readFile(join(import.meta.dir, "publish-github-release.ts"), "utf8"), + ]); + expect(preflight).toContain("readBoundedJsonResponse(response, \"npm registry exact release\")"); + expect(npmPublisher).toContain("readBoundedJsonResponse(response, \"npm registry exact release\")"); + expect(preflight).not.toContain("response.json()"); + expect(npmPublisher).not.toContain("response.json()"); + expect(githubPublisher).toContain("const maximumArtifactBytes = 64 * 1024 * 1024"); + expect(githubPublisher).toContain("maxBuffer: maximumStdoutBytes + 1"); + expect(githubPublisher.match(/false, maximumArtifactBytes\)\.stdout/gu)?.length).toBe(2); + expect(githubPublisher).not.toContain("maxBuffer: 32 * 1_024 * 1_024"); + }); + + test("gives GitHub publisher commands only their explicit non-OIDC environment", () => { + const environment = githubPublisherEnvironment({ + ACTIONS_ID_TOKEN_REQUEST_TOKEN: "oidc-secret", + ACTIONS_ID_TOKEN_REQUEST_URL: "https://oidc.invalid/secret", + GH_TOKEN: "github-secret", + HOME: "/home/release", + LANG: "C.UTF-8", + PATH: "/usr/bin:/bin", + SSL_CERT_FILE: "/etc/ssl/certs/ca-certificates.crt", + UNRELATED_SECRET: "private", + }); + + expect(environment).toEqual({ + GH_PROMPT_DISABLED: "1", + GH_TOKEN: "github-secret", + HOME: "/home/release", + LANG: "C.UTF-8", + NO_COLOR: "1", + PATH: "/usr/bin:/bin", + SSL_CERT_FILE: "/etc/ssl/certs/ca-certificates.crt", + }); + expect(environment.ACTIONS_ID_TOKEN_REQUEST_TOKEN).toBeUndefined(); + expect(environment.ACTIONS_ID_TOKEN_REQUEST_URL).toBeUndefined(); + expect(environment.UNRELATED_SECRET).toBeUndefined(); + expect(() => githubPublisherEnvironment({ GH_TOKEN: "github-secret" })).toThrow(); + }); + + test("binds residual draft identity to the exact same run and artifact authority", () => { + const source = { + GITHUB_EVENT_NAME: "push", + GITHUB_REF: "refs/tags/v0.1.0", + GITHUB_REF_NAME: "v0.1.0", + GITHUB_REF_TYPE: "tag", + GITHUB_REPOSITORY: "hraness/hra", + GITHUB_REPOSITORY_ID: "1343008607", + GITHUB_RUN_ATTEMPT: "2", + GITHUB_RUN_ID: "123", + GITHUB_WORKFLOW_REF: "hraness/hra/.github/workflows/release.yml@refs/tags/v0.1.0", + }; + const run = githubReleaseRun("v0.1.0", source); + const input = { + artifacts: [{ name: "hra.tgz", sha256: "c".repeat(64), size: 7 }], + commitSha: "a".repeat(40), + run, + tag: "v0.1.0", + tagObjectSha: "b".repeat(40), + } as const; + const body = draftReleaseBody(input); + expect(parseReleaseBody(body, input, "draft").createdAttempt).toBe(2); + expect(() => parseReleaseBody(body, { ...input, commitSha: "d".repeat(40) }, "draft")).toThrow(); + expect(() => parseReleaseBody(body, { ...input, tagObjectSha: "e".repeat(40) }, "draft")).toThrow(); + expect(() => parseReleaseBody(body, { ...input, artifacts: [{ ...input.artifacts[0], size: 8 }] }, "draft")).toThrow(); + expect(() => parseReleaseBody(body, { + ...input, + run: { ...run, attempt: 3, id: "124" }, + }, "draft")).toThrow(); + const futureAttemptBody = draftReleaseBody({ + ...input, + run: { ...run, attempt: 3 }, + }); + expect(() => parseReleaseBody(futureAttemptBody, input, "draft")) + .toThrow("workflow-attempt ordering"); + expect(() => githubReleaseRun("v0.1.0", { ...source, GITHUB_RUN_ATTEMPT: "3", GITHUB_RUN_ID: "124" })) + .not.toThrow(); + }); + test("publishes the exact transactional installer in the historical release notes", async () => { const [releaseNotes, readme] = await Promise.all([ readFile(join(import.meta.dir, "..", "docs", "beta-release-notes.md"), "utf8"), @@ -65,7 +172,13 @@ describe("release workflow", () => { expect(domainRecord).toContain("unresolved_current_intent"); expect(releaseRecord).toContain("Status: prepared but blocked before publication."); expect(releaseRecord).toContain("no `v0.1.0` tag"); - expect(releaseRecord).toContain("`@hraness/oh` is a GitHub runtime dependency"); + expect(releaseRecord).toContain("GitHub `@hraness/oh#v0.2.0` runtime dependency"); + expect(releaseRecord).toContain("exact registry version `0.2.4`"); + expect(releaseRecord).toContain("may create\none annotated stable-semver tag before or after"); + expect(releaseRecord).toContain("outer digest is a transport assertion, not independent release authority"); + expect(releaseRecord).toContain("`@hraness/hra@0.1.0-bootstrap.0`"); + expect(releaseRecord).toContain("`HRA_APPROVE_NPM_PUBLICATION=publish:@hraness/hra@0.1.0`"); + expect(releaseRecord).toContain("npm CLI 11.15.0 or newer"); const workflow = await readFile(releaseWorkflow, "utf8"); expect(workflow).toContain("id-token: write"); expect(workflow).toContain("npm pack --ignore-scripts --pack-destination artifacts ."); @@ -127,4 +240,157 @@ describe("release workflow", () => { .toBe("${{ needs.check.result }}"); expect(requiredStep.run).toBe('test "$CHECK_RESULT" = "success"'); }); + + test("pins the privileged release TCB and scopes GitHub tokens to exact steps", async () => { + const workflow = asRecord(Bun.YAML.parse(await readFile( + join(import.meta.dir, "..", ".github", "workflows", "release.yml"), + "utf8", + )), "release workflow"); + const jobs = asRecord(workflow.jobs, "release workflow jobs"); + const publish = asRecord(jobs.publish, "release publish job"); + expect(asRecord(publish.permissions, "release publish permissions")).toEqual({ + contents: "write", + "id-token": "write", + }); + const jobEnvironment = asRecord(publish.env, "release publish environment"); + expect(jobEnvironment.GH_TOKEN).toBeUndefined(); + expect(jobEnvironment.GITHUB_TOKEN).toBeUndefined(); + + if (!Array.isArray(publish.steps)) { + throw new TypeError("release publish job steps must be an array"); + } + const steps = publish.steps.map((step, index) => asRecord(step, `release publish step ${index}`)); + expect(steps + .map((step) => step.uses) + .filter((value): value is string => typeof value === "string")) + .toEqual([ + reviewedActions.checkout, + reviewedActions.setupBun, + reviewedActions.setupNode, + reviewedActions.downloadArtifact, + ]); + + const tokenEnvironments = Object.fromEntries(steps.map((step) => { + const environment = step.env === undefined + ? {} + : asRecord(step.env, `${String(step.name)} environment`); + return [String(step.name), Object.fromEntries(Object.entries({ + GH_TOKEN: environment.GH_TOKEN, + GITHUB_TOKEN: environment.GITHUB_TOKEN, + }).filter((entry) => entry[1] !== undefined))]; + })); + expect(tokenEnvironments).toEqual({ + "Check out verified source with complete history": {}, + "Install Bun": {}, + "Install Node and npm trusted-publishing client": {}, + "Install exact locked dependencies without lifecycle scripts": {}, + "Require registry readiness and trusted publishing support": {}, + "Require exact artifact identity": {}, + "Download validated release bytes": {}, + "Revalidate remote authority and checksum": { GH_TOKEN: "${{ github.token }}" }, + "Publish exact tarball through npm trusted publishing": {}, + "Create immutable GitHub Release from the same bytes": { GH_TOKEN: "${{ github.token }}" }, + "Admit exact public npm and GitHub state": { GITHUB_TOKEN: "${{ github.token }}" }, + }); + }); + + test("binds every artifact consumer to the verify attempt's numeric artifact identity", async () => { + const workflow = asRecord(Bun.YAML.parse(await readFile( + join(import.meta.dir, "..", ".github", "workflows", "release.yml"), + "utf8", + )), "release workflow"); + const jobs = asRecord(workflow.jobs, "release workflow jobs"); + const verify = asRecord(jobs.verify, "release verify job"); + const verifyOutputs = asRecord(verify.outputs, "release verify outputs"); + expect(verifyOutputs.artifact_id).toBe("${{ steps.release_artifact.outputs.artifact-id }}"); + expect(verifyOutputs.artifact_digest) + .toBe("${{ steps.release_artifact.outputs.artifact-digest }}"); + if (!Array.isArray(verify.steps)) throw new TypeError("release verify steps must be an array"); + const verifySteps = verify.steps.map((step, index) => asRecord(step, `verify step ${index}`)); + const upload = verifySteps.find((step) => step.name === "Preserve exact release bytes"); + expect(upload?.id).toBe("release_artifact"); + expect(upload?.uses).toBe(reviewedActions.uploadArtifact); + const uploadInputs = asRecord(upload?.with, "release artifact upload inputs"); + expect(uploadInputs.name).toBe("hra-release-${{ github.run_attempt }}"); + + for (const jobName of ["exact_artifact", "publish"] as const) { + const job = asRecord(jobs[jobName], `${jobName} job`); + if (!Array.isArray(job.steps)) throw new TypeError(`${jobName} steps must be an array`); + const steps = job.steps.map((step, index) => asRecord(step, `${jobName} step ${index}`)); + const requireIdentityIndex = steps.findIndex((step) => step.name === "Require exact artifact identity"); + const downloadIndex = steps.findIndex((step) => step.uses === reviewedActions.downloadArtifact); + expect(requireIdentityIndex).toBeGreaterThanOrEqual(0); + expect(downloadIndex).toBeGreaterThan(requireIdentityIndex); + const identity = steps[requireIdentityIndex]; + const environment = asRecord(identity?.env, `${jobName} artifact identity environment`); + expect(environment).toEqual({ + VERIFIED_ARTIFACT_DIGEST: "${{ needs.verify.outputs.artifact_digest }}", + VERIFIED_ARTIFACT_ID: "${{ needs.verify.outputs.artifact_id }}", + }); + expect(identity?.run).toContain('[[ "$VERIFIED_ARTIFACT_ID" =~ ^[1-9][0-9]*$ ]]'); + expect(identity?.run).toContain('[[ "$VERIFIED_ARTIFACT_DIGEST" =~ ^[0-9a-f]{64}$ ]]'); + const download = steps[downloadIndex]; + const inputs = asRecord(download?.with, `${jobName} artifact download inputs`); + expect(inputs).toEqual({ + "artifact-ids": "${{ needs.verify.outputs.artifact_id }}", + "merge-multiple": true, + path: "artifacts", + }); + expect(inputs.name).toBeUndefined(); + } + }); + + test("completes only one exact residual GitHub draft without substituting bytes", async () => { + const [publisher, admission] = await Promise.all([ + readFile(join(import.meta.dir, "publish-github-release.ts"), "utf8"), + readFile(join(import.meta.dir, "check-public-release.ts"), "utf8"), + ]); + expect(publisher.match(/verifyRemoteAnnotatedTag\(\);/gu)?.length ?? 0).toBeGreaterThanOrEqual(4); + expect(publisher).toContain("parseGitHubIncludedJsonResponse(existing.stdout)"); + expect(publisher).toContain('"-F", "draft=true"'); + expect(publisher).toContain("exactDraft"); + expect(publisher).toContain("findDraft"); + expect(publisher).toContain("verifyDraftAssets"); + expect(publisher).toContain("inventory.length >= 100"); + expect(publisher).toContain("Multiple residual drafts exist"); + expect(publisher).toContain("assertNoResidualDraft();"); + expect(publisher).toContain("remains after immutable publication"); + expect(publisher).toContain("contains ambiguous assets"); + expect(publisher).toContain("has different immutable metadata"); + expect(publisher).toContain("has different bytes"); + expect(publisher).toContain('"--header", "Content-Type: application/octet-stream"'); + expect(publisher).toContain('"--input", source'); + expect(publisher).toContain("https://uploads.github.com/repos/${publicRepository}/releases/${String(draft.id)}/assets?name=${encodeURIComponent(name)}"); + expect(publisher).toContain("readExactDraftById(draft.id)"); + expect(publisher).not.toContain('"gh", "release", "upload"'); + expect(publisher).toContain('"-F", "draft=false", "-F", "prerelease=false", "-f", "make_latest=true"'); + expect(publisher).toContain("parseGitHubRelease(published, inspection.version)"); + expect(publisher).toContain("const publishedBody = publishedReleaseBody(releaseIdentity, draft.createdAttempt)"); + expect(publisher).toContain("published.body !== publishedBody"); + expect(publisher).toContain("releaseId(release(), `Tag-resolved GitHub Release ${tag}`) !== expectedId"); + expect(publisher).toContain("releaseId(latest, \"Latest GitHub Release\") !== publishedReleaseId"); + expect(publisher.indexOf("assertNoResidualDraft();")) + .toBeGreaterThan(publisher.indexOf("await verifyPublishedRelease(publishedReleaseId);")); + expect(publisher).toContain("env: githubPublisherEnvironment(process.env)"); + expect(publisher).not.toContain("...process.env"); + expect(publisher).not.toContain("--target"); + expect(publisher).not.toContain("target_commitish"); + expect(publisher).not.toContain("--generate-notes"); + expect(publisher).toContain("const verifiedTagObject = process.env.VERIFIED_TAG_OBJECT"); + expect(publisher).toContain("tagObject.sha !== verifiedTagObject"); + expect(publisher).toContain("/git/tags/${verifiedTagObject}"); + expect(admission).toContain('environment("VERIFIED_TAG_OBJECT", /^[0-9a-f]{40}$/u)'); + expect(admission).toContain("tagRef.object.sha !== verifiedTagObject"); + expect(admission).toContain("`${api}/git/tags/${verifiedTagObject}`"); + }); + + test("publishes and proves GitHub identity before consuming the npm version", async () => { + const workflow = await readFile(join(import.meta.dir, "..", ".github", "workflows", "release.yml"), "utf8"); + const githubIndex = workflow.indexOf("Create immutable GitHub Release from the same bytes"); + const npmIndex = workflow.indexOf("Publish exact tarball through npm trusted publishing"); + const admissionIndex = workflow.indexOf("Admit exact public npm and GitHub state"); + expect(githubIndex).toBeGreaterThan(0); + expect(npmIndex).toBeGreaterThan(githubIndex); + expect(admissionIndex).toBeGreaterThan(npmIndex); + }); }); diff --git a/scripts/verify-npm-provenance-crypto.mjs b/scripts/verify-npm-provenance-crypto.mjs new file mode 100644 index 0000000..bcdbe0b --- /dev/null +++ b/scripts/verify-npm-provenance-crypto.mjs @@ -0,0 +1,187 @@ +import { Buffer } from "node:buffer"; +import process from "node:process"; +import { pathToFileURL } from "node:url"; + +const GITHUB_REPOSITORY = "hraness/hra"; +const GITHUB_REPOSITORY_ID = "1343008607"; +const GITHUB_REPOSITORY_URL = `https://github.com/${GITHUB_REPOSITORY}`; +const GITHUB_OIDC_ISSUER = "https://token.actions.githubusercontent.com"; +const MAXIMUM_INPUT_BYTES = 1024 * 1_024; +const SHA = /^[0-9a-f]{40}$/u; +const STABLE_TAG = /^v(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)$/u; +const INVOCATION = /^https:\/\/github\.com\/hraness\/hra\/actions\/runs\/[1-9][0-9]*\/attempts\/[1-9][0-9]*$/u; + +function record(value, label) { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${label} must be an object.`); + } + return value; +} + +function exactKeys(value, expected, label) { + const actual = Object.keys(value).sort(); + const keys = [...expected].sort(); + if (actual.length !== keys.length || actual.some((key, index) => key !== keys[index])) { + throw new Error(`${label} has unexpected fields.`); + } +} + +function escapeRegularExpression(value) { + return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); +} + +export function releaseSignerIdentity(tag, sha, invocation) { + if (!STABLE_TAG.test(tag) || !SHA.test(sha) || !INVOCATION.test(invocation)) { + throw new Error("npm provenance signer coordinates are invalid."); + } + const ref = `refs/tags/${tag}`; + const identity = `${GITHUB_REPOSITORY_URL}/.github/workflows/release.yml@${ref}`; + return Object.freeze({ + identity, + options: Object.freeze({ + certificateIdentityURI: `^${escapeRegularExpression(identity)}$`, + certificateIssuer: GITHUB_OIDC_ISSUER, + certificateOIDs: Object.freeze({ + "1.3.6.1.4.1.57264.1.2": "push", + "1.3.6.1.4.1.57264.1.3": sha, + "1.3.6.1.4.1.57264.1.5": GITHUB_REPOSITORY, + "1.3.6.1.4.1.57264.1.6": ref, + "1.3.6.1.4.1.57264.1.11": "github-hosted", + "1.3.6.1.4.1.57264.1.12": GITHUB_REPOSITORY_URL, + "1.3.6.1.4.1.57264.1.13": sha, + "1.3.6.1.4.1.57264.1.14": ref, + "1.3.6.1.4.1.57264.1.15": GITHUB_REPOSITORY_ID, + "1.3.6.1.4.1.57264.1.18": identity, + "1.3.6.1.4.1.57264.1.19": sha, + "1.3.6.1.4.1.57264.1.20": "push", + "1.3.6.1.4.1.57264.1.21": invocation, + "1.3.6.1.4.1.57264.1.22": "public", + "1.3.6.1.4.1.57264.1.24": `repo:${GITHUB_REPOSITORY}:ref:${ref}`, + }), + ctLogThreshold: 1, + retry: 0, + timeout: 10_000, + tlogThreshold: 1, + }), + }); +} + +function registryKeySelector(value) { + const root = record(value, "npm registry keys"); + exactKeys(root, ["keys"], "npm registry keys"); + if (!Array.isArray(root.keys) || root.keys.length < 1 || root.keys.length > 8) { + throw new Error("npm registry key set is not bounded."); + } + const keys = new Map(); + for (const candidate of root.keys) { + const key = record(candidate, "npm registry key"); + exactKeys(key, ["expires", "key", "keyid", "keytype", "scheme"], "npm registry key"); + if ( + typeof key.keyid !== "string" + || !/^SHA256:[A-Za-z0-9+/]{43}$/u.test(key.keyid) + || key.keytype !== "ecdsa-sha2-nistp256" + || key.scheme !== "ecdsa-sha2-nistp256" + || typeof key.key !== "string" + || (key.expires !== null && (typeof key.expires !== "string" || Number.isNaN(Date.parse(key.expires)))) + || keys.has(key.keyid) + ) throw new Error("npm registry key is invalid or duplicated."); + const id = key.keyid.slice("SHA256:".length); + const idBytes = Buffer.from(`${id}=`, "base64"); + const keyBytes = Buffer.from(key.key, "base64"); + if ( + idBytes.byteLength !== 32 + || idBytes.toString("base64").slice(0, -1) !== id + || keyBytes.byteLength === 0 + || keyBytes.toString("base64") !== key.key + ) throw new Error("npm registry key encoding is not canonical."); + const encoded = keyBytes.toString("base64").match(/.{1,64}/gu)?.join("\n"); + if (encoded === undefined) throw new Error("npm registry key is invalid."); + keys.set(key.keyid, `-----BEGIN PUBLIC KEY-----\n${encoded}\n-----END PUBLIC KEY-----\n`); + } + return (hint) => keys.get(hint); +} + +async function verifyRegistryPublishBundle( + bundle, + registryKeys, + tufCachePath, +) { + if (typeof tufCachePath !== "string" || tufCachePath.length === 0) { + throw new Error("npm publish cryptographic verification options are invalid."); + } + const { verify } = await import("sigstore"); + await verify(bundle, { + ctLogThreshold: 0, + keySelector: registryKeySelector(registryKeys), + retry: 0, + timeout: 10_000, + tlogThreshold: 1, + tufCachePath, + tufForceCache: true, + }); +} + +async function readInput() { + const chunks = []; + let length = 0; + for await (const chunk of process.stdin) { + length += chunk.byteLength; + if (length > MAXIMUM_INPUT_BYTES) throw new Error("npm cryptographic input exceeded its bound."); + chunks.push(chunk); + } + try { + return record(JSON.parse(Buffer.concat(chunks, length).toString("utf8")), "npm cryptographic input"); + } catch (error) { + if (error instanceof Error && error.message.startsWith("npm cryptographic input")) throw error; + throw new Error("npm cryptographic input is not JSON."); + } +} + +async function main() { + const [mode, ...arguments_] = process.argv.slice(2); + const input = await readInput(); + const { verify } = await import("sigstore"); + if (mode === "slsa") { + const [tag, sha, invocation, cachePath] = arguments_; + if (tag === undefined || sha === undefined || invocation === undefined || cachePath === undefined) { + throw new Error("SLSA verification arguments are incomplete."); + } + exactKeys(input, ["bundle"], "SLSA cryptographic input"); + const bundle = record(input.bundle, "SLSA bundle"); + if (bundle.mediaType !== "application/vnd.dev.sigstore.bundle.v0.3+json") { + throw new Error("SLSA bundle format is not exact."); + } + const policy = releaseSignerIdentity(tag, sha, invocation); + const signer = await verify(bundle, { + ...policy.options, + tufCachePath: cachePath, + tufForceCache: true, + }); + if ( + signer.identity?.subjectAlternativeName !== policy.identity + || signer.identity?.extensions?.issuer !== GITHUB_OIDC_ISSUER + ) throw new Error("Sigstore verified the wrong npm release signer."); + } else if (mode === "npm-publish") { + const [cachePath] = arguments_; + if (cachePath === undefined) throw new Error("npm publish verification arguments are incomplete."); + exactKeys(input, ["bundle", "registryKeys"], "npm publish cryptographic input"); + const bundle = record(input.bundle, "npm publish bundle"); + if (bundle.mediaType !== "application/vnd.dev.sigstore.bundle+json;version=0.2") { + throw new Error("npm publish bundle format is not exact."); + } + await verifyRegistryPublishBundle(bundle, input.registryKeys, cachePath); + } else { + throw new Error("npm cryptographic verification mode is invalid."); + } + process.stdout.write("verified\n"); +} + +const invokedPath = process.argv[1]; +if (invokedPath !== undefined && import.meta.url === pathToFileURL(invokedPath).href) { + try { + await main(); + } catch { + process.stderr.write("npm cryptographic verification failed.\n"); + process.exitCode = 1; + } +} diff --git a/scripts/verify-npm-provenance-crypto.node.mjs b/scripts/verify-npm-provenance-crypto.node.mjs new file mode 100644 index 0000000..69263b3 --- /dev/null +++ b/scripts/verify-npm-provenance-crypto.node.mjs @@ -0,0 +1,22 @@ +import assert from "node:assert/strict"; +import process from "node:process"; + +import { releaseSignerIdentity } from "./verify-npm-provenance-crypto.mjs"; + +const sha = "a".repeat(40); +const invocation = "https://github.com/hraness/hra/actions/runs/123/attempts/2"; +const policy = releaseSignerIdentity("v0.1.0", sha, invocation); +assert.equal( + policy.options.certificateIdentityURI, + "^https://github\\.com/hraness/hra/\\.github/workflows/release\\.yml@refs/tags/v0\\.1\\.0$", +); +assert.equal(policy.options.certificateOIDs["1.3.6.1.4.1.57264.1.15"], "1343008607"); +assert.equal(policy.options.certificateOIDs["1.3.6.1.4.1.57264.1.21"], invocation); +assert.equal(policy.options.certificateOIDs["1.3.6.1.4.1.57264.1.22"], "public"); +assert.throws(() => releaseSignerIdentity( + "v0.1.0", + sha, + "https://github.com/hraness/other/actions/runs/123/attempts/2", +)); +assert.throws(() => releaseSignerIdentity("v0.1.0", "b".repeat(39), invocation)); +process.stdout.write("node enforced exact npm provenance signer policy\n"); diff --git a/scripts/verify-npm-provenance-crypto.test.ts b/scripts/verify-npm-provenance-crypto.test.ts new file mode 100644 index 0000000..58869bf --- /dev/null +++ b/scripts/verify-npm-provenance-crypto.test.ts @@ -0,0 +1,46 @@ +import { expect, test } from "bun:test"; +import { resolve } from "node:path"; + +import { npmCryptoEnvironment } from "./verify-npm-provenance"; + +test("uses the dependency-pinned Node runtime for cryptographic verification", async () => { + const source = await Bun.file(resolve(import.meta.dir, "verify-npm-provenance.ts")).text(); + const helper = await Bun.file(resolve(import.meta.dir, "verify-npm-provenance-crypto.mjs")).text(); + expect(source).toContain('"node", resolve(import.meta.dir, "verify-npm-provenance-crypto.mjs")'); + expect(source).not.toContain('from "sigstore"'); + expect(helper).toContain('const { verify } = await import("sigstore")'); + expect(helper).toContain("releaseSignerIdentity(tag, sha, invocation)"); + expect(helper).toContain("verifyRegistryPublishBundle(bundle, input.registryKeys"); + expect(helper).toContain("tlogThreshold: 1"); + + expect(npmCryptoEnvironment({ + ACTIONS_ID_TOKEN_REQUEST_TOKEN: "oidc-secret", + GH_TOKEN: "github-secret", + HOME: "/home/release", + PATH: "/usr/bin:/bin", + SSL_CERT_FILE: "/etc/ssl/cert.pem", + UNRELATED_SECRET: "private", + })).toEqual({ + HOME: "/home/release", + PATH: "/usr/bin:/bin", + SSL_CERT_FILE: "/etc/ssl/cert.pem", + }); +}); + +test("executes the Node helper policy and rejects tampered signer coordinates", async () => { + const child = Bun.spawn([ + "node", resolve(import.meta.dir, "verify-npm-provenance-crypto.node.mjs"), + ], { + env: npmCryptoEnvironment(), + stderr: "pipe", + stdout: "pipe", + }); + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + expect(exitCode).toBe(0); + expect(stdout).toBe("node enforced exact npm provenance signer policy\n"); + expect(stderr).toBe(""); +}); diff --git a/scripts/verify-npm-provenance.test.ts b/scripts/verify-npm-provenance.test.ts index e661177..8e9917d 100644 --- a/scripts/verify-npm-provenance.test.ts +++ b/scripts/verify-npm-provenance.test.ts @@ -1,6 +1,13 @@ import { describe, expect, test } from "bun:test"; -import { assertNpmProvenanceBuildIdentity } from "./verify-npm-provenance"; +import { + assertNpmPublishAttestation, + assertNpmProvenanceBuildIdentity, + assertNpmProvenanceSubject, + npmProvenanceSignerPolicy, + npmRegistryKeySelector, + selectNpmProvenanceAttestations, +} from "./verify-npm-provenance"; const sha = "a".repeat(40); const tag = "v0.1.0"; @@ -67,6 +74,12 @@ describe("npm provenance workflow attempt admission", () => { ...identity, attemptPolicy: "same_run_not_later", })).toThrow("workflow-run identity"); + expect(() => assertNpmProvenanceBuildIdentity(predicate("123", "2"), { + ...identity, + attemptPolicy: "same_run_not_later", + maximumAttempt: "1", + runAttempt: "3", + })).toThrow("inadmissible workflow attempt"); }); test("keeps the release workflow, ref, commit, and invocation coordinates exact", () => { @@ -85,3 +98,160 @@ describe("npm provenance workflow attempt admission", () => { })).toThrow("workflow-run identity"); }); }); + +describe("npm provenance package subject admission", () => { + const archiveDigest = Buffer.alloc(64, 0xab); + const integrity = `sha512-${archiveDigest.toString("base64")}`; + const exactStatement = { + subject: [{ + digest: { sha512: archiveDigest.toString("hex") }, + name: "pkg:npm/%40hraness/hra@0.1.0", + }], + }; + + test("binds the versioned npm PURL to the hex SHA-512 of dist.integrity", () => { + expect(() => assertNpmProvenanceSubject(exactStatement, { integrity, tag })) + .not.toThrow(); + expect(() => assertNpmProvenanceSubject({ + subject: [{ + digest: { sha512: archiveDigest.toString("base64") }, + name: "pkg:npm/%40hraness/hra@0.1.0", + }], + }, { integrity, tag })).toThrow("exact package bytes"); + expect(() => assertNpmProvenanceSubject({ + subject: [{ + digest: { sha512: archiveDigest.toString("hex") }, + name: "pkg:npm/%40hraness/hra", + }], + }, { integrity, tag })).toThrow("exact package bytes"); + expect(() => assertNpmProvenanceSubject(exactStatement, { + integrity, + tag: "v0.1.1", + })).toThrow("exact package bytes"); + }); +}); + +describe("npm provenance attestation-set and signer admission", () => { + const archiveDigest = Buffer.alloc(64, 0xab); + const integrity = `sha512-${archiveDigest.toString("base64")}`; + const bundle = (statement: unknown) => ({ + dsseEnvelope: { + payload: Buffer.from(JSON.stringify(statement)).toString("base64"), + payloadType: "application/vnd.in-toto+json", + }, + }); + const provenance = { + bundle: bundle({ predicateType: "https://slsa.dev/provenance/v1" }), + predicateType: "https://slsa.dev/provenance/v1", + signedAccessSignatureUrl: "", + }; + const publishStatement = { + _type: "https://in-toto.io/Statement/v0.1", + predicate: { + name: "@hraness/hra", + registry: "https://registry.npmjs.org", + version: "0.1.0", + }, + predicateType: "https://github.com/npm/attestation/tree/main/specs/publish/v0.1", + subject: [{ + digest: { sha512: archiveDigest.toString("hex") }, + name: "pkg:npm/%40hraness/hra@0.1.0", + }], + }; + const publish = { + bundle: bundle(publishStatement), + predicateType: "https://github.com/npm/attestation/tree/main/specs/publish/v0.1", + signedAccessSignatureUrl: "", + }; + + test("selects one SLSA v1 bundle from npm's real two-attestation shape", () => { + const selected = selectNpmProvenanceAttestations({ attestations: [publish, provenance] }); + expect(selected.provenance.bundle).toBe(provenance.bundle); + expect(selected.publish?.bundle).toBe(publish.bundle); + expect(() => assertNpmPublishAttestation(selected.publish!.bundle, { integrity, tag })) + .not.toThrow(); + }); + + test("rejects missing, duplicate, unexpected, and unbounded provenance sets", () => { + expect(() => selectNpmProvenanceAttestations({ attestations: [publish] })) + .toThrow("exactly one SLSA"); + expect(() => selectNpmProvenanceAttestations({ attestations: [provenance, provenance] })) + .toThrow("exactly one SLSA"); + expect(() => selectNpmProvenanceAttestations({ + attestations: [{ + bundle: {}, + predicateType: "https://example.invalid/other", + signedAccessSignatureUrl: "", + }], + })).toThrow("unexpected attestation predicate"); + expect(() => selectNpmProvenanceAttestations({ + attestations: [{ ...provenance, signedAccessSignatureUrl: "https://private.invalid" }], + })).toThrow("unexpected attestation predicate"); + expect(() => selectNpmProvenanceAttestations({ attestations: [publish, provenance, publish] })) + .toThrow("bounded expected attestation set"); + }); + + test("binds the Fulcio certificate to the exact public workflow run", () => { + const invocation = "https://github.com/hraness/hra/actions/runs/123/attempts/2"; + const policy = npmProvenanceSignerPolicy(tag, sha, invocation); + expect(policy.certificateIdentityURI).toBe( + "^https://github\\.com/hraness/hra/\\.github/workflows/release\\.yml@refs/tags/v0\\.1\\.0$", + ); + expect(policy.certificateOIDs).toEqual({ + "1.3.6.1.4.1.57264.1.2": "push", + "1.3.6.1.4.1.57264.1.3": sha, + "1.3.6.1.4.1.57264.1.5": "hraness/hra", + "1.3.6.1.4.1.57264.1.6": "refs/tags/v0.1.0", + "1.3.6.1.4.1.57264.1.11": "github-hosted", + "1.3.6.1.4.1.57264.1.12": "https://github.com/hraness/hra", + "1.3.6.1.4.1.57264.1.13": sha, + "1.3.6.1.4.1.57264.1.14": "refs/tags/v0.1.0", + "1.3.6.1.4.1.57264.1.15": "1343008607", + "1.3.6.1.4.1.57264.1.18": "https://github.com/hraness/hra/.github/workflows/release.yml@refs/tags/v0.1.0", + "1.3.6.1.4.1.57264.1.19": sha, + "1.3.6.1.4.1.57264.1.20": "push", + "1.3.6.1.4.1.57264.1.21": invocation, + "1.3.6.1.4.1.57264.1.22": "public", + "1.3.6.1.4.1.57264.1.24": "repo:hraness/hra:ref:refs/tags/v0.1.0", + }); + expect(policy.certificateOIDs["1.3.6.1.4.1.57264.1.1"]).toBeUndefined(); + expect(npmProvenanceSignerPolicy(tag, sha, + "https://github.com/hraness/hra/actions/runs/123/attempts/1").certificateOIDs["1.3.6.1.4.1.57264.1.21"]) + .not.toBe(invocation); + expect(npmProvenanceSignerPolicy(tag, sha, + "https://github.com/hraness/hra/actions/runs/123/attempts/1").certificateIdentityURI) + .toBe(policy.certificateIdentityURI); + expect(() => npmProvenanceSignerPolicy(tag, sha, + "https://github.com/hraness/other/actions/runs/123/attempts/2")).toThrow(); + }); + + test("admits only bounded canonical npm registry signing keys", () => { + const primaryKey = { + expires: null, + key: "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEf/5ebeb9dBZGvx5YfFUJWEEupUUQOnHm5W9R4cR4C4hCZyzhUIIogRvuJaNhTZJQYS4lYREspR1QYNLgJQ==", + keyid: "SHA256:jl3bwswu80Pjj5ZJnD8B+seCt9U5TxOsS0UjfVWo7UQ", + keytype: "ecdsa-sha2-nistp256", + scheme: "ecdsa-sha2-nistp256", + } as const; + const liveShape = { + keys: [ + primaryKey, + { + expires: "2027-01-29T00:00:00.000Z", + key: "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAErM/WDadDQZ+f7saNiAr5Oan7t7cl6XNlJbHCaWWtnje4yhlfX7XQDJ5uYjqbpLtNhGN3p4jZWYqQmDLvQw==", + keyid: "SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U", + keytype: "ecdsa-sha2-nistp256", + scheme: "ecdsa-sha2-nistp256", + }, + ], + }; + const select = npmRegistryKeySelector(liveShape); + const keyId = primaryKey.keyid; + expect(select(keyId)).toContain("BEGIN PUBLIC KEY"); + expect(select("SHA256:unknown")).toBeUndefined(); + expect(() => npmRegistryKeySelector({ keys: [{ + ...primaryKey, + keyid: `${primaryKey.keyid}=`, + }] })).toThrow("invalid or duplicated"); + }); +}); diff --git a/scripts/verify-npm-provenance.ts b/scripts/verify-npm-provenance.ts index 3192b11..dd0c468 100644 --- a/scripts/verify-npm-provenance.ts +++ b/scripts/verify-npm-provenance.ts @@ -1,22 +1,37 @@ -import { verify, type Bundle } from "sigstore"; +import { resolve } from "node:path"; type JsonRecord = Record; const SLSA_V1 = "https://slsa.dev/provenance/v1"; +const NPM_PUBLISH_V01 = "https://github.com/npm/attestation/tree/main/specs/publish/v0.1"; const FULCIO_GITHUB_ISSUER = "https://token.actions.githubusercontent.com"; const GITHUB_BUILD_TYPE = "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1"; const GITHUB_BUILDER_ID = "https://github.com/actions/runner/github-hosted"; const GITHUB_REPOSITORY_URL = "https://github.com/hraness/hra"; -const GITHUB_OIDS = Object.freeze({ - "1.3.6.1.4.1.57264.1.1": "push", - "1.3.6.1.4.1.57264.1.2": "__SHA__", - "1.3.6.1.4.1.57264.1.3": "Release", - "1.3.6.1.4.1.57264.1.4": "hraness/hra", - "1.3.6.1.4.1.57264.1.5": "__REF__", -}); +const GITHUB_REPOSITORY_ID = "1343008607"; +const MAXIMUM_DSSE_PAYLOAD_BYTES = 256 * 1_024; +const MAXIMUM_CRYPTO_INPUT_BYTES = 1024 * 1_024; +const MAXIMUM_CRYPTO_OUTPUT_BYTES = 8 * 1_024; export type NpmProvenanceAttemptPolicy = "exact" | "same_run_not_later"; +const CRYPTO_RUNTIME_ENVIRONMENT = Object.freeze([ + "HOME", "LANG", "LC_ALL", "LC_CTYPE", "NODE_EXTRA_CA_CERTS", "PATH", + "SSL_CERT_FILE", "TEMP", "TMP", "TMPDIR", "TZ", +] as const); + +export function npmCryptoEnvironment( + source: Readonly> = process.env, +): Record { + if (source.PATH === undefined || source.PATH.length === 0) { + throw new Error("npm cryptographic verification requires an explicit runtime PATH."); + } + return Object.fromEntries(CRYPTO_RUNTIME_ENVIRONMENT.flatMap((name) => { + const value = source[name]; + return value === undefined ? [] : [[name, value]]; + })); +} + function record(value: unknown, label: string): JsonRecord { if (value === null || typeof value !== "object" || Array.isArray(value)) { throw new Error(`${label} must be an object.`); @@ -32,17 +47,83 @@ function exactKeys(value: JsonRecord, keys: readonly string[], label: string): v } } +function escapeRegularExpression(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); +} + +function decodeStatement(bundle: JsonRecord, label: string): JsonRecord { + const envelope = record(bundle.dsseEnvelope, `${label} DSSE envelope`); + if (envelope.payloadType !== "application/vnd.in-toto+json" || typeof envelope.payload !== "string") { + throw new Error(`${label} does not contain one in-toto DSSE payload.`); + } + const bytes = Buffer.from(envelope.payload, "base64"); + if ( + bytes.byteLength === 0 + || bytes.byteLength > MAXIMUM_DSSE_PAYLOAD_BYTES + || bytes.toString("base64") !== envelope.payload + ) throw new Error(`${label} DSSE payload is not canonical bounded base64.`); + try { + return record(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)), `${label} statement`); + } catch (error) { + if (error instanceof Error && error.message.startsWith(`${label} statement`)) throw error; + throw new Error(`${label} DSSE payload is not canonical UTF-8 JSON.`); + } +} + +export function npmProvenanceSignerPolicy(tag: string, sha: string, invocation: string): Readonly<{ + certificateIdentityURI: string; + certificateIssuer: string; + certificateOIDs: Readonly>; +}> { + if ( + !/^v(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)$/u.test(tag) + || !/^[0-9a-f]{40}$/u.test(sha) + || !/^https:\/\/github\.com\/hraness\/hra\/actions\/runs\/[1-9][0-9]*\/attempts\/[1-9][0-9]*$/u.test(invocation) + ) throw new Error("npm provenance signer coordinates are invalid."); + const ref = `refs/tags/${tag}`; + const identity = `${GITHUB_REPOSITORY_URL}/.github/workflows/release.yml@${ref}`; + return Object.freeze({ + certificateIdentityURI: `^${escapeRegularExpression(identity)}$`, + certificateIssuer: FULCIO_GITHUB_ISSUER, + certificateOIDs: Object.freeze({ + "1.3.6.1.4.1.57264.1.2": "push", + "1.3.6.1.4.1.57264.1.3": sha, + "1.3.6.1.4.1.57264.1.5": "hraness/hra", + "1.3.6.1.4.1.57264.1.6": ref, + "1.3.6.1.4.1.57264.1.11": "github-hosted", + "1.3.6.1.4.1.57264.1.12": GITHUB_REPOSITORY_URL, + "1.3.6.1.4.1.57264.1.13": sha, + "1.3.6.1.4.1.57264.1.14": ref, + "1.3.6.1.4.1.57264.1.15": GITHUB_REPOSITORY_ID, + "1.3.6.1.4.1.57264.1.18": identity, + "1.3.6.1.4.1.57264.1.19": sha, + "1.3.6.1.4.1.57264.1.20": "push", + "1.3.6.1.4.1.57264.1.21": invocation, + "1.3.6.1.4.1.57264.1.22": "public", + "1.3.6.1.4.1.57264.1.24": `repo:hraness/hra:ref:${ref}`, + }), + }); +} + export function assertNpmProvenanceBuildIdentity( value: unknown, input: Readonly<{ attemptPolicy: NpmProvenanceAttemptPolicy; + maximumAttempt?: string; runAttempt: string; runId: string; sha: string; tag: string; }>, ): string { - if (!/^[1-9][0-9]*$/u.test(input.runId) || !/^[1-9][0-9]*$/u.test(input.runAttempt)) { + if ( + !/^[1-9][0-9]*$/u.test(input.runId) + || !/^[1-9][0-9]*$/u.test(input.runAttempt) + || (input.maximumAttempt !== undefined && ( + !/^[1-9][0-9]*$/u.test(input.maximumAttempt) + || BigInt(input.maximumAttempt) > BigInt(input.runAttempt) + )) + ) { throw new Error("npm provenance requires exact workflow run identity."); } const predicate = record(value, "SLSA predicate"); @@ -82,15 +163,226 @@ export function assertNpmProvenanceBuildIdentity( if ( input.attemptPolicy === "exact" ? publishedAttempt !== input.runAttempt - : BigInt(publishedAttempt) > BigInt(input.runAttempt) + : BigInt(publishedAttempt) > BigInt(input.maximumAttempt ?? input.runAttempt) ) throw new Error("SLSA provenance came from an inadmissible workflow attempt."); return publishedAttempt; } +export function assertNpmProvenanceSubject( + value: unknown, + input: Readonly<{ integrity: string; tag: string }>, +): void { + if (!/^v(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)$/u.test(input.tag)) { + throw new Error("npm provenance requires an exact release tag."); + } + const integrity = /^sha512-([A-Za-z0-9+/]{86}==)$/u.exec(input.integrity); + const encodedIntegrity = integrity?.[1]; + const integrityBytes = encodedIntegrity === undefined + ? undefined + : Buffer.from(encodedIntegrity, "base64"); + if ( + integrityBytes === undefined + || integrityBytes.byteLength !== 64 + || integrityBytes.toString("base64") !== encodedIntegrity + ) throw new Error("npm provenance requires one canonical SHA-512 integrity."); + + const statement = record(value, "SLSA statement"); + if (!Array.isArray(statement.subject) || statement.subject.length !== 1) { + throw new Error("npm provenance must bind exactly one package subject."); + } + const subject = record(statement.subject[0], "npm provenance subject"); + exactKeys(subject, ["digest", "name"], "npm provenance subject"); + const digest = record(subject.digest, "npm provenance subject digest"); + exactKeys(digest, ["sha512"], "npm provenance subject digest"); + if ( + subject.name !== `pkg:npm/%40hraness/hra@${input.tag.slice(1)}` + || digest.sha512 !== integrityBytes.toString("hex") + ) throw new Error("npm provenance subject does not bind the exact package bytes."); +} + +type NpmAttestationItem = Readonly<{ + bundle: JsonRecord; + predicateType: string; + signedAccessSignatureUrl: ""; +}>; + +export function selectNpmProvenanceAttestations(value: unknown): Readonly<{ + provenance: NpmAttestationItem; + publish?: NpmAttestationItem; +}> { + const root = record(value, "npm attestations"); + exactKeys(root, ["attestations"], "npm attestations"); + if (!Array.isArray(root.attestations) || root.attestations.length < 1 || root.attestations.length > 2) { + throw new Error("npm must expose a bounded expected attestation set."); + } + const items = root.attestations.map((candidate, index): NpmAttestationItem => { + const item = record(candidate, `npm attestation ${String(index + 1)}`); + exactKeys(item, ["bundle", "predicateType", "signedAccessSignatureUrl"], `npm attestation ${String(index + 1)}`); + if ( + (item.predicateType !== SLSA_V1 && item.predicateType !== NPM_PUBLISH_V01) + || item.signedAccessSignatureUrl !== "" + ) { + throw new Error("npm exposed an unexpected attestation predicate."); + } + return Object.freeze({ + bundle: record(item.bundle, `npm attestation ${String(index + 1)} bundle`), + predicateType: item.predicateType, + signedAccessSignatureUrl: "", + }); + }); + const provenance = items.filter((item) => item.predicateType === SLSA_V1); + const publish = items.filter((item) => item.predicateType === NPM_PUBLISH_V01); + if (provenance.length !== 1 || publish.length > 1) { + throw new Error("npm must expose exactly one SLSA v1 provenance attestation."); + } + const exactProvenance = provenance[0]; + if (exactProvenance === undefined) { + throw new Error("npm must expose exactly one SLSA v1 provenance attestation."); + } + const registryPublish = publish[0]; + return Object.freeze({ + provenance: exactProvenance, + ...(registryPublish === undefined ? {} : { publish: registryPublish }), + }); +} + +export function assertNpmPublishAttestation( + bundle: JsonRecord, + input: Readonly<{ integrity: string; tag: string }>, +): void { + const statement = decodeStatement(bundle, "npm registry publish attestation"); + if ( + statement._type !== "https://in-toto.io/Statement/v0.1" + || statement.predicateType !== NPM_PUBLISH_V01 + || !Array.isArray(statement.subject) + || statement.subject.length !== 1 + ) throw new Error("npm registry publish statement identity is invalid."); + const subject = record(statement.subject[0], "npm registry publish subject"); + exactKeys(subject, ["digest", "name"], "npm registry publish subject"); + const digest = record(subject.digest, "npm registry publish subject digest"); + exactKeys(digest, ["sha512"], "npm registry publish subject digest"); + const integrity = /^sha512-([A-Za-z0-9+/]{86}==)$/u.exec(input.integrity)?.[1]; + const expectedDigest = integrity === undefined ? undefined : Buffer.from(integrity, "base64").toString("hex"); + const predicate = record(statement.predicate, "npm registry publish predicate"); + exactKeys(predicate, ["name", "registry", "version"], "npm registry publish predicate"); + if ( + subject.name !== `pkg:npm/%40hraness/hra@${input.tag.slice(1)}` + || digest.sha512 !== expectedDigest + || predicate.name !== "@hraness/hra" + || predicate.version !== input.tag.slice(1) + || predicate.registry !== "https://registry.npmjs.org" + ) throw new Error("npm registry publish attestation does not bind the exact package bytes."); +} + +export function npmRegistryKeySelector(value: unknown): (hint: string) => string | undefined { + const root = record(value, "npm registry keys"); + exactKeys(root, ["keys"], "npm registry keys"); + if (!Array.isArray(root.keys) || root.keys.length < 1 || root.keys.length > 8) { + throw new Error("npm registry key set is not bounded."); + } + const keys = new Map(); + for (const candidate of root.keys) { + const key = record(candidate, "npm registry key"); + exactKeys(key, ["expires", "key", "keyid", "keytype", "scheme"], "npm registry key"); + if ( + typeof key.keyid !== "string" + || !/^SHA256:[A-Za-z0-9+/]{43}$/u.test(key.keyid) + || key.keytype !== "ecdsa-sha2-nistp256" + || key.scheme !== "ecdsa-sha2-nistp256" + || typeof key.key !== "string" + || (key.expires !== null && (typeof key.expires !== "string" || Number.isNaN(Date.parse(key.expires)))) + || keys.has(key.keyid) + ) throw new Error("npm registry key is invalid or duplicated."); + const keyIdDigest = Buffer.from(`${key.keyid.slice("SHA256:".length)}=`, "base64"); + if ( + keyIdDigest.byteLength !== 32 + || keyIdDigest.toString("base64").slice(0, -1) !== key.keyid.slice("SHA256:".length) + ) throw new Error("npm registry key ID is not canonical base64url-free SHA-256."); + const bytes = Buffer.from(key.key, "base64"); + if (bytes.byteLength === 0 || bytes.toString("base64") !== key.key) { + throw new Error("npm registry key is not canonical base64."); + } + const encoded = bytes.toString("base64").match(/.{1,64}/gu)?.join("\n"); + if (encoded === undefined) throw new Error("npm registry key is invalid."); + keys.set(key.keyid, `-----BEGIN PUBLIC KEY-----\n${encoded}\n-----END PUBLIC KEY-----\n`); + } + return (hint: string) => keys.get(hint); +} + +async function boundedProcessOutput( + stream: ReadableStream, + kill: () => void, +): Promise { + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + let length = 0; + try { + for (;;) { + const item = await reader.read(); + if (item.done) break; + length += item.value.byteLength; + if (length > MAXIMUM_CRYPTO_OUTPUT_BYTES) { + kill(); + throw new Error("npm cryptographic verification output exceeded its bound."); + } + chunks.push(item.value); + } + } finally { + reader.releaseLock(); + } + return Buffer.concat(chunks, length); +} + +async function verifyCryptographicBundle( + mode: "npm-publish" | "slsa", + input: unknown, + arguments_: readonly string[], +): Promise { + const serialized = JSON.stringify(input); + if (Buffer.byteLength(serialized, "utf8") > MAXIMUM_CRYPTO_INPUT_BYTES) { + throw new Error("npm cryptographic verification input exceeded its bound."); + } + const child = Bun.spawn([ + "node", resolve(import.meta.dir, "verify-npm-provenance-crypto.mjs"), mode, ...arguments_, + ], { + env: npmCryptoEnvironment(), + stderr: "pipe", + stdin: "pipe", + stdout: "pipe", + }); + await child.stdin.write(serialized); + await child.stdin.end(); + const kill = () => child.kill(9); + let timer: ReturnType | undefined; + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout(() => { + kill(); + reject(new Error("npm cryptographic verification timed out.")); + }, 60_000); + }); + try { + const [exitCode, stdout, stderr] = await Promise.race([ + Promise.all([ + child.exited, + boundedProcessOutput(child.stdout, kill), + boundedProcessOutput(child.stderr, kill), + ]), + timeout, + ]); + if (exitCode !== 0 || stdout.toString("utf8") !== "verified\n" || stderr.byteLength !== 0) { + throw new Error("npm cryptographic verification failed without exposing provider output."); + } + } finally { + if (timer !== undefined) clearTimeout(timer); + } +} + export async function verifyNpmProvenance(input: Readonly<{ attemptPolicy: NpmProvenanceAttemptPolicy; attestations: unknown; integrity: string; + maximumAttempt?: string; + registryKeys: unknown; runId: string; runAttempt: string; sha: string; @@ -103,47 +395,25 @@ export async function verifyNpmProvenance(input: Readonly<{ if (!/^[0-9a-f]{40}$/u.test(input.sha) || !/^v(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)$/u.test(input.tag)) { throw new Error("npm provenance requires an exact release ref and commit."); } - const root = record(input.attestations, "npm attestations"); - exactKeys(root, ["attestations"], "npm attestations"); - if (!Array.isArray(root.attestations) || root.attestations.length !== 1) { - throw new Error("npm must expose exactly one provenance attestation."); - } - const item = record(root.attestations[0], "npm provenance attestation"); - if (item.predicateType !== SLSA_V1) throw new Error("npm attestation is not exact SLSA v1 provenance."); - const bundle = record(item.bundle, "npm provenance Sigstore bundle") as Bundle; - const workflowIdentity = `https://github.com/hraness/hra/.github/workflows/release.yml@refs/tags/${input.tag}`; - const certificateOIDs = Object.fromEntries(Object.entries(GITHUB_OIDS).map(([oid, expected]) => [ - oid, - expected === "__SHA__" ? input.sha : expected === "__REF__" ? `refs/tags/${input.tag}` : expected, - ])); - await verify(bundle, { - certificateIdentityURI: workflowIdentity, - certificateIssuer: FULCIO_GITHUB_ISSUER, - certificateOIDs, - ctLogThreshold: 1, - retry: 0, - timeout: 10_000, - tlogThreshold: 1, - tufCachePath: input.tufCachePath, - }); - - const envelope = record(record(bundle, "Sigstore bundle").dsseEnvelope, "Sigstore DSSE envelope"); - if (envelope.payloadType !== "application/vnd.in-toto+json" || typeof envelope.payload !== "string") { - throw new Error("npm provenance does not contain one in-toto DSSE payload."); - } - const statement = record(JSON.parse(Buffer.from(envelope.payload, "base64").toString("utf8")), "SLSA statement"); + const selected = selectNpmProvenanceAttestations(input.attestations); + const statement = decodeStatement(selected.provenance.bundle, "npm SLSA provenance"); if (statement._type !== "https://in-toto.io/Statement/v1" || statement.predicateType !== SLSA_V1) { throw new Error("npm provenance statement identity is invalid."); } - if (!Array.isArray(statement.subject) || statement.subject.length !== 1) { - throw new Error("npm provenance must bind exactly one package subject."); - } - const subject = record(statement.subject[0], "npm provenance subject"); - const digest = record(subject.digest, "npm provenance subject digest"); - const expectedSha512 = input.integrity.replace(/^sha512-/u, ""); - if (subject.name !== "pkg:npm/%40hraness/hra" || digest.sha512 !== expectedSha512) { - throw new Error("npm provenance subject does not bind the exact package bytes."); - } + assertNpmProvenanceSubject(statement, input); const predicate = record(statement.predicate, "SLSA predicate"); - assertNpmProvenanceBuildIdentity(predicate, input); + const publishedAttempt = assertNpmProvenanceBuildIdentity(predicate, input); + const invocation = `${GITHUB_REPOSITORY_URL}/actions/runs/${input.runId}/attempts/${publishedAttempt}`; + npmProvenanceSignerPolicy(input.tag, input.sha, invocation); + await verifyCryptographicBundle("slsa", { bundle: selected.provenance.bundle }, [ + input.tag, input.sha, invocation, input.tufCachePath, + ]); + if (selected.publish !== undefined) { + assertNpmPublishAttestation(selected.publish.bundle, input); + npmRegistryKeySelector(input.registryKeys); + await verifyCryptographicBundle("npm-publish", { + bundle: selected.publish.bundle, + registryKeys: input.registryKeys, + }, [input.tufCachePath]); + } } diff --git a/src/install-preflight.test.ts b/src/install-preflight.test.ts index 564ac45..267d07d 100644 --- a/src/install-preflight.test.ts +++ b/src/install-preflight.test.ts @@ -39,13 +39,30 @@ import { } from "./install-preflight-runtime"; const repositoryRoot = resolve(import.meta.dir, ".."); +// Installer security tests use the complete publishable package tree with only +// dependency resolution metadata removed. The package gate separately checks +// the unchanged production tarball and its exact dependency policy. +const TEST_STAGING_DEADLINE_MS = 45_000; // Two serialized staging installs may each consume their complete bounded // installer budget. Keep the outer test deadline above both inner budgets so // scheduling delay cannot terminate a valid second install. const SERIAL_STAGING_INSTALL_TEST_TIMEOUT_MS = 180_000; const temporaryRoots: string[] = []; +type DirectTestChild = Readonly<{ + exited: Promise; + kill: (signal?: number | NodeJS.Signals) => void; +}>; +const directTestChildren = new Set(); let archivePath: string; let archiveSha256: string; +let installerFixtureManifest: Record; +let sourcePackageManifest: Record; + +const trackDirectTestChild = (child: Child): Child => { + directTestChildren.add(child); + void child.exited.finally(() => directTestChildren.delete(child)); + return child; +}; type FetchObservation = Readonly<{ accept: string | null; @@ -74,13 +91,13 @@ const run = async ( command: readonly [string, ...string[]], input: Readonly<{ cwd: string; environment?: NodeJS.ProcessEnv }>, ): Promise> => { - const child = Bun.spawn([...command], { + const child = trackDirectTestChild(Bun.spawn([...command], { cwd: input.cwd, env: input.environment ?? process.env, stderr: "pipe", stdin: "ignore", stdout: "pipe", - }); + })); const [exitCode, stderr, stdout] = await Promise.all([ child.exited, new Response(child.stderr).text(), @@ -174,15 +191,16 @@ const runInstaller = async (root: string): Promise> => { await mkdir(join(root, "home"), { recursive: true, mode: 0o700 }); await chmod(join(root, "home"), 0o700); - return await run([ - "/bin/sh", - "-c", - 'umask 077; exec "$1" "$2" "$3"', - "hra-install-test", - process.execPath, - resolve(import.meta.dir, "install-preflight.ts"), - archivePath, - ], { cwd: root, environment: installEnvironment(root) }); + const runtimePath = resolve(import.meta.dir, "install-preflight-runtime.ts"); + const program = [ + `const module = await import(${JSON.stringify(runtimePath)});`, + `await module.installHraRelease(${JSON.stringify(archivePath)}, { stageDeadlineMilliseconds: ${String(TEST_STAGING_DEADLINE_MS)} });`, + `process.stdout.write(${JSON.stringify(`${HRA_INSTALL_PREFLIGHT_SUCCESS}\n`)});`, + ].join("\n"); + return await run([process.execPath, "-e", program], { + cwd: root, + environment: installEnvironment(root), + }); }; const runTrustedLoader = async ( @@ -195,7 +213,7 @@ const runTrustedLoader = async ( }>> => { await mkdir(join(root, "home"), { recursive: true, mode: 0o700 }); await chmod(join(root, "home"), 0o700); - const child = Bun.spawn([ + const child = trackDirectTestChild(Bun.spawn([ process.execPath, "-e", HRA_INSTALL_PREFLIGHT_LOADER, @@ -208,7 +226,7 @@ const runTrustedLoader = async ( stderr: "pipe", stdin: Bun.file(resolve(import.meta.dir, "install-preflight-runtime.ts")), stdout: "pipe", - }); + })); const [exitCode, stderr, stdout] = await Promise.all([ child.exited, new Response(child.stderr).text(), @@ -280,7 +298,7 @@ const runOfficialInstaller = async ( ? "async () => { const stage = (await fs.readdir(" + JSON.stringify(authorityRoot) + ")).find((entry) => entry.startsWith(\".staging-\")); if (!stage) throw new Error(\"The private archive stage is missing.\"); const privatePath = path.join(" + JSON.stringify(authorityRoot) + ", stage, \".hra-release-archive.tgz\"); const bytes = Buffer.from(await fs.readFile(privatePath)); bytes[0] = (bytes[0] ?? 0) ^ 1; await fs.writeFile(privatePath, bytes, { mode: 0o600 }); }" : "undefined") + ";", "try {", - " await module.installHraRelease(module.HRA_INSTALL_ARCHIVE_URL, { beforePrivateArchiveReadback, fetcher });", + ` await module.installHraRelease(module.HRA_INSTALL_ARCHIVE_URL, { beforePrivateArchiveReadback, fetcher, stageDeadlineMilliseconds: ${String(TEST_STAGING_DEADLINE_MS)} });`, " process.stdout.write(`${module.HRA_INSTALL_SUCCESS}\\n`);", "} finally {", ` await fs.writeFile(${JSON.stringify(observationsPath)}, JSON.stringify(observations), { mode: 0o600 });`, @@ -359,6 +377,7 @@ const assertStalledStageRecovers = async ( beforeAll(async () => { const root = await makeRoot("hra-install-archive-"); + sourcePackageManifest = await readJsonRecord(join(repositoryRoot, "package.json")); const packed = await run([ process.execPath, "pm", @@ -367,12 +386,42 @@ beforeAll(async () => { root, ], { cwd: repositoryRoot }); if (packed.exitCode !== 0) throw new Error(`Could not build installer fixture: ${packed.stderr}${packed.stdout}`); - archivePath = join(root, "hraness-hra-0.1.0.tgz"); + const productionArchivePath = join(root, "hraness-hra-0.1.0.tgz"); + const extractedRoot = join(root, "extracted"); + await mkdir(extractedRoot, { mode: 0o700 }); + const extracted = await run(["tar", "-xzf", productionArchivePath, "-C", extractedRoot], { cwd: root }); + if (extracted.exitCode !== 0) { + throw new Error(`Could not extract installer fixture: ${extracted.stderr}${extracted.stdout}`); + } + const extractedPackageRoot = join(extractedRoot, "package"); + installerFixtureManifest = await readJsonRecord(join(extractedPackageRoot, "package.json")); + delete installerFixtureManifest.dependencies; + delete installerFixtureManifest.devDependencies; + await writeFile( + join(extractedPackageRoot, "package.json"), + `${JSON.stringify(installerFixtureManifest, undefined, 2)}\n`, + { mode: 0o600 }, + ); + await rm(productionArchivePath); + const repacked = await run([ + process.execPath, + "pm", + "pack", + "--destination", + root, + ], { cwd: extractedPackageRoot }); + if (repacked.exitCode !== 0) { + throw new Error(`Could not repack installer fixture: ${repacked.stderr}${repacked.stdout}`); + } + archivePath = productionArchivePath; await chmod(archivePath, 0o600); archiveSha256 = createHash("sha256").update(await readFile(archivePath)).digest("hex"); }); afterAll(async () => { + const unsettledChildren = [...directTestChildren]; + for (const child of unsettledChildren) child.kill("SIGTERM"); + await Promise.allSettled(unsettledChildren.map(async (child) => await child.exited)); if (process.platform === "darwin") { await Promise.all(temporaryRoots.map(async (root) => { await run(["/bin/chmod", "-RN", root], { cwd: tmpdir() }); @@ -384,6 +433,19 @@ afterAll(async () => { }, 60_000); describe("transactional HRA installer", () => { + test("strips only dependency maps from the private installer fixture", () => { + expect(sourcePackageManifest.dependencies).toEqual({ + "@hraness/oh": "github:hraness/oh#v0.2.0", + "@openai/codex": "0.149.0", + convex: "1.45.0", + zod: "4.4.3", + }); + const dependencyFreeSourceManifest = { ...sourcePackageManifest }; + delete dependencyFreeSourceManifest.dependencies; + delete dependencyFreeSourceManifest.devDependencies; + expect(installerFixtureManifest).toEqual(dependencyFreeSourceManifest); + }); + test("binds the public command to one tagged preflight and one exact tagged archive", async () => { expect(HRA_INSTALL_PREFLIGHT_SOURCE_URL).toBe( "https://raw.githubusercontent.com/hraness/hra/v0.1.0/src/install-preflight-runtime.ts", @@ -767,6 +829,7 @@ describe("transactional HRA installer", () => { `const module = await import(${JSON.stringify(runtimePath)});`, `const archive = ${JSON.stringify(localArchive)};`, "await module.installHraRelease(archive, {", + ` stageDeadlineMilliseconds: ${String(TEST_STAGING_DEADLINE_MS)},`, " afterArchiveIdentityResolved: async () => {", " const bytes = Buffer.from(await fs.readFile(archive));", " bytes[0] = (bytes[0] ?? 0) ^ 1;", @@ -804,6 +867,7 @@ describe("transactional HRA installer", () => { 'const fs = await import("node:fs/promises");', `const module = await import(${JSON.stringify(runtimePath)});`, `await module.installHraRelease(${JSON.stringify(archivePath)}, {`, + ` stageDeadlineMilliseconds: ${String(TEST_STAGING_DEADLINE_MS)},`, " beforeStageWorkerSpawn: async (privateArchivePath) => {", " const bytes = Buffer.from(await fs.readFile(privateArchivePath));", " bytes[0] = (bytes[0] ?? 0) ^ 1;", @@ -831,6 +895,7 @@ describe("transactional HRA installer", () => { 'const path = await import("node:path");', `const module = await import(${JSON.stringify(runtimePath)});`, `await module.installHraRelease(${JSON.stringify(archivePath)}, {`, + ` stageDeadlineMilliseconds: ${String(TEST_STAGING_DEADLINE_MS)},`, " afterStageWorkerStarted: async () => {", ` const stage = (await fs.readdir(${JSON.stringify(authorityRoot)})).find((entry) => entry.startsWith(".staging-"));`, " if (!stage) throw new Error(\"The private archive stage is missing.\");", @@ -861,6 +926,7 @@ describe("transactional HRA installer", () => { 'const path = await import("node:path");', `const module = await import(${JSON.stringify(runtimePath)});`, `await module.installHraRelease(${JSON.stringify(archivePath)}, {`, + ` stageDeadlineMilliseconds: ${String(TEST_STAGING_DEADLINE_MS)},`, " afterStageCleanupCustody: async () => {", ` const stage = (await fs.readdir(${JSON.stringify(authorityRoot)})).find((entry) => entry.startsWith(".staging-"));`, " if (!stage) throw new Error(\"The extracted package stage is missing.\");", @@ -895,6 +961,7 @@ describe("transactional HRA installer", () => { 'const path = await import("node:path");', `const module = await import(${JSON.stringify(runtimePath)});`, `await module.installHraRelease(${JSON.stringify(archivePath)}, {`, + ` stageDeadlineMilliseconds: ${String(TEST_STAGING_DEADLINE_MS)},`, " afterStageCleanupCustody: async () => {", ` const stage = (await fs.readdir(${JSON.stringify(authorityRoot)})).find((entry) => entry.startsWith(".staging-"));`, " if (!stage) throw new Error(\"The cleanup stage is missing.\");", @@ -929,6 +996,7 @@ describe("transactional HRA installer", () => { 'const path = await import("node:path");', `const module = await import(${JSON.stringify(runtimePath)});`, `await module.installHraRelease(${JSON.stringify(archivePath)}, {`, + ` stageDeadlineMilliseconds: ${String(TEST_STAGING_DEADLINE_MS)},`, " afterStageWorkerExit: async () => {", ` const stage = (await fs.readdir(${JSON.stringify(authorityRoot)})).find((entry) => entry.startsWith(".staging-"));`, " if (!stage) throw new Error(\"The cache stage is missing.\");", @@ -961,6 +1029,7 @@ describe("transactional HRA installer", () => { 'const path = await import("node:path");', `const module = await import(${JSON.stringify(runtimePath)});`, `await module.installHraRelease(${JSON.stringify(archivePath)}, {`, + ` stageDeadlineMilliseconds: ${String(TEST_STAGING_DEADLINE_MS)},`, " beforeCacheQuarantine: async () => {", ` const stage = (await fs.readdir(${JSON.stringify(authorityRoot)})).find((entry) => entry.startsWith(".staging-"));`, " if (!stage) throw new Error(\"The held cache stage is missing.\");", @@ -994,6 +1063,7 @@ describe("transactional HRA installer", () => { 'const path = await import("node:path");', `const module = await import(${JSON.stringify(runtimePath)});`, `await module.installHraRelease(${JSON.stringify(archivePath)}, {`, + ` stageDeadlineMilliseconds: ${String(TEST_STAGING_DEADLINE_MS)},`, " afterVersionRename: async () => {", ` const versionsRoot = ${JSON.stringify(versionsRoot)};`, " const version = (await fs.readdir(versionsRoot)).find((entry) => !entry.endsWith(\"-authentic\"));", @@ -1024,6 +1094,7 @@ describe("transactional HRA installer", () => { 'const path = await import("node:path");', `const module = await import(${JSON.stringify(runtimePath)});`, `await module.installHraRelease(${JSON.stringify(archivePath)}, {`, + ` stageDeadlineMilliseconds: ${String(TEST_STAGING_DEADLINE_MS)},`, " afterVersionRebind: async () => {", ` const versionsRoot = ${JSON.stringify(versionsRoot)};`, " const version = (await fs.readdir(versionsRoot)).find((entry) => !entry.endsWith(\"-authentic\"));", @@ -1106,14 +1177,14 @@ describe("transactional HRA installer", () => { ` afterStageWorkerReady: async (bunPid, lockPid) => { await Bun.write(${JSON.stringify(sentinel)}, String(bunPid) + " " + String(lockPid) + "\\n"); await new Promise(() => {}); },`, "});", ].join("\n"); - const child = Bun.spawn([process.execPath, "-e", program], { + const child = trackDirectTestChild(Bun.spawn([process.execPath, "-e", program], { cwd: root, detached: true, env: installEnvironment(root), stderr: "ignore", stdin: "ignore", stdout: "ignore", - }); + })); const deadline = Date.now() + 10_000; while (!await Bun.file(sentinel).exists() && Date.now() < deadline) await Bun.sleep(25); if (!await Bun.file(sentinel).exists()) { @@ -1178,6 +1249,7 @@ describe("transactional HRA installer", () => { [ `const module = await import(${JSON.stringify(runtimePath)});`, `await module.installHraRelease(${JSON.stringify(archivePath)}, {`, + ` stageDeadlineMilliseconds: ${String(TEST_STAGING_DEADLINE_MS)},`, ` ${hook}: () => { throw new Error(${JSON.stringify(`test interruption at ${hook}`)}); },`, "});", ].join("\n"), @@ -1203,7 +1275,7 @@ describe("transactional HRA installer", () => { expect(await Bun.file(join(root, "bun root", "install", "hra", "install-intent.json")).exists()).toBeFalse(); }, 60_000); - test("detects same-size dependency mutation after normalization before PATH publication", async () => { + test("detects same-size installed-tree mutation after normalization before PATH publication", async () => { const root = await makeRoot("hra-install-tree-digest-"); await mkdir(join(root, "home"), { mode: 0o700 }); const runtimePath = resolve(import.meta.dir, "install-preflight-runtime.ts"); @@ -1211,9 +1283,10 @@ describe("transactional HRA installer", () => { const program = [ `const module = await import(${JSON.stringify(runtimePath)});`, `await module.installHraRelease(${JSON.stringify(archivePath)}, {`, + ` stageDeadlineMilliseconds: ${String(TEST_STAGING_DEADLINE_MS)},`, " afterNormalized: async () => {", ` const versions = await (await import("node:fs/promises")).readdir(${JSON.stringify(versionsRoot)});`, - ` const path = ${JSON.stringify(versionsRoot)} + "/" + versions[0] + "/install/global/node_modules/zod/package.json";`, + ` const path = ${JSON.stringify(versionsRoot)} + "/" + versions[0] + "/install/global/node_modules/@hraness/hra/src/domain/values.ts";`, " const bytes = Buffer.from(await Bun.file(path).arrayBuffer());", " bytes[0] = (bytes[0] ?? 0) ^ 1;", " await Bun.write(path, bytes);", @@ -1239,6 +1312,7 @@ describe("transactional HRA installer", () => { const program = [ `const module = await import(${JSON.stringify(runtimePath)});`, `await module.installHraRelease(${JSON.stringify(archivePath)}, {`, + ` stageDeadlineMilliseconds: ${String(TEST_STAGING_DEADLINE_MS)},`, " afterStageWorkerExit: async () => {", ' const fs = await import("node:fs/promises");', ' const path = await import("node:path");',