fix(release): recover a tag whose release never published #27

Merged
whitlocktech merged 1 commits from fix/release-tag-without-release into main 2026-08-05 18:09:40 +00:00
Member

What & why

Merging this cuts v1.1.0v1.1.1, which is what finally publishes the arm64 sidecar binary. That is the point of doing it now — see "Why this PR exists" below — but the fix is real on its own terms, not a pretext.

The bug

An existing tag was treated as "nothing to release", unconditionally:

if git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then
  echo "Tag v${VERSION} already exists — nothing to release."
  RELEASE=false
fi

That is wrong in the one case it matters. A tag with no release behind it means an earlier run tagged and then died before publishing — exactly what happened on servuo-plugins' first release, where absent REGISTRY_* secrets took the release API call to 401 after the tag had been pushed. Standing down on the tag alone makes that state permanent: every later run sees the tag, sets RELEASE=false, and the release never appears. The version becomes unpublishable forever, and the only way out is a human deleting a tag.

The fix

The tag decides nothing on its own; the API does.

Answer Action
200 a release exists — stand down
404 tag without release — reuse the tag and publish what is missing
anything else refuse, exit 1

The last row matters as much as the others: a 000 from a network failure or a 401 from a bad token is not evidence of absence, and guessing "no release" would republish over a good one.

The 404 arm deliberately overrides the RELEASE=false decided just above it. With the tag in place there are no releasable commits after it, so the normal path always stands down — which is precisely why this could never self-heal.

Two consequences handled with it:

  • The changelog range becomes previous-tag..this-tag on a recovery run. A run finishing an earlier one has nothing after the tag and would otherwise publish an empty change list.
  • The tagging step tolerates the tag already existing. git tag on an existing name fails under set -e; pushing an identical tag is a harmless no-op. A push that does fail there means the remote tag points somewhere else, which should stop the run.

This is the same handling installer/release.yml already carries — link was the copy that still had the trap.

How it was tested

The plan step was extracted from this branch's YAML and driven through four scenarios against a real clone with the live tags, with curl stubbed to return each status:

Scenario Result
Normal: v1.1.0 released, one fix: since release=true version=1.1.1 bump=patch
Tag v1.1.1 exists, no release (404) warns, release=true, reuses the tag, changelog from v1.1.0..v1.1.1
Tag v1.1.1 exists with a release (200) release=false — stands down
API unreachable (000) ::error::… Refusing to guess, exit 1, nothing published

release.yml also parses as YAML.

Why this PR exists now

link#26 added the aarch64 build, but it was typed ci(release): — and the engine only bumps on feat/fix/perf/breaking. So it correctly declined to release, and no arm64 binary was ever produced: bundles/current.json still names two assets, and installer PLAN.md §5.2 step 3 (promoting linux-aarch64 to a required bundle key) is blocked behind it. The commit type was the mistake — the diff was in CI, but the deliverable was a new artifact for users.

This is a genuine fix in the same file, so the patch bump it earns is honest, and v1.1.1 carries the arm64 binary as a side effect rather than as the excuse.

⚠ One thing to watch on the run

sidecar/Cargo.toml still says 0.1.0 after five releases, so the chore(release): bump version commit has apparently never landed on main — the version-bump step must be taking its "nothing staged" branch every time. This PR does not change that step, and five releases have published through it. If the push to main ever does start failing there, the job would die after tagging — which is now the recoverable state this PR creates, rather than a permanent one. Worth a look separately.

Checklist

  • I have read CONTRIBUTING.md.
  • The change builds and existing tests/checks pass locally.
  • I have added or updated tests/docs where it makes sense.
  • My commits are reasonably scoped with clear messages.

AI-assisted contributions (required)

  • No AI tools were used to produce this contribution.
  • AI tools were used. Tool(s): Claude Code (Opus 5). I have reviewed and understand every change, and take responsibility for it. AI-authored commits are marked with a Co-Authored-By trailer.

License

  • I agree that my contribution is licensed under this project's license (GNU GPL v3.0 or later), and I have the right to contribute it.
## What & why **Merging this cuts `v1.1.0` → `v1.1.1`, which is what finally publishes the arm64 sidecar binary.** That is the point of doing it now — see "Why this PR exists" below — but the fix is real on its own terms, not a pretext. ### The bug An existing tag was treated as *"nothing to release"*, unconditionally: ```bash if git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then echo "Tag v${VERSION} already exists — nothing to release." RELEASE=false fi ``` That is wrong in the one case it matters. A tag with **no release behind it** means an earlier run tagged and then died before publishing — exactly what happened on `servuo-plugins`' first release, where absent `REGISTRY_*` secrets took the release API call to `401` *after* the tag had been pushed. Standing down on the tag alone makes that state permanent: every later run sees the tag, sets `RELEASE=false`, and the release never appears. The version becomes unpublishable forever, and the only way out is a human deleting a tag. ### The fix The tag decides nothing on its own; the API does. | Answer | Action | |---|---| | `200` | a release exists — stand down | | `404` | tag without release — reuse the tag and publish what is missing | | anything else | refuse, exit 1 | The last row matters as much as the others: a `000` from a network failure or a `401` from a bad token is **not evidence of absence**, and guessing "no release" would republish over a good one. The `404` arm deliberately **overrides** the `RELEASE=false` decided just above it. With the tag in place there are no releasable commits after it, so the normal path always stands down — which is precisely why this could never self-heal. Two consequences handled with it: - **The changelog range** becomes `previous-tag..this-tag` on a recovery run. A run finishing an earlier one has nothing after the tag and would otherwise publish an empty change list. - **The tagging step tolerates the tag already existing.** `git tag` on an existing name fails under `set -e`; pushing an identical tag is a harmless no-op. A push that *does* fail there means the remote tag points somewhere else, which should stop the run. This is the same handling `installer/release.yml` already carries — `link` was the copy that still had the trap. ## How it was tested The `plan` step was extracted from this branch's YAML and driven through four scenarios against a **real clone with the live tags**, with `curl` stubbed to return each status: | Scenario | Result | |---|---| | Normal: `v1.1.0` released, one `fix:` since | `release=true version=1.1.1 bump=patch` | | Tag `v1.1.1` exists, **no** release (404) | warns, `release=true`, reuses the tag, changelog from `v1.1.0..v1.1.1` | | Tag `v1.1.1` exists **with** a release (200) | `release=false` — stands down | | API unreachable (000) | `::error::… Refusing to guess`, **exit 1**, nothing published | `release.yml` also parses as YAML. ## Why this PR exists now link#26 added the `aarch64` build, but it was typed `ci(release):` — and the engine only bumps on `feat`/`fix`/`perf`/breaking. So it correctly declined to release, and **no arm64 binary was ever produced**: `bundles/current.json` still names two assets, and installer PLAN.md §5.2 step 3 (promoting `linux-aarch64` to a required bundle key) is blocked behind it. The commit type was the mistake — the diff was in CI, but the deliverable was a new artifact for users. This is a genuine fix in the same file, so the patch bump it earns is honest, and `v1.1.1` carries the arm64 binary as a side effect rather than as the excuse. ## ⚠ One thing to watch on the run `sidecar/Cargo.toml` still says `0.1.0` after five releases, so the `chore(release): bump version` commit has apparently never landed on `main` — the version-bump step must be taking its "nothing staged" branch every time. This PR does not change that step, and five releases have published through it. **If the push to `main` ever does start failing there, the job would die after tagging** — which is now the recoverable state this PR creates, rather than a permanent one. Worth a look separately. ## Checklist - [x] I have read [CONTRIBUTING.md](CONTRIBUTING.md). - [x] The change builds and existing tests/checks pass locally. - [x] I have added or updated tests/docs where it makes sense. - [x] My commits are reasonably scoped with clear messages. ## AI-assisted contributions (required) - [ ] No AI tools were used to produce this contribution. - [x] AI tools were used. Tool(s): `Claude Code (Opus 5)`. I have reviewed and understand every change, and take responsibility for it. AI-authored commits are marked with a `Co-Authored-By` trailer. ## License - [x] I agree that my contribution is licensed under this project's license (**GNU GPL v3.0 or later**), and I have the right to contribute it.
wtclaude added 1 commit 2026-08-05 18:06:05 +00:00
fix(release): recover a tag whose release never published
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 2m15s
07021d38c9
An existing tag was treated as "nothing to release", unconditionally.
That is wrong in the one case it matters: a tag with no release behind
it means an earlier run tagged and then died before publishing -- which
is exactly what happened on servuo-plugins' first release, where absent
REGISTRY_* secrets took the release API call to 401 after the tag had
been pushed. Standing down on the tag alone makes that permanent. Every
later run sees the tag, sets RELEASE=false, and the release never
appears; the version is unpublishable forever.

The tag now decides nothing on its own. The API does:

  200 -> a release exists, stand down
  404 -> tag without release, reuse the tag and publish what is missing
  else -> refuse and exit 1

The last arm matters as much as the others. A 000 from a network failure
or a 401 from a bad token is not evidence of absence, and guessing "no
release" would republish over a good one.

Note the 404 arm deliberately overrides the RELEASE=false decided just
above it: with the tag in place there are no releasable commits after
it, so the normal path always stands down -- which is why this could
never self-heal on its own.

Two consequences handled with it. The changelog range is now
previous-tag..this-tag on a recovery run, since a run finishing an
earlier one has nothing after the tag and would otherwise publish an
empty change list. And the tagging step tolerates the tag already
existing, because `git tag` on an existing name fails under `set -e`
while pushing an identical tag is a harmless no-op -- a push that does
fail there means the remote tag points somewhere else, which should
stop the run.

This is the same handling installer/release.yml already carries; link
was the copy that still had the trap.

Verified by extracting this step and driving it through four scenarios
against a real clone with the live tags, with curl stubbed to return
each status: a normal patch bump (v1.1.1, release=true), a tag with no
release (recovers, release=true), a tag with a release (stands down),
and an unreachable API (exit 1, publishes nothing).

Co-Authored-By: Claude <noreply@anthropic.com>
whitlocktech merged commit 915f0296a9 into main 2026-08-05 18:09:40 +00:00
whitlocktech deleted branch fix/release-tag-without-release 2026-08-05 18:09:41 +00:00
Sign in to join this conversation.
No description provided.