From 06afa2b77f92dec56f29252e8f790cef3a18c66b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 09:29:43 +0000 Subject: [PATCH 1/3] ci(release): support prerelease versions such as 1.0.0-alpha.1 A v* tag always published to npm latest, the Docker latest/major/minor tags and a stable GitHub Release; auto-release.yml's npm version patch turns a prerelease into the stable release; the nightly version became 1.0.NaN-nightly.; and changelog-guard ranked v1.0.0-alpha.1 above v1.0.0 and could not order or link prerelease sections. scripts/release-version.mjs now classifies a version for the workflows: prereleases publish under their own dist-tag, push only :X.Y.Z-pre.N and :pre to GHCR, create a GitHub pre-release, and make auto-release fail loudly. Stable releases are unchanged. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012ESxuEjCeVsRAAxAb29Yjr --- .github/workflows/auto-release.yml | 6 ++ .github/workflows/publish.yml | 27 ++++-- AGENTS.md | 13 +++ CHANGELOG.md | 1 + scripts/changelog-guard.mjs | 65 +++++++++++-- scripts/release-version.mjs | 93 ++++++++++++++++++ .../automation/changelog-guard.node-test.mjs | 94 +++++++++++++++++++ .../automation/release-version.node-test.mjs | 60 ++++++++++++ 8 files changed, 343 insertions(+), 16 deletions(-) create mode 100644 scripts/release-version.mjs create mode 100644 tests/automation/release-version.node-test.mjs diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml index e454c07..a9057e5 100644 --- a/.github/workflows/auto-release.yml +++ b/.github/workflows/auto-release.yml @@ -70,6 +70,12 @@ jobs: with: node-version: 24 + # `npm version patch` on 1.0.0-alpha.1 yields 1.0.0, so a security merge landing while main + # sits on a prerelease would publish the stable release unannounced. Fail loudly instead. + - name: Refuse to bump over a prerelease version + if: steps.guard.outputs.release == 'true' + run: node scripts/release-version.mjs assert-stable "$(node -p "require('./package.json').version")" + - name: Bump patch version if: steps.guard.outputs.release == 'true' id: bump diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a722f45..42b2b51 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -36,6 +36,9 @@ jobs: should_publish: ${{ steps.resolve.outputs.should_publish }} version: ${{ steps.resolve.outputs.version }} npm_tag: ${{ steps.resolve.outputs.npm_tag }} + # 'true' for a tag with a prerelease suffix (v1.0.0-alpha.1): it publishes under its own + # npm dist-tag, never moves `latest`, and the GitHub Release is marked a pre-release. + prerelease: ${{ steps.resolve.outputs.prerelease }} steps: - uses: actions/checkout@v7 with: @@ -52,21 +55,24 @@ jobs: run: | echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" if [ "$EVENT" = "push" ]; then - # Tag push -> release. Version comes from the tag; npm 'latest' dist-tag. + # Tag push -> release. Version comes from the tag. A stable tag publishes to npm + # 'latest'; a prerelease tag (v1.0.0-alpha.1) publishes under its own dist-tag + # ('alpha') and leaves `latest` alone. classify also rejects a tag that is not semver. VERSION="${GITHUB_REF_NAME#v}" + node scripts/release-version.mjs classify "$VERSION" >> "$GITHUB_OUTPUT" echo "channel=release" >> "$GITHUB_OUTPUT" echo "should_publish=true" >> "$GITHUB_OUTPUT" echo "version=$VERSION" >> "$GITHUB_OUTPUT" - echo "npm_tag=latest" >> "$GITHUB_OUTPUT" echo "Release channel: v$VERSION" else # schedule / workflow_dispatch -> nightly. Prerelease version, 'nightly' dist-tag. - NEXT=$(node -p "const [a,b,c]=require('./package.json').version.split('.').map(Number); [a,b,c+1].join('.')") + NEXT=$(node scripts/release-version.mjs nightly-base "$(node -p "require('./package.json').version")") STAMP=$(date -u +%Y%m%dT%H%M) VERSION="${NEXT}-nightly.${STAMP}" echo "channel=nightly" >> "$GITHUB_OUTPUT" echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "npm_tag=nightly" >> "$GITHUB_OUTPUT" + echo "prerelease=false" >> "$GITHUB_OUTPUT" echo "Nightly channel: $VERSION" # Change-guard: publish only if HEAD differs from the rolling nightly tag. HEAD_SHA=$(git rev-parse HEAD) @@ -233,7 +239,9 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} # Release pushes semver + latest; nightly pushes only nightly tags. The - # `enable` flag selects per channel so neither leaks into the other. + # `enable` flag selects per channel so neither leaks into the other. A prerelease release + # pushes only its exact version and its dist-tag (:alpha): `latest`, `1` and `1.0` keep + # pointing at the newest stable image. - name: Docker tags + labels id: meta uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 @@ -241,9 +249,10 @@ jobs: images: ghcr.io/daften/fireflyiii-mcp tags: | type=semver,pattern={{version}},enable=${{ needs.setup.outputs.channel == 'release' }} - type=semver,pattern={{major}}.{{minor}},enable=${{ needs.setup.outputs.channel == 'release' }} - type=semver,pattern={{major}},enable=${{ needs.setup.outputs.channel == 'release' }} - type=raw,value=latest,enable=${{ needs.setup.outputs.channel == 'release' }} + type=semver,pattern={{major}}.{{minor}},enable=${{ needs.setup.outputs.channel == 'release' && needs.setup.outputs.prerelease != 'true' }} + type=semver,pattern={{major}},enable=${{ needs.setup.outputs.channel == 'release' && needs.setup.outputs.prerelease != 'true' }} + type=raw,value=latest,enable=${{ needs.setup.outputs.channel == 'release' && needs.setup.outputs.prerelease != 'true' }} + type=raw,value=${{ needs.setup.outputs.npm_tag }},enable=${{ needs.setup.outputs.channel == 'release' && needs.setup.outputs.prerelease == 'true' }} type=raw,value=nightly,enable=${{ needs.setup.outputs.channel == 'nightly' }} type=raw,value=nightly-{{date 'YYYYMMDD'}},enable=${{ needs.setup.outputs.channel == 'nightly' }} @@ -304,6 +313,10 @@ jobs: with: body_path: release-notes.md generate_release_notes: false + # A prerelease is flagged as such and never becomes the repository's "latest release". + # For a stable release make_latest is left empty, i.e. the action's own default. + prerelease: ${{ needs.setup.outputs.prerelease == 'true' }} + make_latest: ${{ needs.setup.outputs.prerelease == 'true' && 'false' || '' }} - name: Move rolling nightly tag to HEAD if: needs.setup.outputs.channel == 'nightly' diff --git a/AGENTS.md b/AGENTS.md index 2725655..457e3ed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -532,6 +532,19 @@ A manual hotfix (a fix merged straight to `main`, typically a dependency CVE Dep 3. **Cut the release branch from `main`, not `develop`** (`git checkout -b release/X.Y.Z origin/main`), so the release contains only the hotfix and none of `develop`'s unreleased work. Promote `main`'s `[Unreleased]` and bump the patch version as in steps 3–5 above, then PR, merge, tag and push the tag as in steps 6–8. 4. **Expect the post-release back-merge to fail too** whenever `develop` has pending `[Unreleased]` entries: the union merge splices them into the section that just shipped, and the guard rejects the edit to a released section. Resolve it the same way as in step 2, but make the released `## [X.Y.Z]` section identical to the tag (`git show vX.Y.Z:CHANGELOG.md`), remove the bullets that just shipped from `develop`'s `[Unreleased]`, keep `develop`'s own pending entries there, and add the `[X.Y.Z]:` link reference. `node scripts/changelog-guard.mjs` must report the file consistent with the new tag before you push. +### Prerelease releases (alpha / beta / rc) + +A prerelease ships through the normal `release/X.Y.Z-alpha.N` → `main` PR and tag flow above; only the version string and what the tag publishes differ. `scripts/release-version.mjs` is the single place that classifies a version, and `publish.yml`, `auto-release.yml` and `changelog-guard.mjs` all go through it or share its rules. + +- **Name it `X.Y.Z-alpha.N`, with a dot before the number.** Semver compares `alpha.2 < alpha.10` numerically, but the single identifier `alpha10` sorts *before* `alpha2`. Tag it `vX.Y.Z-alpha.N`; the changelog section is `## [X.Y.Z-alpha.N]` and it gets a link reference like any other. +- **npm:** published under the dist-tag named by the leading identifier (`alpha`, `beta`, `rc`; a purely numeric one is `next`), never `latest`. `latest` and `nightly` are rejected as identifiers. Install with `npm i @daften/fireflyiii-mcp@alpha`. +- **Docker:** only `:X.Y.Z-alpha.N` and `:alpha`. `:latest`, `:X` and `:X.Y` keep pointing at the newest stable image. +- **GitHub Release:** marked a pre-release and never the repository's "latest release". +- **`auto-release.yml` refuses to run while `package.json` is a prerelease** (`npm version patch` on `1.0.0-alpha.1` yields the stable `1.0.0`, so an unattended security release would publish it by accident). The workflow fails loudly on the Dependabot merge; cut that release by hand, as the next prerelease or as the stable version. +- **Graduating:** the next release branch sets `version` to `X.Y.Z` and adds `## [X.Y.Z]`. The prerelease sections stay below it, immutable like any other. +- **Nightlies** keep working: on a prerelease `package.json` the nightly version is `X.Y.Z-nightly.`, which sorts after every `X.Y.Z-alpha.*` and before `X.Y.Z`. +- `changelog-guard` orders sections and picks its baseline tag by semver precedence (`1.0.0-alpha.1 < 1.0.0-rc.1 < 1.0.0`) rather than git's version sort, which ranks `v1.0.0-alpha.1` *above* `v1.0.0`. + ### Nightly builds Nightly publishing lives in the **same `publish.yml` workflow** as tagged releases — deliberately, because npm trusted publishing (OIDC) allows only one trusted-publisher workflow file per package, so both channels must publish from `publish.yml` to share that config (no `NPM_TOKEN` needed). A `setup` job resolves the *channel* per run: diff --git a/CHANGELOG.md b/CHANGELOG.md index ce3c963..da65a89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- The release pipeline understands prerelease versions such as `1.0.0-alpha.1`. A prerelease tag publishes to npm under its own dist-tag (`alpha`, `beta`, `rc`) instead of `latest`, pushes only `:X.Y.Z-alpha.1` and `:alpha` to GHCR (so `:latest`, `:X` and `:X.Y` stay on the newest stable image), and creates a GitHub pre-release. `auto-release.yml` now fails instead of bumping the patch version of a prerelease (which would have published the stable release), the nightly version no longer becomes `…NaN…` on a prerelease `package.json`, and `changelog-guard` orders sections and chooses its baseline tag by semver precedence. Nothing changes for stable releases. - Deduped six maintainability items flagged in the v0.4.6..develop pre-release review (#103): `get_transactions`' account-scoped delegation and `get_account_transactions` now share one query-builder helper; `create_account` and `update_account` now share one `liabilityFields()` schema factory instead of repeating the six-field liability block; the OAuth pending-flow expiry check in `src/http.ts` is defined once and used by both the periodic sweep and the read-time check; `GROUP_ID_HINT` and `groupIdField` in `transactions.ts` now derive from one shared warning fragment instead of two independently-worded copies; and `exports.ts`'s dated/undated tool variants are now registered through a single `defineTool` call instead of two near-identical branches. No behavior change to any tool's request/response shape or validation. - **Breaking:** `update_budget_limit` and `delete_budget_limit` take the limit's `budget_id`, since Firefly III only routes budget-limit writes under their budget. ([#117](https://github.com/daften/fireflyiii-mcp/issues/117)) - **Breaking:** `create_piggy_bank` takes `accounts` (or the `account_id` shortcut), `target_amount` and `currency_code`, matching what Firefly III has required since v6.2.0; `start_date` defaults to today. `update_piggy_bank` replaces its ignored `account_id` with `accounts`, merged over the existing links so accounts you don't mention keep their link and saved amount (Firefly III itself would unlink them). Set `accounts[].current_amount` to put money in or take it out. ([#117](https://github.com/daften/fireflyiii-mcp/issues/117)) diff --git a/scripts/changelog-guard.mjs b/scripts/changelog-guard.mjs index f04fff2..9110e1d 100644 --- a/scripts/changelog-guard.mjs +++ b/scripts/changelog-guard.mjs @@ -39,7 +39,7 @@ const UNRELEASED = 'Unreleased'; * would swallow it — stays in the section body and trips the immutability check. Releases add a * definition here for the new version, which is the only reason this block is excluded at all. */ -const LINK_DEFINITION = /^\[(?:Unreleased|\d+\.\d+\.\d+)\]:\s/; +const LINK_DEFINITION = /^\[(?:Unreleased|\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\]:\s/; /** Where the trailing link-reference block starts, scanning from the end of the file. */ function trailingLinkBlockStart(lines) { @@ -148,17 +148,60 @@ export function changedReleasedSections(baselineText, currentText, allowedEdits return problems; } -/** Parse an `X.Y.Z` version for ordering purposes; missing components sort as 0. */ -function compareVersions(a, b) { - const pa = a.split('.').map(Number); - const pb = b.split('.').map(Number); - for (let i = 0; i < Math.max(pa.length, pb.length); i += 1) { - const diff = (pa[i] ?? 0) - (pb[i] ?? 0); - if (diff !== 0) return diff; +const SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/; + +/** + * Semver precedence for prerelease identifiers (semver.org item 11): numeric identifiers compare + * numerically and sort below alphanumeric ones, alphanumeric compare lexically, and a longer list + * wins when every shared identifier is equal. So `alpha.2 < alpha.10`, which a plain string or + * `Number()` comparison gets wrong. + */ +function comparePrerelease(a, b) { + for (let i = 0; i < Math.max(a.length, b.length); i += 1) { + if (a[i] === undefined) return -1; + if (b[i] === undefined) return 1; + const aNumeric = /^\d+$/.test(a[i]); + const bNumeric = /^\d+$/.test(b[i]); + if (aNumeric && bNumeric) { + const diff = Number(a[i]) - Number(b[i]); + if (diff !== 0) return diff; + } else if (aNumeric) { + return -1; + } else if (bNumeric) { + return 1; + } else if (a[i] !== b[i]) { + return a[i] < b[i] ? -1 : 1; + } } return 0; } +/** + * Order two versions by semver precedence: `1.0.0-alpha.1 < 1.0.0-rc.1 < 1.0.0`. Anything that is + * not a full `X.Y.Z[-pre]` falls back to comparing dotted numbers, missing components as 0. + */ +export function compareVersions(a, b) { + const ma = SEMVER.exec(a); + const mb = SEMVER.exec(b); + if (!ma || !mb) { + const pa = a.split('.').map(Number); + const pb = b.split('.').map(Number); + for (let i = 0; i < Math.max(pa.length, pb.length); i += 1) { + const diff = (pa[i] ?? 0) - (pb[i] ?? 0); + if (diff !== 0) return diff; + } + return 0; + } + for (let i = 1; i <= 3; i += 1) { + const diff = Number(ma[i]) - Number(mb[i]); + if (diff !== 0) return diff; + } + if (ma[4] === undefined && mb[4] === undefined) return 0; + if (ma[4] === undefined) return 1; + if (mb[4] === undefined) return -1; + return comparePrerelease(ma[4].split('.'), mb[4].split('.')); +} + /** * Dated sections must read newest-first. A release PR merged into `main` while another release * branch is also in flight is a real three-way merge, not a conflict `merge=union` needs to resolve @@ -282,10 +325,14 @@ export function loadAllowedEdits( */ export function baselineTag(run = (args) => execFileSync('git', args, { encoding: 'utf8' })) { const onHead = new Set(run(['tag', '--points-at', 'HEAD']).split('\n').filter(Boolean)); + // git's version sort ranks `v1.0.0-alpha.1` above `v1.0.0` (the longer name wins) unless + // `versionsort.suffix` is configured, which would make the "newest tag" after a stable release + // its own prerelease. Re-sort by semver precedence here instead of depending on git config. const tags = run(['tag', '--list', 'v*', '--sort=-v:refname']) .split('\n') .filter(Boolean) - .filter((tag) => !onHead.has(tag)); + .filter((tag) => !onHead.has(tag)) + .sort((a, b) => compareVersions(b.slice(1), a.slice(1)) || 0); return tags[0] ?? null; } diff --git a/scripts/release-version.mjs b/scripts/release-version.mjs new file mode 100644 index 0000000..13fd9f9 --- /dev/null +++ b/scripts/release-version.mjs @@ -0,0 +1,93 @@ +#!/usr/bin/env node +/** + * Version classification for the release workflows. + * + * `publish.yml` and `auto-release.yml` used to assume every version is a plain `X.Y.Z`: a `v*` tag + * always published to npm `latest`, the nightly version came from `split('.').map(Number)` (which + * turns `1.0.0-alpha.1` into `1.0.NaN`), and `npm version patch` on a prerelease silently produces + * the stable release. This module is the one place that decides what a version means, so the + * workflows share the decision and the tests can exercise it. + * + * node scripts/release-version.mjs classify -> prerelease= / npm_tag= + * node scripts/release-version.mjs nightly-base -> X.Y.Z the nightly version builds on + * node scripts/release-version.mjs assert-stable -> exit 1 for a prerelease + */ +import { pathToFileURL } from 'node:url'; + +const SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/; + +// `latest` is what a plain install resolves and `nightly` belongs to the nightly channel; a +// prerelease named after either would publish over it. +const RESERVED_NPM_TAGS = new Set(['latest', 'nightly']); + +function parse(version) { + const match = SEMVER.exec(version); + if (!match) { + throw new Error(`'${version}' is not a valid version (expected X.Y.Z or X.Y.Z-prerelease).`); + } + const [, major, minor, patch, prerelease] = match; + return { major: Number(major), minor: Number(minor), patch: Number(patch), prerelease: prerelease ?? null }; +} + +/** + * Release-channel facts for a version being tagged. A stable version publishes to `latest`; a + * prerelease publishes under the dist-tag named by its leading identifier (`1.0.0-alpha.1` and + * `1.0.0-alpha1` both -> `alpha`; a purely numeric one -> `next`) and never to `latest`. + */ +export function classify(version) { + const { prerelease } = parse(version); + if (prerelease === null) return { prerelease: false, npmTag: 'latest' }; + const lead = /^[A-Za-z][A-Za-z-]*/.exec(prerelease); + const npmTag = lead ? lead[0] : 'next'; + if (RESERVED_NPM_TAGS.has(npmTag)) { + throw new Error(`The prerelease identifier '${npmTag}' in '${version}' is reserved; pick another (alpha, beta, rc).`); + } + return { prerelease: true, npmTag }; +} + +/** + * The `X.Y.Z` a nightly version (`-nightly.`) is built on. Stable `0.5.5` -> `0.5.6`, + * the next patch. A prerelease `1.0.0-alpha.1` -> `1.0.0`: `1.0.0-nightly.` sorts after + * every `1.0.0-alpha.*` and before the `1.0.0` it leads up to, which is what a nightly of that + * line should do. + */ +export function nightlyBase(version) { + const { major, minor, patch, prerelease } = parse(version); + return prerelease === null ? `${major}.${minor}.${patch + 1}` : `${major}.${minor}.${patch}`; +} + +/** `npm version patch` on a prerelease yields the stable release, so automation must not run on one. */ +export function assertStable(version) { + if (classify(version).prerelease) { + throw new Error( + `package.json is at the prerelease ${version}. Automated security releases bump the patch ` + + 'version, which for a prerelease means publishing the stable release by accident. Cut this ' + + 'release by hand (AGENTS.md, "Releasing a New Version").', + ); + } +} + +function main(argv) { + const [command, version] = argv; + try { + if (!version) throw new Error(`usage: release-version.mjs `); + if (command === 'classify') { + const { prerelease, npmTag } = classify(version); + console.log(`prerelease=${prerelease}`); + console.log(`npm_tag=${npmTag}`); + } else if (command === 'nightly-base') { + console.log(nightlyBase(version)); + } else if (command === 'assert-stable') { + assertStable(version); + } else { + throw new Error(`unknown command '${command}'`); + } + } catch (error) { + console.error(`::error::${error.message}`); + process.exit(1); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main(process.argv.slice(2)); +} diff --git a/tests/automation/changelog-guard.node-test.mjs b/tests/automation/changelog-guard.node-test.mjs index dd4bc8e..17c242a 100644 --- a/tests/automation/changelog-guard.node-test.mjs +++ b/tests/automation/changelog-guard.node-test.mjs @@ -4,6 +4,7 @@ import { baselineTag, changedReleasedSections, check, + compareVersions, duplicateHeadings, duplicateLinkLabels, duplicateSections, @@ -269,3 +270,96 @@ test('duplicateLinkLabels rejects a repeated compare-link definition', () => { ); assert.match(duplicateLinkLabels(doubled).join('\n'), /defined more than once/); }); + +// Prerelease support: a `1.0.0-alpha.1` tag and changelog section must order, link and be chosen as +// a baseline by semver precedence, not by Number() on dotted parts (which gives NaN) or by git's +// version sort (which ranks the prerelease above the stable release it leads up to). +test('compareVersions follows semver precedence, including the spec example chain', () => { + const ascending = [ + '0.5.5', + '1.0.0-alpha', + '1.0.0-alpha.1', + '1.0.0-alpha.beta', + '1.0.0-beta', + '1.0.0-beta.2', + '1.0.0-beta.11', + '1.0.0-rc.1', + '1.0.0', + '1.0.1', + ]; + for (let i = 1; i < ascending.length; i += 1) { + assert.ok(compareVersions(ascending[i], ascending[i - 1]) > 0, `${ascending[i]} > ${ascending[i - 1]}`); + assert.ok(compareVersions(ascending[i - 1], ascending[i]) < 0, `${ascending[i - 1]} < ${ascending[i]}`); + } + assert.equal(compareVersions('1.0.0-alpha.1', '1.0.0-alpha.1'), 0); + assert.equal(compareVersions('1.0.0', '1.0.0'), 0); + // alpha.2 sorts before alpha.10 (numeric identifiers), but the single identifier alpha10 sorts + // before alpha2 (lexical), which is why releases are named alpha.N. + assert.ok(compareVersions('1.0.0-alpha.10', '1.0.0-alpha.2') > 0); + assert.ok(compareVersions('1.0.0-alpha10', '1.0.0-alpha2') < 0); +}); + +test('compareVersions keeps its lenient behaviour for non-semver labels', () => { + assert.ok(compareVersions('1.1', '1.0.5') > 0); + assert.equal(compareVersions('2', '2.0.0'), 0); +}); + +test('baselineTag ranks a stable release above its own prerelease whatever git sorts first', () => { + const exec = (tags) => (args) => (args[1] === '--points-at' ? '' : tags); + // git's version sort lists the longer name first, i.e. the prerelease above the stable tag. + assert.equal(baselineTag(exec('v1.0.0-alpha.1\nv1.0.0\nv0.5.5\n')), 'v1.0.0'); + assert.equal(baselineTag(exec('v1.0.0-alpha.2\nv1.0.0-alpha.1\nv0.5.5\n')), 'v1.0.0-alpha.2'); + assert.equal(baselineTag(exec('v1.0.0-alpha.1\nv0.5.5\n')), 'v1.0.0-alpha.1'); +}); + +test('baselineTag at publish time of a prerelease steps back to the previous release', () => { + const exec = (args) => (args[1] === '--points-at' ? 'v1.0.0-alpha.1\n' : 'v1.0.0-alpha.1\nv0.5.5\n'); + assert.equal(baselineTag(exec), 'v0.5.5'); +}); + +const withPrerelease = `# Changelog + +## [Unreleased] + +## [1.0.0-alpha.1] - 2026-11-01 + +### Added + +- Alpha thing + +## [0.5.5] - 2026-10-21 + +### Fixed + +- Older thing + +[Unreleased]: https://example.com/compare/v1.0.0-alpha.1...HEAD +[1.0.0-alpha.1]: https://example.com/compare/v0.5.5...v1.0.0-alpha.1 +[0.5.5]: https://example.com/compare/v0.5.4...v0.5.5 +`; + +test('a prerelease section above its predecessor is in order, and below its own stable release is not', () => { + assert.deepEqual(sectionOrder(withPrerelease), []); + const wrong = withPrerelease.replace('## [1.0.0-alpha.1] - 2026-11-01', '## [1.0.0-alpha.1] - 2026-11-01').replace( + '## [0.5.5] - 2026-10-21', + '## [1.0.0] - 2026-12-01\n\n### Added\n\n- Stable\n\n## [0.5.5] - 2026-10-21', + ); + // 1.0.0 now sits below 1.0.0-alpha.1: out of order. + assert.match(sectionOrder(wrong).join('\n'), /out of order/); +}); + +test('prerelease link definitions are part of the trailing link block, not the oldest section', () => { + const sections = parseSections(withPrerelease); + assert.deepEqual(sections.map((s) => s.version), ['Unreleased', '1.0.0-alpha.1', '0.5.5']); + assert.doesNotMatch(sections.at(-1).body, /example\.com/); + assert.deepEqual(duplicateLinkLabels(withPrerelease), []); + const doubled = `${withPrerelease}[1.0.0-alpha.1]: https://example.com/other\n`; + assert.match(duplicateLinkLabels(doubled).join('\n'), /1\.0\.0-alpha\.1/); +}); + +test('a changelog gaining a prerelease section passes the immutability check', () => { + const before = withPrerelease + .replace(/## \[1\.0\.0-alpha\.1\][\s\S]*?(?=## \[0\.5\.5\])/, '') + .replace(/\[1\.0\.0-alpha\.1\]:.*\n/, ''); + assert.deepEqual(check(before, withPrerelease), []); +}); diff --git a/tests/automation/release-version.node-test.mjs b/tests/automation/release-version.node-test.mjs new file mode 100644 index 0000000..3a69486 --- /dev/null +++ b/tests/automation/release-version.node-test.mjs @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict'; +import { execFileSync, spawnSync } from 'node:child_process'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { assertStable, classify, nightlyBase } from '../../scripts/release-version.mjs'; + +const script = fileURLToPath(new URL('../../scripts/release-version.mjs', import.meta.url)); +const cli = (...args) => spawnSync(process.execPath, [script, ...args], { encoding: 'utf8' }); + +test('a stable version publishes to latest', () => { + assert.deepEqual(classify('0.5.6'), { prerelease: false, npmTag: 'latest' }); + assert.deepEqual(classify('1.0.0'), { prerelease: false, npmTag: 'latest' }); +}); + +test('a prerelease publishes under its leading identifier and never to latest', () => { + assert.deepEqual(classify('1.0.0-alpha.1'), { prerelease: true, npmTag: 'alpha' }); + assert.deepEqual(classify('1.0.0-alpha1'), { prerelease: true, npmTag: 'alpha' }); + assert.deepEqual(classify('1.0.0-beta.3'), { prerelease: true, npmTag: 'beta' }); + assert.deepEqual(classify('1.0.0-rc.1'), { prerelease: true, npmTag: 'rc' }); + assert.deepEqual(classify('1.0.0-1'), { prerelease: true, npmTag: 'next' }); +}); + +test('a prerelease may not borrow a reserved dist-tag', () => { + assert.throws(() => classify('1.0.0-latest.1'), /reserved/); + assert.throws(() => classify('1.0.0-nightly.1'), /reserved/); +}); + +test('anything that is not X.Y.Z[-prerelease] is rejected', () => { + for (const bad of ['', 'v1.0.0', '1.0', '1.0.0.0', '1.0.0-', '1.0.0+build', '1.0.0-alpha..1', 'latest', '1.0.0; rm -rf /']) { + assert.throws(() => classify(bad), /not a valid version/, JSON.stringify(bad)); + } +}); + +test('nightly versions build on the next patch of a stable version and the core of a prerelease', () => { + assert.equal(nightlyBase('0.5.5'), '0.5.6'); + assert.equal(nightlyBase('1.0.9'), '1.0.10'); + assert.equal(nightlyBase('1.0.0-alpha.1'), '1.0.0'); + assert.equal(nightlyBase('1.0.0-alpha1'), '1.0.0'); + // The regression this guards: Number('0-alpha1') is NaN and produced 1.0.NaN-nightly.. + assert.doesNotMatch(nightlyBase('1.0.0-alpha1'), /NaN/); +}); + +test('automated security releases refuse to run over a prerelease', () => { + assert.doesNotThrow(() => assertStable('0.5.5')); + assert.throws(() => assertStable('1.0.0-alpha.1'), /prerelease/); +}); + +test('the CLI prints GITHUB_OUTPUT lines and fails with an annotation on bad input', () => { + assert.equal(execFileSync(process.execPath, [script, 'classify', '1.0.0-alpha.1'], { encoding: 'utf8' }), 'prerelease=true\nnpm_tag=alpha\n'); + assert.equal(execFileSync(process.execPath, [script, 'classify', '0.5.6'], { encoding: 'utf8' }), 'prerelease=false\nnpm_tag=latest\n'); + assert.equal(execFileSync(process.execPath, [script, 'nightly-base', '0.5.5'], { encoding: 'utf8' }), '0.5.6\n'); + + const bad = cli('classify', 'not-a-version'); + assert.equal(bad.status, 1); + assert.match(bad.stderr, /^::error::/); + assert.equal(cli('assert-stable', '1.0.0-alpha.1').status, 1); + assert.equal(cli('assert-stable', '0.5.5').status, 0); + assert.equal(cli('classify').status, 1); + assert.equal(cli('bogus', '1.0.0').status, 1); +}); From a1f49bdd702d0805b9c857a8eeee58412ebe1770 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 09:31:24 +0000 Subject: [PATCH 2/3] test: drop a no-op replace in the prerelease ordering test CodeQL (js/identity-replacement) flagged replacing the alpha.1 header with itself; the test only needs the 1.0.0 section inserted below it. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012ESxuEjCeVsRAAxAb29Yjr --- tests/automation/changelog-guard.node-test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/automation/changelog-guard.node-test.mjs b/tests/automation/changelog-guard.node-test.mjs index 17c242a..dfca164 100644 --- a/tests/automation/changelog-guard.node-test.mjs +++ b/tests/automation/changelog-guard.node-test.mjs @@ -340,7 +340,7 @@ const withPrerelease = `# Changelog test('a prerelease section above its predecessor is in order, and below its own stable release is not', () => { assert.deepEqual(sectionOrder(withPrerelease), []); - const wrong = withPrerelease.replace('## [1.0.0-alpha.1] - 2026-11-01', '## [1.0.0-alpha.1] - 2026-11-01').replace( + const wrong = withPrerelease.replace( '## [0.5.5] - 2026-10-21', '## [1.0.0] - 2026-12-01\n\n### Added\n\n- Stable\n\n## [0.5.5] - 2026-10-21', ); From 78d58e2610487ae85884706b318f45a0bbf23084 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 10:48:57 +0000 Subject: [PATCH 3/3] ci(release): one next channel, prerelease stages limited to alpha/beta/rc Prereleases must be X.Y.Z-(alpha|beta|rc).N and all publish to a single next channel (npm dist-tag, Docker :next) instead of one tag per stage that goes stale. next follows the newest tagged release of any kind: a stable release that is the newest tag moves it too, it never moves backwards, and a prerelease older than an existing tag is refused. Moving the npm dist-tag after a stable publish is continue-on-error and prints the manual command, since it needs an opt-in permission. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012ESxuEjCeVsRAAxAb29Yjr --- .github/workflows/publish.yml | 48 +++++++++-- AGENTS.md | 17 ++-- CHANGELOG.md | 2 +- scripts/release-version.mjs | 66 +++++++++++---- .../automation/release-version.node-test.mjs | 82 +++++++++++++++---- 5 files changed, 164 insertions(+), 51 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 42b2b51..2fb71a5 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -36,9 +36,12 @@ jobs: should_publish: ${{ steps.resolve.outputs.should_publish }} version: ${{ steps.resolve.outputs.version }} npm_tag: ${{ steps.resolve.outputs.npm_tag }} - # 'true' for a tag with a prerelease suffix (v1.0.0-alpha.1): it publishes under its own - # npm dist-tag, never moves `latest`, and the GitHub Release is marked a pre-release. + # 'true' for a prerelease tag (v1.0.0-alpha.1, -beta.N, -rc.N): it publishes to the npm + # `next` dist-tag, never moves `latest`, and the GitHub Release is marked a pre-release. prerelease: ${{ steps.resolve.outputs.prerelease }} + # 'true' when `next` (npm dist-tag and Docker tag) should point at this release: every + # prerelease, and a stable release only when it is newer than every existing tag. + update_next: ${{ steps.resolve.outputs.update_next }} steps: - uses: actions/checkout@v7 with: @@ -56,10 +59,21 @@ jobs: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" if [ "$EVENT" = "push" ]; then # Tag push -> release. Version comes from the tag. A stable tag publishes to npm - # 'latest'; a prerelease tag (v1.0.0-alpha.1) publishes under its own dist-tag - # ('alpha') and leaves `latest` alone. classify also rejects a tag that is not semver. + # 'latest'; a prerelease tag (v1.0.0-alpha.1, -beta.N, -rc.N) publishes to 'next' and + # leaves `latest` alone. classify rejects a tag that is not semver or not one of those + # three prerelease forms, before anything is written to the outputs. VERSION="${GITHUB_REF_NAME#v}" - node scripts/release-version.mjs classify "$VERSION" >> "$GITHUB_OUTPUT" + CLASS=$(node scripts/release-version.mjs classify "$VERSION") + printf '%s\n' "$CLASS" >> "$GITHUB_OUTPUT" + # `next` is the newest tagged release of any kind, so it only moves forward: a stable + # release moves it only when nothing newer is tagged (a 0.5.7 hotfix must not pull it + # back from 1.0.0-rc.1), and a prerelease older than an existing tag is refused. + NEWEST=$(node scripts/release-version.mjs is-newest "$VERSION" $(git tag --list 'v*')) + if [ "$NEWEST" != "true" ] && printf '%s\n' "$CLASS" | grep -qx 'prerelease=true'; then + echo "::error::v${VERSION} is a prerelease older than an existing tag; refusing to publish it to 'next'." + exit 1 + fi + echo "update_next=$NEWEST" >> "$GITHUB_OUTPUT" echo "channel=release" >> "$GITHUB_OUTPUT" echo "should_publish=true" >> "$GITHUB_OUTPUT" echo "version=$VERSION" >> "$GITHUB_OUTPUT" @@ -73,6 +87,7 @@ jobs: echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "npm_tag=nightly" >> "$GITHUB_OUTPUT" echo "prerelease=false" >> "$GITHUB_OUTPUT" + echo "update_next=false" >> "$GITHUB_OUTPUT" echo "Nightly channel: $VERSION" # Change-guard: publish only if HEAD differs from the rolling nightly tag. HEAD_SHA=$(git rev-parse HEAD) @@ -208,6 +223,22 @@ jobs: npm publish --provenance --tag "$NPM_TAG" fi + # A prerelease already published to `next` above. A stable release publishes to `latest`, so + # `next` has to be pointed at it separately, or `@next` would trail `@latest`. Moving a + # dist-tag needs the "Allow npm dist-tag" permission on the package's trusted-publisher + # config (or a token). When that is missing the release is still good, so this step must not + # fail the run: it prints the exact command to run by hand instead. + - name: Point the next dist-tag at this stable release + if: needs.setup.outputs.channel == 'release' && needs.setup.outputs.npm_tag == 'latest' && needs.setup.outputs.update_next == 'true' + continue-on-error: true + env: + VERSION: ${{ needs.setup.outputs.version }} + run: | + if ! npm dist-tag add "@daften/fireflyiii-mcp@${VERSION}" next; then + echo "::warning::Could not move the npm 'next' dist-tag. Run: npm dist-tag add @daften/fireflyiii-mcp@${VERSION} next" + exit 1 + fi + publish-docker: needs: [setup, verify] runs-on: ubuntu-latest @@ -240,8 +271,9 @@ jobs: # Release pushes semver + latest; nightly pushes only nightly tags. The # `enable` flag selects per channel so neither leaks into the other. A prerelease release - # pushes only its exact version and its dist-tag (:alpha): `latest`, `1` and `1.0` keep - # pointing at the newest stable image. + # pushes only its exact version and `:next`: `latest`, `1` and `1.0` keep pointing at the + # newest stable image. `:next` follows the newest tagged release of any kind, so a stable + # release that is the newest tag moves it too. - name: Docker tags + labels id: meta uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 @@ -252,7 +284,7 @@ jobs: type=semver,pattern={{major}}.{{minor}},enable=${{ needs.setup.outputs.channel == 'release' && needs.setup.outputs.prerelease != 'true' }} type=semver,pattern={{major}},enable=${{ needs.setup.outputs.channel == 'release' && needs.setup.outputs.prerelease != 'true' }} type=raw,value=latest,enable=${{ needs.setup.outputs.channel == 'release' && needs.setup.outputs.prerelease != 'true' }} - type=raw,value=${{ needs.setup.outputs.npm_tag }},enable=${{ needs.setup.outputs.channel == 'release' && needs.setup.outputs.prerelease == 'true' }} + type=raw,value=next,enable=${{ needs.setup.outputs.channel == 'release' && needs.setup.outputs.update_next == 'true' }} type=raw,value=nightly,enable=${{ needs.setup.outputs.channel == 'nightly' }} type=raw,value=nightly-{{date 'YYYYMMDD'}},enable=${{ needs.setup.outputs.channel == 'nightly' }} diff --git a/AGENTS.md b/AGENTS.md index 457e3ed..c0ebd0d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -534,15 +534,18 @@ A manual hotfix (a fix merged straight to `main`, typically a dependency CVE Dep ### Prerelease releases (alpha / beta / rc) -A prerelease ships through the normal `release/X.Y.Z-alpha.N` → `main` PR and tag flow above; only the version string and what the tag publishes differ. `scripts/release-version.mjs` is the single place that classifies a version, and `publish.yml`, `auto-release.yml` and `changelog-guard.mjs` all go through it or share its rules. - -- **Name it `X.Y.Z-alpha.N`, with a dot before the number.** Semver compares `alpha.2 < alpha.10` numerically, but the single identifier `alpha10` sorts *before* `alpha2`. Tag it `vX.Y.Z-alpha.N`; the changelog section is `## [X.Y.Z-alpha.N]` and it gets a link reference like any other. -- **npm:** published under the dist-tag named by the leading identifier (`alpha`, `beta`, `rc`; a purely numeric one is `next`), never `latest`. `latest` and `nightly` are rejected as identifiers. Install with `npm i @daften/fireflyiii-mcp@alpha`. -- **Docker:** only `:X.Y.Z-alpha.N` and `:alpha`. `:latest`, `:X` and `:X.Y` keep pointing at the newest stable image. -- **GitHub Release:** marked a pre-release and never the repository's "latest release". +A prerelease ships through the normal `release/X.Y.Z-alpha.N` → `main` PR and tag flow above; only the version string and what the tag publishes differ. `scripts/release-version.mjs` is the single place that decides what a version means, and `publish.yml`, `auto-release.yml` and `changelog-guard.mjs` all go through it or share its rules. + +- **The vocabulary is closed: `X.Y.Z-alpha.N`, `X.Y.Z-beta.N`, `X.Y.Z-rc.N`, with a dot and a number without leading zeros.** Anything else (`-alpha1`, `-dev.1`, `-foo.2`, `-1`) fails the tag push before anything is published. The dot matters: semver compares `alpha.2 < alpha.10` numerically, but the single identifier `alpha10` sorts *before* `alpha2`. The three stages sort in maturity order on their own (`alpha < beta < rc < stable`). Tag it `vX.Y.Z-alpha.N`; the changelog section is `## [X.Y.Z-alpha.N]` and it gets a link reference like any other. +- **One prerelease channel, `next`, not one per stage.** `next` is "the newest tagged release of any kind". A prerelease moves it, and so does a stable release that is newer than every existing tag, so `@next` never trails `@latest`. + - npm: a prerelease publishes with `--tag next` and never touches `latest`; install with `npm i @daften/fireflyiii-mcp@next`. + - Docker: a prerelease pushes `:X.Y.Z-alpha.N` and `:next`; `:latest`, `:X` and `:X.Y` keep pointing at the newest stable image. A stable release that is the newest tag also pushes `:next`. + - GitHub Release: marked a pre-release and never the repository's "latest release". +- **`next` only moves forward.** `is-newest` compares the tag being released with every existing `v*` tag. A stable hotfix on an old line (`0.5.7` while `1.0.0-rc.1` exists) leaves `next` alone, and a prerelease older than an existing tag (`1.0.0-rc.2` after `1.0.0`) is refused outright. +- **Moving `next` on a stable release is a separate npm call** (`npm dist-tag add`), which trusted publishing only performs when the package's trusted-publisher config has the "Allow npm dist-tag" permission enabled. The step is `continue-on-error`: the release stays good if it is missing, and the run prints the command to run by hand: `npm dist-tag add @daften/fireflyiii-mcp@X.Y.Z next`. - **`auto-release.yml` refuses to run while `package.json` is a prerelease** (`npm version patch` on `1.0.0-alpha.1` yields the stable `1.0.0`, so an unattended security release would publish it by accident). The workflow fails loudly on the Dependabot merge; cut that release by hand, as the next prerelease or as the stable version. - **Graduating:** the next release branch sets `version` to `X.Y.Z` and adds `## [X.Y.Z]`. The prerelease sections stay below it, immutable like any other. -- **Nightlies** keep working: on a prerelease `package.json` the nightly version is `X.Y.Z-nightly.`, which sorts after every `X.Y.Z-alpha.*` and before `X.Y.Z`. +- **Nightlies** are a different channel (`develop`, never tagged) and keep working: on a prerelease `package.json` the nightly version is `X.Y.Z-nightly.`, which sorts after every `X.Y.Z-alpha.*` and before `X.Y.Z`. - `changelog-guard` orders sections and picks its baseline tag by semver precedence (`1.0.0-alpha.1 < 1.0.0-rc.1 < 1.0.0`) rather than git's version sort, which ranks `v1.0.0-alpha.1` *above* `v1.0.0`. ### Nightly builds diff --git a/CHANGELOG.md b/CHANGELOG.md index da65a89..a706c32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- The release pipeline understands prerelease versions such as `1.0.0-alpha.1`. A prerelease tag publishes to npm under its own dist-tag (`alpha`, `beta`, `rc`) instead of `latest`, pushes only `:X.Y.Z-alpha.1` and `:alpha` to GHCR (so `:latest`, `:X` and `:X.Y` stay on the newest stable image), and creates a GitHub pre-release. `auto-release.yml` now fails instead of bumping the patch version of a prerelease (which would have published the stable release), the nightly version no longer becomes `…NaN…` on a prerelease `package.json`, and `changelog-guard` orders sections and chooses its baseline tag by semver precedence. Nothing changes for stable releases. +- The release pipeline understands prerelease versions. A prerelease tag must be `X.Y.Z-alpha.N`, `-beta.N` or `-rc.N` (anything else fails the tag push). It publishes to the npm `next` dist-tag instead of `latest`, pushes only `:X.Y.Z-alpha.1` and `:next` to GHCR (so `:latest`, `:X` and `:X.Y` stay on the newest stable image), and creates a GitHub pre-release. `next` is the newest tagged release of any kind: a stable release that is newer than every existing tag moves it too, and it never moves backwards. `auto-release.yml` now fails instead of bumping the patch version of a prerelease (which would have published the stable release), the nightly version no longer becomes `…NaN…` on a prerelease `package.json`, and `changelog-guard` orders sections and chooses its baseline tag by semver precedence. Nothing changes for stable releases except that `next` follows them. - Deduped six maintainability items flagged in the v0.4.6..develop pre-release review (#103): `get_transactions`' account-scoped delegation and `get_account_transactions` now share one query-builder helper; `create_account` and `update_account` now share one `liabilityFields()` schema factory instead of repeating the six-field liability block; the OAuth pending-flow expiry check in `src/http.ts` is defined once and used by both the periodic sweep and the read-time check; `GROUP_ID_HINT` and `groupIdField` in `transactions.ts` now derive from one shared warning fragment instead of two independently-worded copies; and `exports.ts`'s dated/undated tool variants are now registered through a single `defineTool` call instead of two near-identical branches. No behavior change to any tool's request/response shape or validation. - **Breaking:** `update_budget_limit` and `delete_budget_limit` take the limit's `budget_id`, since Firefly III only routes budget-limit writes under their budget. ([#117](https://github.com/daften/fireflyiii-mcp/issues/117)) - **Breaking:** `create_piggy_bank` takes `accounts` (or the `account_id` shortcut), `target_amount` and `currency_code`, matching what Firefly III has required since v6.2.0; `start_date` defaults to today. `update_piggy_bank` replaces its ignored `account_id` with `accounts`, merged over the existing links so accounts you don't mention keep their link and saved amount (Firefly III itself would unlink them). Set `accounts[].current_amount` to put money in or take it out. ([#117](https://github.com/daften/fireflyiii-mcp/issues/117)) diff --git a/scripts/release-version.mjs b/scripts/release-version.mjs index 13fd9f9..f8fbcd0 100644 --- a/scripts/release-version.mjs +++ b/scripts/release-version.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node /** - * Version classification for the release workflows. + * Version policy for the release workflows. * * `publish.yml` and `auto-release.yml` used to assume every version is a plain `X.Y.Z`: a `v*` tag * always published to npm `latest`, the nightly version came from `split('.').map(Number)` (which @@ -8,17 +8,27 @@ * the stable release. This module is the one place that decides what a version means, so the * workflows share the decision and the tests can exercise it. * - * node scripts/release-version.mjs classify -> prerelease= / npm_tag= - * node scripts/release-version.mjs nightly-base -> X.Y.Z the nightly version builds on - * node scripts/release-version.mjs assert-stable -> exit 1 for a prerelease + * Release channels: + * - stable `X.Y.Z` npm `latest`, Docker `latest` + `X.Y.Z` + `X.Y` + `X` + * - prerelease `X.Y.Z-(alpha|beta|rc).N` npm `next`, Docker `X.Y.Z-pre.N` + `next` + * - `next` is "the newest tagged release of any kind": a prerelease moves it, and so does a stable + * release that is newer than everything already tagged, so it never trails `latest`. + * - nightly built in CI from `develop`, never tagged, never goes through `classify`. + * + * node scripts/release-version.mjs classify -> prerelease= / npm_tag= + * node scripts/release-version.mjs is-newest [tag...] -> true|false + * node scripts/release-version.mjs nightly-base -> X.Y.Z the nightly version builds on + * node scripts/release-version.mjs assert-stable -> exit 1 for a prerelease */ import { pathToFileURL } from 'node:url'; +import { compareVersions } from './changelog-guard.mjs'; const SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/; -// `latest` is what a plain install resolves and `nightly` belongs to the nightly channel; a -// prerelease named after either would publish over it. -const RESERVED_NPM_TAGS = new Set(['latest', 'nightly']); +// Semver allows any prerelease identifier; the project does not. Three stages, each followed by a +// number with no leading zero. They sort in maturity order (alpha < beta < rc) under semver's +// lexical rule for alphanumeric identifiers, which is what makes the ordering work without help. +const PRERELEASE = /^(alpha|beta|rc)\.(0|[1-9][0-9]*)$/; function parse(version) { const match = SEMVER.exec(version); @@ -29,20 +39,38 @@ function parse(version) { return { major: Number(major), minor: Number(minor), patch: Number(patch), prerelease: prerelease ?? null }; } +/** True for any semver prerelease, including ones the release policy would refuse to publish. */ +export function isPrerelease(version) { + return parse(version).prerelease !== null; +} + /** - * Release-channel facts for a version being tagged. A stable version publishes to `latest`; a - * prerelease publishes under the dist-tag named by its leading identifier (`1.0.0-alpha.1` and - * `1.0.0-alpha1` both -> `alpha`; a purely numeric one -> `next`) and never to `latest`. + * Release-channel facts for a version being tagged. A stable version publishes to `latest`. A + * prerelease must be `alpha.N`, `beta.N` or `rc.N` and publishes to `next`, never `latest`. */ export function classify(version) { const { prerelease } = parse(version); if (prerelease === null) return { prerelease: false, npmTag: 'latest' }; - const lead = /^[A-Za-z][A-Za-z-]*/.exec(prerelease); - const npmTag = lead ? lead[0] : 'next'; - if (RESERVED_NPM_TAGS.has(npmTag)) { - throw new Error(`The prerelease identifier '${npmTag}' in '${version}' is reserved; pick another (alpha, beta, rc).`); + if (!PRERELEASE.test(prerelease)) { + throw new Error( + `The prerelease '${prerelease}' in '${version}' is not allowed. Use alpha.N, beta.N or rc.N ` + + '(for example 1.0.0-alpha.1), so the stages sort in maturity order.', + ); } - return { prerelease: true, npmTag }; + return { prerelease: true, npmTag: 'next' }; +} + +/** + * True when `version` has higher semver precedence than every tag in `tags` (`vX.Y.Z[-pre]`; the + * tag for `version` itself and anything that is not a version tag are ignored). The `next` channel + * only ever moves forward, so a stable hotfix on an old line cannot pull it back from `1.0.0-rc.1`. + */ +export function isNewest(version, tags) { + parse(version); + return tags + .map((tag) => tag.replace(/^v/, '')) + .filter((other) => SEMVER.test(other) && other !== version) + .every((other) => compareVersions(version, other) > 0); } /** @@ -58,7 +86,7 @@ export function nightlyBase(version) { /** `npm version patch` on a prerelease yields the stable release, so automation must not run on one. */ export function assertStable(version) { - if (classify(version).prerelease) { + if (isPrerelease(version)) { throw new Error( `package.json is at the prerelease ${version}. Automated security releases bump the patch ` + 'version, which for a prerelease means publishing the stable release by accident. Cut this ' + @@ -68,13 +96,15 @@ export function assertStable(version) { } function main(argv) { - const [command, version] = argv; + const [command, version, ...rest] = argv; try { - if (!version) throw new Error(`usage: release-version.mjs `); + if (!version) throw new Error('usage: release-version.mjs [tag...]'); if (command === 'classify') { const { prerelease, npmTag } = classify(version); console.log(`prerelease=${prerelease}`); console.log(`npm_tag=${npmTag}`); + } else if (command === 'is-newest') { + console.log(String(isNewest(version, rest))); } else if (command === 'nightly-base') { console.log(nightlyBase(version)); } else if (command === 'assert-stable') { diff --git a/tests/automation/release-version.node-test.mjs b/tests/automation/release-version.node-test.mjs index 3a69486..52c66f0 100644 --- a/tests/automation/release-version.node-test.mjs +++ b/tests/automation/release-version.node-test.mjs @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import { execFileSync, spawnSync } from 'node:child_process'; import test from 'node:test'; import { fileURLToPath } from 'node:url'; -import { assertStable, classify, nightlyBase } from '../../scripts/release-version.mjs'; +import { assertStable, classify, isNewest, isPrerelease, nightlyBase } from '../../scripts/release-version.mjs'; const script = fileURLToPath(new URL('../../scripts/release-version.mjs', import.meta.url)); const cli = (...args) => spawnSync(process.execPath, [script, ...args], { encoding: 'utf8' }); @@ -12,17 +12,34 @@ test('a stable version publishes to latest', () => { assert.deepEqual(classify('1.0.0'), { prerelease: false, npmTag: 'latest' }); }); -test('a prerelease publishes under its leading identifier and never to latest', () => { - assert.deepEqual(classify('1.0.0-alpha.1'), { prerelease: true, npmTag: 'alpha' }); - assert.deepEqual(classify('1.0.0-alpha1'), { prerelease: true, npmTag: 'alpha' }); - assert.deepEqual(classify('1.0.0-beta.3'), { prerelease: true, npmTag: 'beta' }); - assert.deepEqual(classify('1.0.0-rc.1'), { prerelease: true, npmTag: 'rc' }); - assert.deepEqual(classify('1.0.0-1'), { prerelease: true, npmTag: 'next' }); +test('alpha, beta and rc prereleases all publish to next, never latest', () => { + for (const version of ['1.0.0-alpha.1', '1.0.0-beta.2', '1.0.0-rc.10', '0.6.0-alpha.0']) { + assert.deepEqual(classify(version), { prerelease: true, npmTag: 'next' }, version); + } +}); + +test('the prerelease vocabulary is closed: only alpha.N, beta.N and rc.N', () => { + const refused = [ + '1.0.0-alpha', // no number + '1.0.0-alpha1', // number glued to the stage; sorts alpha10 < alpha2 + '1.0.0-beta', '1.0.0-rc', + '1.0.0-foo.1', '1.0.0-next.1', '1.0.0-latest.1', '1.0.0-nightly.1', '1.0.0-dev.1', '1.0.0-canary.1', + '1.0.0-Alpha.1', '1.0.0-RC.1', // case matters: the sort order depends on it + '1.0.0-1', // purely numeric + '1.0.0-alpha.01', // leading zero is not valid semver for a numeric identifier + '1.0.0-alpha.1.2', '1.0.0-alpha.x', '1.0.0-alpha.1-hotfix', + ]; + for (const version of refused) { + assert.throws(() => classify(version), /not allowed/, version); + } }); -test('a prerelease may not borrow a reserved dist-tag', () => { - assert.throws(() => classify('1.0.0-latest.1'), /reserved/); - assert.throws(() => classify('1.0.0-nightly.1'), /reserved/); +test('alpha < beta < rc < stable by semver precedence, so the stages need no special handling', async () => { + const { compareVersions } = await import('../../scripts/changelog-guard.mjs'); + const ascending = ['1.0.0-alpha.1', '1.0.0-alpha.2', '1.0.0-alpha.10', '1.0.0-beta.1', '1.0.0-rc.1', '1.0.0-rc.2', '1.0.0']; + for (let i = 1; i < ascending.length; i += 1) { + assert.ok(compareVersions(ascending[i], ascending[i - 1]) > 0, `${ascending[i]} > ${ascending[i - 1]}`); + } }); test('anything that is not X.Y.Z[-prerelease] is rejected', () => { @@ -31,28 +48,59 @@ test('anything that is not X.Y.Z[-prerelease] is rejected', () => { } }); +test('isNewest: next only ever moves forward', () => { + const tags = ['v0.5.4', 'v0.5.5', 'v1.0.0-alpha.1', 'v1.0.0-beta.1']; + assert.equal(isNewest('1.0.0-rc.1', tags), true); + assert.equal(isNewest('1.0.0-beta.2', tags), true); + assert.equal(isNewest('1.0.0', tags), true); // stable release newer than its own prereleases + assert.equal(isNewest('0.6.0', tags), false); // older than the alpha line already tagged + assert.equal(isNewest('0.5.7', tags), false); // hotfix must not pull next back from 1.0.0-beta.1 + assert.equal(isNewest('1.0.0-alpha.0', tags), false); +}); + +test('isNewest: the tag being released does not count against itself, and non-version tags are ignored', () => { + assert.equal(isNewest('1.0.0-alpha.1', ['v0.5.5', 'v1.0.0-alpha.1']), true); + assert.equal(isNewest('1.0.0', ['v1.0.0-rc.1', 'v1.0.0', 'nightly', 'v', 'vfoo', 'latest']), true); + assert.equal(isNewest('0.5.6', []), true); // first release ever + assert.equal(isNewest('1.0.0', ['v1.0.1']), false); +}); + +test('isNewest: after a stable 1.0.0, a stable 1.0.1 and a new prerelease line both move next', () => { + const tags = ['v0.5.5', 'v1.0.0-rc.1', 'v1.0.0']; + assert.equal(isNewest('1.0.1', tags), true); + assert.equal(isNewest('1.1.0-alpha.1', tags), true); + assert.equal(isNewest('1.0.0-rc.2', tags), false); // older than the stable it leads up to +}); + test('nightly versions build on the next patch of a stable version and the core of a prerelease', () => { assert.equal(nightlyBase('0.5.5'), '0.5.6'); assert.equal(nightlyBase('1.0.9'), '1.0.10'); assert.equal(nightlyBase('1.0.0-alpha.1'), '1.0.0'); - assert.equal(nightlyBase('1.0.0-alpha1'), '1.0.0'); // The regression this guards: Number('0-alpha1') is NaN and produced 1.0.NaN-nightly.. - assert.doesNotMatch(nightlyBase('1.0.0-alpha1'), /NaN/); + assert.doesNotMatch(nightlyBase('1.0.0-alpha.1'), /NaN/); }); -test('automated security releases refuse to run over a prerelease', () => { +test('automated security releases refuse to run over any prerelease', () => { assert.doesNotThrow(() => assertStable('0.5.5')); assert.throws(() => assertStable('1.0.0-alpha.1'), /prerelease/); + // Also a prerelease the release policy would not publish: it is still not a stable version. + assert.throws(() => assertStable('1.0.0-foo.1'), /prerelease/); + assert.equal(isPrerelease('1.0.0-foo.1'), true); + assert.equal(isPrerelease('1.0.0'), false); }); test('the CLI prints GITHUB_OUTPUT lines and fails with an annotation on bad input', () => { - assert.equal(execFileSync(process.execPath, [script, 'classify', '1.0.0-alpha.1'], { encoding: 'utf8' }), 'prerelease=true\nnpm_tag=alpha\n'); - assert.equal(execFileSync(process.execPath, [script, 'classify', '0.5.6'], { encoding: 'utf8' }), 'prerelease=false\nnpm_tag=latest\n'); - assert.equal(execFileSync(process.execPath, [script, 'nightly-base', '0.5.5'], { encoding: 'utf8' }), '0.5.6\n'); + const run = (...args) => execFileSync(process.execPath, [script, ...args], { encoding: 'utf8' }); + assert.equal(run('classify', '1.0.0-alpha.1'), 'prerelease=true\nnpm_tag=next\n'); + assert.equal(run('classify', '0.5.6'), 'prerelease=false\nnpm_tag=latest\n'); + assert.equal(run('nightly-base', '0.5.5'), '0.5.6\n'); + assert.equal(run('is-newest', '1.0.0', 'v0.5.5', 'v1.0.0-rc.1', 'v1.0.0'), 'true\n'); + assert.equal(run('is-newest', '0.5.7', 'v0.5.5', 'v1.0.0-rc.1'), 'false\n'); - const bad = cli('classify', 'not-a-version'); + const bad = cli('classify', '1.0.0-foo.1'); assert.equal(bad.status, 1); assert.match(bad.stderr, /^::error::/); + assert.equal(cli('classify', 'not-a-version').status, 1); assert.equal(cli('assert-stable', '1.0.0-alpha.1').status, 1); assert.equal(cli('assert-stable', '0.5.5').status, 0); assert.equal(cli('classify').status, 1);