Repository navigation
ci: admit the routine marker's push parent provisionally; bind the exact head at merge (Refs #5189) - #5191
Merged
Conversation
…act head at merge (Refs #5189) A push cannot carry a marker naming a head that did not exist when the body was written, so every push to a routine PR failed routine-eligibility once and cost a body edit (7 runs on .github) to clear. On synchronize only, the workflow now offers GitHub's own `before` to the validator when bounded deepening proves the push only added commits on top of it. A marker naming exactly that parent is admitted provisionally with a routine-rebind-owed warning; every other routine check still runs against the new head. The exact binding stays at the merge route, whose routine_contract reads the live body and refuses any head= other than the merged head. Controls (tests/routine-eligibility/push_parent.py, 15 scenarios): stale marker from an unrelated head, two pushes behind, forced push, rebind, forged marker/before, duplicate and protected markers, envelope and validator re-proof, unrebound merge refused by the real routine_contract, and the pre-change workflow as the mutation control. Census pin for check-claim-generation.py updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The first head's change-completeness and declared-closes read a PR body whose option table contained a closing keyword. change-completeness reads the body from the push event payload and does not run on `edited`, so a fresh head is the only way it reads the corrected body. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EHotwagner
marked this pull request as ready for review
November 1, 2026 13:15
Contributor
Author
|
Root/fdev: independent adversarial review APPROVED
Triage
|
This was referenced Nov 1, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Refs #5189
Problem
The routine-development marker's
head=field must name the PR head. A push cannot carry a marker for a head that does not exist yet. Thesynchronizerun ofroutine-eligibilitytherefore always reads the old marker and fails. The owner then edits the body, and the edit starts its own set of runs.Measured on #5188, head
1eb3ae15, with the GitHub runs API:routine-eligibilityfailed (run 41297777859);no-silent-revertdoes not read the routine marker. On #5188 its only non-success was acancelledrun on the superseded head. Its extra cost is the edit-triggered run counted above.Decision: (a) push-parent allowance, with the exact binding enforced at merge
On
synchronizeonly, the workflow passes GitHub's owngithub.event.before(the PR's previous head) to the validator as a provisional binding. The author cannot set this value. It is passed only when the push merely added commits on top of it: bounded deepening (0/8/64/256) has to provebeforeis an ancestor of the new head.The validator then admits a marker that names exactly that parent. It prints a
::warning:: [routine-rebind-owed]and the verdictroutine delivery provisionally admitted ... rebind the marker to head=<new> before merge. Every other routine check still runs against the new head: one marker, no duplicate fields, an allowed and unprotected operation, the live-base policy, and a non-empty change measured from the merge base.The exact binding stays where the merge happens.
tools/routine-delivery.pyroutine_contractreads the live PR body and callsvalidate_routine_body(body, branch, expected_head). That call refuses anyhead=other than the merged head before the eligibility check is even consulted. This already happens onmain; this PR adds a causal control that pins it.So an owner pushes repairs during review without editing the body, and rebinds once before merge. The rebind is a body edit (7 runs on .github), so the saving is one body edit per intermediate push, plus the red
routine-eligibilityrun each push used to produce.beforeand proven fast-forward ancestry, and is limited to one push.checks: writeApp grant (.fsgg/routine-eligibility-activation.json).pr-lane-admission,routine-delivery,board-currency,ContextView.fs, the skills, and the 9 receivers' installed five-file closure. That is a contract migration, not a minimal fix.Security property kept. The marker still binds the author's declaration to an exact head, and that exact head is required at merge. A stale or forged marker cannot admit a different head:
beforeoffers no parent;edited,openedandreopenedruns have no trusted parent;--routine-previous-headoutside the trusted--routine-policy-refroute.Unchanged.
routine-eligibilityis not inmain's required contexts. The check-run name and producer workflow (id 352355034) are unchanged.check-pr-concurrency.pypasses.routine-eligibilityrun on the exact head to be a success.Residual. A
gh pr mergeor UI merge that bypasses the routine merge route is gated only by branch protection. That was already true:routine-eligibilitywas never a required context. Before this change, though, a stale marker showed up as a red check. Now it shows up as a green check with aroutine-rebind-owedwarning.Changes
.github/workflows/routine-eligibility.yml: passesEVENT_ACTIONandEVENT_BEFORE, proves fast-forward ancestry by bounded deepening on asynchronizeofroutine/*, and forwards--previous-head.scripts/check-routine-eligibility-envelope.py: optional--previous-head. It must be lowercase 40-hex, differ from the head, and be an ancestor of the head; otherwise it refuses. It is forwarded as--routine-previous-head.scripts/check-claim-generation.py: the provisional admission and warning; the flag is refused without--routine-policy-ref. Census pin updated indocs/coordination/v1-writer-census.json. The sink kinds did not change.tests/routine-eligibility/push_parent.py(new): 15 causal scenarios, wired intorun.sh.moved_base.py's fixture now accepts event fields.fixtures/pre-5189-routine-eligibility.workflow.txtis the mutation control.routine-eligibility-selftest.ymlpaths now includescripts/check-claim-generation.py,tools/routine-delivery.pyandtools/pr-lane-admission.py, so validator or merge-route changes re-run these controls.Controls
Generator output from
python3 tests/routine-eligibility/push_parent.py:routine-eligibility push-parent: scenarios=15 passed=15 admit=5 refuse=9 mutation-controls=1.These run the real workflow shell, the real base-loaded envelope and the real validator and policy over a shallow
file://transport. The clock is not used, and the sandbox is a temporary directory per scenario.stale-marker-from-an-unrelated-head-refuses,marker-two-pushes-behind-refusesforced-push-offers-no-parent-and-refusesrebind-edit-is-admitted-exactly-not-provisionally,rebound-marker-on-a-synchronize-run-is-exactforged-marker-and-before-naming-an-absent-commit-refuse,duplicate-or-protected-marker-is-not-rescued-by-the-parent,envelope-re-proves-the-parent-and-refuses-a-forged-one,validator-refuses-a-previous-head-outside-the-trusted-route,push-parent-marker-on-an-edited-run-refusesmerge-with-an-unrebound-marker-is-refused-by-the-live-body-checkcalls the realroutine_contractwith a recording API. It refuses withstale/missing headand never consults eligibility. Once rebound, it returnspassed.push-parent-marker-is-admitted-provisionally-with-a-rebind-warning;pre-5189-workflow-refuses-the-same-pushuses the same inputs with the pre-change workflow and getsroutine-changed-head. Alsomulti-commit-push-connects-by-bounded-deepeningandpush-parent-with-a-moved-base-is-still-admitted.A manual validator mutation that disables the allowance makes the admit scenarios fail (
expected ok=True with 'routine delivery provisionally admitted'; got rc=1). It was reverted, and that mutated state is not in the diff.Pipeline preflight
This is a single linear job. The interaction is event order (push, then edit), and the concurrency did not change. I chose static invariants (
run.shrequirelines) plus the existing real-shell causal fixture, extended. A custom Quint model is deferred because the ordering surface is already modelled bycheck-pr-concurrency.py, which is unchanged and green, and a new model would duplicate it.Kit, receivers and release
These five files are the canonical routine producer closure:
.fsgg/routine-development.json,.github/workflows/routine-eligibility.yml,scripts/check-claim-generation.py,scripts/check-routine-eligibility-envelope.pyandscripts/lib/gate.py.FS.GG.Driversships them (src/FS.GG.Drivers/workspace-inventory.jsonsources them from this repository), and receivers carry installed copies.Receivers are not changed by this PR. On origin/main, Audio, Coordination, Governance, Rendering, SDD, Templates, FsQuint, FSBarV2 and Game each carry their own installed copy. Most use an older event-base workflow. They keep failing once per push until they adopt the new closure. Adoption is a separate effect: a Drivers package release (coherent-set version bump) and a per-receiver closure update through the existing routine-producer repair route. The new envelope argument is optional and the workflow sends it only when it is valid, so a receiver that updates all five files together stays consistent. Mixing old and new files is not supported, as with any closure update.
Interim practice: rebind before pushing
Root asked whether the worker workaround (edit the marker to the locally known new SHA, then push) becomes documented practice. My answer:
.githubbefore this PR merges, and every receiver until it adopts it), it is the right interim practice. It needs no code. The push event carries the bound marker, so the new head'sroutine-eligibilityrun is green. This PR's own second head03a77d1bwas pushed that way.The skill text (
pnext-item: "A moved head must be reviewed and rebound") is compatible with both ways of working and is not changed here. It is a FS.GG.Kit member, so wording the interim practice there needs its own kit-bump PR. I recommend one only if Root wants the interim practice written down before receivers adopt the closure.Moved base (Root scope addition, FS.GG.Game#755)
Game#755 failed
routine-eligibilitywithfetched base <new> differs from event base <old>after an unrelated merge moved main (run 41300940959). That refusal exists only in Game's installed workflow (f1d6a436, line 66), which is the pre-#4994 event-base copy.The canonical closure on
.githubmain already tolerates this:merge-base(live, head)..head.tests/routine-eligibility/moved_base.py(23 scenarios, generator:scenarios=23 passed=23 admit=8 refuse=13 mutation-controls=2) is the causal control. Its mutation controlmoved-base-legacy-refuses-with-the-reported-messagereproduces Game's exact message with the legacy bytes. Its admit scenariomoved-base-unchanged-routine-change-passes-without-rebasepasses the same PR under the canonical workflow.This PR leaves that path unchanged and adds
push-parent-with-a-moved-base-is-still-admittedfor the combined case: main moved and the push marker names the parent.Game#755 is fixed by the same remainder as #5189's receivers: adopting the current canonical five-file closure. Game's envelope and validator differ from canonical too (
680b7cbe,f78531e3vs7bcf7a9e,7995b533on.githubmain before this PR).Local gates (this head)
All of these were run locally on this head (the runner script runs each and records its exit code and last output line):
30/30 exit 0.
drivers-verifyranverify-package.shwithout a prior lockeddotnet restore. CI runs both.🤖 Generated with Claude Code