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
10 changes: 6 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ on:
workflow_dispatch:
inputs:
version:
description: "patch, minor, major, or an exact stable version"
description: "patch, minor, major, prerelease, or an exact version such as 11.0.0 or 11.0.0-beta.0"
required: true
default: "patch"
type: string
Expand Down Expand Up @@ -202,15 +202,16 @@ jobs:
outputs:
tag: ${{ steps.validate.outputs.tag }}
latest: ${{ steps.validate.outputs.latest }}
prerelease: ${{ steps.validate.outputs.prerelease }}
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ needs.merge.outputs.tag || needs.release-please.outputs.tag || inputs.release_tag }}
NPM_CONFIG_PROVENANCE: "true"
steps:
- name: Validate tag input
run: |
if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "An existing stable release tag is required." >&2
if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
echo "An existing release tag is required." >&2
exit 1
fi
- uses: actions/checkout@v7
Expand Down Expand Up @@ -272,5 +273,6 @@ jobs:
GH_REPO: ${{ github.repository }}
RELEASE_TAG: ${{ needs.publish.outputs.tag }}
RELEASE_LATEST: ${{ needs.publish.outputs.latest }}
RELEASE_PRERELEASE: ${{ needs.publish.outputs.prerelease }}
run: |
gh release edit "$RELEASE_TAG" --draft=false --latest="$RELEASE_LATEST" --notes-file release-notes.md
gh release edit "$RELEASE_TAG" --draft=false --latest="$RELEASE_LATEST" --prerelease="$RELEASE_PRERELEASE" --notes-file release-notes.md
8 changes: 7 additions & 1 deletion docs/releases/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ To choose the version yourself, release a maintenance branch, or release without

Open the **Release** workflow in GitHub Actions, select **Run workflow**, and choose the branch containing the changes to release. The same flow supports main and any maintenance or release branch that contains the release automation.

Set **version** to `patch`, `minor`, `major`, or an exact stable version. The workflow resolves the version from the selected branch and existing tags. Both packages, the workspace, and the example keep the same version. Existing patch tags from the same version line prevent accidental reuse across sibling branches.
Set **version** to `patch`, `minor`, `major`, `prerelease`, or an exact version such as `11.0.0` or `11.0.0-beta.0`. The workflow resolves the version from the selected branch and existing tags. Both packages, the workspace, and the example keep the same version. Existing patch tags from the same version line prevent accidental reuse across sibling branches.

The workflow creates or updates a Release Please PR against that branch, regenerates the workspace and CocoaPods lockfiles, and commits the generated files through GitHub's signed commit API. It waits for the usual PR checks and merges when GitHub permits the merge. Required reviews and branch protection still apply. No manually prepared version commit or separate publishing trigger is needed.

Expand All @@ -22,6 +22,12 @@ Every release includes the announcement, a Markdown divider, and the current ver

Version 9 releases use the npm `legacy-9` channel and never become GitHub's latest release. Other versions use `latest` unless a newer version is already published. Older maintenance versions use `maintenance-MAJOR.MINOR` instead, and do not move GitHub's latest release backwards.

## Prerelease channels

A prerelease version publishes to an npm channel named after its prerelease identifiers, without the trailing number. For example, `11.0.0-nitro-v1.0` publishes to `nitro-v1` and installs with `react-native-nitro-sqlite@nitro-v1`. Prereleases never move `latest`, and their GitHub release is marked as a prerelease.

Start a channel by releasing its branch with an exact version, such as `11.0.0-nitro-v1.0`. Later releases from the same branch can use `prerelease`, which increments the trailing number to `11.0.0-nitro-v1.1`. Releasing `patch`, `minor`, or `major` from a prerelease finishes that version, so `11.0.0-nitro-v1.3` becomes `11.0.0` for `major`.

## Credentials

Configure a `RELEASE_PLEASE_TOKEN` repository secret with a fine-grained personal access token that can write repository contents, pull requests, and issues. Include workflows write access for release branches whose workflows differ from the default branch. The token must be allowed to create and merge release PRs subject to the repository's normal rules. A token separate from the workflow's built-in token makes automated commits trigger the usual CI checks.
Expand Down
12 changes: 12 additions & 0 deletions patches/release-please+17.6.0.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
diff --git a/node_modules/release-please/build/src/updaters/generic-json.js b/node_modules/release-please/build/src/updaters/generic-json.js
--- a/node_modules/release-please/build/src/updaters/generic-json.js
+++ b/node_modules/release-please/build/src/updaters/generic-json.js
@@ -17,7 +17,7 @@ exports.GenericJson = void 0;
const jsonpath_plus_1 = require("jsonpath-plus");
const json_stringify_1 = require("../util/json-stringify");
const logger_1 = require("../util/logger");
-const VERSION_REGEX = /(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)(-(?<preRelease>[\w.]+))?(\+(?<build>[-\w.]+))?/;
+const VERSION_REGEX = /(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)(-(?<preRelease>[-\w.]+))?(\+(?<build>[-\w.]+))?/;
class GenericJson {
constructor(jsonpath, version) {
this.jsonpath = jsonpath;
2 changes: 1 addition & 1 deletion scripts/generate-release-notes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ test('rejects a release increment before generating notes', () => {
withFixture('', (directory) => {
assert.throws(
() => generate(directory, 'patch'),
/An exact stable release version is required/,
/An exact release version is required/,
)
})
})
Expand Down
4 changes: 2 additions & 2 deletions scripts/generate-release-notes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { git, parseVersion } from './release-plan.ts'
/** Assemble announcement text, Release Please's changelog and first-time contributor acknowledgements.
* Reads the current version's committed changelog without changing its sections or formatting.
* Contributor detection uses the previous reachable tag and the checked-out release commit.
* @param version Exact stable version being released.
* @param version Exact version being released.
* @returns Markdown separated by a divider, with acknowledgements only when GitHub detects newcomers.
* @throws If the changelog or GitHub contributor detection is unavailable.
*/
Expand Down Expand Up @@ -82,7 +82,7 @@ export async function generateReleaseNotes(version: string): Promise<string> {
export function versionChangelog(content: string, version: string): string {
parseVersion(version)
const lines = content.split('\n')
const header = /^## \[?v?(\d+\.\d+\.\d+)(?:\]|\s|$)/
const header = /^## \[?v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)(?:\]|\s|$)/
const start = lines.findIndex((line) => line.match(header)?.[1] === version)
if (start === -1) throw new Error(`CHANGELOG.md has no entry for ${version}.`)
const next = lines.findIndex(
Expand Down
2 changes: 1 addition & 1 deletion scripts/publish-release.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +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.
* @param version Exact stable version already committed and tagged by Release Please.
* @param version Exact version already committed and tagged by Release Please.
* @throws Before publication on failed validation, or after publication if registry verification fails.
*/
export async function publishRelease(version: string): Promise<void> {
Expand Down
102 changes: 81 additions & 21 deletions scripts/release-plan.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import { execFileSync } from 'node:child_process'
import { readFileSync, appendFileSync } from 'node:fs'

/** Resolve a stable release on the checked-out branch, including patch releases made on sibling branches.
* @param increment A patch, minor, major increment or an exact stable version.
// Semantic versioning 2.0.0 core with optional prerelease identifiers, without build metadata.
const versionPattern =
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?$/

/** Resolve a release on the checked-out branch, including patch releases made on sibling branches.
* Increments from a prerelease finish its version first, so 11.0.0-beta.2 becomes 11.0.0 for patch, minor or major.
* @param increment A patch, minor, major or prerelease increment, or an exact version such as 11.0.0-beta.0.
* A prerelease increment continues the current prerelease, so 11.0.0-beta.0 becomes 11.0.0-beta.1.
* @returns The previous reachable tag, baseline version and requested version.
* @throws If no release tag is reachable, the version does not increase, or its tag already exists.
*/
Expand All @@ -27,8 +33,8 @@ export function releasePlan(increment: string) {
const current = parseVersion(metadata.version)
const tags = git('tag', '--list', 'v*').split('\n')
const versions = tags
.filter((tag) => /^v\d+\.\d+\.\d+$/.test(tag))
.map((tag) => tag.slice(1))
.filter((version) => versionPattern.test(version))
const baseline = versions
.filter((version) => {
const [major, minor] = parseVersion(version)
Expand All @@ -39,16 +45,7 @@ export function releasePlan(increment: string) {
compareVersions(version, highest) > 0 ? version : highest,
metadata.version,
)
const [major, minor, patch] = parseVersion(baseline)
const version =
increment === 'major'
? `${major + 1}.0.0`
: increment === 'minor'
? `${major}.${minor + 1}.0`
: increment === 'patch'
? `${major}.${minor}.${patch + 1}`
: increment
parseVersion(version)
const version = incrementVersion(baseline, increment)
if (compareVersions(version, baseline) <= 0)
throw new Error('The release version must increase.')
if (tags.includes(`v${version}`))
Expand All @@ -64,7 +61,7 @@ export function releasePlan(increment: string) {
}

/** Verify every published package, example version and internal dependency before publication.
* @param version Exact stable version to validate.
* @param version Exact version to validate.
* @throws If a manifest or internal dependency differs from the release version.
*/
export function validateReleaseVersions(version: string): void {
Expand Down Expand Up @@ -107,12 +104,11 @@ export function validateReleaseVersions(version: string): void {
}
}

/** Parse a stable semantic version and reject increments, prereleases and malformed inputs. */
/** Parse a stable or prerelease semantic version and reject increments, build metadata and malformed inputs. */
export function parseVersion(version: string): [number, number, number] {
if (!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(version)) {
throw new Error('An exact stable release version is required.')
}
const [major, minor, patch] = version.split('.').map(Number)
const match = versionPattern.exec(version)
if (!match) throw new Error('An exact release version is required.')
const [major, minor, patch] = match.slice(1, 4).map(Number)
if (
major === undefined ||
minor === undefined ||
Expand All @@ -124,11 +120,28 @@ export function parseVersion(version: string): [number, number, number] {
return [major, minor, patch]
}

/** Compare two stable semantic versions numerically. */
/** Read a version's prerelease identifiers, such as ['beta', '1'] for 11.0.0-beta.1.
* @returns An empty list for stable versions.
*/
export function prereleaseIdentifiers(version: string): string[] {
parseVersion(version)
const start = version.indexOf('-')
return start === -1 ? [] : version.slice(start + 1).split('.')
}

/** Compare two versions with semantic versioning precedence, where 11.0.0-beta.2 < 11.0.0-beta.10 < 11.0.0. */
export function compareVersions(left: string, right: string): number {
const a = parseVersion(left)
const b = parseVersion(right)
return a[0] - b[0] || a[1] - b[1] || a[2] - b[2]
return (
a[0] - b[0] ||
a[1] - b[1] ||
a[2] - b[2] ||
comparePrereleases(
prereleaseIdentifiers(left),
prereleaseIdentifiers(right),
)
)
}

/** Require all reported checks, including optional native checks, to finish successfully.
Expand Down Expand Up @@ -178,6 +191,53 @@ export function releaseChecksReady(checks: unknown): boolean {
.every(Boolean)
}

function incrementVersion(baseline: string, increment: string): string {
const [major, minor, patch] = parseVersion(baseline)
const prerelease = prereleaseIdentifiers(baseline)
if (increment === 'major')
return prerelease.length && !minor && !patch
? `${major}.0.0`
: `${major + 1}.0.0`
if (increment === 'minor')
return prerelease.length && !patch
? `${major}.${minor}.0`
: `${major}.${minor + 1}.0`
if (increment === 'patch')
return prerelease.length
? `${major}.${minor}.${patch}`
: `${major}.${minor}.${patch + 1}`
if (increment === 'prerelease') {
if (!prerelease.length)
throw new Error(
'Start a prerelease with an exact version, such as 11.0.0-beta.0.',
)
const last = prerelease.at(-1)
const next =
last && /^\d+$/.test(last)
? [...prerelease.slice(0, -1), String(Number(last) + 1)]
: [...prerelease, '0']
return `${major}.${minor}.${patch}-${next.join('.')}`
}
parseVersion(increment)
return increment
}

function comparePrereleases(left: string[], right: string[]): number {
// A stable version ranks above its prereleases.
if (!left.length || !right.length) return right.length - left.length
for (let index = 0; index < Math.min(left.length, right.length); index++) {
const a = left[index]
const b = right[index]
if (a === b) continue
const aNumeric = /^\d+$/.test(a)
const bNumeric = /^\d+$/.test(b)
if (aNumeric && bNumeric) return Number(a) - Number(b)
if (aNumeric !== bNumeric) return aNumeric ? -1 : 1
return a < b ? -1 : 1
}
return left.length - right.length
}

/** Run Git without a shell and return trimmed output. */
export function git(...args: string[]): string {
return execFileSync('git', args, { encoding: 'utf8' }).trim()
Expand Down
23 changes: 19 additions & 4 deletions scripts/release-registry.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { compareVersions, parseVersion } from './release-plan.ts'
import {
compareVersions,
parseVersion,
prereleaseIdentifiers,
} from './release-plan.ts'

/** Packages published together, in dependency order. */
export const releasePackages = [
Expand All @@ -8,13 +12,24 @@ export const releasePackages = [
]

/** Select the channel without moving latest backwards on maintenance releases.
* Prereleases use their named identifiers without the trailing number, so 11.0.0-nitro-v1.2 uses nitro-v1.
* Version 9 always uses legacy-9; older versions of other lines use maintenance-MAJOR.MINOR.
* @param version Stable version to publish.
* @returns The distribution tag shared by both packages.
* @throws On unavailable or malformed npm registry metadata.
* @param version Exact version to publish.
* @returns The distribution tag shared by all packages.
* @throws If a prerelease has no channel name, or on unavailable or malformed npm registry metadata.
*/
export async function distributionTag(version: string): Promise<string> {
const [major, minor] = parseVersion(version)
const channel = prereleaseIdentifiers(version)
if (channel.length) {
while (/^\d+$/.test(channel.at(-1) ?? '')) channel.pop()
const name = channel.join('.')
if (!name || name === 'latest')
throw new Error(
'A prerelease needs a channel name, such as 11.0.0-beta.0.',
)
return name
}
if (major === 9) return 'legacy-9'
for (const name of releasePackages) {
const latest = await registry(`${name}/latest`, true)
Expand Down
Loading
Loading