Repository navigation
canary gate: a v-scoped channel tag shadows the release tag in _gh_candidate_cut_date, so any cross-repo agent adopting v<M>-<tier> channels is permanently BLOCKED (indeterminate) — blocks #1592 #1046
Description
Blocks petry-projects/.github-private#1592 (D-1). Found by hitting it live while seeding the dev-lead/v139-* channel family, then rolled back. Nothing is currently broken — this is the landmine that must be defused before the v-scoped channels can be adopted for a cross-repo agent.
The bug
_gh_candidate_cut_date (scripts/canary-rollout.sh ~356) enumerates release tags with:
gh api "repos/$repo/git/matching-refs/tags/$agent/v"That prefix matches every <agent>/v… ref — so it catches the channel tags <agent>/v<M>-<tier> as well as the immutable releases <agent>/v<M>.<m>.<p>. It then returns the first ref whose dereferenced object equals the candidate commit:
if [ "$csha" = "$commit" ]; then _to_z "$cdate"; return 0; fiA channel tag is lightweight (type=commit), so the else branch sets csha="$obj"; cdate="". And <agent>/v<M>-<tier> always points at a real commit — that is its entire purpose.
So the moment a v-scoped next channel exists for a cross-repo agent, it points at exactly the candidate commit, matches first, and returns an empty cut date.
Ordering makes it deterministic, not intermittent
matching-refs returns refs sorted, and - (0x2D) sorts before . (0x2E), so v139-next always precedes v139.4.0:
62:refs/tags/dev-lead/v139-next 33451103a2cf commit <- matched, cdate=""
72:refs/tags/dev-lead/v139.4.0 (annotated) <- never reached
Observed live
With dev-lead/v139-{next,ring0,ring1,stable} seeded (v139-next → 33451103a2cf, the same commit as release v139.4.0):
candidate (v139-next) = 33451103a2cf cut=
next->ring0 BLOCKED dwell=0h/0h sample=0/0 cum_fail=0
::warning::state=BLOCKED (indeterminate) — could not resolve the candidate's cut date,
so the gate fails closed and holds (not a detected failure; cum_fail=0).
After deleting the four v139-* tags, the same command on the same candidate:
candidate (next) = 33451103a2cf cut=2026-08-31T23:37:09Z
Same commit, same release tag — the only difference is the presence of the channel tag.
Why this is load-bearing
The gate fails closed on an unresolvable cut date, so the effect is a permanent BLOCKED (indeterminate) hold: an agent that adopts v-scoped channels can never be promoted again. Since #1592's fix requires exactly that adoption for dev-lead (7 live stubs), this silently converts the migration into a fleet-wide promotion freeze.
It is also the per-candidate cumulative window start (#548) — health is measured since the candidate's own cut — so an empty value degrades the whole reliability verdict, not just a display field.
Why it has not bitten yet
Only the cross-repo path is affected. candidate_cut_date branches on host != THIS_REPO:
- Cross-repo (
dev-lead, host.github-private) →_gh_candidate_cut_date, the API path, which yields""for a lightweight tag. Broken. - Same-repo (
apply-repo-settings, host.github) → localgit for-each-refwith%(creatordate:iso-strict), which for a lightweight tag returns the commit date — non-empty, so it silently "works".
So apply-repo-settings, which now has v1-next/v1-ring0 seeded (#989), is unaffected today purely because it is same-repo. But its same-repo path is also subtly wrong: it reports the candidate's commit date as the cut date, not the release tag's tagger date. Those differ whenever a release is cut some time after the commit — e.g. v139.4.0 commit 22:55:46Z vs tagged 23:37:09Z, a 41-minute skew applied to a dwell window. Worth fixing in the same change.
Acceptance criteria
-
_gh_candidate_cut_dateconsiders only immutable release tags —<agent>/v<major>.<minor>.<patch>— and skips channel tags<agent>/v<M>-<tier>and any other non-release<agent>/v…ref. - Same filter applied to the local
git for-each-refpath incandidate_cut_date, so both paths agree. - The local path prefers the annotated tag's tagger date and does not silently substitute a lightweight tag's commit date.
- An annotated release tag whose commit equals the candidate resolves correctly even when a lightweight channel tag points at the same commit and sorts earlier. This is the regression test — use the real shape:
dev-lead/v139-next(lightweight →X) alongsidedev-lead/v139.4.0(annotated →X). - Cover both the cross-repo (API) and same-repo (
for-each-ref) paths. - After the fix, re-seeding
dev-lead/v139-*must yield a resolvablecut=— that is the exit criterion for unblocking #1592 D-1 step 2.
Reproduction
R=petry-projects/.github-private
# seed a v-scoped next channel at the same commit as the newest release
gh api --method POST repos/$R/git/refs -f ref=refs/tags/dev-lead/v139-next \
-f sha=33451103a2cfa8f64449df9030e6e7628381b406
gh workflow run canary-rollout.yml -R petry-projects/.github -f command=evaluate -f agent=dev-lead
# => candidate (v139-next) = 33451103a2cf cut= <-- empty
gh api --method DELETE repos/$R/git/refs/tags/dev-lead/v139-next
# => candidate (next) = 33451103a2cf cut=2026-08-31T23:37:09ZRelated: petry-projects/.github-private#1592, #989, #657 (major-scoped channels, F4), #548, #1049.
Activity
don-petry commentedon Sep 1, 2026
Dev-Lead Implementation Plan
Issue: #1046 — canary gate: a v-scoped channel tag shadows the release tag in _gh_candidate_cut_date
Scope
Make both the cross-repo (
gh api matching-refs) and same-repo (git for-each-ref) candidate-cut-date paths consider only immutable annotated release tags<agent>/v<M>.<m>.<p>, skipping v-scoped channel tags<agent>/v<M>-<tier>that shadow the release tag and yield an empty (fail-closed) cut date.Implementation Checklist
scripts/lib/canary-rollout.sh: add pure helper_is_release_tag_suffix(matchesv<M>.<m>.<p>, rejectsv<M>-<tier>and other non-release suffixes)scripts/canary-rollout.sh_gh_candidate_cut_date(API path): capture.ref, filter to release-tag suffixes only before matching the commitscripts/canary-rollout.shcandidate_cut_date(localfor-each-refpath): add%(refname)to the format, filter to release-tag suffixes, and match on the annotated tag's dereferenced commit +%(creatordate)(tagger date) — never a lightweight tag's commit dateTests to Write
_is_release_tag_suffixpure unit tests (acceptsv139.4.0; rejectsv139-next,next,v139, empty)_gh_candidate_cut_dateregression: lightweightdev-lead/v139-next(→X, sorts first) alongside annotateddev-lead/v139.4.0(→X) resolves the release's tagger date, not empty_gh_candidate_cut_date: only a channel tag at the commit → empty (no release found)candidate_cut_datesame-repo path: lightweight channel + annotated release at same commit resolves the tagger date_crossrepo_stub/_crossrepo_promote_stubfor-each-ref fixtures to the new 4-field annotated-release shapeTest Command
bats tests/canary_rollout.bats(+bash .dev-lead/scripts/dev-lead-lint.sh)don-petry commentedon Sep 1, 2026
Dev-Lead: Implementation Complete
Plan Execution
scripts/lib/canary-rollout.sh: added pure_is_release_tag_suffix(acceptsv<M>.<m>.<p>, rejectsv<M>-<tier>, bare tiers,v139,139.4.0, empty)_gh_candidate_cut_date(cross-repo API path): captures.ref, filters everymatching-refsentry through_is_release_tag_suffixbefore it can match the candidate commit — a channel tag can no longer shadow the releasecandidate_cut_date(same-repofor-each-refpath): format now carries%(refname), refs are filtered to the release suffix, and only the annotated tag's dereferenced commit +%(creatordate)(its tagger date) is used — a lightweight ref's commit date is never substituted_crossrepo_stub/_crossrepo_promote_stubfor-each-reffixtures to the new 4-field annotated-release shapedev-lead-lint.sh+shellcheckgreenAcceptance criteria
_gh_candidate_cut_dateconsiders only immutable release tags<agent>/v<M>.<m>.<p>; skips<agent>/v<M>-<tier>and any other non-release<agent>/v…refgit for-each-refpath so both paths agreedev-lead/v139-next(lightweight →33451103a2cf…) alongsidedev-lead/v139.4.0(annotated → same commit); resolves even though the channel tag sorts earlierfor-each-ref) paths covereddev-lead/v139-*yields a resolvablecut=— live exit criterion for #1592 D-1 step 2 (requires a real workflow run against the host; verified in unit tests, not run here)Test Results
Files Changed
scripts/lib/canary-rollout.sh: new pure predicate_is_release_tag_suffixscripts/canary-rollout.sh:_gh_candidate_cut_dateandcandidate_cut_datenow filter to release tags only; local path uses the annotated tagger datetests/canary_rollout.bats: 7 new tests (pure predicate + both cut-date paths + the canary gate: a v-scoped channel tag shadows the release tag in _gh_candidate_cut_date, so any cross-repo agent adopting v<M>-<tier> channels is permanently BLOCKED (indeterminate) — blocks #1592 #1046 regression); updated 2 existingfor-each-refstubs to the annotated-release shapeNotes
-<.ordering can no longer wedge the gate.git log -1 --format=%cI <commit>fallback (candidate has no release tag yet) is unchanged..github-private, which a unit-test run cannot perform; the behaviour it checks is covered by the API-path regression test.feat: implement issue #1046 — canary gate: a v-scoped channel tag sha…
Merge branch 'main' into dev-lead/issue-1047-20260901-0211