Skip to content

fix(release): retry the cosign call, bounded and transient-only (backend#2379) - #563

Merged
LukasWodka merged 1 commit into
developfrom
fix/2379-cosign-bounded-retry
Aug 24, 2026
Merged

fix(release): retry the cosign call, bounded and transient-only (backend#2379)#563
LukasWodka merged 1 commit into
developfrom
fix/2379-cosign-bounded-retry

Conversation

@LukasWodka

@LukasWodkaLukasWodka commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Summary

v0.10.10-rc.2 existed as a git tag with no GitHub Release and no assets for 6h04m because one of release.yml's eight legs hit a sigstore TUF CDN reset. This retries that one network call.

The ordering, which is the whole defect: the tag is the workflow's trigger, so it exists before any signing happens. The Release object is created by publish, which is needs: release — gated on all eight legs. So the tag necessarily precedes the assets, and any single-leg failure leaves a tag with nothing attached. That ordering cannot be reordered away; the only lever is making the flaky call not fail.

Measured on run 32624602531:

eventtime
tag v0.10.10-rc.2 pushed (staging hop, PR #554)2026-08-23 07:04:32Z
seven legs green07:05:44–07:05:51Z
darwin/arm64 FAILURE — tuf: failed to download 10.root.json … read: connection reset by peer07:05Z
Aggregate + create GitHub Release SKIPPED
gh run rerun --failed → leg green13:08:32Z
Release finally published, 27 assets13:08:45Z

6h04m with a tag and no artifacts. Caught by fr-assist's cli smoke battery, not by the build.

What this does not do

The aggregate gate is untouched.publish still requires all eight legs. A release missing darwin/arm64 is worse than no release, and weakening the gate would ship a partially-signed release — strictly worse than the symptom. And the retry is on the cosign call, not the job: a job-level rerun redoes seven good legs and widens the very window this closes.

The retry design

scripts/cosign-retry.sh wraps cosign, forwarding its arguments verbatim.

BOUNDED — at most 3 attempts, backoff 5s then 15s, per-attempt wall-clock cap 120s. Worst case 3×120 + 5 + 15 = 6m20s, inside the job's timeout-minutes: 20, so the retry can never itself be why a leg is killed. The cap is enforced in bash rather than with coreutils timeout, which is absent on macOS — an untestable branch is how a cap stops being real.

CLASSIFIED — only output matching a known transient sigstore/network signature is retried. A rejected OIDC token (401 Unauthorized from Fulcio) or a missing blob fails on the first attempt, with NOT retrying in the log. This is the point the ticket is sharpest about: an unbounded or indiscriminate retry converts a genuine signing refusal into a slow, noisy failure, which is worse than the bug being fixed.

FAIL CLOSEDcosign exiting 0 is a claim; the artifact is the evidence. Every --output-* file named on the command line must exist and be non-empty afterwards, or the leg fails. The file list is derived from the actual argv, not from a second copy of the naming convention. This case is not retried: a zero exit with no signature is not a network symptom.

Verification

scripts/tests/cosign-retry-verify.sh (15 cases, wired into build.yml's Installer (shell) job — hermetic, no network, no tag required) drives the real script with cosign replaced by a PATH shim. Every case asserts the exit status and the number of cosign invocations, so a case cannot pass by failing for a different reason than its name claims. Its failure texts are written independently of the script's pattern list — including the verbatim 2026-08-23 message — because a list checked against itself is blind.

Mutation-proved, 7 mutations. For each: the anchor was asserted to occur exactly once and the mutant asserted to parse before trusting the red — an inert mutation and real coverage are indistinguishable in a log.

mutationtests that reddened
classifier removed (all failures look transient)both "genuine refusal is NOT retried" cases
fail-closed artifact check disabledboth "exit 0 with no/empty artifact" cases
default attempt budget 3 → 1the shipped-defaults case (+ its backoff assertion)
budget comparison -ge-gtboth exact-attempt-count cases
per-attempt cap disarmedthe hung-attempt case
backoff schedule → constant 0both delay cases
short-backoff config guard disabledthe config-refusal case

Baseline 15/15, restored 15/15, zero mutation markers left behind.

Also run: bash -n + shellcheck -S warning -x on both scripts (both now in build.yml's shellcheck list), yaml.safe_load + actionlint on both workflows, scripts/file-budget.sh green.

Docs

RELEASE_CHECKLIST.md gains the recovery for "tag exists, no Release at all", preserving release.yml's reasoning that a re-run — or a dispatch at the tag ref — is the only valid rebuild: cosign embeds the run's ref in the keyless identity and the installers trust only @refs/tags/v.*, so a branch dispatch publishes signatures every customer install rejects (Bugbot on promotion #428).

Audit: is this the first instance, or the first noticed?

Ran git tag vs the actual published artifact across the five version-cutting repos named in release-train's PUBLISH-PATHS.md. cli, client and data-ingestors are 1:1 and clean. design-system and tracebloc-py-package create no GitHub Releases by design, so they were checked against their real registries instead.

One other instance found, a different repo and mechanism — reported separately, not fixed here: tracebloc-py-packagev0.18.0 (tagged 2026-08-06) never published.Publish to TestPyPI succeeded, then Verify install from TestPyPI — a sleep 60 followed by a single un-retried pip install — lost the index-propagation race, so publish-pypi was skipped and 0.18.0 exists on neither TestPyPI nor PyPI 18 days later. Same shape as this bug: one un-retried third-party call between the tag and the artifact.

Closes tracebloc/backend#2379


Note

Medium Risk
Touches keyless signing in the release path, but keeps the all-legs publish gate and only retries classified transients; a classifier miss could still flake or delay a genuine refusal.

Overview
Stops a single flaky Sigstore sign-blob from leaving a git tag with no GitHub Release. publish still requires all eight matrix legs; the retry is on that one network call, not a job re-run.

scripts/cosign-retry.sh wraps cosign with at most 3 attempts (5s/15s backoff, 120s per-attempt cap), retries only known TUF/CDN/transport failures, and fails closed if a zero exit produces empty --output-* artifacts. Genuine refusals (OIDC 401, missing blob) fail on the first attempt.

A hermetic PATH-shim harness (cosign-retry-verify.sh) is wired into the Installer job, and RELEASE_CHECKLIST.md documents tag-with-no-assets recovery (rerun failed jobs or dispatch at the tag ref).

Reviewed by Cursor Bugbot for commit 31f018b. Bugbot is set up for automated code reviews on this repo. Configure here.

…end#2379)
On 2026-08-23 tag v0.10.10-rc.2 was cut by the 07:04 staging hop. One of
release.yml's eight legs -- darwin/arm64 -- died on a sigstore TUF CDN
reset (`read: connection reset by peer` fetching 10.root.json). The tag is
the workflow's TRIGGER, so it already existed; the GitHub Release is
created by `publish`, which is gated on all eight legs. So one flaky leg
left a git tag with no Release and no assets -- a broken publish channel --
for 6h04m, until `gh run rerun --failed` at 13:07. fr-assist's cli smoke
battery caught it, not the build.
The gate is not the bug and is untouched: a release missing darwin/arm64 is
worse than no release. The unretried network call is the bug, and it is
retried here -- on the one cosign call, not by re-running the job, which
would redo seven good legs and widen the very window this closes.
scripts/cosign-retry.sh wraps `cosign`, forwarding its arguments verbatim:
BOUNDED at most 3 attempts, backoff 5s then 15s, per-attempt cap
120s enforced in bash (not coreutils `timeout`, absent on
macOS, which would leave the cap unverifiable locally).
Worst case 6m20s, inside the job's timeout-minutes: 20.
CLASSIFIED only output matching a known transient sigstore/network
signature is retried. A rejected OIDC token or a missing
blob fails on the FIRST attempt -- an indiscriminate retry
turns a real signing refusal into a slow failure, which is
worse than the bug.
FAIL CLOSED cosign exiting 0 is a claim; the artifact is the evidence.
Every --output-* file named on the command line (derived from
the actual argv, not a second naming convention) must exist
and be non-empty, or the leg fails -- unretried, because a
zero exit with no signature is not a network symptom.
scripts/tests/cosign-retry-verify.sh pins all three by driving the real
script with cosign replaced by a PATH shim, asserting exit status AND the
number of cosign invocations per case, so a test cannot pass by failing for
a different reason than its name. Its failure texts are written
independently of the script's pattern list -- including the verbatim
2026-08-23 message -- since a list checked against itself is blind. It runs
in build.yml's Installer (shell) job: hermetic, no network, no tag needed.
Mutation-proved, 7 mutations, each anchor asserted to apply exactly once
and each mutant asserted to parse (an inert mutation and real coverage look
identical in a log): classifier removed -> the two "genuine refusal" cases
redden; fail-closed check disabled -> both artifact cases redden; default
budget 3->1 -> the shipped-defaults case reddens; budget comparison off by
one -> both attempt-count cases redden; cap disarmed -> the hung-attempt
case reddens; backoff constant -> both delay cases redden; short-schedule
guard disabled -> the config-refusal case reddens. Baseline and restored
both 15/15.
RELEASE_CHECKLIST.md gains the recovery for "tag exists, no Release at
all", preserving release.yml's reasoning that a re-run or a dispatch AT the
tag is the only valid rebuild: cosign embeds the run's ref in the keyless
identity and the installers trust only @refs/tags/v.*, so a branch dispatch
publishes signatures every customer install rejects.
Verified: bash -n + shellcheck -S warning -x on both scripts, yaml.safe_load
+ actionlint on both workflows, harness 15/15, file-budget.sh green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@LukasWodkaLukasWodka self-assigned this Aug 24, 2026

@saqlainsyed007saqlainsyed007 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. Reviewed the cosign-retry wrapper at high effort last pass; CI is now green with no open threads.

scripts/cosign-retry.sh is correct on all three properties it claims: BOUNDED (≤3 attempts, 5s/15s backoff, per-attempt 120s cap enforced in bash via background+poll+TERM/KILL returning 124 — portable to macOS where coreutils timeout is absent; worst case 6m20s < the job's 20m); CLASSIFIED (rc=124 and matched sigstore/network signatures retry, everything else is a genuine refusal and exits on the first attempt); FAIL-CLOSED (every --output-* file, derived from argv, must exist and be non-empty or exit 3 — not retried, since a zero exit with no signature isn't a network symptom). set -uo pipefail without -e is right for a loop that inspects failures, and the backoff-schedule length is guarded. The release-workflow wiring is a minimal cosign → wrapper swap; the all-eight-legs publish gate is untouched. The hermetic 15-case harness asserts both exit status and cosign invocation count with independently-written failure texts — mutation-proof.

Green, mergeable, no threads.

@LukasWodka
LukasWodka merged commit f125832 into developAug 24, 2026
29 checks passed
@LukasWodka
LukasWodka deleted the fix/2379-cosign-bounded-retry branch August 24, 2026 11:57
aptracebloc added a commit that referenced this pull request Aug 25, 2026
…-closed (cli#568) (#570)
scripts/cosign-retry.sh tracked --output-* paths in a way that failed both
directions (two Bugbot findings on PR #563):
- FAIL OPEN on stale artifacts: retry attempts never cleared prior --output-*
files, so a leftover non-empty .cert/.sig from an EARLIER failed attempt
satisfied the `[ -s ]` fail-closed check after a later `exit 0` that wrote
nothing -- reporting a signature that does not exist, the one property the
harness exists to prevent. Fix: remove every parsed --output-* path before
each attempt, so `[ -s ]` is a claim about the current attempt alone.
- FAIL CLOSED on equals-form flags: outputs_from_args set prev
unconditionally, so after --output-x=val the token still matched --output-*
and the next arg was misparsed as a phantom output path, failing a genuine
equals-form sign with exit 3. Fix: reset prev after handling the equals form.
Tests (scripts/tests/cosign-retry-verify.sh): add a stale-leftover case (exits
0 before the fix, now fails closed), equals-form parse cases, and a
wipe-ordering guard. The two bug-catching cases were confirmed to FAIL against
the pre-fix script; all 19 pass on the fix. shellcheck --severity=error and
bash -n clean, matching the build.yml gates.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
LukasWodka added a commit that referenced this pull request Aug 25, 2026
Declares `0.10.13` as the next release version so the release train can cut an
rc for it.
`v0.10.12` is already tagged -- both `v0.10.12` and `v0.10.12-rc.1` exist -- and
the delta waiting on `develop` touches `internal/doctor/doctor.go`, under the
`internal/*` publish_path in release-train's `repos.yml`:
- #563 fix(release): retry the cosign call, bounded and transient-only
- #564 feat(doctor): measure disk, the dimension it never looked at
With `VERSION` still reading `0.10.12`, the staging hop's version preflight
refuses:
rc NOT tagged - v0.10.12 already exists and the delta DOES touch published
files (bump VERSION on develop before the next release)
Measured on the 2026-08-25 staging hop: the `cli` leg failed this preflight, so
`cli` sat the hop out. Same shape as the 0.10.12 bump (#562) one day earlier --
each hop that ships published files needs its own bump, and the previous one is
consumed by the hop that shipped it.
Patch-level, matching the whole 0.10.x series.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@LukasWodka@saqlainsyed007