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
3 changes: 2 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/releases/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
29 changes: 29 additions & 0 deletions scripts/publish-release.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {}) {
const directory = mkdtempSync(join(tmpdir(), 'nitro-publish-test-'))
const project = join(directory, 'project')
Expand Down Expand Up @@ -122,6 +145,12 @@ function runPublication(extraEnv: Record<string, string> = {}) {
'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 },
)
Expand Down
20 changes: 16 additions & 4 deletions scripts/publish-release.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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.
*/
Expand Down Expand Up @@ -40,7 +41,7 @@ export async function publishRelease(version: string): Promise<void> {
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',
Expand All @@ -51,10 +52,21 @@ export async function publishRelease(version: string): Promise<void> {
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 (
Expand Down
20 changes: 15 additions & 5 deletions scripts/release-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
// 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<string, unknown> | 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 },
)
}

Expand Down
22 changes: 22 additions & 0 deletions scripts/release.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading