diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ddc2fb9..e3f0239 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -47,6 +47,7 @@ jobs: if: >- vars.RELEASE_PLEASE_ENABLED == 'true' && github.event_name == 'workflow_run' && + github.run_attempt == 1 && github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' && github.event.workflow_run.head_branch == 'main' @@ -243,7 +244,17 @@ jobs: after_runs="$RUNNER_TEMP/tag-dispatch-runs-after.json" gh api --paginate --slurp \ "repos/${GITHUB_REPOSITORY}/actions/workflows/publish.yml/runs?event=workflow_dispatch&per_page=100" \ - | jq '[.[].workflow_runs[].id]' > "$before_runs" + | jq '[.[].workflow_runs[]]' > "$before_runs" + prior_count="$(jq \ + --arg commit "$RELEASE_COMMIT" --arg tag "$RELEASE_TAG" \ + '[.[] | select(.actor.login == "github-actions[bot]" and + .triggering_actor.login == "github-actions[bot]" and + .event == "workflow_dispatch" and .head_branch == $tag and + .head_sha == $commit)] | length' "$before_runs")" + if [[ "$prior_count" != "0" ]]; then + echo "An exact Publish run already exists for this immutable tag and commit." >&2 + exit 1 + fi gh api --method POST \ "repos/${GITHUB_REPOSITORY}/actions/workflows/publish.yml/dispatches" \ -f ref="$RELEASE_TAG" \ @@ -262,7 +273,7 @@ jobs: candidate_count="$(jq \ --arg commit "$RELEASE_COMMIT" --arg tag "$RELEASE_TAG" \ --slurpfile before "$before_runs" \ - '[.[] | select(.id as $id | ($before[0] | index($id) | not)) | + '[.[] | select(.id as $id | ($before[0] | map(.id) | index($id) | not)) | select(.actor.login == "github-actions[bot]" and .triggering_actor.login == "github-actions[bot]" and .event == "workflow_dispatch" and .head_branch == $tag and @@ -275,7 +286,7 @@ jobs: publish_run_id="$(jq -r \ --arg commit "$RELEASE_COMMIT" --arg tag "$RELEASE_TAG" \ --slurpfile before "$before_runs" \ - '[.[] | select(.id as $id | ($before[0] | index($id) | not)) | + '[.[] | select(.id as $id | ($before[0] | map(.id) | index($id) | not)) | select(.actor.login == "github-actions[bot]" and .triggering_actor.login == "github-actions[bot]" and .event == "workflow_dispatch" and .head_branch == $tag and @@ -296,6 +307,7 @@ jobs: if: >- vars.RELEASE_PLEASE_ENABLED == 'true' && github.event_name == 'workflow_dispatch' && + github.run_attempt == 1 && inputs.publish_operation == 'release' && startsWith(github.ref, 'refs/tags/v0.1.') && github.ref == format('refs/tags/{0}', inputs.release_tag) && @@ -312,11 +324,21 @@ jobs: artifact-name: ${{ steps.artifact-name.outputs.name }} control-commit: ${{ inputs.control_commit }} dist-tag: ${{ steps.version.outputs.dist-tag }} + expected-next-version: ${{ steps.registry-baseline.outputs.next-version }} release-commit: ${{ steps.trust.outputs.release-commit }} release-tag: ${{ steps.trust.outputs.release-tag }} release-please-snapshot: ${{ steps.release-please-snapshot.outputs.digest }} version: ${{ steps.version.outputs.version }} steps: + - name: Reject a repeated verification attempt + env: + WORKFLOW_RUN_ATTEMPT: ${{ github.run_attempt }} + run: | + set -euo pipefail + if [[ "$WORKFLOW_RUN_ATTEMPT" != "1" ]]; then + echo "Exact-artifact verification is restricted to the initial run attempt." >&2 + exit 1 + fi - name: Check out the workflow control commit uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -576,6 +598,18 @@ jobs: EOF - name: Use a Trusted Publishing-capable npm CLI run: npm install --global npm@11.12.1 + - name: Freeze the prerelease dist-tag + id: registry-baseline + shell: bash + run: | + set -euo pipefail + next_version="$(npm view cometapi@next version)" + NEXT_VERSION="$next_version" node --input-type=module <<'EOF' + import { validatePrereleaseDistTagBaseline } from "./scripts/release-workflow-validation.mjs"; + + validatePrereleaseDistTagBaseline(process.env.NEXT_VERSION); + EOF + echo "next-version=${next_version}" >> "$GITHUB_OUTPUT" - name: Install locked dependencies run: npm ci - name: Run release checks @@ -630,6 +664,9 @@ jobs: live-smoke: name: Verify the release tag against CometAPI + if: >- + github.run_attempt == 1 && + needs.verify.result == 'success' needs: - verify concurrency: @@ -641,6 +678,15 @@ jobs: # without required reviewers and add COMETAPI_KEY before publishing a release. environment: live-smoke steps: + - name: Reject a repeated live-smoke attempt + env: + WORKFLOW_RUN_ATTEMPT: ${{ github.run_attempt }} + run: | + set -euo pipefail + if [[ "$WORKFLOW_RUN_ATTEMPT" != "1" ]]; then + echo "The bounded live smoke is restricted to the initial run attempt." >&2 + exit 1 + fi - name: Check out the verified release tag uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -668,6 +714,11 @@ jobs: publish: name: Publish with npm Trusted Publishing + if: >- + always() && + (github.run_attempt == 1 || github.run_attempt == 2) && + needs.live-smoke.result == 'success' && + needs.verify.result == 'success' needs: - live-smoke - verify @@ -684,6 +735,16 @@ jobs: deployments: read id-token: write steps: + - name: Reject an out-of-bounds publication attempt + env: + WORKFLOW_RUN_ATTEMPT: ${{ github.run_attempt }} + run: | + set -euo pipefail + if [[ "$WORKFLOW_RUN_ATTEMPT" != "1" && + "$WORKFLOW_RUN_ATTEMPT" != "2" ]]; then + echo "Publication permits only the initial attempt and one failed-job replay." >&2 + exit 1 + fi - name: Check out the verified release commit uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -704,12 +765,14 @@ jobs: name: ${{ needs.verify.outputs.artifact-name }} path: release-artifacts - name: Reconfirm protected state immediately before publication + id: pre-publish env: CONTROL_COMMIT: ${{ needs.verify.outputs.control-commit }} CONTROL_VALIDATOR: ${{ runner.temp }}/release-workflow-validation.mjs ENVIRONMENT_FILE: ${{ runner.temp }}/npm-environment.json EVENT_REF: ${{ github.ref }} EVENT_SHA: ${{ github.sha }} + EXPECTED_NEXT_VERSION: ${{ needs.verify.outputs.expected-next-version }} GH_TOKEN: ${{ github.token }} POLICIES_FILE: ${{ runner.temp }}/npm-deployment-policies.json RELEASE_COMMIT: ${{ needs.verify.outputs.release-commit }} @@ -722,6 +785,7 @@ jobs: RELEASE_PLEASE_RUNS: ${{ runner.temp }}/release-please-runs-before-publish.json RELEASE_PLEASE_SNAPSHOT: ${{ needs.verify.outputs.release-please-snapshot }} VERSION: ${{ needs.verify.outputs.version }} + WORKFLOW_RUN_ATTEMPT: ${{ github.run_attempt }} WORKFLOW_SHA: ${{ github.workflow_sha }} shell: bash run: | @@ -793,6 +857,10 @@ jobs: exit 1 fi fi + if [[ "$WORKFLOW_RUN_ATTEMPT" == "2" && -z "$exact_version" ]]; then + echo "The failed-job replay requires the exact registry version to exist." >&2 + exit 1 + fi latest_version="$(npm view cometapi@latest version)" next_version="$(npm view cometapi@next version)" gh api --paginate --slurp \ @@ -885,14 +953,21 @@ jobs: }); validateRegistryStateBeforePublish({ exactVersion: process.env.EXACT_VERSION || null, + expectedNextVersion: process.env.EXPECTED_NEXT_VERSION, latestVersion: process.env.LATEST_VERSION, nextVersion: process.env.NEXT_VERSION, version, }); EOF + if [[ -n "$exact_version" ]]; then + echo "expect-existing=true" >> "$GITHUB_OUTPUT" + else + echo "expect-existing=false" >> "$GITHUB_OUTPUT" + fi - name: Publish the exact artifact with provenance env: DIST_TAG: ${{ needs.verify.outputs.dist-tag }} + EXPECT_EXISTING: ${{ steps.pre-publish.outputs.expect-existing }} VERSION: ${{ needs.verify.outputs.version }} run: bash scripts/publish-artifact.sh - name: Verify the public registry artifact @@ -900,6 +975,7 @@ jobs: CONTROL_COMMIT: ${{ needs.verify.outputs.control-commit }} CONTROL_VALIDATOR: ${{ runner.temp }}/release-workflow-validation.mjs DIST_TAG: ${{ needs.verify.outputs.dist-tag }} + EXPECTED_NEXT_VERSION: ${{ needs.verify.outputs.expected-next-version }} GH_TOKEN: ${{ github.token }} WORKFLOW_REF: ${{ github.ref }} VERSION: ${{ needs.verify.outputs.version }} @@ -942,14 +1018,57 @@ jobs: echo "Registry state did not converge for cometapi@${VERSION}, ${DIST_TAG}, integrity, and provenance." >&2 exit 1 fi - if [[ "$(npm view cometapi@next version)" != "0.1.0-alpha.3" ]]; then + if [[ "$(npm view cometapi@next version)" != "$EXPECTED_NEXT_VERSION" ]]; then echo "The next dist-tag changed during publication." >&2 exit 1 fi attestations_url="$(REGISTRY_DIST="$registry_dist" node -e 'process.stdout.write(JSON.parse(process.env.REGISTRY_DIST).attestations.url)')" attestations_file="$RUNNER_TEMP/npm-attestations.json" - curl --fail --silent --show-error "$attestations_url" > "$attestations_file" + ATTESTATIONS_URL="$attestations_url" \ + node --input-type=module <<'EOF' + import { pathToFileURL } from "node:url"; + + const { validateRegistryAttestationUrl } = await import( + pathToFileURL(process.env.CONTROL_VALIDATOR) + ); + validateRegistryAttestationUrl({ + url: process.env.ATTESTATIONS_URL, + version: process.env.VERSION, + }); + EOF + ATTESTATIONS_FILE="$attestations_file" \ + ATTESTATIONS_URL="$attestations_url" \ + bash scripts/fetch-attestations.sh + + post_exact_version="$(npm view "cometapi@${VERSION}" version)" + post_tagged_version="$(npm view "cometapi@${DIST_TAG}" version)" + post_next_version="$(npm view cometapi@next version)" + post_registry_dist="$(npm view "cometapi@${VERSION}" dist --json)" + ATTESTATIONS_URL="$attestations_url" \ + EXACT_VERSION="$post_exact_version" \ + EXPECTED_INTEGRITY="$local_integrity" \ + NEXT_VERSION="$post_next_version" \ + REGISTRY_DIST="$post_registry_dist" \ + TAGGED_VERSION="$post_tagged_version" \ + node --input-type=module <<'EOF' + import { pathToFileURL } from "node:url"; + + const { validatePublishedRegistryState } = await import( + pathToFileURL(process.env.CONTROL_VALIDATOR) + ); + validatePublishedRegistryState({ + attestationUrl: process.env.ATTESTATIONS_URL, + dist: JSON.parse(process.env.REGISTRY_DIST), + exactVersion: process.env.EXACT_VERSION, + expectedNextVersion: process.env.EXPECTED_NEXT_VERSION, + expectedIntegrity: process.env.EXPECTED_INTEGRITY, + nextVersion: process.env.NEXT_VERSION, + taggedVersion: process.env.TAGGED_VERSION, + version: process.env.VERSION, + }); + EOF + registry_dist="$post_registry_dist" local_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"))' "${tarballs[0]}")" provenance_identity="$(ATTESTATIONS_FILE="$attestations_file" \ LOCAL_SHA512="$local_sha512" node --input-type=module <<'EOF' @@ -1176,3 +1295,36 @@ jobs: process.exitCode = 1; }); EOF + + result: + name: Enforce the bounded publication result + if: >- + always() && + github.event_name == 'workflow_dispatch' && + inputs.publish_operation == 'release' + needs: + - live-smoke + - publish + - verify + runs-on: ubuntu-latest + permissions: {} + steps: + - name: Reject skipped or out-of-bounds publication + env: + LIVE_SMOKE_RESULT: ${{ needs.live-smoke.result }} + PUBLISH_RESULT: ${{ needs.publish.result }} + VERIFY_RESULT: ${{ needs.verify.result }} + WORKFLOW_RUN_ATTEMPT: ${{ github.run_attempt }} + run: | + set -euo pipefail + if [[ "$WORKFLOW_RUN_ATTEMPT" != "1" && + "$WORKFLOW_RUN_ATTEMPT" != "2" ]]; then + echo "Publication permits only the initial attempt and one failed-job replay." >&2 + exit 1 + fi + if [[ "$VERIFY_RESULT" != "success" || + "$LIVE_SMOKE_RESULT" != "success" || + "$PUBLISH_RESULT" != "success" ]]; then + echo "The bounded publication job set did not complete successfully." >&2 + exit 1 + fi diff --git a/AGENTS.md b/AGENTS.md index 024d8e1..d1245f0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -283,9 +283,32 @@ repository root. - Prepare or refresh a release PR with a new first-attempt manual Release Please dispatch. Do not rerun a preparation dispatch. Release Please same-run Release reconciliation is allowed only under the exact conditions in `RELEASING.md`. +- The successful Release Please `workflow_run` handoff is attempt-1-only and + must refuse to dispatch when any exact Publish child already exists for the + immutable tag and commit. Never rerun a handoff to create a second child run. - Never bypass a failed stable release with a manual or auxiliary tag, a - branch-context publish, a temporary `main` npm Environment policy, reused - artifact or live evidence, an arbitrary rerun, or a different patch version. + branch-context publish, a temporary `main` npm Environment policy, cross-run + artifact or live-evidence reuse, an arbitrary rerun, or a different patch + version. +- If `npm publish` succeeds but post-publication verification fails, first read + the exact public version, `latest`, `next`, integrity, and every readable + attestation, signature, and provenance field. When the exact version is + absent, `latest` must equal the previous patch. When the exact version equals + the candidate, `latest` may equal the previous patch or candidate. Final state + requires both exact and `latest` to equal the candidate, while `next` must + equal the prerelease value frozen by the initial verification job. A transient + early attestation-endpoint `404` is the known registry convergence condition; + all transport failures receive one wall-clock-bounded wait, and exhaustion is + terminal. Do not rerun to extend it. Only when attestations are readable and + independent signature and provenance checks have passed may exactly one + `rerun failed jobs` on the same immutable-tag run resume a different failed + post-publication gate. Confirm that GitHub preserves the successful + exact-artifact and live-smoke jobs; rerun-all must fail before live API access. + The attempt-2 publish job requires the exact version to exist, matching + integrity, and a fresh Environment approval; it must skip `npm publish`. + Attempt 3 or later, an exact-version metadata `E404`, a second replay request, + non-convergence, or any identity, integrity, provenance, dist-tag, or + configuration mismatch is a hard stop. - After registry verification, immediately restore `RELEASE_PLEASE_ENABLED=false`, keep `LIVE_SMOKE_ENABLED=true`, and require the npm Environment deployment-policy set to contain only `tag:v*`. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a610be3..40843f4 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -139,7 +139,9 @@ the exact Release-producing attempt, SHA, tag, version, URL, repository, workflow identity, run ID, and attempt in a schema-v2 exact-run artifact. An unprivileged `workflow_run` handoff validates only that attempt's artifact, exact tag, immutable Release, and current `main`, then dispatches `publish.yml` -with `ref=v`. Only that tag-bound `workflow_dispatch` can reach fresh +with `ref=v`. The handoff is attempt-1-only and refuses to dispatch if +an exact child run already exists for the tag and commit. Only that tag-bound +`workflow_dispatch` can reach fresh artifact verification, bounded live smoke, the npm Environment, or OIDC. A first-attempt manual run is explicitly release-inert and must succeed only after independently validating one canonical action-created patch PR; its event cannot @@ -153,6 +155,23 @@ Release Please and publication remain separate trust domains. Release Please does not receive npm OIDC permission; `id-token: write` remains limited to the protected publish job. Repository variables gate both flows, and reruns remain fail-closed on exact tag, artifact, dist-tag, integrity, and provenance state. +Registry package metadata and its attestation endpoint can converge at +different times. Post-publication verification therefore gives attestation HTTP +failures one strict wall-clock-bounded retry window before failing; an early +`404` is the known convergence case, while persistent URL, authentication, +authorization, server, and transport failures remain terminal. The initial +verification job freezes the prerelease dist-tag value, and every later registry +gate requires it to remain unchanged rather than encoding a current package +version in durable workflow guidance. If publication succeeded before a later +gate failed, only one attempt-2 failed-job replay of that same immutable-tag run +may continue, and only after attestations, signature, and provenance are already +valid. Verification and bounded live smoke are attempt-1-only, so rerun-all +fails before live API access; attempt 3 or later also fails. The protected-state +step requires the exact version to exist on replay; `publish-artifact.sh` then +skips registry mutation only after its integrity matches the verified tarball +and refuses a later `E404` instead of republishing. The failed-job replay +preserves the same run's successful artifact and bounded live-smoke jobs rather +than borrowing evidence from another run. Manual preparation rejects attempt 2 or later; restart uses a new dispatch with Release creation disabled. A `push` rerun is bounded to the same run ID, SHA, candidate, and final-head review. It may retry while the tag and Release remain diff --git a/RELEASING.md b/RELEASING.md index 5c7b05a..0c4b200 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -345,7 +345,9 @@ The repository maintains four independently auditable workflows: stable `v0.1.x` tag and release commit. Replays are integrity-idempotent, not exactly-once: an existing version is accepted only when its registry integrity matches the downloaded artifact, after which the bounded registry, signature, - and provenance checks run again. + and provenance checks run again. Package metadata and the attestation endpoint + may converge independently, so attestation HTTP failures, including an early + `404`, receive a bounded ten-minute retry. Exhaustion remains a hard failure. The one-time `0.1.1` main-context publication exception is historical evidence, not a reusable release route. Its dispatch inputs, fixed evidence identifiers, @@ -560,13 +562,14 @@ or any head change, invalidates the approval. The release-PR merge creates the only `push` run that may call Release Please in release mode. From that merge through successful registry verification and the final readback, do not start a new Release Please workflow or rerun any run -except the bounded source-run retry described below; publication freezes the -complete Release Please run set. Through post-action validation, do not edit the -release PR, change its labels or review, or mutate its branch. Keep `main` frozen -at the release commit through successful registry verification and the final -variable and Environment-policy readback; every handoff, tag, and pre-publish -gate requires that exact identity. A rerun may retry the same release candidate -only while no tag or Release exists. If an earlier attempt of that same run +except the bounded source-run retry described below or the exact post-publish +failed-job replay described after the handoff; publication freezes the complete +Release Please run set. Through post-action validation, do not edit the release +PR, change its labels or review, or mutate its branch. Keep `main` frozen at the +release commit through successful registry verification and the final variable +and Environment-policy readback; every handoff, tag, and pre-publish gate +requires that exact identity. A Release Please rerun may retry the same release +candidate only while no tag or Release exists. If an earlier attempt of that run already created the Release, same-run reconciliation is allowed only after proving the exact run ID, SHA, tag, bot author, immutable state, target, URL, notes, and publication time inside one earlier Release Please step. It may only @@ -579,12 +582,42 @@ verify the artifact, run the fresh bounded live smoke, enter the npm Environment, request OIDC, or verify the registry. The permanent npm Environment policy set is exactly `tag:v*`. After registry verification, immediately restore `RELEASE_PLEASE_ENABLED=false` and keep `LIVE_SMOKE_ENABLED=true`. +The handoff itself runs only on attempt 1 and refuses the dispatch when any +exact Publish child already exists for that immutable tag and commit. A handoff +rerun may never create a replacement child publication run. Never substitute a manually authored release PR, manual or auxiliary tag, -branch-context publication, temporary `main` Environment policy, reused artifact -or live evidence, arbitrary rerun, or different patch version. An ambiguous npm -result requires exact registry integrity, signature, and provenance inspection; -it never authorizes an automatic retry. +branch-context publication, temporary `main` Environment policy, cross-run +artifact or live-evidence reuse, arbitrary rerun, or different patch version. An +ambiguous npm result requires exact registry integrity, signature, and +provenance inspection; it never authorizes an automatic retry. + +If the exact publish step succeeded but a later registry verification step +failed, query the immutable version and both dist-tags before taking any action. +When the exact version is absent, `latest` must equal the previous patch. When +the exact version equals the candidate, `latest` may equal the previous patch or +candidate. Final verification requires the exact version and `latest` to equal +the candidate. Throughout the run, `next` must equal the prerelease value +captured by the initial verification job; durable guidance does not pin that +registry value. + +An early attestation-endpoint `404` is the known convergence case. Other HTTP or +transport failures receive the same finite retry, but persistent authentication, +authorization, URL, server, or convergence failures remain failures. Let the +workflow's strict ten-minute wall-clock fetch finish; exhaustion is terminal, so +stop and report rather than rerunning to extend the wait. Only after attestations +are readable and independent signature and provenance checks pass may a +different failed post-publication gate use exactly one `rerun failed jobs` on +the same tag-bound run. Before approving that replay, confirm that GitHub kept +the successful exact-artifact and live-smoke jobs. Rerun-all is invalid and its +attempt guards must fail before live API access. The attempt-2 protected +preflight must record that the exact version exists, and the idempotent +publication script must report matching integrity and skip `npm publish`. An +exact-version metadata `E404` after that preflight is a hard stop rather than +permission to republish. The failed publish job still requires a fresh npm +Environment approval. Attempt 3 or later, a second replay request, retry +exhaustion, or any mismatch is terminal. After successful verification, restore +`RELEASE_PLEASE_ENABLED=false` immediately. The `0.1.1` repair used the immutable `0.1.0` commit as a one-cycle `last-release-sha` only for its initial preparation, then removed the anchor. diff --git a/scripts/fetch-attestations.sh b/scripts/fetch-attestations.sh new file mode 100644 index 0000000..153c9cd --- /dev/null +++ b/scripts/fetch-attestations.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash + +set -euo pipefail + +monotonic_milliseconds() { + perl -MTime::HiRes=clock_gettime,CLOCK_MONOTONIC \ + -e 'printf "%d", int(clock_gettime(CLOCK_MONOTONIC) * 1000)' +} + +format_milliseconds() { + local milliseconds="$1" + printf '%d.%03d' \ + "$((milliseconds / 1000))" \ + "$((milliseconds % 1000))" +} + +started_at="$(monotonic_milliseconds)" + +: "${ATTESTATIONS_FILE:?ATTESTATIONS_FILE is required}" +: "${ATTESTATIONS_URL:?ATTESTATIONS_URL is required}" +if [[ -e "$ATTESTATIONS_FILE" || -L "$ATTESTATIONS_FILE" ]]; then + echo "The attestation output path must not already exist." >&2 + exit 1 +fi + +connect_timeout="${ATTESTATION_CONNECT_TIMEOUT_SECONDS:-10}" +deadline="${ATTESTATION_DEADLINE_SECONDS:-600}" +max_time="${ATTESTATION_MAX_TIME_SECONDS:-30}" +retry_count="${ATTESTATION_RETRY_COUNT:-59}" +retry_delay="${ATTESTATION_RETRY_DELAY_SECONDS:-10}" + +for value in \ + "$connect_timeout" \ + "$deadline" \ + "$max_time" \ + "$retry_count" \ + "$retry_delay" +do + if ! [[ "$value" =~ ^(0|[1-9][0-9]*)$ ]]; then + echo "Attestation retry settings must be non-negative integers." >&2 + exit 1 + fi +done +for value in "$connect_timeout" "$deadline" "$max_time"; do + if [[ "$value" -eq 0 ]]; then + echo "Attestation timeout settings must be positive integers." >&2 + exit 1 + fi +done +if [[ "$deadline" -gt 600 ]]; then + echo "The attestation deadline must not exceed 600 seconds." >&2 + exit 1 +fi +for value in "$connect_timeout" "$max_time" "$retry_count" "$retry_delay" +do + if [[ "$value" -gt 600 ]]; then + echo "Attestation retry settings must not exceed 600." >&2 + exit 1 + fi +done + +deadline_milliseconds=$((deadline * 1000)) +connect_timeout_milliseconds=$((connect_timeout * 1000)) +max_time_milliseconds=$((max_time * 1000)) +retry_delay_milliseconds=$((retry_delay * 1000)) +candidate="" +complete="false" +installed="false" +cleanup_candidate() { + if [[ -n "$candidate" ]]; then + rm -f -- "$candidate" + fi + if [[ "$installed" == "true" && "$complete" != "true" ]]; then + rm -f -- "$ATTESTATIONS_FILE" + fi +} +trap cleanup_candidate EXIT +trap 'exit 1' HUP INT TERM + +for ((attempt = 0; attempt <= retry_count; attempt += 1)); do + now="$(monotonic_milliseconds)" + remaining=$((deadline_milliseconds - (now - started_at))) + if [[ "$remaining" -le 0 ]]; then + break + fi + request_connect_timeout="$connect_timeout_milliseconds" + request_max_time="$max_time_milliseconds" + if [[ "$request_connect_timeout" -gt "$remaining" ]]; then + request_connect_timeout="$remaining" + fi + if [[ "$request_max_time" -gt "$remaining" ]]; then + request_max_time="$remaining" + fi + + candidate="${ATTESTATIONS_FILE}.download.${attempt}" + if curl --fail --silent --show-error \ + --connect-timeout "$(format_milliseconds "$request_connect_timeout")" \ + --max-time "$(format_milliseconds "$request_max_time")" \ + --remove-on-error \ + --output "$candidate" \ + "$ATTESTATIONS_URL" + then + now="$(monotonic_milliseconds)" + if [[ $((now - started_at)) -ge "$deadline_milliseconds" ]]; then + break + fi + mv "$candidate" "$ATTESTATIONS_FILE" + installed="true" + now="$(monotonic_milliseconds)" + if [[ $((now - started_at)) -ge "$deadline_milliseconds" ]]; then + rm -f -- "$ATTESTATIONS_FILE" + candidate="" + installed="false" + break + fi + candidate="" + complete="true" + exit 0 + fi + rm -f -- "$candidate" + candidate="" + + now="$(monotonic_milliseconds)" + remaining=$((deadline_milliseconds - (now - started_at))) + if [[ "$attempt" -eq "$retry_count" || "$remaining" -le 0 ]]; then + break + fi + delay="$retry_delay_milliseconds" + if [[ "$delay" -gt "$remaining" ]]; then + delay="$remaining" + fi + if [[ "$delay" -gt 0 ]]; then + sleep "$(format_milliseconds "$delay")" + fi +done + +echo "Registry attestations did not converge." >&2 +exit 1 diff --git a/scripts/publish-artifact.sh b/scripts/publish-artifact.sh index 0eb3c32..f743a22 100644 --- a/scripts/publish-artifact.sh +++ b/scripts/publish-artifact.sh @@ -3,8 +3,14 @@ set -euo pipefail : "${DIST_TAG:?DIST_TAG is required}" +: "${EXPECT_EXISTING:?EXPECT_EXISTING is required}" : "${VERSION:?VERSION is required}" +if [[ "$EXPECT_EXISTING" != "true" && "$EXPECT_EXISTING" != "false" ]]; then + echo "EXPECT_EXISTING must be true or false." >&2 + exit 1 +fi + artifact_directory="${ARTIFACT_DIRECTORY:-release-artifacts}" if [[ -n "${NPM_TOKEN:-}" ]] || [[ -n "${NODE_AUTH_TOKEN:-}" ]]; then @@ -37,6 +43,9 @@ if (dist.integrity !== process.env.LOCAL_INTEGRITY) { } EOF echo "cometapi@${VERSION} already matches the verified artifact; resuming checks." +elif grep -q "E404" "$view_error" && [[ "$EXPECT_EXISTING" == "true" ]]; then + echo "The pre-publish check found this version, but the registry now returns E404; refusing to publish again." >&2 + exit 1 elif grep -q "E404" "$view_error"; then npm publish "${tarballs[0]}" --access public --provenance --tag "$DIST_TAG" else diff --git a/scripts/release-workflow-validation.mjs b/scripts/release-workflow-validation.mjs index 61280a9..984ef34 100644 --- a/scripts/release-workflow-validation.mjs +++ b/scripts/release-workflow-validation.mjs @@ -1,6 +1,8 @@ import { createHash } from "node:crypto"; const STABLE_VERSION_PATTERN = /^0\.1\.(0|[1-9]\d*)$/; +const PRERELEASE_VERSION_PATTERN = + /^0\.1\.(?:0|[1-9]\d*)-(?:0|[1-9]\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))*$/; const RELEASE_PR_FILES = [ ".release-please-manifest.json", "CHANGELOG.md", @@ -16,9 +18,9 @@ const RELEASE_WORKFLOW_STEP = "Run Release Please"; const PUBLISH_OPERATION = "release"; const NPM_TAG_POLICY_ID = 55718965; const PUBLISH_WORKFLOW_CONTRACT_SHA256 = - "43f70219c4b8deed5a68a7a369821cc18f891119b373134fda8ca46fc7080e24"; + "71e517276bc595ca7cb378997f5d9a007d0c2b8da98019c5dc870f74e0621745"; const PUBLISH_HANDOFF_IF = - "vars.RELEASE_PLEASE_ENABLED == 'true' && github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' && github.event.workflow_run.head_branch == 'main'"; + "vars.RELEASE_PLEASE_ENABLED == 'true' && github.event_name == 'workflow_run' && github.run_attempt == 1 && github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' && github.event.workflow_run.head_branch == 'main'"; const PUBLISH_RESULT_IF = "steps.result.outputs.has-result == 'true'"; const PUBLISH_CONDITIONAL_STEPS = new Set([ "handoff:Install validation dependencies without lifecycle scripts", @@ -27,7 +29,13 @@ const PUBLISH_CONDITIONAL_STEPS = new Set([ "handoff:Dispatch the exact immutable tag", ]); const PUBLISH_VERIFY_IF = - "vars.RELEASE_PLEASE_ENABLED == 'true' && github.event_name == 'workflow_dispatch' && inputs.publish_operation == 'release' && startsWith(github.ref, 'refs/tags/v0.1.') && github.ref == format('refs/tags/{0}', inputs.release_tag) && github.sha == inputs.release_commit && github.workflow_sha == inputs.control_commit && inputs.control_commit == inputs.release_commit"; + "vars.RELEASE_PLEASE_ENABLED == 'true' && github.event_name == 'workflow_dispatch' && github.run_attempt == 1 && inputs.publish_operation == 'release' && startsWith(github.ref, 'refs/tags/v0.1.') && github.ref == format('refs/tags/{0}', inputs.release_tag) && github.sha == inputs.release_commit && github.workflow_sha == inputs.control_commit && inputs.control_commit == inputs.release_commit"; +const PUBLISH_LIVE_IF = + "github.run_attempt == 1 && needs.verify.result == 'success'"; +const PUBLISH_JOB_IF = + "always() && (github.run_attempt == 1 || github.run_attempt == 2) && needs.live-smoke.result == 'success' && needs.verify.result == 'success'"; +const PUBLISH_CONCLUSION_IF = + "always() && github.event_name == 'workflow_dispatch' && inputs.publish_operation == 'release'"; const PUBLISH_VERIFY_ENV = { SOURCE_RELEASE_COMMIT: "${{ inputs.release_commit }}", SOURCE_RELEASE_RUN_ATTEMPT: "${{ inputs.release_run_attempt }}", @@ -131,6 +139,18 @@ function stablePatch(version, label) { return Number(match[1]); } +export function validatePrereleaseDistTagBaseline(version) { + if ( + typeof version !== "string" || + !PRERELEASE_VERSION_PATTERN.test(version) + ) { + fail( + "Release workflow prerelease dist-tag baseline must be a 0.1.x prerelease version.", + ); + } + return version; +} + function requireUniqueStep(job, name, label) { const matches = Array.isArray(job?.steps) ? job.steps.filter((step) => step?.name === name) @@ -192,7 +212,7 @@ export function validatePublishWorkflowContract(workflow) { } requireEqual( JSON.stringify(Object.keys(jobs).sort()), - JSON.stringify(["handoff", "live-smoke", "publish", "verify"]), + JSON.stringify(["handoff", "live-smoke", "publish", "result", "verify"]), "Publish job set", ); for (const [jobName, job] of Object.entries(jobs)) { @@ -277,6 +297,8 @@ export function validatePublishWorkflowContract(workflow) { for (const fragment of [ "actions/workflows/publish.yml/dispatches", "tag-dispatch-runs-before.json", + "An exact Publish run already exists for this immutable tag and commit.", + "$before[0] | map(.id) | index($id) | not", "Multiple Publish runs matched the immutable tag handoff.", '-f ref="$RELEASE_TAG"', '-f "inputs[publish_operation]=release"', @@ -307,6 +329,31 @@ export function validatePublishWorkflowContract(workflow) { JSON.stringify(PUBLISH_VERIFY_ENV), "Publish verify source identity environment", ); + const verifyAttemptStep = requireUniqueStep( + jobs.verify, + "Reject a repeated verification attempt", + "Publish verification attempt guard", + ); + requireEqual( + jobs.verify?.steps?.[0], + verifyAttemptStep, + "Publish verification attempt-guard order", + ); + requireEqual( + verifyAttemptStep?.env?.WORKFLOW_RUN_ATTEMPT, + "${{ github.run_attempt }}", + "Publish verification run attempt", + ); + requireEqual( + verifyAttemptStep?.run, + `set -euo pipefail +if [[ "$WORKFLOW_RUN_ATTEMPT" != "1" ]]; then + echo "Exact-artifact verification is restricted to the initial run attempt." >&2 + exit 1 +fi +`, + "Publish verification attempt guard", + ); const dispatchValidationStep = requireUniqueStep( jobs.verify, "Validate the exact workflow dispatch", @@ -357,11 +404,67 @@ export function validatePublishWorkflowContract(workflow) { JSON.stringify(["verify"]), "Publish live-smoke dependencies", ); + requireEqual( + jobs["live-smoke"]?.if, + PUBLISH_LIVE_IF, + "Publish live-smoke attempt gate", + ); + const liveAttemptStep = requireUniqueStep( + jobs["live-smoke"], + "Reject a repeated live-smoke attempt", + "Publish live-smoke attempt guard", + ); + requireEqual( + jobs["live-smoke"]?.steps?.[0], + liveAttemptStep, + "Publish live-smoke attempt-guard order", + ); + requireEqual( + liveAttemptStep?.env?.WORKFLOW_RUN_ATTEMPT, + "${{ github.run_attempt }}", + "Publish live-smoke run attempt", + ); + requireEqual( + liveAttemptStep?.run, + `set -euo pipefail +if [[ "$WORKFLOW_RUN_ATTEMPT" != "1" ]]; then + echo "The bounded live smoke is restricted to the initial run attempt." >&2 + exit 1 +fi +`, + "Publish live-smoke attempt guard", + ); requireEqual( jobs.verify?.outputs?.["release-please-snapshot"], "${{ steps.release-please-snapshot.outputs.digest }}", "Publish Release Please snapshot output", ); + requireEqual( + jobs.verify?.outputs?.["expected-next-version"], + "${{ steps.registry-baseline.outputs.next-version }}", + "Publish prerelease dist-tag baseline output", + ); + const registryBaselineStep = requireUniqueStep( + jobs.verify, + "Freeze the prerelease dist-tag", + "Publish prerelease dist-tag baseline", + ); + requireEqual( + registryBaselineStep?.id, + "registry-baseline", + "Publish prerelease dist-tag baseline ID", + ); + for (const fragment of [ + 'next_version="$(npm view cometapi@next version)"', + "validatePrereleaseDistTagBaseline(process.env.NEXT_VERSION)", + 'echo "next-version=${next_version}" >> "$GITHUB_OUTPUT"', + ]) { + if (!registryBaselineStep?.run?.includes(fragment)) { + fail( + `Release workflow prerelease dist-tag baseline must contain ${fragment}.`, + ); + } + } const releasePleaseSnapshotStep = requireUniqueStep( jobs.verify, "Freeze the Release Please run set", @@ -479,6 +582,7 @@ export function validatePublishWorkflowContract(workflow) { JSON.stringify(["live-smoke", "verify"]), "Publish deployment dependencies", ); + requireEqual(jobs.publish?.if, PUBLISH_JOB_IF, "Publish deployment gate"); requireEqual(jobs.publish?.environment?.name, "npm", "Publish environment"); requireEqual( JSON.stringify(jobs.publish?.permissions), @@ -490,6 +594,32 @@ export function validatePublishWorkflowContract(workflow) { }), "Publish deployment permissions", ); + const publishAttemptStep = requireUniqueStep( + jobs.publish, + "Reject an out-of-bounds publication attempt", + "Publish publication attempt guard", + ); + requireEqual( + jobs.publish?.steps?.[0], + publishAttemptStep, + "Publish publication attempt-guard order", + ); + requireEqual( + publishAttemptStep?.env?.WORKFLOW_RUN_ATTEMPT, + "${{ github.run_attempt }}", + "Publish publication run attempt", + ); + requireEqual( + publishAttemptStep?.run, + `set -euo pipefail +if [[ "$WORKFLOW_RUN_ATTEMPT" != "1" && + "$WORKFLOW_RUN_ATTEMPT" != "2" ]]; then + echo "Publication permits only the initial attempt and one failed-job replay." >&2 + exit 1 +fi +`, + "Publish publication attempt guard", + ); const permissionCounts = { actionsWrite: 0, idTokenWrite: 0 }; for (const job of Object.values(jobs)) { if (job?.permissions?.actions === "write") @@ -504,6 +634,11 @@ export function validatePublishWorkflowContract(workflow) { "Reconfirm protected state immediately before publication", "Publish protected-state reconfirmation", ); + requireEqual( + reconfirmStep?.id, + "pre-publish", + "Publish protected-state output identity", + ); requireEqual( reconfirmStep?.env?.RELEASE_PLEASE_SNAPSHOT, "${{ needs.verify.outputs.release-please-snapshot }}", @@ -514,6 +649,16 @@ export function validatePublishWorkflowContract(workflow) { "${{ vars.RELEASE_PLEASE_ENABLED }}", "Publish Release Please variable context", ); + requireEqual( + reconfirmStep?.env?.WORKFLOW_RUN_ATTEMPT, + "${{ github.run_attempt }}", + "Publish protected-state run attempt", + ); + requireEqual( + reconfirmStep?.env?.EXPECTED_NEXT_VERSION, + "${{ needs.verify.outputs.expected-next-version }}", + "Publish prerelease dist-tag baseline reconfirmation", + ); for (const fragment of [ "actions/workflows/release-please.yml/runs?per_page=100", 'gh api --paginate --slurp \\\n "repos/${GITHUB_REPOSITORY}/environments/npm/deployment-branch-policies?per_page=100"', @@ -521,6 +666,8 @@ export function validatePublishWorkflowContract(workflow) { "snapshotReleasePleaseRuns", "expectedPolicyIds", "if (digest !== process.env.RELEASE_PLEASE_SNAPSHOT)", + 'WORKFLOW_RUN_ATTEMPT" == "2" && -z "$exact_version"', + "expectedNextVersion: process.env.EXPECTED_NEXT_VERSION", ]) { if ( typeof reconfirmStep?.run !== "string" || @@ -531,6 +678,24 @@ export function validatePublishWorkflowContract(workflow) { ); } } + const exactVersionRead = reconfirmStep.run.indexOf( + 'exact_version="$(npm view "cometapi@${VERSION}" version', + ); + const replayVersionGuard = reconfirmStep.run.indexOf( + 'if [[ "$WORKFLOW_RUN_ATTEMPT" == "2" && -z "$exact_version" ]]', + ); + const replayExpectationOutput = reconfirmStep.run.indexOf( + 'echo "expect-existing=true" >> "$GITHUB_OUTPUT"', + ); + if ( + exactVersionRead < 0 || + replayVersionGuard <= exactVersionRead || + replayExpectationOutput <= replayVersionGuard + ) { + fail( + "Release workflow replay must require the exact registry version before publishing.", + ); + } const publishStep = requireUniqueStep( jobs.publish, "Publish the exact artifact with provenance", @@ -546,6 +711,46 @@ export function validatePublishWorkflowContract(workflow) { "${{ github.token }}", "Publish registry verification token", ); + requireEqual( + registryStep?.env?.EXPECTED_NEXT_VERSION, + "${{ needs.verify.outputs.expected-next-version }}", + "Publish registry prerelease dist-tag baseline", + ); + for (const fragment of [ + "bash scripts/fetch-attestations.sh", + 'post_exact_version="$(npm view', + 'post_tagged_version="$(npm view', + 'post_next_version="$(npm view cometapi@next version)"', + 'post_registry_dist="$(npm view', + 'npm view cometapi@next version)" != "$EXPECTED_NEXT_VERSION"', + "expectedNextVersion: process.env.EXPECTED_NEXT_VERSION", + "validatePublishedRegistryState", + ]) { + if ( + typeof registryStep?.run !== "string" || + !registryStep.run.includes(fragment) + ) { + fail( + `Release workflow public-registry verification must contain ${fragment}.`, + ); + } + } + const attestationUrlRead = registryStep.run.indexOf('attestations_url="$('); + const attestationUrlValidation = registryStep.run.indexOf( + "validateRegistryAttestationUrl({", + ); + const attestationFetch = registryStep.run.indexOf( + "bash scripts/fetch-attestations.sh", + ); + if ( + attestationUrlRead < 0 || + attestationUrlValidation <= attestationUrlRead || + attestationFetch <= attestationUrlValidation + ) { + fail( + "Release workflow must validate the registry attestation URL before fetching it.", + ); + } const publishSteps = jobs.publish?.steps ?? []; if ( publishSteps.indexOf(reconfirmStep) >= publishSteps.indexOf(publishStep) || @@ -560,6 +765,64 @@ export function validatePublishWorkflowContract(workflow) { "bash scripts/publish-artifact.sh", "npm publication command", ); + requireEqual( + publishStep?.env?.EXPECT_EXISTING, + "${{ steps.pre-publish.outputs.expect-existing }}", + "npm publication replay expectation", + ); + requireEqual( + jobs.result?.if, + PUBLISH_CONCLUSION_IF, + "Publish conclusion gate", + ); + requireEqual( + JSON.stringify(jobs.result?.needs), + JSON.stringify(["live-smoke", "publish", "verify"]), + "Publish conclusion dependencies", + ); + requireEqual( + JSON.stringify(jobs.result?.permissions), + JSON.stringify({}), + "Publish conclusion permissions", + ); + requireEqual( + jobs.result?.environment, + undefined, + "Publish conclusion environment", + ); + const conclusionStep = requireUniqueStep( + jobs.result, + "Reject skipped or out-of-bounds publication", + "Publish conclusion", + ); + requireEqual(jobs.result?.steps?.length, 1, "Publish conclusion step count"); + requireEqual( + JSON.stringify(conclusionStep?.env), + JSON.stringify({ + LIVE_SMOKE_RESULT: "${{ needs.live-smoke.result }}", + PUBLISH_RESULT: "${{ needs.publish.result }}", + VERIFY_RESULT: "${{ needs.verify.result }}", + WORKFLOW_RUN_ATTEMPT: "${{ github.run_attempt }}", + }), + "Publish conclusion evidence", + ); + requireEqual( + conclusionStep?.run, + `set -euo pipefail +if [[ "$WORKFLOW_RUN_ATTEMPT" != "1" && + "$WORKFLOW_RUN_ATTEMPT" != "2" ]]; then + echo "Publication permits only the initial attempt and one failed-job replay." >&2 + exit 1 +fi +if [[ "$VERIFY_RESULT" != "success" || + "$LIVE_SMOKE_RESULT" != "success" || + "$PUBLISH_RESULT" != "success" ]]; then + echo "The bounded publication job set did not complete successfully." >&2 + exit 1 +fi +`, + "Publish conclusion command", + ); requireEqual( createHash("sha256").update(JSON.stringify(workflow)).digest("hex"), PUBLISH_WORKFLOW_CONTRACT_SHA256, @@ -738,7 +1001,7 @@ export function validatePublishWorkflowDispatchTrigger({ requireEqual(eventRef, `refs/tags/${releaseTag}`, "tag dispatch ref"); requirePositiveInteger(sourceRunId, "tag dispatch source run ID"); requirePositiveInteger(sourceRunAttempt, "tag dispatch source run attempt"); - requirePositiveInteger(workflowRunAttempt, "tag dispatch run attempt"); + requireEqual(workflowRunAttempt, 1, "tag dispatch run attempt"); return { releaseCommit, releaseRunAttempt: sourceRunAttempt, @@ -842,11 +1105,13 @@ export function validateNpmEnvironmentState({ export function validateRegistryStateBeforePublish({ exactVersion, + expectedNextVersion, latestVersion, nextVersion, version, }) { const patch = stablePatch(version, "registry candidate version"); + validatePrereleaseDistTagBaseline(expectedNextVersion); if (patch < 1) { fail("Release workflow registry candidate must be newer than 0.1.0."); } @@ -861,10 +1126,78 @@ export function validateRegistryStateBeforePublish({ `Release workflow registry latest must equal ${allowedLatest.join(" or ")}; received ${String(latestVersion)}.`, ); } - requireEqual(nextVersion, "0.1.0-alpha.3", "registry next version"); + requireEqual( + nextVersion, + expectedNextVersion, + "registry prerelease dist-tag baseline", + ); return { exactVersion, latestVersion, nextVersion, previousVersion, version }; } +export function validateRegistryAttestationUrl({ url, version }) { + stablePatch(version, "registry attestation version"); + requireEqual( + url, + `https://registry.npmjs.org/-/npm/v1/attestations/cometapi@${version}`, + "registry attestation URL identity", + ); + return { url, version }; +} + +export function validatePublishedRegistryState({ + attestationUrl, + dist, + exactVersion, + expectedNextVersion, + expectedIntegrity, + nextVersion, + taggedVersion, + version, +}) { + validateRegistryStateBeforePublish({ + exactVersion, + expectedNextVersion, + latestVersion: taggedVersion, + nextVersion, + version, + }); + requireEqual(exactVersion, version, "published registry exact version"); + requireEqual(taggedVersion, version, "published registry dist-tag"); + validateRegistryAttestationUrl({ url: attestationUrl, version }); + if (dist === null || typeof dist !== "object" || Array.isArray(dist)) { + fail("Release workflow published registry dist must be an object."); + } + if ( + typeof expectedIntegrity !== "string" || + !expectedIntegrity.startsWith("sha512-") + ) { + fail("Release workflow expected registry integrity must use sha512."); + } + requireEqual( + dist.integrity, + expectedIntegrity, + "published registry integrity", + ); + requireEqual( + dist.attestations?.url, + attestationUrl, + "published registry attestation URL", + ); + requireEqual( + dist.attestations?.provenance?.predicateType, + "https://slsa.dev/provenance/v1", + "published registry provenance predicate", + ); + return { + attestationUrl, + exactVersion, + integrity: dist.integrity, + nextVersion, + taggedVersion, + version, + }; +} + export function validateRegistryProvenance({ attestations, commit, diff --git a/tests/fetch-attestations.test.mjs b/tests/fetch-attestations.test.mjs new file mode 100644 index 0000000..f2162e8 --- /dev/null +++ b/tests/fetch-attestations.test.mjs @@ -0,0 +1,321 @@ +import { createServer } from "node:http"; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { clearTimeout, setTimeout } from "node:timers"; +import { spawn } from "node:child_process"; +import { fileURLToPath, URL } from "node:url"; + +import { afterEach, describe, expect, it } from "vitest"; + +const script = fileURLToPath( + new URL("../scripts/fetch-attestations.sh", import.meta.url), +); +const temporaryDirectories = []; + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { force: true, recursive: true }); + } +}); + +async function runFetch( + handler, + { + connectTimeout = "1", + deadline = "5", + maxTime = "2", + prepare, + retryCount = 2, + retryDelay = "0", + terminateAfterMilliseconds, + } = {}, +) { + const root = mkdtempSync(join(tmpdir(), "cometapi-attestations-test-")); + temporaryDirectories.push(root); + const output = join(root, "attestations.json"); + const server = createServer(handler); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (address === null || typeof address === "string") { + throw new Error("Expected a TCP test server address."); + } + + const preparedEnvironment = prepare?.({ output, root }) ?? {}; + const child = spawn("bash", [script], { + detached: terminateAfterMilliseconds !== undefined, + env: { + ...process.env, + ATTESTATIONS_FILE: output, + ATTESTATIONS_URL: `http://127.0.0.1:${address.port}/attestations`, + ATTESTATION_CONNECT_TIMEOUT_SECONDS: connectTimeout, + ATTESTATION_DEADLINE_SECONDS: deadline, + ATTESTATION_MAX_TIME_SECONDS: maxTime, + ATTESTATION_RETRY_COUNT: String(retryCount), + ATTESTATION_RETRY_DELAY_SECONDS: retryDelay, + ...preparedEnvironment, + }, + }); + let stderr = ""; + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + const terminationTimer = + terminateAfterMilliseconds === undefined + ? undefined + : setTimeout(() => { + try { + process.kill(-child.pid, "SIGTERM"); + } catch (error) { + if (error.code !== "ESRCH") throw error; + } + }, terminateAfterMilliseconds); + const status = await new Promise((resolve) => { + child.on("close", resolve); + }); + if (terminationTimer !== undefined) clearTimeout(terminationTimer); + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + return { output, status, stderr }; +} + +function fakeCommand(root, name, contents) { + const bin = join(root, "bin"); + mkdirSync(bin, { recursive: true }); + const command = join(bin, name); + writeFileSync(command, `#!/usr/bin/env bash\n${contents}`); + chmodSync(command, 0o755); + return `${bin}:${process.env.PATH ?? ""}`; +} + +function candidateFiles(output) { + return readdirSync(join(output, "..")).filter((name) => + name.includes(".download."), + ); +} + +describe("attestation registry convergence", () => { + it("retries an early 404 and atomically installs the successful response", async () => { + let requests = 0; + const result = await runFetch((_request, response) => { + requests += 1; + if (requests === 1) { + response.writeHead(404).end("not ready"); + return; + } + response + .writeHead(200, { "content-type": "application/json" }) + .end('{"attestations":[]}'); + }); + expect(result.status, result.stderr).toBe(0); + expect(requests).toBe(2); + expect(readFileSync(result.output, "utf8")).toBe('{"attestations":[]}'); + expect(existsSync(`${result.output}.download.1`)).toBe(false); + expect(candidateFiles(result.output)).toEqual([]); + }); + + it("does not concatenate a partial failed transfer into a retry", async () => { + let requests = 0; + const result = await runFetch((_request, response) => { + requests += 1; + if (requests === 1) { + response.writeHead(200, { "content-length": "100" }); + response.end("PARTIAL"); + return; + } + response + .writeHead(200, { "content-type": "application/json" }) + .end('{"attestations":["valid"]}'); + }); + expect(result.status, result.stderr).toBe(0); + expect(requests).toBe(2); + expect(readFileSync(result.output, "utf8")).toBe( + '{"attestations":["valid"]}', + ); + expect(candidateFiles(result.output)).toEqual([]); + }); + + it("fails closed when the retry budget is exhausted", async () => { + let requests = 0; + const result = await runFetch((_request, response) => { + requests += 1; + response.writeHead(404).end("not ready"); + }); + expect(result.status).not.toBe(0); + expect(requests).toBe(3); + expect(result.stderr).toContain("Registry attestations did not converge"); + expect(existsSync(result.output)).toBe(false); + expect(candidateFiles(result.output)).toEqual([]); + }); + + it.each([ + ["connect timeout", { connectTimeout: "0" }], + ["deadline", { deadline: "0" }], + ["request timeout", { maxTime: "0" }], + ])("rejects a disabled %s", async (_name, options) => { + const result = await runFetch((_request, response) => { + response.writeHead(200).end("unexpected"); + }, options); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain( + "Attestation timeout settings must be positive integers", + ); + }); + + it("rejects a deadline above the ten-minute release bound", async () => { + const result = await runFetch( + (_request, response) => { + response.writeHead(200).end("unexpected"); + }, + { deadline: "601" }, + ); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("must not exceed 600 seconds"); + }); + + it.each([ + ["a noncanonical integer", { connectTimeout: "01" }], + ["an oversized retry count", { retryCount: 601 }], + ])("rejects %s", async (_name, options) => { + const result = await runFetch((_request, response) => { + response.writeHead(200).end("unexpected"); + }, options); + expect(result.status).not.toBe(0); + }); + + it("caps an in-flight transfer at the wall-clock deadline", async () => { + const startedAt = Date.now(); + const result = await runFetch(() => undefined, { + deadline: "1", + maxTime: "30", + retryCount: 59, + }); + const elapsed = Date.now() - startedAt; + expect(result.status).not.toBe(0); + expect(elapsed).toBeLessThan(2_000); + expect(result.stderr).toContain("Registry attestations did not converge"); + }); + + it("rejects and removes a response that completes after the deadline", async () => { + const result = await runFetch(() => undefined, { + deadline: "1", + maxTime: "30", + prepare: ({ root }) => ({ + PATH: fakeCommand( + root, + "curl", + `output="" +while [[ "$#" -gt 0 ]]; do + if [[ "$1" == "--output" ]]; then + output="$2" + shift 2 + else + shift + fi +done +sleep 1.05 +printf '%s' '{"attestations":[]}' > "$output" +`, + ), + }), + retryCount: 0, + }); + expect(result.status).not.toBe(0); + expect(existsSync(result.output)).toBe(false); + expect(candidateFiles(result.output)).toEqual([]); + }); + + it("removes the candidate when atomic installation fails", async () => { + const result = await runFetch( + (_request, response) => { + response + .writeHead(200, { "content-type": "application/json" }) + .end('{"attestations":[]}'); + }, + { + prepare: ({ root }) => ({ + PATH: fakeCommand(root, "mv", "exit 1\n"), + }), + retryCount: 0, + }, + ); + expect(result.status).not.toBe(0); + expect(existsSync(result.output)).toBe(false); + expect(candidateFiles(result.output)).toEqual([]); + }); + + it("rejects a successful install that crosses the deadline", async () => { + const result = await runFetch( + (_request, response) => { + response + .writeHead(200, { "content-type": "application/json" }) + .end('{"attestations":[]}'); + }, + { + deadline: "1", + prepare: ({ root }) => ({ + PATH: fakeCommand(root, "mv", 'sleep 1.05\n/bin/mv "$@"\n'), + }), + retryCount: 0, + }, + ); + expect(result.status).not.toBe(0); + expect(existsSync(result.output)).toBe(false); + expect(candidateFiles(result.output)).toEqual([]); + }); + + it("cleans a partial candidate when the process group is interrupted", async () => { + const result = await runFetch(() => undefined, { + prepare: ({ root }) => ({ + PATH: fakeCommand( + root, + "curl", + `output="" +while [[ "$#" -gt 0 ]]; do + if [[ "$1" == "--output" ]]; then + output="$2" + shift 2 + else + shift + fi +done +printf '%s' 'PARTIAL' > "$output" +sleep 10 +`, + ), + }), + retryCount: 0, + terminateAfterMilliseconds: 250, + }); + expect(result.status).not.toBe(0); + expect(existsSync(result.output)).toBe(false); + expect(candidateFiles(result.output)).toEqual([]); + }); + + it("refuses to replace an existing output file", async () => { + const result = await runFetch( + (_request, response) => { + response.writeHead(200).end("unexpected"); + }, + { + prepare: ({ output }) => { + writeFileSync(output, "preserve-me"); + }, + }, + ); + expect(result.status).not.toBe(0); + expect(readFileSync(result.output, "utf8")).toBe("preserve-me"); + expect(candidateFiles(result.output)).toEqual([]); + }); +}); diff --git a/tests/publish-artifact.test.mjs b/tests/publish-artifact.test.mjs index fb50670..b5f3f35 100644 --- a/tests/publish-artifact.test.mjs +++ b/tests/publish-artifact.test.mjs @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { chmodSync, existsSync, @@ -41,6 +42,10 @@ function fixture() { [ "#!/usr/bin/env bash", 'if [[ "$1" == "view" ]]; then', + ' if [[ -n "${NPM_VIEW_DIST:-}" ]]; then', + ' printf "%s\\n" "$NPM_VIEW_DIST"', + " exit 0", + " fi", ' echo "npm error code E404" >&2', " exit 1", "fi", @@ -60,8 +65,10 @@ function fixture() { function runPublish({ distTag = "next", + expectExisting = "false", nodeAuthToken = "", npmToken = "", + registryIntegrity = "", version = "0.1.0-alpha.3", } = {}) { const { bin, log, root } = fixture(); @@ -72,9 +79,13 @@ function runPublish({ ...process.env, ARTIFACT_DIRECTORY: "artifacts", DIST_TAG: distTag, + EXPECT_EXISTING: expectExisting, NODE_AUTH_TOKEN: nodeAuthToken, NPM_CALL_LOG: log, NPM_TOKEN: npmToken, + NPM_VIEW_DIST: registryIntegrity + ? JSON.stringify({ integrity: registryIntegrity }) + : "", PATH: `${bin}${delimiter}${process.env.PATH ?? ""}`, VERSION: version, }, @@ -111,4 +122,41 @@ describe("publish artifact authentication", () => { ); expect(log).toBe(""); }); + + it("resumes verification without publishing an existing matching artifact", () => { + const integrity = `sha512-${createHash("sha512") + .update("artifact\n") + .digest("base64")}`; + const { log, result } = runPublish({ registryIntegrity: integrity }); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain( + "already matches the verified artifact; resuming checks", + ); + expect(log).toBe(""); + }); + + it("rejects an existing version with different integrity", () => { + const { log, result } = runPublish({ + registryIntegrity: "sha512-different", + }); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain( + "The existing registry version has different integrity", + ); + expect(log).toBe(""); + }); + + it("never republishes when the protected preflight found the exact version", () => { + const { log, result } = runPublish({ expectExisting: "true" }); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("refusing to publish again"); + expect(log).toBe(""); + }); + + it("rejects an invalid protected preflight state", () => { + const { log, result } = runPublish({ expectExisting: "maybe" }); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("EXPECT_EXISTING must be true or false"); + expect(log).toBe(""); + }); }); diff --git a/tests/release-workflow-validation.test.mjs b/tests/release-workflow-validation.test.mjs index 23ce811..bbc3eb4 100644 --- a/tests/release-workflow-validation.test.mjs +++ b/tests/release-workflow-validation.test.mjs @@ -15,9 +15,12 @@ import { validateMergedReleasePullRequest, validateOpenReleasePullRequestCollisions, validatePostActionPullRequestSnapshot, + validatePrereleaseDistTagBaseline, validateNpmEnvironmentState, validatePublishWorkflowContract, validatePublishWorkflowDispatchTrigger, + validatePublishedRegistryState, + validateRegistryAttestationUrl, validateRegistryProvenance, validateRegistryProvenanceInvocation, validateRegistryStateBeforePublish, @@ -39,6 +42,7 @@ const RELEASE_BRANCH = "release-please--branches--main--components--cometapi"; const RELEASE_SHA = "a".repeat(40); const BRANCH_SHA = "b".repeat(40); const RUN_ID = 123456789; +const NEXT_BASELINE = "0.1.0-alpha.3"; const PUBLISH_WORKFLOW = parse( readFileSync( new URL("../.github/workflows/publish.yml", import.meta.url), @@ -343,19 +347,12 @@ describe("Publish tag dispatch trigger", () => { ["source run", { sourceRunId: 0 }], ["source attempt", { sourceRunAttempt: 0 }], ["run attempt", { workflowRunAttempt: 0 }], + ["replay attempt", { workflowRunAttempt: 2 }], ])("rejects tag handoff drift in %s", (_name, overrides) => { expect(() => validatePublishWorkflowDispatchTrigger(tagTrigger(overrides)), ).toThrow(/release workflow/i); }); - - it("allows an idempotent rerun of the same tag handoff", () => { - expect( - validatePublishWorkflowDispatchTrigger( - tagTrigger({ workflowRunAttempt: 2 }), - ), - ).toMatchObject({ releaseRunId: RUN_ID }); - }); }); describe("Publish workflow dispatch contract", () => { @@ -435,6 +432,68 @@ describe("Publish workflow dispatch contract", () => { workflow.jobs.publish.permissions.checks = "read"; }, ], + [ + "missing verification attempt guard", + (workflow) => { + workflow.jobs.verify.steps.shift(); + }, + ], + [ + "late verification attempt guard", + (workflow) => { + const [guard] = workflow.jobs.verify.steps.splice(0, 1); + workflow.jobs.verify.steps.splice(2, 0, guard); + }, + ], + [ + "verification on replay attempt", + (workflow) => { + workflow.jobs.verify.steps[0].run = + workflow.jobs.verify.steps[0].run.replace('!= "1"', '== "0"'); + }, + ], + [ + "missing live-smoke attempt guard", + (workflow) => { + workflow.jobs["live-smoke"].steps.shift(); + }, + ], + [ + "late live-smoke attempt guard", + (workflow) => { + const [guard] = workflow.jobs["live-smoke"].steps.splice(0, 1); + workflow.jobs["live-smoke"].steps.splice(2, 0, guard); + }, + ], + [ + "missing publication attempt guard", + (workflow) => { + workflow.jobs.publish.steps.shift(); + }, + ], + [ + "late publication attempt guard", + (workflow) => { + const [guard] = workflow.jobs.publish.steps.splice(0, 1); + workflow.jobs.publish.steps.splice(2, 0, guard); + }, + ], + [ + "third publication attempt", + (workflow) => { + workflow.jobs.publish.steps[0].run = + workflow.jobs.publish.steps[0].run.replace( + '!= "2"', + '!= "2" && "$WORKFLOW_RUN_ATTEMPT" != "3"', + ); + }, + ], + [ + "missing prerelease dist-tag baseline", + (workflow) => { + delete workflow.jobs.verify.outputs["expected-next-version"]; + }, + ], [ "missing Release Please run snapshot", (workflow) => { @@ -498,6 +557,35 @@ describe("Publish workflow dispatch contract", () => { .replaceAll("exit 1", "true"); }, ], + [ + "replay without the exact registry version", + (workflow) => { + const step = workflow.jobs.publish.steps.find( + ({ name }) => + name === "Reconfirm protected state immediately before publication", + ); + step.run = step.run.replace( + /if \[\[ "\$WORKFLOW_RUN_ATTEMPT" == "2" && -z "\$exact_version" \]\]; then\n {2}echo "The failed-job replay requires the exact registry version to exist\." >&2\n {2}exit 1\nfi\n/, + "", + ); + }, + ], + [ + "late replay exact-version guard", + (workflow) => { + const step = workflow.jobs.publish.steps.find( + ({ name }) => + name === "Reconfirm protected state immediately before publication", + ); + const guard = `if [[ "$WORKFLOW_RUN_ATTEMPT" == "2" && -z "$exact_version" ]]; then + echo "The failed-job replay requires the exact registry version to exist." >&2 + exit 1 +fi +`; + step.run = step.run.replace(guard, ""); + step.run += guard; + }, + ], [ "different uploaded artifact", (workflow) => { @@ -513,6 +601,15 @@ describe("Publish workflow dispatch contract", () => { workflow.jobs["live-smoke"].needs = ["handoff", "verify"]; }, ], + [ + "replayed live-smoke job", + (workflow) => { + workflow.jobs["live-smoke"].if = workflow.jobs["live-smoke"].if.replace( + "github.run_attempt == 1", + "github.run_attempt >= 1", + ); + }, + ], [ "elevated live-smoke permissions", (workflow) => { @@ -537,6 +634,15 @@ describe("Publish workflow dispatch contract", () => { ); }, ], + [ + "replayed tag verification job", + (workflow) => { + workflow.jobs.verify.if = workflow.jobs.verify.if.replace( + "github.run_attempt == 1", + "github.run_attempt >= 1", + ); + }, + ], [ "unguarded tag dispatch", (workflow) => { @@ -555,6 +661,27 @@ describe("Publish workflow dispatch contract", () => { workflow.jobs.handoff.if += " || true"; }, ], + [ + "replayed handoff dispatch", + (workflow) => { + workflow.jobs.handoff.if = workflow.jobs.handoff.if.replace( + "github.run_attempt == 1", + "github.run_attempt >= 1", + ); + }, + ], + [ + "duplicate immutable-tag dispatch", + (workflow) => { + const step = workflow.jobs.handoff.steps.find( + ({ name }) => name === "Dispatch the exact immutable tag", + ); + step.run = step.run.replace( + /prior_count="\$\(jq[\s\S]*?if \[\[ "\$prior_count" != "0" \]\]; then\n {2}echo "An exact Publish run already exists for this immutable tag and commit\." >&2\n {2}exit 1\nfi\n/, + "", + ); + }, + ], [ "missing runtime dispatch validation", (workflow) => { @@ -713,6 +840,63 @@ describe("Publish workflow dispatch contract", () => { step["continue-on-error"] = true; }, ], + [ + "unbounded attestation fetch", + (workflow) => { + const step = workflow.jobs.publish.steps.find( + ({ name }) => name === "Verify the public registry artifact", + ); + step.run = step.run.replace( + "bash scripts/fetch-attestations.sh", + 'curl "$attestations_url" > "$attestations_file"', + ); + }, + ], + [ + "missing pre-fetch attestation URL validation", + (workflow) => { + const step = workflow.jobs.publish.steps.find( + ({ name }) => name === "Verify the public registry artifact", + ); + step.run = step.run.replace( + /validateRegistryAttestationUrl\(\{\n {2}url: process\.env\.ATTESTATIONS_URL,\n {2}version: process\.env\.VERSION,\n\}\);\n/, + "", + ); + }, + ], + [ + "post-fetch attestation URL validation", + (workflow) => { + const step = workflow.jobs.publish.steps.find( + ({ name }) => name === "Verify the public registry artifact", + ); + const call = `validateRegistryAttestationUrl({ + url: process.env.ATTESTATIONS_URL, + version: process.env.VERSION, +}); +`; + step.run = step.run.replace(call, ""); + step.run += call; + }, + ], + [ + "missing post-attestation registry readback", + (workflow) => { + const step = workflow.jobs.publish.steps.find( + ({ name }) => name === "Verify the public registry artifact", + ); + step.run = step.run.replace("validatePublishedRegistryState", ""); + }, + ], + [ + "missing existing-version replay expectation", + (workflow) => { + const step = workflow.jobs.publish.steps.find( + ({ name }) => name === "Publish the exact artifact with provenance", + ); + delete step.env.EXPECT_EXISTING; + }, + ], [ "normal live-smoke condition", (workflow) => { @@ -765,6 +949,27 @@ describe("Publish workflow dispatch contract", () => { ]; }, ], + [ + "unbounded publication job gate", + (workflow) => { + workflow.jobs.publish.if = workflow.jobs.publish.if.replace( + "(github.run_attempt == 1 || github.run_attempt == 2)", + "github.run_attempt >= 1", + ); + }, + ], + [ + "missing bounded publication conclusion", + (workflow) => { + delete workflow.jobs.result; + }, + ], + [ + "skipped bounded publication conclusion", + (workflow) => { + workflow.jobs.result.if = "${{ false }}"; + }, + ], ])("rejects %s", (_name, mutate) => { expect(() => validatePublishWorkflowContract(workflowContract(mutate)), @@ -857,31 +1062,114 @@ describe("npm publication state", () => { expect( validateRegistryStateBeforePublish({ exactVersion: null, + expectedNextVersion: NEXT_BASELINE, latestVersion: "0.1.0", - nextVersion: "0.1.0-alpha.3", + nextVersion: NEXT_BASELINE, version: "0.1.1", }), ).toMatchObject({ previousVersion: "0.1.0" }); }); + it.each([ + "", + "0.1.2", + "0.2.0-alpha.1", + "0.1.0-alpha.01", + "0.1.0-alpha\nnext=bad", + ])("rejects an invalid prerelease dist-tag baseline %j", (version) => { + expect(() => validatePrereleaseDistTagBaseline(version)).toThrow( + /release workflow/i, + ); + }); + it("accepts an idempotent replay only at the same exact version", () => { expect( validateRegistryStateBeforePublish({ exactVersion: "0.1.1", + expectedNextVersion: NEXT_BASELINE, latestVersion: "0.1.1", - nextVersion: "0.1.0-alpha.3", + nextVersion: NEXT_BASELINE, version: "0.1.1", }), ).toMatchObject({ exactVersion: "0.1.1" }); }); + function publishedRegistryFixture() { + return { + attestationUrl: + "https://registry.npmjs.org/-/npm/v1/attestations/cometapi@0.1.2", + dist: { + attestations: { + provenance: { predicateType: "https://slsa.dev/provenance/v1" }, + url: "https://registry.npmjs.org/-/npm/v1/attestations/cometapi@0.1.2", + }, + integrity: "sha512-exact", + }, + exactVersion: "0.1.2", + expectedNextVersion: NEXT_BASELINE, + expectedIntegrity: "sha512-exact", + nextVersion: NEXT_BASELINE, + taggedVersion: "0.1.2", + version: "0.1.2", + }; + } + + it("revalidates immutable and mutable registry state after attestation convergence", () => { + expect( + validatePublishedRegistryState(publishedRegistryFixture()), + ).toMatchObject({ + exactVersion: "0.1.2", + nextVersion: "0.1.0-alpha.3", + taggedVersion: "0.1.2", + }); + }); + + it("accepts only the canonical npm attestation endpoint before fetching", () => { + expect( + validateRegistryAttestationUrl({ + url: publishedRegistryFixture().attestationUrl, + version: "0.1.2", + }), + ).toMatchObject({ version: "0.1.2" }); + expect(() => + validateRegistryAttestationUrl({ + url: "https://example.com/attestations", + version: "0.1.2", + }), + ).toThrow(/release workflow/i); + }); + + it.each([ + ["exact version", (state) => (state.exactVersion = "0.1.1")], + ["dist-tag", (state) => (state.taggedVersion = "0.1.1")], + ["next", (state) => (state.nextVersion = "0.1.0-alpha.4")], + ["integrity", (state) => (state.dist.integrity = "sha512-different")], + ["attestation URL identity", (state) => (state.attestationUrl += "-other")], + [ + "attestation URL", + (state) => (state.dist.attestations.url += "-different"), + ], + [ + "provenance predicate", + (state) => + (state.dist.attestations.provenance.predicateType = "unexpected"), + ], + ])("rejects post-attestation registry drift in %s", (_name, mutate) => { + const state = publishedRegistryFixture(); + mutate(state); + expect(() => validatePublishedRegistryState(state)).toThrow( + /release workflow/i, + ); + }); + it.each([ [ "wrong latest", { exactVersion: null, + expectedNextVersion: NEXT_BASELINE, latestVersion: "0.1.1", - nextVersion: "0.1.0-alpha.3", + nextVersion: NEXT_BASELINE, version: "0.1.1", }, ], @@ -889,6 +1177,7 @@ describe("npm publication state", () => { "changed next", { exactVersion: null, + expectedNextVersion: NEXT_BASELINE, latestVersion: "0.1.0", nextVersion: "0.1.0-alpha.4", version: "0.1.1", @@ -898,8 +1187,9 @@ describe("npm publication state", () => { "wrong exact", { exactVersion: "0.1.2", + expectedNextVersion: NEXT_BASELINE, latestVersion: "0.1.0", - nextVersion: "0.1.0-alpha.3", + nextVersion: NEXT_BASELINE, version: "0.1.1", }, ], @@ -907,8 +1197,9 @@ describe("npm publication state", () => { "0.2 release", { exactVersion: null, + expectedNextVersion: NEXT_BASELINE, latestVersion: "0.1.1", - nextVersion: "0.1.0-alpha.3", + nextVersion: NEXT_BASELINE, version: "0.2.0", }, ], diff --git a/tests/workflow-contract.test.mjs b/tests/workflow-contract.test.mjs index 9fce7b1..dbf4465 100644 --- a/tests/workflow-contract.test.mjs +++ b/tests/workflow-contract.test.mjs @@ -40,6 +40,10 @@ const releaseWorkflowValidation = readFileSync( new URL("../scripts/release-workflow-validation.mjs", import.meta.url), "utf8", ); +const fetchAttestations = readFileSync( + new URL("../scripts/fetch-attestations.sh", import.meta.url), + "utf8", +); const releasePleaseWorkflow = parse(workflows["release-please.yml"]); const publishWorkflow = parse(workflows["publish.yml"]); @@ -565,15 +569,36 @@ describe("GitHub Actions workflow contract", () => { expect(publishJob).not.toContain("source_publish"); expect(publishJob).toContain("validateRegistryProvenance"); expect(publishJob).toContain("validateRegistryProvenanceInvocation"); + expect(publishJob).toContain("bash scripts/fetch-attestations.sh"); + expect(publishJob).toContain("validatePublishedRegistryState"); + expect(publishJob).toContain("post_exact_version"); + expect(publishJob).toContain("post_tagged_version"); + expect(publishJob).toContain("post_next_version"); + expect(publishJob).toContain("post_registry_dist"); + expect(fetchAttestations).toContain( + 'deadline="${ATTESTATION_DEADLINE_SECONDS:-600}"', + ); + expect(fetchAttestations).toContain( + "clock_gettime(CLOCK_MONOTONIC) * 1000", + ); + expect(fetchAttestations).toContain( + "deadline_milliseconds=$((deadline * 1000))", + ); + expect(fetchAttestations).toContain("--remove-on-error"); + expect(fetchAttestations).toContain('--output "$candidate"'); expect(publishJob).toContain("WORKFLOW_REF: ${{ github.ref }}"); expect(publishJob).toContain("provenance.provenanceRunId"); expect(publishJob).toContain("PROVENANCE_RUN_ATTEMPT"); expect(publishJob).toContain( "for state in in_progress queued waiting requested pending", ); + expect(publishWorkflow.jobs.verify.outputs["expected-next-version"]).toBe( + "${{ steps.registry-baseline.outputs.next-version }}", + ); expect(publishJob).toContain( - 'npm view cometapi@next version)" != "0.1.0-alpha.3"', + 'npm view cometapi@next version)" != "$EXPECTED_NEXT_VERSION"', ); + expect(publish).not.toContain("0.1.0-alpha.3"); expect(publishJob).toContain( "RELEASE_PLEASE_ENABLED: ${{ vars.RELEASE_PLEASE_ENABLED }}", );