From 85d84a23614a597557070a0d2d239cdaa2658c93 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 13 Nov 2026 09:23:18 +0100 Subject: [PATCH 1/2] ci: publish prereleases to named npm channels The Release workflow accepted only stable versions, so a branch like the Nitro V1 lane could not publish a preview. Exact prerelease versions such as 11.0.0-nitro-v1.0 now publish to an npm channel named after their prerelease identifiers, never move latest, and mark the GitHub release as a prerelease. A new prerelease increment continues a channel. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 10 +- docs/releases/README.md | 8 +- scripts/generate-release-notes.test.ts | 2 +- scripts/generate-release-notes.ts | 4 +- scripts/publish-release.ts | 2 +- scripts/release-plan.ts | 102 ++++++++++--- scripts/release-registry.ts | 23 ++- scripts/release.test.ts | 189 +++++++++++++++++-------- scripts/validate-release.ts | 2 + 9 files changed, 246 insertions(+), 96 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a5287d84..8dcaf810 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 @@ -202,6 +202,7 @@ 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 }} @@ -209,8 +210,8 @@ jobs: 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 @@ -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 diff --git a/docs/releases/README.md b/docs/releases/README.md index 2f96b4b0..228db758 100644 --- a/docs/releases/README.md +++ b/docs/releases/README.md @@ -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. @@ -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. diff --git a/scripts/generate-release-notes.test.ts b/scripts/generate-release-notes.test.ts index 220b84b6..12074bef 100644 --- a/scripts/generate-release-notes.test.ts +++ b/scripts/generate-release-notes.test.ts @@ -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/, ) }) }) diff --git a/scripts/generate-release-notes.ts b/scripts/generate-release-notes.ts index 6fdc018f..232916b7 100644 --- a/scripts/generate-release-notes.ts +++ b/scripts/generate-release-notes.ts @@ -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. */ @@ -82,7 +82,7 @@ export async function generateReleaseNotes(version: string): Promise { 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( diff --git a/scripts/publish-release.ts b/scripts/publish-release.ts index 8142eceb..1558b9cf 100644 --- a/scripts/publish-release.ts +++ b/scripts/publish-release.ts @@ -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 { diff --git a/scripts/release-plan.ts b/scripts/release-plan.ts index 9fefdb16..d700d6cd 100644 --- a/scripts/release-plan.ts +++ b/scripts/release-plan.ts @@ -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. */ @@ -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) @@ -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}`)) @@ -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 { @@ -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 || @@ -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. @@ -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() diff --git a/scripts/release-registry.ts b/scripts/release-registry.ts index 1a84093b..dd145300 100644 --- a/scripts/release-registry.ts +++ b/scripts/release-registry.ts @@ -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 = [ @@ -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 { 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) diff --git a/scripts/release.test.ts b/scripts/release.test.ts index ea8bd58c..6247aeaa 100644 --- a/scripts/release.test.ts +++ b/scripts/release.test.ts @@ -23,8 +23,10 @@ import { releasePlan, validateReleaseVersions, parseVersion, + compareVersions, releaseChecksReady, } from './release-plan.ts' +import { versionChangelog } from './generate-release-notes.ts' import { distributionTag, pendingPackages, @@ -62,8 +64,8 @@ test('sibling maintenance patches do not cause duplicate versions on main', () = test('rejects reused tags, non-increasing versions and malformed input', () => { fixture(() => { assert.throws(() => releasePlan('1.0.0'), /increase/) - assert.throws(() => releasePlan('preminor'), /exact stable/) - assert.throws(() => releasePlan('01.0.1'), /exact stable/) + assert.throws(() => releasePlan('preminor'), /exact release/) + assert.throws(() => releasePlan('01.0.1'), /exact release/) git('commit', '--allow-empty', '-m', 'feat: new major') git('tag', 'v2.0.0') assert.throws(() => releasePlan('major'), /already exists/) @@ -84,19 +86,55 @@ test('validates internal dependency versions before publication', () => { }) }) -test('stable version parsing rejects prereleases and unsafe components', () => { +test('version parsing accepts prereleases and rejects build metadata and unsafe components', () => { assert.deepEqual(parseVersion('10.1.0'), [10, 1, 0]) + assert.deepEqual(parseVersion('11.0.0-nitro-v1.0'), [11, 0, 0]) for (const invalid of [ 'patch', 'v10.0.0', - '10.0.0-beta.1', '10.0.0+build', + '10.0.0-', + '10.0.0-01', + '10.0.0-beta..1', '999999999999999999.0.0', ]) { assert.throws(() => parseVersion(invalid)) } }) +test('prereleases rank below their stable version', () => { + const ordered = [ + '10.3.0', + '11.0.0-alpha', + '11.0.0-alpha.1', + '11.0.0-beta.2', + '11.0.0-beta.10', + '11.0.0-nitro-v1.0', + '11.0.0', + ] + for (const [index, version] of ordered.entries()) { + const next = ordered[index + 1] + if (next) + assert.ok(compareVersions(version, next) < 0, `${version} < ${next}`) + } +}) + +test('a prerelease lane starts from an exact version and continues with prerelease increments', () => { + fixture(() => { + assert.throws(() => releasePlan('prerelease'), /exact version/) + git('switch', '-c', 'release/next') + assert.equal(releasePlan('2.0.0-next.0').version, '2.0.0-next.0') + setVersion('2.0.0-next.0') + git('commit', '-am', 'chore: release 2.0.0-next.0') + git('tag', 'v2.0.0-next.0') + assert.equal(releasePlan('prerelease').version, '2.0.0-next.1') + assert.equal(releasePlan('major').version, '2.0.0') + assert.equal(releasePlan('patch').previousTag, 'v2.0.0-next.0') + assert.throws(() => releasePlan('2.0.0-next.0'), /increase/) + assert.throws(() => releasePlan('2.0.0-alpha.0'), /increase/) + }) +}) + test('a release waits for optional native checks and fails on unsuccessful CI', () => { const success = { status: 'COMPLETED', conclusion: 'SUCCESS' } assert.equal( @@ -134,6 +172,21 @@ test('maintenance releases cannot move the latest npm channel backwards', async } }) +test('prereleases publish to their named channel and never to latest', async () => { + const stub = mock.method(globalThis, 'fetch', async () => + Response.json({ version: '10.3.0' }), + ) + try { + assert.equal(await distributionTag('11.0.0-nitro-v1.0'), 'nitro-v1') + assert.equal(await distributionTag('11.0.0-nitro-v1.12'), 'nitro-v1') + assert.equal(await distributionTag('11.0.0-rc'), 'rc') + await assert.rejects(distributionTag('11.0.0-0'), /channel name/) + await assert.rejects(distributionTag('11.0.0-latest.1'), /channel name/) + } finally { + stub.mock.restore() + } +}) + test('partial publication retries skip only packages from the same release commit', async () => { const stub = mock.method( globalThis, @@ -255,66 +308,73 @@ test('Release Please updates every public version and keeps the configured chang }, ) try { - const strategy = new Node({ - github: client, - targetBranch: 'maintenance', - releaseAs: '2.0.0', - includeComponentInTag: false, - changelogSections: options['changelog-sections'], - extraFiles: options['extra-files'], - }) - const messages = [ - 'feat!: add new connection mode', - 'perf: improve query scheduling', - 'fix: fix connection ownership', - 'refactor: simplify queue', - 'chore: update build flow', - 'chore(deps): bump SQLite', - 'docs: explain WAL', - ] - const pr = await strategy.buildReleasePullRequest( - parseConventionalCommits( - messages.map((message, index) => ({ - message, - sha: String(index + 1).repeat(40), - })), - ), - { tag: new TagName(Version.parse('1.0.0')), sha: 'baseline', notes: '' }, - ) - assert.ok(pr) - const changelog = pr.updates - .find((update) => update.path === 'CHANGELOG.md') - ?.updater.updateContent(undefined) - assert.ok(changelog) - for (const section of [ - 'Features', - 'Performance Improvements', - 'Bug Fixes', - 'Code Refactoring', - 'Project configuration and build flow', - 'Dependency Upgrades', - 'Documentation', - 'BREAKING CHANGES', - ]) { - assert.ok(changelog.includes(section), section) - } - const contents = new Map() - for (const update of pr.updates) { - if ( - !update.path.endsWith('package.json') || - update.path.startsWith('samples/') + for (const version of ['2.0.0', '2.0.0-nitro-v1.0']) { + const strategy = new Node({ + github: client, + targetBranch: 'maintenance', + releaseAs: version, + includeComponentInTag: false, + changelogSections: options['changelog-sections'], + extraFiles: options['extra-files'], + }) + const messages = [ + 'feat!: add new connection mode', + 'perf: improve query scheduling', + 'fix: fix connection ownership', + 'refactor: simplify queue', + 'chore: update build flow', + 'chore(deps): bump SQLite', + 'docs: explain WAL', + ] + const pr = await strategy.buildReleasePullRequest( + parseConventionalCommits( + messages.map((message, index) => ({ + message, + sha: String(index + 1).repeat(40), + })), + ), + { + tag: new TagName(Version.parse('1.0.0')), + sha: 'baseline', + notes: '', + }, ) - continue - const original = - contents.get(update.path) ?? - readFileSync(new URL('../' + update.path, import.meta.url), 'utf8') - contents.set(update.path, update.updater.updateContent(original)) + assert.ok(pr) + const changelog = pr.updates + .find((update) => update.path === 'CHANGELOG.md') + ?.updater.updateContent(undefined) + assert.ok(changelog) + assert.match(versionChangelog(changelog, version), /BREAKING CHANGES/) + for (const section of [ + 'Features', + 'Performance Improvements', + 'Bug Fixes', + 'Code Refactoring', + 'Project configuration and build flow', + 'Dependency Upgrades', + 'Documentation', + 'BREAKING CHANGES', + ]) { + assert.ok(changelog.includes(section), section) + } + const contents = new Map() + for (const update of pr.updates) { + if ( + !update.path.endsWith('package.json') || + update.path.startsWith('samples/') + ) + continue + const original = + contents.get(update.path) ?? + readFileSync(new URL('../' + update.path, import.meta.url), 'utf8') + contents.set(update.path, update.updater.updateContent(original)) + } + fixture((directory) => { + for (const [path, content] of contents) + writeFileSync(join(directory, path), content) + validateReleaseVersions(version) + }) } - fixture((directory) => { - for (const [path, content] of contents) - writeFileSync(join(directory, path), content) - validateReleaseVersions('2.0.0') - }) } finally { stub.mock.restore() } @@ -364,3 +424,8 @@ function fixture(run: (directory: string) => void): void { function git(...args: string[]): void { execFileSync('git', args, { stdio: 'pipe' }) } + +function setVersion(version: string): void { + const metadata = JSON.parse(readFileSync('package.json', 'utf8')) + writeFileSync('package.json', JSON.stringify({ ...metadata, version })) +} diff --git a/scripts/validate-release.ts b/scripts/validate-release.ts index aed6c52a..bb80746f 100644 --- a/scripts/validate-release.ts +++ b/scripts/validate-release.ts @@ -3,6 +3,7 @@ import { git, output, parseVersion, + prereleaseIdentifiers, validateReleaseVersions, } from './release-plan.ts' import { distributionTag } from './release-registry.ts' @@ -35,3 +36,4 @@ if (git('rev-list', '-n', '1', tag) !== git('rev-parse', 'HEAD')) git('merge-base', '--is-ancestor', tag, `origin/${process.env.GITHUB_REF_NAME}`) output('tag', tag) output('latest', String((await distributionTag(version)) === 'latest')) +output('prerelease', String(prereleaseIdentifiers(version).length > 0)) From eed21dcaf6fbdea084ffdc2dd361c497c940034c Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 13 Nov 2026 10:00:55 +0100 Subject: [PATCH 2/2] fix(ci): keep hyphenated prerelease versions intact in release PRs Release Please's JSON updater stops matching a prerelease at the first hyphen, so bumping 11.0.0-nitro-v1.0 wrote 11.0.0-nitro-v1.1-v1.0 into the package manifests. The patch lets prerelease identifiers contain hyphens, as semantic versioning allows. Co-Authored-By: Claude Opus 5.5 --- patches/release-please+17.6.0.patch | 12 ++++++++++++ scripts/release.test.ts | 13 +++++++++++++ 2 files changed, 25 insertions(+) create mode 100644 patches/release-please+17.6.0.patch diff --git a/patches/release-please+17.6.0.patch b/patches/release-please+17.6.0.patch new file mode 100644 index 00000000..966856b2 --- /dev/null +++ b/patches/release-please+17.6.0.patch @@ -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 = /(?\d+)\.(?\d+)\.(?\d+)(-(?[\w.]+))?(\+(?[-\w.]+))?/; ++const VERSION_REGEX = /(?\d+)\.(?\d+)\.(?\d+)(-(?[-\w.]+))?(\+(?[-\w.]+))?/; + class GenericJson { + constructor(jsonpath, version) { + this.jsonpath = jsonpath; diff --git a/scripts/release.test.ts b/scripts/release.test.ts index 6247aeaa..f688b380 100644 --- a/scripts/release.test.ts +++ b/scripts/release.test.ts @@ -17,6 +17,7 @@ import { } from 'release-please' import { Version } from 'release-please/build/src/version.js' import { Node } from 'release-please/build/src/strategies/node.js' +import { GenericJson } from 'release-please/build/src/updaters/generic-json.js' import { TagName } from 'release-please/build/src/util/tag-name.js' import { parseConventionalCommits } from 'release-please/build/src/commit.js' import { @@ -380,6 +381,18 @@ test('Release Please updates every public version and keeps the configured chang } }) +test('Release Please replaces a whole hyphenated prerelease version', () => { + const updater = new GenericJson( + '$.version', + Version.parse('11.0.0-nitro-v1.1'), + ) + assert.equal( + JSON.parse(updater.updateContent('{"version":"11.0.0-nitro-v1.0"}')) + .version, + '11.0.0-nitro-v1.1', + ) +}) + function fixture(run: (directory: string) => void): void { const directory = mkdtempSync(join(tmpdir(), 'nitro-release-plan-')) const original = process.cwd()