fix(release): preflight credentials and recover the orphaned v0.1.0 tag #8

Merged
whitlocktech merged 1 commits from fix/release-credential-preflight into main 2026-08-04 15:26:56 +00:00
Member

What & why

The first release run (task 496, after #7 merged) tagged the repo and then failed, leaving v0.1.0 with no release behind it — and no way to ever get one.

What actually happened

REGISTRY_USER / REGISTRY_TOKEN are not configured on this repo. The log shows them empty:

env:
  REGISTRY_USER: 
  REGISTRY_TOKEN: 

But the tag push succeeded anyway:

 * [new tag]         v0.1.0 -> v0.1.0

actions/checkout leaves an http.<host>.extraheader credential in the local git config, so git remote set-url to a URL with empty credentials still authenticated through that leftover header. The release API call had no such fallback:

curl: (22) The requested URL returned error: 401

So the run got exactly far enough to do the one thing that's hard to undo. Everything before it was correct — plan v0.1.0, gates green (22 scripts, 3 patches), protocol=3, file_count=30, tarball built.

Why it couldn't self-heal

The plan step treated any existing tag as "nothing to release". So every subsequent push to main would see v0.1.0, set RELEASE=false, and stand down. The release would never appear, and re-running would never help. That's the more serious of the two bugs — the missing secret is a config gap, this was a design flaw that turned a transient failure permanent.

The fixes

1. Credential preflight — before anything is built or pushed, gated on the run intending to publish so a docs:/chore:-only merge still passes on a repo without secrets. Names the missing secrets and the scope they need, instead of failing at whichever step happens to use them first.

2. Orphan-tag recovery — the plan step asks the API whether a release exists for the tag:

Result Action
200 Release exists → stand down, nothing to do
404 An earlier run died after tagging → reuse the tag, publish the release it's missing
anything else Refuse. A network failure or bad token is not evidence of absence, and guessing "no release" would republish over a good one

The 404 branch deliberately overrides the RELEASE=false the bump logic just decided — with the tag already in place there are no releasable commits after it, which is exactly why the state was stuck.

Supporting changes: the tag step reuses an existing tag rather than failing on git tag, and a recovery run's changelog summarizes what the tag contains (previous-tag..this-tag) instead of the empty range after it.

How it was tested

Extracted every run: block and exercised each path — including against live repo state, not just synthetic fixtures:

  • Orphaned v0.1.0 in this reporelease=true version=0.1.0 bump=none reuse_tag=true, with the warning, and the changelog correctly renders the 13 feat: commits the tag contains.
  • A tag that does have a release — checked against RunicGateway/link's v0.3.0 (real 200 from the API) → release=false, stands down.
  • Fresh repo, no tags → seed path, v0.1.0, unchanged.
  • Preflight: empty secrets → exit 1 with the legible message; populated → passes.
  • API discrimination confirmed live: link v0.3.0200, servuo-plugins v0.1.0404.

What you need to do

Set REGISTRY_USER / REGISTRY_TOKEN on this repo (Settings → Actions → Secrets), token scoped write:repository. Once they exist, the next push to main finishes the release the first run started — v0.1.0, from the same commit it already points at. No tag cleanup needed.

If you'd rather start clean instead, delete the v0.1.0 tag and the next merge cuts it fresh; the recovery path just makes that unnecessary.

The same two defects are latent in the installer repo's copy of this engine — fixed in installer#2. link's release.yml has the same orphan-tag trap but hasn't tripped it; 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)

  • AI tools were used. Tool(s): Claude Code (Claude Opus 5). Every change has been reviewed and is understood. 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 The first release run (task 496, after #7 merged) **tagged the repo and then failed**, leaving `v0.1.0` with no release behind it — and no way to ever get one. ### What actually happened `REGISTRY_USER` / `REGISTRY_TOKEN` are not configured on this repo. The log shows them empty: ``` env: REGISTRY_USER: REGISTRY_TOKEN: ``` But **the tag push succeeded anyway**: ``` * [new tag] v0.1.0 -> v0.1.0 ``` `actions/checkout` leaves an `http.<host>.extraheader` credential in the local git config, so `git remote set-url` to a URL with empty credentials still authenticated through that leftover header. The release API call had no such fallback: ``` curl: (22) The requested URL returned error: 401 ``` So the run got exactly far enough to do the one thing that's hard to undo. Everything before it was correct — plan `v0.1.0`, gates green (22 scripts, 3 patches), `protocol=3`, `file_count=30`, tarball built. ### Why it couldn't self-heal The plan step treated *any* existing tag as "nothing to release". So every subsequent push to `main` would see `v0.1.0`, set `RELEASE=false`, and stand down. The release would never appear, and re-running would never help. That's the more serious of the two bugs — the missing secret is a config gap, this was a design flaw that turned a transient failure permanent. ## The fixes **1. Credential preflight** — before anything is built or pushed, gated on the run intending to publish so a `docs:`/`chore:`-only merge still passes on a repo without secrets. Names the missing secrets and the scope they need, instead of failing at whichever step happens to use them first. **2. Orphan-tag recovery** — the plan step asks the API whether a release exists for the tag: | Result | Action | |---|---| | `200` | Release exists → stand down, nothing to do | | `404` | An earlier run died after tagging → reuse the tag, publish the release it's missing | | anything else | Refuse. A network failure or bad token is **not** evidence of absence, and guessing "no release" would republish over a good one | The `404` branch deliberately **overrides** the `RELEASE=false` the bump logic just decided — with the tag already in place there are no releasable commits after it, which is exactly why the state was stuck. Supporting changes: the tag step reuses an existing tag rather than failing on `git tag`, and a recovery run's changelog summarizes what the tag *contains* (`previous-tag..this-tag`) instead of the empty range after it. ## How it was tested Extracted every `run:` block and exercised each path — including against **live repo state**, not just synthetic fixtures: - **Orphaned `v0.1.0` in this repo** → `release=true version=0.1.0 bump=none reuse_tag=true`, with the warning, and the changelog correctly renders the 13 `feat:` commits the tag contains. - **A tag that does have a release** — checked against `RunicGateway/link`'s `v0.3.0` (real `200` from the API) → `release=false`, stands down. - **Fresh repo, no tags** → seed path, `v0.1.0`, unchanged. - **Preflight**: empty secrets → exit 1 with the legible message; populated → passes. - API discrimination confirmed live: link `v0.3.0` → `200`, servuo-plugins `v0.1.0` → `404`. ## What you need to do **Set `REGISTRY_USER` / `REGISTRY_TOKEN` on this repo** (Settings → Actions → Secrets), token scoped `write:repository`. Once they exist, the next push to `main` finishes the release the first run started — `v0.1.0`, from the same commit it already points at. No tag cleanup needed. If you'd rather start clean instead, delete the `v0.1.0` tag and the next merge cuts it fresh; the recovery path just makes that unnecessary. ## Related The same two defects are latent in the installer repo's copy of this engine — fixed in [installer#2](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/2). `link`'s `release.yml` has the same orphan-tag trap but hasn't tripped it; 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) - [x] AI tools were used. Tool(s): `Claude Code (Claude Opus 5)`. Every change has been reviewed and is understood. 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-04 15:15:43 +00:00
The first release run tagged the repo and then failed, leaving v0.1.0 with
no release behind it and no way to ever get one.

REGISTRY_USER and REGISTRY_TOKEN are not configured on this repo, but the
tag push SUCCEEDED anyway: actions/checkout leaves an
`http.<host>.extraheader` credential in the local git config, so
`git remote set-url` to a URL with empty credentials still authenticated
through that leftover header. The release API call had no such fallback and
returned 401 (visible in the run log as `REGISTRY_USER:` / `REGISTRY_TOKEN:`
with empty values). So the run got exactly far enough to do the one thing
that is hard to undo.

Worse, that state was self-perpetuating. The plan step treated any existing
tag as "nothing to release", so every subsequent push to main would see
v0.1.0, set RELEASE=false, and stand down — the release would never appear,
and no amount of re-running would fix it.

Two fixes:

  A credential preflight, before anything is built or pushed, gated on the
  run intending to publish so a docs:/chore:-only merge still passes on a
  repo without secrets. It names the missing secrets and the scope they
  need, rather than failing at whichever step happens to use them first.

  Orphan-tag recovery. The plan step now asks the API whether a release
  exists for the tag: 200 means stand down, 404 means an earlier run died
  after tagging, so reuse the tag and publish the release it is missing.
  This deliberately overrides the RELEASE=false the bump logic just decided
  — with the tag already in place there are no releasable commits after it,
  which is precisely why the stuck state could not clear itself.

  Anything other than 200/404 (network failure, bad token) is refused
  rather than guessed, because assuming "no release" would republish over a
  good one.

  The tag step reuses an existing tag instead of failing on `git tag`, and a
  recovery run's changelog summarizes what the tag contains
  (previous-tag..this-tag) instead of the empty range after it.

Once REGISTRY_USER / REGISTRY_TOKEN are set, the next push to main will
finish the release that the first run started — v0.1.0, from the same
commit it already points at.

Verified against the live repo state: the plan step now reports
release=true reuse_tag=true for the orphaned v0.1.0 and renders the correct
changelog; a tag that does have a release (checked against link's v0.3.0)
still stands down; a fresh repo still takes the seed path; and the
preflight fails loudly on empty secrets and passes on populated ones.

Co-Authored-By: Claude <noreply@anthropic.com>
whitlocktech approved these changes 2026-08-04 15:26:49 +00:00
whitlocktech merged commit 3a52abbd77 into main 2026-08-04 15:26:56 +00:00
whitlocktech deleted branch fix/release-credential-preflight 2026-08-04 15:26:57 +00:00
Sign in to join this conversation.
No Reviewers
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: RunicGateway/servuo-plugins#8
No description provided.