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()