fix(ci): preflight release credentials and recover from an orphan tag #2

Merged
whitlocktech merged 1 commits from fix/ci-credential-preflight into main 2026-08-04 15:27:30 +00:00
Member

What & why

servuo-plugins hit two defects on its first real release run (servuo-plugins#8 has the full post-mortem). This repo runs the same engine, so both are latent here. Fixing them before this repo ever cuts a release.

Also fixes this repo's own sync-project-tree failure on main, which had the same root cause.

What happened over there

REGISTRY_USER / REGISTRY_TOKEN were empty, yet the tag push succeededactions/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. The run got exactly far enough to do the one thing that's hard to undo: repo tagged, no release, failed job.

And it couldn't self-heal — any existing tag was read as "nothing to release", so every later run would see the tag, stand down, and the release would never appear.

The fixes

1. Credential preflight, before anything is built or pushed, gated on the run actually intending to publish — so a docs:/chore:-only merge, or this repo's current pre-crate no-op, still passes green on a repo with no secrets. Names the missing secrets and the scope they need instead of failing wherever they happen to be used first.

2. Orphan-tag recovery in the plan step:

API result for the tag Action
200 Release exists → stand down
404 Earlier run died after tagging → reuse the tag, publish the missing release
anything else Refuse — a network failure or bad token isn't evidence of absence, and guessing would republish over a good release

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

Supporting changes: 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) rather than the empty range after it.

3. The same preflight in sync-project-tree.yml. Its first run on main failed with an opaque git clone error against https://:@host/... that said nothing about a missing secret.

How it was tested

Extracted every run: block from all three workflows; all parse under bash -n. Then exercised the paths:

  • No Cargo.toml (this repo today) → guard still short-circuits to release=false, exit 0. The preflight does not fire, so a secretless repo stays green.
  • Crate present, no tag → seed path, v0.1.0, unchanged.
  • Tag with a release → verified against RunicGateway/link's real v0.3.0 (200 from the API) → stands down.
  • Orphan tag → verified against servuo-plugins' real v0.1.0 (404) → recovers.
  • Both preflights: empty secrets → exit 1 with the legible message naming the repo and required scope; populated → pass.

Still needed on this repo

REGISTRY_USER / REGISTRY_TOKEN (Settings → Actions → Secrets) — write:repository here, plus read/write on RunicGateway/docs for the tree sync. Until they're set, sync-project-tree will now fail with a message that says so, rather than a clone error.

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 `servuo-plugins` hit two defects on its first real release run ([servuo-plugins#8](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/8) has the full post-mortem). This repo runs the same engine, so both are latent here. Fixing them before this repo ever cuts a release. Also fixes this repo's own `sync-project-tree` failure on `main`, which had the same root cause. ### What happened over there `REGISTRY_USER` / `REGISTRY_TOKEN` were empty, yet **the tag push succeeded** — `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`. The run got exactly far enough to do the one thing that's hard to undo: repo tagged, no release, failed job. And it couldn't self-heal — any existing tag was read as "nothing to release", so every later run would see the tag, stand down, and the release would never appear. ## The fixes **1. Credential preflight**, before anything is built or pushed, gated on the run actually intending to publish — so a `docs:`/`chore:`-only merge, or this repo's current pre-crate no-op, still passes green on a repo with no secrets. Names the missing secrets and the scope they need instead of failing wherever they happen to be used first. **2. Orphan-tag recovery** in the plan step: | API result for the tag | Action | |---|---| | `200` | Release exists → stand down | | `404` | Earlier run died after tagging → reuse the tag, publish the missing release | | anything else | Refuse — a network failure or bad token isn't evidence of absence, and guessing would republish over a good release | The `404` branch deliberately overrides the `RELEASE=false` the bump logic just decided; with the tag in place there are no releasable commits after it, which is exactly why the state was unrecoverable. Supporting changes: 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`) rather than the empty range after it. **3. The same preflight in `sync-project-tree.yml`.** Its first run on `main` failed with an opaque `git clone` error against `https://:@host/...` that said nothing about a missing secret. ## How it was tested Extracted every `run:` block from all three workflows; all parse under `bash -n`. Then exercised the paths: - **No `Cargo.toml`** (this repo today) → guard still short-circuits to `release=false`, exit 0. The preflight does not fire, so a secretless repo stays green. - **Crate present, no tag** → seed path, `v0.1.0`, unchanged. - **Tag with a release** → verified against `RunicGateway/link`'s real `v0.3.0` (`200` from the API) → stands down. - **Orphan tag** → verified against servuo-plugins' real `v0.1.0` (`404`) → recovers. - **Both preflights**: empty secrets → exit 1 with the legible message naming the repo and required scope; populated → pass. ## Still needed on this repo `REGISTRY_USER` / `REGISTRY_TOKEN` (Settings → Actions → Secrets) — `write:repository` here, plus read/write on `RunicGateway/docs` for the tree sync. Until they're set, `sync-project-tree` will now fail with a message that says so, rather than a clone error. ## 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:16:09 +00:00
fix(ci): preflight release credentials and recover from an orphan tag
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 5s
60ae6f1f75
servuo-plugins hit both of these on its first real release run; this repo
runs the same engine, so it has the same two defects latent.

REGISTRY_USER / REGISTRY_TOKEN were empty there, yet the tag push
SUCCEEDED: 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. Net result was the worst
available outcome — the repo tagged, no release, and a failed job.

Two fixes:

  A credential preflight, before anything is built or pushed, gated on the
  run actually intending to publish so a docs:/chore:-only merge (or this
  repo's pre-crate no-op) still passes on a repo with no secrets. It names
  the missing secrets and the scope they need instead of failing wherever
  they happen to be used first.

  Orphan-tag recovery. A tag with no release behind it means an earlier run
  died after tagging, and the old code treated any existing tag as "nothing
  to release" — so that state could never clear itself: every later run
  would see the tag and stand down, forever. The plan step now asks the API
  whether a release exists for the tag, and on 404 reuses the tag and
  publishes the release it is missing. This deliberately overrides the
  RELEASE=false the bump logic just decided, which is the whole point —
  with the tag in place there are no releasable commits after it.

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

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

sync-project-tree gets the same preflight: its first run on main failed
with an opaque `git clone` error against `https://:@host/...` that said
nothing about a missing secret.

Verified by extracting every run block and exercising the paths: empty
secrets fail the preflight with a legible message and populated ones pass;
the no-Cargo.toml guard still short-circuits to release=false; a crate with
no tag still takes the seed path; and against real repo state, a tag with a
release stands down while an orphan tag recovers.

Co-Authored-By: Claude <noreply@anthropic.com>
whitlocktech approved these changes 2026-08-04 15:27:23 +00:00
whitlocktech merged commit cfebbbbd4b into main 2026-08-04 15:27:30 +00:00
whitlocktech deleted branch fix/ci-credential-preflight 2026-08-04 15:27:31 +00:00
Sign in to join this conversation.
No description provided.