From 01be6a5fb0449f184fc3ecd759c45ee8e73c76eb Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 13 Nov 2026 11:06:07 +0100 Subject: [PATCH] ci: wait out npm's processing delay when publishing npm hides a new version for several minutes while it processes it. The publish step gave up when a registry request failed during that wait, and a retry then failed with E409 because npm refuses to publish the hidden version again. The step now publishes every package before verifying them, retries registry errors for up to 20 minutes, and accepts the E409 for a version that verification then confirms comes from the release commit. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 3 ++- docs/releases/README.md | 2 +- scripts/publish-release.test.ts | 29 +++++++++++++++++++++++++++++ scripts/publish-release.ts | 20 ++++++++++++++++---- scripts/release-registry.ts | 20 +++++++++++++++----- scripts/release.test.ts | 22 ++++++++++++++++++++++ 6 files changed, 85 insertions(+), 11 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8dcaf810..870db485 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -195,7 +195,8 @@ jobs: needs: [preflight, merge, release-please] if: ${{ always() && needs.preflight.result == 'success' && (needs.merge.result == 'success' || needs.release-please.outputs.tag != '' || inputs.release_tag != '') }} runs-on: ubuntu-24.04 - timeout-minutes: 30 + # Builds and checks take about ten minutes, and npm can hide new versions for up to 20. + timeout-minutes: 45 permissions: contents: write id-token: write diff --git a/docs/releases/README.md b/docs/releases/README.md index 228db758..f3a7e844 100644 --- a/docs/releases/README.md +++ b/docs/releases/README.md @@ -38,6 +38,6 @@ During migration, the existing `RELEASE_IT_GITHUB_TOKEN` secret is accepted as a Use GitHub's **Re-run failed jobs** to continue the same release after fixing a transient failure or completing required reviews. If a tag and draft already exist, start the Release workflow on the same branch with **release_tag** set to `vX.Y.Z`. This skips the version bump and release PR. -Publication retries skip a package only when the existing npm version records the same Git commit. The workflow refuses a version published from another commit. An APK failure leaves the GitHub release as a draft; retrying attaches the APK and publishes the assembled notes without another version bump. +Publication retries skip a package only when the existing npm version records the same Git commit. The workflow refuses a version published from another commit. npm hides a new version for several minutes while it processes it, so the workflow waits up to 20 minutes for every published version to appear. A retry during that time still succeeds when the hidden version comes from the same commit. An APK failure leaves the GitHub release as a draft; retrying attaches the APK and publishes the assembled notes without another version bump. The migration must be merged or cherry-picked onto an older maintenance branch before running this workflow there. The workflow selects its baseline at runtime, so no branch-specific Release Please configuration is needed. diff --git a/scripts/publish-release.test.ts b/scripts/publish-release.test.ts index 363dde93..bc0ebeb9 100644 --- a/scripts/publish-release.test.ts +++ b/scripts/publish-release.test.ts @@ -72,6 +72,29 @@ test('retry skips a package already published from the selected commit', () => { assert.match(published[1]!, /^npm:react-native-nitro-sqlite-drizzle:/) }) +test('a retry continues when npm still processes a version from the same commit', () => { + const result = runPublication({ PUBLISH_TEST_CORE_STAGED: '1' }) + assert.equal(result.status, 0, result.stderr) + assert.match(result.stderr, /previously staged version/) + assert.deepEqual( + result.calls + .filter((line) => line.startsWith('npm:')) + .map((line) => line.split(':')[1]), + [ + 'react-native-nitro-sqlite', + 'react-native-nitro-sqlite-vec', + 'react-native-nitro-sqlite-drizzle', + ], + ) +}) + +test('other npm publish failures stop before the next package', () => { + const result = runPublication({ PUBLISH_TEST_CORE_FAILS: '1' }) + assert.notEqual(result.status, 0) + assert.match(result.stderr, /react-native-nitro-sqlite: npm publish failed/) + assert.equal(result.calls.filter((line) => line.startsWith('npm:')).length, 1) +}) + function runPublication(extraEnv: Record = {}) { const directory = mkdtempSync(join(tmpdir(), 'nitro-publish-test-')) const project = join(directory, 'project') @@ -122,6 +145,12 @@ function runPublication(extraEnv: Record = {}) { 'if [ "' + command + '" = "bun" ] && [ "$(basename "$PWD")" = "react-native-nitro-sqlite-vec" ] && [ "$*" = "run typecheck" ] && [ "$PUBLISH_TEST_FAIL_VALIDATION" = "1" ]; then exit 1; fi', + 'if [ "' + + command + + '" = "npm" ] && [ "$(basename "$PWD")" = "react-native-nitro-sqlite" ] && [ -n "$PUBLISH_TEST_CORE_STAGED" ]; then echo "npm error 409 Conflict - Cannot publish over previously staged version \\"1.0.0\\"." >&2; exit 1; fi', + 'if [ "' + + command + + '" = "npm" ] && [ "$(basename "$PWD")" = "react-native-nitro-sqlite" ] && [ -n "$PUBLISH_TEST_CORE_FAILS" ]; then echo "npm error 403 Forbidden" >&2; exit 1; fi', ].join('\n'), { mode: 0o755 }, ) diff --git a/scripts/publish-release.ts b/scripts/publish-release.ts index 1558b9cf..8f5ce02e 100644 --- a/scripts/publish-release.ts +++ b/scripts/publish-release.ts @@ -1,4 +1,4 @@ -import { execFileSync } from 'node:child_process' +import { execFileSync, spawnSync } from 'node:child_process' import { resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { git, validateReleaseVersions } from './release-plan.ts' @@ -13,6 +13,7 @@ import { /** Build and validate both packages before publishing either package from the selected tag. * Checks internal versions, lockfiles, source cleanliness, existing publications and both trusted publishers. * Retries skip packages whose registry metadata records the same release commit. + * A version npm still processes from an earlier attempt counts as published once verification confirms its commit. * @param version Exact version already committed and tagged by Release Please. * @throws Before publication on failed validation, or after publication if registry verification fails. */ @@ -40,7 +41,7 @@ export async function publishRelease(version: string): Promise { const tag = await distributionTag(version) for (const name of pending) { // npm's CLI exchanges GitHub OIDC credentials and records provenance without a publishing token. - execFileSync( + const result = spawnSync( 'npm', [ 'publish', @@ -51,10 +52,21 @@ export async function publishRelease(version: string): Promise { tag, '--workspaces=false', ], - { cwd: `packages/${name}`, stdio: 'inherit' }, + { + cwd: `packages/${name}`, + stdio: ['inherit', 'inherit', 'pipe'], + encoding: 'utf8', + }, ) - await verifyPublication(name, version, commit) + const stderr = result.stderr ?? '' + process.stderr.write(stderr) + // npm hides a new version for several minutes and rejects a second publish of it until then. + const stagedByEarlierAttempt = /previously staged version/.test(stderr) + if (result.status !== 0 && !stagedByEarlierAttempt) + throw new Error(`${name}: npm publish failed.`, { cause: result.error }) } + // Verify after publishing every package, so npm's processing delays overlap. + for (const name of pending) await verifyPublication(name, version, commit) } if ( diff --git a/scripts/release-registry.ts b/scripts/release-registry.ts index dd145300..ec24db2f 100644 --- a/scripts/release-registry.ts +++ b/scripts/release-registry.ts @@ -118,23 +118,33 @@ export async function checkTrustedPublishers( } /** Confirm the published version records the selected commit, allowing registry propagation. - * @throws After five minutes if the version is missing or its commit differs. + * Registry and connection errors while waiting are retried until the deadline. + * @throws After 20 minutes if the version is missing or unreadable, or at once if its commit differs. */ export async function verifyPublication( name: string, version: string, commit: string, + pollMs = 5_000, ): Promise { - // npm can take several minutes to serve a new version. 10.3.0 took more than one. - for (let attempt = 0; attempt < 60; attempt++) { - const metadata = await registry(`${name}/${version}`, true) + // npm hides a new version while it processes it. 10.3.0 took over a minute and 11.0.0-nitro-v1.0 about four. + const deadline = Date.now() + 20 * 60_000 + let lastError: unknown + while (Date.now() < deadline) { + let metadata: Record | undefined + try { + metadata = await registry(`${name}/${version}`, true) + } catch (error) { + lastError = error + } if (metadata?.gitHead === commit) return if (metadata) throw new Error(`${name}: published commit differs from the release tag.`) - await new Promise((resolve) => setTimeout(resolve, 5000)) + await new Promise((resolve) => setTimeout(resolve, pollMs)) } throw new Error( `${name}: published version could not be verified. Resume with release_tag.`, + { cause: lastError }, ) } diff --git a/scripts/release.test.ts b/scripts/release.test.ts index f688b380..2bbe9cd6 100644 --- a/scripts/release.test.ts +++ b/scripts/release.test.ts @@ -393,6 +393,28 @@ test('Release Please replaces a whole hyphenated prerelease version', () => { ) }) +test('verification keeps polling through connection errors until npm shows the version', async () => { + let requests = 0 + const stub = mock.method(globalThis, 'fetch', async () => { + requests++ + if (requests === 1) throw new TypeError('fetch failed') + if (requests === 2) return new Response('', { status: 404 }) + if (requests === 3) return new Response('', { status: 503 }) + return Response.json({ gitHead: 'release-commit' }) + }) + try { + await verifyPublication( + 'react-native-nitro-sqlite', + '11.0.0-nitro-v1.0', + 'release-commit', + 1, + ) + assert.equal(requests, 4) + } finally { + stub.mock.restore() + } +}) + function fixture(run: (directory: string) => void): void { const directory = mkdtempSync(join(tmpdir(), 'nitro-release-plan-')) const original = process.cwd()