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; fi
A 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) → local git for-each-ref with %(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
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:09Z
Related: petry-projects/.github-private#1592, #989, #657 (major-scoped channels, F4), #548, #1049.
Blocks
petry-projects/.github-private#1592(D-1). Found by hitting it live while seeding thedev-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:A channel tag is lightweight (
type=commit), so theelsebranch setscsha="$obj"; cdate="". And<agent>/v<M>-<tier>always points at a real commit — that is its entire purpose.So the moment a v-scoped
nextchannel 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-refsreturns refs sorted, and-(0x2D) sorts before.(0x2E), sov139-nextalways precedesv139.4.0:Observed live
With
dev-lead/v139-{next,ring0,ring1,stable}seeded (v139-next→33451103a2cf, the same commit as releasev139.4.0):After deleting the four
v139-*tags, the same command on the same candidate: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 fordev-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_datebranches onhost != THIS_REPO:dev-lead, host.github-private) →_gh_candidate_cut_date, the API path, which yields""for a lightweight tag. Broken.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 hasv1-next/v1-ring0seeded (#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.0commit22:55:46Zvs tagged23: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.git for-each-refpath incandidate_cut_date, so both paths agree.dev-lead/v139-next(lightweight →X) alongsidedev-lead/v139.4.0(annotated →X).for-each-ref) paths.dev-lead/v139-*must yield a resolvablecut=— that is the exit criterion for unblocking #1592 D-1 step 2.Reproduction
Related:
petry-projects/.github-private#1592, #989, #657 (major-scoped channels, F4), #548, #1049.