diff --git a/.github/workflows/npm-stage.yml b/.github/workflows/npm-stage.yml index b5df92d..cce6810 100644 --- a/.github/workflows/npm-stage.yml +++ b/.github/workflows/npm-stage.yml @@ -1,117 +1,108 @@ -name: Publish npm package +name: Stage npm package on: push: - tags: - - "v*" + branches: [main] + paths: + - "package.json" + workflow_dispatch: + inputs: + publish_to_npm: + description: Stage the verified package through npm trusted publishing + required: false + default: false + type: boolean + resolved_stage_version: + description: Exact prior stage version already rejected in npm; leave empty normally + required: false + default: "" + type: string permissions: contents: read concurrency: - group: npm-publish + group: npm-stage cancel-in-progress: false jobs: - authorize: - name: Authorize owner release tag - permissions: {} - runs-on: ubuntu-24.04 - timeout-minutes: 5 - steps: - - name: Verify immutable owner and public repository identity - env: - EXPECTED_ACTOR_ID: "894119" - EXPECTED_REPOSITORY: "hraness/kb" - EXPECTED_REPOSITORY_ID: "1308971873" - REF_PROTECTED: ${{ github.ref_protected }} - run: | - set -euo pipefail - if [[ "$GITHUB_EVENT_NAME" != push || \ - "$GITHUB_ACTOR_ID" != "$EXPECTED_ACTOR_ID" || \ - "$GITHUB_REPOSITORY" != "$EXPECTED_REPOSITORY" || \ - "$GITHUB_REPOSITORY_ID" != "$EXPECTED_REPOSITORY_ID" || \ - "$REF_PROTECTED" != true ]]; then - echo "::error::Publication requires an owner-created protected release tag in the exact public repository" - exit 1 - fi - EXPECTED_ACTOR_ID="$EXPECTED_ACTOR_ID" \ - EXPECTED_REPOSITORY="$EXPECTED_REPOSITORY" \ - EXPECTED_REPOSITORY_ID="$EXPECTED_REPOSITORY_ID" node <<'NODE' - const { readFileSync } = require("node:fs"); - const event = JSON.parse(readFileSync(process.env.GITHUB_EVENT_PATH, "utf8")); - if ( - event.sender?.id !== Number(process.env.EXPECTED_ACTOR_ID) - || event.sender?.type !== "User" - || event.repository?.id !== Number(process.env.EXPECTED_REPOSITORY_ID) - || event.repository?.full_name !== process.env.EXPECTED_REPOSITORY - || event.repository?.visibility !== "public" - || event.repository?.private !== false - || event.repository?.default_branch !== "main" - ) throw new Error("Tag-push sender or public repository identity is not the immutable release authority"); - NODE - select: - name: Select publishable package version - needs: authorize + name: Select stable package version permissions: contents: read runs-on: ubuntu-latest timeout-minutes: 5 outputs: current_version: ${{ steps.selection.outputs.current_version }} - publish_tag: ${{ steps.selection.outputs.publish_tag }} reason: ${{ steps.selection.outputs.reason }} - should_publish: ${{ steps.selection.outputs.should_publish }} + should_stage: ${{ steps.selection.outputs.should_stage }} steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 persist-credentials: false - - name: Validate owner-tagged publication request + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: "1.3.14" + - name: Select stage request id: selection env: + BEFORE_SHA: ${{ github.event.before }} DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} - REF_PROTECTED: ${{ github.ref_protected }} run: | set -euo pipefail - if [[ "$GITHUB_EVENT_NAME" != push || "$REF_PROTECTED" != true ]]; then - echo "::error::Publication requires a protected owner-created release tag" + expected_ref="refs/heads/$DEFAULT_BRANCH" + git check-ref-format "$expected_ref" + if [[ "$GITHUB_REF" != "$expected_ref" ]]; then + echo "::error::Run npm staging from $expected_ref, received $GITHUB_REF" exit 1 fi git fetch --no-tags origin \ - "refs/heads/$DEFAULT_BRANCH:refs/remotes/origin/$DEFAULT_BRANCH" + "$expected_ref:refs/remotes/origin/$DEFAULT_BRANCH" default_head="$(git rev-parse "origin/$DEFAULT_BRANCH")" checked_out_head="$(git rev-parse HEAD)" if [[ "$GITHUB_SHA" != "$default_head" || "$checked_out_head" != "$default_head" ]]; then - echo "::error::Tagged workflow commit $GITHUB_SHA is not current $DEFAULT_BRANCH head $default_head" - exit 1 - fi - package_name="$(node -p 'require("./package.json").name')" - package_version="$(node -p 'require("./package.json").version')" - if [[ "$package_name" != "@hraness/kb" || \ - ! "$package_version" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-beta\.(0|[1-9][0-9]*))?$ || \ - "$GITHUB_REF" != "refs/tags/v$package_version" || \ - "$GITHUB_REF_NAME" != "v$package_version" ]]; then - echo "::error::Publication ref does not exactly match a publishable @hraness/kb version" - exit 1 - fi - release_ref="refs/npm-publish-tags/$GITHUB_REF_NAME" - git fetch --no-tags origin "refs/tags/$GITHUB_REF_NAME:$release_ref" - if [[ "$(git cat-file -t "$release_ref")" != tag || \ - "$(git rev-parse "$release_ref^{commit}")" != "$GITHUB_SHA" ]]; then - echo "::error::Publication requires the exact annotated release tag" + echo "::error::Npm staging commit $GITHUB_SHA is not current $DEFAULT_BRANCH head $default_head" exit 1 fi - publish_tag=latest - if [[ "$package_version" == *-beta.* ]]; then publish_tag=beta; fi - printf 'current_version=%s\npublish_tag=%s\nreason=owner-tag\nshould_publish=true\n' \ - "$package_version" "$publish_tag" >> "$GITHUB_OUTPUT" + + selection_args=( + --current-manifest package.json + --event "$GITHUB_EVENT_NAME" + --github-output "$GITHUB_OUTPUT" + ) + case "$GITHUB_EVENT_NAME" in + workflow_dispatch) + ;; + push) + if [[ ! "$BEFORE_SHA" =~ ^[a-f0-9]{40}$ || \ + "$BEFORE_SHA" == 0000000000000000000000000000000000000000 ]]; then + echo "::error::Push event does not identify a previous commit" + exit 1 + fi + if ! git cat-file -e "$BEFORE_SHA^{commit}"; then + echo "::error::Previous push commit $BEFORE_SHA is unavailable" + exit 1 + fi + if ! git merge-base --is-ancestor "$BEFORE_SHA" "$default_head"; then + echo "::error::Previous push commit $BEFORE_SHA is not an ancestor of current $DEFAULT_BRANCH" + exit 1 + fi + previous_manifest="$RUNNER_TEMP/kb-previous-package.json" + git show "$BEFORE_SHA:package.json" > "$previous_manifest" + selection_args+=(--previous-manifest "$previous_manifest") + ;; + *) + echo "::error::Unsupported npm staging event $GITHUB_EVENT_NAME" + exit 1 + ;; + esac + bun run ./scripts/npm-stage-selection.ts "${selection_args[@]}" verify: name: Verify exact package needs: select - if: needs.select.outputs.should_publish == 'true' + if: needs.select.outputs.should_stage == 'true' permissions: contents: read runs-on: ubuntu-latest @@ -119,7 +110,6 @@ jobs: outputs: artifact_name: ${{ steps.artifact.outputs.artifact_name }} package_version: ${{ steps.artifact.outputs.package_version }} - publish_tag: ${{ needs.select.outputs.publish_tag }} source_sha: ${{ steps.identity.outputs.source_sha }} tarball_name: ${{ steps.artifact.outputs.tarball_name }} steps: @@ -149,8 +139,9 @@ jobs: DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} run: | set -euo pipefail - if [[ ! "$GITHUB_REF" =~ ^refs/tags/v ]]; then - echo "::error::Run this workflow from its owner-created release tag, received $GITHUB_REF" + expected_ref="refs/heads/$DEFAULT_BRANCH" + if [[ "$GITHUB_REF" != "$expected_ref" ]]; then + echo "::error::Run this workflow from $expected_ref, received $GITHUB_REF" exit 1 fi git fetch --no-tags origin \ @@ -162,7 +153,7 @@ jobs: exit 1 fi printf 'source_sha=%s\n' "$default_head" >> "$GITHUB_OUTPUT" - - name: Verify package can be published + - name: Verify package can be staged run: | set -euo pipefail package_name="$(node -p 'require("./package.json").name')" @@ -171,18 +162,28 @@ jobs: echo "::error::Unexpected package name $package_name" exit 1 fi - if [[ ! "$package_version" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-beta\.(0|[1-9][0-9]*))?$ ]]; then - echo "::error::Package version $package_version is not stable SemVer or a beta. prerelease" + if [[ ! "$package_version" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo "::error::Package version $package_version is not stable semantic version" exit 1 fi - if [[ "$GITHUB_REF" != "refs/tags/v$package_version" || \ - "$GITHUB_REF_NAME" != "v$package_version" ]]; then - echo "::error::Owner-created tag does not match $package_name@$package_version" + if git rev-parse --verify --quiet "refs/tags/v$package_version" >/dev/null; then + echo "::error::Create v$package_version only after exact npm delivery is verified" exit 1 fi if ! npm view "$package_name" name --json \ --registry=https://registry.npmjs.org >/dev/null; then - echo "::error::$package_name must exist before trusted publishing can run" + echo "::error::$package_name must be bootstrapped interactively before staged publishing can run" + exit 1 + fi + version_error="$RUNNER_TEMP/npm-version-error.txt" + if npm view "$package_name@$package_version" version --json \ + --registry=https://registry.npmjs.org >/dev/null 2>"$version_error"; then + echo "::error::$package_name@$package_version is already public" + exit 1 + fi + if ! grep -q 'E404' "$version_error"; then + cat "$version_error" + echo "::error::Could not prove that $package_name@$package_version is unpublished" exit 1 fi - run: bun install --frozen-lockfile --ignore-scripts @@ -243,15 +244,112 @@ jobs: compression-level: 0 retention-days: 30 - publish: - name: Publish exact package + stage: + name: Stage exact package v${{ needs.verify.outputs.package_version }} needs: verify + if: inputs.publish_to_npm == true environment: npm-stage permissions: + actions: read id-token: write runs-on: ubuntu-latest timeout-minutes: 10 steps: + - name: Reauthorize current npm staging attempt + env: + EXPECTED_ACTOR_ID: "894119" + EXPECTED_REPOSITORY: "hraness/kb" + EXPECTED_REPOSITORY_ID: "1308971873" + EXPECTED_SOURCE_SHA: ${{ needs.verify.outputs.source_sha }} + EXPECTED_WORKFLOW_ID: "344070109" + EXPECTED_WORKFLOW_NAME: "Stage npm package" + EXPECTED_WORKFLOW_PATH: ".github/workflows/npm-stage.yml" + GH_TOKEN: ${{ github.token }} + PUBLISH_TO_NPM: ${{ inputs.publish_to_npm }} + REF_PROTECTED: ${{ github.ref_protected }} + run: | + set -euo pipefail + if [[ ! "$GITHUB_RUN_ID" =~ ^[1-9][0-9]*$ || \ + ! "$GITHUB_RUN_ATTEMPT" =~ ^[1-9][0-9]*$ || \ + ! "$EXPECTED_SOURCE_SHA" =~ ^[a-f0-9]{40}$ || \ + "$GITHUB_EVENT_NAME" != workflow_dispatch || \ + "$PUBLISH_TO_NPM" != true || \ + "$GITHUB_ACTOR_ID" != "$EXPECTED_ACTOR_ID" || \ + "$GITHUB_REPOSITORY" != "$EXPECTED_REPOSITORY" || \ + "$GITHUB_REPOSITORY_ID" != "$EXPECTED_REPOSITORY_ID" || \ + "$GITHUB_REF" != refs/heads/main || \ + "$GITHUB_SHA" != "$EXPECTED_SOURCE_SHA" || \ + "$REF_PROTECTED" != true ]]; then + echo "::error::Current npm staging attempt is not the explicit owner-authorized protected-main dispatch" + exit 1 + fi + attempt_json="$(mktemp "$RUNNER_TEMP/kb-stage-attempt.XXXXXX")" + workflow_json="$(mktemp "$RUNNER_TEMP/kb-stage-workflow.XXXXXX")" + repository_json="$(mktemp "$RUNNER_TEMP/kb-stage-repository.XXXXXX")" + gh api --method GET \ + "/repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/attempts/$GITHUB_RUN_ATTEMPT" \ + > "$attempt_json" + gh api --method GET \ + "/repos/$GITHUB_REPOSITORY/actions/workflows/$EXPECTED_WORKFLOW_ID" \ + > "$workflow_json" + gh api --method GET "/repos/$GITHUB_REPOSITORY" > "$repository_json" + ATTEMPT_JSON="$attempt_json" \ + WORKFLOW_JSON="$workflow_json" \ + REPOSITORY_JSON="$repository_json" \ + EXPECTED_ACTOR_ID="$EXPECTED_ACTOR_ID" \ + EXPECTED_REPOSITORY="$EXPECTED_REPOSITORY" \ + EXPECTED_REPOSITORY_ID="$EXPECTED_REPOSITORY_ID" \ + EXPECTED_RUN_ID="$GITHUB_RUN_ID" \ + EXPECTED_RUN_ATTEMPT="$GITHUB_RUN_ATTEMPT" \ + EXPECTED_SHA="$EXPECTED_SOURCE_SHA" \ + EXPECTED_WORKFLOW_ID="$EXPECTED_WORKFLOW_ID" \ + EXPECTED_WORKFLOW_NAME="$EXPECTED_WORKFLOW_NAME" \ + EXPECTED_WORKFLOW_PATH="$EXPECTED_WORKFLOW_PATH" node <<'NODE' + const { readFileSync } = require("node:fs"); + const positiveInteger = (name) => { + const value = process.env[name] ?? ""; + if (!/^[1-9][0-9]*$/u.test(value)) throw new Error(`Invalid ${name}`); + const number = Number(value); + if (!Number.isSafeInteger(number)) throw new Error(`Unsafe ${name}`); + return number; + }; + const attempt = JSON.parse(readFileSync(process.env.ATTEMPT_JSON, "utf8")); + const workflow = JSON.parse(readFileSync(process.env.WORKFLOW_JSON, "utf8")); + const repository = JSON.parse(readFileSync(process.env.REPOSITORY_JSON, "utf8")); + const actorId = positiveInteger("EXPECTED_ACTOR_ID"); + const repositoryId = positiveInteger("EXPECTED_REPOSITORY_ID"); + const runId = positiveInteger("EXPECTED_RUN_ID"); + const runAttempt = positiveInteger("EXPECTED_RUN_ATTEMPT"); + const workflowId = positiveInteger("EXPECTED_WORKFLOW_ID"); + if ( + attempt.id !== runId + || attempt.run_attempt !== runAttempt + || attempt.workflow_id !== workflowId + || attempt.name !== process.env.EXPECTED_WORKFLOW_NAME + || attempt.path !== process.env.EXPECTED_WORKFLOW_PATH + || attempt.event !== "workflow_dispatch" + || attempt.head_branch !== "main" + || attempt.head_sha !== process.env.EXPECTED_SHA + || attempt.status !== "in_progress" + || attempt.conclusion !== null + || attempt.actor?.id !== actorId + || attempt.actor?.type !== "User" + || attempt.triggering_actor?.id !== actorId + || attempt.triggering_actor?.type !== "User" + || attempt.repository?.id !== repositoryId + || attempt.repository?.full_name !== process.env.EXPECTED_REPOSITORY + || attempt.repository?.private !== false + || workflow.id !== workflowId + || workflow.name !== process.env.EXPECTED_WORKFLOW_NAME + || workflow.path !== process.env.EXPECTED_WORKFLOW_PATH + || workflow.state !== "active" + || repository.id !== repositoryId + || repository.full_name !== process.env.EXPECTED_REPOSITORY + || repository.visibility !== "public" + || repository.private !== false + || repository.default_branch !== "main" + ) throw new Error("Current npm staging attempt is not owner-authorized for this exact public workflow"); + NODE - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: "24" @@ -265,6 +363,159 @@ jobs: --registry=https://registry.npmjs.org test "$(npm --version)" = "11.19.0" [[ "$(node --version)" == v24.* ]] + - name: Reject another pending stable stage + env: + EXPECTED_VERSION: ${{ needs.verify.outputs.package_version }} + EXPECTED_WORKFLOW_ID: "344070109" + GH_TOKEN: ${{ github.token }} + RESOLVED_STAGE_VERSION: ${{ inputs.resolved_stage_version }} + run: | + set -euo pipefail + node <<'NODE' + const { spawnSync } = require("node:child_process"); + const expectedName = "@hraness/kb"; + const expectedVersion = process.env.EXPECTED_VERSION ?? ""; + const workflowId = process.env.EXPECTED_WORKFLOW_ID ?? ""; + const repository = process.env.GITHUB_REPOSITORY ?? ""; + const currentRunId = process.env.GITHUB_RUN_ID ?? ""; + const resolvedStageVersion = process.env.RESOLVED_STAGE_VERSION ?? ""; + const legacyStages = new Map([ + ["33269920554", Object.freeze({ + headSha: "e12d3fd05ffaa722ac1c43a8ecaa7d21fece679a", + runAttempt: 1, + version: "0.17.3", + })], + ]); + const maximum = BigInt(Number.MAX_SAFE_INTEGER); + const versionPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/u; + const parseVersion = (value, label) => { + const match = versionPattern.exec(value); + if (match === null) throw new Error(`${label} is not a stable semantic version`); + const parts = match.slice(1).map(BigInt); + if (parts.some((part) => part > maximum)) { + throw new Error(`${label} exceeds Number.MAX_SAFE_INTEGER`); + } + return parts; + }; + const compare = (left, right) => { + for (let index = 0; index < 3; index += 1) { + if (left[index] !== right[index]) return left[index] > right[index] ? 1 : -1; + } + return 0; + }; + const execute = (command, args, label) => { + const result = spawnSync(command, args, { + encoding: "utf8", + maxBuffer: 10 * 1024 * 1024, + }); + if (result.status !== 0 || result.error) { + throw new Error(`Could not read ${label}`); + } + return result.stdout; + }; + if ( + !/^[1-9][0-9]*$/u.test(workflowId) + || !/^[1-9][0-9]*$/u.test(currentRunId) + || repository !== "hraness/kb" + ) throw new Error("Stable-stage history identity is invalid"); + const current = parseVersion(expectedVersion, "Candidate version"); + const resolved = resolvedStageVersion === "" + ? null + : parseVersion(resolvedStageVersion, "Resolved prior stage version"); + const latestValue = JSON.parse(execute("npm", [ + "view", + expectedName, + "dist-tags.latest", + "--json", + "--registry=https://registry.npmjs.org", + ], "npm latest")); + if (typeof latestValue !== "string") throw new Error("npm latest is not a version string"); + const latest = parseVersion(latestValue, "npm latest"); + if (compare(current, latest) <= 0) { + throw new Error(`Candidate ${expectedVersion} is not newer than npm latest ${latestValue}`); + } + const runsPayload = JSON.parse(execute("gh", [ + "api", + "--method", "GET", + `/repos/${repository}/actions/workflows/${workflowId}/runs?event=workflow_dispatch&status=completed&branch=main&per_page=100`, + ], "completed npm-stage workflow runs")); + if ( + !runsPayload + || typeof runsPayload !== "object" + || !Number.isSafeInteger(runsPayload.total_count) + || runsPayload.total_count < 0 + || runsPayload.total_count > 100 + || !Array.isArray(runsPayload.workflow_runs) + || runsPayload.workflow_runs.length !== runsPayload.total_count + ) throw new Error("Completed npm-stage history exceeds the reviewed 100-run bound"); + let resolvedStageSeen = false; + for (const run of runsPayload.workflow_runs) { + if ( + !run + || typeof run !== "object" + || !Number.isSafeInteger(run.id) + || run.id <= 0 + || String(run.id) === currentRunId + || run.workflow_id !== Number(workflowId) + || run.event !== "workflow_dispatch" + || run.head_branch !== "main" + || run.status !== "completed" + ) throw new Error("Completed npm-stage history contains an invalid run"); + const jobsPayload = JSON.parse(execute("gh", [ + "api", + "--method", "GET", + `/repos/${repository}/actions/runs/${run.id}/jobs?filter=all&per_page=100`, + ], `jobs for npm-stage run ${run.id}`)); + if ( + !jobsPayload + || typeof jobsPayload !== "object" + || !Number.isSafeInteger(jobsPayload.total_count) + || jobsPayload.total_count < 0 + || jobsPayload.total_count > 100 + || !Array.isArray(jobsPayload.jobs) + || jobsPayload.jobs.length !== jobsPayload.total_count + ) throw new Error(`npm-stage run ${run.id} exceeds the reviewed 100-job bound`); + for (const job of jobsPayload.jobs) { + if (!job || typeof job !== "object" || typeof job.name !== "string") { + throw new Error(`npm-stage run ${run.id} contains an invalid job`); + } + if (job.conclusion !== "success" || !job.name.startsWith("Stage exact package")) continue; + const match = /^Stage exact package v((?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*))$/u.exec(job.name); + let stagedVersion; + if (match !== null) { + stagedVersion = match[1]; + } else { + const legacy = legacyStages.get(String(run.id)); + if ( + job.name !== "Stage exact package" + || legacy === undefined + || job.head_sha !== legacy.headSha + || job.run_attempt !== legacy.runAttempt + ) { + throw new Error(`Successful npm-stage run ${run.id} lacks a version-bound stage job`); + } + stagedVersion = legacy.version; + } + const staged = parseVersion(stagedVersion, `Staged version from run ${run.id}`); + if (compare(staged, latest) > 0) { + if ( + resolved !== null + && stagedVersion === resolvedStageVersion + && compare(staged, current) <= 0 + ) { + resolvedStageSeen = true; + continue; + } + throw new Error( + `Refusing to stage ${expectedVersion}: run ${run.id} already staged pending ${stagedVersion}`, + ); + } + } + } + if (resolved !== null && !resolvedStageSeen) { + throw new Error(`Resolved prior stage ${resolvedStageVersion} does not identify a blocking stage`); + } + NODE - name: Bind artifact reference env: ARTIFACT_NAME: ${{ needs.verify.outputs.artifact_name }} @@ -273,10 +524,18 @@ jobs: run: | set -euo pipefail if [[ ! "$EXPECTED_SOURCE_SHA" =~ ^[a-f0-9]{40}$ || \ - ! "$EXPECTED_VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-beta\.(0|[1-9][0-9]*))?$ ]]; then + ! "$EXPECTED_VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then echo "::error::Verified package identity is invalid" exit 1 fi + node -e ' + const version = process.env.EXPECTED_VERSION ?? ""; + const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/u.exec(version); + if (match === null || match.slice(1).some((part) => BigInt(part) > BigInt(Number.MAX_SAFE_INTEGER))) { + console.error("::error::Verified package version components exceed Number.MAX_SAFE_INTEGER"); + process.exit(1); + } + ' expected_artifact_name="npm-package-$EXPECTED_VERSION-$EXPECTED_SOURCE_SHA-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" if [[ "$ARTIFACT_NAME" != "$expected_artifact_name" ]]; then echo "::error::Verified artifact name is not bound to this run and attempt" @@ -286,7 +545,7 @@ jobs: uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: ${{ needs.verify.outputs.artifact_name }} - path: ${{ runner.temp }}/kb-npm-publish + path: ${{ runner.temp }}/kb-npm-stage - name: Rebind downloaded package id: artifact env: @@ -295,17 +554,25 @@ jobs: EXPECTED_VERSION: ${{ needs.verify.outputs.package_version }} run: | set -euo pipefail - artifact_directory="$RUNNER_TEMP/kb-npm-publish" + artifact_directory="$RUNNER_TEMP/kb-npm-stage" metadata="$artifact_directory/npm-pack.json" digest="$artifact_directory/npm-package.sha256" if [[ ! "$EXPECTED_SOURCE_SHA" =~ ^[a-f0-9]{40}$ ]]; then echo "::error::Verified source commit is invalid" exit 1 fi - if [[ ! "$EXPECTED_VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-beta\.(0|[1-9][0-9]*))?$ ]]; then - echo "::error::Verified package version is not stable SemVer or a beta. prerelease" + if [[ ! "$EXPECTED_VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo "::error::Verified package version is not stable semantic version" exit 1 fi + node -e ' + const version = process.env.EXPECTED_VERSION ?? ""; + const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/u.exec(version); + if (match === null || match.slice(1).some((part) => BigInt(part) > BigInt(Number.MAX_SAFE_INTEGER))) { + console.error("::error::Verified package version components exceed Number.MAX_SAFE_INTEGER"); + process.exit(1); + } + ' expected_tarball_name="hraness-kb-$EXPECTED_VERSION.tgz" if [[ "$EXPECTED_TARBALL_NAME" != "$expected_tarball_name" ]]; then echo "::error::Verified tarball name is unsafe or inconsistent" @@ -338,6 +605,7 @@ jobs: TARBALL="$tarball" node > "$rebound_output" <<'NODE' const { createHash } = require("node:crypto"); const { readFileSync, statSync } = require("node:fs"); + const { gunzipSync } = require("node:zlib"); const minimumFiles = 190; const maximumFiles = 210; const minimumPackedBytes = 950_000; @@ -405,7 +673,7 @@ jobs: || packageRecord.id !== `${expectedName}@${expectedVersion}` || packageRecord.version !== expectedVersion || packageRecord.filename !== expectedFilename - || !/^hraness-kb-(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-beta\.(0|[1-9][0-9]*))?\.tgz$/u.test(packageRecord.filename) + || !/^hraness-kb-(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.tgz$/u.test(packageRecord.filename) ) { throw new Error("npm-pack.json has the wrong package identity or filename"); } @@ -483,6 +751,134 @@ jobs: if (packageRecord.integrity !== integrity || packageRecord.shasum !== shasum) { throw new Error("Downloaded tarball differs from npm-pack.json SHA-1 or SHA-512"); } + const tar = gunzipSync(archiveBytes, { + maxOutputLength: maximumUnpackedBytes + maximumFiles * 1_024 + 1_024, + }); + const tarText = (header, start, length) => { + const field = header.subarray(start, start + length); + const zero = field.indexOf(0); + const selected = zero < 0 ? field : field.subarray(0, zero); + if (selected.some(byte => byte > 0x7f)) { + throw new Error("Packed package.json tar header is not ASCII"); + } + return selected.toString("ascii"); + }; + const tarOctal = (header, start, length, label) => { + const text = tarText(header, start, length).trim(); + if (!/^[0-7]+$/u.test(text)) { + throw new Error(`Packed package.json has an invalid ${label}`); + } + const value = Number.parseInt(text, 8); + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`Packed package.json has an unsafe ${label}`); + } + return value; + }; + let offset = 0; + let zeroBlocks = 0; + let entries = 0; + let manifestBytes; + const ustarSignature = Buffer.from([0x75, 0x73, 0x74, 0x61, 0x72, 0x00, 0x30, 0x30]); + while (offset < tar.length) { + if (tar.length - offset < 512) { + throw new Error("Packed package.json tar header is truncated"); + } + const header = tar.subarray(offset, offset + 512); + offset += 512; + if (header.every(byte => byte === 0)) { + zeroBlocks += 1; + if (zeroBlocks === 2) { + if (tar.subarray(offset).some(byte => byte !== 0)) { + throw new Error("Packed package.json tar has trailing data"); + } + break; + } + continue; + } + if (zeroBlocks !== 0) { + throw new Error("Packed package.json tar terminator is invalid"); + } + const expectedChecksum = tarOctal(header, 148, 8, "checksum"); + let checksum = 0; + for (let index = 0; index < header.length; index += 1) { + checksum += index >= 148 && index < 156 ? 0x20 : header[index]; + } + if ( + checksum !== expectedChecksum + || !header.subarray(257, 265).equals(ustarSignature) + ) { + throw new Error("Packed package.json tar header is invalid"); + } + const name = tarText(header, 0, 100); + const prefix = tarText(header, 345, 155); + const path = prefix === "" ? name : `${prefix}/${name}`; + if ( + path.startsWith("/") + || path.includes("\\") + || path.split("/").some(part => part === "" || part === "." || part === "..") + ) { + throw new Error("Packed package.json tar path is unsafe"); + } + const size = tarOctal(header, 124, 12, "size"); + const type = header[156]; + if (type !== 0 && type !== 0x30 && type !== 0x35) { + throw new Error("Packed package.json tar contains a non-regular entry"); + } + if (type === 0x35 && size !== 0) { + throw new Error("Packed package.json tar directory has data"); + } + if (size > tar.length - offset) { + throw new Error("Packed package.json tar entry is truncated"); + } + const padded = Math.ceil(size / 512) * 512; + if ( + padded > tar.length - offset + || tar.subarray(offset + size, offset + padded).some(byte => byte !== 0) + ) { + throw new Error("Packed package.json tar padding is invalid"); + } + if (path === "package/package.json") { + if ( + (type !== 0 && type !== 0x30) + || manifestBytes !== undefined + || size < 1 + || size > 256 * 1024 + ) { + throw new Error("Packed package.json is missing, duplicated, or unsafe"); + } + manifestBytes = Buffer.from(tar.subarray(offset, offset + size)); + } + offset += padded; + entries += 1; + if (entries > maximumFiles * 2) { + throw new Error("Packed package.json tar contains too many entries"); + } + } + if (zeroBlocks !== 2 || manifestBytes === undefined) { + throw new Error("Packed package.json is missing"); + } + const manifest = JSON.parse( + new TextDecoder("utf-8", { fatal: true }).decode(manifestBytes), + ); + const publishConfig = manifest?.publishConfig; + if ( + manifest === null + || typeof manifest !== "object" + || Array.isArray(manifest) + || manifest.name !== expectedName + || manifest.version !== expectedVersion + || manifest.private === true + || manifest.contentPolicy?.class !== "dual-use" + || Object.hasOwn(manifest, "tag") + || publishConfig === null + || typeof publishConfig !== "object" + || Array.isArray(publishConfig) + || JSON.stringify(Object.keys(publishConfig).sort()) !== JSON.stringify(["access", "registry"]) + || publishConfig.access !== "public" + || publishConfig.registry !== "https://registry.npmjs.org" + ) { + throw new Error("Packed KB can publish only with the canonical npm registry and dist-tag policy"); + } process.stdout.write(`${archiveSha256} ${metadataSha256} ${digestSha256}\n`); NODE read -r rebound_archive_sha256 rebound_metadata_sha256 rebound_digest_sha256 < "$rebound_output" @@ -500,7 +896,7 @@ jobs: >> "$GITHUB_OUTPUT" printf 'tarball=%s\nmetadata=%s\ndigest=%s\n' \ "$tarball" "$metadata" "$digest" >> "$GITHUB_OUTPUT" - - name: Revalidate current main, publish, and verify registry readback + - name: Revalidate current main and stage exact package env: DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} DIGEST: ${{ steps.artifact.outputs.digest }} @@ -510,47 +906,78 @@ jobs: EXPECTED_SOURCE_SHA: ${{ needs.verify.outputs.source_sha }} EXPECTED_VERSION: ${{ needs.verify.outputs.package_version }} METADATA: ${{ steps.artifact.outputs.metadata }} - PUBLISH_TAG: ${{ needs.verify.outputs.publish_tag }} TARBALL: ${{ steps.artifact.outputs.tarball }} run: | set -euo pipefail git check-ref-format "refs/heads/$DEFAULT_BRANCH" - if [[ ! "$EXPECTED_VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-beta\.(0|[1-9][0-9]*))?$ ]]; then - echo "::error::Verified package version is not stable SemVer or a beta. prerelease" - exit 1 - fi - expected_publish_tag=latest - if [[ "$EXPECTED_VERSION" == *-beta.* ]]; then - expected_publish_tag=beta - fi - if [[ "$PUBLISH_TAG" != "$expected_publish_tag" ]]; then - echo "::error::Verified package channel $PUBLISH_TAG does not match $EXPECTED_VERSION" + if [[ ! "$EXPECTED_VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo "::error::Verified package version is not stable semantic version" exit 1 fi + node -e ' + const version = process.env.EXPECTED_VERSION ?? ""; + const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/u.exec(version); + if (match === null || match.slice(1).some((part) => BigInt(part) > BigInt(Number.MAX_SAFE_INTEGER))) { + console.error("::error::Verified package version components exceed Number.MAX_SAFE_INTEGER"); + process.exit(1); + } + ' release_tag="v$EXPECTED_VERSION" - release_ref="refs/tags/$release_tag" - local_release_ref="refs/owner-release-tags/$release_tag" - git check-ref-format "$release_ref" + git check-ref-format "refs/tags/$release_tag" current_main="$RUNNER_TEMP/kb-current-main.git" git init --quiet --bare "$current_main" git --git-dir="$current_main" fetch --quiet --no-tags --depth=1 \ "https://github.com/$GITHUB_REPOSITORY.git" \ "refs/heads/$DEFAULT_BRANCH" current_default_sha="$(git --git-dir="$current_main" rev-parse FETCH_HEAD)" - if [[ "$GITHUB_REF" != "$release_ref" || \ + if [[ "$GITHUB_REF" != "refs/heads/$DEFAULT_BRANCH" || \ "$GITHUB_SHA" != "$EXPECTED_SOURCE_SHA" || \ "$GITHUB_SHA" != "$current_default_sha" ]]; then echo "::error::$DEFAULT_BRANCH advanced to $current_default_sha after artifact verification" exit 1 fi - git --git-dir="$current_main" fetch --quiet --no-tags \ + tag_lookup_output="$RUNNER_TEMP/kb-stage-tag-lookup.txt" + if git ls-remote --exit-code --refs \ "https://github.com/$GITHUB_REPOSITORY.git" \ - "$release_ref:$local_release_ref" - release_commit="$(git --git-dir="$current_main" rev-parse "$local_release_ref^{commit}")" - if [[ "$release_commit" != "$EXPECTED_SOURCE_SHA" ]]; then - echo "::error::Owner-created release tag identifies $release_commit instead of $EXPECTED_SOURCE_SHA" + "refs/tags/$release_tag" > "$tag_lookup_output"; then + echo "::error::Tag $release_tag was created after package verification" exit 1 + else + tag_lookup_status=$? + if [[ "$tag_lookup_status" -ne 2 || -s "$tag_lookup_output" ]]; then + echo "::error::Could not prove that tag $release_tag is still absent from origin" + exit 1 + fi fi + current_latest="$(npm view "@hraness/kb" dist-tags.latest \ + --json \ + --registry=https://registry.npmjs.org)" + CURRENT_LATEST="$current_latest" node -e ' + const pattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/u; + const maximum = BigInt(Number.MAX_SAFE_INTEGER); + const parse = (value, label) => { + const match = pattern.exec(value); + if (match === null) throw new Error(`${label} is not a stable semantic version`); + const parts = match.slice(1).map(BigInt); + if (parts.some((part) => part > maximum)) { + throw new Error(`${label} exceeds Number.MAX_SAFE_INTEGER`); + } + return parts; + }; + const compare = (left, right) => { + for (let index = 0; index < 3; index += 1) { + if (left[index] !== right[index]) return left[index] > right[index] ? 1 : -1; + } + return 0; + }; + const candidate = parse(process.env.EXPECTED_VERSION ?? "", "Candidate version"); + const latestValue = JSON.parse(process.env.CURRENT_LATEST ?? "null"); + if (typeof latestValue !== "string") throw new Error("npm latest is not a version string"); + const latest = parse(latestValue, "npm latest"); + if (compare(candidate, latest) <= 0) { + throw new Error(`Candidate ${process.env.EXPECTED_VERSION} is not newer than npm latest ${latestValue}`); + } + ' current_archive_sha256="$(sha256sum "$TARBALL" | cut -d ' ' -f 1)" current_metadata_sha256="$(sha256sum "$METADATA" | cut -d ' ' -f 1)" current_digest_sha256="$(sha256sum "$DIGEST" | cut -d ' ' -f 1)" @@ -560,80 +987,32 @@ jobs: echo "::error::Downloaded package changed after current-main verification" exit 1 fi - package_spec="@hraness/kb@$EXPECTED_VERSION" - registry_dist="$RUNNER_TEMP/kb-published-dist.json" - registry_tag="$RUNNER_TEMP/kb-published-tag.json" - version_error="$RUNNER_TEMP/kb-published-version-error.txt" - if npm view "$package_spec" version --json \ - --registry=https://registry.npmjs.org >/dev/null 2>"$version_error"; then - echo "::notice::$package_spec is already public; verifying exact idempotent readback" - else - version_status=$? - if [[ "$version_status" -ne 1 ]] || ! grep -q E404 "$version_error"; then - cat "$version_error" - echo "::error::Could not determine whether $package_spec is already public" - exit 1 - fi - npm publish "$TARBALL" \ - --access public \ - --ignore-scripts \ - --provenance \ - --tag "$PUBLISH_TAG" \ - --registry=https://registry.npmjs.org - fi - readback_verified=false - for attempt in {1..12}; do - if npm view "$package_spec" dist --json \ - --registry=https://registry.npmjs.org > "$registry_dist" \ - && npm view "@hraness/kb" "dist-tags.$PUBLISH_TAG" --json \ - --registry=https://registry.npmjs.org > "$registry_tag" \ - && EXPECTED_VERSION="$EXPECTED_VERSION" \ - METADATA="$METADATA" \ - PUBLISH_TAG="$PUBLISH_TAG" \ - REGISTRY_DIST="$registry_dist" \ - REGISTRY_TAG="$registry_tag" node <<'NODE' - const { readFileSync } = require("node:fs"); - const expectedVersion = process.env.EXPECTED_VERSION; - const metadataPath = process.env.METADATA; - const publishTag = process.env.PUBLISH_TAG; - const registryDistPath = process.env.REGISTRY_DIST; - const registryTagPath = process.env.REGISTRY_TAG; - if (!expectedVersion || !metadataPath || !publishTag || !registryDistPath || !registryTagPath) { - throw new Error("Registry readback environment is incomplete"); - } - const metadata = JSON.parse(readFileSync(metadataPath, "utf8")); - const expected = Array.isArray(metadata) ? metadata[0] : undefined; - const dist = JSON.parse(readFileSync(registryDistPath, "utf8")); - const observedTag = JSON.parse(readFileSync(registryTagPath, "utf8")); - if (!expected || typeof expected !== "object" || !dist || typeof dist !== "object") { - throw new Error("Registry readback metadata is invalid"); - } - if ( - dist.integrity !== expected.integrity - || dist.shasum !== expected.shasum - || dist.fileCount !== expected.entryCount - || dist.unpackedSize !== expected.unpackedSize - ) throw new Error("Registry package differs from the reviewed artifact"); - const expectedTarball = `https://registry.npmjs.org/@hraness/kb/-/kb-${expectedVersion}.tgz`; - if (dist.tarball !== expectedTarball || observedTag !== expectedVersion) { - throw new Error(`Registry ${publishTag} channel does not identify the reviewed package`); - } - if ( - !dist.attestations - || typeof dist.attestations.url !== "string" - || !dist.attestations.url.startsWith("https://registry.npmjs.org/-/npm/v1/attestations/") - || dist.attestations.provenance?.predicateType !== "https://slsa.dev/provenance/v1" - || !Array.isArray(dist.signatures) - || dist.signatures.length < 1 - ) throw new Error("Registry readback is missing signatures or SLSA provenance"); - NODE - then - readback_verified=true - break - fi - if [[ "$attempt" -lt 12 ]]; then sleep 5; fi - done - if [[ "$readback_verified" != true ]]; then - echo "::error::Could not verify exact npm registry publication, channel, signatures, and provenance" + node -e ' + const conflicting = Object.keys(process.env) + .filter((name) => name.toLowerCase() === "npm_config_tag"); + if (conflicting.length !== 0) { + throw new Error(`Ambient npm tag configuration is forbidden: ${conflicting.join(", ")}`); + } + ' + clean_npm_directory="$RUNNER_TEMP/kb-npm-stage-clean" + clean_user_config="$RUNNER_TEMP/kb-npm-stage-user.npmrc" + clean_global_config="$RUNNER_TEMP/kb-npm-stage-global.npmrc" + mkdir "$clean_npm_directory" + : > "$clean_user_config" + : > "$clean_global_config" + chmod 700 "$clean_npm_directory" + chmod 600 "$clean_user_config" "$clean_global_config" + if [[ "$(cd "$clean_npm_directory" && npm config get tag \ + --userconfig="$clean_user_config" \ + --globalconfig="$clean_global_config")" != latest ]]; then + echo "::error::Pinned npm's clean default publication tag is not latest" exit 1 fi + cd "$clean_npm_directory" + npm stage publish "$TARBALL" \ + --access public \ + --globalconfig="$clean_global_config" \ + --ignore-scripts \ + --provenance \ + --registry=https://registry.npmjs.org \ + --userconfig="$clean_user_config" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 38f02a5..5c032c7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -110,6 +110,13 @@ jobs: echo "::error::Release tag $release_tag is not a stable semantic version" exit 1 fi + RELEASE_VERSION="${release_tag#v}" node -e ' + const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/u.exec(process.env.RELEASE_VERSION ?? ""); + if (match === null || match.slice(1).some((part) => BigInt(part) > BigInt(Number.MAX_SAFE_INTEGER))) { + console.error("::error::Release version components must not exceed Number.MAX_SAFE_INTEGER"); + process.exit(1); + } + ' git check-ref-format "refs/tags/$release_tag" release_ref="refs/kb-release-tags/$release_tag" git fetch --no-tags origin "refs/tags/$release_tag:$release_ref" @@ -159,10 +166,35 @@ jobs: echo "::error::Tag $release_tag changed during identity verification" exit 1 fi - newest_stable_tag="$(git tag --list 'v*' \ - | grep -E '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' \ - | LC_ALL=C sort -V \ - | tail -n 1)" + newest_stable_tag="$(git tag --list 'v*' | node -e ' + const { readFileSync } = require("node:fs"); + const pattern = /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/u; + const maximum = BigInt(Number.MAX_SAFE_INTEGER); + const parse = (tag) => { + const match = pattern.exec(tag); + if (match === null) return null; + const parts = match.slice(1).map(BigInt); + if (parts.some((part) => part > maximum)) { + console.error("::error::Stable version components exceed Number.MAX_SAFE_INTEGER: " + tag); + process.exit(1); + } + return parts; + }; + const compare = (left, right) => { + for (let index = 0; index < 3; index += 1) { + if (left[index] !== right[index]) return left[index] > right[index] ? 1 : -1; + } + return 0; + }; + let newest = null; + for (const tag of readFileSync(0, "utf8").split(/\r?\n/u)) { + const parts = parse(tag); + if (parts !== null && (newest === null || compare(parts, newest.parts) > 0)) { + newest = { parts, tag }; + } + } + process.stdout.write(newest?.tag ?? ""); + ')" if [[ "$newest_stable_tag" != "$release_tag" ]]; then echo "::error::Tag $release_tag is not the newest stable tag $newest_stable_tag" exit 1 @@ -212,6 +244,7 @@ jobs: - name: Verify canonical npm delivery env: EXPECTED_NAME: "@hraness/kb" + EXPECTED_SOURCE_SHA: ${{ steps.identity.outputs.source_sha }} EXPECTED_VERSION: ${{ steps.identity.outputs.tag }} SOURCE_TREE: ${{ steps.source.outputs.tree }} WORKFLOW_SHA: ${{ steps.identity.outputs.workflow_sha }} @@ -232,12 +265,18 @@ jobs: registry_archive="$registry_directory/$tarball_name" registry_pack_json="$registry_directory/npm-pack.json" registry_view_json="$registry_directory/npm-view.json" + registry_latest_json="$registry_directory/npm-latest.json" + registry_audit_json="$registry_directory/npm-audit-signatures.json" + audit_directory="$RUNNER_TEMP/kb-release-signature-audit" registry_ready=false for registry_poll in {1..60}; do published_version="$(npm view "$package_spec" version --json \ --registry=https://registry.npmjs.org 2>/dev/null || true)" - if [[ "$published_version" == "\"$package_version\"" ]]; then + published_latest="$(npm view "$EXPECTED_NAME" dist-tags.latest --json \ + --registry=https://registry.npmjs.org 2>/dev/null || true)" + if [[ "$published_version" == "\"$package_version\"" && \ + "$published_latest" == "\"$package_version\"" ]]; then registry_ready=true break fi @@ -255,6 +294,7 @@ jobs: current_tools=( scripts/package-artifact.ts scripts/npm-package-identity.ts + scripts/npm-release-attestation.ts scripts/package-smoke.ts scripts/prepare-npm-package.ts ) @@ -273,6 +313,7 @@ jobs: done current_prepare="$GITHUB_WORKSPACE/scripts/prepare-npm-package.ts" current_identity="$GITHUB_WORKSPACE/scripts/npm-package-identity.ts" + current_attestation="$GITHUB_WORKSPACE/scripts/npm-release-attestation.ts" current_smoke="$GITHUB_WORKSPACE/scripts/package-smoke.ts" ( @@ -291,6 +332,10 @@ jobs: --json \ --registry=https://registry.npmjs.org \ > "$registry_view_json" + npm view "$EXPECTED_NAME" dist-tags.latest \ + --json \ + --registry=https://registry.npmjs.org \ + > "$registry_latest_json" bun --no-env-file --config=/dev/null run "$current_identity" \ --expected-name "$EXPECTED_NAME" \ --expected-version "$package_version" \ @@ -306,10 +351,53 @@ jobs: --pack-json "$registry_pack_json" ) + if [[ -e "$audit_directory" ]]; then + echo "::error::npm signature audit directory already exists" + exit 1 + fi + mkdir -p "$audit_directory" + AUDIT_DIRECTORY="$audit_directory" node <<'NODE' + const { writeFileSync } = require("node:fs"); + const { join } = require("node:path"); + writeFileSync(join(process.env.AUDIT_DIRECTORY, "package.json"), `${JSON.stringify({ + name: "kb-release-signature-audit", + private: true, + version: "0.0.0", + }, null, 2)}\n`, { encoding: "utf8", flag: "wx" }); + NODE + ( + cd "$audit_directory" + npm install "$package_spec" \ + --ignore-scripts \ + --no-audit \ + --no-fund \ + --package-lock=false \ + --save-exact \ + --registry=https://registry.npmjs.org + npm audit signatures \ + --json \ + --include-attestations \ + --registry=https://registry.npmjs.org \ + > "$registry_audit_json" + ) + registry_tarball_sha512="$(node -e ' + const { createHash } = require("node:crypto"); + const { readFileSync } = require("node:fs"); + process.stdout.write(createHash("sha512").update(readFileSync(process.argv[1])).digest("hex")); + ' "$registry_archive")" + bun --no-env-file --config=/dev/null run "$current_attestation" \ + --audit-json "$registry_audit_json" \ + --expected-source-sha "$EXPECTED_SOURCE_SHA" \ + --expected-tarball-sha512 "$registry_tarball_sha512" \ + --expected-version "$package_version" \ + --registry-latest-json "$registry_latest_json" \ + --registry-view-json "$registry_view_json" + publish: name: Publish needs: verify permissions: + actions: read contents: write runs-on: ubuntu-latest timeout-minutes: 5 @@ -321,6 +409,93 @@ jobs: VERIFIED_TAG: ${{ needs.verify.outputs.verified_tag }} WORKFLOW_SHA: ${{ needs.verify.outputs.workflow_sha }} steps: + - name: Reauthorize current release attempt + env: + EXPECTED_ACTOR_ID: "894119" + EXPECTED_REPOSITORY: "hraness/kb" + EXPECTED_REPOSITORY_ID: "1308971873" + EXPECTED_WORKFLOW_ID: "320004141" + EXPECTED_WORKFLOW_NAME: "Release" + EXPECTED_WORKFLOW_PATH: ".github/workflows/release.yml" + run: | + set -euo pipefail + if [[ ! "$GITHUB_RUN_ID" =~ ^[1-9][0-9]*$ || \ + ! "$GITHUB_RUN_ATTEMPT" =~ ^[1-9][0-9]*$ || \ + "$GITHUB_EVENT_NAME" != push || \ + "$GITHUB_REPOSITORY" != "$EXPECTED_REPOSITORY" || \ + "$GITHUB_REPOSITORY_ID" != "$EXPECTED_REPOSITORY_ID" || \ + "$GITHUB_REF" != "refs/tags/$VERIFIED_TAG" ]]; then + echo "::error::Current release attempt is not the exact protected tag run" + exit 1 + fi + attempt_json="$(mktemp "$RUNNER_TEMP/kb-release-attempt.XXXXXX")" + workflow_json="$(mktemp "$RUNNER_TEMP/kb-release-workflow.XXXXXX")" + repository_json="$(mktemp "$RUNNER_TEMP/kb-release-repository.XXXXXX")" + gh api --method GET \ + "/repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/attempts/$GITHUB_RUN_ATTEMPT" \ + > "$attempt_json" + gh api --method GET \ + "/repos/$GITHUB_REPOSITORY/actions/workflows/$EXPECTED_WORKFLOW_ID" \ + > "$workflow_json" + gh api --method GET "/repos/$GITHUB_REPOSITORY" > "$repository_json" + ATTEMPT_JSON="$attempt_json" \ + WORKFLOW_JSON="$workflow_json" \ + REPOSITORY_JSON="$repository_json" \ + EXPECTED_ACTOR_ID="$EXPECTED_ACTOR_ID" \ + EXPECTED_REPOSITORY="$EXPECTED_REPOSITORY" \ + EXPECTED_REPOSITORY_ID="$EXPECTED_REPOSITORY_ID" \ + EXPECTED_RUN_ID="$GITHUB_RUN_ID" \ + EXPECTED_RUN_ATTEMPT="$GITHUB_RUN_ATTEMPT" \ + EXPECTED_SHA="$VERIFIED_SOURCE_SHA" \ + EXPECTED_TAG="$VERIFIED_TAG" \ + EXPECTED_WORKFLOW_ID="$EXPECTED_WORKFLOW_ID" \ + EXPECTED_WORKFLOW_NAME="$EXPECTED_WORKFLOW_NAME" \ + EXPECTED_WORKFLOW_PATH="$EXPECTED_WORKFLOW_PATH" node <<'NODE' + const { readFileSync } = require("node:fs"); + const positiveInteger = (name) => { + const value = process.env[name] ?? ""; + if (!/^[1-9][0-9]*$/u.test(value)) throw new Error(`Invalid ${name}`); + const number = Number(value); + if (!Number.isSafeInteger(number)) throw new Error(`Unsafe ${name}`); + return number; + }; + const attempt = JSON.parse(readFileSync(process.env.ATTEMPT_JSON, "utf8")); + const workflow = JSON.parse(readFileSync(process.env.WORKFLOW_JSON, "utf8")); + const repository = JSON.parse(readFileSync(process.env.REPOSITORY_JSON, "utf8")); + const actorId = positiveInteger("EXPECTED_ACTOR_ID"); + const repositoryId = positiveInteger("EXPECTED_REPOSITORY_ID"); + const runId = positiveInteger("EXPECTED_RUN_ID"); + const runAttempt = positiveInteger("EXPECTED_RUN_ATTEMPT"); + const workflowId = positiveInteger("EXPECTED_WORKFLOW_ID"); + if ( + attempt.id !== runId + || attempt.run_attempt !== runAttempt + || attempt.workflow_id !== workflowId + || attempt.name !== process.env.EXPECTED_WORKFLOW_NAME + || attempt.path !== process.env.EXPECTED_WORKFLOW_PATH + || attempt.event !== "push" + || attempt.head_branch !== process.env.EXPECTED_TAG + || attempt.head_sha !== process.env.EXPECTED_SHA + || attempt.status !== "in_progress" + || attempt.conclusion !== null + || attempt.actor?.id !== actorId + || attempt.actor?.type !== "User" + || attempt.triggering_actor?.id !== actorId + || attempt.triggering_actor?.type !== "User" + || attempt.repository?.id !== repositoryId + || attempt.repository?.full_name !== process.env.EXPECTED_REPOSITORY + || attempt.repository?.private !== false + || workflow.id !== workflowId + || workflow.name !== process.env.EXPECTED_WORKFLOW_NAME + || workflow.path !== process.env.EXPECTED_WORKFLOW_PATH + || workflow.state !== "active" + || repository.id !== repositoryId + || repository.full_name !== process.env.EXPECTED_REPOSITORY + || repository.visibility !== "public" + || repository.private !== false + || repository.default_branch !== "main" + ) throw new Error("Current release attempt is not owner-authorized for this exact public workflow"); + NODE - name: Publish verified GitHub Release run: | set -euo pipefail @@ -357,9 +532,16 @@ jobs: printf '%s\n' "$repository_tags" | NEXT_TAG="$VERIFIED_TAG" node -e ' const fs = require("node:fs"); const pattern = /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/u; + const maximum = BigInt(Number.MAX_SAFE_INTEGER); const parse = (tag) => { const match = pattern.exec(tag); - return match === null ? null : match.slice(1).map(BigInt); + if (match === null) return null; + const parts = match.slice(1).map(BigInt); + if (parts.some((part) => part > maximum)) { + console.error("::error::Stable version components exceed Number.MAX_SAFE_INTEGER: " + tag); + process.exit(1); + } + return parts; }; const compare = (left, right) => { for (let index = 0; index < 3; index += 1) { @@ -389,9 +571,16 @@ jobs: printf '%s\n' "$published_tags" | NEXT_TAG="$VERIFIED_TAG" node -e ' const fs = require("node:fs"); const pattern = /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/u; + const maximum = BigInt(Number.MAX_SAFE_INTEGER); const parse = (tag) => { const match = pattern.exec(tag); - return match === null ? null : match.slice(1).map(BigInt); + if (match === null) return null; + const parts = match.slice(1).map(BigInt); + if (parts.some((part) => part > maximum)) { + console.error("::error::Stable version components exceed Number.MAX_SAFE_INTEGER: " + tag); + process.exit(1); + } + return parts; }; const compare = (left, right) => { for (let index = 0; index < 3; index += 1) { @@ -411,21 +600,51 @@ jobs: } ' - if ! gh release view "$VERIFIED_TAG" >/dev/null 2>&1; then - gh release create "$VERIFIED_TAG" \ - --verify-tag \ - --generate-notes \ - --latest \ - --title "KB $VERIFIED_TAG" - fi - release_state="$(gh release view "$VERIFIED_TAG" \ - --json assets,isDraft,isImmutable,isPrerelease,tagName \ - --jq '[.tagName, .isDraft, .isPrerelease, .isImmutable, (.assets | length)] | @tsv')" - expected_state="$VERIFIED_TAG"$'\tfalse\tfalse\ttrue\t0' - if [[ "$release_state" != "$expected_state" ]]; then - echo "::error::Release $VERIFIED_TAG is not published and immutable" + verified_version="${VERIFIED_TAG#v}" + npm_latest="$(npm view "@hraness/kb" dist-tags.latest \ + --json \ + --registry=https://registry.npmjs.org)" + if [[ "$VERIFIED_TAG" != "v$verified_version" || \ + "$npm_latest" != "\"$verified_version\"" ]]; then + echo "::error::npm latest is $npm_latest, expected $verified_version" exit 1 fi + + printf -v expected_release_body \ + 'Automated immutable release for @hraness/kb@%s.\n\nSource commit: %s\nWorkflow run: %s' \ + "$verified_version" "$VERIFIED_SOURCE_SHA" "$GITHUB_RUN_ID" + release_json="$(mktemp "$RUNNER_TEMP/kb-release.XXXXXX")" + if ! gh release create "$VERIFIED_TAG" \ + --verify-tag \ + --latest \ + --title "KB $VERIFIED_TAG" \ + --notes "$expected_release_body"; then + echo "::notice::Release creation did not complete; validating exact idempotent state" + fi + gh api "/repos/$GITHUB_REPOSITORY/releases/tags/$VERIFIED_TAG" \ + > "$release_json" + RELEASE_JSON="$release_json" \ + EXPECTED_ACTIONS_BOT_ID="41898282" \ + EXPECTED_BODY="$expected_release_body" \ + EXPECTED_NAME="KB $VERIFIED_TAG" node <<'NODE' + const { readFileSync } = require("node:fs"); + const release = JSON.parse(readFileSync(process.env.RELEASE_JSON, "utf8")); + if ( + !release + || typeof release !== "object" + || release.tag_name !== process.env.VERIFIED_TAG + || release.name !== process.env.EXPECTED_NAME + || release.body !== process.env.EXPECTED_BODY + || release.draft !== false + || release.prerelease !== false + || release.immutable !== true + || !Array.isArray(release.assets) + || release.assets.length !== 0 + || release.author?.id !== Number(process.env.EXPECTED_ACTIONS_BOT_ID) + || release.author?.login !== "github-actions[bot]" + || release.author?.type !== "Bot" + ) throw new Error("GitHub Release is not the exact immutable artifact created by this authorized Actions run"); + NODE latest_tag="$(gh api "/repos/$GITHUB_REPOSITORY/releases/latest" --jq '.tag_name')" if [[ "$latest_tag" != "$VERIFIED_TAG" ]]; then echo "::error::Latest release is $latest_tag, expected $VERIFIED_TAG" diff --git a/AGENTS.md b/AGENTS.md index 5cdb64d..070105c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ - `kb/` – this source repository's authored rationale, maintained synthesis, and implementation plans; it is separate from the package's graph implementation and fixtures. - `WRITING.md` and `STYLE.md` – internal and public prose contracts. - `docs/` – design, capture, and agent-workflow documentation. -- `.github/workflows/` – read-only branch validation, stable-or-beta direct OIDC npm publication, and checks-gated immutable GitHub Release automation. +- `.github/workflows/` – read-only branch validation, stage-only OIDC npm delivery, and checks-gated immutable GitHub Release automation. - `portfolio-inventory.json`, `scripts/check-portfolio-inventory.ts`, and `scripts/check-installed-command-docs.ts` – canonical public package inventory and standalone public-command consistency gates. - `README.md`, `CONTRIBUTING.md`, `SECURITY.md`, and `LICENSE` – public usage, project policy, threat model, and terms. - `package.json`, `tsconfig.json`, and `bun.lock` – standalone package and frozen verification configuration. @@ -20,6 +20,7 @@ - Follow `WRITING.md` for internal prose and `STYLE.md` for public prose. - Follow the shared [Hraness README guidelines](https://github.com/hraness/.github/blob/main/README_GUIDELINES.md) for the README trust path and its website projection. Adapt the structure to KB's local-data and Agent Skill boundaries instead of copying a fixed template. - Apply unreasonably robust programming when agent work is cheap. Model invalid states out of existence, parse every foreign value from `unknown`, and pair readable deterministic regressions with property tests for parsing, resolution, ordering, path confinement, and round trips. +- Treat a stable package or release version as exactly three canonical decimal components in the inclusive range `0..Number.MAX_SAFE_INTEGER`. Reject larger components at selection, package preparation, artifact verification, attestation verification, and release ordering boundaries. - Deliver changes to `main` through a current-head pull request. Keep the stable `Required` CI job green, resolve every review thread, and serialize merges. Human approval stays optional while one regular maintainer would otherwise self-review. Never force-push or bypass the gate. - Pin Hraness dependencies to reviewed immutable releases or full commits. Never connect repositories through sibling paths, Git submodules, or coordinated `main` assumptions; upgrade each consumer independently. - Extract a shared package only after two concrete consumers require the same stable interface. Keep every shared package product-neutral and free of product imports. @@ -51,8 +52,8 @@ - Keep `portfolio-inventory.json` byte-canonical and consistent with the public package identity, version, repository, direct `@hraness/*` dependency edges, and Hraness-owned dependencies pinned by exact immutable GitHub specifiers. - Pair concrete behavior tests with property tests for parsing, resolution, ordering, path confinement, and round-trip laws. - Run `bun test src/benchmark.test.ts src/evaluation.test.ts src/evaluation-kb.test.ts src/search.test.ts src/sdk.test.ts` when changing rank fusion, retrieval defaults, frozen-corpus execution, or built-in evaluation adapters. The six-case synthetic rank-fusion fixture is a deterministic regression, not a retrieval-quality or performance benchmark. Keep real-corpus manifests versioned, judgments independent of rankings, raw lane evidence intact, and performance claims tied to named hardware and measured runs. Run `bun run check` before handing off a change; it must leave committed `dist/` and `bun.lock` unchanged. -- Follow `docs/publishing.md` for the historical interactive bootstrap and direct OIDC trusted publishing. After the exact active CI workflow's `push` run and `Required` job succeed on protected current `main`, an agent acting under standing release authorization runs `bun run ./scripts/push-npm-release-tag.ts ` with already-available owner `gh` and Git credentials. The script must keep authentication opaque; bind immutable owner `User` ID `894119`, public repository ID `1308971873`, the exact live `npm-stage` environment with administrator bypass disabled, sole `branch_policy` protection, and sole `v*` tag deployment policy, plus the exact package/current-main/active-CI run and attempt/Required job; enforce bounded monotonic stable or numeric `beta.N` remote-tag inventory; refuse conflicts and inherited local refs; and push only one exact annotated `v` ref. An exact same remote annotated tag and commit is an idempotent no-op. Missing authentication or ambiguous evidence fails closed. -- Use two exact active rulesets matching `refs/tags/v*`: **Immutable version tags** restricts update and deletion with an empty bypass list, while **Release tag creation** restricts creation and has owner `User` ID `894119` as its sole always-bypass actor. The local tag command must read back both before mutation. Never grant generic GitHub Actions integration ID `15368`, an administrator, a repository role, a team, or another integration this bypass; never combine creation with update/delete or create probe tags. Protected tag-push workflows must bind the actor and event sender to that owner and the exact public repository before checkout, require the annotated tag/package/source identity, keep source verification read-only, publish each unique version directly through the minimal OIDC job with its final initial tag (`latest` or `beta`), verify exact registry content/integrity/channel/signatures/provenance, and never use staged publishing or dist-tag promotion. Stable tags independently wait for npm before the immutable Latest Release; beta tags stop after npm readback. Trust a dedicated Release App only after its isolated credential and exact immutable App ID are configured explicitly. Never move a tag or republish npm, and finish one stable release before requesting the next because workflow concurrency is not a durable queue. +- Follow `docs/publishing.md` for the historical bootstrap and later releases. Trust only `.github/workflows/npm-stage.yml` with `npm stage publish` permission bound to the exact `npm-stage` environment. Keep that environment restricted solely to the selected default branch `main`, with administrator bypass disabled, no required deployment reviewers, and no secrets. Pushes and default dispatches must build and upload the exact candidate without OIDC; only an intentional current-main stable-train dispatch with boolean `publish_to_npm=true` may admit the minimal staging job. Its first step must use only `actions: read` plus `id-token: write` and reauthorize the current run attempt against owner `User` ID `894119`, both actor identities, active workflow ID/name/path, exact public repository ID `1308971873`, protected `main`, and the verified source SHA. Independently reject npm's packed top-level `tag` override and every noncanonical `publishConfig`, re-read public `latest`, and reject any successful version-bound Actions stage newer than `latest` before staging only the reviewed tarball through pinned npm's scrubbed clean default `latest`; do not pass an explicit tag because that disables npm's built-in higher-version guard. A rejected npm stage may release only its exact durable history lock through the exceptional owner-authorized `resolved_stage_version` input; leave that input empty normally. Disallow traditional publishing tokens and preserve `contentPolicy.class=dual-use` plus the root `DISCLOSURE` in every package. npm's separate public promotion remains human-gated by two-factor authentication; batch that unavoidable promotion into intentional stable releases. +- Use two exact active rulesets matching `refs/tags/v*`: **Immutable version tags** restricts update and deletion with an empty bypass list, while **Release tag creation** restricts creation and has owner `User` ID `894119` as its sole always-bypass actor. Never grant generic GitHub Actions integration ID `15368`, an administrator, a repository role, a team, or another integration this bypass; never combine creation with update/delete or create probe tags. Publish and verify the exact staged npm artifact first, approve its public promotion with human 2FA, then let the owner-authenticated operator create the exact annotated stable `v` tag on `main`. The protected tag workflow must bind the actor and event sender to owner `User` ID `894119` and public repository ID `1308971873` before checkout, then verify the tag, source, registry artifact, and immutable Latest Release. Before any GitHub Release mutation, require exact npm `dist-tags.latest`, nonempty canonical registry signatures, and pinned npm `11.19.0` cryptographic verification of the exact publish and SLSA provenance attestations, including the registry tarball SHA-512, staging workflow identity, public repository and owner IDs, sole main source commit, `workflow_dispatch` event, GitHub-hosted builder, and canonical invocation. Accept an existing Release only when its exact title and source/run receipt match this workflow and its creator is immutable `github-actions[bot]` ID `41898282`. Never move a tag, republish npm, or start a second stable release before the first completes. - Treat the user's request to change this repository as standing authorization for routine task-owned commits, pushes, pull requests, merges, releases, deployments, and production verification after the repository's required validation, review, identity, and rollout gates pass. Do not ask for another confirmation at each delivery step. diff --git a/docs/publishing.md b/docs/publishing.md index 03f411d..d425d95 100644 --- a/docs/publishing.md +++ b/docs/publishing.md @@ -1,15 +1,14 @@ # Publish KB -KB used an interactive first publication and now uses direct OIDC trusted -publishing for every later version. The interactive bootstrap remains here only -as history; routine beta and stable releases need no maintainer npm session, -one-time password, staging approval, or long-lived publishing token. +KB uses an interactive first publication and stage-only trusted publishing for +later versions. npm staged publishing cannot create a package name, so the +initial registry write follows a separate path. ## Bootstrap the npm package This section records the one-time `0.17.1` bootstrap. Do not reuse the interactive path for a later release; follow -[Publish a later version](#publish-a-later-version) instead. The bootstrap started +[Stage a later version](#stage-a-later-version) instead. The bootstrap started from the checked `main` commit with Node 24, npm 11.19.0, and Bun 1.3.14. The matching Git tag was created only after the public package was verified. @@ -43,16 +42,10 @@ matching Git tag was created only after the public package was verified. integrity before continuing. The smoke installs the exact archive with both Bun and npm, with lifecycle scripts disabled. -4. Publish the reviewed tarball with the signed-in maintainer session. - - ```sh - npm publish "$kb_npm_artifact/hraness-kb-0.17.1.tgz" \ - --access public \ - --ignore-scripts \ - --registry=https://registry.npmjs.org - ``` - - Complete npm's two-factor authentication prompt locally. Never put an npm +4. Historical bootstrap record: the signed-in maintainer published that exact + reviewed tarball and completed npm's two-factor authentication prompt. The + direct command is intentionally omitted because the package now exists and + later versions must use the stage-only workflow below. Never put an npm password, one-time password, recovery code, session cookie, or token in Git, a workflow, a task file, or chat. @@ -70,169 +63,165 @@ systems. ## Configure trusted publishing After the first version exists, configure one GitHub Actions trusted publisher -in the npm package settings. If a staged-publishing connection already exists, -delete and recreate it once because npm does not let a trusted publisher's -workflow, environment, or allowed action be edited in place: +in the npm package settings: - organization or owner: `hraness` - repository: `kb` - workflow filename: `npm-stage.yml` -- allowed action: direct `npm publish` (`--allow-publish`) -- environment: `npm-stage` (`--environment npm-stage`) - -Do not grant `--allow-stage` or any staged-publishing permission. Create the -`npm-stage` GitHub environment before enabling later publishing. Disable -administrator bypass and use custom deployment policies rather than -protected-branch admission. Its sole protection rule must be `branch_policy`, -with no required deployment reviewers; its sole deployment policy must be tag -pattern `v*`. A verified release can then reach npm without a second GitHub -approval. The npm trusted publisher must name that exact environment. Then +- allowed action: `npm stage publish` only +- environment: `npm-stage` + +Create the `npm-stage` GitHub environment before enabling later publishing +and disable administrator bypass. Its sole protection rule must be +`branch_policy`, and its sole deployment policy must be the selected branch +`main` with type `branch`. Configure no required deployment reviewers and no +environment secrets, so a verified version bump reaches npm staging without a +second GitHub approval only after an explicit release dispatch. Pushes and +default manual dispatches build and upload the candidate but cannot request the +OIDC staging job; current-main dispatch must set `publish_to_npm=true`. The npm +trusted publisher must name that exact environment. Then require publishing two-factor authentication and disallow traditional tokens. Do not add an npm publishing token to GitHub. Preserve `contentPolicy.class=dual-use` and the root `DISCLOSURE` in every package. -GitHub Actions may retain read-and-write workflow permissions for the stable -Release job's narrowly declared `contents: write` permission. No workflow gets -`contents: write` authority to create a tag or `actions: write` authority to -dispatch another workflow. Never give the generic GitHub Actions integration a -release-tag ruleset bypass: repository branch workflows can request a -write-capable `GITHUB_TOKEN`. - -Enable immutable releases in the repository settings before the first stable -OIDC release. The Release workflow requires GitHub's immutable readback; it does -not emulate immutability in workflow code. - -## Publish a later version - -1. Merge one unique, strictly increasing version to `main`. Stable versions use - `M.m.p`; beta versions use `M.m.p-beta.N`, with an increasing numeric `N`. - An agent operating under the repository's standing release authorization - then runs the checked local tag command from a clean current `main` checkout: - - ```sh - bun run ./scripts/push-npm-release-tag.ts - ``` +## Stage a later version - The command uses only already-available `gh` and Git credentials; it never - reads or prints a token. Before its first mutation it requires authenticated - immutable owner `User` ID `894119`, public repository ID `1308971873`, the - exact active **Release tag creation** and **Immutable version tags** rulesets, - and a - live `npm-stage` environment with administrator bypass disabled and only - the `v*` tag deployment policy, a clean exact protected current `main`, - matching package identity, the exact - active `.github/workflows/ci.yml`, its sole successful `push` run for that - commit and exact attempt, and that attempt's successful **Required** job. It - reads a bounded remote-tag inventory twice, enforces monotonic stable or beta - SemVer, refuses conflicting or inherited local refs, creates one annotated - `v` tag, and pushes only that exact ref. If the same annotated remote - tag already identifies the same commit, the command reports idempotent proof - and does nothing. Missing authentication or ambiguous evidence fails closed. +1. Merge a strictly increasing stable version to `main`. A `package.json` push + that changes the version automatically starts **Stage npm package** in + build-only mode. When + `package.json` changes but its version is unchanged, the selector exits + successfully before package verification or OIDC use. 2. Wait for the read-only verification job and inspect the uploaded artifact. It contains exactly the tarball, `npm-pack.json`, and `npm-package.sha256`, bound to the source commit, version, complete inventory, size, integrity, dual-use declaration, and disclosure. -3. The protected tag push starts the workflow. Its first job binds the push - actor and event sender to owner `User` ID `894119`, the immutable public - repository ID, and a protected tag before checkout. The minimal OIDC job - starts automatically after verification. Its exact - `npm-stage` environment allows only `v*` tags. The job - revalidates the artifact and current branch head, publishes the reviewed - tarball directly without a maintainer OTP, and polls the registry until - exact integrity, inventory, channel, signature, and SLSA provenance - readback succeeds. - Stable versions publish with `--tag latest`; beta versions publish with - `--tag beta`. Never use `npm dist-tag` promotion: a beta promoted to stable - is a new stable version and a new exact publication. -4. A stable protected tag also starts the Release workflow. It waits for exact - npm readback, independently rechecks the owner-created protected annotated - tag, npm delivery, and source, then creates the immutable GitHub Release. - Beta tags do not start the Release workflow. - -If the local command fails before creating the remote tag, update to current -`main` and rerun it. If a tag-bound workflow later fails transiently, rerun that -exact workflow run; never dispatch it against another ref or move the tag. -Never reuse a published version. If npm accepted the package but registry -readback timed out, verify that exact version and channel instead of rerunning -publication; recover only the later GitHub Release when necessary. +3. When the stable train is intentionally ready for npm staging, dispatch the + exact workflow from current `main` with the explicit opt-in: + + ```sh + gh workflow run npm-stage.yml --ref main -f publish_to_npm=true + ``` + + The run repeats candidate verification. Only then may the minimal OIDC job + start. Its exact `npm-stage` environment allows only `main`. Its first step, + before Node setup or artifact download, reauthorizes the current attempt + through GitHub's API. Both the original actor and triggering actor must be + owner `User` ID `894119`; the run must identify the active **Stage npm + package** workflow, public repository ID `1308971873`, protected `main`, the + exact verified source SHA, and the explicit true input. A collaborator + rerun, a missing or false input, a push, another branch, or a stale commit + cannot reach npm. + Before mutation, the job also reads the bounded completed-run history for + this exact workflow. Every successful staging job carries its stable + version in the provider-owned job record. If any such version is newer than + public `dist-tags.latest`, the new run stops, so workflow concurrency cannot + leave two independently approvable stable candidates after the first run + ends. The same final boundary re-reads `latest` and requires this candidate + to be strictly newer. The sole successful pre-versioned stage record is + sealed to run `33269920554`, attempt `1`, source + `e12d3fd05ffaa722ac1c43a8ecaa7d21fece679a`, and version `0.17.3`; every + later successful stage must carry its version in the Actions job name. +4. Batch the unavoidable human gate into an intentional stable release, then + inspect and approve the staged package through npm with two-factor + authentication. +5. Verify the public registry package in a clean consumer. +6. Create and push the matching annotated `v` tag on the same `main` + commit using the owner's existing local Git credential. The protected tag + workflow verifies owner and event-sender ID `894119`, public repository ID + `1308971873`, npm delivery, and exact source identity before it creates the + immutable GitHub Release. The release verification installs the exact + public package in an isolated directory with lifecycle scripts disabled and + runs pinned npm `11.19.0` `npm audit signatures --json + --include-attestations`. It requires a nonempty registry signature and the + canonical npm publish and SLSA provenance attestations for that tarball and + source commit. It also requires `dist-tags.latest` to equal the release + version and reads `latest` again immediately before any GitHub Release + mutation. A newly created or recovery Release must have the exact title and + run/source receipt written by this workflow and immutable creator + `github-actions[bot]` ID `41898282`; a collaborator-created release cannot + be accepted as successful delivery. + +If npm rejects a candidate, reject that exact staged version through npm first +(npm requires two-factor authentication for rejection). Then dispatch the +replacement from current `main` with `publish_to_npm=true` and +`resolved_stage_version=`. This owner-authorized exceptional +input releases only that matching Actions-history lock; leave it empty for all +normal releases. Approval needs no override because the promoted version +becomes public `latest` and releases the lock automatically. + +If candidate generation is missing or fails, dispatch **Stage npm package** from +current `main` without the opt-in. That recovery remains build-only. Use +`publish_to_npm=true` only for an intentional stable train after reviewing the +candidate. Every dispatch runs the same verification and main-branch checks. + +Stable semantic-version components are canonical decimal integers and may not +exceed `Number.MAX_SAFE_INTEGER`; selectors, package tools, provenance checks, +and release ordering all fail closed beyond that boundary. The verification job checks out source, installs dependencies without lifecycle scripts, runs the complete gate, creates the three-file artifact, -and smokes the exact tarball. Its dependent publishing job is the only job with -OIDC authority. The exact `npm-stage` environment restricts deployments to -`v*` tags, disables administrator bypass, and has no required reviewers, so the job starts -automatically after verification. It checks out no source and runs no -repository code. It rebinds +and smokes the exact tarball. Its dependent staging job is the only job with +OIDC authority. That job has only `actions: read` and `id-token: write`. The +exact `npm-stage` environment restricts deployments to `main` and has no +required reviewers, so an explicitly opted-in staging job +starts after verification without another GitHub approval. It checks out no +source and runs no repository code. It rebinds identity, filename, inventory, count, modes, sizes, SHA-1, SHA-512, and the independent SHA-256 manifest before mutation. Immediately -before publication, -it fetches current `main` into a new bare Git directory, then rehashes all -three files and invokes direct `npm publish` against -`https://registry.npmjs.org`. The registry readback must match the reviewed -artifact and intended `latest` or `beta` channel and must expose npm signatures -and SLSA provenance. - -Release selection is deliberately confined to the checked local tag script and -tag-bound workflow guards. The obsolete staged push/manual selection helper and -its tests were removed rather than retained as a second executable policy that -could drift into an alternate publication path. - -## Protect npm release tags without a sudo prompt - -Create two repository rulesets matching `refs/tags/v*`. Name **Immutable -version tags** restricts updates and deletions with an empty bypass list. Name -**Release tag creation** restricts creation and gives only immutable owner `User` ID -`894119` an always bypass. It grants no update or delete bypass and includes no -administrator, repository-role, team, deploy-key, or integration actor. Never -give GitHub Actions integration ID `15368` this bypass: any same-repository -branch workflow could otherwise mint a release tag. Do not combine the two -rules in one bypassable ruleset, and do not create throwaway or probe tags. -After this one-time ruleset setup, the owner's existing local Git credential can -push the script's exact annotated ref without routine GitHub sudo approval; -neither the credential nor an approval is stored in the repository. Publication -and Release require `github.ref_protected`, the exact owner/event sender and -public repository identity, annotated-tag identity, package version, and source -commit before any provider mutation. The local tag command reads back both exact -active rulesets and refuses any namespace, rule, enforcement, or bypass drift. - -For a larger maintainer group, replace the owner-local boundary with a dedicated -Release GitHub App only after its isolated credential and immutable installed -App ID are configured explicitly in the script/workflow and creation ruleset. -Do not use the generic Actions integration or trust an unconfigured/name-only -App. +before staging, +it independently parses the packed manifest, rejects npm's top-level `tag` +override, rejects an unresolved prior successful stage from the durable +Actions run history, fetches current `main` into a new bare Git directory, +then rehashes all three files and invokes only `npm stage publish` against +`https://registry.npmjs.org`. It rejects ambient tag configuration, runs from +an empty directory with empty user/global npm config, and proves pinned npm's +clean default `latest` before invocation. Leaving the tag implicit preserves +npm's own higher-version guard; the independently validated packed manifest +cannot override it. + +## Protect release tags without a sudo prompt + +Keep two active repository rulesets matching `refs/tags/v*`. **Immutable +version tags** restricts update and deletion with an empty bypass list. +**Release tag creation** restricts creation only and gives immutable owner +`User` ID `894119` the sole always-bypass entry. Do not grant the generic +GitHub Actions integration, an administrator, a repository role, a team, or +another integration this bypass, and never combine creation with update or +deletion. This one-time provider setup lets the already-authenticated owner +create the exact release tag under standing task authority without a routine +GitHub sudo approval. Never create probe tags, move a version tag, or tag before +the matching staged package has been promoted and independently verified. ## Recover an already-published release -If npm delivery succeeded but the GitHub Release job failed, keep the tag and -npm version immutable. Re-run the failed exact Release workflow run. Running -the local command with the same version is also a read-only proof: - -```sh -bun run ./scripts/push-npm-release-tag.ts 0.19.0 -``` - -The command idempotently accepts only the same exact owner-created annotated tag -and commit and performs no push. A publication rerun verifies the existing -package and registry channel without republishing it. The Release workflow -freshly resolves the stable repository tag, -requires its commit to remain reachable from current `main`, reads the name and -version from the tagged `package.json`, and +If npm delivery succeeded but the tag-triggered GitHub Release job failed, +keep the tag and npm version immutable and rerun that exact failed workflow +attempt. The workflow accepts only the newest stable repository tag. It freshly +resolves that tag from GitHub, requires its commit to remain reachable from +current `main`, reads the name and version from the tagged `package.json`, and checks and builds the tagged source in a detached worktree. That explicit tagged `bun run check` is the only historical build boundary. Afterward, the -workflow rebinds the release helpers to their reviewed Git blobs in the -tag-bound workflow checkout and invokes those files by absolute path while -retaining the detached tree as the package working directory. Bun loads no -working-tree config or environment file. The package step uses -`npm pack --ignore-scripts`, so it does +workflow rebinds the release helpers to their reviewed Git blobs in the current +workflow checkout and invokes those files by absolute path while retaining the +tagged tree as the package working directory. Bun loads no tag-owned config or +environment file. The package step uses `npm pack --ignore-scripts`, so it does not run the tag's `prepack` or another historical lifecycle script. The current helpers import their current core-only archive inspector. They do not import a script from the tagged tree. They compare the rebuilt package with the public -npm package by canonical content and registry metadata before the write-scoped -job creates the missing immutable Release. Recovery never moves the tag or +npm package by canonical content and registry metadata. The pinned signature +audit must cryptographically validate both registry and Sigstore evidence. The +decoded attestations must bind the downloaded tarball SHA-512 to the exact npm +publish predicate and to SLSA provenance for +`.github/workflows/npm-stage.yml` on `refs/heads/main`, `workflow_dispatch`, +repository ID `1308971873`, owner ID `307125679`, the sole source Git commit, a +GitHub-hosted builder, and a canonical Actions run-attempt URL. Only after that +read-only job succeeds can the write-scoped job reauthorize its own current +attempt, re-read the live tag, `main`, repository state, and npm `latest`, and +create the missing immutable Release. Recovery never moves the tag or republishes npm. See npm's documentation for [trusted -publishing](https://docs.npmjs.com/trusted-publishers/), [package -provenance](https://docs.npmjs.com/viewing-package-provenance/), and [dual-use +publishing](https://docs.npmjs.com/trusted-publishers/), [staged +publishing](https://docs.npmjs.com/staged-publishing/), and [dual-use content](https://docs.npmjs.com/policies/dual-use/). diff --git a/scripts/check-workflow-yaml.test.ts b/scripts/check-workflow-yaml.test.ts index c7a4be9..fa58757 100644 --- a/scripts/check-workflow-yaml.test.ts +++ b/scripts/check-workflow-yaml.test.ts @@ -4,22 +4,9 @@ import { readFile } from "node:fs/promises"; import { resolve } from "node:path"; import { - validateNpmPublishWorkflow, - validateOwnerTagWorkflow, + validateNpmStageWorkflow, validateWorkflowYaml, } from "./check-workflow-yaml.ts"; -import { - admitActiveCiWorkflow, - admitCiRequiredJob, - admitCiRun, - admitOwner, - admitReleaseEnvironment, - admitReleaseRulesets, - admitRemoteReleaseTags, - admitRemoteRoutes, - admitRepository, - parseReleaseVersion, -} from "./push-npm-release-tag.ts"; describe("GitHub workflow YAML", () => { test("accepts commands with YAML-significant text inside block scalars", () => { @@ -52,39 +39,51 @@ jobs: test("requires a fresh default-branch HEAD guard at the final publication boundary", async () => { const path = resolve(import.meta.dir, "../.github/workflows/npm-stage.yml"); const source = await readFile(path, "utf8"); - const finalGuard = 'git --git-dir="$current_main" fetch --quiet --no-tags --depth=1'; + const finalGuard = 'git --git-dir="$current_main" fetch'; const finalGuardIndex = source.lastIndexOf(finalGuard); expect(finalGuardIndex).toBeGreaterThan(-1); const missingFinalGuard = source.slice(0, finalGuardIndex) + "git status --short" + source.slice(finalGuardIndex + finalGuard.length); - expect(() => validateNpmPublishWorkflow(source, "npm-stage.yml")).not.toThrow(); - expect(() => validateNpmPublishWorkflow( + expect(() => validateNpmStageWorkflow(source, "npm-stage.yml")).not.toThrow(); + expect(() => validateNpmStageWorkflow( missingFinalGuard, "npm-stage.yml", )).toThrow("must recheck current default-branch HEAD"); }); - test("keeps npm publishing version-selected, environment-bound, tokenless, and artifact-bound", async () => { + test("keeps npm staging version-selected, environment-bound, tokenless, artifact-bound, and stage-only", async () => { const path = resolve(import.meta.dir, "../.github/workflows/npm-stage.yml"); const source = await readFile(path, "utf8"); for (const required of [ - 'tags:\n - "v*"', - "name: Authorize owner release tag", - 'EXPECTED_ACTOR_ID: "894119"', - 'EXPECTED_REPOSITORY_ID: "1308971873"', - "GITHUB_ACTOR_ID", - 'event.sender?.type !== "User"', - 'event.repository?.visibility !== "public"', - "name: Select publishable package version", - "github.ref_protected", + "push:", + "branches: [main]", + 'paths:\n - "package.json"', + "workflow_dispatch:", + "publish_to_npm:", + "resolved_stage_version:", + "required: false", + "default: false", + "type: boolean", + "name: Select stable package version", + "github.event.before", + "git merge-base --is-ancestor", + 'git show "$BEFORE_SHA:package.json"', + "scripts/npm-stage-selection.ts", "needs: select", - "if: needs.select.outputs.should_publish == 'true'", + "if: needs.select.outputs.should_stage == 'true'", "contents: read", "environment: npm-stage", + "if: inputs.publish_to_npm == true", + "actions: read", "id-token: write", + "Reauthorize current npm staging attempt", + "Reject another pending stable stage", + "Verified package version components exceed Number.MAX_SAFE_INTEGER", + 'EXPECTED_WORKFLOW_ID: "344070109"', + "attempt.triggering_actor?.id !== actorId", "runs-on: ubuntu-latest", "node-version: \"24\"", "package-manager-cache: false", @@ -98,9 +97,9 @@ jobs: "npm-package.sha256", "actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c", "git init --quiet --bare \"$current_main\"", - "npm publish \"$TARBALL\"", - "--tag \"$PUBLISH_TAG\"", - "dist.attestations.provenance?.predicateType", + "npm stage publish \"$TARBALL\"", + "npm config get tag", + "Pinned npm's clean default publication tag is not latest", "--registry=https://registry.npmjs.org", ] as const) { expect(source).toContain(required); @@ -108,184 +107,68 @@ jobs: expect(source).not.toContain("secrets.NPM_TOKEN"); expect(source).not.toContain("NODE_AUTH_TOKEN"); - expect(source).not.toMatch(/\bnpm\s+(?:stage|dist-tag)\b/u); - expect(source).not.toContain("workflow_dispatch:"); - expect(source).not.toContain("actions: write"); - expect(source).not.toContain("authorization_run_id"); + expect(source).not.toMatch(/\bnpm publish\b/u); + expect(source).not.toContain("--tag latest"); expect(source.match(/id-token: write/gu) ?? []).toHaveLength(1); - const publish = source.slice(source.indexOf("\n publish:\n")); - expect(publish).not.toContain("actions/checkout@"); - expect(publish).not.toContain("setup-bun@"); - expect(publish).not.toContain("./scripts/"); + expect(source.match(/Verified package version components exceed Number\.MAX_SAFE_INTEGER/gu) ?? []) + .toHaveLength(3); + const stage = source.slice(source.indexOf("\n stage:\n")); + expect(stage).not.toContain("actions/checkout@"); + expect(stage).not.toContain("setup-bun@"); + expect(stage).not.toContain("./scripts/"); }); - test("requires the exact environment and fail-closed owner identity", async () => { + test("requires the exact environment and fail-closed version selector", async () => { const path = resolve(import.meta.dir, "../.github/workflows/npm-stage.yml"); const source = await readFile(path, "utf8"); - expect(() => validateNpmPublishWorkflow( + expect(() => validateNpmStageWorkflow( source.replace("environment: npm-stage", "environment: unprotected"), "npm-stage.yml", )).toThrow("exact npm-stage environment"); - expect(() => validateNpmPublishWorkflow( + expect(() => validateNpmStageWorkflow( source.replace( - '"$GITHUB_ACTOR_ID" != "$EXPECTED_ACTOR_ID"', - '"$GITHUB_ACTOR_ID" == "$EXPECTED_ACTOR_ID"', + 'git show "$BEFORE_SHA:package.json"', + 'cp package.json "$previous_manifest"', ), "npm-stage.yml", - )).toThrow("owner authorization is missing"); - expect(() => validateNpmPublishWorkflow( - source.replace( - "event.sender?.id !== Number(process.env.EXPECTED_ACTOR_ID)", - "event.sender?.id !== 894120", - ), + )).toThrow("package-version selection is missing"); + expect(() => validateNpmStageWorkflow( + source.replace("default: false", "default: true"), "npm-stage.yml", - )).toThrow("owner authorization is missing"); - expect(() => validateNpmPublishWorkflow( + )).toThrow("fail-closed boolean publish_to_npm input"); + expect(() => validateNpmStageWorkflow( + source.replace('default: ""', 'default: "0.19.0"'), + "npm-stage.yml", + )).toThrow("empty-by-default resolved_stage_version"); + expect(() => validateNpmStageWorkflow( source.replace( - 'event.sender?.type !== "User"', - 'event.sender?.type !== "Bot"', + "if: inputs.publish_to_npm == true", + "if: always()", ), "npm-stage.yml", - )).toThrow("owner authorization is missing"); - expect(() => validateNpmPublishWorkflow( - source.replace('event.repository?.visibility !== "public"', 'event.repository?.visibility !== "private"'), - "npm-stage.yml", - )).toThrow("owner authorization is missing"); - expect(() => validateNpmPublishWorkflow( - source.replace("needs: authorize", "needs: untrusted"), + )).toThrow("explicit publish_to_npm opt-in"); + expect(() => validateNpmStageWorkflow( + source.replace(" actions: read\n id-token: write", " id-token: write"), "npm-stage.yml", - )).toThrow("select must follow owner authorization"); - }); - - test("requires exact owner actor and sender guards before release checkout", async () => { - const source = await readFile(resolve(import.meta.dir, "../.github/workflows/release.yml"), "utf8"); - expect(() => validateOwnerTagWorkflow(source, "release.yml")).not.toThrow(); - expect(() => validateOwnerTagWorkflow( + )).toThrow("actions: read and id-token: write"); + expect(() => validateNpmStageWorkflow( source.replace( - '"$GITHUB_ACTOR_ID" != "$EXPECTED_ACTOR_ID"', - '"$GITHUB_ACTOR_ID" == "$EXPECTED_ACTOR_ID"', + "attempt.triggering_actor?.id !== actorId", + "attempt.triggering_actor?.id !== 123456", ), - "release.yml", - )).toThrow("owner authorization is missing"); - expect(() => validateOwnerTagWorkflow( + "npm-stage.yml", + )).toThrow("staging attempt authorization is missing"); + expect(() => validateNpmStageWorkflow( + source.replace("npm config get tag", "npm config get fund"), + "npm-stage.yml", + )).toThrow("must recheck current default-branch HEAD"); + expect(() => validateNpmStageWorkflow( source.replace( - "event.sender?.id !== Number(process.env.EXPECTED_ACTOR_ID)", - "event.sender?.id !== 894120", + "BigInt(Number.MAX_SAFE_INTEGER)", + "BigInt(9007199254740992)", ), - "release.yml", - )).toThrow("owner authorization is missing"); - }); - - test("keeps local release-tag creation owner-scoped, CI-gated, monotonic, and exact-ref only", async () => { - const source = await readFile(resolve(import.meta.dir, "push-npm-release-tag.ts"), "utf8"); - for (const required of [ - "PROCESS_TIMEOUT_MS = 30_000", - "MAXIMUM_OUTPUT_BYTES = 1024 * 1024", - 'GH_PROMPT_DISABLED: "1", GIT_TERMINAL_PROMPT: "0"', - '["git", "remote", "get-url", "--push", "--all", "origin"]', - 'admitOwner(await jsonCommand(["gh", "api", "user"]', - "admitProtectedBranch(", - "admitActiveCiWorkflow(", - "admitCiRun(runInventory", - "admitCiRequiredJob(jobs", - "admitReleaseEnvironment(", - "admitReleaseRulesets(rulesetList, rulesetDetails)", - "repos/${EXPECTED_REPOSITORY}/rulesets", - "/deployment-branch-policies", - "const secondAdmission = admitRemoteReleaseTags", - "refusing an inherited tag object", - '["git", "tag", "--annotate"', - '`refs/tags/${release.tag}:refs/tags/${release.tag}`', - '["git", "update-ref", "-d", `refs/tags/${release.tag}`, createdTagObject]', - ]) expect(source).toContain(required); - const ciIndex = source.indexOf("const jobId = admitCiRequiredJob"); - const environmentIndex = source.indexOf(" admitReleaseEnvironment(\n await jsonCommand("); - const rulesetIndex = source.indexOf(" admitReleaseRulesets(rulesetList, rulesetDetails)"); - const monotonicIndex = source.indexOf("const secondAdmission = admitRemoteReleaseTags"); - const mutationIndex = source.indexOf('["git", "tag", "--annotate"'); - const pushIndex = source.indexOf('`refs/tags/${release.tag}:refs/tags/${release.tag}`'); - const compareDeleteIndex = source.indexOf('["git", "update-ref", "-d", `refs/tags/${release.tag}`, createdTagObject]'); - expect(ciIndex).toBeGreaterThan(-1); - expect(environmentIndex).toBeGreaterThan(-1); - expect(rulesetIndex).toBeGreaterThan(-1); - expect(monotonicIndex).toBeGreaterThan(ciIndex); - expect(mutationIndex).toBeGreaterThan(monotonicIndex); - expect(mutationIndex).toBeGreaterThan(environmentIndex); - expect(mutationIndex).toBeGreaterThan(rulesetIndex); - expect(pushIndex).toBeGreaterThan(mutationIndex); - expect(compareDeleteIndex).toBeGreaterThan(pushIndex); - - const sha = "a".repeat(40); - const otherSha = "b".repeat(40); - admitOwner({ id: 894119, type: "User" }); - admitRepository({ archived: false, default_branch: "main", disabled: false, full_name: "hraness/kb", id: 1308971873, private: false, visibility: "public" }); - const releaseEnvironment = { - can_admins_bypass: false, - deployment_branch_policy: { custom_branch_policies: true, protected_branches: false }, - name: "npm-stage", - protection_rules: [{ type: "branch_policy" }], - }; - const releasePolicies = { branch_policies: [{ name: "v*", type: "tag" }], total_count: 1 }; - expect(() => admitReleaseEnvironment(releaseEnvironment, releasePolicies)).not.toThrow(); - expect(() => admitReleaseEnvironment({ ...releaseEnvironment, can_admins_bypass: true }, releasePolicies)).toThrow("administrator bypass"); - expect(() => admitReleaseEnvironment({ - ...releaseEnvironment, - deployment_branch_policy: { custom_branch_policies: false, protected_branches: true }, - }, releasePolicies)).toThrow("branch_policy"); - expect(() => admitReleaseEnvironment({ - ...releaseEnvironment, - protection_rules: [{ type: "branch_policy" }, { type: "required_reviewers" }], - }, releasePolicies)).toThrow("only branch_policy"); - expect(() => admitReleaseEnvironment(releaseEnvironment, { - branch_policies: [{ name: "main", type: "branch" }], - total_count: 1, - })).toThrow("v* tag policy"); - const ruleset = (id: number, name: string, rules: readonly string[], bypassOwner = false) => ({ - bypass_actors: bypassOwner - ? [{ actor_id: 894119, actor_type: "User", bypass_mode: "always" }] - : [], - conditions: { ref_name: { exclude: [], include: ["refs/tags/v*"] } }, - enforcement: "active", - id, - name, - rules: rules.map((type) => ({ type })), - target: "tag", - }); - const rulesetList = [ - { id: 1, name: "Release tag creation" }, - { id: 2, name: "Immutable version tags" }, - ]; - const rulesetDetails = new Map([ - ["Release tag creation", ruleset(1, "Release tag creation", ["creation"], true)], - ["Immutable version tags", ruleset(2, "Immutable version tags", ["update", "deletion"])], - ]); - expect(() => admitReleaseRulesets(rulesetList, rulesetDetails)).not.toThrow(); - rulesetDetails.set("Release tag creation", { - ...ruleset(1, "Release tag creation", ["creation"], true), - bypass_actors: [{ actor_id: 15368, actor_type: "Integration", bypass_mode: "always" }], - }); - expect(() => admitReleaseRulesets(rulesetList, rulesetDetails)).toThrow("unexpected bypass authority"); - rulesetDetails.set("Release tag creation", ruleset(1, "Release tag creation", ["creation"], true)); - rulesetDetails.set("Immutable version tags", ruleset(2, "Immutable version tags", ["creation", "update", "deletion"])); - expect(() => admitReleaseRulesets(rulesetList, rulesetDetails)).toThrow("unexpected rules"); - expect(() => admitOwner({ id: 894119, type: "Bot" })).toThrow("owner User"); - expect(() => admitRepository({ archived: false, default_branch: "main", disabled: false, full_name: "hraness/kb", id: 1308971873, private: true, visibility: "private" })).toThrow(); - admitRemoteRoutes("https://github.com/hraness/kb.git\n", "git@github.com:hraness/kb.git\n"); - expect(() => admitRemoteRoutes("https://github.com/hraness/kb.git\n", "https://github.com/hraness/kb.git\nhttps://github.com/attacker/kb.git\n")).toThrow(); - const workflowId = admitActiveCiWorkflow({ id: 9, name: "CI", path: ".github/workflows/ci.yml", state: "active" }); - const exactRun = { conclusion: "success", event: "push", head_branch: "main", head_repository: { full_name: "hraness/kb" }, head_sha: sha, id: 10, name: "CI", path: ".github/workflows/ci.yml", repository: { full_name: "hraness/kb" }, run_attempt: 2, status: "completed", workflow_id: workflowId }; - const run = admitCiRun({ total_count: 1, workflow_runs: [exactRun] }, workflowId, sha); - expect(() => admitCiRun({ total_count: 1, workflow_runs: [{ ...exactRun, event: "workflow_dispatch" }] }, workflowId, sha)).toThrow("exactly one exact CI push run"); - expect(admitCiRequiredJob({ jobs: [{ conclusion: "success", head_sha: sha, id: 11, name: "Required", run_attempt: 2, run_id: 10, status: "completed" }], total_count: 1 }, run, sha)).toBe(11); - expect(() => admitCiRequiredJob({ jobs: [{ conclusion: "success", head_sha: sha, id: 11, name: "Required", run_attempt: 1, run_id: 10, status: "completed" }], total_count: 1 }, run, sha)).toThrow(); - const inventory = `${otherSha}\trefs/tags/v0.19.0\n`; - expect(admitRemoteReleaseTags(inventory, "0.20.0", sha)).toBe("absent"); - expect(admitRemoteReleaseTags(inventory, "0.19.1-beta.0", sha)).toBe("absent"); - expect(() => admitRemoteReleaseTags(inventory, "0.18.0", sha)).toThrow("monotonically"); - const exact = `${otherSha}\trefs/tags/v0.20.0\n${sha}\trefs/tags/v0.20.0^{}\n`; - expect(admitRemoteReleaseTags(exact, "0.20.0", sha)).toBe("same-annotated-commit"); - expect(() => admitRemoteReleaseTags(`${otherSha}\trefs/tags/v0.20.0\n`, "0.20.0", sha)).toThrow("conflicts"); - expect(() => parseReleaseVersion("0.20.0-beta.01")).toThrow("canonical"); + "npm-stage.yml", + )).toThrow("must reject unsafe stable-version components"); }); test("gates the immutable GitHub release on the exact public npm artifact", async () => { @@ -293,18 +176,14 @@ jobs: const source = await readFile(path, "utf8"); expect(source).toContain("Verify canonical npm delivery"); - expect(source).toContain('tags:\n - "v*"\n - "!v*-beta.*"'); - expect(source).toContain("Authorize owner release tag"); - expect(source).toContain('EXPECTED_REPOSITORY_ID: "1308971873"'); - expect(source).toContain('event.repository?.visibility !== "public"'); - expect(source).toContain("github.ref_protected"); - expect(source).not.toContain("workflow_dispatch:"); - expect(source).not.toContain("publication_run_id"); - expect(source).toContain("Release tag must be annotated"); - expect(source).toContain("for registry_poll in {1..60}"); expect(source).toContain('package_spec="$EXPECTED_NAME@$package_version"'); expect(source).toContain("scripts/npm-package-identity.ts"); + expect(source).toContain("scripts/npm-release-attestation.ts"); expect(source).toContain("--registry-view-json"); + expect(source).toContain("npm audit signatures"); + expect(source).toContain("--include-attestations"); + expect(source).toContain("--registry-latest-json"); + expect(source).toContain('npm view "@hraness/kb" dist-tags.latest'); expect(source).not.toContain('cmp "$source_archive" "$registry_archive"'); expect(source).toContain("--registry=https://registry.npmjs.org"); expect(source).toContain("scripts/package-smoke.ts"); diff --git a/scripts/check-workflow-yaml.ts b/scripts/check-workflow-yaml.ts index 977b8fe..6e90895 100644 --- a/scripts/check-workflow-yaml.ts +++ b/scripts/check-workflow-yaml.ts @@ -11,7 +11,10 @@ function record(value: unknown, label: string): Record { } function workflowRecord(source: string, label: string): Record { - const document = parseDocument(source, { prettyErrors: true, uniqueKeys: true }); + const document = parseDocument(source, { + prettyErrors: true, + uniqueKeys: true, + }); if (document.errors.length > 0) { throw new Error(`${label} is invalid YAML: ${document.errors[0]?.message ?? "unknown parse error"}`); } @@ -21,7 +24,9 @@ function workflowRecord(source: string, label: string): Record } record(workflow.on, `${label} on`); const jobs = record(workflow.jobs, `${label} jobs`); - if (Object.keys(jobs).length === 0) throw new Error(`${label} jobs must not be empty`); + if (Object.keys(jobs).length === 0) { + throw new Error(`${label} jobs must not be empty`); + } return workflow; } @@ -29,129 +34,240 @@ export function validateWorkflowYaml(source: string, label: string): void { workflowRecord(source, label); } -function validateOwnerTagAuthorization( - workflow: Record, - label: string, - dependentJobName: string, -): void { - const jobs = record(workflow.jobs, `${label} jobs`); - const authorize = record(jobs.authorize, `${label} authorize job`); - if (Object.keys(record(authorize.permissions, `${label} authorize permissions`)).length !== 0) { - throw new Error(`${label} owner authorization must have no token permissions`); - } - if (!Array.isArray(authorize.steps) || authorize.steps.length !== 1) { - throw new Error(`${label} owner authorization must be one pre-checkout step`); +export function validateNpmStageWorkflow(source: string, label: string): void { + const workflow = workflowRecord(source, label); + const triggers = record(workflow.on, `${label} on`); + if (!("workflow_dispatch" in triggers)) { + throw new Error(`${label} must retain manual recovery dispatch`); } - const step = record(authorize.steps[0], `${label} owner authorization step`); - const environment = record(step.env, `${label} owner authorization environment`); - const run = step.run; + const dispatch = record(triggers.workflow_dispatch, `${label} workflow dispatch`); + const dispatchInputs = record(dispatch.inputs, `${label} workflow dispatch inputs`); + const publishInput = record( + dispatchInputs.publish_to_npm, + `${label} publish_to_npm input`, + ); + const resolvedStageInput = record( + dispatchInputs.resolved_stage_version, + `${label} resolved_stage_version input`, + ); if ( - environment.EXPECTED_ACTOR_ID !== "894119" - || environment.EXPECTED_REPOSITORY !== "hraness/kb" - || environment.EXPECTED_REPOSITORY_ID !== "1308971873" - || environment.REF_PROTECTED !== "${{ github.ref_protected }}" - || typeof run !== "string" - || !run.includes('"$GITHUB_ACTOR_ID" != "$EXPECTED_ACTOR_ID"') - || !run.includes("event.sender?.id !== Number(process.env.EXPECTED_ACTOR_ID)") - || !run.includes('event.sender?.type !== "User"') - || !run.includes("event.repository?.id !== Number(process.env.EXPECTED_REPOSITORY_ID)") - || !run.includes('event.repository?.visibility !== "public"') - || !run.includes("event.repository?.private !== false") + Object.keys(dispatchInputs).length !== 2 + || publishInput.default !== false + || publishInput.required !== false + || publishInput.type !== "boolean" + || typeof publishInput.description !== "string" + || publishInput.description.length === 0 ) { - throw new Error(`${label} owner authorization is missing an exact actor, sender, or public-repository guard`); - } - if (JSON.stringify(authorize).includes("actions/checkout@")) { - throw new Error(`${label} must authorize the tag sender before checkout`); - } - const dependent = record(jobs[dependentJobName], `${label} ${dependentJobName} job`); - if (dependent.needs !== "authorize") { - throw new Error(`${label} ${dependentJobName} must follow owner authorization`); + throw new Error(`${label} must expose one fail-closed boolean publish_to_npm input`); } -} - -export function validateOwnerTagWorkflow(source: string, label: string): void { - const workflow = workflowRecord(source, label); - validateOwnerTagAuthorization(workflow, label, "verify"); -} - -export function validateNpmPublishWorkflow(source: string, label: string): void { - const workflow = workflowRecord(source, label); - const triggers = record(workflow.on, `${label} on`); - if (Object.keys(triggers).length !== 1 || !("push" in triggers)) { - throw new Error(`${label} must accept only protected release-tag pushes`); + if ( + resolvedStageInput.default !== "" + || resolvedStageInput.required !== false + || resolvedStageInput.type !== "string" + || typeof resolvedStageInput.description !== "string" + || resolvedStageInput.description.length === 0 + ) { + throw new Error(`${label} must expose one empty-by-default resolved_stage_version recovery input`); } const push = record(triggers.push, `${label} push trigger`); - if (!Array.isArray(push.tags) || push.tags.length !== 1 || push.tags[0] !== "v*") { - throw new Error(`${label} must accept exactly v* tag pushes`); - } - - const permissions = record(workflow.permissions, `${label} permissions`); - if (permissions.contents !== "read" || Object.keys(permissions).length !== 1) { - throw new Error(`${label} top-level permissions must be contents: read only`); + if ( + !Array.isArray(push.branches) + || push.branches.length !== 1 + || push.branches[0] !== "main" + || !Array.isArray(push.paths) + || push.paths.length !== 1 + || push.paths[0] !== "package.json" + ) { + throw new Error(`${label} must run only for package.json pushes to main`); } const jobs = record(workflow.jobs, `${label} jobs`); - const authorize = record(jobs.authorize, `${label} authorize job`); const select = record(jobs.select, `${label} select job`); const verify = record(jobs.verify, `${label} verify job`); - const publish = record(jobs.publish, `${label} publish job`); - validateOwnerTagAuthorization(workflow, label, "select"); - + const stage = record(jobs.stage, `${label} stage job`); + if (stage.name !== "Stage exact package v${{ needs.verify.outputs.package_version }}") { + throw new Error(`${label} staging job name must bind the exact package version`); + } const selectPermissions = record(select.permissions, `${label} select permissions`); if ( - select.needs !== "authorize" - || selectPermissions.contents !== "read" + selectPermissions.contents !== "read" + || "id-token" in selectPermissions || Object.keys(selectPermissions).length !== 1 - ) throw new Error(`${label} selection must follow authorization and remain read-only`); + ) { + throw new Error(`${label} selection must remain read-only without OIDC authority`); + } const selectOutputs = record(select.outputs, `${label} select outputs`); + if (selectOutputs.should_stage !== "${{ steps.selection.outputs.should_stage }}") { + throw new Error(`${label} selection must expose the reviewed should_stage decision`); + } + if ( + verify.needs !== "select" + || verify.if !== "needs.select.outputs.should_stage == 'true'" + ) { + throw new Error(`${label} verification must require an affirmative stage selection`); + } + const verifyPermissions = record(verify.permissions, `${label} verify permissions`); + if (verifyPermissions.contents !== "read" || "id-token" in verifyPermissions) { + throw new Error(`${label} verification must remain read-only without OIDC authority`); + } + const stagePermissions = record(stage.permissions, `${label} stage permissions`); + if (stage.needs !== "verify" || stage.if !== "inputs.publish_to_npm == true") { + throw new Error(`${label} staging must require explicit publish_to_npm opt-in`); + } + if ( + stagePermissions.actions !== "read" + || stagePermissions["id-token"] !== "write" + || Object.keys(stagePermissions).length !== 2 + ) { + throw new Error(`${label} staging must hold only actions: read and id-token: write`); + } + if (stage.environment !== "npm-stage") { + throw new Error(`${label} staging must use the exact npm-stage environment`); + } + if (!Array.isArray(select.steps)) { + throw new Error(`${label} select steps must be a sequence`); + } + const selectionSteps = select.steps.map((step, index) => + record(step, `${label} select step ${String(index + 1)}`)); + const selectionCommands = selectionSteps.filter((step) => + typeof step.run === "string" && step.run.includes("scripts/npm-stage-selection.ts")); + if (selectionCommands.length !== 1 || typeof selectionCommands[0]?.run !== "string") { + throw new Error(`${label} must contain exactly one package-version selection step`); + } + const selectionCommand = selectionCommands[0].run; + for (const required of [ + 'git fetch --no-tags origin', + 'git merge-base --is-ancestor "$BEFORE_SHA" "$default_head"', + 'git show "$BEFORE_SHA:package.json"', + 'bun run ./scripts/npm-stage-selection.ts', + ]) { + if (!selectionCommand.includes(required)) { + throw new Error(`${label} package-version selection is missing ${required}`); + } + } + if (!Array.isArray(stage.steps)) { + throw new Error(`${label} stage steps must be a sequence`); + } + const steps = stage.steps.map((step, index) => + record(step, `${label} stage step ${String(index + 1)}`)); + const authorizationStep = steps[0]; if ( - selectOutputs.should_publish !== "${{ steps.selection.outputs.should_publish }}" - || selectOutputs.publish_tag !== "${{ steps.selection.outputs.publish_tag }}" - ) throw new Error(`${label} selection must expose the reviewed decision and tag`); - const selectionSource = JSON.stringify(select); + authorizationStep?.name !== "Reauthorize current npm staging attempt" + || typeof authorizationStep.run !== "string" + ) { + throw new Error(`${label} staging must reauthorize the current attempt before any other step`); + } + const authorizationEnvironment = record( + authorizationStep.env, + `${label} staging authorization environment`, + ); + if ( + authorizationEnvironment.EXPECTED_ACTOR_ID !== "894119" + || authorizationEnvironment.EXPECTED_REPOSITORY !== "hraness/kb" + || authorizationEnvironment.EXPECTED_REPOSITORY_ID !== "1308971873" + || authorizationEnvironment.EXPECTED_SOURCE_SHA !== "${{ needs.verify.outputs.source_sha }}" + || authorizationEnvironment.EXPECTED_WORKFLOW_ID !== "344070109" + || authorizationEnvironment.EXPECTED_WORKFLOW_NAME !== "Stage npm package" + || authorizationEnvironment.EXPECTED_WORKFLOW_PATH !== ".github/workflows/npm-stage.yml" + || authorizationEnvironment.GH_TOKEN !== "${{ github.token }}" + || authorizationEnvironment.PUBLISH_TO_NPM !== "${{ inputs.publish_to_npm }}" + || authorizationEnvironment.REF_PROTECTED !== "${{ github.ref_protected }}" + ) { + throw new Error(`${label} staging authorization must bind the exact owner, repository, workflow, source, input, and protected ref`); + } for (const required of [ - "GITHUB_EVENT_NAME", - "GITHUB_SHA", - "GITHUB_REF_NAME", - "github.ref_protected", - "refs/remotes/origin/$DEFAULT_BRANCH", - "refs/npm-publish-tags/$GITHUB_REF_NAME", - "git cat-file -t", - "exact annotated release tag", - "publish_tag=latest", - "publish_tag=beta", + '"$GITHUB_ACTOR_ID" != "$EXPECTED_ACTOR_ID"', + '"$GITHUB_EVENT_NAME" != workflow_dispatch', + '"$GITHUB_REPOSITORY_ID" != "$EXPECTED_REPOSITORY_ID"', + '"$GITHUB_REF" != refs/heads/main', + '"$GITHUB_SHA" != "$EXPECTED_SOURCE_SHA"', + '"$PUBLISH_TO_NPM" != true', + '"$REF_PROTECTED" != true', + 'attempt.id !== runId', + 'attempt.run_attempt !== runAttempt', + 'attempt.workflow_id !== workflowId', + 'attempt.name !== process.env.EXPECTED_WORKFLOW_NAME', + 'attempt.path !== process.env.EXPECTED_WORKFLOW_PATH', + 'attempt.event !== "workflow_dispatch"', + 'attempt.head_branch !== "main"', + 'attempt.head_sha !== process.env.EXPECTED_SHA', + 'attempt.actor?.id !== actorId', + 'attempt.actor?.type !== "User"', + 'attempt.triggering_actor?.id !== actorId', + 'attempt.triggering_actor?.type !== "User"', + 'attempt.repository?.id !== repositoryId', + 'workflow.id !== workflowId', + 'workflow.name !== process.env.EXPECTED_WORKFLOW_NAME', + 'workflow.path !== process.env.EXPECTED_WORKFLOW_PATH', + 'workflow.state !== "active"', + 'repository.id !== repositoryId', + 'repository.visibility !== "public"', + 'repository.default_branch !== "main"', ]) { - if (!selectionSource.includes(required)) throw new Error(`${label} tag selection is missing ${required}`); + if (!authorizationStep.run.includes(required)) { + throw new Error(`${label} staging attempt authorization is missing ${required}`); + } } - - const verifyPermissions = record(verify.permissions, `${label} verify permissions`); + for (const stepName of [ + "Bind artifact reference", + "Rebind downloaded package", + "Revalidate current main and stage exact package", + ]) { + const boundary = steps.find((step) => step.name === stepName); + if ( + boundary === undefined + || typeof boundary.run !== "string" + || !boundary.run.includes("BigInt(Number.MAX_SAFE_INTEGER)") + || !boundary.run.includes("Verified package version components exceed Number.MAX_SAFE_INTEGER") + ) { + throw new Error(`${label} ${stepName} must reject unsafe stable-version components`); + } + } + const pendingStageStep = steps.find((step) => step.name === "Reject another pending stable stage"); + if (pendingStageStep === undefined || typeof pendingStageStep.run !== "string") { + throw new Error(`${label} must reject another unresolved successful stage`); + } + if (!pendingStageStep.run.includes("BigInt(Number.MAX_SAFE_INTEGER)")) { + throw new Error(`${label} pending-stage guard must reject unsafe stable-version components`); + } + const pendingStageEnvironment = record( + pendingStageStep.env, + `${label} pending-stage environment`, + ); if ( - verify.needs !== "select" - || verify.if !== "needs.select.outputs.should_publish == 'true'" - || verifyPermissions.contents !== "read" - || Object.keys(verifyPermissions).length !== 1 - ) throw new Error(`${label} package verification must follow selection and remain read-only`); - - const publishPermissions = record(publish.permissions, `${label} publish permissions`); - if (publishPermissions["id-token"] !== "write" || Object.keys(publishPermissions).length !== 1) { - throw new Error(`${label} terminal publishing must hold only id-token: write`); + pendingStageEnvironment.EXPECTED_VERSION !== "${{ needs.verify.outputs.package_version }}" + || pendingStageEnvironment.EXPECTED_WORKFLOW_ID !== "344070109" + || pendingStageEnvironment.GH_TOKEN !== "${{ github.token }}" + || pendingStageEnvironment.RESOLVED_STAGE_VERSION !== "${{ inputs.resolved_stage_version }}" + ) { + throw new Error(`${label} pending-stage guard must bind exact workflow history and recovery input`); } - if (publish.environment !== "npm-stage") { - throw new Error(`${label} publishing must use the exact npm-stage environment`); + for (const required of [ + "Completed npm-stage history exceeds the reviewed 100-run bound", + "Stage exact package v", + "already staged pending", + "does not identify a blocking stage", + 'execute("npm", [', + "dist-tags.latest", + ]) { + if (!pendingStageStep.run.includes(required)) { + throw new Error(`${label} pending-stage guard is missing ${required}`); + } } - if (!Array.isArray(publish.steps)) throw new Error(`${label} publish steps must be a sequence`); - const steps = publish.steps.map((step, index) => record(step, `${label} publish step ${String(index + 1)}`)); if (steps.some((step) => typeof step.uses === "string" && (step.uses.startsWith("actions/checkout@") || step.uses.startsWith("oven-sh/setup-bun@")))) { - throw new Error(`${label} publishing must not check out source or install Bun`); + throw new Error(`${label} staging must not check out source or install Bun`); } const publicationSteps = steps.filter((step) => - typeof step.run === "string" && step.run.includes('npm publish "$TARBALL"')); - if (publicationSteps.length !== 1 || typeof publicationSteps[0]?.run !== "string") { - throw new Error(`${label} must contain exactly one direct-publication step`); + typeof step.run === "string" && step.run.includes("npm stage publish")); + if (publicationSteps.length !== 1) { + throw new Error(`${label} must contain exactly one staged-publication step`); } const publicationStep = publicationSteps[0]; - const environment = record(publicationStep.env, `${label} direct-publication environment`); + if (publicationStep === undefined || typeof publicationStep.run !== "string") { + throw new Error(`${label} staged-publication command is missing`); + } + const environment = record(publicationStep.env, `${label} staged-publication environment`); for (const name of [ "DEFAULT_BRANCH", "DIGEST", @@ -159,61 +275,57 @@ export function validateNpmPublishWorkflow(source: string, label: string): void "EXPECTED_DIGEST_SHA256", "EXPECTED_METADATA_SHA256", "EXPECTED_SOURCE_SHA", + "EXPECTED_VERSION", "METADATA", - "PUBLISH_TAG", "TARBALL", ]) { - if (typeof environment[name] !== "string") throw new Error(`${label} direct publication must bind ${name}`); + if (typeof environment[name] !== "string") { + throw new Error(`${label} staged publication must bind ${name}`); + } } const guardCommands = [ 'git init --quiet --bare "$current_main"', - 'git --git-dir="$current_main" fetch --quiet --no-tags --depth=1', + 'git --git-dir="$current_main" fetch', 'current_default_sha="$(git --git-dir="$current_main" rev-parse FETCH_HEAD)"', - 'release_commit="$(git --git-dir="$current_main" rev-parse', + 'npm view "@hraness/kb" dist-tags.latest', 'current_archive_sha256="$(sha256sum "$TARBALL"', 'current_metadata_sha256="$(sha256sum "$METADATA"', 'current_digest_sha256="$(sha256sum "$DIGEST"', - 'npm view "$package_spec" version --json', - 'npm publish "$TARBALL"', - '--tag "$PUBLISH_TAG"', - 'npm view "$package_spec" dist --json', - "dist.attestations.provenance?.predicateType", + "npm config get tag", + 'npm stage publish "$TARBALL"', ]; let previousIndex = -1; - for (const required of guardCommands) { - const index = publicationStep.run.indexOf(required); + for (const command of guardCommands) { + const index = publicationStep.run.indexOf(command); if (index <= previousIndex) { - throw new Error(`${label} must recheck current default-branch HEAD and artifact before direct publication/readback`); + throw new Error(`${label} must recheck current default-branch HEAD immediately before staged publication`); } previousIndex = index; } - const publishSource = JSON.stringify(publish); - if (/\bbun\b/u.test(publishSource) || publishSource.includes("./scripts/")) { - throw new Error(`${label} publishing must not execute repository code`); + const publishIndex = publicationStep.run.indexOf('npm stage publish "$TARBALL"'); + if (!publicationStep.run.slice(publishIndex).includes("--registry=https://registry.npmjs.org")) { + throw new Error(`${label} staged publication must bind the canonical npm registry`); } - if (/\bnpm\s+(?:dist-tag|stage)\b/u.test(source)) { - throw new Error(`${label} must publish with its initial tag and never promote or stage it`); + if (/--tag(?:=|\s)/u.test(publicationStep.run)) { + throw new Error(`${label} must preserve pinned npm's default-tag monotonicity guard`); + } + const stageSource = JSON.stringify(stage); + if (/\bbun\b/u.test(stageSource) || stageSource.includes("./scripts/")) { + throw new Error(`${label} staging must not execute repository code`); } if ((source.match(/id-token: write/gu) ?? []).length !== 1) { throw new Error(`${label} must grant OIDC authority to exactly one job`); } - for (const forbidden of ["workflow_dispatch:", "actions: write", "authorization_run_id", "NPM_TOKEN", "NODE_AUTH_TOKEN"]) { - if (source.includes(forbidden)) throw new Error(`${label} contains forbidden release authority ${forbidden}`); - } } if (import.meta.main) { const repositoryRoot = resolve(import.meta.dir, ".."); - const ciPath = ".github/workflows/ci.yml"; - validateWorkflowYaml(await readFile(resolve(repositoryRoot, ciPath), "utf8"), ciPath); - const releasePath = ".github/workflows/release.yml"; - validateOwnerTagWorkflow( - await readFile(resolve(repositoryRoot, releasePath), "utf8"), - releasePath, - ); - const npmPublishPath = ".github/workflows/npm-stage.yml"; - validateNpmPublishWorkflow( - await readFile(resolve(repositoryRoot, npmPublishPath), "utf8"), - npmPublishPath, + for (const path of [".github/workflows/ci.yml", ".github/workflows/release.yml"]) { + validateWorkflowYaml(await readFile(resolve(repositoryRoot, path), "utf8"), path); + } + const npmStagePath = ".github/workflows/npm-stage.yml"; + validateNpmStageWorkflow( + await readFile(resolve(repositoryRoot, npmStagePath), "utf8"), + npmStagePath, ); } diff --git a/scripts/npm-package-identity.ts b/scripts/npm-package-identity.ts index 10778dd..5008d56 100644 --- a/scripts/npm-package-identity.ts +++ b/scripts/npm-package-identity.ts @@ -11,6 +11,7 @@ import { const packageName = "@hraness/kb"; const npmRegistry = "https://registry.npmjs.org"; const stableVersionPattern = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/u; +const maximumStableVersionPart = BigInt(Number.MAX_SAFE_INTEGER); const ohAdoptionPreparerIntroduction = [0n, 18n, 0n] as const; type NpmPackFile = Readonly<{ mode: number; path: string; size: number }>; @@ -61,7 +62,13 @@ function stableVersionParts(version: string): readonly [bigint, bigint, bigint] if (match === null || match[1] === undefined || match[2] === undefined || match[3] === undefined) { throw new TypeError(`Package version must be a canonical stable semantic version: ${version}`); } - return [BigInt(match[1]), BigInt(match[2]), BigInt(match[3])]; + const parts = [BigInt(match[1]), BigInt(match[2]), BigInt(match[3])] as const; + if (parts.some((part) => part > maximumStableVersionPart)) { + throw new TypeError( + `Package version components must not exceed Number.MAX_SAFE_INTEGER: ${version}`, + ); + } + return parts; } export function requiresOhAdoptionPreparerExport(packageVersion: string): boolean { @@ -127,9 +134,7 @@ function expectedFilename(name: string, version: string): string { if (name !== packageName) { throw new Error(`Expected package name must be ${packageName}, received ${name}`); } - if (!stableVersionPattern.test(version)) { - throw new Error(`Expected package version is not stable semantic version: ${version}`); - } + stableVersionParts(version); return `hraness-kb-${version}.tgz`; } diff --git a/scripts/npm-release-attestation.test.ts b/scripts/npm-release-attestation.test.ts new file mode 100644 index 0000000..3a0f769 --- /dev/null +++ b/scripts/npm-release-attestation.test.ts @@ -0,0 +1,369 @@ +import { describe, expect, test } from "bun:test"; + +import { verifyNpmReleaseAttestation } from "./npm-release-attestation.ts"; + +const version = "0.20.0"; +const sourceSha = "b".repeat(40); +const tarballSha512 = "a".repeat(128); +const provenancePredicateType = "https://slsa.dev/provenance/v1"; +const publishPredicateType = "https://github.com/npm/attestation/tree/main/specs/publish/v0.1"; + +function record(value: unknown, label: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new TypeError(`${label} must be an object`); + } + return value as Record; +} + +function subject(packageVersion: string): readonly Record[] { + return [{ + name: `pkg:npm/%40hraness/kb@${packageVersion}`, + digest: { sha512: tarballSha512 }, + }]; +} + +function bundle( + predicateType: string, + mediaType: string, + statement: Readonly>, +): Readonly> { + const publish = predicateType === publishPredicateType; + const keyid = publish ? "SHA256:test-key" : ""; + return { + predicateType, + bundle: { + mediaType, + verificationMaterial: { + ...(publish + ? { publicKey: { hint: keyid } } + : { certificate: { rawBytes: Buffer.from("test-certificate").toString("base64") } }), + tlogEntries: [{ logIndex: "123" }], + timestampVerificationData: { rfc3161Timestamps: [] }, + }, + dsseEnvelope: { + payload: Buffer.from(JSON.stringify(statement), "utf8").toString("base64"), + payloadType: "application/vnd.in-toto+json", + signatures: [{ keyid, sig: Buffer.from("test-signature").toString("base64") }], + }, + }, + }; +} + +function validInput(packageVersion = version) { + const attestationMetadata = { + url: `https://registry.npmjs.org/-/npm/v1/attestations/@hraness%2fkb@${packageVersion}`, + provenance: { predicateType: provenancePredicateType }, + }; + const publishStatement = { + _type: "https://in-toto.io/Statement/v0.1", + subject: subject(packageVersion), + predicateType: publishPredicateType, + predicate: { + name: "@hraness/kb", + version: packageVersion, + registry: "https://registry.npmjs.org", + }, + }; + const provenanceStatement = { + _type: "https://in-toto.io/Statement/v1", + subject: subject(packageVersion), + predicateType: provenancePredicateType, + predicate: { + buildDefinition: { + buildType: "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1", + externalParameters: { + workflow: { + ref: "refs/heads/main", + repository: "https://github.com/hraness/kb", + path: ".github/workflows/npm-stage.yml", + }, + }, + internalParameters: { + github: { + event_name: "workflow_dispatch", + repository_id: "1308971873", + repository_owner_id: "307125679", + }, + }, + resolvedDependencies: [{ + uri: "git+https://github.com/hraness/kb@refs/heads/main", + digest: { gitCommit: sourceSha }, + }], + }, + runDetails: { + builder: { id: "https://github.com/actions/runner/github-hosted" }, + metadata: { + invocationId: "https://github.com/hraness/kb/actions/runs/123456/attempts/2", + }, + }, + }, + }; + return { + audit: { + invalid: [], + missing: [], + verified: [{ + name: "@hraness/kb", + version: packageVersion, + location: "node_modules/@hraness/kb", + registry: "https://registry.npmjs.org/", + attestations: structuredClone(attestationMetadata), + attestationBundles: [ + bundle( + publishPredicateType, + "application/vnd.dev.sigstore.bundle+json;version=0.2", + publishStatement, + ), + bundle( + provenancePredicateType, + "application/vnd.dev.sigstore.bundle.v0.3+json", + provenanceStatement, + ), + ], + }], + }, + expectedSourceSha: sourceSha, + expectedTarballSha512: tarballSha512, + expectedVersion: packageVersion, + registryLatest: packageVersion, + registryView: { + name: "@hraness/kb", + version: packageVersion, + dist: { + integrity: `sha512-${Buffer.from(tarballSha512, "hex").toString("base64")}`, + attestations: structuredClone(attestationMetadata), + signatures: [{ keyid: "SHA256:test", sig: "MEUCIQtest" }], + }, + }, + }; +} + +type Fixture = ReturnType; + +function verifiedRecord(input: Fixture): Record { + const audit = record(input.audit, "audit"); + const verified = audit.verified; + if (!Array.isArray(verified)) throw new TypeError("verified must be an array"); + return record(verified[0], "verified[0]"); +} + +function statement( + input: Fixture, + expectedPredicateType: string, +): Record { + const verified = verifiedRecord(input); + const bundles = verified.attestationBundles; + if (!Array.isArray(bundles)) throw new TypeError("attestationBundles must be an array"); + const candidate = bundles + .map((value) => record(value, "attestation bundle")) + .find((value) => value.predicateType === expectedPredicateType); + if (candidate === undefined) throw new TypeError("attestation bundle is missing"); + const envelope = record(record(candidate.bundle, "bundle").dsseEnvelope, "envelope"); + const payload = envelope.payload; + if (typeof payload !== "string") throw new TypeError("payload must be a string"); + return record(JSON.parse(Buffer.from(payload, "base64").toString("utf8")) as unknown, "statement"); +} + +function replaceStatement( + input: Fixture, + expectedPredicateType: string, + replacement: Record, +): void { + const verified = verifiedRecord(input); + const bundles = verified.attestationBundles; + if (!Array.isArray(bundles)) throw new TypeError("attestationBundles must be an array"); + const candidate = bundles + .map((value) => record(value, "attestation bundle")) + .find((value) => value.predicateType === expectedPredicateType); + if (candidate === undefined) throw new TypeError("attestation bundle is missing"); + const envelope = record(record(candidate.bundle, "bundle").dsseEnvelope, "envelope"); + envelope.payload = Buffer.from(JSON.stringify(replacement), "utf8").toString("base64"); +} + +function mutateStatement( + input: Fixture, + expectedPredicateType: string, + mutate: (statement: Record) => void, +): void { + const decoded = statement(input, expectedPredicateType); + mutate(decoded); + replaceStatement(input, expectedPredicateType, decoded); +} + +describe("npm release attestation", () => { + test("binds the cryptographically audited package to the exact workflow and source", () => { + expect(verifyNpmReleaseAttestation(validInput())).toEqual({ + invocationId: "https://github.com/hraness/kb/actions/runs/123456/attempts/2", + sourceSha, + tarballSha512, + version, + }); + }); + + test("rejects identity, provenance, publication, signature, and channel drift", () => { + const corruptions: readonly Readonly<{ + label: string; + mutate: (input: Fixture) => void; + }>[] = [ + { + label: "tarball subject", + mutate: (input) => mutateStatement(input, provenancePredicateType, (decoded) => { + const subjects = decoded.subject; + if (!Array.isArray(subjects)) throw new TypeError("subject must be an array"); + record(record(subjects[0], "subject").digest, "digest").sha512 = "c".repeat(128); + }), + }, + { + label: "workflow path", + mutate: (input) => mutateStatement(input, provenancePredicateType, (decoded) => { + const build = record(record(decoded.predicate, "predicate").buildDefinition, "build"); + record(record(build.externalParameters, "external").workflow, "workflow").path = "other.yml"; + }), + }, + { + label: "workflow ref", + mutate: (input) => mutateStatement(input, provenancePredicateType, (decoded) => { + const build = record(record(decoded.predicate, "predicate").buildDefinition, "build"); + record(record(build.externalParameters, "external").workflow, "workflow").ref = "refs/heads/other"; + }), + }, + { + label: "workflow repository", + mutate: (input) => mutateStatement(input, provenancePredicateType, (decoded) => { + const build = record(record(decoded.predicate, "predicate").buildDefinition, "build"); + record(record(build.externalParameters, "external").workflow, "workflow").repository = "https://github.com/other/repository"; + }), + }, + { + label: "event", + mutate: (input) => mutateStatement(input, provenancePredicateType, (decoded) => { + const build = record(record(decoded.predicate, "predicate").buildDefinition, "build"); + record(record(build.internalParameters, "internal").github, "github").event_name = "push"; + }), + }, + { + label: "repository id", + mutate: (input) => mutateStatement(input, provenancePredicateType, (decoded) => { + const build = record(record(decoded.predicate, "predicate").buildDefinition, "build"); + record(record(build.internalParameters, "internal").github, "github").repository_id = "1"; + }), + }, + { + label: "repository owner id", + mutate: (input) => mutateStatement(input, provenancePredicateType, (decoded) => { + const build = record(record(decoded.predicate, "predicate").buildDefinition, "build"); + record(record(build.internalParameters, "internal").github, "github").repository_owner_id = "1"; + }), + }, + { + label: "source dependency count", + mutate: (input) => mutateStatement(input, provenancePredicateType, (decoded) => { + const build = record(record(decoded.predicate, "predicate").buildDefinition, "build"); + const dependencies = build.resolvedDependencies; + if (!Array.isArray(dependencies)) throw new TypeError("dependencies must be an array"); + dependencies.push(structuredClone(dependencies[0])); + }), + }, + { + label: "source commit", + mutate: (input) => mutateStatement(input, provenancePredicateType, (decoded) => { + const build = record(record(decoded.predicate, "predicate").buildDefinition, "build"); + const dependencies = build.resolvedDependencies; + if (!Array.isArray(dependencies)) throw new TypeError("dependencies must be an array"); + record(record(dependencies[0], "dependency").digest, "digest").gitCommit = "c".repeat(40); + }), + }, + { + label: "source dependency URI", + mutate: (input) => mutateStatement(input, provenancePredicateType, (decoded) => { + const build = record(record(decoded.predicate, "predicate").buildDefinition, "build"); + const dependencies = build.resolvedDependencies; + if (!Array.isArray(dependencies)) throw new TypeError("dependencies must be an array"); + record(dependencies[0], "dependency").uri = "git+https://github.com/hraness/kb@refs/tags/v0.20.0"; + }), + }, + { + label: "builder", + mutate: (input) => mutateStatement(input, provenancePredicateType, (decoded) => { + const run = record(record(decoded.predicate, "predicate").runDetails, "run"); + record(run.builder, "builder").id = "https://example.com/runner"; + }), + }, + { + label: "invocation", + mutate: (input) => mutateStatement(input, provenancePredicateType, (decoded) => { + const run = record(record(decoded.predicate, "predicate").runDetails, "run"); + record(run.metadata, "metadata").invocationId = "https://github.com/other/repo/actions/runs/1/attempts/1"; + }), + }, + { + label: "publish registry", + mutate: (input) => mutateStatement(input, publishPredicateType, (decoded) => { + record(decoded.predicate, "predicate").registry = "https://registry.example"; + }), + }, + { + label: "publish version", + mutate: (input) => mutateStatement(input, publishPredicateType, (decoded) => { + record(decoded.predicate, "predicate").version = "0.19.0"; + }), + }, + { + label: "registry integrity", + mutate: (input) => { + record(input.registryView.dist, "dist").integrity = `sha512-${Buffer.from("c".repeat(128), "hex").toString("base64")}`; + }, + }, + { + label: "registry signature", + mutate: (input) => { + record(input.registryView.dist, "dist").signatures = []; + }, + }, + { + label: "attestation metadata", + mutate: (input) => { + record(record(input.registryView.dist, "dist").attestations, "attestations").url = "https://example.invalid"; + }, + }, + { + label: "latest channel", + mutate: (input) => { + input.registryLatest = "0.19.0"; + }, + }, + { + label: "cryptographic audit", + mutate: (input) => { + input.audit.invalid.push({ code: "EATTESTATIONVERIFY" }); + }, + }, + { + label: "missing cryptographic evidence", + mutate: (input) => { + input.audit.missing.push({ name: "@hraness/kb", version }); + }, + }, + ]; + + for (const corruption of corruptions) { + const input = structuredClone(validInput()); + corruption.mutate(input); + expect(() => verifyNpmReleaseAttestation(input), corruption.label).toThrow(); + } + }); + + test("rejects stable version components above Number.MAX_SAFE_INTEGER", () => { + const maximum = "9007199254740991"; + expect(verifyNpmReleaseAttestation(validInput(`${maximum}.${maximum}.${maximum}`)).version) + .toBe(`${maximum}.${maximum}.${maximum}`); + for (const packageVersion of [ + "9007199254740992.0.0", + "0.9007199254740992.0", + "0.0.9007199254740992", + ]) { + expect(() => verifyNpmReleaseAttestation(validInput(packageVersion))) + .toThrow("Number.MAX_SAFE_INTEGER"); + } + }); +}); diff --git a/scripts/npm-release-attestation.ts b/scripts/npm-release-attestation.ts new file mode 100644 index 0000000..a49658b --- /dev/null +++ b/scripts/npm-release-attestation.ts @@ -0,0 +1,516 @@ +import { readFile } from "node:fs/promises"; +import { isAbsolute, resolve } from "node:path"; + +const expectedName = "@hraness/kb"; +const expectedRegistry = "https://registry.npmjs.org"; +const expectedAuditRegistry = `${expectedRegistry}/`; +const expectedRepository = "https://github.com/hraness/kb"; +const expectedRepositoryId = "1308971873"; +const expectedRepositoryOwnerId = "307125679"; +const expectedWorkflowPath = ".github/workflows/npm-stage.yml"; +const expectedWorkflowRef = "refs/heads/main"; +const expectedWorkflowEvent = "workflow_dispatch"; +const expectedBuilder = "https://github.com/actions/runner/github-hosted"; +const expectedBuildType = "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1"; +const provenancePredicateType = "https://slsa.dev/provenance/v1"; +const publishPredicateType = "https://github.com/npm/attestation/tree/main/specs/publish/v0.1"; +const inTotoPayloadType = "application/vnd.in-toto+json"; +const provenanceBundleMediaType = "application/vnd.dev.sigstore.bundle.v0.3+json"; +const publishBundleMediaType = "application/vnd.dev.sigstore.bundle+json;version=0.2"; +const stableVersionPattern = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/u; +const maximumStableVersionPart = BigInt(Number.MAX_SAFE_INTEGER); +const maximumAuditBytes = 20_000_000; +const maximumRegistryViewBytes = 1_000_000; + +type ReleaseAttestationInput = Readonly<{ + audit: unknown; + expectedSourceSha: string; + expectedTarballSha512: string; + expectedVersion: string; + registryLatest: unknown; + registryView: unknown; +}>; + +export type VerifiedReleaseAttestation = Readonly<{ + invocationId: string; + sourceSha: string; + tarballSha512: string; + version: string; +}>; + +function record(value: unknown, label: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new TypeError(`${label} must be an object`); + } + return value as Record; +} + +function array(value: unknown, label: string): unknown[] { + if (!Array.isArray(value)) throw new TypeError(`${label} must be an array`); + return value; +} + +function stringField(value: Record, key: string, label: string): string { + const field = value[key]; + if (typeof field !== "string" || field.length === 0) { + throw new TypeError(`${label}.${key} must be a non-empty string`); + } + return field; +} + +function exactKeys(value: Record, keys: readonly string[], label: string): void { + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new TypeError(`${label} must contain exactly ${expected.join(", ")}`); + } +} + +function stableVersion(version: string): void { + const match = stableVersionPattern.exec(version); + if (match === null || match[1] === undefined || match[2] === undefined || match[3] === undefined) { + throw new TypeError(`Expected version is not a canonical stable semantic version: ${version}`); + } + if ([match[1], match[2], match[3]].some( + (part) => BigInt(part) > maximumStableVersionPart, + )) { + throw new TypeError(`Expected version components exceed Number.MAX_SAFE_INTEGER: ${version}`); + } +} + +function canonicalAttestations(version: string): Readonly> { + return Object.freeze({ + provenance: Object.freeze({ predicateType: provenancePredicateType }), + url: `${expectedRegistry}/-/npm/v1/attestations/@hraness%2fkb@${version}`, + }); +} + +function canonicalJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; + if (typeof value === "object" && value !== null) { + const source = value as Record; + return `{${Object.keys(source).sort().map((key) => ( + `${JSON.stringify(key)}:${canonicalJson(source[key])}` + )).join(",")}}`; + } + return JSON.stringify(value) ?? "undefined"; +} + +function canonicalBase64(value: string, label: string): Buffer { + if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(value)) { + throw new TypeError(`${label} is not canonical base64`); + } + const decoded = Buffer.from(value, "base64"); + if (decoded.byteLength === 0 || decoded.toString("base64") !== value) { + throw new TypeError(`${label} is not nonempty canonical base64`); + } + return decoded; +} + +function assertCanonicalAttestations(value: unknown, version: string, label: string): void { + if (canonicalJson(value) !== canonicalJson(canonicalAttestations(version))) { + throw new TypeError(`${label} is not the canonical npm attestation metadata`); + } +} + +function verifyRegistryIntegrity(value: unknown, tarballSha512: string): void { + if (typeof value !== "string") { + throw new TypeError("npm registry view.dist.integrity must be a string"); + } + const match = /^sha512-((?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==)?)$/u.exec(value); + if (match === null || match[1] === undefined) { + throw new TypeError("npm registry view.dist.integrity must be one canonical SHA-512 SRI"); + } + const digest = Buffer.from(match[1], "base64"); + if ( + digest.byteLength !== 64 + || digest.toString("base64") !== match[1] + || digest.toString("hex") !== tarballSha512 + ) { + throw new TypeError("npm registry integrity does not bind the downloaded tarball SHA-512"); + } +} + +function verifySubject( + statement: Record, + version: string, + tarballSha512: string, + label: string, +): void { + const subjects = array(statement.subject, `${label}.subject`); + if (subjects.length !== 1) throw new TypeError(`${label} must have exactly one subject`); + const subject = record(subjects[0], `${label}.subject[0]`); + const digest = record(subject.digest, `${label}.subject[0].digest`); + exactKeys(subject, ["digest", "name"], `${label}.subject[0]`); + exactKeys(digest, ["sha512"], `${label}.subject[0].digest`); + if ( + subject.name !== `pkg:npm/%40hraness/kb@${version}` + || digest.sha512 !== tarballSha512 + ) { + throw new TypeError(`${label} subject does not bind the exact npm tarball`); + } +} + +function decodeStatement( + value: unknown, + predicateType: string, + mediaType: string, +): Record { + const attestation = record(value, `${predicateType} attestation`); + exactKeys(attestation, ["bundle", "predicateType"], `${predicateType} attestation`); + if (attestation.predicateType !== predicateType) { + throw new TypeError(`${predicateType} attestation has the wrong predicate type`); + } + const bundle = record(attestation.bundle, `${predicateType} bundle`); + exactKeys( + bundle, + ["dsseEnvelope", "mediaType", "verificationMaterial"], + `${predicateType} bundle`, + ); + if (bundle.mediaType !== mediaType) { + throw new TypeError(`${predicateType} bundle has the wrong media type`); + } + const verificationMaterial = record( + bundle.verificationMaterial, + `${predicateType} verification material`, + ); + const materialKeys = predicateType === publishPredicateType + ? ["publicKey", "timestampVerificationData", "tlogEntries"] + : ["certificate", "timestampVerificationData", "tlogEntries"]; + exactKeys(verificationMaterial, materialKeys, `${predicateType} verification material`); + if (array(verificationMaterial.tlogEntries, `${predicateType} transparency log entries`).length < 1) { + throw new TypeError(`${predicateType} bundle has no transparency log entry`); + } + const timestampVerification = record( + verificationMaterial.timestampVerificationData, + `${predicateType} timestamp verification data`, + ); + exactKeys( + timestampVerification, + ["rfc3161Timestamps"], + `${predicateType} timestamp verification data`, + ); + if (array( + timestampVerification.rfc3161Timestamps, + `${predicateType} RFC3161 timestamps`, + ).length !== 0) { + throw new TypeError(`${predicateType} bundle has unexpected RFC3161 timestamps`); + } + const envelope = record(bundle.dsseEnvelope, `${predicateType} DSSE envelope`); + exactKeys(envelope, ["payload", "payloadType", "signatures"], `${predicateType} DSSE envelope`); + if (envelope.payloadType !== inTotoPayloadType) { + throw new TypeError(`${predicateType} DSSE envelope has the wrong payload type`); + } + const signatures = array(envelope.signatures, `${predicateType} DSSE signatures`); + if (signatures.length !== 1) { + throw new TypeError(`${predicateType} DSSE envelope must have exactly one signature`); + } + const signature = record(signatures[0], `${predicateType} DSSE signature`); + exactKeys(signature, ["keyid", "sig"], `${predicateType} DSSE signature`); + canonicalBase64(stringField(signature, "sig", `${predicateType} DSSE signature`), `${predicateType} DSSE signature.sig`); + if (typeof signature.keyid !== "string") { + throw new TypeError(`${predicateType} DSSE signature.keyid must be a string`); + } + if (predicateType === publishPredicateType) { + const publicKey = record(verificationMaterial.publicKey, "npm publish public key"); + exactKeys(publicKey, ["hint"], "npm publish public key"); + const hint = stringField(publicKey, "hint", "npm publish public key"); + if (signature.keyid !== hint) { + throw new TypeError("npm publish signature key ID does not match its public key hint"); + } + } else { + const certificate = record(verificationMaterial.certificate, "SLSA signing certificate"); + exactKeys(certificate, ["rawBytes"], "SLSA signing certificate"); + canonicalBase64( + stringField(certificate, "rawBytes", "SLSA signing certificate"), + "SLSA signing certificate.rawBytes", + ); + if (signature.keyid !== "") { + throw new TypeError("SLSA provenance must use a keyless DSSE signature"); + } + } + const payload = stringField(envelope, "payload", `${predicateType} DSSE envelope`); + const decoded = canonicalBase64(payload, `${predicateType} DSSE payload`); + try { + return record( + JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(decoded)) as unknown, + `${predicateType} statement`, + ); + } catch (error) { + throw new TypeError(`${predicateType} DSSE payload is not UTF-8 JSON`, { cause: error }); + } +} + +function verifyPublishStatement( + statement: Record, + version: string, + tarballSha512: string, +): void { + exactKeys(statement, ["_type", "predicate", "predicateType", "subject"], "npm publish statement"); + if ( + statement._type !== "https://in-toto.io/Statement/v0.1" + || statement.predicateType !== publishPredicateType + ) { + throw new TypeError("npm publish statement has the wrong in-toto identity"); + } + verifySubject(statement, version, tarballSha512, "npm publish statement"); + const predicate = record(statement.predicate, "npm publish predicate"); + exactKeys(predicate, ["name", "registry", "version"], "npm publish predicate"); + if ( + predicate.name !== expectedName + || predicate.version !== version + || predicate.registry !== expectedRegistry + ) { + throw new TypeError("npm publish attestation does not identify the exact registry package"); + } +} + +function verifyProvenanceStatement( + statement: Record, + version: string, + tarballSha512: string, + sourceSha: string, +): string { + exactKeys(statement, ["_type", "predicate", "predicateType", "subject"], "SLSA provenance statement"); + if ( + statement._type !== "https://in-toto.io/Statement/v1" + || statement.predicateType !== provenancePredicateType + ) { + throw new TypeError("SLSA provenance statement has the wrong in-toto identity"); + } + verifySubject(statement, version, tarballSha512, "SLSA provenance statement"); + const predicate = record(statement.predicate, "SLSA provenance predicate"); + exactKeys(predicate, ["buildDefinition", "runDetails"], "SLSA provenance predicate"); + const buildDefinition = record(predicate.buildDefinition, "SLSA build definition"); + exactKeys( + buildDefinition, + ["buildType", "externalParameters", "internalParameters", "resolvedDependencies"], + "SLSA build definition", + ); + if (buildDefinition.buildType !== expectedBuildType) { + throw new TypeError("SLSA provenance has the wrong GitHub Actions build type"); + } + const external = record(buildDefinition.externalParameters, "SLSA external parameters"); + exactKeys(external, ["workflow"], "SLSA external parameters"); + const workflow = record(external.workflow, "SLSA workflow parameters"); + exactKeys(workflow, ["path", "ref", "repository"], "SLSA workflow parameters"); + if ( + workflow.path !== expectedWorkflowPath + || workflow.ref !== expectedWorkflowRef + || workflow.repository !== expectedRepository + ) { + throw new TypeError("SLSA provenance does not identify the exact main staging workflow"); + } + const internal = record(buildDefinition.internalParameters, "SLSA internal parameters"); + exactKeys(internal, ["github"], "SLSA internal parameters"); + const github = record(internal.github, "SLSA GitHub parameters"); + exactKeys( + github, + ["event_name", "repository_id", "repository_owner_id"], + "SLSA GitHub parameters", + ); + if ( + github.event_name !== expectedWorkflowEvent + || github.repository_id !== expectedRepositoryId + || github.repository_owner_id !== expectedRepositoryOwnerId + ) { + throw new TypeError("SLSA provenance has the wrong GitHub event or immutable repository identity"); + } + const dependencies = array(buildDefinition.resolvedDependencies, "SLSA resolved dependencies"); + if (dependencies.length !== 1) { + throw new TypeError("SLSA provenance must have exactly one resolved source dependency"); + } + const dependency = record(dependencies[0], "SLSA resolved dependency"); + exactKeys(dependency, ["digest", "uri"], "SLSA resolved dependency"); + const dependencyDigest = record(dependency.digest, "SLSA resolved dependency digest"); + exactKeys(dependencyDigest, ["gitCommit"], "SLSA resolved dependency digest"); + if ( + dependency.uri !== `git+${expectedRepository}@${expectedWorkflowRef}` + || dependencyDigest.gitCommit !== sourceSha + ) { + throw new TypeError("SLSA provenance does not bind the exact main source commit"); + } + const runDetails = record(predicate.runDetails, "SLSA run details"); + exactKeys(runDetails, ["builder", "metadata"], "SLSA run details"); + const builder = record(runDetails.builder, "SLSA builder"); + exactKeys(builder, ["id"], "SLSA builder"); + if (builder.id !== expectedBuilder) { + throw new TypeError("SLSA provenance was not produced by a GitHub-hosted runner"); + } + const metadata = record(runDetails.metadata, "SLSA run metadata"); + exactKeys(metadata, ["invocationId"], "SLSA run metadata"); + const invocationId = stringField(metadata, "invocationId", "SLSA run metadata"); + if (!/^https:\/\/github\.com\/hraness\/kb\/actions\/runs\/[1-9][0-9]*\/attempts\/[1-9][0-9]*$/u.test(invocationId)) { + throw new TypeError("SLSA provenance has a noncanonical GitHub Actions invocation"); + } + return invocationId; +} + +export function verifyNpmReleaseAttestation( + input: ReleaseAttestationInput, +): VerifiedReleaseAttestation { + stableVersion(input.expectedVersion); + if (!/^[a-f0-9]{40}$/u.test(input.expectedSourceSha)) { + throw new TypeError("Expected source SHA must be one lowercase Git commit"); + } + if (!/^[a-f0-9]{128}$/u.test(input.expectedTarballSha512)) { + throw new TypeError("Expected tarball SHA-512 must be one lowercase hexadecimal digest"); + } + if (input.registryLatest !== input.expectedVersion) { + throw new TypeError("npm latest does not identify the exact release version"); + } + + const registryView = record(input.registryView, "npm registry view"); + if (registryView.name !== expectedName || registryView.version !== input.expectedVersion) { + throw new TypeError("npm registry view does not identify the exact release package"); + } + const dist = record(registryView.dist, "npm registry view.dist"); + verifyRegistryIntegrity(dist.integrity, input.expectedTarballSha512); + assertCanonicalAttestations(dist.attestations, input.expectedVersion, "npm registry view.dist.attestations"); + const registrySignatures = array(dist.signatures, "npm registry view.dist.signatures"); + if (registrySignatures.length < 1) { + throw new TypeError("npm registry view has no registry signature"); + } + for (const [index, value] of registrySignatures.entries()) { + const signature = record(value, `npm registry signature ${String(index + 1)}`); + stringField(signature, "keyid", `npm registry signature ${String(index + 1)}`); + stringField(signature, "sig", `npm registry signature ${String(index + 1)}`); + } + + const audit = record(input.audit, "npm signature audit"); + if (array(audit.invalid, "npm signature audit.invalid").length !== 0) { + throw new TypeError("npm signature audit reports invalid cryptographic evidence"); + } + if (array(audit.missing, "npm signature audit.missing").length !== 0) { + throw new TypeError("npm signature audit reports missing registry signatures"); + } + const matching = array(audit.verified, "npm signature audit.verified") + .map((value, index) => record(value, `npm signature audit.verified[${String(index)}]`)) + .filter((value) => value.name === expectedName && value.version === input.expectedVersion); + if (matching.length !== 1) { + throw new TypeError("npm signature audit must verify the exact release package once"); + } + const verified = matching[0] as Record; + if ( + verified.location !== "node_modules/@hraness/kb" + || verified.registry !== expectedAuditRegistry + ) { + throw new TypeError("npm signature audit did not verify the isolated canonical package install"); + } + assertCanonicalAttestations( + verified.attestations, + input.expectedVersion, + "npm signature audit attestation metadata", + ); + const bundles = array(verified.attestationBundles, "npm signature audit attestation bundles"); + if (bundles.length !== 2) { + throw new TypeError("npm signature audit must verify exactly publish and provenance attestations"); + } + const byPredicate = new Map(); + for (const value of bundles) { + const attestation = record(value, "npm signature audit attestation bundle"); + const predicateType = stringField(attestation, "predicateType", "npm signature audit attestation bundle"); + if (byPredicate.has(predicateType)) { + throw new TypeError(`npm signature audit repeats ${predicateType}`); + } + byPredicate.set(predicateType, value); + } + if ( + byPredicate.size !== 2 + || !byPredicate.has(publishPredicateType) + || !byPredicate.has(provenancePredicateType) + ) { + throw new TypeError("npm signature audit has an unexpected attestation predicate set"); + } + const publishStatement = decodeStatement( + byPredicate.get(publishPredicateType), + publishPredicateType, + publishBundleMediaType, + ); + verifyPublishStatement(publishStatement, input.expectedVersion, input.expectedTarballSha512); + const provenanceStatement = decodeStatement( + byPredicate.get(provenancePredicateType), + provenancePredicateType, + provenanceBundleMediaType, + ); + const invocationId = verifyProvenanceStatement( + provenanceStatement, + input.expectedVersion, + input.expectedTarballSha512, + input.expectedSourceSha, + ); + return Object.freeze({ + invocationId, + sourceSha: input.expectedSourceSha, + tarballSha512: input.expectedTarballSha512, + version: input.expectedVersion, + }); +} + +function parseJson(source: string, label: string, maximumBytes: number): unknown { + if (Buffer.byteLength(source, "utf8") > maximumBytes) { + throw new TypeError(`${label} exceeds its size bound`); + } + try { + return JSON.parse(source) as unknown; + } catch (error) { + throw new TypeError(`${label} must be valid JSON`, { cause: error }); + } +} + +function resolvePath(value: string): string { + return isAbsolute(value) ? value : resolve(process.cwd(), value); +} + +async function main(): Promise { + const requiredFlags = [ + "--audit-json", + "--expected-source-sha", + "--expected-tarball-sha512", + "--expected-version", + "--registry-latest-json", + "--registry-view-json", + ] as const; + const args = process.argv.slice(2); + if (args.length !== requiredFlags.length * 2) { + throw new TypeError(`Usage: bun run scripts/npm-release-attestation.ts ${requiredFlags.map((flag) => `${flag} `).join(" ")}`); + } + const values = new Map(); + for (let index = 0; index < args.length; index += 2) { + const flag = args[index]; + const value = args[index + 1]; + if ( + flag === undefined + || value === undefined + || !requiredFlags.includes(flag as (typeof requiredFlags)[number]) + || values.has(flag) + ) { + throw new TypeError("npm release attestation arguments are unknown, duplicated, or incomplete"); + } + values.set(flag, value); + } + for (const flag of requiredFlags) { + if (!values.has(flag)) throw new TypeError(`Missing npm release attestation argument ${flag}`); + } + const auditPath = resolvePath(values.get("--audit-json") as string); + const latestPath = resolvePath(values.get("--registry-latest-json") as string); + const registryViewPath = resolvePath(values.get("--registry-view-json") as string); + const [auditSource, latestSource, registryViewSource] = await Promise.all([ + readFile(auditPath, "utf8"), + readFile(latestPath, "utf8"), + readFile(registryViewPath, "utf8"), + ]); + const result = verifyNpmReleaseAttestation({ + audit: parseJson(auditSource, "npm signature audit", maximumAuditBytes), + expectedSourceSha: values.get("--expected-source-sha") as string, + expectedTarballSha512: values.get("--expected-tarball-sha512") as string, + expectedVersion: values.get("--expected-version") as string, + registryLatest: parseJson(latestSource, "npm latest readback", 1_024), + registryView: parseJson(registryViewSource, "npm registry view", maximumRegistryViewBytes), + }); + console.log( + `Verified npm registry signature, publish attestation, and SLSA provenance for ${expectedName}@${result.version} from ${result.sourceSha}; invocation ${result.invocationId}.`, + ); +} + +if (import.meta.main) await main(); diff --git a/scripts/npm-release-workflow.test.ts b/scripts/npm-release-workflow.test.ts index 26eac7b..20791dd 100644 --- a/scripts/npm-release-workflow.test.ts +++ b/scripts/npm-release-workflow.test.ts @@ -1,10 +1,11 @@ import { describe, expect, test } from "bun:test"; import { createHash } from "node:crypto"; -import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { chmod, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { gunzipSync, gzipSync } from "node:zlib"; +import { parse } from "yaml"; import { inspectPackageArtifact, @@ -15,7 +16,7 @@ import { verifyNpmPackageIdentity, } from "./npm-package-identity.js"; -const publishWorkflowUrl = new URL("../.github/workflows/npm-stage.yml", import.meta.url); +const stageWorkflowUrl = new URL("../.github/workflows/npm-stage.yml", import.meta.url); const releaseWorkflowUrl = new URL("../.github/workflows/release.yml", import.meta.url); const ciWorkflowUrl = new URL("../.github/workflows/ci.yml", import.meta.url); const manifestUrl = new URL("../package.json", import.meta.url); @@ -44,6 +45,156 @@ function integrity(bytes: Uint8Array): string { return `sha512-${createHash("sha512").update(bytes).digest("base64")}`; } +function sha256(bytes: Uint8Array): string { + return createHash("sha256").update(bytes).digest("hex"); +} + +async function injectPackedTopLevelTag( + artifactDirectory: string, + tarballName: string, +): Promise { + const tarballPath = join(artifactDirectory, tarballName); + const metadataPath = join(artifactDirectory, "npm-pack.json"); + const digestPath = join(artifactDirectory, "npm-package.sha256"); + const tar = gunzipSync(await readFile(tarballPath)); + let offset = 0; + let replaced = false; + while (offset + 512 <= tar.length) { + const header = tar.subarray(offset, offset + 512); + offset += 512; + if (header.every((byte) => byte === 0)) break; + const readText = (start: number, length: number): string => { + const field = header.subarray(start, start + length); + const zero = field.indexOf(0); + return (zero < 0 ? field : field.subarray(0, zero)).toString("ascii"); + }; + const name = readText(0, 100); + const prefix = readText(345, 155); + const path = prefix === "" ? name : `${prefix}/${name}`; + const sizeText = readText(124, 12).trim(); + if (!/^[0-7]+$/u.test(sizeText)) throw new Error("Test tar entry size is invalid"); + const size = Number.parseInt(sizeText, 8); + if (path === "package/package.json") { + const source = tar.subarray(offset, offset + size).toString("utf8"); + const original = '"type": "module",'; + const hostile = '"tag": "beta", '; + if (original.length !== hostile.length || !source.includes(original)) { + throw new Error("Packed manifest lacks the fixed-width mutation target"); + } + Buffer.from(source.replace(original, hostile), "utf8").copy(tar, offset); + replaced = true; + } + offset += Math.ceil(size / 512) * 512; + } + if (!replaced) throw new Error("Packed manifest was not mutated"); + + const archive = gzipSync(tar); + const metadata = JSON.parse(await readFile(metadataPath, "utf8")) as Array>; + if (metadata.length !== 1 || metadata[0] === undefined) { + throw new Error("Test npm-pack.json is invalid"); + } + metadata[0].size = archive.byteLength; + metadata[0].integrity = integrity(archive); + metadata[0].shasum = sha1(archive); + const metadataBytes = Buffer.from(`${JSON.stringify(metadata)}\n`, "utf8"); + await Promise.all([ + writeFile(tarballPath, archive), + writeFile(metadataPath, metadataBytes), + writeFile( + digestPath, + `${sha256(archive)} ${tarballName}\n${sha256(metadataBytes)} npm-pack.json\n`, + ), + ]); +} + +async function corruptPackedUstarVersion( + artifactDirectory: string, + tarballName: string, +): Promise { + const tarballPath = join(artifactDirectory, tarballName); + const metadataPath = join(artifactDirectory, "npm-pack.json"); + const digestPath = join(artifactDirectory, "npm-package.sha256"); + const tar = gunzipSync(await readFile(tarballPath)); + const signature = Buffer.from([0x75, 0x73, 0x74, 0x61, 0x72, 0x00, 0x30, 0x30]); + if (!tar.subarray(257, 265).equals(signature)) { + throw new Error("Test archive lacks an exact USTAR magic/version header"); + } + tar[263] = 0x78; + tar[264] = 0x78; + writeHeaderChecksum(tar, 0); + const archive = gzipSync(tar); + const metadata = JSON.parse(await readFile(metadataPath, "utf8")) as Array>; + if (metadata.length !== 1 || metadata[0] === undefined) { + throw new Error("Test npm-pack.json is invalid"); + } + metadata[0].size = archive.byteLength; + metadata[0].integrity = integrity(archive); + metadata[0].shasum = sha1(archive); + const metadataBytes = Buffer.from(`${JSON.stringify(metadata)}\n`, "utf8"); + await Promise.all([ + writeFile(tarballPath, archive), + writeFile(metadataPath, metadataBytes), + writeFile( + digestPath, + `${sha256(archive)} ${tarballName}\n${sha256(metadataBytes)} npm-pack.json\n`, + ), + ]); +} + +function requireOwnerReleaseAuthorization(workflow: string): void { + const start = workflow.indexOf(" authorize:\n"); + const end = workflow.indexOf("\n verify:\n"); + if (start === -1 || end === -1 || end <= start) { + throw new Error("release.yml is missing the leading authorization job"); + } + const authorize = workflow.slice(start, end); + if (!authorize.includes('"$GITHUB_ACTOR_ID" != "$EXPECTED_ACTOR_ID"')) { + throw new Error("release.yml is missing the exact event actor guard"); + } + if (!authorize.includes("event.sender?.id !== Number(process.env.EXPECTED_ACTOR_ID)")) { + throw new Error("release.yml is missing the exact event sender guard"); + } + if (!authorize.includes('event.sender?.type !== "User"')) { + throw new Error("release.yml is missing the immutable sender type guard"); + } + const firstCheckout = workflow.indexOf("actions/checkout@"); + if (firstCheckout === -1 || firstCheckout < end) { + throw new Error("release.yml must authorize before checkout"); + } +} + +function workflowStepScript(workflow: string, name: string): string { + const parsed = parse(workflow) as Readonly<{ + jobs?: Readonly[]; + }>>>; + }>; + for (const job of Object.values(parsed.jobs ?? {})) { + for (const step of job.steps ?? []) { + if (step.name === name && typeof step.run === "string") return step.run; + } + } + throw new Error(`Workflow run step not found: ${name}`); +} + +async function runWorkflowScript( + script: string, + environment: Readonly>, +): Promise> { + const child = Bun.spawn(["/bin/bash", "-c", script], { + cwd: repository, + env: { ...process.env, ...environment }, + stderr: "pipe", + stdout: "pipe", + }); + const [exitCode, stderr, stdout] = await Promise.all([ + child.exited, + new Response(child.stderr).text(), + new Response(child.stdout).text(), + ]); + return Object.freeze({ exitCode, stderr, stdout }); +} + describe("package smoke version policy", () => { test("requires the Oh adoption preparer only from its stable introduction", () => { expect(requiresOhAdoptionPreparerExport("0.17.1")).toBe(false); @@ -52,6 +203,9 @@ describe("package smoke version policy", () => { expect(requiresOhAdoptionPreparerExport("0.18.1")).toBe(true); expect(requiresOhAdoptionPreparerExport("0.19.0")).toBe(true); expect(requiresOhAdoptionPreparerExport("1.0.0")).toBe(true); + expect(requiresOhAdoptionPreparerExport( + "9007199254740991.9007199254740991.9007199254740991", + )).toBe(true); }); test("rejects noncanonical or non-stable package versions", () => { @@ -68,6 +222,15 @@ describe("package smoke version policy", () => { "canonical stable semantic version", ); } + for (const version of [ + "9007199254740992.0.0", + "0.9007199254740992.0", + "0.0.9007199254740992", + ]) { + expect(() => requiresOhAdoptionPreparerExport(version)).toThrow( + "Number.MAX_SAFE_INTEGER", + ); + } }); }); @@ -176,48 +339,42 @@ describe("npm release workflows", () => { ]) expect(readme).toContain(link); }); - test("keeps the exact terminal OIDC publication independent from repository code", async () => { - const workflow = await readFile(publishWorkflowUrl, "utf8"); - const authorizeStart = workflow.indexOf("\n authorize:\n"); + test("keeps the exact terminal OIDC stage independent from repository code", async () => { + const workflow = await readFile(stageWorkflowUrl, "utf8"); const selectStart = workflow.indexOf("\n select:\n"); const verifyStart = workflow.indexOf("\n verify:\n"); - const publishStart = workflow.indexOf("\n publish:\n"); - expect(authorizeStart).toBeGreaterThan(-1); - expect(selectStart).toBeGreaterThan(authorizeStart); + const stageStart = workflow.indexOf("\n stage:\n"); + expect(selectStart).toBeGreaterThan(-1); expect(verifyStart).toBeGreaterThan(selectStart); - expect(publishStart).toBeGreaterThan(verifyStart); - const authorizeJob = workflow.slice(authorizeStart, selectStart); + expect(stageStart).toBeGreaterThan(verifyStart); const selectJob = workflow.slice(selectStart, verifyStart); - const verifyJob = workflow.slice(verifyStart, publishStart); - const publishJob = workflow.slice(publishStart); - - for (const required of [ - "name: Authorize owner release tag", - 'EXPECTED_ACTOR_ID: "894119"', - 'EXPECTED_REPOSITORY_ID: "1308971873"', - "GITHUB_ACTOR_ID", - 'event.sender?.type !== "User"', - 'event.repository?.visibility !== "public"', - 'event.repository?.private !== false', - "github.ref_protected", - ] as const) expect(authorizeJob).toContain(required); - expect(authorizeJob).not.toContain("actions/checkout@"); + const verifyJob = workflow.slice(verifyStart, stageStart); + const stageJob = workflow.slice(stageStart); for (const required of [ - "name: Select publishable package version", - "contents: read", - "publish_tag: ${{ steps.selection.outputs.publish_tag }}", - "should_publish: ${{ steps.selection.outputs.should_publish }}", - "needs: authorize", - "github.ref_protected", - "Publication requires the exact annotated release tag", + "name: Select stable package version", + "permissions:\n contents: read", + "should_stage: ${{ steps.selection.outputs.should_stage }}", + "BEFORE_SHA: ${{ github.event.before }}", + 'expected_ref="refs/heads/$DEFAULT_BRANCH"', + 'git merge-base --is-ancestor "$BEFORE_SHA" "$default_head"', + 'git show "$BEFORE_SHA:package.json"', + 'bun run ./scripts/npm-stage-selection.ts "${selection_args[@]}"', ] as const) expect(selectJob).toContain(required); expect(selectJob).not.toContain("id-token: write"); + for (const required of [ + "publish_to_npm:", + "resolved_stage_version:", + "required: false", + "default: false", + "type: boolean", + ] as const) expect(workflow).toContain(required); + for (const required of [ "name: Verify exact package", "needs: select", - "if: needs.select.outputs.should_publish == 'true'", + "if: needs.select.outputs.should_stage == 'true'", "permissions:\n contents: read", "source_sha: ${{ steps.identity.outputs.source_sha }}", "artifact_name: ${{ steps.artifact.outputs.artifact_name }}", @@ -233,11 +390,22 @@ describe("npm release workflows", () => { "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a", ] as const) expect(verifyJob).toContain(required); expect(verifyJob).not.toContain("id-token: write"); - expect(verifyJob).not.toMatch(/\bnpm publish\b/u); + expect(verifyJob).not.toContain("npm stage publish"); for (const required of [ + "name: Stage exact package v${{ needs.verify.outputs.package_version }}", + "if: inputs.publish_to_npm == true", "environment: npm-stage", - "id-token: write", + "permissions:\n actions: read\n id-token: write", + "Reauthorize current npm staging attempt", + 'EXPECTED_WORKFLOW_ID: "344070109"', + 'PUBLISH_TO_NPM: ${{ inputs.publish_to_npm }}', + 'REF_PROTECTED: ${{ github.ref_protected }}', + "attempt.triggering_actor?.id !== actorId", + "Reject another pending stable stage", + "Completed npm-stage history exceeds the reviewed 100-run bound", + "already staged pending", + "RESOLVED_STAGE_VERSION: ${{ inputs.resolved_stage_version }}", "actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c", "Downloaded npm artifact must contain exactly the tarball, npm-pack.json, and npm-package.sha256", 'expected_tarball_name="hraness-kb-$EXPECTED_VERSION.tgz"', @@ -249,51 +417,735 @@ describe("npm release workflows", () => { 'createHash("sha1")', 'createHash("sha512")', 'createHash("sha256")', + 'gunzipSync(archiveBytes', + 'header.subarray(257, 265).equals(ustarSignature)', + 'Object.hasOwn(manifest, "tag")', + 'JSON.stringify(Object.keys(publishConfig).sort()) !== JSON.stringify(["access", "registry"])', 'git init --quiet --bare "$current_main"', '"https://github.com/$GITHUB_REPOSITORY.git"', 'EXPECTED_VERSION: ${{ needs.verify.outputs.package_version }}', 'release_tag="v$EXPECTED_VERSION"', - 'local_release_ref="refs/owner-release-tags/$release_tag"', - 'release_commit="$(git --git-dir="$current_main" rev-parse', - "Owner-created release tag identifies", + "git ls-remote --exit-code --refs", + '"refs/tags/$release_tag" > "$tag_lookup_output"', + 'tag_lookup_status=$?', + '[[ "$tag_lookup_status" -ne 2 || -s "$tag_lookup_output" ]]', + "Could not prove that tag $release_tag is still absent from origin", 'current_archive_sha256="$(sha256sum "$TARBALL"', 'current_metadata_sha256="$(sha256sum "$METADATA"', 'current_digest_sha256="$(sha256sum "$DIGEST"', - 'npm publish "$TARBALL"', - '--access public', + 'npm stage publish "$TARBALL"', "--ignore-scripts", "--provenance", - '--tag "$PUBLISH_TAG"', - 'npm view "$package_spec" dist --json', - '"dist-tags.$PUBLISH_TAG" --json', - 'dist.attestations.provenance?.predicateType', - 'dist.signatures.length < 1', + "npm config get tag", + "Pinned npm's clean default publication tag is not latest", + '--globalconfig="$clean_global_config"', + '--userconfig="$clean_user_config"', `--registry=${npmRegistry}`, - ] as const) expect(publishJob).toContain(required); + ] as const) expect(stageJob).toContain(required); expect(workflow.match(/id-token: write/gu) ?? []).toHaveLength(1); - expect(publishJob).not.toContain("contents: read"); - expect(publishJob).not.toContain("actions/checkout@"); - expect(publishJob).not.toContain("setup-bun@"); - expect(publishJob).not.toMatch(/\bbun\b/u); - expect(publishJob).not.toContain("./scripts/"); - expect(publishJob.match(/npm publish/gu) ?? []).toHaveLength(1); - const fetchIndex = publishJob.lastIndexOf('git --git-dir="$current_main" fetch'); - const tagLookupIndex = publishJob.lastIndexOf('release_commit="$(git --git-dir="$current_main" rev-parse'); - const rehashIndex = publishJob.lastIndexOf('current_archive_sha256="$(sha256sum "$TARBALL"'); - const publishIndex = publishJob.indexOf('npm publish "$TARBALL"'); - const readbackIndex = publishJob.indexOf('npm view "$package_spec" dist --json'); + expect(stageJob).not.toContain("contents: read"); + expect(stageJob).not.toContain("actions/checkout@"); + expect(stageJob).not.toContain("setup-bun@"); + expect(stageJob).not.toMatch(/\bbun\b/u); + expect(stageJob).not.toContain("./scripts/"); + expect(stageJob.match(/npm stage publish/gu) ?? []).toHaveLength(1); + expect(stageJob).not.toContain("--tag latest"); + const authorizationIndex = stageJob.indexOf("Reauthorize current npm staging attempt"); + const setupIndex = stageJob.indexOf("actions/setup-node@"); + const pendingStageIndex = stageJob.indexOf("Reject another pending stable stage"); + const fetchIndex = stageJob.lastIndexOf('git --git-dir="$current_main" fetch'); + const tagLookupIndex = stageJob.lastIndexOf("git ls-remote --exit-code --refs"); + const rehashIndex = stageJob.lastIndexOf('current_archive_sha256="$(sha256sum "$TARBALL"'); + const stageIndex = stageJob.indexOf('npm stage publish "$TARBALL"'); + expect(authorizationIndex).toBeGreaterThan(-1); + expect(authorizationIndex).toBeLessThan(setupIndex); + expect(pendingStageIndex).toBeGreaterThan(setupIndex); + expect(pendingStageIndex).toBeLessThan(stageIndex); expect(fetchIndex).toBeGreaterThan(-1); expect(fetchIndex).toBeLessThan(tagLookupIndex); expect(tagLookupIndex).toBeLessThan(rehashIndex); - expect(rehashIndex).toBeLessThan(publishIndex); - expect(publishIndex).toBeLessThan(readbackIndex); + expect(rehashIndex).toBeLessThan(stageIndex); expect(workflow).not.toContain("secrets.NPM_TOKEN"); expect(workflow).not.toContain("NODE_AUTH_TOKEN"); - expect(workflow).not.toMatch(/\bnpm\s+(?:stage|dist-tag)\b/u); - expect(workflow).not.toContain("workflow_dispatch:"); - expect(workflow).not.toContain("actions: write"); - expect(workflow).not.toContain("authorization_run_id"); - expect(workflow).toContain('tags:\n - "v*"'); + expect(workflow).not.toMatch(/\bnpm publish\b/u); + expect(workflow).toContain('branches: [main]\n paths:\n - "package.json"'); + expect(workflow).toContain("workflow_dispatch:"); + expect(workflow).toContain("publish_to_npm:"); + }); + + test("the staging job reauthorizes the exact attempt and rejects collaborator reruns", async () => { + const workflow = await readFile(stageWorkflowUrl, "utf8"); + const stageJob = workflow.slice(workflow.indexOf("\n stage:\n")); + const authorizationIndex = stageJob.indexOf("Reauthorize current npm staging attempt"); + const setupIndex = stageJob.indexOf("actions/setup-node@"); + const mutationIndex = stageJob.indexOf('npm stage publish "$TARBALL"'); + expect(stageJob).toContain("permissions:\n actions: read\n id-token: write"); + expect(authorizationIndex).toBeGreaterThan(-1); + expect(authorizationIndex).toBeLessThan(setupIndex); + expect(setupIndex).toBeLessThan(mutationIndex); + + const script = workflowStepScript(workflow, "Reauthorize current npm staging attempt"); + const directory = await mkdtemp(join(tmpdir(), "kb-stage-attempt-")); + const binaryDirectory = join(directory, "bin"); + const attemptPath = join(directory, "attempt.json"); + const workflowPath = join(directory, "workflow.json"); + const repositoryPath = join(directory, "repository.json"); + const commandLog = join(directory, "gh.log"); + const sourceSha = "a".repeat(40); + const attempt = { + id: 45678, + run_attempt: 2, + workflow_id: 344070109, + name: "Stage npm package", + path: ".github/workflows/npm-stage.yml", + event: "workflow_dispatch", + head_branch: "main", + head_sha: sourceSha, + status: "in_progress", + conclusion: null, + actor: { id: 894119, type: "User" }, + triggering_actor: { id: 894119, type: "User" }, + repository: { + id: 1308971873, + full_name: "hraness/kb", + private: false, + }, + }; + + try { + await mkdir(binaryDirectory, { recursive: true }); + await writeFile( + join(binaryDirectory, "gh"), + [ + "#!/bin/bash", + "set -euo pipefail", + 'printf \'%s\\n\' "$*" >> "$GH_COMMAND_LOG"', + 'endpoint=""', + 'for argument in "$@"; do endpoint="$argument"; done', + 'case "$endpoint" in', + ' */actions/runs/*) cat "$MOCK_ATTEMPT_JSON" ;;', + ' */actions/workflows/*) cat "$MOCK_WORKFLOW_JSON" ;;', + ' /repos/hraness/kb) cat "$MOCK_REPOSITORY_JSON" ;;', + ' *) echo "unexpected gh endpoint: $endpoint" >&2; exit 2 ;;', + "esac", + ].join("\n"), + ); + await chmod(join(binaryDirectory, "gh"), 0o755); + await Promise.all([ + writeFile(attemptPath, JSON.stringify(attempt)), + writeFile(workflowPath, JSON.stringify({ + id: 344070109, + name: "Stage npm package", + path: ".github/workflows/npm-stage.yml", + state: "active", + })), + writeFile(repositoryPath, JSON.stringify({ + id: 1308971873, + full_name: "hraness/kb", + visibility: "public", + private: false, + default_branch: "main", + })), + ]); + const environment = { + PATH: `${binaryDirectory}:${process.env.PATH ?? ""}`, + GH_COMMAND_LOG: commandLog, + MOCK_ATTEMPT_JSON: attemptPath, + MOCK_WORKFLOW_JSON: workflowPath, + MOCK_REPOSITORY_JSON: repositoryPath, + RUNNER_TEMP: directory, + EXPECTED_ACTOR_ID: "894119", + EXPECTED_REPOSITORY: "hraness/kb", + EXPECTED_REPOSITORY_ID: "1308971873", + EXPECTED_SOURCE_SHA: sourceSha, + EXPECTED_WORKFLOW_ID: "344070109", + EXPECTED_WORKFLOW_NAME: "Stage npm package", + EXPECTED_WORKFLOW_PATH: ".github/workflows/npm-stage.yml", + PUBLISH_TO_NPM: "true", + REF_PROTECTED: "true", + GITHUB_RUN_ID: "45678", + GITHUB_RUN_ATTEMPT: "2", + GITHUB_EVENT_NAME: "workflow_dispatch", + GITHUB_ACTOR_ID: "894119", + GITHUB_REPOSITORY: "hraness/kb", + GITHUB_REPOSITORY_ID: "1308971873", + GITHUB_REF: "refs/heads/main", + GITHUB_SHA: sourceSha, + }; + const admitted = await runWorkflowScript(script, environment); + expect(admitted.exitCode).toBe(0); + expect(await readFile(commandLog, "utf8")).toContain( + "actions/runs/45678/attempts/2", + ); + + await writeFile(attemptPath, JSON.stringify({ + ...attempt, + triggering_actor: { id: 123456, type: "User" }, + })); + const hostileRerun = await runWorkflowScript(script, environment); + expect(hostileRerun.exitCode).not.toBe(0); + expect(hostileRerun.stderr).toContain( + "Current npm staging attempt is not owner-authorized", + ); + + await writeFile(attemptPath, JSON.stringify({ ...attempt, head_sha: "b".repeat(40) })); + const sourceDrift = await runWorkflowScript(script, environment); + expect(sourceDrift.exitCode).not.toBe(0); + expect(sourceDrift.stderr).toContain( + "Current npm staging attempt is not owner-authorized", + ); + + const falseInput = await runWorkflowScript(script, { + ...environment, + PUBLISH_TO_NPM: "false", + }); + expect(falseInput.exitCode).not.toBe(0); + expect(falseInput.stdout).toContain( + "Current npm staging attempt is not the explicit owner-authorized protected-main dispatch", + ); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + test("the source-free staging boundary accepts MAX_SAFE_INTEGER and rejects larger components", async () => { + const workflow = await readFile(stageWorkflowUrl, "utf8"); + const script = workflowStepScript(workflow, "Bind artifact reference"); + const sourceSha = "a".repeat(40); + const maximum = "9007199254740991"; + const baseEnvironment = { + ARTIFACT_NAME: "", + EXPECTED_SOURCE_SHA: sourceSha, + EXPECTED_VERSION: "", + GITHUB_RUN_ATTEMPT: "3", + GITHUB_RUN_ID: "45678", + }; + const maximumVersion = `${maximum}.${maximum}.${maximum}`; + const admitted = await runWorkflowScript(script, { + ...baseEnvironment, + ARTIFACT_NAME: `npm-package-${maximumVersion}-${sourceSha}-45678-3`, + EXPECTED_VERSION: maximumVersion, + }); + expect(admitted.exitCode).toBe(0); + + for (const unsafeVersion of [ + "9007199254740992.0.0", + "0.9007199254740992.0", + "0.0.9007199254740992", + ]) { + const rejected = await runWorkflowScript(script, { + ...baseEnvironment, + ARTIFACT_NAME: `npm-package-${unsafeVersion}-${sourceSha}-45678-3`, + EXPECTED_VERSION: unsafeVersion, + }); + expect(rejected.exitCode).not.toBe(0); + expect(rejected.stderr).toContain( + "Verified package version components exceed Number.MAX_SAFE_INTEGER", + ); + } + }); + + test("a completed stage remains a durable lock until that version is public latest", async () => { + const workflow = await readFile(stageWorkflowUrl, "utf8"); + const script = workflowStepScript(workflow, "Reject another pending stable stage"); + const directory = await mkdtemp(join(tmpdir(), "kb-stage-history-")); + const binaryDirectory = join(directory, "bin"); + const runsPath = join(directory, "runs.json"); + const jobsPath = join(directory, "jobs.json"); + try { + await mkdir(binaryDirectory, { recursive: true }); + await writeFile( + join(binaryDirectory, "npm"), + [ + "#!/bin/bash", + "set -euo pipefail", + "printf '\"%s\"\\n' \"$MOCK_NPM_LATEST\"", + ].join("\n"), + ); + await writeFile( + join(binaryDirectory, "gh"), + [ + "#!/bin/bash", + "set -euo pipefail", + 'case "$*" in', + ' *"/actions/workflows/344070109/runs?"*) cat "$MOCK_RUNS_JSON" ;;', + ' *"/actions/runs/12345/jobs?"*|*"/actions/runs/33269920554/jobs?"*) cat "$MOCK_JOBS_JSON" ;;', + ' *) echo "unexpected gh request: $*" >&2; exit 2 ;;', + "esac", + ].join("\n"), + ); + await Promise.all([ + chmod(join(binaryDirectory, "npm"), 0o755), + chmod(join(binaryDirectory, "gh"), 0o755), + writeFile(runsPath, JSON.stringify({ + total_count: 1, + workflow_runs: [{ + id: 12345, + workflow_id: 344070109, + event: "workflow_dispatch", + head_branch: "main", + status: "completed", + }], + })), + ]); + const environment = { + PATH: `${binaryDirectory}:${process.env.PATH ?? ""}`, + EXPECTED_VERSION: "0.19.2", + EXPECTED_WORKFLOW_ID: "344070109", + GITHUB_REPOSITORY: "hraness/kb", + GITHUB_RUN_ID: "67890", + MOCK_NPM_LATEST: "0.19.0", + MOCK_RUNS_JSON: runsPath, + MOCK_JOBS_JSON: jobsPath, + RESOLVED_STAGE_VERSION: "", + }; + + await writeFile(jobsPath, JSON.stringify({ + total_count: 1, + jobs: [{ name: "Stage exact package v0.19.0", conclusion: "success" }], + })); + const released = await runWorkflowScript(script, environment); + expect(released.exitCode).toBe(0); + + await writeFile(jobsPath, JSON.stringify({ + total_count: 1, + jobs: [{ name: "Stage exact package v0.19.1", conclusion: "success" }], + })); + const pending = await runWorkflowScript(script, environment); + expect(pending.exitCode).not.toBe(0); + expect(pending.stderr).toContain( + "run 12345 already staged pending 0.19.1", + ); + + const rejectedInNpm = await runWorkflowScript(script, { + ...environment, + RESOLVED_STAGE_VERSION: "0.19.1", + }); + expect(rejectedInNpm.exitCode).toBe(0); + + await writeFile(jobsPath, JSON.stringify({ + total_count: 1, + jobs: [{ name: "Stage exact package", conclusion: "success" }], + })); + const unboundHistory = await runWorkflowScript(script, environment); + expect(unboundHistory.exitCode).not.toBe(0); + expect(unboundHistory.stderr).toContain("lacks a version-bound stage job"); + + await Promise.all([ + writeFile(runsPath, JSON.stringify({ + total_count: 1, + workflow_runs: [{ + id: 33269920554, + workflow_id: 344070109, + event: "workflow_dispatch", + head_branch: "main", + status: "completed", + }], + })), + writeFile(jobsPath, JSON.stringify({ + total_count: 1, + jobs: [{ + name: "Stage exact package", + conclusion: "success", + head_sha: "e12d3fd05ffaa722ac1c43a8ecaa7d21fece679a", + run_attempt: 1, + }], + })), + ]); + const sealedLegacyStage = await runWorkflowScript(script, environment); + expect(sealedLegacyStage.exitCode).toBe(0); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + test("the source-free staging boundary rejects npm's packed top-level tag override", async () => { + const workflow = await readFile(stageWorkflowUrl, "utf8"); + const script = workflowStepScript(workflow, "Rebind downloaded package"); + const manifest = JSON.parse(await readFile(manifestUrl, "utf8")) as { readonly version: string }; + const root = await mkdtemp(join(tmpdir(), "kb-stage-packed-manifest-")); + const artifactDirectory = join(root, "kb-npm-stage"); + const tarballName = `hraness-kb-${manifest.version}.tgz`; + const githubOutput = join(root, "github-output.txt"); + try { + await run([ + process.execPath, + "run", + "./scripts/prepare-npm-package.ts", + artifactDirectory, + ], repository); + const [archiveBytes, metadataBytes] = await Promise.all([ + readFile(join(artifactDirectory, tarballName)), + readFile(join(artifactDirectory, "npm-pack.json")), + ]); + await writeFile( + join(artifactDirectory, "npm-package.sha256"), + `${sha256(archiveBytes)} ${tarballName}\n${sha256(metadataBytes)} npm-pack.json\n`, + ); + const environment = { + EXPECTED_SOURCE_SHA: "a".repeat(40), + EXPECTED_TARBALL_NAME: tarballName, + EXPECTED_VERSION: manifest.version, + GITHUB_OUTPUT: githubOutput, + RUNNER_TEMP: root, + }; + const accepted = await runWorkflowScript(script, environment); + if (accepted.exitCode !== 0) { + throw new Error(`Canonical packed manifest was rejected:\n${accepted.stderr}${accepted.stdout}`); + } + + await injectPackedTopLevelTag(artifactDirectory, tarballName); + const rejected = await runWorkflowScript(script, environment); + expect(rejected.exitCode).not.toBe(0); + expect(rejected.stderr).toContain( + "Packed KB can publish only with the canonical npm registry and dist-tag policy", + ); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + test("the source-free staging boundary rejects a USTAR version/path differential", async () => { + const workflow = await readFile(stageWorkflowUrl, "utf8"); + const script = workflowStepScript(workflow, "Rebind downloaded package"); + const manifest = JSON.parse(await readFile(manifestUrl, "utf8")) as { readonly version: string }; + const root = await mkdtemp(join(tmpdir(), "kb-stage-ustar-version-")); + const artifactDirectory = join(root, "kb-npm-stage"); + const tarballName = `hraness-kb-${manifest.version}.tgz`; + try { + await run([ + process.execPath, + "run", + "./scripts/prepare-npm-package.ts", + artifactDirectory, + ], repository); + await corruptPackedUstarVersion(artifactDirectory, tarballName); + const rejected = await runWorkflowScript(script, { + EXPECTED_SOURCE_SHA: "a".repeat(40), + EXPECTED_TARBALL_NAME: tarballName, + EXPECTED_VERSION: manifest.version, + GITHUB_OUTPUT: join(root, "github-output.txt"), + RUNNER_TEMP: root, + }); + expect(rejected.exitCode).not.toBe(0); + expect(rejected.stderr).toContain("Packed package.json tar header is invalid"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + test("hostile actor or sender drift cannot reach the protected release workflow", async () => { + const workflow = await readFile(releaseWorkflowUrl, "utf8"); + expect(() => requireOwnerReleaseAuthorization(workflow)).not.toThrow(); + + const actorDrift = workflow.replace( + '"$GITHUB_ACTOR_ID" != "$EXPECTED_ACTOR_ID"', + '"$GITHUB_ACTOR_ID" == "$EXPECTED_ACTOR_ID"', + ); + expect(actorDrift).not.toBe(workflow); + expect(() => requireOwnerReleaseAuthorization(actorDrift)).toThrow( + "exact event actor guard", + ); + + const senderDrift = workflow.replace( + "event.sender?.id !== Number(process.env.EXPECTED_ACTOR_ID)", + "event.sender?.id !== 894120", + ); + expect(senderDrift).not.toBe(workflow); + expect(() => requireOwnerReleaseAuthorization(senderDrift)).toThrow( + "exact event sender guard", + ); + }); + + test("the write job reauthorizes the exact run attempt and rejects collaborator reruns", async () => { + const workflow = await readFile(releaseWorkflowUrl, "utf8"); + const publishJob = workflow.slice(workflow.indexOf("\n publish:\n")); + const authorizationIndex = publishJob.indexOf("Reauthorize current release attempt"); + const liveTagIndex = publishJob.indexOf('current_tag_sha="$(gh api'); + const mutationIndex = publishJob.indexOf('gh release create "$VERIFIED_TAG"'); + const npmLatestIndex = publishJob.indexOf('npm view "@hraness/kb" dist-tags.latest'); + expect(publishJob).toContain("permissions:\n actions: read\n contents: write"); + expect(authorizationIndex).toBeGreaterThan(-1); + expect(authorizationIndex).toBeLessThan(liveTagIndex); + expect(liveTagIndex).toBeLessThan(mutationIndex); + expect(npmLatestIndex).toBeGreaterThan(liveTagIndex); + expect(npmLatestIndex).toBeLessThan(mutationIndex); + expect(publishJob).toContain( + '"/repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/attempts/$GITHUB_RUN_ATTEMPT"', + ); + expect(publishJob).toContain('EXPECTED_WORKFLOW_ID: "320004141"'); + expect(publishJob).toContain("attempt.triggering_actor?.id !== actorId"); + expect(publishJob).toContain('attempt.triggering_actor?.type !== "User"'); + expect(publishJob).toContain('repository.visibility !== "public"'); + expect(publishJob).toContain('EXPECTED_ACTIONS_BOT_ID="41898282"'); + expect(publishJob).toContain("Automated immutable release for @hraness/kb@"); + expect(publishJob).toContain("GitHub Release is not the exact immutable artifact created by this authorized Actions run"); + + const script = workflowStepScript(workflow, "Reauthorize current release attempt"); + const directory = await mkdtemp(join(tmpdir(), "kb-release-attempt-")); + const binaryDirectory = join(directory, "bin"); + const attemptPath = join(directory, "attempt.json"); + const workflowPath = join(directory, "workflow.json"); + const repositoryPath = join(directory, "repository.json"); + const commandLog = join(directory, "gh.log"); + const sourceSha = "b".repeat(40); + const attempt = { + id: 67890, + run_attempt: 3, + workflow_id: 320004141, + name: "Release", + path: ".github/workflows/release.yml", + event: "push", + head_branch: "v0.20.0", + head_sha: sourceSha, + status: "in_progress", + conclusion: null, + actor: { id: 894119, type: "User" }, + triggering_actor: { id: 894119, type: "User" }, + repository: { + id: 1308971873, + full_name: "hraness/kb", + private: false, + }, + }; + + try { + await mkdir(binaryDirectory, { recursive: true }); + await writeFile( + join(binaryDirectory, "gh"), + [ + "#!/bin/bash", + "set -euo pipefail", + 'printf \'%s\\n\' "$*" >> "$GH_COMMAND_LOG"', + 'endpoint=""', + 'for argument in "$@"; do endpoint="$argument"; done', + 'case "$endpoint" in', + ' */actions/runs/*) cat "$MOCK_ATTEMPT_JSON" ;;', + ' */actions/workflows/*) cat "$MOCK_WORKFLOW_JSON" ;;', + ' /repos/hraness/kb) cat "$MOCK_REPOSITORY_JSON" ;;', + ' *) echo "unexpected gh endpoint: $endpoint" >&2; exit 2 ;;', + "esac", + ].join("\n"), + ); + await chmod(join(binaryDirectory, "gh"), 0o755); + await Promise.all([ + writeFile(attemptPath, JSON.stringify(attempt)), + writeFile(workflowPath, JSON.stringify({ + id: 320004141, + name: "Release", + path: ".github/workflows/release.yml", + state: "active", + })), + writeFile(repositoryPath, JSON.stringify({ + id: 1308971873, + full_name: "hraness/kb", + visibility: "public", + private: false, + default_branch: "main", + })), + ]); + const environment = { + PATH: `${binaryDirectory}:${process.env.PATH ?? ""}`, + GH_COMMAND_LOG: commandLog, + MOCK_ATTEMPT_JSON: attemptPath, + MOCK_WORKFLOW_JSON: workflowPath, + MOCK_REPOSITORY_JSON: repositoryPath, + RUNNER_TEMP: directory, + EXPECTED_ACTOR_ID: "894119", + EXPECTED_REPOSITORY: "hraness/kb", + EXPECTED_REPOSITORY_ID: "1308971873", + EXPECTED_WORKFLOW_ID: "320004141", + EXPECTED_WORKFLOW_NAME: "Release", + EXPECTED_WORKFLOW_PATH: ".github/workflows/release.yml", + GITHUB_RUN_ID: "67890", + GITHUB_RUN_ATTEMPT: "3", + GITHUB_EVENT_NAME: "push", + GITHUB_REPOSITORY: "hraness/kb", + GITHUB_REPOSITORY_ID: "1308971873", + GITHUB_REF: "refs/tags/v0.20.0", + VERIFIED_SOURCE_SHA: sourceSha, + VERIFIED_TAG: "v0.20.0", + }; + const admitted = await runWorkflowScript(script, environment); + expect(admitted.exitCode).toBe(0); + expect(await readFile(commandLog, "utf8")).toContain( + "actions/runs/67890/attempts/3", + ); + + await writeFile(attemptPath, JSON.stringify({ + ...attempt, + triggering_actor: { id: 123456, type: "User" }, + })); + const hostileRerun = await runWorkflowScript(script, environment); + expect(hostileRerun.exitCode).not.toBe(0); + expect(hostileRerun.stderr).toContain( + "Current release attempt is not owner-authorized", + ); + + await writeFile(attemptPath, JSON.stringify(attempt)); + await writeFile(repositoryPath, JSON.stringify({ + id: 1308971873, + full_name: "hraness/kb", + visibility: "private", + private: true, + default_branch: "main", + })); + const privateRepository = await runWorkflowScript(script, environment); + expect(privateRepository.exitCode).not.toBe(0); + expect(privateRepository.stderr).toContain( + "Current release attempt is not owner-authorized", + ); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + test("release ordering fails closed on oversized numeric tags and releases", async () => { + const workflow = await readFile(releaseWorkflowUrl, "utf8"); + const script = workflowStepScript(workflow, "Publish verified GitHub Release"); + const directory = await mkdtemp(join(tmpdir(), "kb-release-ordering-")); + const binaryDirectory = join(directory, "bin"); + const commandLog = join(directory, "gh.log"); + const sourceSha = "b".repeat(40); + try { + await mkdir(binaryDirectory, { recursive: true }); + await writeFile( + join(binaryDirectory, "gh"), + [ + "#!/bin/bash", + "set -euo pipefail", + 'printf \'%s\\n\' "$*" >> "$GH_COMMAND_LOG"', + 'case "$*" in', + ' *"/commits/v0.20.0"*) printf \'%s\\n\' "$MOCK_SOURCE_SHA" ;;', + ' *"/commits/main"*) printf \'%s\\n\' "$MOCK_SOURCE_SHA" ;;', + ' *"/compare/"*) printf \'ahead\\n\' ;;', + ' *"/tags?per_page=100"*) printf \'%s\\n\' "$MOCK_TAGS" ;;', + ' *"/releases?per_page=100"*) printf \'%s\\n\' "$MOCK_RELEASES" ;;', + ' *) echo "unexpected gh invocation: $*" >&2; exit 2 ;;', + "esac", + ].join("\n"), + ); + await chmod(join(binaryDirectory, "gh"), 0o755); + const environment = { + PATH: `${binaryDirectory}:${process.env.PATH ?? ""}`, + DEFAULT_BRANCH: "main", + GH_COMMAND_LOG: commandLog, + GITHUB_EVENT_NAME: "push", + GITHUB_REF: "refs/tags/v0.20.0", + GITHUB_REPOSITORY: "hraness/kb", + GITHUB_SHA: sourceSha, + MOCK_RELEASES: "", + MOCK_SOURCE_SHA: sourceSha, + MOCK_TAGS: "v0.20.0", + VERIFIED_SOURCE_SHA: sourceSha, + VERIFIED_TAG: "v0.20.0", + WORKFLOW_SHA: sourceSha, + }; + + const oversizedTag = await runWorkflowScript(script, { + ...environment, + MOCK_TAGS: "v0.20.0\nv9007199254740992.0.0", + }); + expect(oversizedTag.exitCode).not.toBe(0); + expect(oversizedTag.stderr).toContain( + "Stable version components exceed Number.MAX_SAFE_INTEGER: v9007199254740992.0.0", + ); + + const oversizedRelease = await runWorkflowScript(script, { + ...environment, + MOCK_RELEASES: "v9007199254740992.0.0", + }); + expect(oversizedRelease.exitCode).not.toBe(0); + expect(oversizedRelease.stderr).toContain( + "Stable version components exceed Number.MAX_SAFE_INTEGER: v9007199254740992.0.0", + ); + expect(await readFile(commandLog, "utf8")).not.toContain("release create"); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + test("a pre-existing GitHub Release must carry this Actions run's exact identity", async () => { + const workflow = await readFile(releaseWorkflowUrl, "utf8"); + const script = workflowStepScript(workflow, "Publish verified GitHub Release"); + const directory = await mkdtemp(join(tmpdir(), "kb-release-identity-")); + const binaryDirectory = join(directory, "bin"); + const releasePath = join(directory, "release.json"); + const sourceSha = "b".repeat(40); + const expectedBody = [ + "Automated immutable release for @hraness/kb@0.20.0.", + "", + `Source commit: ${sourceSha}`, + "Workflow run: 67890", + ].join("\n"); + try { + await mkdir(binaryDirectory, { recursive: true }); + await writeFile( + join(binaryDirectory, "npm"), + ["#!/bin/bash", "set -euo pipefail", "printf '\"0.20.0\"\\n'"].join("\n"), + ); + await writeFile( + join(binaryDirectory, "gh"), + [ + "#!/bin/bash", + "set -euo pipefail", + 'case "$*" in', + ' *"/commits/v0.20.0"*) printf \'%s\\n\' "$MOCK_SOURCE_SHA" ;;', + ' *"/commits/main"*) printf \'%s\\n\' "$MOCK_SOURCE_SHA" ;;', + ' *"/compare/"*) printf \'ahead\\n\' ;;', + ' *"/tags?per_page=100"*) printf \'v0.20.0\\n\' ;;', + ' *"/releases?per_page=100"*) printf \'\\n\' ;;', + ' *"/releases/tags/v0.20.0"*) cat "$MOCK_RELEASE_JSON" ;;', + ' *"/releases/latest"*) printf \'v0.20.0\\n\' ;;', + ' *) echo "unexpected gh invocation: $*" >&2; exit 2 ;;', + "esac", + ].join("\n"), + ); + await Promise.all([ + chmod(join(binaryDirectory, "npm"), 0o755), + chmod(join(binaryDirectory, "gh"), 0o755), + ]); + const environment = { + PATH: `${binaryDirectory}:${process.env.PATH ?? ""}`, + DEFAULT_BRANCH: "main", + GITHUB_EVENT_NAME: "push", + GITHUB_REF: "refs/tags/v0.20.0", + GITHUB_REPOSITORY: "hraness/kb", + GITHUB_RUN_ID: "67890", + GITHUB_SHA: sourceSha, + MOCK_RELEASE_JSON: releasePath, + MOCK_SOURCE_SHA: sourceSha, + RUNNER_TEMP: directory, + VERIFIED_SOURCE_SHA: sourceSha, + VERIFIED_TAG: "v0.20.0", + WORKFLOW_SHA: sourceSha, + }; + const release = { + tag_name: "v0.20.0", + name: "KB v0.20.0", + body: expectedBody, + draft: false, + prerelease: false, + immutable: true, + assets: [], + author: { id: 123456, login: "collaborator", type: "User" }, + }; + await writeFile(releasePath, JSON.stringify(release)); + const frontRun = await runWorkflowScript(script, environment); + expect(frontRun.exitCode).not.toBe(0); + expect(frontRun.stderr).toContain( + "GitHub Release is not the exact immutable artifact created by this authorized Actions run", + ); + + await writeFile(releasePath, JSON.stringify({ + ...release, + author: { id: 41898282, login: "github-actions[bot]", type: "Bot" }, + })); + const authorizedRecovery = await runWorkflowScript(script, environment); + expect(authorizedRecovery.exitCode).toBe(0); + } finally { + await rm(directory, { recursive: true, force: true }); + } }); test("gates immutable releases on an owner-created protected stable tag and exact npm delivery", async () => { @@ -314,10 +1166,10 @@ describe("npm release workflows", () => { "Release tag must be annotated", 'git merge-base --is-ancestor "$tag_commit" "$default_head"', "Tag $release_tag is not the newest stable tag", - "for registry_poll in {1..60}", 'git worktree add --detach "$source_tree" "$SOURCE_SHA"', 'current_prepare="$GITHUB_WORKSPACE/scripts/prepare-npm-package.ts"', 'current_identity="$GITHUB_WORKSPACE/scripts/npm-package-identity.ts"', + 'current_attestation="$GITHUB_WORKSPACE/scripts/npm-release-attestation.ts"', 'current_smoke="$GITHUB_WORKSPACE/scripts/package-smoke.ts"', 'git -C "$GITHUB_WORKSPACE" rev-parse "$WORKFLOW_SHA:$relative_tool"', 'git hash-object "$current_tool"', @@ -326,16 +1178,36 @@ describe("npm release workflows", () => { '--registry-pack-json "$registry_pack_json"', '--registry-view-json "$registry_view_json"', 'npm view "$package_spec" name version dist', + 'npm view "$EXPECTED_NAME" dist-tags.latest', + 'npm install "$package_spec"', + "npm audit signatures", + "--include-attestations", + '--expected-source-sha "$EXPECTED_SOURCE_SHA"', + '--expected-tarball-sha512 "$registry_tarball_sha512"', + '--registry-latest-json "$registry_latest_json"', + 'npm view "@hraness/kb" dist-tags.latest', 'current_tag_sha="$(gh api', 'compare/$VERIFIED_SOURCE_SHA...$current_default_sha', - '"$GITHUB_EVENT_NAME" != push', + 'EXPECTED_ACTIONS_BOT_ID="41898282"', + "Automated immutable release for @hraness/kb@", ] as const) expect(workflow).toContain(required); - expect(workflow).not.toContain("workflow_dispatch:"); - expect(workflow).not.toContain("publication_run_id"); + const auditIndex = workflow.indexOf("npm audit signatures"); + const attestationIndex = workflow.indexOf('bun --no-env-file --config=/dev/null run "$current_attestation"'); + const publishJobIndex = workflow.indexOf("\n publish:\n"); + const liveLatestIndex = workflow.lastIndexOf('npm view "@hraness/kb" dist-tags.latest'); + const releaseMutationIndex = workflow.indexOf('gh release create "$VERIFIED_TAG"'); + expect(auditIndex).toBeGreaterThan(workflow.indexOf("npm@11.19.0")); + expect(attestationIndex).toBeGreaterThan(auditIndex); + expect(attestationIndex).toBeLessThan(publishJobIndex); + expect(liveLatestIndex).toBeGreaterThan(publishJobIndex); + expect(liveLatestIndex).toBeLessThan(releaseMutationIndex); + expect(workflow.match(/Stable version components exceed Number\.MAX_SAFE_INTEGER/gu) ?? []) + .toHaveLength(3); expect(workflow).not.toContain('cmp "$source_archive" "$registry_archive"'); expect(workflow).not.toContain("bun run ./scripts/prepare-npm-package.ts"); expect(workflow).not.toContain("bun run ./scripts/package-smoke.ts"); expect(workflow).not.toMatch(/\bnpm (?:publish|stage publish)\b/u); + expect(workflow).not.toContain("workflow_dispatch:"); expect(workflow.match(/contents: write/gu) ?? []).toHaveLength(1); for (const required of [ "contentSha256", @@ -369,41 +1241,59 @@ describe("npm release workflows", () => { readFile(agentGuideUrl, "utf8"), ]); for (const required of [ - "starts automatically", + "automatically starts", "one-time `0.17.1` bootstrap", "Do not reuse the\ninteractive path for a later release", - "[Publish a later version](#publish-a-later-version)", + "[Stage a later version](#stage-a-later-version)", + "version is unchanged", "exact `npm-stage` environment", - "`--environment npm-stage`", - "`--allow-publish`", - "Do not grant `--allow-stage`", - "allows only `v*` tags", + "disable administrator bypass", + "allows only `main`", + "original actor and triggering actor", + "current attempt", + "`actions: read` and `id-token: write`", + "`Number.MAX_SAFE_INTEGER`", + "`npm audit signatures --json", + "`dist-tags.latest`", + "owner ID `307125679`", + "clean default `latest`", + "top-level `tag`", "rebinds the release helpers to their reviewed Git blobs", "invokes those files by absolute path", "`npm pack --ignore-scripts`", npmRegistry, ] as const) expect(guide).toContain(required); + const normalizedGuide = guide.replace(/\s+/gu, " "); + expect(normalizedGuide).toContain( + "selected branch `main` with type `branch`", + ); expect(guide).toMatch(/the only job with\s+OIDC authority/u); - expect(guide).toMatch(/has no required reviewers, so the job starts\s+automatically/u); - expect(guide).toMatch(/publishes the reviewed\s+tarball directly\s+without a maintainer OTP/u); - expect(guide).toMatch(/checks out no source and runs no\s+repository\s+code/u); + expect(guide).toMatch(/explicitly opted-in staging job\s+starts after verification/u); + expect(guide).toMatch(/approve the staged package through npm with two-factor\s+authentication/u); + expect(guide).toMatch(/checks out no\s+source and runs no\s+repository\s+code/u); expect(guide).toMatch(/exactly the tarball,\s+`npm-pack\.json`, and `npm-package\.sha256`/u); expect(guide).toMatch(/new bare\s+Git directory/u); expect(guide).toMatch(/do not import a\s+script from the tagged tree/u); - expect(agents).toContain("publish each unique version directly through the minimal OIDC job"); - expect(agents).toContain("direct OIDC trusted publishing"); - expect(agents).toContain("Protected tag-push workflows"); - expect(agents).toContain("**Immutable version tags** restricts update and deletion with an empty bypass list"); - expect(agents).toContain("**Release tag creation** restricts creation and has owner `User` ID `894119` as its sole always-bypass actor"); - expect(agents).toContain("Never grant generic GitHub Actions integration ID `15368`"); + expect(agents).toContain("Trust only `.github/workflows/npm-stage.yml` with `npm stage publish` permission"); + expect(agents).toContain("selected default branch `main`"); + expect(agents).toContain("administrator bypass disabled"); + expect(agents).toContain("public promotion remains human-gated by two-factor authentication"); + expect(agents).toContain("boolean `publish_to_npm=true`"); + expect(agents).toContain("`actions: read` plus `id-token: write`"); + expect(agents).toContain("clean default `latest`"); + expect(agents).toContain("pinned npm `11.19.0`"); + expect(agents).toContain("sole main source commit"); + expect(agents).toContain("The protected tag workflow must bind the actor and event sender"); expect(agents).toContain("public repository ID `1308971873`"); }); test("pins publication to the canonical npm registry", async () => { const manifest = JSON.parse(await readFile(manifestUrl, "utf8")) as { readonly publishConfig?: unknown; + readonly tag?: unknown; }; expect(manifest.publishConfig).toEqual({ access: "public", registry: npmRegistry }); + expect(Object.hasOwn(manifest, "tag")).toBe(false); }); }); diff --git a/scripts/npm-stage-selection.test.ts b/scripts/npm-stage-selection.test.ts new file mode 100644 index 0000000..186bf6c --- /dev/null +++ b/scripts/npm-stage-selection.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, test } from "bun:test"; +import fc from "fast-check"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { selectNpmStage } from "./npm-stage-selection.ts"; + +function manifest(version: unknown, name: unknown = "@hraness/kb"): string { + return JSON.stringify({ name, version }); +} + +describe("npm stage selection", () => { + test("selects a strictly increasing stable version from a push", () => { + expect(selectNpmStage({ + currentManifest: manifest("0.17.3"), + eventName: "push", + previousManifest: manifest("0.17.2"), + })).toEqual({ + currentVersion: "0.17.3", + previousVersion: "0.17.2", + reason: "stable-version-increase", + shouldStage: true, + }); + }); + + test("makes an unrelated package.json edit a successful no-op", () => { + expect(selectNpmStage({ + currentManifest: manifest("0.17.3"), + eventName: "push", + previousManifest: manifest("0.17.3"), + })).toEqual({ + currentVersion: "0.17.3", + previousVersion: "0.17.3", + reason: "version-unchanged", + shouldStage: false, + }); + }); + + test("retains a manual current-version recovery request", () => { + expect(selectNpmStage({ + currentManifest: manifest("0.17.3"), + eventName: "workflow_dispatch", + })).toEqual({ + currentVersion: "0.17.3", + reason: "manual-recovery", + shouldStage: true, + }); + }); + + test("rejects downgrades, prereleases, foreign packages, and incomplete pushes", () => { + expect(() => selectNpmStage({ + currentManifest: manifest("0.17.0"), + eventName: "push", + previousManifest: manifest("0.17.3"), + })).toThrow("Package version must increase"); + expect(() => selectNpmStage({ + currentManifest: manifest("0.18.0-beta.1"), + eventName: "push", + previousManifest: manifest("0.17.3"), + })).toThrow("stable semantic version"); + expect(() => selectNpmStage({ + currentManifest: manifest("0.18.0", "@hraness/not-kb"), + eventName: "push", + previousManifest: manifest("0.17.3"), + })).toThrow("must identify @hraness/kb"); + expect(() => selectNpmStage({ + currentManifest: manifest("0.18.0"), + eventName: "push", + })).toThrow("must provide the previous package.json"); + }); + + test("accepts and compares every version component through Number.MAX_SAFE_INTEGER", () => { + expect(selectNpmStage({ + currentManifest: manifest("9007199254740991.9007199254740991.9007199254740991"), + eventName: "push", + previousManifest: manifest("9007199254740991.9007199254740991.9007199254740990"), + }).shouldStage).toBe(true); + }); + + test("rejects a current or previous component above Number.MAX_SAFE_INTEGER", () => { + for (const version of [ + "9007199254740992.0.0", + "0.9007199254740992.0", + "0.0.9007199254740992", + ]) { + expect(() => selectNpmStage({ + currentManifest: manifest(version), + eventName: "workflow_dispatch", + })).toThrow("components must not exceed Number.MAX_SAFE_INTEGER"); + } + expect(() => selectNpmStage({ + currentManifest: manifest("9007199254740991.0.0"), + eventName: "push", + previousManifest: manifest("9007199254740992.0.0"), + })).toThrow("components must not exceed Number.MAX_SAFE_INTEGER"); + }); + + test("matches lexicographic numeric ordering for stable versions", () => { + const versionPart = fc.bigInt({ min: 0n, max: BigInt(Number.MAX_SAFE_INTEGER) }); + const versionParts = fc.tuple(versionPart, versionPart, versionPart); + fc.assert(fc.property(versionParts, versionParts, (current, previous) => { + const differenceIndex = current.findIndex((part, index) => part !== previous[index]); + const ordering = differenceIndex === -1 + ? 0 + : current[differenceIndex]! > previous[differenceIndex]! + ? 1 + : -1; + const input = { + currentManifest: manifest(current.join(".")), + eventName: "push", + previousManifest: manifest(previous.join(".")), + }; + if (ordering < 0) { + expect(() => selectNpmStage(input)).toThrow("Package version must increase"); + return; + } + const selection = selectNpmStage(input); + expect(selection.shouldStage).toBe(ordering > 0); + expect(selection.reason).toBe(ordering > 0 + ? "stable-version-increase" + : "version-unchanged"); + }), { numRuns: 200 }); + }); + + test("writes a bounded GitHub Actions selection output", async () => { + const work = await mkdtemp(join(tmpdir(), "kb-npm-stage-selection-")); + try { + const currentManifest = join(work, "current-package.json"); + const previousManifest = join(work, "previous-package.json"); + const githubOutput = join(work, "github-output.txt"); + await Promise.all([ + writeFile(currentManifest, manifest("0.17.3")), + writeFile(previousManifest, manifest("0.17.2")), + ]); + const child = Bun.spawn([ + process.execPath, + "run", + fileURLToPath(new URL("./npm-stage-selection.ts", import.meta.url)), + "--current-manifest", + currentManifest, + "--event", + "push", + "--github-output", + githubOutput, + "--previous-manifest", + previousManifest, + ], { stderr: "pipe", stdout: "pipe" }); + const [exitCode, output, error] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + expect(error).toBe(""); + expect(exitCode).toBe(0); + expect(output).toContain("Stage @hraness/kb@0.17.3: stable-version-increase"); + expect(await readFile(githubOutput, "utf8")).toBe( + "current_version=0.17.3\n" + + "reason=stable-version-increase\n" + + "should_stage=true\n" + + "previous_version=0.17.2\n", + ); + } finally { + await rm(work, { force: true, recursive: true }); + } + }); +}); diff --git a/scripts/npm-stage-selection.ts b/scripts/npm-stage-selection.ts new file mode 100644 index 0000000..6c971d0 --- /dev/null +++ b/scripts/npm-stage-selection.ts @@ -0,0 +1,183 @@ +import { appendFile, readFile } from "node:fs/promises"; + +const expectedPackageName = "@hraness/kb"; +const stableVersionPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/u; +const maximumStableVersionPart = BigInt(Number.MAX_SAFE_INTEGER); + +type PackageIdentity = Readonly<{ + name: typeof expectedPackageName; + version: string; + versionParts: readonly [bigint, bigint, bigint]; +}>; + +export type NpmStageSelection = Readonly<{ + currentVersion: string; + previousVersion?: string; + reason: "manual-recovery" | "stable-version-increase" | "version-unchanged"; + shouldStage: boolean; +}>; + +type CliOptions = Readonly<{ + currentManifestPath: string; + eventName: string; + githubOutputPath: string; + previousManifestPath?: string; +}>; + +function record(value: unknown, label: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new TypeError(`${label} must be a JSON object`); + } + return value as Record; +} + +function packageIdentity(source: string, label: string): PackageIdentity { + let value: unknown; + try { + value = JSON.parse(source) as unknown; + } catch (error) { + throw new TypeError(`${label} must be valid JSON`, { cause: error }); + } + const manifest = record(value, label); + if (manifest.name !== expectedPackageName) { + throw new TypeError(`${label} must identify ${expectedPackageName}`); + } + if (typeof manifest.version !== "string") { + throw new TypeError(`${label}.version must be a string`); + } + const match = stableVersionPattern.exec(manifest.version); + if (match === null || match[1] === undefined || match[2] === undefined || match[3] === undefined) { + throw new TypeError(`${label}.version must be a stable semantic version`); + } + const versionParts = [BigInt(match[1]), BigInt(match[2]), BigInt(match[3])] as const; + if (versionParts.some((part) => part > maximumStableVersionPart)) { + throw new TypeError( + `${label}.version components must not exceed Number.MAX_SAFE_INTEGER`, + ); + } + return { + name: expectedPackageName, + version: manifest.version, + versionParts, + }; +} + +function compareVersions(left: PackageIdentity, right: PackageIdentity): number { + for (let index = 0; index < left.versionParts.length; index += 1) { + const leftPart = left.versionParts[index]; + const rightPart = right.versionParts[index]; + if (leftPart === undefined || rightPart === undefined) { + throw new TypeError("Stable semantic version comparison is incomplete"); + } + if (leftPart !== rightPart) return leftPart > rightPart ? 1 : -1; + } + return 0; +} + +export function selectNpmStage(input: Readonly<{ + currentManifest: string; + eventName: string; + previousManifest?: string; +}>): NpmStageSelection { + const current = packageIdentity(input.currentManifest, "current package.json"); + if (input.eventName === "workflow_dispatch") { + return { + currentVersion: current.version, + reason: "manual-recovery", + shouldStage: true, + }; + } + if (input.eventName !== "push") { + throw new TypeError(`Unsupported npm staging event ${input.eventName}`); + } + if (input.previousManifest === undefined) { + throw new TypeError("A push event must provide the previous package.json"); + } + const previous = packageIdentity(input.previousManifest, "previous package.json"); + const comparison = compareVersions(current, previous); + if (comparison < 0) { + throw new TypeError( + `Package version must increase from ${previous.version}, received ${current.version}`, + ); + } + if (comparison === 0) { + return { + currentVersion: current.version, + previousVersion: previous.version, + reason: "version-unchanged", + shouldStage: false, + }; + } + return { + currentVersion: current.version, + previousVersion: previous.version, + reason: "stable-version-increase", + shouldStage: true, + }; +} + +function parseCliOptions(args: readonly string[]): CliOptions { + const values = new Map(); + for (let index = 0; index < args.length; index += 2) { + const name = args[index]; + const value = args[index + 1]; + if (name === undefined || value === undefined || !name.startsWith("--") || value.length === 0) { + throw new TypeError("npm stage selection arguments must be non-empty name/value pairs"); + } + if (values.has(name)) throw new TypeError(`npm stage selection repeats ${name}`); + values.set(name, value); + } + const allowed = new Set([ + "--current-manifest", + "--event", + "--github-output", + "--previous-manifest", + ]); + for (const name of values.keys()) { + if (!allowed.has(name)) throw new TypeError(`Unknown npm stage selection argument ${name}`); + } + const currentManifestPath = values.get("--current-manifest"); + const eventName = values.get("--event"); + const githubOutputPath = values.get("--github-output"); + if (currentManifestPath === undefined || eventName === undefined || githubOutputPath === undefined) { + throw new TypeError("npm stage selection requires --current-manifest, --event, and --github-output"); + } + const previousManifestPath = values.get("--previous-manifest"); + return { + currentManifestPath, + eventName, + githubOutputPath, + ...(previousManifestPath === undefined ? {} : { previousManifestPath }), + }; +} + +async function main(): Promise { + const options = parseCliOptions(process.argv.slice(2)); + const [currentManifest, previousManifest] = await Promise.all([ + readFile(options.currentManifestPath, "utf8"), + options.previousManifestPath === undefined + ? undefined + : readFile(options.previousManifestPath, "utf8"), + ]); + const selection = selectNpmStage({ + currentManifest, + eventName: options.eventName, + ...(previousManifest === undefined ? {} : { previousManifest }), + }); + const output = [ + `current_version=${selection.currentVersion}`, + `reason=${selection.reason}`, + `should_stage=${selection.shouldStage ? "true" : "false"}`, + ...(selection.previousVersion === undefined + ? [] + : [`previous_version=${selection.previousVersion}`]), + ]; + await appendFile(options.githubOutputPath, `${output.join("\n")}\n`, "utf8"); + if (selection.shouldStage) { + console.log(`Stage ${expectedPackageName}@${selection.currentVersion}: ${selection.reason}`); + } else { + console.log(`Package version remains ${selection.currentVersion}; npm staging is not required`); + } +} + +if (import.meta.main) await main(); diff --git a/scripts/package-smoke.ts b/scripts/package-smoke.ts index 36b1fae..ccaa07f 100644 --- a/scripts/package-smoke.ts +++ b/scripts/package-smoke.ts @@ -416,6 +416,7 @@ async function verifyInstalledPackagePolicy(consumer: string): Promise=1.3.14"); } if ( - manifest.publishConfig?.access !== "public" + Object.hasOwn(manifest, "tag") + || Object.hasOwn(sourceManifest, "tag") + || manifest.publishConfig?.access !== "public" || manifest.publishConfig.registry !== "https://registry.npmjs.org" ) { - throw new Error("installed package must pin public publication to the canonical npm registry"); + throw new Error("installed package must reject npm tag overrides and pin publication to the canonical registry"); } const files = await regularFiles(installedPackage); for (const requiredPath of requiredPackageFiles) { diff --git a/scripts/prepare-npm-package.ts b/scripts/prepare-npm-package.ts index 8677596..b5ca99d 100644 --- a/scripts/prepare-npm-package.ts +++ b/scripts/prepare-npm-package.ts @@ -8,6 +8,8 @@ import { inspectPackageArtifact } from "./package-artifact.js"; const packageName = "@hraness/kb"; const npmRegistry = "https://registry.npmjs.org"; const requiredNpmVersion = "11.19.0"; +const maximumStableVersionPart = BigInt(Number.MAX_SAFE_INTEGER); +const stableVersionPattern = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/u; function record(value: unknown, label: string): Record { if (typeof value !== "object" || value === null || Array.isArray(value)) { @@ -80,7 +82,15 @@ function verifyPublicManifest(manifest: Record): string { if (manifestName !== packageName) { throw new Error(`package.json name is ${manifestName}, expected ${packageName}`); } - if (!/^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/u.test(manifestVersion)) { + const versionMatch = stableVersionPattern.exec(manifestVersion); + if ( + versionMatch === null + || versionMatch[1] === undefined + || versionMatch[2] === undefined + || versionMatch[3] === undefined + || [versionMatch[1], versionMatch[2], versionMatch[3]] + .some((part) => BigInt(part) > maximumStableVersionPart) + ) { throw new Error(`package.json version is not a stable semantic version: ${manifestVersion}`); } stringField(manifest, "description", "package.json"); @@ -100,6 +110,11 @@ function verifyPublicManifest(manifest: Record): string { access: "public", registry: npmRegistry, }, "package.json"); + if (Object.hasOwn(manifest, "tag")) { + throw new Error( + "package.json must not contain a top-level tag because npm lets it override the requested dist-tag", + ); + } if (manifest.private === true) throw new Error("package.json cannot be private"); if (!Array.isArray(manifest.files) || manifest.files.length === 0) { throw new Error("package.json.files must be a non-empty allowlist"); diff --git a/scripts/push-npm-release-tag.ts b/scripts/push-npm-release-tag.ts deleted file mode 100644 index 5b2d709..0000000 --- a/scripts/push-npm-release-tag.ts +++ /dev/null @@ -1,651 +0,0 @@ -import { lstatSync, readFileSync, realpathSync } from "node:fs"; -import { resolve } from "node:path"; - -const EXPECTED_ACTOR_ID = 894119; -const EXPECTED_PACKAGE = "@hraness/kb"; -const EXPECTED_REPOSITORY = "hraness/kb"; -const EXPECTED_REPOSITORY_ID = 1308971873; -const EXPECTED_REMOTE_URLS = new Set([ - "git@github.com:hraness/kb.git", - "https://github.com/hraness/kb.git", - "ssh://git@github.com/hraness/kb.git", -]); -const DEFAULT_BRANCH = "main"; -const CI_WORKFLOW_NAME = "CI"; -const CI_WORKFLOW_PATH = ".github/workflows/ci.yml"; -const CI_REQUIRED_JOB = "Required"; -const RELEASE_ENVIRONMENT = "npm-stage"; -const RELEASE_RULESET_POLICIES = [ - { bypassOwner: true, name: "Release tag creation", rules: ["creation"] }, - { bypassOwner: false, name: "Immutable version tags", rules: ["deletion", "update"] }, -] as const; -const MAXIMUM_OUTPUT_BYTES = 1024 * 1024; -const MAXIMUM_INVENTORY_ITEMS = 100; -const MAXIMUM_TAG_LINES = 2_000; -const PROCESS_TIMEOUT_MS = 30_000; -const SHA = /^[0-9a-f]{40}$/u; -const VERSION = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-beta\.(0|[1-9][0-9]*))?$/u; - -type JsonRecord = Record; - -export type ReleaseVersion = Readonly<{ - beta: bigint | null; - parts: readonly [bigint, bigint, bigint]; - tag: string; - version: string; -}>; - -export type CiRunIdentity = Readonly<{ runAttempt: number; runId: number }>; - -export type RemoteTagAdmission = "absent" | "same-annotated-commit"; - -function record(value: unknown, label: string): JsonRecord { - if (value === null || typeof value !== "object" || Array.isArray(value)) { - throw new Error(`${label} is malformed.`); - } - return value as JsonRecord; -} - -function positiveInteger(value: unknown): value is number { - return Number.isSafeInteger(value) && Number(value) > 0; -} - -export function parseReleaseVersion(value: string): ReleaseVersion { - const match = VERSION.exec(value); - if (match === null) { - throw new Error(`Release version ${value} is not canonical stable or beta. SemVer.`); - } - const major = match[1]; - const minor = match[2]; - const patch = match[3]; - if (major === undefined || minor === undefined || patch === undefined) { - throw new Error("Release version parser lost a required SemVer component."); - } - return Object.freeze({ - beta: match[4] === undefined ? null : BigInt(match[4]), - parts: Object.freeze([BigInt(major), BigInt(minor), BigInt(patch)]) as readonly [bigint, bigint, bigint], - tag: `v${value}`, - version: value, - }); -} - -export function compareReleaseVersions(left: ReleaseVersion, right: ReleaseVersion): number { - for (let index = 0; index < left.parts.length; index += 1) { - const leftPart = left.parts[index]!; - const rightPart = right.parts[index]!; - if (leftPart !== rightPart) return leftPart > rightPart ? 1 : -1; - } - if (left.beta === null && right.beta === null) return 0; - if (left.beta === null) return 1; - if (right.beta === null) return -1; - return left.beta === right.beta ? 0 : left.beta > right.beta ? 1 : -1; -} - -export function admitOwner(value: unknown): void { - const actor = record(value, "GitHub authentication receipt"); - if (actor.id !== EXPECTED_ACTOR_ID || actor.type !== "User") { - throw new Error(`Release-tag authentication must be immutable owner User ${String(EXPECTED_ACTOR_ID)}.`); - } -} - -export function admitRepository(value: unknown): void { - const repository = record(value, "GitHub repository receipt"); - if ( - repository.full_name !== EXPECTED_REPOSITORY - || repository.id !== EXPECTED_REPOSITORY_ID - || repository.default_branch !== DEFAULT_BRANCH - || repository.archived !== false - || repository.disabled !== false - || repository.private !== false - || repository.visibility !== "public" - ) { - throw new Error(`Release-tag authentication must address active ${EXPECTED_REPOSITORY} with default branch ${DEFAULT_BRANCH}.`); - } -} - -export function admitReleaseEnvironment(environmentValue: unknown, policiesValue: unknown): void { - const environment = record(environmentValue, "npm release environment receipt"); - const deploymentPolicy = record(environment.deployment_branch_policy, "npm release deployment policy"); - if ( - environment.name !== RELEASE_ENVIRONMENT - || environment.can_admins_bypass !== false - || deploymentPolicy.custom_branch_policies !== true - || deploymentPolicy.protected_branches !== false - || !Array.isArray(environment.protection_rules) - || environment.protection_rules.length !== 1 - || record(environment.protection_rules[0], "npm release protection rule").type !== "branch_policy" - ) { - throw new Error(`${RELEASE_ENVIRONMENT} must have no administrator bypass or reviewers and use only branch_policy protection.`); - } - const policies = record(policiesValue, "npm release deployment-policy receipt"); - if ( - policies.total_count !== 1 - || !Array.isArray(policies.branch_policies) - || policies.branch_policies.length !== 1 - ) throw new Error(`${RELEASE_ENVIRONMENT} must admit exactly one deployment policy.`); - const policy = record(policies.branch_policies[0], "npm release deployment policy entry"); - if (policy.name !== "v*" || policy.type !== "tag") { - throw new Error(`${RELEASE_ENVIRONMENT} must admit only the v* tag policy.`); - } -} - -function releaseRulesetIdentities(value: unknown): ReadonlyMap { - if (!Array.isArray(value) || value.length > MAXIMUM_INVENTORY_ITEMS) { - throw new Error("GitHub ruleset inventory is malformed or exceeds its bound."); - } - const identities = new Map(); - for (const expected of RELEASE_RULESET_POLICIES) { - const matches = value - .map((item) => record(item, "GitHub ruleset summary")) - .filter((item) => item.name === expected.name); - if (matches.length !== 1 || !positiveInteger(matches[0]?.id)) { - throw new Error(`Expected exactly one ${expected.name} ruleset.`); - } - identities.set(expected.name, matches[0].id); - } - return identities; -} - -export function admitReleaseRulesets( - listValue: unknown, - detailValues: ReadonlyMap, -): void { - const identities = releaseRulesetIdentities(listValue); - for (const expected of RELEASE_RULESET_POLICIES) { - const expectedId = identities.get(expected.name); - const detailValue = detailValues.get(expected.name); - if (expectedId === undefined || detailValue === undefined) { - throw new Error(`Missing exact ${expected.name} ruleset readback.`); - } - const detail = record(detailValue, `${expected.name} ruleset`); - const conditions = record(detail.conditions, `${expected.name} ruleset conditions`); - const refName = record(conditions.ref_name, `${expected.name} ruleset ref condition`); - if ( - detail.id !== expectedId - || detail.name !== expected.name - || detail.target !== "tag" - || detail.enforcement !== "active" - || !Array.isArray(refName.exclude) - || refName.exclude.length !== 0 - || !Array.isArray(refName.include) - || refName.include.length !== 1 - || refName.include[0] !== "refs/tags/v*" - || !Array.isArray(detail.rules) - || detail.rules.length > 20 - ) throw new Error(`${expected.name} does not protect the exact release-tag namespace.`); - const ruleTypes = detail.rules.map((rule) => record(rule, `${expected.name} rule`).type).sort(); - if ( - ruleTypes.some((type) => typeof type !== "string") - || JSON.stringify(ruleTypes) !== JSON.stringify([...expected.rules].sort()) - ) throw new Error(`${expected.name} has unexpected rules.`); - if (!Array.isArray(detail.bypass_actors) || detail.bypass_actors.length > 10) { - throw new Error(`${expected.name} has malformed bypass authority.`); - } - const bypassActors = detail.bypass_actors.map((value) => { - const actor = record(value, `${expected.name} bypass actor`); - if (!positiveInteger(actor.actor_id) || typeof actor.actor_type !== "string" || typeof actor.bypass_mode !== "string") { - throw new Error(`${expected.name} has malformed bypass authority.`); - } - return { actor_id: actor.actor_id, actor_type: actor.actor_type, bypass_mode: actor.bypass_mode }; - }); - const expectedBypass = expected.bypassOwner - ? [{ actor_id: EXPECTED_ACTOR_ID, actor_type: "User", bypass_mode: "always" }] - : []; - if (JSON.stringify(bypassActors) !== JSON.stringify(expectedBypass)) { - throw new Error(`${expected.name} has unexpected bypass authority.`); - } - } -} - -export function admitRemoteRoutes(fetchOutput: string, pushOutput: string): void { - const lines = (value: string): string[] => value.trimEnd().split("\n"); - const fetchUrls = lines(fetchOutput); - const pushUrls = lines(pushOutput); - if ( - fetchUrls.length !== 1 - || pushUrls.length !== 1 - || !EXPECTED_REMOTE_URLS.has(fetchUrls[0] ?? "") - || !EXPECTED_REMOTE_URLS.has(pushUrls[0] ?? "") - ) throw new Error(`Origin fetch and push routing must each name only canonical ${EXPECTED_REPOSITORY}.`); -} - -export function admitProtectedBranch(value: unknown, expectedSha: string): void { - if (!SHA.test(expectedSha)) throw new Error("Protected-main verification requires one lowercase commit SHA."); - const branch = record(value, "GitHub protected-branch receipt"); - const commit = record(branch.commit, "GitHub protected-branch commit"); - if (branch.name !== DEFAULT_BRANCH || branch.protected !== true || commit.sha !== expectedSha) { - throw new Error(`Release commit must be the exact protected ${DEFAULT_BRANCH} head.`); - } -} - -export function admitActiveCiWorkflow(value: unknown): number { - const workflow = record(value, "CI workflow receipt"); - if ( - !positiveInteger(workflow.id) - || workflow.name !== CI_WORKFLOW_NAME - || workflow.path !== CI_WORKFLOW_PATH - || workflow.state !== "active" - ) { - throw new Error(`CI must be the exact active ${CI_WORKFLOW_PATH} workflow.`); - } - return workflow.id; -} - -export function admitCiRun( - value: unknown, - workflowId: number, - expectedSha: string, -): CiRunIdentity { - if (!positiveInteger(workflowId) || !SHA.test(expectedSha)) { - throw new Error("CI run verification received an invalid workflow or commit identity."); - } - const inventory = record(value, "CI run inventory"); - if ( - !Number.isSafeInteger(inventory.total_count) - || Number(inventory.total_count) < 0 - || !Array.isArray(inventory.workflow_runs) - || inventory.workflow_runs.length > MAXIMUM_INVENTORY_ITEMS - || inventory.total_count !== inventory.workflow_runs.length - ) { - throw new Error("CI run inventory is malformed or truncated."); - } - const candidates = inventory.workflow_runs.filter((item) => { - const run = record(item, "CI run"); - const repository = record(run.repository, "CI run repository"); - const headRepository = record(run.head_repository, "CI run head repository"); - return run.workflow_id === workflowId - && run.name === CI_WORKFLOW_NAME - && run.path === CI_WORKFLOW_PATH - && run.event === "push" - && run.head_branch === DEFAULT_BRANCH - && run.head_sha === expectedSha - && repository.full_name === EXPECTED_REPOSITORY - && headRepository.full_name === EXPECTED_REPOSITORY; - }); - if (candidates.length !== 1) { - throw new Error("Current main must have exactly one exact CI push run."); - } - const run = record(candidates[0], "Exact CI run"); - if ( - !positiveInteger(run.id) - || !positiveInteger(run.run_attempt) - || run.status !== "completed" - || run.conclusion !== "success" - ) { - throw new Error("The exact current-main CI push run is not successful."); - } - return Object.freeze({ runAttempt: run.run_attempt, runId: run.id }); -} - -export function admitCiRequiredJob(value: unknown, run: CiRunIdentity, expectedSha: string): number { - if (!positiveInteger(run.runId) || !positiveInteger(run.runAttempt) || !SHA.test(expectedSha)) { - throw new Error("CI job verification received an invalid run or commit identity."); - } - const inventory = record(value, "CI job inventory"); - if ( - !Number.isSafeInteger(inventory.total_count) - || Number(inventory.total_count) < 0 - || !Array.isArray(inventory.jobs) - || inventory.jobs.length > MAXIMUM_INVENTORY_ITEMS - || inventory.total_count !== inventory.jobs.length - ) { - throw new Error("CI job inventory is malformed or truncated."); - } - const candidates = inventory.jobs.filter((item) => { - const job = record(item, "CI job"); - return job.name === CI_REQUIRED_JOB - && job.run_id === run.runId - && job.run_attempt === run.runAttempt - && job.head_sha === expectedSha; - }); - if (candidates.length !== 1) { - throw new Error(`Exact CI run attempt must have one ${CI_REQUIRED_JOB} job.`); - } - const job = record(candidates[0], `Exact ${CI_REQUIRED_JOB} job`); - if (!positiveInteger(job.id) || job.status !== "completed" || job.conclusion !== "success") { - throw new Error(`The exact CI run attempt ${CI_REQUIRED_JOB} job is not successful.`); - } - return job.id; -} - -export function admitRemoteReleaseTags( - text: string, - expectedVersion: string, - expectedSha: string, -): RemoteTagAdmission { - if (!SHA.test(expectedSha)) throw new Error("Remote tag verification requires one lowercase commit SHA."); - if (new TextEncoder().encode(text).byteLength > MAXIMUM_OUTPUT_BYTES) { - throw new Error("Remote tag inventory exceeds its byte bound."); - } - const expected = parseReleaseVersion(expectedVersion); - const lines = text === "" ? [] : text.replace(/\n$/u, "").split("\n"); - if (lines.length > MAXIMUM_TAG_LINES || lines.some((line) => line.length > 512 || line.length === 0)) { - throw new Error("Remote tag inventory is malformed or exceeds its line bound."); - } - const tags = new Map(); - for (const line of lines) { - const match = /^([0-9a-f]{40})\trefs\/tags\/(v[^\s^]+)(\^\{\})?$/u.exec(line); - if (match === null) throw new Error("Remote tag inventory contains a malformed ref."); - const sha = match[1]; - const tag = match[2]; - const suffix = match[3]; - if (sha === undefined || tag === undefined) throw new Error("Remote tag inventory lost a ref component."); - parseReleaseVersion(tag.slice(1)); - const current = tags.get(tag) ?? {}; - const field = suffix === undefined ? "object" : "peeled"; - if (current[field] !== undefined) throw new Error(`Remote tag inventory repeats ${tag}${suffix ?? ""}.`); - current[field] = sha; - tags.set(tag, current); - } - for (const [tag, identity] of tags) { - if (identity.object === undefined || identity.peeled === identity.object) { - throw new Error(`Remote tag inventory has an invalid ${tag} identity.`); - } - } - const exact = tags.get(expected.tag); - if (exact !== undefined) { - if (exact.peeled === undefined || exact.peeled !== expectedSha) { - throw new Error(`${expected.tag} conflicts with the requested annotated tag and commit.`); - } - return "same-annotated-commit"; - } - const relevant = [...tags.keys()] - .map((tag) => parseReleaseVersion(tag.slice(1))) - .filter((candidate) => expected.beta !== null || candidate.beta === null); - const blocker = relevant.find((candidate) => compareReleaseVersions(expected, candidate) <= 0); - if (blocker !== undefined) { - throw new Error(`${expected.tag} must increase monotonically beyond ${blocker.tag}.`); - } - return "absent"; -} - -type CommandResult = Readonly<{ exitCode: number; stderr: string; stdout: string }>; - -async function readBounded(stream: ReadableStream, label: string): Promise { - const reader = stream.getReader(); - const chunks: Uint8Array[] = []; - let total = 0; - while (true) { - const next = await reader.read(); - if (next.done) break; - total += next.value.byteLength; - if (total > MAXIMUM_OUTPUT_BYTES) throw new Error(`${label} exceeded its output bound.`); - chunks.push(next.value); - } - const combined = new Uint8Array(total); - let offset = 0; - for (const chunk of chunks) { - combined.set(chunk, offset); - offset += chunk.byteLength; - } - return new TextDecoder("utf-8", { fatal: true }).decode(combined); -} - -async function command( - argv: readonly string[], - options: Readonly<{ allowFailure?: boolean; cwd: string; label: string }>, -): Promise { - const child = Bun.spawn([...argv], { - cwd: options.cwd, - env: { ...process.env, GH_PROMPT_DISABLED: "1", GIT_TERMINAL_PROMPT: "0" }, - stderr: "pipe", - stdin: "ignore", - stdout: "pipe", - }); - let timeout: ReturnType | undefined; - try { - const completed = Promise.all([ - child.exited, - readBounded(child.stdout, `${options.label} stdout`), - readBounded(child.stderr, `${options.label} stderr`), - ] as const); - const timedOut = new Promise((_resolve, reject) => { - timeout = setTimeout(() => reject(new Error(`${options.label} timed out.`)), PROCESS_TIMEOUT_MS); - }); - const [exitCode, stdout, stderr] = await Promise.race([completed, timedOut]); - const result = Object.freeze({ exitCode, stderr, stdout }); - if (exitCode !== 0 && options.allowFailure !== true) { - const detail = stderr.trim().slice(0, 2_000); - throw new Error(`${options.label} failed${detail === "" ? "." : `: ${detail}`}`); - } - return result; - } catch (error) { - child.kill(); - await child.exited; - throw error; - } finally { - if (timeout !== undefined) clearTimeout(timeout); - } -} - -async function jsonCommand(argv: readonly string[], root: string, label: string): Promise { - const result = await command(argv, { cwd: root, label }); - try { - return JSON.parse(result.stdout) as unknown; - } catch { - throw new Error(`${label} returned malformed JSON.`); - } -} - -async function refreshMain(root: string, expectedSha?: string): Promise { - await command( - ["git", "fetch", "--no-tags", "origin", `refs/heads/${DEFAULT_BRANCH}:refs/remotes/origin/${DEFAULT_BRANCH}`], - { cwd: root, label: `Fetch ${DEFAULT_BRANCH}` }, - ); - const head = (await command(["git", "rev-parse", "HEAD"], { cwd: root, label: "Read HEAD" })).stdout.trim(); - const remote = ( - await command(["git", "rev-parse", `refs/remotes/origin/${DEFAULT_BRANCH}`], { - cwd: root, - label: `Read origin/${DEFAULT_BRANCH}`, - }) - ).stdout.trim(); - if (!SHA.test(head) || head !== remote || (expectedSha !== undefined && head !== expectedSha)) { - throw new Error(`Release checkout must be exact current origin/${DEFAULT_BRANCH}.`); - } - return head; -} - -async function remoteTagInventory(root: string): Promise { - return ( - await command(["git", "ls-remote", "--tags", "origin", "refs/tags/v*"], { - cwd: root, - label: "Read remote release tags", - }) - ).stdout; -} - -async function requireLocalTag(root: string, tag: string, sha: string, message: string): Promise { - const type = (await command(["git", "cat-file", "-t", `refs/tags/${tag}`], { cwd: root, label: `Read ${tag} type` })).stdout.trim(); - const commit = ( - await command(["git", "rev-parse", `refs/tags/${tag}^{commit}`], { cwd: root, label: `Read ${tag} commit` }) - ).stdout.trim(); - const object = (await command(["git", "cat-file", "tag", `refs/tags/${tag}`], { cwd: root, label: `Read ${tag} object` })).stdout; - const separator = object.indexOf("\n\n"); - const headers = separator === -1 ? [] : object.slice(0, separator).split("\n"); - const body = separator === -1 ? "" : object.slice(separator + 2).trimEnd(); - if ( - type !== "tag" - || commit !== sha - || !headers.includes(`object ${sha}`) - || !headers.includes("type commit") - || !headers.includes(`tag ${tag}`) - || body !== message - ) { - throw new Error(`Local ${tag} conflicts with the exact annotated release tag.`); - } -} - -async function main(): Promise { - const [versionArgument, ...extraArguments] = Bun.argv.slice(2); - if (versionArgument === undefined || extraArguments.length !== 0) { - throw new Error("Usage: bun run ./scripts/push-npm-release-tag.ts "); - } - const release = parseReleaseVersion(versionArgument); - const root = realpathSync(resolve(import.meta.dir, "..")); - const reportedRoot = realpathSync( - (await command(["git", "rev-parse", "--show-toplevel"], { cwd: root, label: "Resolve repository root" })).stdout.trim(), - ); - if (reportedRoot !== root) throw new Error("Release-tag script is not running in its owning repository."); - const branch = (await command(["git", "branch", "--show-current"], { cwd: root, label: "Read current branch" })).stdout.trim(); - if (branch !== DEFAULT_BRANCH) throw new Error(`Release-tag script must run on ${DEFAULT_BRANCH}.`); - const fetchUrls = ( - await command(["git", "remote", "get-url", "--all", "origin"], { cwd: root, label: "Read origin fetch URLs" }) - ).stdout; - const pushUrls = ( - await command(["git", "remote", "get-url", "--push", "--all", "origin"], { cwd: root, label: "Read origin push URLs" }) - ).stdout; - admitRemoteRoutes(fetchUrls, pushUrls); - const status = ( - await command(["git", "status", "--porcelain", "--untracked-files=all"], { cwd: root, label: "Read worktree status" }) - ).stdout; - if (status !== "") throw new Error("Release-tag script requires a clean worktree."); - - const packagePath = resolve(root, "package.json"); - const packageStat = lstatSync(packagePath); - if (!packageStat.isFile() || packageStat.isSymbolicLink() || packageStat.size > MAXIMUM_OUTPUT_BYTES) { - throw new Error("package.json must be one bounded regular file."); - } - const packageJson = record(JSON.parse(readFileSync(packagePath, "utf8")) as unknown, "package.json"); - if (packageJson.name !== EXPECTED_PACKAGE || packageJson.version !== release.version) { - throw new Error(`Requested version must exactly match ${EXPECTED_PACKAGE} in package.json.`); - } - - admitOwner(await jsonCommand(["gh", "api", "user"], root, "Verify GitHub authentication")); - admitRepository( - await jsonCommand(["gh", "api", `repos/${EXPECTED_REPOSITORY}`], root, "Verify GitHub repository"), - ); - const rulesetList = await jsonCommand( - ["gh", "api", "--method", "GET", `repos/${EXPECTED_REPOSITORY}/rulesets`, "-f", "per_page=100"], - root, - "Read release rulesets", - ); - const rulesetDetails = new Map(); - for (const [name, id] of releaseRulesetIdentities(rulesetList)) { - rulesetDetails.set( - name, - await jsonCommand(["gh", "api", `repos/${EXPECTED_REPOSITORY}/rulesets/${String(id)}`], root, `Verify ${name}`), - ); - } - admitReleaseRulesets(rulesetList, rulesetDetails); - admitReleaseEnvironment( - await jsonCommand( - ["gh", "api", `repos/${EXPECTED_REPOSITORY}/environments/${RELEASE_ENVIRONMENT}`], - root, - "Verify npm release environment", - ), - await jsonCommand( - [ - "gh", "api", "--method", "GET", - `repos/${EXPECTED_REPOSITORY}/environments/${RELEASE_ENVIRONMENT}/deployment-branch-policies`, - "-f", "per_page=100", - ], - root, - "Verify npm release deployment policies", - ), - ); - const sha = await refreshMain(root); - admitProtectedBranch( - await jsonCommand(["gh", "api", `repos/${EXPECTED_REPOSITORY}/branches/${DEFAULT_BRANCH}`], root, "Verify protected main"), - sha, - ); - const workflowId = admitActiveCiWorkflow( - await jsonCommand( - ["gh", "api", `repos/${EXPECTED_REPOSITORY}/actions/workflows/ci.yml`], - root, - "Verify active CI workflow", - ), - ); - const runInventory = await jsonCommand( - [ - "gh", "api", "--method", "GET", `repos/${EXPECTED_REPOSITORY}/actions/workflows/ci.yml/runs`, - "-f", `branch=${DEFAULT_BRANCH}`, "-f", "event=push", "-f", `head_sha=${sha}`, "-F", "per_page=100", - ], - root, - "Read exact CI run", - ); - const run = admitCiRun(runInventory, workflowId, sha); - const jobs = await jsonCommand( - [ - "gh", "api", "--method", "GET", - `repos/${EXPECTED_REPOSITORY}/actions/runs/${String(run.runId)}/attempts/${String(run.runAttempt)}/jobs`, - "-F", "per_page=100", - ], - root, - "Read exact CI attempt jobs", - ); - const jobId = admitCiRequiredJob(jobs, run, sha); - - const firstAdmission = admitRemoteReleaseTags(await remoteTagInventory(root), release.version, sha); - if (firstAdmission === "same-annotated-commit") { - console.log(`${release.tag} already exists as the exact annotated release tag at ${sha}.`); - return; - } - - await refreshMain(root, sha); - admitProtectedBranch( - await jsonCommand(["gh", "api", `repos/${EXPECTED_REPOSITORY}/branches/${DEFAULT_BRANCH}`], root, "Reverify protected main"), - sha, - ); - const secondAdmission = admitRemoteReleaseTags(await remoteTagInventory(root), release.version, sha); - if (secondAdmission === "same-annotated-commit") { - console.log(`${release.tag} was concurrently created as the exact annotated release tag at ${sha}.`); - return; - } - - const releaseMessage = `Release ${EXPECTED_PACKAGE}@${release.version}`; - const localLookup = await command(["git", "show-ref", "--verify", "--quiet", `refs/tags/${release.tag}`], { - allowFailure: true, - cwd: root, - label: `Check local ${release.tag}`, - }); - if (localLookup.exitCode === 0) { - throw new Error(`Local ${release.tag} already exists while the remote tag is absent; refusing an inherited tag object.`); - } - if (localLookup.exitCode !== 1) { - throw new Error(`Could not determine whether local ${release.tag} exists.`); - } - - await command(["git", "tag", "--annotate", release.tag, sha, "--message", releaseMessage], { - cwd: root, - label: `Create local ${release.tag}`, - }); - const createdTagObject = ( - await command(["git", "rev-parse", `refs/tags/${release.tag}`], { - cwd: root, - label: `Read created ${release.tag} object`, - }) - ).stdout.trim(); - if (!SHA.test(createdTagObject)) throw new Error(`Created ${release.tag} has an invalid object identity.`); - - try { - await requireLocalTag(root, release.tag, sha, releaseMessage); - const push = await command( - ["git", "push", "origin", `refs/tags/${release.tag}:refs/tags/${release.tag}`], - { allowFailure: true, cwd: root, label: `Push exact ${release.tag} ref` }, - ); - const finalAdmission = admitRemoteReleaseTags(await remoteTagInventory(root), release.version, sha); - if (finalAdmission !== "same-annotated-commit") { - const detail = push.stderr.trim().slice(0, 2_000); - throw new Error(`Exact release tag push did not produce the requested remote tag${detail === "" ? "." : `: ${detail}`}`); - } - } catch (error) { - const cleanup = await command( - ["git", "update-ref", "-d", `refs/tags/${release.tag}`, createdTagObject], - { allowFailure: true, cwd: root, label: `Compare-delete unverified local ${release.tag}` }, - ); - if (cleanup.exitCode !== 0) { - throw new AggregateError( - [error, new Error(`Local ${release.tag} changed after creation and was not deleted.`)], - `Release-tag publication failed and safe local cleanup was not possible.`, - ); - } - throw error; - } - console.log( - `${release.tag} is the exact annotated release tag at ${sha}; CI run ${String(run.runId)} attempt ${String(run.runAttempt)} Required job ${String(jobId)} was successful.`, - ); -} - -if (import.meta.main) { - await main(); -}