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/.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..eeaf5f251 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,41 @@ 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 + # 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. + # + # **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.11.2 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.11.2 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..ef52c33ee 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -130,14 +130,18 @@ 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, `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`) @@ -152,17 +156,30 @@ 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. -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 -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), @@ -215,6 +232,41 @@ 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. 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` + 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