Merge approved inspect_ai PRs (Merge queue)
Merge the approved upstream PRs linked from Atlas-board issues in the Merge stage, strictly one at a time — they usually conflict with each other, so each must land before the next is rebased.
Workspace: a throwaway worktree of ~/git/inspect_ai, so queue work
never collides with in-progress work in the main checkout or leftovers from
a previous queue run (a stale checked-out branch once absorbed a stray
git merge origin/main meant for the next queue item). Remotes in that
clone: origin = UKGovernmentBEIS/inspect_ai (upstream, where PRs merge),
meridian = the fork (where PR branches live and are pushed).
cd ~/git/inspect_ai && git fetch origin main
git worktree add --detach <scratch>/merge-queue origin/main
cd <scratch>/merge-queue
Worktree rules (learned the hard way):
- NEVER run
git submodule update --initinside the worktree — git's worktree+submodule handling writes a broken.gitpointer file that then poisons every later command. Run fetch/checkout with submodule recursion off instead:git fetch --no-recurse-submodules, and forgh pr checkoutsetGIT_CONFIG_COUNT=2 GIT_CONFIG_KEY_0=fetch.recurseSubmodules GIT_CONFIG_VALUE_0=false GIT_CONFIG_KEY_1=submodule.recurse GIT_CONFIG_VALUE_1=false. The queue never needs submodule contents (the gitlink invariant is checked viagit diff, not the worktree). - A branch already checked out in another worktree (e.g. the user has it open in the main clone) can't be checked out again — coordinate rather than force.
git worktree removeit in cleanup.- EXCEPTION — "PRs that need a ts-mono change" (below) require real
submodule contents and pnpm builds: do those in a primary clone
(historically
~/git/viewer, remotesorigin/meridianlabs-ai), not a worktree.
1. Find the queue
gh project item-list 1 --owner meridianlabs-ai --format json --limit 1000 \
| jq -r '.items[] | select(.stage == "Merge")
| [(.content.number|tostring), .repository, .title,
((.["linked pull requests"] // []) | join(","))] | @tsv'
- The stage lives in the
stagefield, notstatus. Always use--limit 1000— the default 30 and even 200 truncate the board silently. hold:releasegate: check each queue issue's labels (gh issue view <n> --repo meridianlabs-ai/inspect_ai --json labels) and SKIP any carryinghold:release— approved but deliberately parked until a stable release point (design/atlas-tracking.md → flags). List the skips in the report. EXCEPTION: when the user explicitly says to include holds ("merge the holds too", "post-release sweep"), process them like the rest — and remove thehold:releaselabel from each issue after its PR merges.- External items: the same labels call reveals the
Externallabel — these are contributor PRs the sync queued on approval. Their linked-PR chip is usually absent (it can't be scripted for cross-org PRs), so take the upstream PR URL from the proxy issue body'sUpstream PR:line instead. Then the prereq:gh pr view <n> --repo UKGovernmentBEIS/inspect_ai --json maintainerCanModifymust betrue— if not, SKIP it (leave it queued), and report it with the remedy: ask the contributor to enable "Allow edits by maintainers", or merge manually. Process externals per the "External PRs" section below. - Confirm each upstream PR:
state=OPEN,reviewDecision=APPROVED, notemergeable(usuallyCONFLICTING).
2. Per PR, in order (repeat from here after each merge)
git fetch origin main
git fetch meridian <branch>
git checkout -B <branch> meridian/<branch>
git merge origin/main
Take <branch> from the PR JSON already in hand (headRefName) — NEVER
type it from memory: a guessed branch name once failed the checkout and the
follow-on git merge origin/main landed on whatever branch was current.
Same rule for chained commands: don't pipe state-changing git commands
through | tail/| head inside && chains — the pipe's exit status masks
the failure (run them bare; inspect output separately).
Conflict resolution invariants
- CHANGELOG.md (conflicts almost every time): keep origin/main's released
sections intact; the PR's entries belong under
## Unreleasedat the top (create the section if missing — upstream releases frequently, so it's often gone). Then verify every branch entry mechanically — entries relocate under released headings silently, including via clean auto-merges:
For each added line, confirm its section isgit diff "$(git merge-base origin/main HEAD)" HEAD -- CHANGELOG.md | grep '^+- '## Unreleased(awk trick:awk '/^## /{sec=$0} /<entry text>/{print sec}' CHANGELOG.md). Check this even when CHANGELOG didn't conflict. - Submodule gitlink: after the merge,
git diff --cached origin/main -- src/inspect_ai/_view/ts-monomust be empty (branch carries no net submodule change). If not, restore:git checkout origin/main -- src/inspect_ai/_view/ts-mono. Exception: a PR that changes the viewer type schema needs a deliberate pointer bump — see "PRs that need a ts-mono change" below. - Code conflicts (common once earlier queue PRs land in main): before
resolving, inspect what main changed since divergence —
git log/diff "$(git merge-base HEAD origin/main)"..origin/main -- <file>— and make sure refactors main applied to code this PR deletes are already present in the surviving replacement (e.g. main refactoredrun_multipleand its successor identically; deletingrun_multiplewas safe). Then grep the whole tree for stale references to anything deleted (docstrings too).
Commit the merge (Co-Authored-By trailer). If a code conflict was involved,
sanity-check locally before pushing: ruff check + ruff format --check on
touched files, mypy <touched files>, and any targeted tests that cover the
conflicted area. Pure CHANGELOG/docs conflicts can go straight to CI.
- Run pytest with
PYTHONPATH=$PWD/src(from the worktree root). The venv's editable install points at the PRIMARY clone'ssrc/, so without it pytest imports the main checkout's code and silently tests the wrong tree (observed: a green run that hadn't exercised the merge at all — caught only when a branch-side import didn't exist in the main clone). Verify once per session:PYTHONPATH=$PWD/src python -c 'import inspect_ai; print(inspect_ai.__file__)'must print the worktree path. (ruff/mypytake file paths, so they check the worktree files regardless.) - A merged branch may owe more than textual resolution: when main has
established a new cross-cutting contract (e.g. mutation verbs carry
--tersewith piped-output default; human output goes through the_echosanitizer wrappers, enforced by a meta-test), a branch that ADDS a new command or path in that family must honor the contract even where git reports no conflict — thread the new flags/wrappers through the branch's additions and pin pre-contract tests (--no-terse) the way main's own tests were adapted.
Push, arm auto-merge, watch
git push meridian <branch> # externals: plain `git push` (contributor fork)
gh pr merge <n> --repo UKGovernmentBEIS/inspect_ai --auto --squash
Arm auto-merge (squash — repo history uses it) per PR as you reach it, never on the whole queue up front: the later PRs' green CI is against stale main, and arming them all can land semantically-conflicting merges concurrently.
Then watch in a background monitor (poll ~60s) for FOUR terminal
conditions — the last two are silent stalls that a merged-or-failed watch
never fires on (observed: checks green, auto-merge armed, PR sat BEHIND
until someone happened to look):
- MERGED → confirm, fetch main, next PR.
- any check failed → investigate and fix.
- checks green but
mergeStateStatus: BEHIND→ main moved during CI and branch protection wants branches current, so auto-merge waits forever: mergeorigin/mainagain, RE-VERIFY the CHANGELOG/submodule invariants (every merge re-rolls the relocation dice — clean auto-merges relocated an entry into a released section twice in one day), push. Each loop costs one more CI round; expect several on a busy release day. - checks green but
mergeStateStatus: DIRTY→ main now genuinely conflicts; resolve per the invariants above. - runs stuck in
action_required→ the fork-PR workflow-approval gate: first-time contributors need a maintainer "Approve and run" on EVERY push, including ours (repeat externals don't hit this). Detect viagh api "repos/<upstream>/actions/runs?head_sha=<sha>"and approve withgh api -X POST .../actions/runs/<id>/approve— but only for the exact sha WE pushed; never blind-approve a head someone else moved. - Watch-script pitfalls (both produced silent hour-long stalls):
gh pr checksprints "no checks reported" on STDERR — capture2>&1or the gated state looks like an empty happy loop; and never hand-extend an abbreviated push sha into a query —head_sha=with a fabricated tail matches nothing and the poll spins forever. Resolve withgit rev-parse.
"Pull request was already merged" is success — auto-merge fired the
moment checks passed. Always confirm with
gh pr view <n> --json state,mergedAt → state=MERGED.
Then git fetch origin main and start the next PR — it now conflicts with
what just landed.
External PRs (contributor-owned branches)
Same flow as above with these substitutions — the branch lives on the contributor's fork, not meridianlabs-ai:
- Checkout/push: instead of
git checkout -B <branch> meridian/<branch>, usegh pr checkout <n> --repo UKGovernmentBEIS/inspect_aiin the worktree (with the submodule-recursion-off GIT_CONFIG env from the worktree rules) — withmaintainerCanModifyit wires the branch's push remote to the contributor's fork, so aftergit merge origin/maina plaingit pushlands on their branch (verify withgit push --dry-runthe first time). Never rebase or force-push a contributor branch — merge commits only; their local clone must stay fast-forwardable. - Approval can be dismissed by your push (repo setting–dependent):
re-check
reviewDecisionafter pushing. You can re-approve — pushing to someone else's PR doesn't make you its author — but if branch protection requires approval of the most recent push by someone else, surface that in the report instead of looping. - Invariants are unchanged (CHANGELOG entries under
## Unreleased, no net submodule change) — but they were reviewed, not authored, by us, so check them even more mechanically. A violation that needs real rework goes back to the contributor: comment on the upstream PR, move the proxy to Contributor, and skip — don't rewrite their PR beyond conflict resolution. - ts-mono companions: an external contributor can't author one in meridianlabs-ai/ts-mono — if the PR needs a schema/pointer bump, you author the companion yourself and follow the same sequence below.
- Cleanup: there is no fork review PR to close; the hourly sync closes
the proxy on merge as usual (external proxies always carry the
Upstream PRfield, its join key).
PRs that need a ts-mono change
Recognize it: the PR touches src/inspect_ai/_view/inspect-openapi.json
(or the Pydantic models feeding it) and its check-schema-and-types check is
failing. A companion PR usually already exists in meridianlabs-ai/ts-mono
(the inspect_ai PR/issue or the failing check's diff will reference it; also
gh pr list --repo meridianlabs-ai/ts-mono and search branch names).
Why the ordering is forced — three jobs in the "Validate Embedded Viewer"
workflow (.github/workflows/log_viewer.yml):
check-schema-and-types: the submodule'spackages/inspect-common/src/types/generated.ts(at the pinned commit) must byte-matchpnpm --filter @tsmono/inspect-common types:generaterun against the committedinspect-openapi.json; and the schema must match the Python source modulo docstring-only drift (python .github/scripts/check_openapi_drift.py).submodule-on-main: the pointer must be an ancestor of ts-mono main — you cannot point at a branch commit, so the ts-mono PR merges first.dist-validation: checked-insrc/inspect_ai/_view/distmust matchpnpm --filter @meridianlabs/log-viewer buildat the pinned commit. Bumping the pointer picks up every viewer change on ts-mono main since the last bump, so the bump commit almost always needs a rebuiltdist/too.
Sequence (submodule remote: origin = meridianlabs-ai/ts-mono):
- Sync the inspect_ai branch with origin/main first — the final schema
depends on the merged Python. Then regenerate and check:
python src/inspect_ai/_view/schema.py+python .github/scripts/check_openapi_drift.py. Commitinspect-openapi.jsonif drift is structural. (Seen in practice: the PR's committed schema was stale against its own Python — a PR-authoredLiteralhad been extended — so don't assume the branch's schema is current just because its CI once passed the drift step.) Push; other CI starts churning while the viewer checks stay red — expected. - Update the companion ts-mono PR:
schema.pyalso regeneratedgenerated.tsin the submodule working tree — copy it aside before any git operations in the submodule. Then in the submodule: check out the companion branch,git merge origin/main, restore the regeneratedgenerated.ts, commit, push, wait for its CI. - Merge the companion:
gh pr merge <n> --repo meridianlabs-ai/ts-mono --squash— ts-mono main is squash-only, and regenerate-style companions merge without human review (precedent: #427, #439). Then immediately re-check the tracking issue — companions are Development-panel-linked to it (that link IS the board's PR pill), and GitHub treats every panel-linked PR as a closer, so the companion's merge auto-closes the issue days before the inspect_ai PR lands (no closing keywords involved; issue #251, 2026-08-26). Ifgh issue view <issue> --repo meridianlabs-ai/inspect_ai --json statesays CLOSED, reopen it with a comment saying the companion merge closed it early, and restore the boardStatusfield to "In progress" (Stage is untouched):
(Status field and option ids as in atlas_sync.py; fetch the item id one at a time as in the cleanup section below.) The Atlas sync also self-heals this on its next run, but fixing it inline keeps the board honest while the queue is still working the PR.gh project item-edit --id <ITEM_ID> --project-id PVT_kwDOC7YMCM4BU68p \ --field-id PVTSSF_lADOC7YMCM4BU68pzhKizZM --single-select-option-id 47fc9ee4 - Bump pointer + rebuild dist in one commit: in the submodule,
git fetch origin main && git checkout <squash-sha>; verifytypes:generateis now a no-op;pnpm install --frozen-lockfileandpnpm --filter @meridianlabs/log-viewer build. In the parent, commit the gitlink and the modifieddist/files together, push. - Resume the normal push/CI/merge flow above. Upstream main often moves
during all this (
mergeStateStatus: BEHIND) — merge it in again and re-verify the CHANGELOG invariant before merging.
If ts-mono review comments come in (an automated reviewer runs there), apply
actionable ones in the companion PR before merging it — barrel re-exports in
packages/inspect-common/src/types/index.ts are the recurring one: new
public types plucked from generated.ts should be re-exported like their
neighbors (LogUpdate, ProvenanceData).
3. Clean up
- The hourly Atlas sync closes each fork issue, sets
Status: Done, and clearsStage— for items whoseUpstream PRfield is set (its join key; promotions missing the field are invisible to it — the #90 lesson). So the fast path is: verify the field is set, then run/resolve-board(or just wait for :17). Manual cleanup below is the immediate path or the missing-field fallback. - Issues auto-close and board
Statusauto-moves to Done, but theStagefield stays at "Merge" — clear it per item:
(Stage field id fromgh project item-edit --id <ITEM_ID> --project-id PVT_kwDOC7YMCM4BU68p \ --field-id <STAGE_FIELD_ID> --cleargh project field-list 1 --owner meridianlabs-ai; fetch item ids one at a time — batched jq lookups have returned empty ids.) - Close any still-open fork review PRs (meridianlabs-ai/inspect_ai) with a comment linking the merged upstream PR.
- Then check for newly-ready work: approvals often land while a set is
merging, and the board only reflects them after a sync. Dispatch the Atlas
sync (or
/resolve-board) and re-run step 1 — if new items entered Merge, process them as the next set. Repeat until the queue comes back empty; only then write the final report. - Report per-PR: what conflicted, how it was resolved, merge commit oid.