diff --git a/.gitea/workflows/android-apk.yml b/.gitea/workflows/android-apk.yml index 93165d4..94e6c06 100644 --- a/.gitea/workflows/android-apk.yml +++ b/.gitea/workflows/android-apk.yml @@ -139,6 +139,23 @@ jobs: echo "skip=true" >> "$GITHUB_OUTPUT" exit 0 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" # Android orders releases by an integer and refuses anything diff --git a/.gitea/workflows/arch-package.yml b/.gitea/workflows/arch-package.yml index 701d386..82070d4 100644 --- a/.gitea/workflows/arch-package.yml +++ b/.gitea/workflows/arch-package.yml @@ -72,6 +72,22 @@ jobs: exit 0 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 "tag=$v" >> "$GITHUB_OUTPUT" echo "building $v" diff --git a/.gitea/workflows/desktop-assets.yml b/.gitea/workflows/desktop-assets.yml index 34db86e..a993830 100644 --- a/.gitea/workflows/desktop-assets.yml +++ b/.gitea/workflows/desktop-assets.yml @@ -95,6 +95,19 @@ jobs: exit 0 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 "tag=$v" >> "$GITHUB_OUTPUT" echo "version=${v#v}" >> "$GITHUB_OUTPUT" diff --git a/.gitea/workflows/homebrew-formula.yml b/.gitea/workflows/homebrew-formula.yml index c19d14e..1bb698b 100644 --- a/.gitea/workflows/homebrew-formula.yml +++ b/.gitea/workflows/homebrew-formula.yml @@ -56,6 +56,18 @@ jobs: echo "skip=true" >> "$GITHUB_OUTPUT" exit 0 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" TARBALL="${SOURCE_TARBALL_BASE}/${TAG}.tar.gz" diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 4216ef3..965172a 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -1,11 +1,36 @@ name: Release # 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 -# since the last tag, and if any of them is releasable it writes the -# changelog, pushes the tag, and creates the Gitea release whose body is -# that changelog section. The publishing workflows are keyed on `v*`, so -# the tag push is what starts them. +# run at all. It reads the Conventional Commits since the last tag, and +# if any of them is releasable it writes the changelog, pushes the tag, +# and creates the Gitea release whose body is that changelog section. +# The publishing workflows are keyed on `v*`, so the tag push is what +# 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.** # Gitea, like GitHub, does not start a workflow from a ref pushed by a @@ -19,9 +44,12 @@ name: Release # instead. on: - push: - branches: [main] 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 # 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 green, and the release body would have been empty. Check the # 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 if: steps.guard.outputs.skip == 'false' working-directory: /src + env: + DRY_RUN: ${{ inputs.dry_run }} run: | set -eu git config user.name "yellowjacket-ci" 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 \ -p semantic-release@25 \ -p @semantic-release/commit-analyzer@13 \ @@ -178,5 +224,5 @@ jobs: -p @semantic-release/changelog@7 \ -p @semantic-release/exec@7 \ -p conventional-changelog-conventionalcommits@9 \ - semantic-release \ + semantic-release $dry \ --repository-url "https://x-access-token:${PACKAGE_TOKEN}@${SERVER_URL#https://}/${REPO}.git" diff --git a/.releaserc.yml b/.releaserc.yml index 1c375b2..dd9825a 100644 --- a/.releaserc.yml +++ b/.releaserc.yml @@ -1,8 +1,19 @@ # semantic-release configuration. # -# Runs on pushes to main from .gitea/workflows/release.yml: determine the -# version from the Conventional Commits since the last tag, write the -# changelog, commit it, push the tag, and create the Gitea release. +# Run by hand from .gitea/workflows/release.yml, which has no push +# trigger: determine the version from the Conventional Commits since the +# 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 # be.** Gitea's API is `/api/v1` and is not GitHub's surface. The Gitea diff --git a/CHANGELOG.md b/CHANGELOG.md index f58ba04..d051065 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,15 +5,20 @@ The changelog is the releases page: Every release there is generated from the Conventional Commits it -contains, by `.gitea/workflows/release.yml` on merge to `main`. Each one -carries its notes as its body, grouped by change type, with a link to the -commit behind every line. +contains, by `.gitea/workflows/release.yml`. Each one carries its notes +as its body, grouped by change type, with a link to the commit behind +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 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 -be worse than no file at all. `make release-dry` prints what the next -merge would release. +be worse than no file at all. `make release-dry` prints what a 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 by hand and are not on the releases page; the entries this file used to diff --git a/CLAUDE.md b/CLAUDE.md index a9a1f1a..f4d804e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 deciding whether a push was healthy. -**`release.yml` is the entry point for all of it.** On every push to -`main` it reads the Conventional Commits since the last tag and, if any +**`release.yml` is the entry point for all of it, and it is triggered by +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 release whose body is that changelog section. `arch-package`, `homebrew-formula`, `android-apk` and `desktop-assets` are all keyed on -`v*`, so **the tag push is what starts them** — nothing is released by -hand any more. +`v*`, so **the tag push is what starts them** — the version, the notes +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: diff --git a/Makefile b/Makefile index 83354a0..83008a4 100644 --- a/Makefile +++ b/Makefile @@ -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 @./scripts/commit-check.sh $(if $(RANGE),--range $(RANGE)) -# What a merge to main would release, without releasing it. Reads the -# same .releaserc.yml CI does, so "why did that not cut a version" is -# answerable locally instead of by pushing and watching. Needs no -# credentials: --dry-run neither tags nor publishes. +# What running the release workflow now would ship, without shipping it. +# Reads the same .releaserc.yml CI does, so "why did that not cut a +# version" is answerable locally instead of by pushing and watching. +# 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 # on holding the conventionalcommits preset at 9 lives -- at 10 the # 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 \ -p semantic-release@25 \ -p @semantic-release/commit-analyzer@13 \ diff --git a/docs/android-release.md b/docs/android-release.md index 67f3999..923b276 100644 --- a/docs/android-release.md +++ b/docs/android-release.md @@ -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 looking at the release page downloads. -**Tags are not pushed by hand any more.** `.gitea/workflows/release.yml` -reads the Conventional Commits on every merge to `main`, decides the -version, and pushes the tag this workflow is keyed on — so releasing the -APK means merging a `fix:` or `feat:` commit, not running `git tag`. The -`workflow_dispatch` path below remains, for rebuilding a tag that already -exists. +**Tags are not pushed by hand any more, but releasing is a decision.** +`.gitea/workflows/release.yml` reads the Conventional Commits since the +last tag, decides the version, and pushes the tag this workflow is keyed +on — so releasing the APK means **running that workflow**, not running +`git tag`. It has no push trigger: merging a `fix:` or `feat:` used to +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 diff --git a/packaging/arch/README.md b/packaging/arch/README.md index 336c173..a0bfc13 100644 --- a/packaging/arch/README.md +++ b/packaging/arch/README.md @@ -1,10 +1,15 @@ # YellowJacket Arch package 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 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 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 produces (detached symbols) is skipped by the workflow. - 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. - Repo priority: if another configured repo ever provides a package named `yellowjacket`, the repo listed **first** in `pacman.conf` wins. Force a