cut: rerun a flaky release run once, resume after the tag

The v0.0.16 cut pushed the tag and then reported a failure because the release.yml run lost one gate to a timing-flaky test while its guard had passed and its publish job never ran. Rerunning the run by hand published the release; the tool had exited 1 and could not be run again, since a tag on origin meant "derive the next version".

The release stage now classifies a failed run from forge state: retryable when the conclusion is failure, the run is on its first attempt, every commit-status context in a failure state is a gate, and no release is published for the tag. A retryable run is rerun once through the actions API and the same run id is watched again, restarted only once the forge reports a higher run_attempt, so a concluded previous attempt is never read as the result. The rerun decision parses statuses strictly and treats anything it cannot read as terminal. A tag already on origin that peels to HEAD with no published release resumes at the release stage after the origin tag is verified (annotated, tool convention, pinned signing fingerprint). Both decisions are pure, table-tested functions. Spec bullets in specs/release-cut.md.
This commit is contained in:
2026-09-08 01:47:09 +02:00
parent 299d7af99c
commit 3e57f43e08
2 changed files with 882 additions and 91 deletions
+4 -2
View File
@@ -20,7 +20,7 @@ Recipes only — no variables, no embedded logic:
Wired like the other host tools (`hostTool` + `addRunArtifact`, see build.zig ~230): `zig build cut -- {major|minor|patch}`. NOT installed to zig-out/bin. Its tests join `zig build test`.
Constants: one repo API base `https://git.mial.net/api/v1/repos/mokhtar/nxdns` (the tool can only ever target this repo — no configurability). The runs API needs a token (verified: anonymous GET is 401); read it from `~/.config/tea/config.yml` (logins entry for git.mial.net); a missing token is a clear error naming the file.
Constants: one repo API base `https://git.mial.net/api/v1/repos/mokhtar/nxdns` (the tool can only ever target this repo — no configurability); the endpoints under it are `/actions/runs`, `/commits/{sha}/status`, `/releases/tags/{tag}` and `/actions/runs/{id}/rerun`, the last being the only non-GET this program makes. The runs API needs a token (verified: anonymous GET is 401); read it from `~/.config/tea/config.yml` (logins entry for git.mial.net); a missing token is a clear error naming the file.
### Sequence
@@ -38,10 +38,12 @@ Constants: one repo API base `https://git.mial.net/api/v1/repos/mokhtar/nxdns` (
A failed run must not strand the operator:
- Bump pushed, then failure: rerun continues (preflight sees the version already bumped).
- Local tag exists but never reached origin: verify it is an annotated tag by this tool's convention pointing at the current HEAD — adopt it; otherwise refuse with the exact `git tag -d` to run. Never delete a tag that exists on origin.
- Release run failed, retryably: one automatic rerun (`POST {api_base}/actions/runs/{id}/rerun`, 201, then the wait targets that same run id — a rerun keeps it — and treats the run as restarted only once the forge reports a HIGHER `run_attempt`, so the concluded previous attempt is never read as the result and a rerun that finishes between two polls still is). Retryable only while the run is on its first attempt (`run_attempt` is 1; an absent attempt number is not read as 1), the conclusion is `failure` (never `cancelled`), at least one commit-status context is in a failure state, every context in a failure state is a gate (`Gates / …` or `Release / gates`, event suffix dropped), and the release object for the tag is absent or a draft; a published release is terminal, as `release.yml`'s guard already states. The bound is the forge's attempt count and not a counter in this process, so a run already rerun by hand or by an earlier invocation is never rerun again. The statuses are parsed STRICTLY for this decision — an entry missing a string `context`, `status` or `target_url` refuses the whole payload — because an unreadable entry is not counted as a failure and would leave a list of nothing but gates; the failing-job report keeps the tolerant parse. Anything the tool cannot read — the statuses, the release object, the attempt number — is terminal rather than retried.
- Tag pushed, then failure: the tag on origin is no longer a reason to derive the next version. When origin's peeled tag object is the current HEAD and no release is published for it, the cut resumes at the release stage — no bump, no push, no CI wait, no tag, and no preflight, because everything the preflight guards has already happened — and reports it (`cut: resuming v0.0.16 at the release stage: the tag is on origin at HEAD and no release is published`). Peeling to HEAD is not on its own enough to resume on a tag: before the release wait, origin's tag object is verified the way the adopt path verifies a local one — annotated (a tag whose ref line and peeled line are one object is lightweight and refused), fetched when it is not here and compared against origin's object id when it is, carrying this tool's `v<version>` message, pointing at that commit, and signed under the fingerprint `release.yml` pins — so a lightweight or unsigned tag somebody pushed at HEAD is a refusal naming what is wrong, never a deletion or a move. A published release, or a tag pointing anywhere else, derives the next version as before. `planVersion` takes the manifest, the bump kind and that one three-state fact, and stays a pure table-tested function.
### Tests (in-file, join `zig build test`)
Pure functions unit-tested: semver validation (accept/reject table incl. leading zeroes, `v` prefix), bump-kind parse, derivation table with the minor/major resets and overflow refusals, derive-vs-resume decision for all three kinds, zon `.version` parse + rewrite round-trip, changelog heading + non-empty body check, runs-JSON → decision (running / success / failure / no-run), tea-config token extraction. Process spawning and HTTP live behind thin call sites and are not mocked.
Pure functions unit-tested: semver validation (accept/reject table incl. leading zeroes, `v` prefix), bump-kind parse, derivation table with the minor/major resets and overflow refusals, derive-vs-resume decision for all three kinds, zon `.version` parse + rewrite round-trip, changelog heading + non-empty body check, runs-JSON → decision (running / success / failure / no-run, the run-id narrowing a rerun needs, and whether an observed `run_attempt` has advanced past the one a rerun was asked for), failing-contexts JSON → retryable or terminal, release-object status and JSON → absent / draft / published, `ls-remote` peeling an annotated tag to its commit, tea-config token extraction. Process spawning and HTTP live behind thin call sites and are not mocked.
## Anti-requirements
+878 -89
View File
File diff suppressed because it is too large Load Diff