Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 45 additions & 2 deletions .github/scripts/release.mjs
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -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`);

Expand Down Expand Up @@ -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');
}
}

Expand Down
62 changes: 61 additions & 1 deletion .github/scripts/release.test.mjs
Original file line number Diff line number Diff line change
@@ -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' };
Expand Down Expand Up @@ -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/);
});
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
13 changes: 13 additions & 0 deletions docs/releasing.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
Loading