diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 64809b2..e876d10 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -662,7 +662,9 @@ jobs: '--registry=https://registry.npmjs.org'], { encoding: 'utf8' }); if (existing.status === 0) { const integrity = 'sha512-' + createHash('sha512').update(readFileSync(archive)).digest('base64'); - assert.equal(JSON.parse(existing.stdout), integrity, `${spec} already exists with different contents`); + const published = JSON.parse(existing.stdout); + assert.deepEqual(Array.isArray(published) ? published : [published], [integrity], + `${spec} already exists with different contents`); return true; } let error; @@ -682,7 +684,7 @@ jobs: try { code = JSON.parse(result.stdout).error?.code; } catch {} // A concurrent publish may fail in npm's version check or during the PUT. let matched = false; - for (const delay of code === 'E409' ? [0, 2000, 4000, 8000, 16000] : [0]) { + for (const delay of code === 'E409' ? [0, 2000, 4000, 8000, 16000, 30000, 60000, 60000, 60000, 60000] : [0]) { if (delay) await new Promise(resolve => setTimeout(resolve, delay)); if (isPublished(spec, archive)) { matched = true; diff --git a/package.json b/package.json index 5cda3c0..801cfb8 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,8 @@ "fmt": "prettier --write \"./**/*.{scss,ts}\" && prettier --parser angular --write \"./**/*.html\"", "release": "np --no-tests --no-publish", "prepublishOnly": "npm run build", - "test:esm": "rdlabo-check-esm && node --test scripts/presets.test.mjs", + "test:esm": "rdlabo-check-esm && node --test scripts/presets.test.mjs && npm run test:release", + "test:release": "node --test scripts/release.test.mjs", "test:presets": "npm run build && node --test scripts/presets.test.mjs", "test:dynamic-colors": "npm --prefix demo run test:dynamic-colors" }, diff --git a/scripts/release.test.mjs b/scripts/release.test.mjs new file mode 100644 index 0000000..afa672f --- /dev/null +++ b/scripts/release.test.mjs @@ -0,0 +1,138 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +const workflow = readFileSync(new URL('../.github/workflows/release.yml', import.meta.url), 'utf8'); +const match = workflow.match(/const isPublished = \(spec, archive\) => \{([\s\S]*?)\n \};/); +assert(match, 'Expected the release workflow to define isPublished'); +const archive = Buffer.from('immutable npm candidate archive'); +const integrity = 'sha512-' + createHash('sha512').update(archive).digest('base64'); +const publishCode = workflow.slice( + workflow.indexOf('const isPublished ='), + workflow.indexOf('\n JS', workflow.indexOf('const isPublished =')), +); + +const checkPublished = (published) => { + const isPublished = new Function('spawnSync', 'assert', 'createHash', 'readFileSync', `return (spec, archive) => {${match[1]}\n};`)( + (command, args) => { + assert.equal(command, 'npm'); + assert.equal(args[0], 'view'); + return { status: 0, stdout: JSON.stringify(published) }; + }, + assert, + createHash, + () => archive, + ); + return isPublished('@rdlabo/ionic-theme-md3@1.0.0-beta.test', 'candidate.tgz'); +}; + +test('existing npm versions accept matching scalar and singleton-array integrity responses', () => { + assert.equal(checkPublished(integrity), true); + assert.equal(checkPublished([integrity]), true); +}); + +test('existing npm versions reject mismatched, empty, or ambiguous integrity responses', () => { + for (const published of ['sha512-other', ['sha512-other'], [], [integrity, integrity], [integrity, 'sha512-other'], null, {}]) { + assert.throws(() => checkPublished(published), { code: 'ERR_ASSERTION' }); + } +}); + +const runWorkflow = (spawnSync, setTimeout) => { + const AsyncFunction = Object.getPrototypeOf(async () => {}).constructor; + const run = new AsyncFunction( + 'spawnSync', + 'assert', + 'createHash', + 'readFileSync', + 'archives', + 'EXPECTED_VERSION', + 'DIST_TAG', + 'console', + 'process', + 'setTimeout', + publishCode, + ); + return run( + spawnSync, + assert, + createHash, + () => archive, + new Map([['@rdlabo/ionic-theme-md3', 'candidate.tgz']]), + '1.0.0-beta.test', + 'beta', + { log() {} }, + { stdout: { write() {} }, stderr: { write() {} } }, + setTimeout, + ); +}; + +test('the workflow skips npm publish for matching scalar and singleton-array responses', async () => { + for (const published of [integrity, [integrity]]) { + const commands = []; + await runWorkflow((command, args) => { + commands.push(args[0]); + assert.equal(command, 'npm'); + assert.equal(args[0], 'view', 'A matching published archive must skip npm publish'); + return { status: 0, stdout: JSON.stringify(published) }; + }); + assert.deepEqual(commands, ['view']); + } +}); + +const notVisible = { status: 1, stdout: JSON.stringify({ error: { code: 'E404' } }), stderr: '' }; + +const simulateConflict = async (resolveView, code = 'E409') => { + let views = 0; + let publishes = 0; + const delays = []; + const result = runWorkflow( + (command, args) => { + assert.equal(command, 'npm'); + if (args[0] === 'view') return resolveView(++views); + assert.equal(args[0], 'publish'); + publishes++; + return { status: 1, stdout: JSON.stringify({ error: { code } }), stderr: '' }; + }, + (resolve, delay) => { + delays.push(delay); + resolve(); + }, + ); + return { result, delays, views: () => views, publishes: () => publishes }; +}; + +test('E409 checks beyond the old reflection window and accepts a matching array without republishing', async () => { + const run = await simulateConflict((view) => (view <= 7 ? notVisible : { status: 0, stdout: JSON.stringify([integrity]) })); + await run.result; + assert.equal(run.publishes(), 1); + assert.equal(run.views(), 8); + assert.deepEqual(run.delays, [2000, 4000, 8000, 16000, 30000, 60000]); +}); + +test('E409 stops after a bounded five-minute reflection budget', async () => { + const run = await simulateConflict(() => notVisible); + await assert.rejects(run.result, /no matching public version became visible/); + assert.equal(run.publishes(), 1); + assert.equal(run.views(), 11); + assert.equal( + run.delays.reduce((sum, delay) => sum + delay, 0), + 300000, + ); +}); + +test('other publish errors receive no extended reflection polling', async () => { + const run = await simulateConflict(() => notVisible, 'E403'); + await assert.rejects(run.result, /no matching public version became visible/); + assert.equal(run.publishes(), 1); + assert.equal(run.views(), 2); + assert.deepEqual(run.delays, []); +}); + +test('E409 fails immediately when a reflected version has a different integrity', async () => { + const run = await simulateConflict((view) => (view === 1 ? notVisible : { status: 0, stdout: JSON.stringify(['sha512-other']) })); + await assert.rejects(run.result, /already exists with different contents/); + assert.equal(run.publishes(), 1); + assert.equal(run.views(), 2); + assert.deepEqual(run.delays, []); +});