From ecfe43fdda134d6b4eee199683361a4e77663b94 Mon Sep 17 00:00:00 2001 From: Matt Fiddaman Date: Tue, 19 May 2026 13:47:08 +0100 Subject: [PATCH] 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 --- .github/actions/docs-spelling/expect.txt | 1 + .../actions/release-notes/generate/action.yml | 14 +++ .github/workflows/cut-release-branch.yml | 118 ++++++++++++------ .github/workflows/release-notes.yml | 44 +++++-- .../bin/get-next-package-version.ts | 12 ++ .../ci-actions/bin/release-notes-generate.mjs | 56 ++++++--- packages/docs/docs/contributing/releasing.md | 66 ++++------ upcoming-release-notes/7844.md | 6 + 8 files changed, 206 insertions(+), 111 deletions(-) create mode 100644 upcoming-release-notes/7844.md diff --git a/.github/actions/docs-spelling/expect.txt b/.github/actions/docs-spelling/expect.txt index 07f250cf23..83ca8fb474 100644 --- a/.github/actions/docs-spelling/expect.txt +++ b/.github/actions/docs-spelling/expect.txt @@ -11,6 +11,7 @@ ANZ aql AUR Authentik +autogen AVERAGEA BANKA BANKINTER diff --git a/.github/actions/release-notes/generate/action.yml b/.github/actions/release-notes/generate/action.yml index b936b89bd5..fa6fc48ee1 100644 --- a/.github/actions/release-notes/generate/action.yml +++ b/.github/actions/release-notes/generate/action.yml @@ -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 diff --git a/.github/workflows/cut-release-branch.yml b/.github/workflows/cut-release-branch.yml index e606a8926d..39577bbf83 100644 --- a/.github/workflows/cut-release-branch.yml +++ b/.github/workflows/cut-release-branch.yml @@ -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) + - 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 diff --git a/.github/workflows/release-notes.yml b/.github/workflows/release-notes.yml index e62c47b317..fc8cf7af53 100644 --- a/.github/workflows/release-notes.yml +++ b/.github/workflows/release-notes.yml @@ -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 }} diff --git a/packages/ci-actions/bin/get-next-package-version.ts b/packages/ci-actions/bin/get-next-package-version.ts index dfc911d60e..9eea28d6b5 100644 --- a/packages/ci-actions/bin/get-next-package-version.ts +++ b/packages/ci-actions/bin/get-next-package-version.ts @@ -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( diff --git a/packages/ci-actions/bin/release-notes-generate.mjs b/packages/ci-actions/bin/release-notes-generate.mjs index b043987559..579c9bb12f 100644 --- a/packages/ci-actions/bin/release-notes-generate.mjs +++ b/packages/ci-actions/bin/release-notes-generate.mjs @@ -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( //, ); -const releaseDate = releaseDateMatch ? releaseDateMatch[1] : 'TODO'; +if (!releaseDateMatch) { + console.error( + `PR for ${notesBranch} body missing 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 = ''; 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( diff --git a/packages/docs/docs/contributing/releasing.md b/packages/docs/docs/contributing/releasing.md index a1c7d80092..8df485681e 100644 --- a/packages/docs/docs/contributing/releasing.md +++ b/packages/docs/docs/contributing/releasing.md @@ -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 - ``` -- [ ] 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. diff --git a/upcoming-release-notes/7844.md b/upcoming-release-notes/7844.md new file mode 100644 index 0000000000..f69cace7a5 --- /dev/null +++ b/upcoming-release-notes/7844.md @@ -0,0 +1,6 @@ +--- +category: Maintenance +authors: [matt-fidd] +--- + +Split the release process into two branches