Repository navigation
Fail pull requests that add agent job folders or hand-off notes - #713
Merged
Merged
Conversation
Subfleet build jobs keep their state in .job/, and #704 and #699 committed .job/HANDOFF.md and .job/PR_BODY.md on 23 October 2026. The new Agent notes workflow runs .github/check-agent-notes.sh on every pull request, with no path filter, and fails when the merged tree holds a .job/, .job-git/ or .diag/ folder at any depth, or HANDOFF.md, handoffs.jsonl, PR_BODY.md, PR-BODY.md, REPORT.md or PROGRESS.md at the root, in any capitalisation. It mirrors policyengine-uk's root-note guard. .gitignore now ignores the three folders. Fixes #712 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… check must reject Reverted in the next commit; the PR is squash-merged, so neither reaches main. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Agent notes check rejected it (run 40000640876). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The review of #713 (GPT-6.1 Sol) found three gaps in the pull_request version. A "[skip ci]" head commit skipped it. Retargeting a stacked pull request to main did not re-run it. And a path holding the legacy "##[" command syntax could stop the runner's commands and swallow the error annotation. The workflow now runs main's guard on pull_request_target, which skip instructions do not affect and which also runs on conflicted pull requests. It fetches the head commit as data and checks that commit's tree through a temporary index, so the base, and so a retarget, cannot change the result. A pull request cannot turn the guard off by editing it. The script prints the paths between stop-commands and a random resume token, and percent-encodes the annotation data. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis
added a commit
that referenced
this pull request
Oct 23, 2026
Round 2 of the review of #713 (GPT-6.1 Sol) found that GitHub blocks pull_request_target in public repositories without an Actions event policy from 2 December 2026, and this repository has none. Its pull request workflows also never use pull_request_target (docs/release-bundles.md). The guard goes back to pull_request but keeps checking the head commit's tree, fetched by SHA, so a retarget still cannot change the result. A "[skip ci]" head commit or a direct push can still get past a pull request run, so the workflow also checks main on every push and on a daily schedule, which skip instructions do not affect. The stop-commands hardening stays. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Round 2 of the review of #713 (GPT-6.1 Sol) found that GitHub blocks pull_request_target in public repositories without an Actions event policy from 2 December 2026, and this repository has none. Its pull request workflows also never use pull_request_target (docs/release-bundles.md). The guard goes back to pull_request but keeps checking the head commit's tree, fetched by SHA, so a retarget still cannot change the result. A skip instruction in a head commit's message, or a direct push, can still get past a pull request run, so the workflow also checks main on every push and on a daily schedule, which skip instructions do not affect. The stop-commands hardening stays. (The previous head's message quoted a skip instruction while describing this gap, and GitHub skipped every pull request workflow on it.) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis
force-pushed
the
ci/agent-notes-guard
branch
from
October 23, 2026 19:56
33b76b7 to
693d012
Compare
… check must reject Reverted in the next commit; the PR is squash-merged, so neither reaches main. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Agent notes check rejected it (run 40015409561). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rget Round 3 of the review of #713 (GPT-6.1 Sol) found two gaps. A pull request could replace the script that judges it. The workflow ran the checked-out copy, so a head that added .job/HANDOFF.md and a pass-always script passed. On a pull request the job now fetches the default branch with the head, and runs the default branch's copy of the script. Only while the default branch has none, on the pull request that adds it, does the checked-out copy run. Without the default branch the step fails rather than fall back. A stacked pull request whose old base had no guard got its first run only on its next push. Retargeting emits "edited", which the workflow now handles. Every edit runs the full check on the head, so a title edit cannot replace a failure with a skipped run. The docs and changelog now say that a skip instruction can skip push runs as well as pull request runs; only the daily schedule cannot be skipped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Round 4 of the review of #713 (GPT-6.1 Sol) approved, with one nit: GitHub can delay or drop a scheduled run, so the daily check finds a note that reached main by the next scheduled run, if the note is still there. The concurrency comment now says that an edit, as well as a push, supersedes a run in progress. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.
Fixes #712
What and why
Subfleet build jobs keep their state in a
.job/folder at the root of their worktree:HANDOFF.md,PR_BODY.md,env, aHEADfile, git bundles and sometimes a wholerepo.git. On 23 October 2026, #704 committed.job/HANDOFF.md, and #699 committed.job/HANDOFF.mdand.job/PR_BODY.md. Later commits on both branches removed them. Nothing in CI would have stopped them.pr_code_changes.yamlhas apaths:filter, so a pull request that adds only such files runs no workflow at all.This PR adds an Agent notes workflow (
.github/workflows/pr_agent_notes.yaml). It runs.github/check-agent-notes.shon every pull request and fails when the PR's head commit contains:.job/,.job-git/or.diag/folder at any depth, or a file of one of those names..job-git/is a Subfleet job's git directory. A local clone of policyengine-uk has 674.job-git/files and 8.diag/files in its branch history, none ever on main.HANDOFF.md,handoffs.jsonl,PR_BODY.md,PR-BODY.md,REPORT.mdorPROGRESS.md, in any ASCII capitalisation, sopr-body.mdandpr_body.mdcount too. Four of these (REPORT.md,PROGRESS.md,HANDOFF.md,handoffs.jsonl) are policyengine-uk's list.It also ignores the three folders in
.gitignore, sogit add -Aleaves them out, and documents the check indocs/engineering/skills/github-prs.md.Design
policyengine-uk's "No agent working notes at the repository root" step (
pr_code_changes.yamlon its main) is the model, and its pieces carry over:git ls-fileswith:(top,icase)pathspecs, plus:(glob,icase)**/for the folders at any depth.shell: bashadds pipefail, and the script also setsset -euo pipefail, so a git failure fails the step instead of passing it. The script reports a failure with::error::andexit 1.It departs from policyengine-uk's guard where pe.py differs, or where review found a gap:
editedhandled. policyengine-uk's guard is a step in Lint, which runs on every pull request there. pe.py's code workflow has a path filter, so the guard needs its own workflow.types: [opened, synchronize, reopened, edited]: retargeting emitsedited, so the guard checks a stacked PR whose old base had no guard when someone retargets it to main. Every edit runs the full check on the head, with no condition, so a title edit cannot replace a failure with a skipped run. As with everypull_requestworkflow, GitHub runs it only from the workflow files of the PR's merge with its base, and not at all while the PR conflicts with its base..github/check-agent-notes.shinto$RUNNER_TEMPand runs that copy, so a PR cannot pass by editing the script. Round 3 reproduced a false pass with a pass-always script. Only while the default branch has no guard, on this PR, does the checked-out copy run. A missing default-branch ref fails the step rather than falling back. Push, schedule and dispatch runs check out the commit they check. The guard cannot stop a PR that edits the workflow file itself withoutpull_request_targetor a required-workflow setting; the edit shows in the PR's diff.github.event.pull_request.head.shaby exact SHA. The script reads<commit>^{tree}into a temporaryGIT_INDEX_FILEand lists it with the same pathspecs. The result does not depend on the base, so retargeting a stacked pull request cannot change it. No commit on main's first-parent line, all 510 back to the initial commit, has had one of these paths in its tree. The merges of Add canonical SPM selection, receipts and native loading to the wrapper #515, Allow a UK local-area release on an accepted model mismatch #699 and Support bundle-pinned local data release manifests #704 bring 61 commits into main's history that did (44 with a rootPROGRESS.md, 17 with.job/notes), but none reached main's tree. So a PR fails exactly when its head holds a note, and for a PR cut from main's first-parent line, that means exactly when its diff adds one. A branch whose merge base with main is one of the 61 commits, as Build and verify isolated wrapper release candidates #520's is, can carry a note that its diff shows as modified rather than added; the check fails it too.pull_requestandpushruns. GitHub's docs: "Skip instructions only apply to thepushandpull_requestevents." A merge that carries such a commit to main can skip the push run too. This PR showed it by accident: two of its own head commits quoted the instruction while describing this gap, and no pull request workflow ran on them. No commit message can skip the dailyschedulerun, so the next scheduled run finds a note that reaches main that way, if it is still there. GitHub can delay or drop a scheduled run. Thepushrun catches an ordinary direct push at once. GitHub disables schedules in public repositories after 60 days without activity; this one is active.pull_request, notpull_request_target. Round 2 usedpull_request_target. That made skip instructions powerless, ran on conflicted PRs, and ran main's copy of the guard. Two things ruled it out. GitHub blockspull_request_targetin public repositories without an Actions event policy from 2 December 2026, and neither this repository nor the org has one. And pe.py's PR verification never uses it (docs/release-bundles.md). Allowing it is a settings decision, outside this PR.::name::at the start of a line, and the legacy##[name]anywhere in a line. So the guard prints the paths between::stop-commands::<random 32-hex token>and::<token>::(the runner masks the token in the log), and it percent-encodes%in the annotation data. The log lists every path; the annotation gives the count and the first path. This handles a trackedrepo.git..github/check-changelog.sh, so pytest can drive it. It runs under the macOS bash 3.2 as well as Ubuntu's bash 5.permissions: contents: read, no secrets,ubuntu-latest. Let CI test and check jobs use another runner pool, set by repository variables #706's runner-variable test (invariant 2: every other job keepsubuntu-latest) still holds.scripts/check_uk_candidate.pypromotes a candidate automatically only once the newest run of every workflow on its pull request has succeeded, so automatic promotion now also waits for this check. The check runs when the certification workflow opens a candidate PR, pushes a new candidate commit to one, or edits its body, all with its GitHub App token, and takes seconds. A promote run that starts before it finishes waits for the next scheduled run, as it does for any unfinished check.Invariants
pull_request_target.Test plan
tests/test_check_agent_notes.py, 55 tests:.job/HANDOFF.mdfails, and a normal change passes;docs/HANDOFF.md,.jobs/,x.job/,my.job,REPORTS.mdand others);repo.gitin full;##[stop-commands]…,##[add-mask]…,::warning::…, an indented::error::…) and newline names, which an emulation of the runner's parser finds no command in. The emulation followsactions/runnersource (ActionCommand.csTryParseV2/TryParse,ActionCommandManager.csTryProcessCommand);%in the annotation;HEAD, and the.gitignorerules;.job/HANDOFF.mdstill fails. Before main has a guard, the checked-out copy runs. A missing default-branch ref fails closed. Push, schedule and dispatch events check their own commit;Mutation check. Fourteen mutants each fail the suite:
.diag;.jobmatched at the root only;PR-BODY.mddropped;set -eand pipefail removed;%encoding removed;editeddropped;A simulation ran the guard's steps against the head of every open PR, on 23 October and again on 5 November: a clone of main, then a fetch of each open PR's head and the script on that SHA. Both times only Add behavior input binding contract #511 and Build and verify isolated wrapper release candidates #520 fail (both
PROGRESS.md). On 5 November the other 29 open PRs, this one among them, pass.Against main as of 5 November (
286fce481, 54 commits past this PR's base), on a local merge of this head:tests/test_check_agent_notes.pyand Let CI test and check jobs use another runner pool, set by repository variables #706'stests/test_ci_runner_variables.pypass, 130 tests;shellcheckon the script, andruff checkandruff format --checkon the test file, are clean;actionlint 1.7.12onpr_agent_notes.yaml: no findings. On all workflows, its one finding is the existingactions/configure-pages@v3inpush.yaml, which this PR does not touch.This version on GitHub:
.job/HANDOFF.md(run 40015409561), which the next commit reverts. Its log shows::stop-commands::***(the runner masks the token), thenAgent or job note: .job/HANDOFF.md, then the annotation "1 agent or job note path(s) in the tree, starting with .job/HANDOFF.md". The.gitignoremade the demonstration needgit add -f.The round-1 version did the same (pass, fail). A squash merge keeps the demonstration commits off main.
CI on the head
3f30759e5:286fce481(merge commit9d70dfcbb), and every check passed (Code changes, Agent notes, Docs).After merging, with a squash message free of skip instructions (the repository builds squash messages from commit messages, and 0fe7a2d's quotes one). The merge commit
b672ca9dehas the tree CI tested on9d70dfcbb, and its message holds no skip instruction:.job/HANDOFF.mdand replaced the script with a pass-always one, and the check failed (run 41963984834). Its log shows main's copy reporting.job/HANDOFF.md, so main's copy ran. Throwaway: show the Agent notes check runs main's guard (do not merge) #740 is closed unmerged, and its branch is gone.Review
GPT-6.1 Sol (
subfleet run --task review --tier hard) reviewed rounds 1 to 5, and a separate Claude Opus 5.5 agent round 6.Round 1 (
pull_request, merge tree): REQUEST_CHANGES.##[stop-commands]tokensuppressed the error annotation.The fix: the check now reads the head tree, and output goes through stop-commands. The push and schedule runs on main narrow finding 2.
Round 2 (
pull_request_target): REQUEST_CHANGES. It found no bypass, and reproduced the retarget and stop-commands fixes, including with a compiled copy of the runner's parser. The one finding was the 2 Decemberpull_request_targetblock. The fix returned topull_request, as above.Round 3 (
pull_request, head tree, push and schedule on main): REQUEST_CHANGES. It found no path-matching or output bypass, and verified the three GitHub runs. Its findings:edited.8656ee4 fixes them: the job runs the default branch's guard and handles
edited, and the docs say what a skip instruction can skip.Round 4 (8656ee4): APPROVE. No blocker or should-fix. It reproduced the closure of both round-3 findings in scratch repositories: main's copy runs over a PR's pass-always script, and the guard checks an old stacked head after a retarget to main. It verified the shallow two-refspec fetch, fork-object fetch and concurrency, and it caught three workflow-step mutations. One nit: "found within a day" was too strong, because GitHub can delay or drop scheduled runs. The next commit, 3f30759, fixes the wording and the concurrency comment it flagged.
Round 5 (3f30759): APPROVE. The change since 8656ee4 touches only comments and one documentation paragraph, and the workflow YAML loads identically. The new wording agrees with GitHub's schedule and skip-instruction docs, actionlint passes, and it found no inaccurate mechanism claim in this description.
Round 6 (Claude Opus 5.5; 3f30759 merged into main at 286fce4, and this description): REQUEST_CHANGES, on wording only. It reran the history check, the sweep, the tests on the merge, and the CI and documentation checks, and it found nothing wrong with the code or the merge. It found eight statements in this description and the squash message that were inaccurate or incomplete, and the effect on UK certification described above. This version corrects them all, and it approved the corrected text.
Sweep of open pull requests
A sweep on 5 November 2026, up to date at 03:50 UTC, matched the changed-file lists of all 31 open PRs (240 files, from
gh api repos/PolicyEngine/policyengine.py/pulls/N/files) against every path this guard rejects. It also flagged any root-level.md,.jsonlor.txtfile and any file name that mentions a hand-off, PR body, progress note, report or review. Then it ran the guard itself on every open PR's head commit, as in the test plan. The sweep on 23 October found the same two PRs.feat/be-behavior-input-contract→ mainPROGRESS.mdmax/wrapper-release-candidates-20260914→max/spm-canonical-wrapper-release-20260910PROGRESS.md, which its base has since deletedPROGRESS.md, and with main overPROGRESS.md,push.yamland two test files, so GitHub runs nopull_requestworkflow on it; neither its base nor its head has this workflow. Once its merge with its base holds the workflow and has no conflict, its next push or reopen runs the check, which fails ifPROGRESS.mdis still thereNo open PR has a
.job/,.job-git/or.diag/path, or a hand-off or PR-body file. The other root-level matches are edits toREADME.md(#581, #596, #709, #710). The sweep pushed nothing to any of these branches.Not in this PR
.job/,.job-git/or.diag/, and nothing checks its main on a schedule. That is a separate change to policyengine-uk.gh pr checksmust pass) already block on it. A required check that a skip instruction skips stays pending and blocks the merge (GitHub's skip docs). Requiring it needs a ruleset that lets the release bot's version commits through, which is a settings decision.axiom: n/a: CI infrastructure only
🤖 Generated with Claude Code