Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
59 changes: 52 additions & 7 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +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 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:
Expand All @@ -52,21 +58,36 @@ 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, -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}"
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"
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 "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)
Expand Down Expand Up @@ -202,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
Expand Down Expand Up @@ -233,17 +270,21 @@ 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 `: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
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=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' }}

Expand Down Expand Up @@ -304,6 +345,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
16 changes: 16 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -532,6 +532,22 @@ 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 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** are a different channel (`develop`, never tagged) and 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. 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))
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
Loading
Loading