Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
6 changes: 6 additions & 0 deletions .github/workflows/auto-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 20 additions & 7 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)
Expand Down Expand Up @@ -233,17 +239,20 @@ 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
with:
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' }}

Expand Down Expand Up @@ -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'
Expand Down
13 changes: 13 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<stamp>`, 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:
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
# Changelog

All notable changes to this project will be documented in this file.
Expand All @@ -18,6 +18,7 @@

### 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))
Expand Down
65 changes: 56 additions & 9 deletions scripts/changelog-guard.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}

Expand Down
93 changes: 93 additions & 0 deletions scripts/release-version.mjs
Original file line number Diff line number Diff line change
@@ -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 <version> -> prerelease=<bool> / npm_tag=<tag>
* node scripts/release-version.mjs nightly-base <version> -> X.Y.Z the nightly version builds on
* node scripts/release-version.mjs assert-stable <version> -> 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 (`<base>-nightly.<stamp>`) 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.<stamp>` 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 <classify|nightly-base|assert-stable> <version>`);
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));
}
Loading
Loading