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
11 changed files with 214 additions and 33 deletions
+17
View File
@@ -139,6 +139,23 @@ jobs:
echo "skip=true" >> "$GITHUB_OUTPUT" echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0 exit 0
fi fi
# Nor is a prerelease, and this trigger is `v*`, which matches
# `v0.4.0-beta.1`. Two reasons it is worst here. The APK goes
# to the *generic* registry, which is readable without
# credentials so Obtainium can poll a plain URL — a beta would
# be offered to every device on it. And the versionCode maths
# below splits on dots and would read "1" out of "0-beta",
# producing a code that is wrong rather than a build that
# fails: Android orders releases by that integer and refuses
# anything not greater than what is installed.
case "$v" in
*-*)
echo "v$v is a prerelease; not publishing an APK for it"
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
;;
esac
echo "skip=false" >> "$GITHUB_OUTPUT" echo "skip=false" >> "$GITHUB_OUTPUT"
# Android orders releases by an integer and refuses anything # Android orders releases by an integer and refuses anything
+16
View File
@@ -72,6 +72,22 @@ jobs:
exit 0 exit 0
fi fi
# A prerelease is not a shipment either, and this trigger is
# `v*` — which matches `v0.4.0-beta.1`. Nothing produces one
# today; the guard is here because the thing that would is
# semantic-release's `prerelease: true` channel, a one-line
# change in .releaserc.yml whose blast radius is four public
# package channels. Same argument as release.yml's
# `chore(release):` guard: cheap, against something a future
# edit turns on somewhere else entirely.
case "$v" in
*-*)
echo "$v is a prerelease; not packaging it for pacman"
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
;;
esac
echo "skip=false" >> "$GITHUB_OUTPUT" echo "skip=false" >> "$GITHUB_OUTPUT"
echo "tag=$v" >> "$GITHUB_OUTPUT" echo "tag=$v" >> "$GITHUB_OUTPUT"
echo "building $v" echo "building $v"
+13
View File
@@ -95,6 +95,19 @@ jobs:
exit 0 exit 0
fi fi
# Nor is a prerelease, and this trigger is `v*`, which matches
# `v0.4.0-beta.1`. The mildest of the four — assets attach to
# the prerelease's own Gitea release and no package manager
# reads them — but four workflows sharing one trigger should
# share one answer about what a shipment is.
case "$v" in
*-*)
echo "$v is a prerelease; not attaching desktop assets"
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
;;
esac
echo "skip=false" >> "$GITHUB_OUTPUT" echo "skip=false" >> "$GITHUB_OUTPUT"
echo "tag=$v" >> "$GITHUB_OUTPUT" echo "tag=$v" >> "$GITHUB_OUTPUT"
echo "version=${v#v}" >> "$GITHUB_OUTPUT" echo "version=${v#v}" >> "$GITHUB_OUTPUT"
+12
View File
@@ -56,6 +56,18 @@ jobs:
echo "skip=true" >> "$GITHUB_OUTPUT" echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0 exit 0
fi fi
# Nor is a prerelease, and this trigger is `v*`, which matches
# `v0.4.0-beta.1`. It matters most here of the four: the tap
# is public, and `brew upgrade` would offer a beta to everyone
# on it.
case "$VERSION" in
*-*)
echo "$TAG is a prerelease; not syncing it to a public tap"
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
;;
esac
echo "skip=false" >> "$GITHUB_OUTPUT" echo "skip=false" >> "$GITHUB_OUTPUT"
TARBALL="${SOURCE_TARBALL_BASE}/${TAG}.tar.gz" TARBALL="${SOURCE_TARBALL_BASE}/${TAG}.tar.gz"
+54 -8
View File
@@ -1,11 +1,36 @@
name: Release name: Release
# The sixth workflow, and the one that decides whether the other three # The sixth workflow, and the one that decides whether the other three
# run at all. On every push to main it reads the Conventional Commits # run at all. It reads the Conventional Commits since the last tag, and
# since the last tag, and if any of them is releasable it writes the # if any of them is releasable it writes the changelog, pushes the tag,
# changelog, pushes the tag, and creates the Gitea release whose body is # and creates the Gitea release whose body is that changelog section.
# that changelog section. The publishing workflows are keyed on `v*`, so # The publishing workflows are keyed on `v*`, so the tag push is what
# the tag push is what starts them. # starts them.
#
# **It is triggered by hand, and there is deliberately no `push`
# trigger.** There was one, on `main`, which made the trigger "a PR was
# merged" and nothing else: eight releases in twenty-two hours
# (v0.0.1 -> v0.3.1) for one session's work, each fanning out to four
# publishers on a runner with capacity 1, so ~40 packaging jobs shipped
# three issues and ordinary PR CI queued behind them. A version per
# merged PR is a version per unit of *work*, not per *shipment*, and
# pacman, Homebrew and Obtainium see every one.
#
# Nothing else had to change to batch them: 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 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 (docs/android-release.md).
#
# A schedule was considered and rejected: a cron batches without anyone
# having to remember, but it puts the decision back on a timer, which is
# the thing being removed.
# #
# **Why the tag is pushed with PACKAGE_TOKEN and not the Actions token.** # **Why the tag is pushed with PACKAGE_TOKEN and not the Actions token.**
# Gitea, like GitHub, does not start a workflow from a ref pushed by a # Gitea, like GitHub, does not start a workflow from a ref pushed by a
@@ -19,9 +44,12 @@ name: Release
# instead. # instead.
on: on:
push:
branches: [main]
workflow_dispatch: workflow_dispatch:
inputs:
dry_run:
description: "Report what would be released and stop"
required: false
default: "false"
# Cutting a tag is not a thing to cancel halfway: a superseded run must # Cutting a tag is not a thing to cancel halfway: a superseded run must
# finish, not be killed between `git push --tags` and the release POST. # finish, not be killed between `git push --tags` and the release POST.
@@ -163,14 +191,32 @@ jobs:
# been right, the tag would have been right, every job would have # been right, the tag would have been right, every job would have
# been green, and the release body would have been empty. Check the # been green, and the release body would have been empty. Check the
# notes, not the exit code, before moving any of these. # notes, not the exit code, before moving any of these.
# The point of a manual trigger is deliberateness, and deliberate
# means being able to look before pulling the lever. `--dry-run`
# reports the version and the notes and writes nothing: no tag, no
# release, no publishers. `make release-dry` is the same answer
# locally; this is it from the runner, against the same commit and
# the same tag history, which is what actually decides.
- name: Run semantic-release - name: Run semantic-release
if: steps.guard.outputs.skip == 'false' if: steps.guard.outputs.skip == 'false'
working-directory: /src working-directory: /src
env:
DRY_RUN: ${{ inputs.dry_run }}
run: | run: |
set -eu set -eu
git config user.name "yellowjacket-ci" git config user.name "yellowjacket-ci"
git config user.email "yj@yellowjacket.app" git config user.email "yj@yellowjacket.app"
# Anything but a literal "true" releases for real. A typo in a
# dispatch box must not silently turn a shipment into a no-op
# that reports success — the failure worth avoiding is the one
# where nothing happens and the run is green.
dry=""
if [ "${DRY_RUN:-false}" = "true" ]; then
echo "DRY RUN — no tag will be pushed and no release created"
dry="--dry-run"
fi
npx --yes \ npx --yes \
-p semantic-release@25 \ -p semantic-release@25 \
-p @semantic-release/commit-analyzer@13 \ -p @semantic-release/commit-analyzer@13 \
@@ -178,5 +224,5 @@ jobs:
-p @semantic-release/changelog@7 \ -p @semantic-release/changelog@7 \
-p @semantic-release/exec@7 \ -p @semantic-release/exec@7 \
-p conventional-changelog-conventionalcommits@9 \ -p conventional-changelog-conventionalcommits@9 \
semantic-release \ semantic-release $dry \
--repository-url "https://x-access-token:${PACKAGE_TOKEN}@${SERVER_URL#https://}/${REPO}.git" --repository-url "https://x-access-token:${PACKAGE_TOKEN}@${SERVER_URL#https://}/${REPO}.git"
+14 -3
View File
@@ -1,8 +1,19 @@
# semantic-release configuration. # semantic-release configuration.
# #
# Runs on pushes to main from .gitea/workflows/release.yml: determine the # Run by hand from .gitea/workflows/release.yml, which has no push
# version from the Conventional Commits since the last tag, write the # trigger: determine the version from the Conventional Commits since the
# changelog, commit it, push the tag, and create the Gitea release. # last tag, write the changelog, push the tag, and create the Gitea
# release. A release is a shipment rather than a merge, and the commits
# accumulate until someone says so -- this file needs to know nothing
# about that, because reading everything since the last tag is what it
# already did.
#
# `branches` is main and only main. A `prerelease: true` channel is the
# obvious next edit here and is the one to think twice about: all four
# publishing workflows trigger on `v*`, which matches `v0.4.0-beta.1`.
# They carry a prerelease guard now, so the failure is a clean skip
# rather than a beta in a public tap -- but they are four separate files
# and this is the line that would turn them on.
# #
# **There is no `@semantic-release/github` plugin here and there must not # **There is no `@semantic-release/github` plugin here and there must not
# be.** Gitea's API is `/api/v1` and is not GitHub's surface. The Gitea # be.** Gitea's API is `/api/v1` and is not GitHub's surface. The Gitea
+10 -5
View File
@@ -5,15 +5,20 @@ The changelog is the releases page:
<https://git.ljones.me/yonlu/yellowjacket/releases> <https://git.ljones.me/yonlu/yellowjacket/releases>
Every release there is generated from the Conventional Commits it Every release there is generated from the Conventional Commits it
contains, by `.gitea/workflows/release.yml` on merge to `main`. Each one contains, by `.gitea/workflows/release.yml`. Each one carries its notes
carries its notes as its body, grouped by change type, with a link to the as its body, grouped by change type, with a link to the commit behind
commit behind every line. every line.
That workflow is **run by hand**, so a release holds everything merged
since the last one rather than one PR's worth. It used to fire on every
push to `main`, which made a version per merged PR (issue #115).
**This file is not generated and is not a copy of that.** `main` is a **This file is not generated and is not a copy of that.** `main` is a
protected branch, so nothing pushes a changelog commit back to it — and a protected branch, so nothing pushes a changelog commit back to it — and a
file that claimed to be a changelog while silently never updating would file that claimed to be a changelog while silently never updating would
be worse than no file at all. `make release-dry` prints what the next be worse than no file at all. `make release-dry` prints what a release
merge would release. run would cut right now, and the workflow's own `dry_run` input answers
the same question from CI.
History before `v0.0.1` is in `git log`. The versions before it were cut History before `v0.0.1` is in `git log`. The versions before it were cut
by hand and are not on the releases page; the entries this file used to by hand and are not on the releases page; the entries this file used to
+44 -4
View File
@@ -2309,13 +2309,53 @@ those run at all; `unclaim.yml` is housekeeping on the tracker and
touches no code; only `ci.yml` gates, and it is the one to look at when touches no code; only `ci.yml` gates, and it is the one to look at when
deciding whether a push was healthy. deciding whether a push was healthy.
**`release.yml` is the entry point for all of it.** On every push to **`release.yml` is the entry point for all of it, and it is triggered by
`main` it reads the Conventional Commits since the last tag and, if any hand.** It reads the Conventional Commits since the last tag and, if any
is releasable, writes the changelog, pushes the tag and creates the Gitea is releasable, writes the changelog, pushes the tag and creates the Gitea
release whose body is that changelog section. `arch-package`, release whose body is that changelog section. `arch-package`,
`homebrew-formula`, `android-apk` and `desktop-assets` are all keyed on `homebrew-formula`, `android-apk` and `desktop-assets` are all keyed on
`v*`, so **the tag push is what starts them**nothing is released by `v*`, so **the tag push is what starts them**the version, the notes
hand any more. and the packaging are still nobody's manual work; *when* is the only
decision left to a person.
**It used to fire on every push to `main`, which made the trigger "a PR
was merged".** That is a version per unit of *work* rather than per
*shipment*: eight releases in twenty-two hours (`v0.0.1``v0.3.1`) for
one session, each fanning out to four publishers on a runner with
capacity 1 — ~40 packaging jobs to ship three issues, with ordinary PR CI
queued behind them. Nothing else had to change to batch them, because
**semantic-release already reads every commit since the last tag**: 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 same rule `index-artifact.yml` states — *a job that mutates
state which cannot be rebuilt in ten minutes is triggered deliberately,
not by a push* — and the two are now the only workflows with no push
trigger. A schedule was considered and rejected: a cron batches without
anyone having to remember, but it puts the decision back on a timer,
which is the thing being removed. A `beta` integration branch was
considered and rejected too (#115): it relocates the trigger rather than
removing one, needs a second protected branch carrying the same required
checks, and *adds* a full `check` + `e2e` run per batch on the very
runner whose queue is the complaint.
**`dry_run` is why the manual trigger is usable.** The point of pulling
a lever by hand is being able to look first, so the dispatch takes a
flag that 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.
**A prerelease tag is not a shipment, and all four publishers now say
so.** Their trigger is `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 a public Homebrew tap
and a credential-free APK registry that Obtainium polls. `android-apk`
is the worst of the four twice over, since its `versionCode` maths
splits on dots and 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.
Four things about it are load-bearing: Four things about it are load-bearing:
+11 -5
View File
@@ -192,15 +192,21 @@ skill-check: ## Fail if the agent docs name a missing make target, or AGENTS.md
commit-check: ## Fail if a commit subject is not a Conventional Commit commit-check: ## Fail if a commit subject is not a Conventional Commit
@./scripts/commit-check.sh $(if $(RANGE),--range $(RANGE)) @./scripts/commit-check.sh $(if $(RANGE),--range $(RANGE))
# What a merge to main would release, without releasing it. Reads the # What running the release workflow now would ship, without shipping it.
# same .releaserc.yml CI does, so "why did that not cut a version" is # Reads the same .releaserc.yml CI does, so "why did that not cut a
# answerable locally instead of by pushing and watching. Needs no # version" is answerable locally instead of by pushing and watching.
# credentials: --dry-run neither tags nor publishes. # Needs no credentials: --dry-run neither tags nor publishes.
#
# release.yml is dispatch-only, so this answers the question that
# actually gets asked now -- what has accumulated since the last tag --
# rather than what one merge would have done. The workflow's own
# `dry_run` input is the same answer from the runner, against whatever
# main points at rather than the working tree.
# #
# The pins must stay identical to release.yml's, which is where the note # The pins must stay identical to release.yml's, which is where the note
# on holding the conventionalcommits preset at 9 lives -- at 10 the # on holding the conventionalcommits preset at 9 lives -- at 10 the
# release notes come out empty with everything green. # release notes come out empty with everything green.
release-dry: ## Print the version a merge to main would release release-dry: ## Print the version a release run would cut right now
@npx --yes \ @npx --yes \
-p semantic-release@25 \ -p semantic-release@25 \
-p @semantic-release/commit-analyzer@13 \ -p @semantic-release/commit-analyzer@13 \
+16 -6
View File
@@ -7,12 +7,22 @@ which is what lets Obtainium poll a plain URL with no token. It also
attaches the same file to the Gitea release, which is what a person attaches the same file to the Gitea release, which is what a person
looking at the release page downloads. looking at the release page downloads.
**Tags are not pushed by hand any more.** `.gitea/workflows/release.yml` **Tags are not pushed by hand any more, but releasing is a decision.**
reads the Conventional Commits on every merge to `main`, decides the `.gitea/workflows/release.yml` reads the Conventional Commits since the
version, and pushes the tag this workflow is keyed on — so releasing the last tag, decides the version, and pushes the tag this workflow is keyed
APK means merging a `fix:` or `feat:` commit, not running `git tag`. The on — so releasing the APK means **running that workflow**, not running
`workflow_dispatch` path below remains, for rebuilding a tag that already `git tag`. It has no push trigger: merging a `fix:` or `feat:` used to
exists. be enough and produced a version per merged PR (issue #115). Run it with
`dry_run` first to see what the accumulated commits would ship. The
`workflow_dispatch` path below is a different thing and remains, for
rebuilding a tag that already exists.
**A prerelease tag is skipped here**, cleanly. This workflow triggers on
`v*`, which matches `v0.4.0-beta.1`, and it is the one where that would
hurt most: the APK goes to the credential-free generic registry that
Obtainium polls, and the `versionCode` maths below splits on dots — it
would read `1` out of `0-beta` and produce a wrong number rather than a
failed build.
## The 1.x installs cannot be upgraded to 0.0.x ## The 1.x installs cannot be upgraded to 0.0.x
+7 -2
View File
@@ -1,10 +1,15 @@
# YellowJacket Arch package # YellowJacket Arch package
This directory holds the `PKGBUILD` and desktop entry used to build the This directory holds the `PKGBUILD` and desktop entry used to build the
Arch Linux package. CI builds it on every push to `main` (see Arch Linux package. CI builds it on every **release tag** (see
`.gitea/workflows/arch-package.yml`) and publishes it to the Gitea Arch `.gitea/workflows/arch-package.yml`) and publishes it to the Gitea Arch
package registry, from which pacman can install it directly. package registry, from which pacman can install it directly.
Tags come from `.gitea/workflows/release.yml`, which is run by hand — it
used to fire on every push to `main`, which meant a new package per
merged PR (issue #115). A prerelease tag (`v0.4.0-beta.1`) is skipped:
the workflow's trigger is `v*` and matches one.
## Installing from the registry ## Installing from the registry
The registry is public — no login required. The registry is public — no login required.
@@ -58,7 +63,7 @@ this is not recommended.
- Only the runtime package is published; the `-debug` package makepkg - Only the runtime package is published; the `-debug` package makepkg
produces (detached symbols) is skipped by the workflow. produces (detached symbols) is skipped by the workflow.
- Package versions come from `pkgver()` in the PKGBUILD, derived from git - Package versions come from `pkgver()` in the PKGBUILD, derived from git
(e.g. `1.3.0.r173.g4ae5ffc-1`), so every push to `main` yields a new (e.g. `1.3.0.r173.g4ae5ffc-1`), so every release tag yields a new
version. version.
- Repo priority: if another configured repo ever provides a package named - Repo priority: if another configured repo ever provides a package named
`yellowjacket`, the repo listed **first** in `pacman.conf` wins. Force a `yellowjacket`, the repo listed **first** in `pacman.conf` wins. Force a