From 869ea812b5a0c11a6144ca7a8a3aaea505d5c438 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Sat, 3 Oct 2026 17:58:55 +0000 Subject: [PATCH] fix(ci): verify artifact integrity after duplicate npm publication --- .github/scripts/release.mjs | 47 ++++++++++++++++++++++-- .github/scripts/release.test.mjs | 62 +++++++++++++++++++++++++++++++- .github/workflows/release.yml | 4 +-- docs/releasing.md | 13 +++++++ 4 files changed, 121 insertions(+), 5 deletions(-) diff --git a/.github/scripts/release.mjs b/.github/scripts/release.mjs index a3f43bb..96b28ab 100644 --- a/.github/scripts/release.mjs +++ b/.github/scripts/release.mjs @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; import { execFileSync } from 'node:child_process'; import { appendFileSync, readFileSync } from 'node:fs'; import { pathToFileURL } from 'node:url'; @@ -25,7 +26,7 @@ export function validateRelease(version, shas, manifest, pkg) { // Keep the outbound URL constant: manifest and workflow-input data stay local. export async function registryMetadata(fetcher = fetch) { const response = await fetcher('https://registry.npmjs.org/@configbutler%2Fkrm-stream', { - headers: { Accept: 'application/vnd.npm.install-v1+json' }, + headers: { Accept: 'application/vnd.npm.install-v1+json', 'Cache-Control': 'no-cache' }, signal: AbortSignal.timeout(30_000), }); if (response.status === 404) return null; @@ -63,6 +64,32 @@ export function validatePackage(pkg, version) { assert.equal(pkg.version, version, 'tarball version disagrees with release'); } +// A registry read may lag a successful upload. Only recover npm's explicit immutable-version +// rejection, and only when the registry confirms exactly the validated artifact's bytes. +export async function publishWithRecovery({ version, integrity, publish, fetcher = fetch, + wait = ms => new Promise(resolve => setTimeout(resolve, ms)) }) { + releaseTags(version); + assert.match(integrity, /^sha512-[A-Za-z0-9+/]+={0,2}$/); + try { + await publish(); + return 'published'; + } catch (error) { + const detail = `${error.stderr ?? ''}\n${error.stdout ?? ''}`; + if (!detail.includes(`You cannot publish over the previously published versions: ${version}.`)) throw error; + for (let attempt = 0; attempt < 5; attempt++) { + if (attempt) await wait(2_000); + const metadata = await registryMetadata(fetcher); + const existing = metadata?.versions[version]; + if (!existing) continue; + assert.equal(existing.version, version); + assert.equal(existing.dist?.integrity, integrity, + 'published version does not match the validated tarball'); + return 'already published'; + } + throw error; + } +} + const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }).trim(); const output = (key, value) => appendFileSync(process.env.GITHUB_OUTPUT, `${key}=${value}\n`); @@ -95,8 +122,24 @@ async function main() { // A retry after npm accepted the upload is successful without another publish attempt. output('needed', await needsPublish(version)); output('tarball', tarball); + } else if (process.argv[2] === 'publish') { + const tarball = `/tmp/npm/configbutler-krm-stream-${version}.tgz`; + const pkg = JSON.parse(execFileSync('tar', ['-xOf', tarball, 'package/package.json'], { encoding: 'utf8' })); + validatePackage(pkg, version); + const integrity = `sha512-${createHash('sha512').update(readFileSync(tarball)).digest('base64')}`; + const result = await publishWithRecovery({ version, integrity, publish: () => { + try { + const log = execFileSync('npm', ['publish', tarball, '--provenance', '--access', 'public'], { encoding: 'utf8' }); + process.stdout.write(log); + } catch (error) { + process.stdout.write(error.stdout ?? ''); + process.stderr.write(error.stderr ?? ''); + throw error; + } + } }); + console.log(`${packageName}@${version}: ${result}.`); } else { - throw new Error('usage: release.mjs resolve|verify'); + throw new Error('usage: release.mjs resolve|verify|publish'); } } diff --git a/.github/scripts/release.test.mjs b/.github/scripts/release.test.mjs index 0571aca..f1e9c9e 100644 --- a/.github/scripts/release.test.mjs +++ b/.github/scripts/release.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { needsPublish, packageName, registryMetadata, releaseTags, validatePackage, validateRelease } from './release.mjs'; +import { needsPublish, packageName, publishWithRecovery, registryMetadata, releaseTags, validatePackage, validateRelease } from './release.mjs'; const sha = 'a'.repeat(40); const manifest = { 'packages/krm-stream': '0.3.0', gateway: '0.3.0', 'gateway/kube': '0.3.0' }; @@ -52,3 +52,63 @@ test('registry requests never include manifest or workflow-input data', async () assert.deepEqual(requests[0], requests[1]); assert.equal(requests[0].url, 'https://registry.npmjs.org/@configbutler%2Fkrm-stream'); }); + +const integrity = `sha512-${Buffer.alloc(64, 1).toString('base64')}`; +const duplicate = () => Object.assign(new Error('npm failed'), { + stderr: 'npm error 403 You cannot publish over the previously published versions: 0.3.0.', +}); +const publishedMetadata = (value = integrity) => ({ ...metadata('0.3.0'), + versions: { '0.3.0': { version: '0.3.0', dist: { integrity: value } } } }); + +test('a successful publish does not require another registry read', async () => { + assert.equal(await publishWithRecovery({ version: '0.3.0', integrity, + publish: async () => {}, fetcher: async () => assert.fail('unexpected registry read') }), 'published'); +}); + +test('duplicate upload recovers only after matching metadata becomes visible, without republishing', async () => { + let publishes = 0; + let reads = 0; + const waits = []; + const result = await publishWithRecovery({ version: '0.3.0', integrity, + publish: async () => { publishes++; throw duplicate(); }, + fetcher: async () => response(200, ++reads === 1 ? metadata('0.2.1') : publishedMetadata()), + wait: async ms => waits.push(ms) }); + assert.equal(result, 'already published'); + assert.equal(publishes, 1); + assert.equal(reads, 2); + assert.deepEqual(waits, [2_000]); +}); + +test('duplicate publication never accepts different or missing integrity', async () => { + for (const value of ['sha512-different', undefined]) { + await assert.rejects(publishWithRecovery({ version: '0.3.0', integrity, + publish: async () => { throw duplicate(); }, + fetcher: async () => response(200, publishedMetadata(value === undefined ? null : value)) }), + /does not match/); + } +}); + +test('authentication and other publication errors remain failures', async () => { + for (const stderr of ['npm error E403 access denied', 'npm error ECONNRESET', + 'You cannot publish over the previously published versions: 0.2.1.']) { + const error = Object.assign(new Error('publish failed'), { stderr }); + await assert.rejects(publishWithRecovery({ version: '0.3.0', integrity, + publish: async () => { throw error; }, fetcher: async () => assert.fail('unexpected registry read') }), + caught => caught === error); + } +}); + +test('unconfirmed duplicate publication has bounded reads and remains a failure', async () => { + let reads = 0; + const error = duplicate(); + await assert.rejects(publishWithRecovery({ version: '0.3.0', integrity, + publish: async () => { throw error; }, + fetcher: async () => { reads++; return response(404); }, wait: async () => {} }), + caught => caught === error); + assert.equal(reads, 5); +}); + +test('registry outage during duplicate confirmation remains a failure', async () => { + await assert.rejects(publishWithRecovery({ version: '0.3.0', integrity, + publish: async () => { throw duplicate(); }, fetcher: async () => response(503) }), /HTTP 503/); +}); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b00fb29..2bcdc0e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -107,5 +107,5 @@ jobs: - name: publish the official package if: steps.artifact.outputs.needed == 'true' env: - TARBALL: ${{ steps.artifact.outputs.tarball }} - run: npm publish "$TARBALL" --provenance --access public + VERSION: ${{ needs.prepare.outputs.version }} + run: node .github/scripts/release.mjs publish diff --git a/docs/releasing.md b/docs/releasing.md index 1014a2f..23235a7 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -1,5 +1,18 @@ # Releasing +If npm rejects an upload because the version already exists, the release job checks registry +metadata again for a bounded period. It succeeds only when that version's SHA-512 integrity matches +the validated tarball. Other publish failures, unavailable metadata and mismatched artifacts still +fail. Registry reads request cache revalidation. + +The 0.6.0 release demonstrated two distinct delays/failures: its first release attempt failed when +the Go checksum database returned `unknown revision gateway/v0.6.0` shortly after tagging. A rerun +later passed and published npm 0.6.0. A subsequent push attempted the same npm version and was +rejected as a duplicate. Do not bypass Go checksum verification or bump the version to recover these +cases; verify the public release state first. See the +[release run, including its attempts](https://github.com/ConfigButler/krm-stream/actions/runs/37061239061) +and the [duplicate upload failure](https://github.com/ConfigButler/krm-stream/actions/runs/37065350963). + Releases are generated from conventional commits on `main`. Release Please opens a release pull request with version bumps and changelog entries; merging that pull request creates the release. Nothing in it needs editing by hand.