Skip to content

ci(migrations): re-check open PRs when a new head lands on main (CHOO-2689) - #441

Merged
petr-sandbox merged 5 commits into
mainfrom
ci/migration-heads-on-merge
Sep 15, 2026
Merged

petr-sandbox merged 5 commits into
mainfrom
ci/migration-heads-on-merge

Conversation

@wojtyniakAQ

Copy link
Copy Markdown
Collaborator

Summary

Two migrations chained off the same parent are each green in isolation and produce a second head only once both merge — at which point the server crash-loops on boot, because it self-migrates with alembic upgrade head and that is ambiguous with more than one. It happened twice in 48 hours (#404 with #397, then the templates migration) and was hand-fixed with merge revisions both times.

The gap is not where the ticket says. A manual alembic stamp cures nothing permanently, and the per-PR check is not testing the wrong tree: actions/checkout already checks out GitHub's merge preview on a pull_request event, verified against a real run's logs. The gap is staleness — GitHub recomputes that preview as the base moves but nothing re-runs the workflow, and merging is not gated on the branch being current, so a day-old green check still satisfies the merge button.

So: when a migration lands on main, re-check every other open PR that also adds one, and post a failing status on it immediately rather than letting the next deploy find out.

It reads the chain statically, and that is the security-relevant part of the design. The obvious implementation overlays the other PR's files and runs the existing test — but that test builds an Alembic ScriptDirectory, and Alembic imports every revision module to construct the graph. On a main workflow run holding a token that can write statuses, in a public repository, that is executing unreviewed Python from any pull request anyone cares to open. This parses revision and down_revision out of the file text with a regex and ast.literal_eval instead — the same way the existing test_revision_ids_are_unique already reads revision ids, and for the same reason — and derives heads as "any revision that is nobody's parent". Nothing is imported, nothing is checked out, and the job needs neither a database nor the project's dependencies.

The per-PR run keeps using the real test unchanged: there, the code being imported is the code under review.

Test plan

  • Verified offline against the real 65-revision tree: healthy chain reports no problem.
  • Fabricated a sibling migration off the current head's parent → expected exactly one migration head, got ['a3f61c02d5be', 'fake000000aa'].
  • Fabricated a duplicate revision id and a dangling parent → each reported.
  • Merge revisions parse correctly, including the tuple form of down_revision (checked against the real b47e0c39a1f5).
  • just check, just typecheck and mypy on the script directly all pass; workflow YAML validated.
  • End to end needs a real collision: open two PRs each adding a migration off the current head, merge one, and watch the status appear on the other.

Note for whoever has repository admin

Out of scope here, but found while investigating: the branch ruleset's required status checks are only "Detect changed trees" and "Secret scan". Backend (core) is not required, so a red test suite does not block a merge today.

*args: str, cwd: Path = REPO_ROOT, check: bool = True
) -> subprocess.CompletedProcess[str]:
return subprocess.run(
list(args), cwd=cwd, check=check, capture_output=True, text=True

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Semgrep identified a blocking 🔴 issue in your code:
Detected subprocess function 'run' with user controlled data. A malicious actor could leverage this to perform command injection. You may consider using 'shlex.quote()'.

Why this might be safe to ignore:

The tainted repository value is passed as one element in a list to subprocess.run, with shell execution not enabled, so shell metacharacters cannot cause command injection. GITHUB_REPOSITORY is also a GitHub-provided repository identifier rather than arbitrary user input in this script.

Dataflow graph
flowchart LR
    classDef invis fill:white, stroke: none
    classDef default fill:#e7f5ff, color:#1c7fd6, stroke: none

    subgraph File0["<b>scripts/check_migration_heads_for_open_prs.py</b>"]
        direction LR
        %% Source

        subgraph Source
            direction LR

            v0["<a href=https://github.com/sandbox-quantum/switch/blob/06598b33a7ffea479fa039d41c2f3b4450097604/scripts/check_migration_heads_for_open_prs.py#L245 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 245] os.environ</a>"]
        end
        %% Intermediate

        subgraph Traces0[Traces]
            direction TB

            v2["<a href=https://github.com/sandbox-quantum/switch/blob/06598b33a7ffea479fa039d41c2f3b4450097604/scripts/check_migration_heads_for_open_prs.py#L245 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 245] repo</a>"]

            v3["<a href=https://github.com/sandbox-quantum/switch/blob/06598b33a7ffea479fa039d41c2f3b4450097604/scripts/check_migration_heads_for_open_prs.py#L247 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 247] open_prs_touching_migrations</a>"]

            v4["<a href=https://github.com/sandbox-quantum/switch/blob/06598b33a7ffea479fa039d41c2f3b4450097604/scripts/check_migration_heads_for_open_prs.py#L131 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 131] repo</a>"]

            v5["<a href=https://github.com/sandbox-quantum/switch/blob/06598b33a7ffea479fa039d41c2f3b4450097604/scripts/check_migration_heads_for_open_prs.py#L132 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 132] run</a>"]

            v6["<a href=https://github.com/sandbox-quantum/switch/blob/06598b33a7ffea479fa039d41c2f3b4450097604/scripts/check_migration_heads_for_open_prs.py#L124 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 124] args</a>"]
        end
            v2 --> v3
            v3 --> v4
            v4 --> v5
            v5 --> v6
        %% Sink

        subgraph Sink
            direction LR

            v1["<a href=https://github.com/sandbox-quantum/switch/blob/06598b33a7ffea479fa039d41c2f3b4450097604/scripts/check_migration_heads_for_open_prs.py#L127 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 127] list(args)</a>"]
        end
    end
    %% Class Assignment
    Source:::invis
    Sink:::invis

    Traces0:::invis
    File0:::invis

    %% Connections

    Source --> Traces0
    Traces0 --> Sink


Loading

To resolve this comment:

🔧 No guidance has been designated for this issue. Fix according to your organization's approved methods.

💬 Ignore this finding

Reply with Semgrep commands to ignore this finding.

  • /fp <comment> for false positive
  • /ar <comment> for acceptable risk
  • /other <comment> for all other reasons

Alternatively, triage in Semgrep AppSec Platform to ignore the finding created by dangerous-subprocess-use-tainted-env-args.

You can view more details about this finding in the Semgrep AppSec Platform.

@petr-sandbox

Copy link
Copy Markdown
Collaborator

A few comments:

  1. The union double-counts every shared migration — blocker

fetch_pr_revisions does git ls-tree -r FETCH_HEAD -- versions/, which returns the PR head's whole versions directory, not just the files it adds. check_pr then does base_revisions + fetch_pr_revisions(...), so all 65 migrations the two sides share appear twice.

base alone            -> ''                 (clean)
base + PR-head union  -> duplicate migration revision ids:
   {'04f27f37e474': ['04f27f37e474_oidc_identities_table.py',
                     '04f27f37e474_oidc_identities_table.py'], ...}

So every PR that touches migrations gets state: failure with "Merging this PR now would break the migration chain — rebase onto the latest main", unconditionally, and the job exits 1 on every main push. Worse, describe_chain_problem returns on duplicates first, so the two-head case it exists to catch is never reached.

The docstring's reasoning is right ("the versions directory only ever gains files, so a plain union ... is the merge result") — it's a set union that was implemented as list concatenation. Build the union keyed by filename, PR side winning:

merged = {r.filename: r for r in base_revisions}
merged.update({r.filename: r for r in fetch_pr_revisions(int(number))})
problem = describe_chain_problem(list(merged.values()))

With that, against the real tree: a PR chaining off the current head reports clean, and a sibling off the head's parent reports expected exactly one migration head, got ['a3f61c02d5be', 'sibrev00001']. That's the intended behaviour, and it's what the unchecked end-to-end box in the test plan would have caught.

  1. gh pr list silently caps at 30

No --limit. The repo has 33 open PRs today:

gh pr list --state open --json number            -> 30
gh pr list --state open --limit 200 --json number -> 33

Three PRs get no check and nothing says so. Pass --limit 1000, or better --search to filter server-side.

  1. The concurrency group cancels the run that matters
concurrency:
  group: pr-ci-${{ github.ref }}
  cancel-in-progress: true

On push, github.ref is refs/heads/main for every merge. Merge a migration PR, then merge anything else a minute later: the second run cancels the first, and because paths-filter on push only sees the second commit's files, migrations is false and the job is skipped. No statuses are posted, silently — on precisely the busy day this is meant to cover. Either give push runs their own group (pr-ci-${{ github.ref }}-${{ github.event_name == 'push' && github.sha || '' }}) or set cancel-in-progress: ${{ github.event_name == 'pull_request' }}.

  1. One bad PR aborts the whole sweep

run() is check=True everywhere and check_pr has no error handling, so a single gh/git failure — a transient API error, a fetch that can't resolve — raises out of the list comprehension in main(). PRs already checked keep their status; the rest get nothing, and the only signal is a red job on main that looks identical to a genuine finding. Wrap each PR, post an error status for the one that failed, and keep going.

That matters because two inputs can raise on ordinary content:

  • Multi-line down_revision tuple. The regex captures to end of line, so down_revision = (\n "bb",\n "cc",\n) gives ast.literal_eval("(") → SyntaxError: '(' was never closed. Today's merge revisions fit on one line, so this is latent — but a long merge revision or a formatter change makes it live.
  • A non-revision .py under versions/ → KeyError: 'revision' from values["revision"].

Both should be reported as a problem with that file, not a stack trace.

@petr-sandbox

Copy link
Copy Markdown
Collaborator

All four addressed in 0f564fa, and each verified against the real 65-revision tree the way the review was.

1. The union — blocker. Keyed by filename now, PR side winning, in a merge_preview helper that says why. Reproducing your numbers on the same tree:

base alone                    -> ''
OLD concat (base + prhead)    -> duplicate migration revision ids: {'04f27f37e474': [...
NEW union (base + prhead)     -> ''
PR chains off the head        -> ''
PR siblings the head          -> expected exactly one migration head, got ['a3f61c02d5be', 'sibrev00001']

You were right that the docstring's reasoning was sound and the implementation wasn't; the reasoning now lives on the helper that actually does the union.

2. gh pr list cap. --limit 1000, and it raises rather than proceeding if the listing ever comes back at the limit — silently checking a subset is the failure mode worth being loud about. Kept gh pr diff --name-only for the per-PR filter rather than moving to gh pr list --json files: that field is capped at 100 files per PR, and #444 has 217, so it would have swapped one silent truncation for another.

3. Concurrency. Push runs now carry the commit in their group:

group: pr-ci-${{ github.ref }}-${{ github.event_name == 'push' && github.sha || '' }}

Your first suggestion rather than the second: with cancel-in-progress: false on pushes the runs queue instead, and a third merge cancels the pending second one, which is the same hole a lane deeper.

4. The sweep, and the two parse inputs. Each PR now runs inside check_pr_isolated, which posts an error status naming the PR and carries on (and survives that post failing too). For the inputs:

  • Multi-line down_revision — I swapped the regex for ast.parse and read the two assignments off the tree instead of the text. Still nothing imported and nothing executed, which was the whole reason for not using Alembic here, but an assignment is no longer assumed to be a line, so the multi-line tuple stops being a latent bug rather than a patched one. Confirmed against the real b47e0c39a1f5 and a fabricated multi-line tuple.
  • Non-migration .pyRevisionProblem, reported as a finding against that file. Same for a file that won't parse and for a non-string revision:
helper.py -> no module-level `revision` assignment -- every file under versions/ must be a migration
broken.py -> is not valid Python: invalid syntax (broken.py, line 1)
weird.py  -> revision is not a string literal: 1

main treats an unreadable file on main itself separately: it exits 1 saying so, rather than painting every open PR red for a problem none of them caused.

ruff format, ruff check and mypy all clean on the script; workflow YAML validated.

On the Semgrep finding — the taint it drew (GITHUB_REPOSITORY into an argument list) was removed in 8883291, and the rule reports nothing locally: 0 findings for dangerous-subprocess-use-tainted-env-args specifically and 0 for p/python + p/security-audit over the file. But the platform scan was still red on 540a994, which it scanned after those commits landed — so either it's an unresolved finding that needs triaging on their side, or their Pro engine's interprocedural taint sees something the community rule doesn't. Watching what this push reports before deciding between a code change and an /fp.

Not addressed, and I don't think it should be here: the branch ruleset gap you found in the description (Backend (core) not being a required check) still needs someone with repository admin.

@petr-sandbox

Copy link
Copy Markdown
Collaborator

Parking this until Phase 2 (CHOO-2624) is done. Not because of the review — all four points are fixed in 0f564fa and CI is green apart from the stale Semgrep finding.

The reason is scope: this touches pr-ci.yml, which is in the shared paths filter, and it changes the concurrency group for every run in the repo. Giving push runs their own group is the right fix for the hole in point 3, but it also means two merges in quick succession no longer cancel each other's 12-minute Backend (core) job — they both run. That is a repo-wide CI cost and behaviour change, and it wants the team's agreement rather than one reviewer's.

Nothing in the Phase 2 stack (#444#445#446#447, plus #442) depends on this. Until it lands, a second head that slips past a stale green check is still caught the way it has been: by hand, with a merge revision.

Two things to pick up when it comes off the shelf:

  1. The Semgrep status is still red on the original finding from 06598b3 — no new comment, and the code that produced the taint went in 8883291. Needs an /fp or a console triage, not a code change (see the previous comment).
  2. Worth considering splitting migration-heads-on-merge into its own workflow file with its own concurrency group. The job would then need no change to pr-ci.yml at all, which removes the repo-wide part of this and most of the reason it needs wider sign-off.

@petr-sandbox petr-sandbox left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Looks good.

wojtyniakAQ and others added 5 commits September 15, 2026 09:55
…-2689)

actions/checkout already checks out refs/pull/<n>/merge on pull_request
events, so test_migration_chain.py already runs against the merge preview,
not the branch tip. The actual gap is that GitHub keeps that preview current
as the base moves but never re-runs the workflow to notice, and merging isn't
gated on being up to date -- so a PR whose last CI run was green can still
merge after main gains a colliding migration. That's exactly what happened
twice in #404/#426, hand-fixed with merge revisions b47e0c39a1f5 and
c81f4a06d2b7.

Add a job that runs on push to main when the migrations directory changes:
it re-checks every other open PR touching migrations by reading that PR's
added revision files as plain text alongside the base as it now stands, then
posts the result as a commit status on the PR's own head commit. A PR that
would create a second head goes red the moment the collision exists, without
needing a new push.

The check reads revision/down_revision with a regex and ast.literal_eval
instead of going through Alembic's ScriptDirectory, which builds its graph by
importing every revision file. Importing a file from someone else's open PR
on a public repo, from a job that runs on push to main with a token that can
write commit statuses, would be running unreviewed code with that token's
privileges. The per-PR pull_request run keeps using the real
test_migration_chain.py unchanged, since there the code under test is the
code already under review.
…2689)

The repository slug and the tracked path both reach a git or gh argument
list. Nothing runs through a shell, so the risk was never a metavariable
— it is a leading dash, which git reads as an option rather than a path.
Both are now matched against an explicit pattern first, which also keeps
the reachable surface to files that could actually be migrations.
…689)

The scanner flagged the repository slug reaching a subprocess argument
list, and its own note said the flow was safe: list form, no shell, and a
value GitHub supplies. Both true, and still the wrong thing to argue
about — the value did not need to be there at all.

gh resolves the repository from its own environment, and fills its
`{owner}/{repo}` placeholders in an API path, so `--repo` and the
interpolated status URL both go away. What remains from outside the
script is a tracked path and a commit id, each matched against a pattern
before it is passed. Removing the flow beats annotating it.
The remaining scanner finding was the run URL, which comes from the
environment and was interpolated into a `-f` argument. The rule is about
environment values reaching an argument list, and it is right that this
is the wrong shape even though no shell is involved.

The whole request body now goes to `gh api --input -` as JSON on stdin,
which takes the description text off the command line too. Nothing
variable-length is an argument any more.
…aborted sweep (CHOO-2689)

Four defects found in review, each of which would have made the check
useless in a different way.

The base and the PR's head both carry the whole versions directory, so
concatenating them counts every shared migration twice. Every PR that
touched migrations would have gone red on a duplicate-id error, and
because duplicates are reported first, the two-head case the job exists
to catch was unreachable. The union is now keyed by filename.

`gh pr list` stops at 30 and says nothing about the rest; the repository
has more open than that, so the PRs it skipped got no check and no
signal. It now asks for a limit it will not reach, and fails loudly if
it ever does.

Every push to main shares one `github.ref`, so the concurrency group let
a later merge cancel the run for the migration that had just landed —
and the replacement run skips this job, because its own commit touched
no migration. Push runs now carry the commit in their group.

A single `gh` or `git` failure aborted the whole sweep from inside a
list comprehension, leaving the PRs not yet reached with nothing on them
and a red job on main indistinguishable from a genuine finding. Each PR
is now checked in isolation and gets an error status of its own.

Two inputs could raise on ordinary content: a `down_revision` tuple
spread over several lines, which a line-anchored regex hands to
`literal_eval` half-finished, and a non-migration `.py` under
`versions/`, which came out as a bare `KeyError`. Reading the
assignments off `ast.parse`'s tree removes the first outright, and both
are now reported as a problem naming the file. Nothing is imported or
executed, which was the point of not using Alembic here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@petr-sandbox
petr-sandbox force-pushed the ci/migration-heads-on-merge branch from 0f564fa to 9ddfbc4 Compare September 15, 2026 13:56
@petr-sandbox
petr-sandbox merged commit c0b92d0 into main Sep 15, 2026
10 of 11 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.

2 participants