split release into two branches (#7844)

* split release into two branches

* update docs to match the new process

* note

* zizmor comment

* Update check-spelling metadata

* use single long lived release branch

* coderabbit

* jfdoming suggestions
This commit is contained in:
Matt Fiddaman
2026-05-19 13:47:08 +01:00
committed by GitHub
parent 132b9db11c
commit ecfe43fdda
8 changed files with 206 additions and 111 deletions

View File

@@ -11,6 +11,7 @@ ANZ
aql
AUR
Authentik
autogen
AVERAGEA
BANKA
BANKINTER

View File

@@ -1,6 +1,17 @@
name: Generate release notes
description: Generate release documentation from release note files
inputs:
release-branch:
description: 'The release/X.Y.Z branch to read release notes from'
required: true
notes-branch:
description: 'The release-notes/X.Y.Z branch to write generated docs to'
required: true
version:
description: 'The release version (e.g. 26.5.0)'
required: true
runs:
using: composite
steps:
@@ -14,4 +25,7 @@ runs:
shell: bash
env:
GITHUB_TOKEN: ${{ github.token }}
RELEASE_BRANCH: ${{ inputs.release-branch }}
NOTES_BRANCH: ${{ inputs.notes-branch }}
VERSION: ${{ inputs.version }}
run: node packages/ci-actions/bin/release-notes-generate.mjs

View File

@@ -6,10 +6,6 @@ on:
- cron: '0 17 25 * *'
workflow_dispatch:
inputs:
ref:
description: 'Commit or branch to release'
required: true
default: 'master'
version:
description: 'Version number for the release (optional)'
required: false
@@ -31,8 +27,9 @@ jobs:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.inputs.ref || 'master' }}
persist-credentials: false
ref: master
fetch-depth: 0
token: ${{ secrets.ACTIONS_UPDATE_TOKEN }}
- name: Set up environment
uses: ./.github/actions/setup
@@ -41,41 +38,25 @@ jobs:
cache: 'false'
download-translations: 'false'
- name: Bump package versions
id: bump_package_versions
- name: Determine target version
id: version
shell: bash
env:
INPUT_VERSION: ${{ github.event.inputs.version }}
run: |
declare -A packages=(
[web]="desktop-client"
[electron]="desktop-electron"
[sync]="sync-server"
[api]="api"
[cli]="cli"
[core]="loot-core"
)
declare -A new_versions
args=(--package-json ./packages/desktop-client/package.json --type auto)
if [[ -n "$INPUT_VERSION" ]]; then
args+=(--version "$INPUT_VERSION")
fi
yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts "${args[@]}"
for key in "${!packages[@]}"; do
pkg="${packages[$key]}"
if [[ -n "$INPUT_VERSION" ]]; then
version=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts \
--package-json "./packages/$pkg/package.json" \
--version "$INPUT_VERSION" \
--update)
else
version=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts \
--package-json "./packages/$pkg/package.json" \
--type auto \
--update)
fi
new_versions[$key]="$version"
done
echo "version=${new_versions[web]}" >> "$GITHUB_OUTPUT"
- name: Determine previous tag
id: prev_tag
env:
GH_TOKEN: ${{ secrets.ACTIONS_UPDATE_TOKEN || github.token }}
run: |
prev_tag=$(gh api "repos/${GITHUB_REPOSITORY}/releases/latest" --jq '.tag_name' 2>/dev/null) || prev_tag=""
echo "tag=${prev_tag:-master}" >> "$GITHUB_OUTPUT"
- name: Compute release date
id: release_date
@@ -90,15 +71,72 @@ jobs:
echo "date=$(date -d '+1 month' '+%Y-%m-01')" >> "$GITHUB_OUTPUT"
fi
- name: Create release branch and PR
- name: Validate target version
env:
GH_TOKEN: ${{ secrets.ACTIONS_UPDATE_TOKEN }}
VERSION: ${{ steps.version.outputs.version }}
TYPE: ${{ steps.version.outputs.type }}
shell: bash
run: |
if gh api "repos/${GITHUB_REPOSITORY}/branches/release" --silent >/dev/null 2>&1; then
current_version=$(gh api "repos/${GITHUB_REPOSITORY}/contents/packages/desktop-client/package.json?ref=release" --jq '.content' | base64 -d | jq -r .version)
latest=$(printf '%s\n%s\n' "$VERSION" "$current_version" | sort -V | tail -1)
if [[ "$latest" != "$VERSION" || "$current_version" == "$VERSION" ]]; then
echo "::error::Target version $VERSION is not newer than current release version $current_version"
exit 1
fi
fi
echo "Type: $TYPE (target=$VERSION, current=${current_version:-none})"
- name: Apply version bumps
shell: bash
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
for pkg in desktop-client desktop-electron sync-server api cli loot-core; do
yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts \
--package-json "./packages/$pkg/package.json" \
--version "$VERSION" \
--update
done
- name: Open release-notes branch and PR
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
with:
token: ${{ secrets.ACTIONS_UPDATE_TOKEN }}
commit-message: '🔖 (${{ steps.bump_package_versions.outputs.version }})'
title: '🔖 (${{ steps.bump_package_versions.outputs.version }})'
commit-message: '🔖 (${{ steps.version.outputs.version }})'
title: '🔖 (${{ steps.version.outputs.version }})'
body: |
Generated by [cut-release-branch.yml](../tree/master/.github/workflows/cut-release-branch.yml)
Release branch: [`release`](../compare/${{ steps.prev_tag.outputs.tag }}...release)
<!-- release-date:${{ steps.release_date.outputs.date }} -->
branch: 'release/${{ steps.bump_package_versions.outputs.version }}'
branch: 'release-notes/${{ steps.version.outputs.version }}'
base: master
- name: Update release branch
env:
GH_TOKEN: ${{ secrets.ACTIONS_UPDATE_TOKEN }}
VERSION: ${{ steps.version.outputs.version }}
TYPE: ${{ steps.version.outputs.type }}
shell: bash
run: |
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
if gh api "repos/${GITHUB_REPOSITORY}/branches/release" --silent >/dev/null 2>&1; then
git fetch origin release
git checkout release
if [[ "$TYPE" == "monthly" ]]; then
git fetch origin master
git merge --no-edit -X theirs origin/master
git diff --exit-code origin/master
fi
fi
git fetch origin "release-notes/$VERSION"
git cherry-pick FETCH_HEAD
git push origin HEAD:refs/heads/release

View File

@@ -2,6 +2,9 @@ name: Release notes
on:
pull_request:
push:
branches:
- release
permissions:
contents: write
@@ -12,7 +15,11 @@ concurrency:
cancel-in-progress: true
jobs:
release-notes:
check-release-notes:
if: >-
github.event_name == 'pull_request'
&& github.head_ref != 'release'
&& startsWith(github.head_ref, 'release-notes/') == false
runs-on: ubuntu-latest
environment: pr-automation
steps:
@@ -37,9 +44,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
token: ${{ secrets.ACTIONS_UPDATE_TOKEN || github.token }}
# Need to be able to commit release notes after generation
persist-credentials: true
persist-credentials: false
- name: Get changed files
if: steps.bot-check.outputs.skip != 'true'
@@ -59,13 +64,34 @@ jobs:
- name: Check release notes
if: >-
steps.bot-check.outputs.skip != 'true'
&& startsWith(github.head_ref, 'release/') == false
&& steps.changed-files.outputs.only_docs != 'true'
uses: ./.github/actions/release-notes/check
generate-release-notes:
if: github.event_name == 'push'
runs-on: ubuntu-latest
environment: release
steps:
- name: Resolve version
id: version
env:
GH_TOKEN: ${{ secrets.ACTIONS_UPDATE_TOKEN || github.token }}
run: |
version=$(gh api "repos/${GITHUB_REPOSITORY}/contents/packages/desktop-client/package.json?ref=release" --jq '.content' | base64 -d | jq -r .version)
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "notes_branch=release-notes/$version" >> "$GITHUB_OUTPUT"
- name: Checkout release-notes branch
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ steps.version.outputs.notes_branch }}
fetch-depth: 0
token: ${{ secrets.ACTIONS_UPDATE_TOKEN || github.token }}
persist-credentials: true
- name: Generate release notes
if: >-
steps.bot-check.outputs.skip != 'true'
&& startsWith(github.head_ref, 'release/') == true
&& github.event.pull_request.head.repo.full_name == github.event.pull_request.base.repo.full_name
uses: ./.github/actions/release-notes/generate
with:
release-branch: release
notes-branch: ${{ steps.version.outputs.notes_branch }}
version: ${{ steps.version.outputs.version }}

View File

@@ -80,6 +80,18 @@ try {
process.stdout.write(newVersion);
if (process.env.GITHUB_OUTPUT) {
const resolvedType = newVersion.includes('-nightly.')
? 'nightly'
: newVersion.split('.')[2] === '0'
? 'monthly'
: 'hotfix';
fs.appendFileSync(
process.env.GITHUB_OUTPUT,
`version=${newVersion}\ntype=${resolvedType}\n`,
);
}
if (values.update) {
packageJson.version = newVersion;
fs.writeFileSync(

View File

@@ -15,6 +15,16 @@ const exec = promisify(childProcess.exec);
const [owner, repo] = process.env.GITHUB_REPOSITORY.split('/');
const releaseBranch = process.env.RELEASE_BRANCH;
const notesBranch = process.env.NOTES_BRANCH;
const version = process.env.VERSION;
if (!releaseBranch || !notesBranch || !version) {
throw new Error(
'RELEASE_BRANCH, NOTES_BRANCH, and VERSION env vars are required',
);
}
const apiResult = await fetch('https://api.github.com/graphql', {
method: 'POST',
headers: {
@@ -44,24 +54,38 @@ const apiResult = await fetch('https://api.github.com/graphql', {
variables: {
name: repo,
owner,
headRefName: process.env.GITHUB_HEAD_REF || process.env.GITHUB_REF_NAME,
headRefName: notesBranch,
},
}),
}).then(res => res.json());
await collapsedLog('API Response', apiResult);
const prData = apiResult.data.repository.pullRequests.edges[0].node;
const version = prData.headRefName.split('/')[1].replace(/^v/, '');
const slug = version.replace(/\./g, '-');
const author = process.env.GITHUB_ACTOR || 'TODO';
const commitMessage = `Generate release notes for v${version}`;
const prData = apiResult.data.repository.pullRequests.edges[0]?.node;
if (!prData) {
console.error(`No PR found for branch ${notesBranch}`);
process.exit(1);
}
const releaseDateMatch = (prData.body || '').match(
/<!-- release-date:(\d{4}-\d{2}-\d{2}) -->/,
);
const releaseDate = releaseDateMatch ? releaseDateMatch[1] : 'TODO';
if (!releaseDateMatch) {
console.error(
`PR for ${notesBranch} body missing <!-- release-date:YYYY-MM-DD --> marker`,
);
process.exit(1);
}
const releaseDate = releaseDateMatch[1];
const author = process.env.GITHUB_ACTOR;
if (!author) {
console.error('::error::GITHUB_ACTOR env var is not set');
process.exit(1);
}
const slug = version.replace(/\./g, '-');
const commitMessage = `Generate release notes for v${version}`;
const botName = 'github-actions[bot]';
const botEmail = '41898282+github-actions[bot]@users.noreply.github.com';
@@ -72,17 +96,8 @@ await exec(`git config user.email '${botEmail}'`);
const AUTOGEN_MARKER = '<!-- release-notes:auto-generated -->';
await group('Prepare branch', async () => {
if (process.env.GITHUB_HEAD_REF) {
await exec(`git fetch origin ${process.env.GITHUB_HEAD_REF}`, {
stdio: 'inherit',
});
await exec(`git checkout ${process.env.GITHUB_HEAD_REF}`, {
stdio: 'inherit',
});
}
// recover deleted release note files from previous generation commits
const baseRef = process.env.GITHUB_BASE_REF || 'master';
const baseRef = 'master';
await exec(`git fetch origin ${baseRef}`, { stdio: 'inherit' });
const { stdout: mergeBase } = await exec(
`git merge-base HEAD origin/${baseRef}`,
@@ -110,6 +125,11 @@ await group('Prepare branch', async () => {
await fs.unlink(patchPath).catch(() => undefined);
}
}
await exec(`git fetch origin ${releaseBranch}`, { stdio: 'inherit' });
await exec(`git checkout origin/${releaseBranch} -- upcoming-release-notes`, {
stdio: 'inherit',
});
});
const { notesByCategory, files } = await parseReleaseNotes(

View File

@@ -2,8 +2,9 @@
## General information
In the open-source version of Actual, there are 4 NPM packages:
In the open-source version of Actual, there are 5 NPM packages:
- [@actual-app/core](https://www.npmjs.com/package/@actual-app/core): The shared core library (loot-core) used by the other packages. Platform-agnostic business logic, database operations, and calculations.
- [@actual-app/api](https://www.npmjs.com/package/@actual-app/api): The API for the underlying functionality. This includes the entire backend of Actual, meant to be used with Node.
- [@actual-app/web](https://www.npmjs.com/package/@actual-app/web): A web build that will serve the app with a web frontend. This includes both the frontend and backend of Actual. It includes the backend as well because it's built to be used as a Web Worker.
- [@actual-app/sync-server](https://www.npmjs.com/package/@actual-app/sync-server): The entire sync-server and underlying web client in one package. This includes the Server CLI, meant to be used with Node.
@@ -24,28 +25,33 @@ For example:
- `v23.3.2` - another bugfix launched later in the month of March;
- `v23.4.0` - first release launched on 9th of April, 2023;
### Release branch
### Release branches
A release branch and PR are automatically cut at 17:00 UTC on the 25th of each month. To cut one manually, run [this GitHub Action](https://github.com/actualbudget/actual/actions/workflows/cut-release-branch.yml).
There are two branches involved in every release:
The release notes workflow automatically generates a blog post and updates `docs/releases.md` from the files in `upcoming-release-notes/`. This runs each time the release PR is updated, so there is no need to manually copy notes into the docs.
- `release`: the single long-lived branch where release tags live. This is the branch we actually base the release on, and where commits are cherry-picked to if we want them included.
- `release-notes/X.Y.Z`: the branch that is used for the release PR. It holds the generated docs pages. It is deleted once a version ships.
Fixes that need to be included in the release should be cherry-picked onto the release branch manually.
Splitting them avoids merge conflicts between cherry-picks on the release branch and the version-bump/docs commits that need to be merged back to `master`.
Monthly cuts run automatically at 17:00 UTC on the 25th of each month. To cut a release manually (monthly or patch), run the [Cut release workflow](https://github.com/actualbudget/actual/actions/workflows/cut-release-branch.yml).
Changes that need to be included in the release after the cut has been made should be cherry-picked onto `release`. Each cherry-pick triggers regeneration of the release notes on `release-notes/X.Y.Z`. Human edits to frontmatter (release highlights, author, etc.) on `release-notes/X.Y.Z` are preserved across regenerations as long as they are above the autogen marker.
## Release process
### Stabilize the release
- [ ] Fix spelling in the generated release notes as needed.
- [ ] Fix spelling and add highlights in the generated release notes as needed (edit `release-notes/X.Y.Z` directly).
- [ ] Share the release PR in the release channel on Discord.
- [ ] Wait until at least 2 other maintainers have approved the release.
### Merge and tag the release
- [ ] Merge the release PR to master.
- [ ] Create the tag on the **release branch** and push it. When the tag is pushed, it triggers the Docker stable image, all NPM packages and the Desktop app to be built and published.
- [ ] Merge the `release-notes/X.Y.Z` PR to master.
- [ ] Create the tag on the **`release` branch** and push it. When the tag is pushed, it triggers the Docker stable image, all NPM packages and the Desktop app to be built and published.
```bash
git checkout release/vX.Y.Z
git checkout release
git tag vX.Y.Z
git push {remote} vX.Y.Z
```
@@ -71,43 +77,15 @@ Finally, a draft GitHub release should be automatically created; confirm [on the
## Cutting a patch release
Patch releases (e.g. `v26.5.1`) ship a small, targeted set of fixes on top of an existing release. Unlike monthly releases, the release branch is built by cherry-picking specific commits from `master` onto the previous release tag, so unrelated in-progress work on `master` is not pulled in.
Patch releases (e.g. `26.6.1`) ship a small, targeted set of fixes on top of the latest release. Because `release` is a single long-lived branch, a patch is just a version bump and cherry-picks on top of the previous release, with no new branch to create.
### Build the release branch
### Cut the patch
- [ ] Identify the commits on `master` that should be included in the patch release and note their commit hashes.
- [ ] Check out the previous release tag and create a new release branch from it:
```bash
git checkout v26.5.0
git checkout -b release/v26.5.1
```
- [ ] Cherry-pick each commit onto the new branch, in the same order they were merged to `master`:
```bash
git cherry-pick <commit-sha>
```
- [ ] Push the release branch. This is the branch that will be tagged later — **do not tag it yet**:
```bash
git push -u {remote} release/v26.5.1
```
Run the [Cut release workflow](https://github.com/actualbudget/actual/actions/workflows/cut-release-branch.yml) manually with:
### Open the release PR against master
- `version`: the patch version (e.g. `26.6.1`).
- `release-date`: when the patch is expected to ship (optional).
The release branch is what gets tagged, but the version bump, release notes cleanup, and blog post still need to land on `master` so future releases pick them up.
This creates `release-notes/26.6.1`. It's worth noting that the release branch after a prior releases have no `upcoming-release-notes/*.md` files in them, so the initial release-notes run generates an empty blog, content will fill in once changes are cherry-picked in to the `release` branch.
- [ ] Check out `master` and create a new branch from it (e.g. `release-notes/v26.5.1`).
- [ ] In this branch:
- Bump the version in the relevant `package.json` files.
- Delete the `upcoming-release-notes/*.md` files that correspond to the cherry-picked commits.
- Add a new blog post under `packages/docs/blog/` (see [`2026-02-22-release-26-2-1.md`](https://github.com/actualbudget/actual/blob/master/packages/docs/blog/2026-02-22-release-26-2-1.md) for an example).
- [ ] Commit the changes and open a PR against `master`. Include a link to the previously pushed release branch (e.g. `release/v26.5.1`) in the PR description so reviewers can see exactly what is shipping.
### Tag the release
- [ ] Once the PR has been approved and merged, tag the **release branch** (not `master`) and push the tag:
```bash
git checkout release/v26.5.1
git tag v26.5.1
git push {remote} v26.5.1
```
From here the rest of the release pipeline (NPM, Docker, Desktop, GitHub draft release) runs automatically. Follow the [Verify the release](#verify-the-release) and [Finalize the release](#finalize-the-release) steps above to complete the rollout.
The rest of the release process remains the same as a major release. Cherry-pick the appropriate changes into the `release` branch. Follow the steps to get the `release-notes/X.Y.Z` branch ready, then follow the merging and tagging steps outlined above.

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [matt-fidd]
---
Split the release process into two branches