Make a release a shipment rather than a merge #116

Merged
logan merged 2 commits from ci/115-manual-release into main 2026-08-19 02:36:01 +00:00
Collaborator

Answers #115.

What was wrong

release.yml fired on push: branches: [main], so the trigger was "a PR was merged" and nothing else decided. That is a version per unit of work rather than per shipment:

releases in 22 hours 8 (v0.0.1v0.3.1)
issues actually shipped 3
publisher jobs booked ~40, on a runner with capacity 1

Ordinary PR CI queued behind those repeatedly during the session, and pacman, Homebrew and Obtainium saw every version.

Commits

Commit Issue
ci: skip a prerelease tag in all four publishers #115
ci: make a release a shipment rather than a merge #115

Two commits so the guard can be dropped independently of the trigger change.

The change

The push trigger is gone; workflow_dispatch — already present, already working — is the whole mechanism.

Nothing else had to change to batch releases. semantic-release already reads every commit since the last tag, so five fixes and two feats become one minor release with all seven in the notes. Release frequency was only ever how often the workflow fired.

This is the rule index-artifact.yml already states and is now the other instance of: a job that mutates state which cannot be rebuilt in ten minutes is triggered deliberately, not by a push. A release here is a tag, a Gitea release, an Arch package, a Homebrew formula, a signed APK and desktop assets.

dry_run is what makes a manual trigger usable. The point of pulling a lever by hand is being able to look first, so the input runs semantic-release --dry-run: the version and the notes, no tag, no release, no publishers. Anything but the literal string true releases for real — a typo in a dispatch box must not silently turn a shipment into a green no-op.

The prerelease guard

All four publishers trigger on v*, which matches v0.4.0-beta.1. They guarded v0.0.0 and nothing else.

Nothing produces a prerelease today; the guard is there because the thing that would is prerelease: true in .releaserc.yml — one line, whose blast radius is four public channels, and which none of those four files mentions. Same argument release.yml's chore(release): guard is kept on.

android-apk is the worst of the four twice over: the APK goes to the credential-free generic registry Obtainium polls, and its versionCode maths splits on dots — it would read 1 out of 0-beta, a wrong number rather than a failed build, and Android refuses anything not greater than what is installed.

Verification actually run

  • All five workflow files parse as YAML (yaml.safe_load), and release.yml's triggers assert to exactly ['workflow_dispatch'].
  • The guard's case glob checked against both forms it appears in (tag with v, version without) across v0.0.0, v0.3.1, v1.0.0, v10.20.30, v0.4.0-beta.1, v0.4.0-rc.1 — the three-part versions build, the prereleases skip.
  • The dry_run shell checked against true / false / empty / True: only the literal true adds --dry-run.
  • make skill-check clean.

Both commits are ci:, so this PR releases nothing — which is the behaviour it is introducing, exercised on itself.

Rejected, and recorded on the issue

  • A beta integration branch. It relocates the trigger rather than removing one: a second protected branch carrying the same required checks (main has enable_push: false, empty whitelist, CI / check* + CI / e2e*), and it adds a full check + e2e run per batch on the very runner whose queue is the complaint.
  • A schedule. A cron batches without anyone having to remember, but it puts the decision back on a timer, which is the thing being removed.

After this merges

Releasing is: run Release from the Actions tab, dry_run: true to look, then again to ship. make release-dry is the same answer locally.

Closes #115

Answers #115. ## What was wrong `release.yml` fired on `push: branches: [main]`, so the trigger was "a PR was merged" and nothing else decided. That is a version per unit of **work** rather than per **shipment**: | | | | --- | --- | | releases in 22 hours | 8 (`v0.0.1` → `v0.3.1`) | | issues actually shipped | 3 | | publisher jobs booked | ~40, on a runner with **capacity 1** | Ordinary PR CI queued behind those repeatedly during the session, and pacman, Homebrew and Obtainium saw every version. ## Commits | Commit | Issue | | --- | --- | | `ci: skip a prerelease tag in all four publishers` | #115 | | `ci: make a release a shipment rather than a merge` | #115 | Two commits so the guard can be dropped independently of the trigger change. ## The change The push trigger is gone; `workflow_dispatch` — already present, already working — is the whole mechanism. **Nothing else had to change to batch releases.** semantic-release already reads every commit since the last tag, so five `fix`es and two `feat`s become one minor release with all seven in the notes. Release frequency was only ever how often the workflow fired. This is the rule `index-artifact.yml` already states and is now the other instance of: *a job that mutates state which cannot be rebuilt in ten minutes is triggered deliberately, not by a push.* A release here is a tag, a Gitea release, an Arch package, a Homebrew formula, a signed APK and desktop assets. **`dry_run` is what makes a manual trigger usable.** The point of pulling a lever by hand is being able to look first, so the input runs `semantic-release --dry-run`: the version and the notes, no tag, no release, no publishers. Anything but the literal string `true` releases for real — a typo in a dispatch box must not silently turn a shipment into a green no-op. ## The prerelease guard All four publishers trigger on `v*`, **which matches `v0.4.0-beta.1`**. They guarded `v0.0.0` and nothing else. Nothing produces a prerelease today; the guard is there because the thing that would is `prerelease: true` in `.releaserc.yml` — one line, whose blast radius is four public channels, and which none of those four files mentions. Same argument `release.yml`'s `chore(release):` guard is kept on. `android-apk` is the worst of the four twice over: the APK goes to the credential-free generic registry Obtainium polls, and its `versionCode` maths splits on dots — it would read `1` out of `0-beta`, a wrong number rather than a failed build, and Android refuses anything not greater than what is installed. ## Verification actually run - All five workflow files parse as YAML (`yaml.safe_load`), and `release.yml`'s triggers assert to exactly `['workflow_dispatch']`. - The guard's `case` glob checked against both forms it appears in (tag with `v`, version without) across `v0.0.0`, `v0.3.1`, `v1.0.0`, `v10.20.30`, `v0.4.0-beta.1`, `v0.4.0-rc.1` — the three-part versions build, the prereleases skip. - The `dry_run` shell checked against `true` / `false` / empty / `True`: only the literal `true` adds `--dry-run`. - `make skill-check` clean. **Both commits are `ci:`, so this PR releases nothing** — which is the behaviour it is introducing, exercised on itself. ## Rejected, and recorded on the issue - **A `beta` integration branch.** It relocates the trigger rather than removing one: a second protected branch carrying the same required checks (`main` has `enable_push: false`, empty whitelist, `CI / check*` + `CI / e2e*`), and it *adds* a full `check` + `e2e` run per batch on the very runner whose queue is the complaint. - **A schedule.** A cron batches without anyone having to remember, but it puts the decision back on a timer, which is the thing being removed. ## After this merges Releasing is: run **Release** from the Actions tab, `dry_run: true` to look, then again to ship. `make release-dry` is the same answer locally. Closes #115
logan added 2 commits 2026-08-19 02:25:43 +00:00
Their trigger is `v*`, which matches `v0.4.0-beta.1`. They guarded
`v0.0.0` -- the version floor -- and nothing else, so the first
prerelease tag would have published a beta everywhere.

Nothing produces one today. The guard is here because the thing that
would is `prerelease: true` in .releaserc.yml, a one-line change whose
blast radius is four public channels and which nothing in those four
files mentions. That is the same argument release.yml's `chore(release):`
guard is kept on: cheap, against something a future edit turns on
somewhere else entirely.

android-apk is the worst of the four twice over. Its APK goes to the
*generic* registry, which is readable without credentials so Obtainium
can poll a plain URL, so a beta would be offered to every device on it.
And its versionCode maths splits on dots: it would read "1" out of
"0-beta" and produce a wrong number rather than a failed build, which
matters because Android orders releases by that integer and refuses
anything not greater than what is installed.

Each is a clean skip rather than a failure, matching the v0.0.0 guard
beside it: a red run against a tag that was never meant to ship is noise.
ci: make a release a shipment rather than a merge
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m25s
CI / e2e (pull_request) Successful in 6m13s
90f1239fba
release.yml fired on every push to main, so the trigger was "a PR was
merged" and nothing else decided. That is a version per unit of *work*
rather than per *shipment*: eight releases in twenty-two hours, v0.0.1
through v0.3.1, for one session -- each fanning out to four publishers on
a runner with capacity 1, so roughly forty packaging jobs shipped three
issues while ordinary PR CI queued behind them. pacman, Homebrew and
Obtainium see every one.

The push trigger is gone and workflow_dispatch, which was already there
and already worked, is the whole mechanism. Nothing else had to change to
batch releases, because semantic-release already reads every commit since
the last tag: five fixes and two feats become one minor release with all
seven in the notes. Release frequency was only ever how often this file
fired.

This is the rule index-artifact.yml states and is the other instance of:
a job that mutates state which cannot be rebuilt in ten minutes is
triggered deliberately, not by a push. A release here is a tag, a Gitea
release, an Arch package, a Homebrew formula, a signed APK and desktop
assets -- and an Android version going backwards costs the user their
library.

`dry_run` is what makes a manual trigger usable: the point of pulling a
lever by hand is being able to look first, so the input runs
semantic-release --dry-run -- the version and the notes, no tag, no
release, no publishers. Anything but the literal string "true" releases
for real, because a typo in a dispatch box must not silently turn a
shipment into a green no-op.

Two alternatives were considered and rejected, both recorded on the
issue. A `beta` integration branch relocates the trigger rather than
removing one: it needs a second protected branch carrying the same
required checks, and it *adds* a full check + e2e run per batch on the
very runner whose queue is the complaint. A schedule batches without
anyone having to remember, but puts the decision back on a timer, which
is the thing being removed.

Closes #115
logan merged commit fc99d9e0d7 into main 2026-08-19 02:36:01 +00:00
Sign in to join this conversation.