From 9dfd5e680171c2a19e57f6c087d3830a9db7fac9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 4 Aug 2026 18:17:38 -0700 Subject: [PATCH] fix(release): wait for npm propagation --- .github/workflows/release.yml | 70 +++++----- .github/workflows/update-homebrew-tap.yml | 23 ++-- docs/RELEASE.md | 6 +- scripts/release.sh | 25 +--- scripts/test-release.sh | 61 +++++++-- scripts/verify-npm-publication.mjs | 154 ++++++++++++++++++++++ tests/verify-npm-publication.test.mjs | 111 ++++++++++++++++ 7 files changed, 368 insertions(+), 82 deletions(-) create mode 100644 scripts/verify-npm-publication.mjs create mode 100644 tests/verify-npm-publication.test.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d01c5431..0c49fd99 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,6 +31,12 @@ jobs: permissions: contents: read id-token: write + outputs: + default_branch: ${{ steps.release.outputs.default_branch }} + npm_integrity: ${{ steps.publish.outputs.integrity }} + native_verifier_run_id: ${{ steps.proof.outputs.native_verifier_run_id }} + tag: ${{ steps.release.outputs.tag }} + version: ${{ steps.tag.outputs.version }} steps: - name: Resolve real published release id: release @@ -247,57 +253,55 @@ jobs: } - name: Publish verified npm archive with provenance + id: publish shell: bash env: NPM_ARCHIVE: ${{ steps.proof.outputs.npm_archive }} NPM_CONFIG_IGNORE_SCRIPTS: 'true' run: | set -euo pipefail + expected_integrity="sha512-$(openssl dgst -sha512 -binary "$NPM_ARCHIVE" | openssl base64 -A)" + echo "integrity=$expected_integrity" >> "$GITHUB_OUTPUT" if ! npm publish --access public --provenance "$NPM_ARCHIVE"; then echo "::error::npm trusted publishing failed. Configure the mcporter package on npmjs.com with openclaw/mcporter and .github/workflows/release.yml; this workflow intentionally has no NPM_TOKEN." exit 1 fi + verify-npm: + needs: release + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ needs.release.outputs.tag }} + persist-credentials: false + + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + check-latest: true + - name: Verify immutable npm publication - shell: bash env: - NPM_ARCHIVE: ${{ steps.proof.outputs.npm_archive }} - PACKAGE_VERSION: ${{ steps.tag.outputs.version }} - run: | - set -euo pipefail - expected_integrity="sha512-$(openssl dgst -sha512 -binary "$NPM_ARCHIVE" | openssl base64 -A)" - registry_ready=0 - - for _ in {1..20}; do - registry_version="$(npm view "mcporter@$PACKAGE_VERSION" version 2>/dev/null || true)" - registry_integrity="$(npm view "mcporter@$PACKAGE_VERSION" dist.integrity 2>/dev/null || true)" - if [[ "$registry_version" == "$PACKAGE_VERSION" && "$registry_integrity" == "$expected_integrity" ]]; then - registry_ready=1 - break - fi - if [[ "$registry_version" == "$PACKAGE_VERSION" && -n "$registry_integrity" && "$registry_integrity" != "$expected_integrity" ]]; then - echo "::error::npm registry integrity does not match the protected GitHub Release tarball." - exit 1 - fi - sleep 3 - done - - [[ "$registry_ready" == 1 ]] || { - echo "::error::npm did not expose the verified release artifact before timeout." - exit 1 - } - [[ "$(npm view mcporter dist-tags.latest)" == "$PACKAGE_VERSION" ]] || { - echo "::error::npm latest does not point to $PACKAGE_VERSION." - exit 1 - } + EXPECTED_INTEGRITY: ${{ needs.release.outputs.npm_integrity }} + PACKAGE_VERSION: ${{ needs.release.outputs.version }} + run: node scripts/verify-npm-publication.mjs mcporter "$PACKAGE_VERSION" "$EXPECTED_INTEGRITY" + dispatch-homebrew: + needs: release + runs-on: ubuntu-latest + permissions: + contents: read + steps: - name: Dispatch protected Homebrew update shell: bash env: - DEFAULT_BRANCH: ${{ steps.release.outputs.default_branch }} + DEFAULT_BRANCH: ${{ needs.release.outputs.default_branch }} GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} - NATIVE_VERIFIER_RUN_ID: ${{ steps.proof.outputs.native_verifier_run_id }} - RELEASE_TAG: ${{ steps.release.outputs.tag }} + NATIVE_VERIFIER_RUN_ID: ${{ needs.release.outputs.native_verifier_run_id }} + RELEASE_TAG: ${{ needs.release.outputs.tag }} run: | set -euo pipefail [[ -n "$GH_TOKEN" ]] diff --git a/.github/workflows/update-homebrew-tap.yml b/.github/workflows/update-homebrew-tap.yml index 59e7a9db..31e5fc05 100644 --- a/.github/workflows/update-homebrew-tap.yml +++ b/.github/workflows/update-homebrew-tap.yml @@ -51,14 +51,14 @@ jobs: echo "tag=$tag" >> "$GITHUB_OUTPUT" echo "request_id=mcporter-${tag}-${GITHUB_RUN_ID}" >> "$GITHUB_OUTPUT" - - name: Verify npm publication - shell: bash - env: - RELEASE_TAG: ${{ steps.release.outputs.tag }} - run: | - set -euo pipefail - version="${RELEASE_TAG#v}" - [[ "$(npm view "mcporter@$version" version)" == "$version" ]] + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - uses: actions/setup-node@v6 + with: + node-version: 24 + check-latest: true - name: Dispatch tap update env: @@ -176,12 +176,7 @@ jobs: process.stdout.write(`sha512-${digest}`); NODE ) - registry_npm_integrity=$(npm view "mcporter@$version" dist.integrity) - [[ "$registry_npm_integrity" == "$verified_npm_integrity" ]] || { - echo "npm registry integrity does not match the verified GitHub tarball" >&2 - exit 1 - } - [[ "$(npm view mcporter dist-tags.latest)" == "$version" ]] + node scripts/verify-npm-publication.mjs mcporter "$version" "$verified_npm_integrity" gh workflow run update-formula.yml \ --repo steipete/homebrew-tap \ diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 0b9700fc..907fc49b 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -77,10 +77,10 @@ Do not publish GitHub, npm, or Homebrew before both native jobs succeed. 1. Publish the already-verified GitHub draft without changing its tag or asset inventory. Publishing a real, non-prerelease GitHub Release triggers **Release**; pushing the tag alone does not publish npm. 2. **Release** checks out the exact tag on a GitHub-hosted Ubuntu runner, validates the package author and normalized repository URL, requires `vMAJOR.MINOR.PATCH` to equal `v${package.json.version}`, and proves the tagged commit is contained in `origin/main`. It rejects draft and prerelease releases and any version already present on npm. 3. Before npm publication, the workflow finds the successful **Verify Release Assets** run for the exact tag commit, downloads both architecture proof artifacts and every published release asset, and requires their IDs, sizes, and SHA-256 digests to match. It then runs `pnpm check` and `pnpm test` without weakening either gate. -4. With those proofs complete, the workflow confirms that GitHub OIDC is available and uses npm trusted publishing plus provenance to publish the exact verified `mcporter-.tgz` from the GitHub Release. It waits for npm to expose that immutable version, requires registry integrity to match the verified tarball, and requires `latest` to point to it. No Developer ID, notarization, or npm token enters Actions. -5. Only after npm verification succeeds, **Release** dispatches **Update Homebrew Tap** from the current default branch with the tag and resolved native verifier run ID. That existing workflow rechecks npm, the exact native proof SHA/title/workflow, both architecture manifests, and every published GitHub byte before dispatching the tap. It computes SHA-512 integrity from the verified GitHub npm tarball and requires npm `dist.integrity` plus `latest` to match; `HOMEBREW_TAP_TOKEN` is scoped only to proof access and dispatch/wait steps. +4. With those proofs complete, the workflow confirms that GitHub OIDC is available and uses npm trusted publishing plus provenance to publish the exact verified `mcporter-.tgz` from the GitHub Release. A sibling verification job gives npm up to five minutes to expose the version, integrity, and `latest` metadata, using capped exponential backoff for metadata that is still absent. A visible wrong version, integrity, or `latest` value is not retried: it fails immediately as an immutable-publication mismatch. No Developer ID, notarization, or npm token enters Actions. +5. **Release** dispatches **Update Homebrew Tap** from a separate job that depends on successful publication, not on the sibling npm propagation check. This prevents ordinary registry latency from suppressing Homebrew recovery while preserving a red release run for a genuine verification failure. The Homebrew workflow repeats the same patient npm check after revalidating the exact native proof SHA/title/workflow, both architecture manifests, and every published GitHub byte; only matching SHA-512 integrity and `latest` metadata can reach the tap dispatch. `HOMEBREW_TAP_TOKEN` is scoped only to proof access and dispatch/wait steps. -A manual **Release** dispatch is a recovery fallback, not a way around the native gate. Dispatch it from the current default branch with `tag=v`; it accepts only an existing published, non-prerelease GitHub Release and repeats the same tag, `main`, native-proof, source-gate, npm, and Homebrew checks. Because npm versions are immutable, if npm succeeded but the downstream Homebrew dispatch failed, rerun **Update Homebrew Tap** directly with the same tag and recorded native verifier run ID instead of rerunning **Release**. +A manual **Release** dispatch is a recovery fallback, not a way around the native gate. Dispatch it from the current default branch with `tag=v`; it accepts only an existing published, non-prerelease GitHub Release and repeats the same tag, `main`, native-proof, source-gate, npm, and Homebrew checks. Because npm versions are immutable, do not rerun **Release** after npm accepted the version. If the separate Homebrew job or **Update Homebrew Tap** fails, rerun **Update Homebrew Tap** directly with the same tag and recorded native verifier run ID. Verify registry metadata after automation completes: diff --git a/scripts/release.sh b/scripts/release.sh index cad2561d..95fc04d0 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -193,7 +193,7 @@ phase_publish_npm() { banner "Publish npm after native proof" phase_verify_local verify_public_native_proof - local npm_archive local_integrity existing_version registry_version registry_integrity registry_ready + local npm_archive local_integrity existing_version npm_archive=$VERIFIED_PUBLIC_NPM_ARCHIVE [[ -f "$npm_archive" ]] || { echo "missing verified npm artifact: $npm_archive" >&2 @@ -213,28 +213,7 @@ NODE echo "npm publish returned non-zero; checking whether the immutable version was accepted before retrying." >&2 fi - registry_ready=0 - for _ in {1..20}; do - registry_version=$(npm view "mcporter@$VERSION" version 2>/dev/null || true) - registry_integrity=$(npm view "mcporter@$VERSION" dist.integrity 2>/dev/null || true) - if [[ "$registry_version" == "$VERSION" && "$registry_integrity" == "$local_integrity" ]]; then - registry_ready=1 - break - fi - if [[ "$registry_version" == "$VERSION" && -n "$registry_integrity" && "$registry_integrity" != "$local_integrity" ]]; then - echo "npm registry integrity does not match the verified release tarball" >&2 - exit 1 - fi - sleep 3 - done - [[ "$registry_ready" == 1 ]] || { - echo "npm registry did not expose the verified release artifact before timeout" >&2 - exit 1 - } - [[ "$(npm view mcporter dist-tags.latest)" == "$VERSION" ]] || { - echo "npm latest dist-tag does not point to $VERSION" >&2 - exit 1 - } + run node "$ROOT/scripts/verify-npm-publication.mjs" mcporter "$VERSION" "$local_integrity" run npm view "mcporter@$VERSION" dist.tarball dist.integrity time } diff --git a/scripts/test-release.sh b/scripts/test-release.sh index 518fec1f..f0021a73 100755 --- a/scripts/test-release.sh +++ b/scripts/test-release.sh @@ -29,6 +29,7 @@ for script in \ "$ROOT/scripts/verify-release.sh"; do bash -n "$script" done +node --check "$ROOT/scripts/verify-npm-publication.mjs" plutil -lint "$ROOT/scripts/macos-release.entitlements" >/dev/null WORK=$(mktemp -d "${TMPDIR:-/tmp}/mcporter-release-contract.XXXXXX") @@ -161,13 +162,33 @@ MOCK cat >"$MOCK_BIN/npm" <<'MOCK' #!/usr/bin/env bash set -euo pipefail -[[ "${1:-}" == install ]] -archive=${!#} -stage=$(mktemp -d "${TMPDIR:-/tmp}/mcporter-mock-install.XXXXXX") -trap 'rm -rf "$stage"' EXIT -tar -xzf "$archive" -C "$stage" -mkdir -p node_modules/mcporter -cp -R "$stage/package/." node_modules/mcporter/ +case "${1:-}" in + install) + archive=${!#} + stage=$(mktemp -d "${TMPDIR:-/tmp}/mcporter-mock-install.XXXXXX") + trap 'rm -rf "$stage"' EXIT + tar -xzf "$archive" -C "$stage" + mkdir -p node_modules/mcporter + cp -R "$stage/package/." node_modules/mcporter/ + ;; + view) + case "${3:-}" in + version) value=${MOCK_NPM_VERSION:-} ;; + dist.integrity) value=${MOCK_NPM_INTEGRITY:-} ;; + dist-tags.latest) value=${MOCK_NPM_LATEST:-} ;; + *) echo "unexpected mock npm view field: ${3:-}" >&2; exit 64 ;; + esac + if [[ -z "$value" ]]; then + echo 'npm error code E404' >&2 + exit 1 + fi + node -e 'process.stdout.write(JSON.stringify(process.argv[1]))' "$value" + ;; + *) + echo "unexpected mock npm arguments: $*" >&2 + exit 64 + ;; +esac MOCK cat >"$MOCK_BIN/codesign" <<'MOCK' @@ -249,6 +270,23 @@ MOCK chmod 755 "$MOCK_BIN"/* export PATH="$MOCK_BIN:$PATH" +# Immutable npm metadata mismatches are hard failures, not propagation retries. +mock_integrity=sha512-protected-release +MOCK_NPM_VERSION="$VERSION" \ +MOCK_NPM_INTEGRITY="$mock_integrity" \ +MOCK_NPM_LATEST="$VERSION" \ + node "$ROOT/scripts/verify-npm-publication.mjs" mcporter "$VERSION" "$mock_integrity" >/dev/null +assert_fails env \ + MOCK_NPM_VERSION="$VERSION" \ + MOCK_NPM_INTEGRITY="$mock_integrity" \ + MOCK_NPM_LATEST="$VERSION" \ + node "$ROOT/scripts/verify-npm-publication.mjs" mcporter "$VERSION" sha512-mutated +assert_fails env \ + MOCK_NPM_VERSION="$VERSION" \ + MOCK_NPM_INTEGRITY="$mock_integrity" \ + MOCK_NPM_LATEST=0.0.0 \ + node "$ROOT/scripts/verify-npm-publication.mjs" mcporter "$VERSION" "$mock_integrity" + # Ordinary builds never enter signing or notarization code. MCPORTER_OFFICIAL_RELEASE=0 "$ROOT/scripts/codesign-native.sh" "$WORK/missing" [[ ! -s "$MOCK_LOG" ]] || fail 'ordinary build invoked release tools' @@ -416,7 +454,12 @@ grep -Fq 'ACTIONS_ID_TOKEN_REQUEST_URL' "$publish_workflow" grep -Fq 'secrets.HOMEBREW_TAP_TOKEN' "$publish_workflow" grep -Fq 'workflow run update-homebrew-tap.yml' "$publish_workflow" grep -Fq 'verify-published-release-proof.mjs' "$publish_workflow" +grep -Fq 'verify-npm-publication.mjs' "$publish_workflow" grep -Fq 'validate-release-metadata.mjs' "$publish_workflow" +grep -Eq '^ verify-npm:$' "$publish_workflow" +grep -Eq '^ dispatch-homebrew:$' "$publish_workflow" +[[ "$(grep -Ec '^ needs: release$' "$publish_workflow")" == 2 ]] || fail 'npm verification and Homebrew dispatch no longer depend independently on publication' +! grep -Eq 'needs: verify-npm' "$publish_workflow" || fail 'Homebrew dispatch became downstream of npm propagation verification' ! grep -Eq 'secrets\.(NPM_TOKEN|NODE_AUTH_TOKEN)' "$publish_workflow" || fail 'automated release regained a persistent npm token' ! grep -Eq '\bspctl\b' "$ROOT/scripts/codesign-native.sh" "$ROOT/scripts/verify-release.sh" || \ @@ -438,7 +481,7 @@ grep -Eq 'verified-assets-arm64' "$homebrew_workflow" grep -Eq 'verified-assets-x86_64' "$homebrew_workflow" grep -Eq 'native proof artifacts disagree on the verified asset set' "$homebrew_workflow" grep -Eq 'published asset digest changed after native verification' "$homebrew_workflow" -grep -Eq 'npm registry integrity does not match the verified GitHub tarball' "$homebrew_workflow" +grep -Fq 'verify-npm-publication.mjs' "$homebrew_workflow" grep -Eq 'codesign-run --' "$ROOT/scripts/release.sh" grep -Eq 'command -v mac-release' "$ROOT/scripts/release.sh" grep -Eq 'MAC_RELEASE_HELPER' "$ROOT/scripts/release.sh" @@ -447,7 +490,7 @@ grep -Eq 'verified-assets-arm64' "$ROOT/scripts/release.sh" grep -Eq 'verified-assets-x86_64' "$ROOT/scripts/release.sh" grep -Eq 'native proof artifacts disagree on the verified asset set' "$ROOT/scripts/release.sh" grep -Eq 'immutable registry metadata' "$ROOT/scripts/release.sh" -grep -Eq 'registry did not expose the verified release artifact before timeout' "$ROOT/scripts/release.sh" +grep -Fq 'verify-npm-publication.mjs' "$ROOT/scripts/release.sh" ! grep -Eq '^ all|git push|git tag ' "$ROOT/scripts/release.sh" || fail 'release helper regained combined/tag/push path' grep -Eq 'pnpm clean' "$ROOT/scripts/package-release.sh" grep -Eq 'pnpm build' "$ROOT/scripts/package-release.sh" diff --git a/scripts/verify-npm-publication.mjs b/scripts/verify-npm-publication.mjs new file mode 100644 index 00000000..57898dc2 --- /dev/null +++ b/scripts/verify-npm-publication.mjs @@ -0,0 +1,154 @@ +#!/usr/bin/env node + +import { execFile as execFileCallback } from 'node:child_process'; +import { promisify } from 'node:util'; +import { pathToFileURL } from 'node:url'; + +const execFile = promisify(execFileCallback); +const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000; +const DEFAULT_INITIAL_DELAY_MS = 5 * 1000; +const DEFAULT_MAX_DELAY_MS = 30 * 1000; + +export class NpmPublicationMismatchError extends Error {} +export class NpmPublicationTimeoutError extends Error {} + +function errorText(error) { + if (!(error instanceof Error)) return String(error); + const details = [error.stderr, error.stdout, error.message] + .filter((value) => typeof value === 'string' && value.trim() !== '') + .join('\n'); + return details || error.name; +} + +function isNotFound(error) { + return /(?:\bE404\b|404 Not Found)/i.test(errorText(error)); +} + +function parseViewValue(stdout, spec, field) { + const value = stdout.trim(); + if (value === '' || value === 'null' || value === 'undefined') return null; + + let parsed; + try { + parsed = JSON.parse(value); + } catch { + parsed = value; + } + if (typeof parsed !== 'string') { + throw new Error(`npm view ${spec} ${field} returned unexpected metadata: ${value}`); + } + return parsed; +} + +async function npmView(spec, field) { + try { + const { stdout } = await execFile('npm', ['view', spec, field, '--json'], { + encoding: 'utf8', + }); + return parseViewValue(stdout, spec, field); + } catch (error) { + if (isNotFound(error)) return null; + throw new Error(`npm view ${spec} ${field} failed: ${errorText(error)}`, { cause: error }); + } +} + +function timeoutLabel(timeoutMs) { + if (timeoutMs % 60_000 === 0) return `${timeoutMs / 60_000} minutes`; + return `${Math.ceil(timeoutMs / 1000)} seconds`; +} + +export async function verifyNpmPublication({ + packageName, + version, + expectedIntegrity, + timeoutMs = DEFAULT_TIMEOUT_MS, + initialDelayMs = DEFAULT_INITIAL_DELAY_MS, + maxDelayMs = DEFAULT_MAX_DELAY_MS, + view = npmView, + sleep = (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)), + now = Date.now, + log = console.log, +}) { + if (!packageName || !version || !expectedIntegrity) { + throw new Error('package name, version, and expected integrity are required'); + } + + const exactSpec = `${packageName}@${version}`; + const deadline = now() + timeoutMs; + let delayMs = initialDelayMs; + let firstAttempt = true; + + while (true) { + if (!firstAttempt && now() >= deadline) { + throw new NpmPublicationTimeoutError( + `npm did not expose ${exactSpec} with complete verified metadata within ${timeoutLabel(timeoutMs)}.` + ); + } + firstAttempt = false; + + const registryVersion = await view(exactSpec, 'version'); + let pending = `${exactSpec} is not visible yet`; + + if (registryVersion !== null && registryVersion !== version) { + throw new NpmPublicationMismatchError( + `npm returned version ${registryVersion} for ${exactSpec}; expected ${version}.` + ); + } + + if (registryVersion === version) { + const registryIntegrity = await view(exactSpec, 'dist.integrity'); + pending = `${exactSpec} integrity is not visible yet`; + + if (registryIntegrity !== null && registryIntegrity !== expectedIntegrity) { + throw new NpmPublicationMismatchError( + `npm registry integrity mismatch for ${exactSpec}: expected ${expectedIntegrity}, received ${registryIntegrity}.` + ); + } + + if (registryIntegrity === expectedIntegrity) { + const latest = await view(packageName, 'dist-tags.latest'); + pending = `${packageName} latest dist-tag is not visible yet`; + + if (latest !== null && latest !== version) { + throw new NpmPublicationMismatchError( + `npm latest dist-tag mismatch for ${packageName}: expected ${version}, received ${latest}.` + ); + } + if (latest === version) { + return { version: registryVersion, integrity: registryIntegrity, latest }; + } + } + } + + const remainingMs = deadline - now(); + if (remainingMs <= 0) { + throw new NpmPublicationTimeoutError( + `npm did not expose ${exactSpec} with complete verified metadata within ${timeoutLabel(timeoutMs)}.` + ); + } + + const waitMs = Math.min(delayMs, remainingMs); + log(`${pending}; retrying in ${Math.ceil(waitMs / 1000)}s.`); + await sleep(waitMs); + delayMs = Math.min(maxDelayMs, delayMs * 2); + } +} + +async function main() { + const [packageName, version, expectedIntegrity] = process.argv.slice(2); + if (!packageName || !version || !expectedIntegrity) { + console.error('Usage: verify-npm-publication.mjs '); + process.exitCode = 2; + return; + } + + await verifyNpmPublication({ packageName, version, expectedIntegrity }); + console.log(`Verified immutable npm publication ${packageName}@${version}.`); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + }); +} diff --git a/tests/verify-npm-publication.test.mjs b/tests/verify-npm-publication.test.mjs new file mode 100644 index 00000000..b49f7461 --- /dev/null +++ b/tests/verify-npm-publication.test.mjs @@ -0,0 +1,111 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + NpmPublicationMismatchError, + NpmPublicationTimeoutError, + verifyNpmPublication, +} from '../scripts/verify-npm-publication.mjs'; + +const packageName = 'mcporter'; +const version = '0.13.0'; +const integrity = 'sha512-protected-release'; + +function successfulView(overrides = {}) { + return vi.fn(async (_spec, field) => { + if (field === 'version') return overrides.version ?? version; + if (field === 'dist.integrity') return overrides.integrity ?? integrity; + if (field === 'dist-tags.latest') return overrides.latest ?? version; + throw new Error(`unexpected npm view field: ${field}`); + }); +} + +describe('verifyNpmPublication', () => { + it('backs off while npm propagates incomplete metadata', async () => { + let clock = 0; + const sleep = vi.fn(async (delayMs) => { + clock += delayMs; + }); + const view = vi + .fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(version) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(version) + .mockResolvedValueOnce(integrity) + .mockResolvedValueOnce(version); + + await expect( + verifyNpmPublication({ + packageName, + version, + expectedIntegrity: integrity, + timeoutMs: 60_000, + initialDelayMs: 1_000, + maxDelayMs: 4_000, + view, + sleep, + now: () => clock, + log: vi.fn(), + }) + ).resolves.toEqual({ version, integrity, latest: version }); + + expect(sleep.mock.calls.map(([delayMs]) => delayMs)).toEqual([1_000, 2_000]); + }); + + it('fails immediately when the expected integrity is mutated', async () => { + const sleep = vi.fn(); + + await expect( + verifyNpmPublication({ + packageName, + version, + expectedIntegrity: `${integrity}-mutated`, + view: successfulView(), + sleep, + log: vi.fn(), + }) + ).rejects.toThrow(NpmPublicationMismatchError); + + expect(sleep).not.toHaveBeenCalled(); + }); + + it('fails immediately when the latest dist-tag is mutated', async () => { + const sleep = vi.fn(); + + await expect( + verifyNpmPublication({ + packageName, + version, + expectedIntegrity: integrity, + view: successfulView({ latest: '0.12.4' }), + sleep, + log: vi.fn(), + }) + ).rejects.toThrow(NpmPublicationMismatchError); + + expect(sleep).not.toHaveBeenCalled(); + }); + + it('times out when the version never propagates', async () => { + let clock = 0; + const sleep = vi.fn(async (delayMs) => { + clock += delayMs; + }); + + await expect( + verifyNpmPublication({ + packageName, + version, + expectedIntegrity: integrity, + timeoutMs: 3_000, + initialDelayMs: 1_000, + maxDelayMs: 2_000, + view: vi.fn().mockResolvedValue(null), + sleep, + now: () => clock, + log: vi.fn(), + }) + ).rejects.toThrow(NpmPublicationTimeoutError); + + expect(sleep.mock.calls.map(([delayMs]) => delayMs)).toEqual([1_000, 2_000]); + }); +});