From 6e563f38460afe3b20817317f2db13a85eae97a5 Mon Sep 17 00:00:00 2001 From: Logan Date: Mon, 17 Aug 2026 23:35:59 -0400 Subject: [PATCH] docs: record what shipping the release pipeline taught us Moves plan 017 to completed with a recap, and lifts the three findings that generalise into NOTES.md: a preset major that renders empty notes with everything green, a 403 that looks like branch protection and is a token scope, and tag-triggered workflows running the tagged commit's own definitions. --- .planning/NOTES.md | 38 ++ .../plans/active/017-release-automation.md | 358 ------------------ .../plans/completed/017-release-automation.md | 87 +++++ 3 files changed, 125 insertions(+), 358 deletions(-) delete mode 100644 .planning/plans/active/017-release-automation.md create mode 100644 .planning/plans/completed/017-release-automation.md diff --git a/.planning/NOTES.md b/.planning/NOTES.md index e25d116..c108b99 100644 --- a/.planning/NOTES.md +++ b/.planning/NOTES.md @@ -3445,3 +3445,41 @@ their own output, and leave the previous snapshots in place. makes it worth having: a restored snapshot resolves to `refresh` and folds in the incremental listens since — minutes, against the 3–23 h a rebuild was estimating. + +## A green release pipeline can ship an empty changelog (2026-08-18) + +`conventional-changelog-conventionalcommits@10` is silently incompatible +with the writer `@semantic-release/release-notes-generator@14` depends on +(`conventional-changelog-writer@^8`). Every release note renders as a bare +`## 0.0.1 (date)` heading with **no sections and no commits under it**, no +step fails, and the release ships with an empty body. + +It is pinned to `9` in `.gitea/workflows/release.yml` and in +`make release-dry`, which must stay identical. **Check the rendered notes, +never the exit code** — this is invisible to every tick in the pipeline. + +## semantic-release needs push rights to the branch even when it never pushes to it (2026-08-18) + +Core runs `git push --dry-run HEAD:` as a permission check, before +and independently of any plugin. With `@semantic-release/git` removed +nothing ever pushes to `main`, and the check still runs. + +Two things this looked like and was not: + +- **Not branch protection.** A `--dry-run` push does not reach the + pre-receive hook: pushing one to protected `main` with a write-scoped + token succeeds. So `main`'s `enable_push: false` is not what fails here. +- **A flat `403 Forbidden`, not Gitea's protection message.** That is the + tell. `PACKAGE_TOKEN` had package-write and repo-*read* — enough to + clone a private repo, so every other workflow was fine — and needed + `write:repository`. + +## A tag-triggered workflow runs the workflow file at the *tagged* commit (2026-08-18) + +Not the one on `main`. Moving `v0.0.0` onto a pre-merge commit ran that +commit's version of `homebrew-formula.yml`, which predated the `v0.0.0` +skip guard added in the same plan, and it pushed a `0.0.0` formula to the +public tap. + +A guard added today does not protect a tag that points at yesterday. When +re-pointing a tag, check what the workflows looked like *there*. diff --git a/.planning/plans/active/017-release-automation.md b/.planning/plans/active/017-release-automation.md deleted file mode 100644 index 29aed26..0000000 --- a/.planning/plans/active/017-release-automation.md +++ /dev/null @@ -1,358 +0,0 @@ -# 017 — Releases that happen by themselves - -> **Status: built, not yet run.** Phases 0–4 have landed on this branch; -> phase 5 is the merge itself and cannot be done until then. The old -> `v1.x` tags are already deleted from `origin`. Verified locally against -> a scratch remote: semantic-release computes **0.0.1** from these -> commits and renders correct sectioned notes. -> -> **One thing found by testing that no amount of reading would have -> caught.** `conventional-changelog-conventionalcommits@10` — the current -> release, and my first pin — is silently incompatible with the writer -> `release-notes-generator@14` depends on: the version is right, the tag -> is right, every step reports success, and the release body is a bare -> `## 0.0.1 (date)` heading with **nothing under it**. It is pinned to 9 -> in both `release.yml` and `make release-dry`, with the reason written -> beside it. Four of my seven original pins were wrong majors besides; -> they were guesses, and `npm view` was the fix. - -The goal in one sentence: **a merge to `main` computes the next version -from the commits it contains, cuts a tag and a Gitea release whose body -is the changelog, and every publishing channel builds that tag.** The -first release under this scheme is `v0.0.1`, and the five existing `v1.x` -tags go. - -## What is there now - -Measured, not remembered: - -- **Five tags and zero releases.** `v1.3.0`, `v1.4.0`, `v1.4.1`, - `v1.5.0`, `v1.6.0` exist on `origin`; - `GET /api/v1/repos/yonlu/yellowjacket/releases` returns `[]`. So there - is no release page to preserve and nothing but the tags to remove. -- **`CHANGELOG.md` is stale and belongs to another repo.** Its newest - entry is `1.3.0` and every link in it points at - `github.com/onion-4-dinner/yellowjacket` — it was written by a - semantic-release run against a GitHub remote this project no longer - has. -- **`.releaserc.yml` is a complete semantic-release config that nothing - invokes**, which CLAUDE.md already says in as many words. -- **Root `package.json` is literally `{}`** — the stub left behind by - whatever was going to run it. -- The triggers today are: `arch-package` on **push to `main`**, - `homebrew-formula` on **`v*`**, `android-apk` on **`v*`**, `ci` on - every branch, `index-artifact` on cron/dispatch. So Arch publishes a - `git describe` version on every merge and the other two publish only - when a human remembers to push a tag. - -## Decision 1 — semantic-release, with `exec` in place of the `github` plugin - -**Revised: the first draft of this plan proposed a shell script and the -argument for it does not hold.** Recorded here rather than deleted, -because the reasoning is what the decision rests on. - -What I said, and what checking it showed: - -- *"The two plugins that would carry the work do not fit."* Half true. - `@semantic-release/github` genuinely does not speak Gitea's `/api/v1` - — but the replacement is **`@semantic-release/exec`**, which is - first-party, published 2026-06, and peer-deps `semantic-release >=24.1`. - Its `publishCmd` is one `curl` at the Gitea release endpoint with - `${nextRelease.notes}` as the body. The Gitea-shaped part of this is - five lines, and the part I proposed to hand-roll — parsing conventional - commits, ordering semver, rendering grouped notes — is the part with - the edge cases and none of it is Gitea-shaped at all. -- *"`@semantic-release/git` commits the changelog back to `main`, which - re-triggers everything."* True, and it is the one real risk — but it - is a two-line guard (skip the job when `HEAD`'s subject is - `chore(release):`), not a reason to write a version calculator. That - guard is needed under **either** design, since either one writes a - changelog commit. -- *"A Node dependency tree at the root of a Go repo."* The commitlint - precedent does not transfer. commitlint was a dependency to regex one - line; this is a dependency to do something with real complexity, it is - `npx`-only so nothing lands in the repo, and Node is already installed - in CI for the frontend. -- *"It cannot be told to produce `0.0.1`."* Wrong — that is a property - of which commits are in the range, not of the tool. Identical under - both designs. See below. - -Note also that **`@saithodev/semantic-release-gitea` is a dead end** and -should not be reached for: last published 2022, depends on `got@10` and -`fs-extra@8`, and declares no peer dependency on semantic-release at all -— i.e. it is untested against anything since v19, against a core now at -v25. `exec` + `curl` is both simpler and maintained. - -So `.releaserc.yml` stays, and its plugin list becomes five **first-party** -plugins, all published within the last six months: - -| plugin | job | -| --- | --- | -| `commit-analyzer` | the version | -| `release-notes-generator` | the notes | -| `changelog` | writes `CHANGELOG.md` | -| `git` | commits it back | -| `exec` | `curl`s the Gitea release | - -The `releaseRules` and `presetConfig` blocks already in the file are -kept verbatim — they are the same bump table `commit-check.sh` already -enforces the grammar for, and nothing about the project's commit -convention changes. - -Two mechanical details that decide whether this works at all: - -- **semantic-release pushes the tag itself**, as core behaviour, using - `repositoryUrl`. The remote here is `ssh://git@git.ljones.me:2222/…`, - which would need an SSH key in CI — so the run passes - `--repository-url "https://x-access-token:$PACKAGE_TOKEN@git.ljones.me/yonlu/yellowjacket.git"` - on the command line rather than committing a token to the config. - **That is also what satisfies Decision 2**: the tag push is attributed - to a real user, not to the Actions token. -- **The empty root `package.json` (`{}`) goes.** semantic-release does - not need one when `--repository-url` is explicit, and leaving a - package manifest at the root of a Go repo invites the npm plugin and - every tool that looks for one. - -Invocation is pinned in the workflow, not installed into the repo: - -``` -npx --yes \ - -p semantic-release@25 \ - -p @semantic-release/commit-analyzer@14 \ - -p @semantic-release/release-notes-generator@15 \ - -p @semantic-release/changelog@6 \ - -p @semantic-release/git@10 \ - -p @semantic-release/exec@7 \ - -p conventional-changelog-conventionalcommits@9 \ - semantic-release --repository-url "…" -``` - -(Exact majors get pinned from `npm view` at implementation time; -`conventional-changelog-conventionalcommits` is in the list because both -the analyzer and the notes generator name that preset and neither -depends on it.) - -## Decision 2 — how the publish workflows learn about the tag - -**Gitea, like GitHub, does not start a workflow from a tag pushed by a -workflow's own token** (go-gitea#33123, and the forum thread it points -at). This is the one load-bearing unknown in the plan. - -The remedy is to push the tag with a *user* PAT — `secrets.PACKAGE_TOKEN` -is already in this repo and already used by `arch-package` and -`android-apk` to clone and to publish — so the push is attributed to a -person and the `v*` triggers fire normally. That keeps the three publish -workflows completely unchanged in shape. - -**It is verified in phase 5, not assumed.** The fallback, if it does not -fire, is an explicit `POST -/api/v1/repos/{owner}/{repo}/actions/workflows/{file}/dispatches` per -channel from the release job. That needs `workflow_dispatch` (with a -`version` input) added to `homebrew-formula.yml` and `arch-package.yml`; -`android-apk.yml` already has both. **Add those inputs in phase 3 -regardless** — a hand-triggered rebuild of one channel is worth having -whether or not the fallback is needed. - -The alternative — one `release.yml` with the three publishes as -`needs:` jobs — is rejected: it means either copying ~400 lines of -Android and Arch setup into it or relying on `workflow_call`, and it -puts every merge to `main` behind an up-to-60-minute Android build on a -runner with capacity 1. - -## Decision 3 — 1.6.0 → 0.0.1 is a downgrade, and the answer is reinstall - -**Decided: no version-code offset, no epoch. The version number stays -honest and existing installs are replaced by hand.** Every channel is a -downgrade and each declines differently, so what to expect: - -- **Arch: no upgrade is offered, silently.** `pkgver()` derives from - `git describe`, so after the wipe it reads `0.0.1.rN.gHASH`, which - pacman orders *below* the `1.3.0.rN.*` in the registry. `pacman -R - yellowjacket && pacman -S yellowjacket` is the remedy. (`epoch=1` in - the PKGBUILD would have avoided it for one line — but an epoch can - never be removed, and it puts a permanent `1:` in front of every - version string this project will ever have.) -- **Homebrew: no upgrade is offered, silently.** Brew has no epoch at - all. `brew uninstall yellowjacket && brew install …`. -- **Android: a hard refusal.** `versionCode` is - `maj*10000 + min*100 + pat`, so `0.0.1` is **1** against the **10300** - an installed 1.3.0 carries, and the install fails with - `INSTALL_FAILED_VERSION_DOWNGRADE`. Uninstall first — **and that takes - the app's library and config with it**, which is the same data loss - `android-apk.yml`'s keystore guard exists to prevent, arrived at from - the other direction. The workflow's own `code -le 0` guard still passes - at 1, so nothing in CI stops or warns about this. - -All three go in the release notes for `v0.0.1` and in -`packaging/homebrew/README.md` / `docs/android-release.md`, because a -channel that silently offers no upgrade is indistinguishable from a -broken pipeline six months from now. - -## Landing exactly `v0.0.1` - -Determinism comes from two things: - -1. **Seed `v0.0.0` on `6fb7b5e`** (current `origin/main`) after wiping - the old tags. That is the floor, and the analyser's range starts - there. -2. **This branch carries no `feat:` commit.** Everything in it is - `ci:`/`docs:`/`chore:`/`build:`, plus at least one `fix:` — which is - honest, since wiring up release machinery that was configured and - never run *is* a fix. One patch-level commit in `v0.0.0..HEAD` - computes `0.0.1` and nothing else can. - -This is a property of the commit range, not of the tool — it would have -been the same constraint under the shell script. - -This is a real constraint on the branch, not an accounting trick: a -single `feat:` commit here makes the first release `v0.1.0`. - -`v0.0.0` itself gets no release object — it is a floor, not a shipment. - -## Decision 4 — what the release page carries - -Four artifacts, and the fourth is the interesting one. Measured on this -machine rather than assumed: - -| asset | built by | state | -| --- | --- | --- | -| `yellowjacket--android-arm64.apk` | `android-apk.yml` | already built, verified, signed | -| `yellowjacket--linux-amd64.tar.gz` | new job | binary + `.desktop` + icon | -| `yellowjacket--x86_64.pkg.tar.zst` | `arch-package.yml` | already built; free to attach | -| `yellowjacket--windows-amd64.zip` | new job | **compiles; has never been run** | - -**macOS cannot be one of them.** `GOOS=darwin CGO_ENABLED=0` fails at -`wails/v3/pkg/mac: build constraints exclude all Go files` — the darwin -backend is Objective-C behind cgo, so a `.app` needs a macOS host and -the runner is a Linux container. That is precisely why the Homebrew -channel builds from source on the user's own Mac, and it stays the -answer for macOS. - -**Windows is newly possible and should be labelled honestly.** -`GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -tags production` -succeeds in 2.5 s and produces a 40 MB `.exe` — nothing in the audio, -database or webview path needs cgo on Windows (oto uses WinMM through -`x/sys`, sqlite is modernc's pure-Go driver, WebView2 is COM syscalls, -and MPRIS is `linux && !android`-tagged). But **compiling is not -running**: no Windows build of this app has ever been started, no CI tier -can exercise one, and `backend/system`'s `%LOCALAPPDATA%` path has never -resolved on a real machine. It ships marked as untested in the release -notes, or it does not ship — an unlabelled Windows download is a promise -nothing here can keep. - -### The race the ordering creates - -semantic-release runs **prepare** (changelog commit, tag push) before -**publish** (the `exec` curl that creates the release object). The tag -push is what starts the publishing workflows — so a fast one can reach -its upload step *before the release exists*, and -`POST /releases/{id}/assets` needs an id. - -The capacity-1 runner serialises things enough that this would usually -work, which is the worst kind of bug. So each upload step **polls -`GET /api/v1/repos/…/releases/tags/{tag}` with a bounded retry** before -uploading, and fails loudly on timeout rather than skipping the asset. -That is ~8 lines of shell, shared by all three publishers. - -## Phases - -**Phase 0 — clear the ground.** -Delete `v1.3.0`–`v1.6.0` locally and on `origin`; push `v0.0.0` at -`6fb7b5e` — this is the floor semantic-release reads, and without it the -first release is `1.0.0` by its own rule. Delete the empty root -`package.json`. Truncate `CHANGELOG.md` to a header plus a line saying -history before `0.0.1` is in `git log` — the existing content is another -repo's links and cannot be repaired, only replaced, and the `changelog` -plugin prepends to whatever it finds. - -**Phase 1 — `.releaserc.yml`.** -Swap `@semantic-release/github` for `@semantic-release/exec`, whose -`publishCmd` POSTs to -`/api/v1/repos/yonlu/yellowjacket/releases` with `tag_name`, `name` and -`body` taken from `${nextRelease.*}`. Keep `commit-analyzer`, -`release-notes-generator`, `changelog` and `git` exactly as written; fix -the `git` plugin's commit message so it passes `commit-check` -(`chore(release): ${nextRelease.version}` — the existing one already -does, but the trailing `${nextRelease.notes}` in the body is worth -keeping deliberate rather than incidental). `make release-dry` wraps -`semantic-release --dry-run` so the next version is answerable without -pushing anything. - -`scripts/commit-check.sh`'s header already points at `.releaserc.yml` -for the type list and stays correct — that coupling survives this plan -rather than being broken by it. - -**Phase 2 — `.gitea/workflows/release.yml`.** -On `push: branches: [main]`. Node 22, the pinned `npx` line from -Decision 1, `--repository-url` carrying `PACKAGE_TOKEN`. Concurrency -group `release-main` with `cancel-in-progress: false` — cutting a tag is -not a thing to cancel halfway. - -The one guard that matters: **the job exits early when `HEAD`'s subject -starts `chore(release):`**, so the changelog commit the `git` plugin -pushes cannot re-enter this workflow. That is checked in shell rather -than left to `[skip ci]`, whose handling in Gitea is one more thing that -would have to be verified. - -**Phase 3 — rewire the publish workflows.** -`arch-package.yml` moves from `push: branches: [main]` to -`push: tags: ['v*']` plus `workflow_dispatch`, so a merge no longer -publishes an untagged package. `homebrew-formula.yml` gains -`workflow_dispatch` with a `version` input and takes its version from -the input when there is no tag. `android-apk.yml` needs neither. - -**Phase 3b — the assets.** -`scripts/release-asset.sh` is the shared uploader: wait for the release -by tag, then `POST /releases/{id}/assets?name=…`. `android-apk.yml` and -`arch-package.yml` each call it with the artifact they already built. -A new `desktop-assets` job — `push: tags: ['v*']`, in the same -`ubuntu:24.04` container `ci.yml` uses — builds the Linux binary via -`make build-prod` and the Windows one via the `CGO_ENABLED=0` -cross-compile, and uploads both. It is a separate job from the Arch one -because that runs in an `archlinux` container as an unprivileged -`makepkg` user, and grafting two unrelated builds onto it would make one -failure look like the other. - -**Phase 4 — say that the upgrade is a reinstall, and that Windows is untried.** -No code change: a note in `packaging/homebrew/README.md`, one in -`docs/android-release.md`, the three-channel downgrade warning written -into the `v0.0.1` release notes, and a standing line in the notes -template marking the Windows asset unverified until someone runs it. - -**Phase 5 — cut it and watch.** *(the only phase left)* -Merge, then verify with `gitea_ci` that (a) `release.yml` ran, seeded -`v0.0.0` and produced `v0.0.1`, (b) the release exists **with a non-empty -body** — check the body, not the exit code — and (c) **all four publish -workflows started from the tag**. If (c) is empty, that is Decision 2's -fallback and the `workflow_dispatch` inputs added in phase 3 are already -there to drive it. - -The expected sequence on the merge is: `release.yml` seeds `v0.0.0` -(triggering nothing), releases `0.0.1`, and pushes both the changelog -commit and the tag — at which point `release.yml` fires a second time on -the changelog commit and exits at the `chore(release):` guard, while the -four `v*` workflows start. On a capacity-1 runner they will queue behind -each other, Android last and longest. - -**Phase 6 — the documentation that will otherwise be wrong.** -CLAUDE.md's *Commits* section currently explains `.releaserc.yml` and -says nothing runs it; the CI section says there are five workflows and -that only `ci.yml` gates. Both change. `docs/android-release.md` -describes tags as hand-pushed. `make skill-check` fails on a `.pi/` -reference to a make target that does not exist, so `make release-dry` -gets documented or nothing does. - -## Open questions for you - -1. **Ship the Windows `.exe` or not?** It builds, and it has never run. - Marked-as-untested is the assumption; say if you would rather hold it - back until someone boots it. - -Resolved: semantic-release stays, with `exec` in place of the `github` -plugin (Decision 1). Reinstalls are accepted, so no epoch and no -versionCode offset (Decision 3). The release carries the APK, a Linux -tarball, the Arch package and — pending (1) — a Windows zip; macOS is -not buildable here and stays a Homebrew-from-source channel (Decision 4). -`v0.0.0` has to be a real tag under this design — semantic-release reads -git tags for its floor and has no "treat absence as 0.0.0" knob that -also stops it calling the first release `1.0.0`. diff --git a/.planning/plans/completed/017-release-automation.md b/.planning/plans/completed/017-release-automation.md new file mode 100644 index 0000000..fb2357b --- /dev/null +++ b/.planning/plans/completed/017-release-automation.md @@ -0,0 +1,87 @@ +# 017 — Releases that happen by themselves + +**Shipped as `v0.0.1`.** A merge to `main` now reads the Conventional +Commits since the last tag, cuts the tag and the Gitea release whose body +is the generated changelog, and the four publishing workflows build that +tag and attach their artifacts. Nothing is released by hand. + +## What it looks like now + +`release.yml` on push to `main` → semantic-release → tag → four `v*` +workflows in parallel (serialised in practice by the capacity-1 runner): + +| workflow | publishes | attaches | +| --- | --- | --- | +| `arch-package` | pacman registry | `…-x86_64.pkg.tar.zst` | +| `android-apk` | generic registry (Obtainium) | `…-android-arm64.apk` | +| `desktop-assets` | — | `…-linux-amd64.tar.gz` | +| `homebrew-formula` | the public tap | — (builds from source) | + +Verified on the real thing: all five green, three assets on the release, +the tap at `0.0.1`, and the Obtainium `latest` URL serving 200. + +## The five decisions, and what they cost + +1. **semantic-release, not a shell script.** The first draft of this plan + proposed hand-rolling it and the argument did not survive checking: + `@semantic-release/exec` is first-party and current, and the + Gitea-shaped part is one `curl`. What I would have hand-rolled — + commit parsing, semver ordering, note rendering — is the part with the + edge cases and none of it is Gitea-shaped. +2. **`@saithodev/semantic-release-gitea` is a dead end** and was offered + before it was checked: last published 2022, `got@10`, and no peer + dependency on semantic-release at all. +3. **No `@semantic-release/git`.** `main` is protected, so a changelog + commit-back is rejected by the pre-receive hook — and would be + rejected *after* the tag was pushed, leaving a tagged release the run + reports as failed. The release page is the changelog; + `.release-notes.md` is a gitignored carrier and `CHANGELOG.md` is a + signpost. +4. **Versions restart at `0.0.1`**, a downgrade on every channel. No + `epoch`, no `versionCode` offset: both are permanent, a reinstall is + once. Documented in `packaging/homebrew/README.md` and + `docs/android-release.md`. +5. **No macOS and no Windows.** `GOOS=darwin CGO_ENABLED=0` fails at + `wails/v3/pkg/mac` and there is no macOS runner, so Homebrew-from-source + stays that channel. Windows cross-compiles in ~2.5 s and is withheld + because no build of it has ever been *run*. + +## Four things that only showed up by running it + +- **`conventional-changelog-conventionalcommits@10` renders empty + notes.** Silently: right version, right tag, every step green, and a + release body that is a bare `## 0.0.1 (date)` heading with nothing + beneath it. Held at `9`, in `release.yml` and `make release-dry`, with + the reason beside both. **Check the rendered notes, never the exit + code.** +- **semantic-release core dry-run-pushes to the release branch** as a + permission check, independently of any plugin. `PACKAGE_TOKEN` had + package-write and repo-*read* — enough to clone, not enough for this — + and it failed with a flat `403 Forbidden` that reads exactly like + branch protection. It is not: a `--dry-run` push never reaches the + pre-receive hook, which a one-line experiment settled. The token needed + `write:repository`. +- **The floor tag must go on `HEAD^`, not `HEAD`.** Seeded on the merge + commit itself it leaves nothing between the floor and HEAD, and + semantic-release correctly reports there is nothing to release. The + first run did exactly that and cut nothing. +- **A tag-triggered workflow runs from the tagged commit's tree.** + Moving `v0.0.0` back to `6fb7b5e` ran the *pre-merge* homebrew + workflow, which predates the `v0.0.0` skip guard, and pushed a `0.0.0` + formula to the public tap. Self-corrected at `0.0.1`. The corollary is + general: a guard added today does not protect a tag pointing at + yesterday. + +## Two mechanisms confirmed, having been assumptions + +- **A tag pushed with a user PAT does start the `v*` workflows**; one + pushed with the Actions token does not (go-gitea#33123). Both halves + are load-bearing and both were observed: the floor seed triggered + nothing, and the release tag triggered all four. +- **Tags are not protected** on this repo, only `main` — which is what + lets semantic-release tag at all. + +## Left behind deliberately + +`v0.0.0` stays on `origin` as the floor. It carries no release, and all +four publishers skip it by name. -- 2.54.0