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
43 changes: 36 additions & 7 deletions scripts/release.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { readFile, writeFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { setTimeout as wait } from 'node:timers/promises';
import { fileURLToPath } from 'node:url';

const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
Expand Down Expand Up @@ -119,6 +120,38 @@ function publishArgs(version) {
];
}

function requirePublishedVersion(deps, name, expectedVersion) {
const publishedVersion = deps.verifyPublished(name, expectedVersion);
if (publishedVersion !== expectedVersion) {
throw new Error(
`npm returned ${publishedVersion || 'no version'} after publishing ${expectedVersion}.`,
);
}
}

function errorMessage(error) {
return error instanceof Error ? error.message : String(error);
}

async function verifyPublishedRelease(deps, name, version) {
const verificationDelays = [0, 1_000, 2_000, 4_000];
let lastError;
for (const delay of verificationDelays) {
if (delay > 0) await deps.wait(delay);
try {
requirePublishedVersion(deps, name, version);
return;
} catch (error) {
lastError = error;
}
}

throw new Error(
`${name}@${version} was published, but post-publish verification failed: ${errorMessage(lastError)}`,
{ cause: lastError },
);
}

function registryVersionExists(name, version) {
const result = runNpm(
['view', `${name}@${version}`, 'version', '--json', '--registry', registry],
Expand Down Expand Up @@ -172,6 +205,7 @@ const defaults = {
);
return result.stdout.replace(/^"|"$/g, '');
},
wait,
writeReceipt: (receipt) =>
writeFile(
resolve(root, verificationReceipt),
Expand Down Expand Up @@ -251,12 +285,7 @@ export async function publishRelease(expectedVersion, overrides = {}) {
}

deps.publish(publishArgs(pkg.version));
const publishedVersion = deps.verifyPublished(pkg.name, pkg.version);
if (publishedVersion !== pkg.version) {
throw new Error(
`npm returned ${publishedVersion || 'no version'} after publishing ${pkg.version}.`,
);
}
await verifyPublishedRelease(deps, pkg.name, pkg.version);
return { account, package: pkg.name, version: pkg.version };
}

Expand Down Expand Up @@ -289,7 +318,7 @@ if (resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
try {
await main(process.argv.slice(2));
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
console.error(errorMessage(error));
process.exitCode = 2;
}
}
67 changes: 67 additions & 0 deletions tests/release.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,73 @@ test('publishes a verified stable release under the default tag', async () => {
});
});

test('retries a transient post-publish lookup without publishing again', async () => {
const calls = [];
let verificationAttempt = 0;
const result = await publishRelease('1.2.3', {
publish: () => calls.push('publish'),
readPackage: async () => ({ name: 'diffsplain', version: '1.2.3' }),
readReceipt: async () => receipt,
readState: () => cleanState,
registryVersionExists: () => false,
requireNpmLogin: () => 'jling',
sha256: async () => 'verified-hash',
verifyPublished: () => {
calls.push('verify');
verificationAttempt += 1;
if (verificationAttempt === 1) {
throw new Error('npm registry lookup failed');
}
return '1.2.3';
},
wait: async (milliseconds) => calls.push(['wait', milliseconds]),
});

assert.deepEqual(calls, [
'publish',
'verify',
['wait', 1_000],
'verify',
]);
assert.deepEqual(result, {
account: 'jling',
package: 'diffsplain',
version: '1.2.3',
});
});

test('stops after bounded post-publish verification attempts', async () => {
const calls = [];
await assert.rejects(
publishRelease('1.2.3', {
publish: () => calls.push('publish'),
readPackage: async () => ({ name: 'diffsplain', version: '1.2.3' }),
readReceipt: async () => receipt,
readState: () => cleanState,
registryVersionExists: () => false,
requireNpmLogin: () => 'jling',
sha256: async () => 'verified-hash',
verifyPublished: () => {
calls.push('verify');
throw new Error('npm registry lookup failed');
},
wait: async (milliseconds) => calls.push(['wait', milliseconds]),
}),
/diffsplain@1\.2\.3 was published, but post-publish verification failed: npm registry lookup failed/,
);

assert.deepEqual(calls, [
'publish',
'verify',
['wait', 1_000],
'verify',
['wait', 2_000],
'verify',
['wait', 4_000],
'verify',
]);
});

test('publishes a verified prerelease under the next tag', async () => {
const calls = [];
const prerelease = {
Expand Down
Loading