Skip to content

Commit 93a08a3

Browse files
committed
Retry npm release verification on registry lag
1 parent b5c68ab commit 93a08a3

6 files changed

Lines changed: 180 additions & 27 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,7 @@ All notable changes to ContextForge will be documented in this file.
44

55
## Unreleased
66

7-
- No unreleased changes tracked yet.
8-
- The `0.1.0` entry below reflects the prepared release contents. GitHub Release creation and npm publish still happen only after the maintainer dispatches the release workflow successfully.
7+
- Release automation now retries npm version verification for several minutes after publish so registry propagation delays do not immediately fail the workflow.
98

109
## 0.1.0
1110

docs/maintainers/npm-publish-guide.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ If trusted publishing is not available yet:
3030

3131
The workflow will then publish using token auth instead of trusted publishing.
3232

33+
After a successful publish response, the workflow also retries npm visibility checks for a short window so normal registry propagation delays do not immediately mark the release as failed.
34+
3335
For the first live public release, keep these workflow inputs:
3436

3537
- `version = 0.1.0`

docs/maintainers/release-automation.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ When configured correctly, the workflow:
1717
5. optionally syncs GitHub repository metadata from `.github/release/repo-metadata.json`
1818
6. optionally creates the GitHub Release and uploads the tarball plus checksums
1919
7. optionally publishes the generated tarball to npm
20-
8. verifies the release state and prints a compact summary
20+
8. retries npm visibility checks for a short propagation window after publish
21+
9. verifies the release state and prints a compact summary
2122

2223
## Workflow inputs
2324

@@ -47,6 +48,12 @@ Secrets:
4748
- `NPM_TOKEN` only if npm trusted publishing is not configured
4849
- the public npm package identifier should match `package.json`, currently `@xiwuqi/contextforge`
4950

51+
## npm verification behavior
52+
53+
After `npm publish`, the workflow retries `npm view` verification for a few minutes before failing.
54+
55+
This is intentional because npm registry propagation can lag behind a successful publish response.
56+
5057
## Source of truth files
5158

5259
- `package.json` for the version

scripts/lib/release-automation.mjs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import path from 'node:path';
22

33
export const DEFAULT_REPO_METADATA_PATH = '.github/release/repo-metadata.json';
4+
export const NPM_PUBLISH_VERIFY_MAX_ATTEMPTS = 15;
5+
export const NPM_PUBLISH_VERIFY_DELAY_MS = 20_000;
46

57
export function parseRepoMetadataConfig(raw) {
68
let parsed = raw;
@@ -213,6 +215,38 @@ export function buildReleaseSuccessSummary({
213215
return `${lines.join('\n')}\n`;
214216
}
215217

218+
export function parseNpmViewVersion(stdout) {
219+
const trimmed = stdout.trim();
220+
if (trimmed.length === 0) {
221+
return null;
222+
}
223+
224+
try {
225+
const parsed = JSON.parse(trimmed);
226+
return typeof parsed === 'string' ? parsed : null;
227+
} catch {
228+
return trimmed.replace(/^"|"$/g, '');
229+
}
230+
}
231+
232+
export function isNpmViewNotFound(stdout = '', stderr = '') {
233+
const message = `${stdout}\n${stderr}`.trim().toLowerCase();
234+
return message.includes('not found') || message.includes('e404');
235+
}
236+
237+
export function classifyNpmViewVersionResult({ exitCode, stdout = '', stderr = '', expectedVersion }) {
238+
const actualVersion = exitCode === 0 ? parseNpmViewVersion(stdout) : null;
239+
const matchesExpectedVersion = actualVersion === expectedVersion;
240+
const retryable =
241+
(exitCode !== 0 && isNpmViewNotFound(stdout, stderr)) || (exitCode === 0 && !matchesExpectedVersion);
242+
243+
return {
244+
actualVersion,
245+
matchesExpectedVersion,
246+
retryable,
247+
};
248+
}
249+
216250
export function parseBooleanInput(value, defaultValue = false) {
217251
if (typeof value === 'boolean') {
218252
return value;

scripts/release-automation.mjs

Lines changed: 87 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,19 @@ import { constants as fsConstants } from 'node:fs';
66
import { fileURLToPath } from 'node:url';
77
import { parseArgs } from 'node:util';
88
import {
9+
NPM_PUBLISH_VERIFY_DELAY_MS,
10+
NPM_PUBLISH_VERIFY_MAX_ATTEMPTS,
911
DEFAULT_REPO_METADATA_PATH,
12+
classifyNpmViewVersionResult,
1013
buildGhReleaseCreateArgs,
1114
buildGhRepoEditArgs,
1215
buildGhTopicsArgs,
1316
buildNpmPublishArgs,
1417
buildReleaseSuccessSummary,
1518
ensureReleaseVersionMatchesPackage,
19+
isNpmViewNotFound,
1620
parseBooleanInput,
21+
parseNpmViewVersion,
1722
parseRepoMetadataConfig,
1823
relativeRepoPath,
1924
resolveRepoSlug,
@@ -190,14 +195,11 @@ async function runNpmPublish(options) {
190195
});
191196
await runCommand('npm', publishArgs, { cwd: repoRoot, stdio: 'inherit' });
192197

193-
const verified = await runCommand('npm', ['view', packageIdentifier, 'version', '--json'], {
194-
cwd: repoRoot,
195-
stdio: 'pipe',
198+
await waitForPublishedPackageVersion({
199+
packageIdentifier,
200+
version,
201+
reason: 'npm publish verification',
196202
});
197-
const publishedVersion = parseNpmViewVersion(verified.stdout);
198-
if (publishedVersion !== version) {
199-
throw new Error(`npm publish verification failed. Expected version "${version}" but found "${publishedVersion}".`);
200-
}
201203

202204
console.log(`npm publish verification passed for ${packageIdentifier} on tag "${npmTag}".`);
203205
}
@@ -240,14 +242,11 @@ async function runVerify(options) {
240242

241243
if (npmPublished) {
242244
const packageIdentifier = `${packageJson.name}@${version}`;
243-
const published = await runCommand('npm', ['view', packageIdentifier, 'version', '--json'], {
244-
cwd: repoRoot,
245-
stdio: 'pipe',
245+
await waitForPublishedPackageVersion({
246+
packageIdentifier,
247+
version,
248+
reason: 'post-release npm verification',
246249
});
247-
const publishedVersion = parseNpmViewVersion(published.stdout);
248-
if (publishedVersion !== version) {
249-
throw new Error(`Post-release npm verification failed. Expected "${version}" but found "${publishedVersion}".`);
250-
}
251250
}
252251

253252
const summary = buildReleaseSuccessSummary({
@@ -353,18 +352,82 @@ async function readPackageJson() {
353352
return JSON.parse(await readFile(packageJsonPath, 'utf8'));
354353
}
355354

356-
function parseNpmViewVersion(stdout) {
357-
const trimmed = stdout.trim();
358-
if (trimmed.length === 0) {
359-
return null;
360-
}
355+
async function waitForPublishedPackageVersion({ packageIdentifier, version, reason }) {
356+
let lastResult = null;
361357

362-
try {
363-
const parsed = JSON.parse(trimmed);
364-
return typeof parsed === 'string' ? parsed : null;
365-
} catch {
366-
return trimmed.replace(/^"|"$/g, '');
358+
for (let attempt = 1; attempt <= NPM_PUBLISH_VERIFY_MAX_ATTEMPTS; attempt += 1) {
359+
const result = await runOptionalCommand('npm', ['view', packageIdentifier, 'version', '--json'], {
360+
cwd: repoRoot,
361+
stdio: 'pipe',
362+
});
363+
lastResult = result;
364+
365+
const classification = classifyNpmViewVersionResult({
366+
exitCode: result.code,
367+
stdout: result.stdout,
368+
stderr: result.stderr,
369+
expectedVersion: version,
370+
});
371+
372+
if (classification.matchesExpectedVersion) {
373+
return classification.actualVersion;
374+
}
375+
376+
const isLastAttempt = attempt === NPM_PUBLISH_VERIFY_MAX_ATTEMPTS;
377+
if (!classification.retryable || isLastAttempt) {
378+
throw new Error(
379+
buildNpmVerifyFailureMessage({
380+
packageIdentifier,
381+
version,
382+
reason,
383+
result,
384+
actualVersion: classification.actualVersion,
385+
}),
386+
);
387+
}
388+
389+
const delaySeconds = Math.floor(NPM_PUBLISH_VERIFY_DELAY_MS / 1000);
390+
const notFoundHint = isNpmViewNotFound(result.stdout, result.stderr)
391+
? 'npm registry still reports the package as not found'
392+
: `npm registry returned version "${classification.actualVersion ?? 'unknown'}"`;
393+
console.log(
394+
`${reason} is waiting for npm registry propagation: ${notFoundHint} for ${packageIdentifier} (attempt ${attempt}/${NPM_PUBLISH_VERIFY_MAX_ATTEMPTS}). Retrying in ${delaySeconds}s.`,
395+
);
396+
await sleep(NPM_PUBLISH_VERIFY_DELAY_MS);
367397
}
398+
399+
throw new Error(
400+
buildNpmVerifyFailureMessage({
401+
packageIdentifier,
402+
version,
403+
reason,
404+
result: lastResult ?? { code: 1, stdout: '', stderr: '' },
405+
actualVersion: null,
406+
}),
407+
);
408+
}
409+
410+
function buildNpmVerifyFailureMessage({ packageIdentifier, version, reason, result, actualVersion }) {
411+
const detail =
412+
actualVersion !== null
413+
? `Expected version "${version}" but found "${actualVersion}".`
414+
: isNpmViewNotFound(result.stdout, result.stderr)
415+
? `npm registry still does not expose ${packageIdentifier} after ${NPM_PUBLISH_VERIFY_MAX_ATTEMPTS} attempts.`
416+
: `npm registry lookup for ${packageIdentifier} failed unexpectedly.`;
417+
418+
return [
419+
`${reason} failed for ${packageIdentifier}. ${detail}`,
420+
result.stdout.trim() ? `Stdout:\n${result.stdout.trim()}` : '',
421+
result.stderr.trim() ? `Stderr:\n${result.stderr.trim()}` : '',
422+
]
423+
.filter(Boolean)
424+
.join('\n\n');
425+
}
426+
427+
function sleep(ms) {
428+
return new Promise((resolve) => {
429+
setTimeout(resolve, ms);
430+
});
368431
}
369432

370433
function isGhNotFound(stderr) {

tests/unit/release-automation.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
import { describe, expect, it } from 'vitest';
22
import {
3+
NPM_PUBLISH_VERIFY_DELAY_MS,
4+
NPM_PUBLISH_VERIFY_MAX_ATTEMPTS,
35
buildGhReleaseCreateArgs,
46
buildGhRepoEditArgs,
57
buildGhTopicsArgs,
68
buildNpmPublishArgs,
79
buildReleaseSuccessSummary,
10+
classifyNpmViewVersionResult,
811
ensureReleaseVersionMatchesPackage,
12+
isNpmViewNotFound,
13+
parseNpmViewVersion,
914
parseRepoMetadataConfig,
1015
resolveRepoSlug,
1116
validateChangelogForVersion,
@@ -170,4 +175,47 @@ describe('release automation helpers', () => {
170175
expect(summary).toContain('Metadata sync ran: yes');
171176
expect(summary).toContain('npm publish ran: yes');
172177
});
178+
179+
it('parses npm view output and classifies propagation-delay retries', () => {
180+
expect(parseNpmViewVersion('"0.1.0"')).toBe('0.1.0');
181+
expect(
182+
classifyNpmViewVersionResult({
183+
exitCode: 1,
184+
stdout: '',
185+
stderr: 'npm ERR! 404 Not Found - GET https://registry.npmjs.org/@xiwuqi%2fcontextforge',
186+
expectedVersion: '0.1.0',
187+
}),
188+
).toEqual({
189+
actualVersion: null,
190+
matchesExpectedVersion: false,
191+
retryable: true,
192+
});
193+
expect(isNpmViewNotFound('', 'npm ERR! code E404')).toBe(true);
194+
expect(
195+
classifyNpmViewVersionResult({
196+
exitCode: 0,
197+
stdout: '"0.0.9"',
198+
stderr: '',
199+
expectedVersion: '0.1.0',
200+
}),
201+
).toEqual({
202+
actualVersion: '0.0.9',
203+
matchesExpectedVersion: false,
204+
retryable: true,
205+
});
206+
expect(
207+
classifyNpmViewVersionResult({
208+
exitCode: 0,
209+
stdout: '"0.1.0"',
210+
stderr: '',
211+
expectedVersion: '0.1.0',
212+
}),
213+
).toEqual({
214+
actualVersion: '0.1.0',
215+
matchesExpectedVersion: true,
216+
retryable: false,
217+
});
218+
expect(NPM_PUBLISH_VERIFY_MAX_ATTEMPTS).toBeGreaterThan(1);
219+
expect(NPM_PUBLISH_VERIFY_DELAY_MS).toBeGreaterThan(0);
220+
});
173221
});

0 commit comments

Comments
 (0)