From 8dd1512873bbb1f0624babd58a539b0ae14494ae Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Fri, 16 Oct 2026 11:21:52 +0200 Subject: [PATCH 1/3] ci(release): cut patch releases from maintenance branches, and tag first release-please also runs on release/** branches, targeting the pushed branch, so a fix for a shipped line can be released without the features already on main; the Cargo.lock build accepts release PRs into those branches. The GitHub release step now runs before the release PR update: in the other order the update walked 1,500 commits looking for a release not yet created, and the timeout cut the job before the tag. RELEASING.md documents the maintenance branch, and that release.yml must be started by hand for a tag the workflow made. --- .../release-please-lockfile-build.yml | 3 +- .github/workflows/release-please.yml | 36 ++++++++++----- docs/RELEASING.md | 44 ++++++++++++++++++- 3 files changed, 70 insertions(+), 13 deletions(-) diff --git a/.github/workflows/release-please-lockfile-build.yml b/.github/workflows/release-please-lockfile-build.yml index b4fba8246..a58636039 100644 --- a/.github/workflows/release-please-lockfile-build.yml +++ b/.github/workflows/release-please-lockfile-build.yml @@ -27,7 +27,8 @@ name: Build Cargo.lock for release-please PR "on": pull_request: - branches: [main] + # Maintenance branches get release PRs too (see release-please.yml). + branches: [main, "release/**"] types: [opened, synchronize, reopened] permissions: diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 4062917e0..713d99875 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -24,9 +24,15 @@ name: Release Please # workflow keeps gating correctly on `startsWith(head_ref, # 'release-please--')`.** +# Also on maintenance branches (`release/1.9.x`, …): a patch release of a +# shipped line is cut from there, so the features already on main stay +# out of it. Each run targets the branch that was pushed +# (`--target-branch`), and reads that branch's config and manifest. +# A push to a branch runs that branch's copy of this file, so a +# maintenance branch needs this version of it too. "on": push: - branches: [main] + branches: [main, "release/**"] permissions: contents: write @@ -47,27 +53,37 @@ jobs: with: node-version: 20 - # Phase A — open / update the release PR. Idempotent: re-runs - # on every push, only mutates GitHub when something changed. - - name: Update release PR + # Phase A — when the head commit IS the release PR's merge + # commit, cut the GitHub release + tag. No-op on every other + # push, so we can run both phases unconditionally. + # + # **First, not second.** Right after a release PR merges, the + # update below looks for the release of the version the manifest + # now names; when that release does not exist yet it walks back up + # to `commit-search-depth` (1500) commits, one API call each, and + # the job's timeout cut it before this step ever ran — no tag, and + # a manual rerun on every release (1.8.1, 1.9.0, 1.9.1). Tagged + # first, the update finds its starting point at once. + - name: Publish GitHub release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - npx --yes release-please@17 release-pr \ + npx --yes release-please@17 github-release \ --token="${GITHUB_TOKEN}" \ --repo-url="${GITHUB_REPOSITORY}" \ + --target-branch="${GITHUB_REF_NAME}" \ --config-file=release-please-config.json \ --manifest-file=.release-please-manifest.json - # Phase B — when the head commit IS the release PR's merge - # commit, cut the GitHub release + tag. No-op on every other - # push, so we can run both phases unconditionally. - - name: Publish GitHub release + # Phase B — open / update the release PR. Idempotent: re-runs + # on every push, only mutates GitHub when something changed. + - name: Update release PR env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - npx --yes release-please@17 github-release \ + npx --yes release-please@17 release-pr \ --token="${GITHUB_TOKEN}" \ --repo-url="${GITHUB_REPOSITORY}" \ + --target-branch="${GITHUB_REF_NAME}" \ --config-file=release-please-config.json \ --manifest-file=.release-please-manifest.json diff --git a/docs/RELEASING.md b/docs/RELEASING.md index f994886db..e91cba3b3 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -157,8 +157,19 @@ release-please tolerates both). The merge causes release-please to: - update [`.release-please-manifest.json`](../.release-please-manifest.json) to record the new version. -The new tag triggers [`release.yml`](../.github/workflows/release.yml) -exactly as before — no change needed downstream. +The tag is created with the workflow's `GITHUB_TOKEN`, and a tag pushed +that way **does not trigger** [`release.yml`](../.github/workflows/release.yml) +(GitHub does not start workflows from events the token itself caused). +Start it by hand once the tag exists: + +```bash +gh workflow run release.yml -f tag_name=vX.Y.Z +``` + +The workflow tags first and updates the release PR second: in the other +order, the update right after a release PR merged went looking for a +release that did not exist yet, walked up to 1,500 commits one API call at +a time, and the job's timeout cut it before the tag was ever made. ### 2. Watch the workflow @@ -215,6 +226,35 @@ updater errors. Common causes: pubkey mismatch (private/public keys regenerated), endpoint 404, malformed `latest.json`, signature corrupted on upload. +## Patch releases from a maintenance branch + +Once `main` carries features for the next minor or major, a fix for the +version users have cannot be cut from it: release-please would bump to +the next minor and ship those features with it. A patch is cut from a +maintenance branch instead, `release/X.Y.x`, branched from the last tag +of that line: + +1. **Create the branch** from the tag: `git push origin vX.Y.Z:refs/heads/release/X.Y.x`. +2. **Bring the workflows** to it. A push runs the pushed branch's copy of + [`release-please.yml`](../.github/workflows/release-please.yml), and + a pull request runs its base's copy of + [`release-please-lockfile-build.yml`](../.github/workflows/release-please-lockfile-build.yml); + a tag older than their `release/**` support has neither. Cherry-pick + the commit that added it. +3. **Cherry-pick each fix** from `main` (signed: `git cherry-pick -S`), + through a pull request into `release/X.Y.x` so CI runs on it. +4. release-please, run on the branch with `--target-branch`, opens + `chore(release/X.Y.x): release X.Y.Z+1` against it; the Cargo.lock + pipeline fills it in as on `main`. Merge it, then start `release.yml` + for the new tag as above. Downstream (AUR, winget, COPR, apt) and the + updater manifest work the same, since everything starts from the tag. + +`main` keeps its own manifest, so its next release still counts from its +last stable version; its changelog will list the cherry-picked fixes a +second time, under the version that ships them on `main`. For a major, +set `release-as` in [`release-please-config.json`](../release-please-config.json) +before merging the release PR. + ## Beta channel WaveFlow ships an **opt-in beta channel** so testers can run From 7ef71207ec4b52c0a473b1f8b57300cda5c4e64c Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Fri, 16 Oct 2026 11:33:32 +0200 Subject: [PATCH 2/3] ci(release): pin release-please, and say how release branches are protected npx release-please@17 resolved to whatever 17.x was current at each run, with the write token; it is pinned to 17.11.2. RELEASING.md now says the workflow runs on maintenance branches and tags the branch it targets, that release.yml is started by hand for that tag, and that a Maintenance branches ruleset protects release/** like main, without the CodeQL gate. --- .github/workflows/release-please.yml | 8 ++++++-- docs/RELEASING.md | 22 ++++++++++++++++------ 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 713d99875..eeaf5f251 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -53,6 +53,10 @@ jobs: with: node-version: 20 + # release-please is pinned to an exact version: `@17` resolved to + # whatever 17.x npm served at each run, handed the write token. + # Bump it by hand, on both steps. + # # Phase A — when the head commit IS the release PR's merge # commit, cut the GitHub release + tag. No-op on every other # push, so we can run both phases unconditionally. @@ -68,7 +72,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - npx --yes release-please@17 github-release \ + npx --yes release-please@17.11.2 github-release \ --token="${GITHUB_TOKEN}" \ --repo-url="${GITHUB_REPOSITORY}" \ --target-branch="${GITHUB_REF_NAME}" \ @@ -81,7 +85,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - npx --yes release-please@17 release-pr \ + npx --yes release-please@17.11.2 release-pr \ --token="${GITHUB_TOKEN}" \ --repo-url="${GITHUB_REPOSITORY}" \ --target-branch="${GITHUB_REF_NAME}" \ diff --git a/docs/RELEASING.md b/docs/RELEASING.md index e91cba3b3..c685bfd1f 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -130,9 +130,11 @@ gh secret set SIGNTOOL_PFX_PASSWORD --body "" ### 1. Cut the release via release-please -You do **not** hand-bump versions anymore. Every push to `main` -runs [`.github/workflows/release-please.yml`](../.github/workflows/release-please.yml), -which: +You do **not** hand-bump versions anymore. Every push to `main` — or to +a maintenance branch, see +[Patch releases from a maintenance branch](#patch-releases-from-a-maintenance-branch) — +runs [`.github/workflows/release-please.yml`](../.github/workflows/release-please.yml) +against that branch, which: 1. Parses the Conventional Commits since the last tag. 2. Computes the next semver (`feat:` → minor, `fix:` → patch, @@ -152,7 +154,8 @@ which: To ship, **review and merge the release PR** (squash or merge, release-please tolerates both). The merge causes release-please to: -- create the `vX.Y.Z` tag on `main`, +- create the `vX.Y.Z` tag on the branch it targets (`main`, or the + maintenance branch), - create the matching GitHub Release with the auto-generated notes, - update [`.release-please-manifest.json`](../.release-please-manifest.json) to record the new version. @@ -173,7 +176,8 @@ a time, and the job's timeout cut it before the tag was ever made. ### 2. Watch the workflow -Pushing the tag triggers `.github/workflows/release.yml`: +`.github/workflows/release.yml`, started for the tag as above (a tag +pushed by hand would start it on its own; release-please's does not): - Builds Linux on `ubuntu-latest` — produces an `.AppImage` (universal, also the updater payload), a `.deb` (Debian/Ubuntu), @@ -242,7 +246,13 @@ of that line: a tag older than their `release/**` support has neither. Cherry-pick the commit that added it. 3. **Cherry-pick each fix** from `main` (signed: `git cherry-pick -S`), - through a pull request into `release/X.Y.x` so CI runs on it. + through a pull request into `release/X.Y.x` so CI runs on it. The + **Maintenance branches** ruleset protects `release/**` the way `Main` + protects `main` — no deletion or force push, signed commits, a pull + request with one approval — without the CodeQL gate, since CodeQL only + analyses pull requests into `main`. A push to these branches runs their + own copy of the workflow with a write token, which is why they must not + take direct pushes. 4. release-please, run on the branch with `--target-branch`, opens `chore(release/X.Y.x): release X.Y.Z+1` against it; the Cargo.lock pipeline fills it in as on `main`. Merge it, then start `release.yml` From 4cb4946b39e24cb8534c2da4a4cdacbc7523f707 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Fri, 16 Oct 2026 11:51:35 +0200 Subject: [PATCH 3/3] ci(release): bind the lockfile artifact to the run that built it The privileged push checked the PR the artifact names, not that the artifact was built for it: the PR must now be one GitHub attached to the triggering run, at the same branch and head SHA, or the push fails before any write. RELEASING.md gives the release PR title on a maintenance branch. --- .../release-please-bump-lockfile.yml | 27 +++++++++++++++++++ docs/RELEASING.md | 4 ++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release-please-bump-lockfile.yml b/.github/workflows/release-please-bump-lockfile.yml index 35dacd49f..c101d8dac 100644 --- a/.github/workflows/release-please-bump-lockfile.yml +++ b/.github/workflows/release-please-bump-lockfile.yml @@ -109,6 +109,33 @@ jobs: return; } + // Bind the artifact to the run that built it: the PR it + // names must be one GitHub attached to that run, at the same + // branch and commit. Otherwise a run built for one PR could + // hand over a lockfile claiming to be another's. Release PRs + // come from this repository, so the list is never empty for + // them; an empty list fails like a mismatch. + const run = context.payload.workflow_run; + const linked = (run.pull_requests || []).find( + (p) => p.number === prNumber, + ); + if (!linked) { + core.setFailed( + `artifact names PR #${prNumber}, which is not a PR of run ${run.id}`, + ); + return; + } + if ( + linked.head.ref !== branch || + linked.head.sha !== artifactHeadSha || + run.head_sha !== artifactHeadSha + ) { + core.setFailed( + `run ${run.id} built ${linked.head.ref}@${linked.head.sha}, artifact claims ${branch}@${artifactHeadSha}`, + ); + return; + } + // Verify the PR is still open, still authored by the // release-please bot, and its head ref still matches the // artifact's claim. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index c685bfd1f..ef52c33ee 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -139,7 +139,9 @@ against that branch, which: 1. Parses the Conventional Commits since the last tag. 2. Computes the next semver (`feat:` → minor, `fix:` → patch, `feat!:` / `BREAKING CHANGE:` → major, no relevant commits → no PR). -3. Opens or refreshes a **chore(main): release X.Y.Z** PR that +3. Opens or refreshes a release PR — **chore(main): release X.Y.Z** on + `main`, **chore(release/X.Y.x): release X.Y.Z+1** on a maintenance + branch — that bumps every version manifest in lockstep: - [`package.json`](../package.json) (canonical, owned by release-please) - [`src-tauri/crates/app/tauri.conf.json`](../src-tauri/crates/app/tauri.conf.json) (`$.version`)