Skip to content

Correct the 0.2.0 readback and the tag recovery table - #23

Merged
jeremy merged 3 commits into
mainfrom
release-doc-corrections
Aug 24, 2026
Merged

Correct the 0.2.0 readback and the tag recovery table#23
jeremy merged 3 commits into
mainfrom
release-doc-corrections

Conversation

@jeremy

@jeremy jeremy commented Aug 22, 2026

Copy link
Copy Markdown
Member

Pre-release correction to RELEASING.md. Two defects, both load-bearing under failure.

The first finding changed during this PR. The audit that prompted it concluded the recorded deletion was simply false. Investigating before writing the correction showed that is not what happened — and the original draft of this PR would have written a false statement into a security document, which is exactly the failure it exists to fix. The corrected finding is below.

1. A readback that cannot stay true

The "0.2.0 control readback (2026-08-17)" section records the unused copilot environment as "verified to have no protection rules and deleted; readback lists only github-release, release-recovery, and release-rubygems."

The environment was present again on 2026-08-22. The tempting inference — that it was never deleted — is wrong. It is recreated on demand by GitHub's Copilot code review, which the active Copilot Reviews ruleset (copilot_code_review, scoped ~ALL) runs on every pull request:

Evidence
Old env created_at 2026-08-17T21:56:44Z
PR #11 opened 2026-08-17T21:56:43Zone second earlier, in the burst that opened #9#13
Deleted it on 2026-08-22 readback confirmed three environments
This PR (#23) opened 2026-08-22T19:13:34Z
Env reappeared, new id 20398402446 2026-08-22T19:13:44Zten seconds later

So the 2026-08-17 readback was almost certainly accurate the moment it was taken, and an unrelated pull request recreated the environment seconds afterwards.

The defect is therefore not a false record. It is that a transient deletion was written down as a settled control, and that "the readback lists only three environments" is an assertion which cannot hold in a repository that receives pull requests.

No release authority was ever affected. Across both incarnations: no protection rules, no deployment branch policy, no secrets, no variables, no deployments, and no reference from any workflow — release.yml and release-recovery.yml name only release-rubygems, github-release and release-recovery. It can neither gate nor bypass any release job.

The consequence for the gate matters more than the record: the pre-tag snapshot asserts protection values, not an environment count — the three release environments carry their required-reviewer and can_admins_bypass settings, and any environment outside that set is inert. An equality check on the name list would fail every release that follows a pull request.

Recorded as a dated correction with the original paragraph intact, so the reason this readback could not be reproduced stays visible.

2. Contradictory break-glass instructions

The recovery table's first row told an operator that, for a pre-publish failure, an admin should "temporarily lift the release-tags-immutable ruleset, delete, and re-enable it." Every other rule in this runbook says never to touch a pushed tag, and conflicting emergency instructions are precisely the ones followed under pressure.

The row also conflated "unpublished" with "deletable". The actual discriminator is whether the tag reached the remote, and then whether a corrective commit is needed. rake tag creates the local tag before pushing, and pushes main and the tag as two separate operations — so a failed run routinely leaves a local tag with no remote counterpart. That case is resolved by git tag -d on a purely local ref: it touches no remote ref and no ruleset, and is not the immutability case at all.

The table now splits on remote-tag presence, spells out the ls-remote that establishes it, and ends with a catch-all: anything still appearing to require lifting the ruleset is a stop, pending a separately reviewed break-glass runbook.

The ls-remote is written against the literal canonical URL rather than origin, matching what rake tag itself validates — local remote and insteadOf config is exactly what you cannot trust while establishing remote state. The two existing published-tag rows are unchanged.

Cost

This commit moves main, voiding the earlier rehearsal and its digest 33448ee9…a09acb. A fresh rehearsal runs against the merged commit before tagging. check_iana_drift is clean against live registries on all three sources, so there is no clock on this.

Copilot AI balanced review requested due to automatic review settings August 22, 2026 19:13

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Corrects release-control history and replaces unsafe tag-recovery guidance.

Changes:

  • Records the corrected environment readback.
  • Splits recovery steps by remote-tag state.

Tip

If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread RELEASING.md Outdated
Two defects in RELEASING.md, both load-bearing under failure.

The 0.2.0 control readback records the unused `copilot` environment as
deleted, and the environment readback as listing only three environments.
Neither survives: the environment is recreated on demand by GitHub's
Copilot code review, which the active `Copilot Reviews` ruleset
(`copilot_code_review`, scoped `~ALL`) runs on every pull request.

The evidence is unambiguous. Its `created_at` of 2026-08-17T21:56:44Z is
one second after PR #11 was opened, in the burst that opened #9-#13.
Deleting it on 2026-08-22 reproduced the mechanism: it came back with a
new id ten seconds after the next pull request was opened.

So the readback was almost certainly accurate when taken, and an unrelated
pull request recreated the environment seconds later. The defect is that a
transient deletion was written down as a settled control, and that "the
readback lists only three environments" cannot stay true in a repository
that receives pull requests. Release authority was never affected: across
both incarnations the environment had no protection rules, no branch
policy, no secrets, no variables, no deployments and no workflow
reference.

The pre-tag gate therefore asserts protection values rather than an
environment count -- an equality check on the name list would fail the
release spuriously after any pull request.

Recorded as a dated correction with the original paragraph intact. Amending
in place would erase the reason this readback could not be reproduced.

The recovery table separately told an operator to delete an unpublished tag
by having an admin temporarily lift `release-tags-immutable`. That
contradicts every other rule in this runbook, and conflicting emergency
instructions are the ones followed under pressure. The row also conflated
"unpublished" with "deletable". The real discriminator is whether the tag
reached the remote, then whether a corrective commit is needed: `rake tag`
creates the local tag before pushing and pushes `main` and the tag
separately, so a failed run routinely leaves a local tag with no remote
counterpart. That case needs `git tag -d` on a purely local ref and no
ruleset change at all. Anything still appearing to require lifting the
ruleset is now a stop.
@jeremy
jeremy force-pushed the release-doc-corrections branch from 46ad4af to e0113bc Compare August 22, 2026 19:18

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 46ad4affca

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread RELEASING.md Outdated
Comment thread RELEASING.md Outdated
Comment thread RELEASING.md Outdated
Four review findings on the recovery guidance, all valid.

`git ls-remote` cannot establish canonical remote state. A URL spelled out
in full on the command line is still rewritten by `url.<base>.insteadOf`,
so the query can inspect a different repository and report the canonical
tag absent. The previous text claimed the opposite -- that spelling the URL
out defeats the rewrite -- which is exactly backwards, and a rewriting rule
has already been configured on this maintainer's machine once. Recovery now
asks GitHub over `git/matching-refs`, which touches no Git configuration and
returns 200 with `[]` for a genuinely absent tag, so a failed query is
distinguishable from absence rather than indistinguishable from it.

A present remote tag is not necessarily your tag. A concurrent attempt can
win the name, and the workflow only checks that its tag is well formed and
points into `main` -- not that it matches your checkout or your rehearsal.
Routing straight to `gh run rerun` could therefore have an approver
authorize someone else's run. Recovery now splits present-and-identical
from present-and-different, and the latter stops.

The transient-retry row promised a re-run would work whenever no corrective
commit was needed. It does not if remote `main` advanced meanwhile: the Rake
task fetches `main` and aborts because the tagged HEAD no longer equals it,
and fast-forwarding while keeping the tag then fails the peel check. That
state has its own row, and its remedy is the local-only deletion plus a
fresh rehearsal on the new head.

Finally, the tag step said "on an up-to-date `main` checkout", which invites
a `git pull` that silently moves the checkout off the rehearsed commit onto
one whose bytes nobody has built -- and the Rake guard, being HEAD == fetched
`main`, is satisfied by construction after a pull. The step now binds to the
rehearsed SHA explicitly and says not to pull. The remaining race is safe in
one direction: if `main` advances after the assertion, `rake tag` re-fetches
and refuses. It cannot tag the wrong commit.
@jeremy

jeremy commented Aug 22, 2026

Copy link
Copy Markdown
Member Author

Review round addressed — all four threads fixed and resolved (48ae43c)

The doc fixes are in the commit; the operator-script fixes are outside the PR, under ~/surfguard-release-evidence/0.2.0/. Summary of both, since the review covered both.

RELEASING.md (in 48ae43c)

Finding Fix
ls-remote on a literal URL is still rewritten by insteadOf — and the doc claimed the opposite Canonical state now comes from gh api .../git/matching-refs/tags/vX.Y.Z, which consults no Git config, and returns 200 + [] for a genuinely absent tag so absence is distinguishable from a failed query
Remote tag present ≠ remote tag is yours Split into present-and-identical (gh run rerun) vs present-and-different (stop; do not approve that run's environments), with the object comparison spelled out
Retry row false once remote main advanced Dedicated row: prove absent → delete local ref only → fast-forward → re-rehearse on the new head → tag that
— (found while fixing the above) Step 3 said "up-to-date main checkout", inviting a git pull that moves off the rehearsed commit onto unbuilt bytes — and the Rake guard HEAD == fetched main is satisfied by construction after a pull. Now binds to the rehearsed SHA and says not to pull

Operator scripts

Both now set -Eeuo pipefail, write to fresh attempt-scoped evidence directories, and contain no variable-derived rm -rf — a path that already exists is refused rather than cleared.

The central fix is that a failed producer can no longer read as a passing value. Demonstrated:

old style:  $(false | wc -l)  ->  "0"   == want "0"   => FALSE GO
new chk():  NO-GO tag absent: producer failed (exit 1)

Three real bugs surfaced because the scripts now fail closed, none of which the previous happy-path testing could have caught:

  1. jq -e exits 1 on a literal false, so every can_admins_bypass: false assertion was failing as a "producer error" and masking the real value. Booleans now compare via tostring.
  2. The mise shim prints a banner to stderr on every gh call; merging it with 2>&1 corrupted every compared value. stderr is now captured separately, for diagnostics only.
  3. grep exits 1 on no match, which pipefail surfaced as a producer failure on the "named by no workflow" check.

I also had release-recovery's expected branch policy wrong (branch:main); RELEASING.md:363-368 documents both main and v*, which is what the API returns. The doc was right, my assertion was wrong.

Snapshot assertions completed as requested: required-status-check integration id 15368, dismiss_stale_reviews_on_push, release-recovery branch policy, the tag-creation creation rule, reviewer numeric id 199 alongside the login, plus local tag absence, clean tree, and on-main.

Negative control now checks the reason

It previously accepted any non-zero exit. It now requires the specific SourceRepositoryRef mismatch, and I verified that discriminates — a wrong --signer-workflow fails with verifying with issuer "sigstore.dev" and no SourceRepositoryRef, so it is correctly rejected as an invalid control rather than counted as a passing one.

A live instance of the same bug class, in a different tool

While testing, gh hit a secondary rate limit and gh release view reported an existing release as release not found — a 403 rendered as absence. verify-release.sh now looks the release up over the REST API first and distinguishes "rate limited → UNKNOWN" from "absent", rather than trusting that message.

Current state

pretag-snapshot.sh passes every control against live state. The only NO-GO rows are the two rehearsal checks, which correctly report producer failed (no rehearsal file yet) rather than silently passing — the rehearsal runs after this merges, on the merged commit.

No merge, no RubyGems MFA readback, no tag. Returning for review on the final head.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 48ae43c6ad

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread RELEASING.md Outdated
`git/matching-refs/tags/vX.Y.Z` matches by PREFIX, not exactly. Confirmed
against this repository: querying `tags/v0.1` returns v0.1.0, v0.1.1,
v0.1.2 and v0.1.3, none of which is a tag named `v0.1`.

So when `vX.Y.Z` is absent but some `vX.Y.Z...` tag exists -- a `-rc1`, or
`v1.2.30` against a query for `v1.2.3` -- the raw array is populated even
though the tag being looked for is not there. The unfiltered length test
then reported an absent tag as present and routed the operator into the
present-tag rows, where the exact selector yields no SHA and the guidance
lands on the present-but-different stop case. The absent-tag recovery,
which is the correct path, was unreachable in exactly the situation a
release-candidate tag makes likely.

The query now filters to the exact ref before testing emptiness, and the
paragraph explains why the filter is load-bearing rather than tidiness.
@jeremy

jeremy commented Aug 24, 2026

Copy link
Copy Markdown
Member Author

Round 3 addressed — thread resolved, four script items done (4d3338b)

matching-refs is prefix-based (in 4d3338b)

Valid, and verified against this repository rather than from the docs:

$ gh api repos/basecamp/surfguard/git/matching-refs/tags/v0.1 --jq '[.[].ref]'
["refs/tags/v0.1.0","refs/tags/v0.1.1","refs/tags/v0.1.2","refs/tags/v0.1.3"]

No tag named v0.1 exists, yet the unfiltered length is 4.

Query Unfiltered Exact-filtered
tags/v0.1 (no such tag) 4 → "present" ✗ 0 → absent ✓
tags/v0.1.3 (real tag) 1 1 ✓

Both call sites now filter to the exact ref before testing emptiness. Worth noting the two directions differ, which is why both needed it: in the snapshot an unfiltered result is a false NO-GO (blocks a release); in the recovery table it is a false stop (blocks a recovery) — the correct absent-tag path becomes unreachable exactly when a prefix-sharing tag exists, which an -rc tag makes likely.

Script items

eval over API-derived names removed. Environment names came from the API and were interpolated into an eval'd string. Inside single quotes a $(...) payload cannot fire — but a single-quote breakout can, which I confirmed rather than assumed:

E="x'; touch /tmp/PWNED-DEMO; echo 'y"
eval "printf '%s' 'repos/x/environments/$E'"                    ->  INJECTED
probe(){ printf '%s' "repos/x/environments/$1"; }; probe "$E"   ->  safe

All environment-name checks now go through a new chk_cmd that passes argv and never re-parses the name; grep -F and -- stop a hostile name being read as a pattern or an option.

Atomic fresh directories. mkdir -p on a second-resolution stamp silently reuses a directory when two runs land in the same second — precisely the stale-evidence case the scheme exists to prevent. Both scripts now use mktemp -d: 3 same-second creations → 3 distinct directories. This also removed the last [ -e ] && exit / mkdir -p race.

Negative control tightened. Was grep -qi 'SourceRepositoryRef' anywhere in the output; now matches the full assertion including both refs — expected SourceRepositoryRef to be <bogus>, got refs/tags/v<version>.

Compact-index check added, bounded. index.rubygems.org's checksum: field is the sha256 of the .gem archive — verified equal to v0.1.3's digest, so it is a genuine fifth authority rather than a restatement. Bounded poll (20 × 15s, overridable via INDEX_TRIES/INDEX_SLEEP), hard failure if the version never appears, and only a checksum match counts as confirmation — presence of the version does not.

Re-tested against live v0.1.3

=== all five authorities agree: c0911960… ===, with the negative control still firing. pretag-snapshot.sh passes every control; the only NO-GO rows remain the two rehearsal checks, correctly reporting producer failed because no rehearsal exists yet.

One more grep-exit-1-on-no-match bug surfaced inside the new argv helper and was fixed — the third instance of that pattern in this work, which is itself the argument for the fail-closed design.

No merge, no MFA readback, no tag.

@jeremy
jeremy merged commit 2eba545 into main Aug 24, 2026
31 checks passed
@jeremy
jeremy deleted the release-doc-corrections branch August 24, 2026 07:11
Sign up for free to 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