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
2 changes: 2 additions & 0 deletions docs/releases.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ The job updates the version only in its temporary build checkout. Both npm manif

Releases run serially. A job whose source is no longer current `master` stops before publication. Re-running the current commit discovers any package already published for that SHA, rebuilds and compares its integrity, then publishes only the missing package. A conflicting source, changed bytes, multiple versions for one source, or a newer published stable version stops the retry. The workflow never overwrites immutable npm versions or silently moves `latest` backward.

After an upload, verification waits up to three minutes for npm to expose the version and its distribution tag. Registry reads retry temporary connection failures and use fresh connections after the build. Package identity, source, archive integrity, alias dependency and channel checks still have to pass before the release succeeds.

The workflow retains both archives and `release.json` under the `cli-release-<sha>-<attempt>` GitHub artifact for 90 days, including evidence available after a failure. The receipt has source SHA, workflow run ID, version, channel, integrity digests and a `verified` result. A successful job requires verification of both packages. The run summary links to both exact npm versions.

## One-time access setup
Expand Down
21 changes: 2 additions & 19 deletions scripts/release-auto.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import { execFileSync } from 'node:child_process';
import { appendFileSync, readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { setTimeout } from 'node:timers/promises';
import { planRelease, releasePackages, verifyPublishedPackage } from './release-plan.mjs';
import { registry, waitForPublishedPackage } from './release-registry.mjs';

const root = fileURLToPath(new URL('..', import.meta.url));
const [mode, ...extra] = process.argv.slice(2);
Expand All @@ -25,16 +25,6 @@ if (mode === 'publish') {
assertCurrentMaster();
}

async function registry(name) {
const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(name)}`, {
headers: { accept: 'application/json', 'cache-control': 'no-cache' },
redirect: 'error', signal: AbortSignal.timeout(30_000),
});
assert(response.ok, `Cannot read ${name} registry metadata (${response.status}). No release decision was made.`);
const metadata = await response.json();
assert(metadata.name === name && metadata.versions && typeof metadata.versions === 'object', `Invalid registry metadata for ${name}.`);
return metadata;
}
const registries = Object.fromEntries(await Promise.all(releasePackages.map(async name => [name, await registry(name)])));
const manifest = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8'));
const plan = planRelease(manifest.version, sourceSha, registries);
Expand Down Expand Up @@ -67,14 +57,7 @@ try {
execute('npm', ['publish', resolve(output, artifact.filename), '--ignore-scripts', '--access', 'public', '--registry', 'https://registry.npmjs.org', '--tag', plan.tag, ...(mode === 'dry-run' ? ['--dry-run'] : [])]);
}
if (mode === 'publish') {
// Public registry replicas may briefly lag the successful publish response.
let metadata;
for (let attempt = 0; attempt < 6; attempt++) {
metadata = await registry(artifact.name);
if (metadata.versions?.[plan.version] && metadata['dist-tags']?.[plan.tag] === plan.version) break;
if (attempt < 5) await setTimeout(2_000);
}
verifyPublishedPackage(artifact, metadata, plan, { requireTag: true });
await waitForPublishedPackage(artifact, plan);
}
}
if (mode === 'publish') {
Expand Down
45 changes: 45 additions & 0 deletions scripts/release-registry.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import assert from 'node:assert/strict';
import { verifyPublishedPackage } from './release-plan.mjs';

const pause = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds));

export async function registry(name, deadline = Date.now() + 90_000) {
let failure;
for (let attempt = 0; attempt < 3; attempt++) {
const remaining = deadline - Date.now();
if (remaining <= 0) break;
try {
const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(name)}`, {
// A synchronous build can outlive an idle registry connection.
headers: { accept: 'application/json', 'cache-control': 'no-cache', connection: 'close' },
redirect: 'error', signal: AbortSignal.timeout(Math.min(30_000, remaining)),
});
assert(response.ok, `Cannot read ${name} registry metadata (${response.status}). No release decision was made.`);
const metadata = await response.json();
assert(metadata?.name === name && metadata.versions && typeof metadata.versions === 'object', `Invalid registry metadata for ${name}.`);
return metadata;
} catch (error) {
if (!(error instanceof TypeError || error?.name === 'TimeoutError') || attempt === 2) throw error;
failure = error;
const delay = Math.min(1_000 * (attempt + 1), deadline - Date.now());
if (delay > 0) await pause(delay);
}
}
throw failure ?? new Error(`Timed out reading ${name} registry metadata. No release decision was made.`);
}

export async function waitForPublishedPackage(artifact, plan) {
const deadline = Date.now() + 180_000;
let metadata;
while (Date.now() < deadline) {
metadata = await registry(artifact.name, deadline);
if (metadata.versions?.[plan.version]) {
verifyPublishedPackage(artifact, metadata, plan);
if (metadata['dist-tags']?.[plan.tag] === plan.version) return metadata;
}
const remaining = deadline - Date.now();
if (remaining > 0) await pause(Math.min(2_000, remaining));
}
verifyPublishedPackage(artifact, metadata ?? {}, plan, { requireTag: true });
return metadata;
}
42 changes: 41 additions & 1 deletion tests/release.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { planRelease, verifyPublishedPackage } from '../scripts/release-plan.mjs';
import { registry as readRegistry, waitForPublishedPackage } from '../scripts/release-registry.mjs';

const sourceSha = 'a'.repeat(40);
const otherSha = 'b'.repeat(40);
Expand All @@ -8,6 +9,7 @@ const published = (sha = otherSha, integrity = 'sha512-reviewed') => ({
dist: { integrity },
dependencies: { '@dealmachine/cli': '0.4.1' },
});

const registry = (versions = {}, latest = '0.3.0') => ({ versions, 'dist-tags': { latest } });
const registries = (implementation = registry(), alias = registry()) => ({ '@dealmachine/cli': implementation, dealmachine: alias });

Expand Down Expand Up @@ -56,3 +58,41 @@ describe('automatic CLI release contract', () => {
)).toThrow(error);
});
});

describe('npm registry recovery', () => {
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});

const response = (available = true) => new Response(JSON.stringify({
name: '@dealmachine/cli',
...registry(available ? { '0.4.1': published(sourceSha) } : {}, available ? '0.4.1' : '0.3.0'),
}), { headers: { 'content-type': 'application/json' } });

it('finishes reading a published version after the registry connection drops', async () => {
vi.useFakeTimers();
vi.stubGlobal('fetch', vi.fn()
.mockRejectedValueOnce(new TypeError('fetch failed', { cause: Object.assign(new Error('read ECONNRESET'), { code: 'ECONNRESET' }) }))
.mockImplementation(async () => response()));
const pending = readRegistry('@dealmachine/cli').then(value => ({ value, error: null }), error => ({ value: null, error }));
await vi.advanceTimersByTimeAsync(180_000);
const result = await pending;
expect(result.error).toBeNull();
expect(result.value?.versions['0.4.1'].dealmachineRelease.sourceSha).toBe(sourceSha);
});

it('verifies a release when npm processing takes longer than ten seconds', async () => {
vi.useFakeTimers();
vi.setSystemTime(0);
vi.stubGlobal('fetch', async () => response(Date.now() >= 12_000));
const pending = waitForPublishedPackage(
{ name: '@dealmachine/cli', integrity: 'sha512-reviewed' },
{ version: '0.4.1', tag: 'latest', sourceSha },
).then(value => ({ value, error: null }), error => ({ value: null, error }));
await vi.advanceTimersByTimeAsync(180_000);
const result = await pending;
expect(result.error).toBeNull();
expect(result.value?.['dist-tags'].latest).toBe('0.4.1');
});
});
Loading