From 942218f8f9ce76436239385a511caf4467b33e56 Mon Sep 17 00:00:00 2001 From: Christopher Sellers <1@christophersellers.com> Date: Wed, 28 Oct 2026 07:38:23 -0700 Subject: [PATCH 1/3] ci: merge-queue triggers, compile check, grouped Dependabot, serial deploys - pr-gate, backend-ci, frontend-ci: add merge_group (checks_requested) so the required contexts post on merge-group SHAs; PR-only jobs skip on merge_group - inventory guard check E: required emitters + mirrored gates need merge_group - backend-ci: Python compile check (compileall) feeding Backend CI Status Gate - dependabot: split pip runtime and actions groups into minor/patch vs major - deploy.yml: cancel-in-progress false (one Railway rollout at a time, newest pending kept) --- .github/dependabot.yml | 23 +++++++++ .github/workflows/backend-ci.yml | 22 +++++++- .github/workflows/deploy.yml | 5 +- .github/workflows/frontend-ci.yml | 2 + .github/workflows/pr-gate.yml | 49 +++++++++++------- docs/runbooks/BRANCH_PROTECTION.md | 13 +++++ scripts/check_workflow_inventory.py | 18 +++++++ scripts/workflow_inventory.txt | 5 ++ .../scripts/test_check_workflow_inventory.py | 50 ++++++++++++++++++- 9 files changed, 165 insertions(+), 22 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 98886de71..76d808114 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -98,6 +98,7 @@ updates: - "dependencies" - "backend" groups: + # Separate major upgrades so routine minor/patch updates can land independently. root-backend-runtime: patterns: - "*" @@ -112,6 +113,22 @@ updates: - "stripe" - "httpx" - "opentelemetry-*" + update-types: ["minor", "patch"] + root-backend-major: + patterns: + - "*" + exclude-patterns: + - "cryptography" + - "pyjwt" + - "python-jose" + - "sqlalchemy" + - "alembic" + - "fastapi" + - "starlette" + - "stripe" + - "httpx" + - "opentelemetry-*" + update-types: ["major"] # Services directories no longer have their own requirements files; # all Python dependencies are managed at the repository root. @@ -135,6 +152,12 @@ updates: - "dependencies" - "ci" groups: + # Separate major upgrades so routine minor/patch updates can land independently. all-actions: patterns: - "*" + update-types: ["minor", "patch"] + actions-major: + patterns: + - "*" + update-types: ["major"] diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index d42e2ea03..1cffcf8cf 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -1,6 +1,8 @@ name: Backend Services CI/CD on: + merge_group: + types: [checks_requested] # Nightly run against main. The push/pull_request triggers below are # path-filtered, so a stretch of docs- or frontend-only merges leaves the # backend gauntlet unexercised — that let seven gates rot silently on main @@ -72,6 +74,20 @@ permissions: contents: read jobs: + # Compile without dependencies so syntax errors fail before image startup. + compile-check: + name: Python compile check + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 + - name: Setup Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: ${{ env.PYTHON_VERSION }} + - name: Compile backend Python sources + run: python -m compileall -q -j 0 -x '(^|/)(tests|node_modules)(/|$)' server services alembic plugins/fsma scripts + # ── Dependency Lock Freshness ──────────────────────────── dependency-lock: name: Dependency Lock Freshness @@ -1486,11 +1502,12 @@ jobs: # jobs that were aborted mid-flight. `!cancelled()` does not relax the # script below: every dependency still has to be success. if: always() && !cancelled() - needs: [dependency-lock, test, lint, lint-shared, lint-repo-roots, ingestion-full-suite, repo-root-suite, seeded-migration-rehearsal, live-db-suite, docker-build, security, rls-fallback-check, alembic-revision-check, dep-version-check, tenant-id-uuid-check] + needs: [compile-check, dependency-lock, test, lint, lint-shared, lint-repo-roots, ingestion-full-suite, repo-root-suite, seeded-migration-rehearsal, live-db-suite, docker-build, security, rls-fallback-check, alembic-revision-check, dep-version-check, tenant-id-uuid-check] runs-on: ubuntu-latest steps: - name: Check results run: | + echo "Python compile: ${{ needs.compile-check.result }}" echo "Dependency lock: ${{ needs.dependency-lock.result }}" echo "Test: ${{ needs.test.result }}" echo "Lint: ${{ needs.lint.result }}" @@ -1510,7 +1527,8 @@ jobs: # have caught PR #1482's NLP NameError class of bugs if this # gate had been enforcing. Promoting lint to a hard gate so # the next occurrence trips on PR open rather than in prod. - if [[ "${{ needs.dependency-lock.result }}" != "success" \ + if [[ "${{ needs.compile-check.result }}" != "success" \ + || "${{ needs.dependency-lock.result }}" != "success" \ || "${{ needs.test.result }}" != "success" \ || "${{ needs.lint.result }}" != "success" \ || "${{ needs.lint-shared.result }}" != "success" \ diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a7cb55001..80c9ad66a 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -7,7 +7,8 @@ on: # passed /readiness on the first attempt, and per-container memory sits at # ~1.3 GB against a 24 GB replica limit. The wait-for-ci job below still # blocks any SHA without a green Backend CI Status Gate, and the - # cancel-in-progress concurrency group collapses push storms. + # Concurrency preserves the in-flight rollout and keeps only the newest + # pending deploy, collapsing push storms without cancelling railway up. push: branches: ["main"] # Deploy only on backend-relevant changes. This list MUST stay a subset @@ -37,7 +38,7 @@ permissions: concurrency: group: deploy-main - cancel-in-progress: true + cancel-in-progress: false jobs: # Gate: manual deploys must wait for a green CI Status Gate check on the diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml index 8d3e2439a..575740efd 100644 --- a/.github/workflows/frontend-ci.yml +++ b/.github/workflows/frontend-ci.yml @@ -1,6 +1,8 @@ name: Frontend CI/CD on: + merge_group: + types: [checks_requested] # Nightly run against main, mirroring backend-ci: the path-filtered # triggers below leave these gates unexercised during stretches of # backend-only merges, letting drift (promise-audit URL rot, e2e flake) diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml index efd1f06fa..0f7efabbe 100644 --- a/.github/workflows/pr-gate.yml +++ b/.github/workflows/pr-gate.yml @@ -42,12 +42,14 @@ name: PR Gate on: + merge_group: + types: [checks_requested] pull_request: branches: [main] concurrency: - group: pr-gate-${{ github.event.pull_request.number }} - cancel-in-progress: true + group: pr-gate-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} permissions: {} @@ -67,8 +69,8 @@ jobs: - name: Detect backend-relevant changes id: detect env: - BASE_SHA: ${{ github.event.pull_request.base.sha }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} + BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha }} run: | python3 - <<'PY' >> "$GITHUB_OUTPUT" import os, subprocess @@ -110,7 +112,7 @@ jobs: if: steps.detect.outputs.changed == 'true' uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: - HEAD_SHA: ${{ github.event.pull_request.head.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha }} CHECK_NAME: "Backend CI Status Gate" with: github-token: ${{ github.token }} @@ -271,12 +273,13 @@ jobs: actions: read name: "PR Gate: Frontend" runs-on: ubuntu-latest - # Short bound: when the runner queue is backed up the Status Gate may not + # Short PR bound: when the runner queue is backed up the Status Gate may not # exist yet. pr-gate-frontend-rerun.yml re-runs this job when Frontend # CI/CD completes, so a timeout here is recovered automatically. - timeout-minutes: 15 + # Merge groups cannot use the PR-only rerun helper, so wait for real CI. + timeout-minutes: ${{ github.event_name == 'merge_group' && 150 || 15 }} concurrency: - group: pr-gate-frontend-${{ github.event.pull_request.number }} + group: pr-gate-frontend-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 @@ -285,8 +288,8 @@ jobs: - name: Detect frontend-relevant changes id: detect env: - BASE_SHA: ${{ github.event.pull_request.base.sha }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} + BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha }} run: | python3 - <<'PY' >> "$GITHUB_OUTPUT" import os, subprocess @@ -334,20 +337,22 @@ jobs: if: steps.detect.outputs.changed == 'true' uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: - HEAD_SHA: ${{ github.event.pull_request.head.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha }} CHECK_NAME: "Frontend CI Status Gate" with: github-token: ${{ github.token }} # "Frontend CI Status Gate" is created only after earlier frontend # jobs finish. A gate that is missing or still queued is pending, not - # failed: we poll up to 12 minutes, then fail with an explicit + # failed: PRs poll up to 12 minutes (merge groups: 140), then fail with an explicit # "still pending" message. pr-gate-frontend-rerun.yml re-runs this job # once Frontend CI/CD completes, which then mirrors the real result. script: | const { owner, repo } = context.repo; const sha = process.env.HEAD_SHA; const CHECK_NAME = process.env.CHECK_NAME; - const deadline = Date.now() + 12 * 60 * 1000; + // Merge groups have no PR metadata for the rerun helper. + const waitMinutes = context.eventName === 'merge_group' ? 140 : 12; + const deadline = Date.now() + waitMinutes * 60 * 1000; const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); const callWithRetry = async (fn) => { @@ -466,9 +471,8 @@ jobs: } core.setFailed( - `${CHECK_NAME} is still pending (not created yet or queued) after 12 minutes; ` + - `this is a CI queue delay, not a frontend failure. This job is re-run ` + - `automatically when Frontend CI/CD completes (pr-gate-frontend-rerun.yml).` + `${CHECK_NAME} is still pending (not created yet or queued) after ${waitMinutes} minutes; ` + + `inspect the Frontend CI/CD run for this SHA.` ); - name: No frontend-relevant changes if: steps.detect.outputs.changed != 'true' @@ -522,7 +526,7 @@ jobs: gitleaks version - name: Scan PR commits with gitleaks env: - BASE_SHA: ${{ github.event.pull_request.base.sha }} + BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }} run: | gitleaks detect \ --source . \ @@ -570,6 +574,7 @@ jobs: # Shared PR file detection. Required gate jobs above remain unconditional. changes: + if: github.event_name == 'pull_request' name: "PR Gate: Path Detection" runs-on: ubuntu-latest permissions: @@ -602,6 +607,7 @@ jobs: # Jobs migrated from PR Quality Gate. # ── PR Size Check ─────────────────────────────────────── pr-size: + if: github.event_name == 'pull_request' needs: changes permissions: contents: read @@ -643,6 +649,7 @@ jobs: # ── Conventional Commits ──────────────────────────────── commit-lint: + if: github.event_name == 'pull_request' needs: changes permissions: contents: read @@ -692,6 +699,7 @@ jobs: # ── Copy Lint: Ban A-F Grade / Verdict UIs in /tools ──── copy-lint-tools-grade-verdicts: + if: github.event_name == 'pull_request' needs: changes permissions: contents: read @@ -733,6 +741,7 @@ jobs: # ── Label PR ──────────────────────────────────────────── label: + if: github.event_name == 'pull_request' needs: changes permissions: contents: read @@ -761,6 +770,7 @@ jobs: # ────────────────────────────────────────────── supabase-patterns: + if: github.event_name == 'pull_request' needs: changes permissions: contents: read @@ -821,6 +831,7 @@ jobs: # ────────────────────────────────────────────── source-hygiene: + if: github.event_name == 'pull_request' needs: changes permissions: contents: read @@ -885,6 +896,7 @@ jobs: # ────────────────────────────────────────────── audit-trail: + if: github.event_name == 'pull_request' needs: changes permissions: contents: read @@ -986,6 +998,7 @@ jobs: # ────────────────────────────────────────────── sql-safety: + if: github.event_name == 'pull_request' needs: changes permissions: contents: read @@ -1019,6 +1032,7 @@ jobs: # ────────────────────────────────────────────── tenant-isolation: + if: github.event_name == 'pull_request' needs: changes permissions: contents: read @@ -1093,6 +1107,7 @@ jobs: # ────────────────────────────────────────────── react-patterns: + if: github.event_name == 'pull_request' needs: changes permissions: contents: read diff --git a/docs/runbooks/BRANCH_PROTECTION.md b/docs/runbooks/BRANCH_PROTECTION.md index 36f0ef588..a375e6f05 100644 --- a/docs/runbooks/BRANCH_PROTECTION.md +++ b/docs/runbooks/BRANCH_PROTECTION.md @@ -56,6 +56,19 @@ time out (spurious block, recoverable), while the dangerous direction — passing an area whose real gate actually failed — only happens on under-detection. +## Merge queue + +After these workflow changes land, configure the queue as a repository ruleset +on `main`: squash merging, `ALLGREEN`, and a check timeout of 180 minutes. +Required-context workflows need `merge_group: types: [checks_requested]` +triggers, as do `backend-ci.yml` and `frontend-ci.yml` because PR Gate mirrors +their gates. Without these triggers the queue would deadlock waiting for +checks on the merge-group SHA. The workflow inventory guard enforces this. + +`gh pr merge --squash --auto` enqueues the PR. To turn the queue off, +disable or delete its repository ruleset; removing workflow triggers while +the queue is enabled would leave required checks pending forever. + ## Rules ### No admin-override merges diff --git a/scripts/check_workflow_inventory.py b/scripts/check_workflow_inventory.py index e2ed74af7..5776cdb7d 100644 --- a/scripts/check_workflow_inventory.py +++ b/scripts/check_workflow_inventory.py @@ -29,6 +29,8 @@ D. The workflow emitting each required context runs on EVERY pull request — a ``pull_request:`` trigger with no ``paths:`` / ``paths-ignore:`` filter. + E. Required-context emitters and the backend/frontend gates they mirror + have a ``merge_group:`` trigger, or the merge queue would deadlock. The inventory (``scripts/workflow_inventory.txt``) is the checked-in mirror of branch-protection settings. Changing branch protection is a @@ -49,6 +51,9 @@ WORKFLOW_DIR = ROOT / ".github" / "workflows" INVENTORY_FILE = Path(__file__).with_name("workflow_inventory.txt") +# PR Gate mirrors these gates, so they must also post on merge-group SHAs. +MIRRORED_GATE_WORKFLOWS = {"backend-ci.yml", "frontend-ci.yml"} + RUNBOOK = "docs/runbooks/BRANCH_PROTECTION.md" @@ -159,9 +164,11 @@ def main() -> int: ) # Build the context -> emitting workflow map across every workflow on disk. + workflows: dict[str, dict] = {} emitters: dict[str, list[tuple[str, dict]]] = {} for path in sorted(WORKFLOW_DIR.glob("*.y*ml")): workflow = _load_workflow(path) + workflows[path.name] = workflow for job_name in _job_names(workflow): emitters.setdefault(job_name, []).append((path.name, workflow)) @@ -185,6 +192,17 @@ def main() -> int: f"{reason}, so PRs that miss the filter can never satisfy it." ) + # Check E: every emitter and mirrored gate must run for the merge queue. + queue_workflows = MIRRORED_GATE_WORKFLOWS & on_disk + for context in required_contexts: + queue_workflows.update(fname for fname, _ in emitters.get(context, [])) + for fname in sorted(queue_workflows): + if "merge_group" not in _triggers(workflows[fname]): + failures.append( + f"MERGE QUEUE workflow '{fname}' has no `merge_group:` trigger — " + "the merge queue would deadlock waiting for its required or mirrored gate." + ) + print(f"Scanned {len(on_disk)} workflow file(s) in .github/workflows/") print(f"Checked {len(required_contexts)} required context(s) from {INVENTORY_FILE.name}") diff --git a/scripts/workflow_inventory.txt b/scripts/workflow_inventory.txt index b0eea87f3..70492ac9d 100644 --- a/scripts/workflow_inventory.txt +++ b/scripts/workflow_inventory.txt @@ -9,6 +9,11 @@ # exactly what happened between #2551 and 2026-07-31: the required context # "CI Status Gate" was emitted by nothing at all. # +# Merge queue: configure a repository ruleset on main after these changes land +# (squash, ALLGREEN, check timeout 180 min). Required-context workflows and +# mirrored backend/frontend CI need merge_group triggers or the queue deadlocks. +# gh pr merge --squash --auto enqueues; disable/delete the ruleset to turn it off. +# # Two sections, both enforced: # # [workflows] every .github/workflows/*.yml file, one per line. diff --git a/tests/scripts/test_check_workflow_inventory.py b/tests/scripts/test_check_workflow_inventory.py index 22186c9ef..ef5b85dff 100644 --- a/tests/scripts/test_check_workflow_inventory.py +++ b/tests/scripts/test_check_workflow_inventory.py @@ -44,6 +44,8 @@ def _inventory(workflows: list[str], contexts: list[str]) -> str: _GOOD_WORKFLOW = """\ name: gate on: + merge_group: + types: [checks_requested] pull_request: jobs: gate: @@ -56,6 +58,8 @@ def _inventory(workflows: list[str], contexts: list[str]) -> str: _FILTERED_WORKFLOW = """\ name: gate on: + merge_group: + types: [checks_requested] pull_request: paths-ignore: - 'docs/**' @@ -173,7 +177,7 @@ def test_job_id_used_when_job_has_no_name(self, sandbox, capsys): _write_workflow( workflow_dir, "gate.yml", - "name: gate\non:\n pull_request:\njobs:\n ci-status-gate:\n" + "name: gate\non:\n merge_group:\n pull_request:\njobs:\n ci-status-gate:\n" " runs-on: ubuntu-latest\n steps:\n - run: echo ok\n", ) inventory.write_text(_inventory(["gate.yml"], ["ci-status-gate"])) @@ -191,6 +195,50 @@ def test_one_reachable_emitter_is_enough(self, sandbox, capsys): assert _mod.main() == 0 +class TestMergeQueueContract: + def test_required_emitter_without_merge_group_fails(self, sandbox, capsys): + workflow_dir, inventory = sandbox + _write_workflow( + workflow_dir, + "gate.yml", + _GOOD_WORKFLOW.replace(" merge_group:\n types: [checks_requested]\n", ""), + ) + inventory.write_text(_inventory(["gate.yml"], ["CI Status Gate"])) + + assert _mod.main() == 1 + out = capsys.readouterr().out + assert "MERGE QUEUE" in out + assert "deadlock" in out + + def test_every_required_emitter_needs_merge_group(self, sandbox, capsys): + workflow_dir, inventory = sandbox + _write_workflow(workflow_dir, "gate.yml", _GOOD_WORKFLOW) + _write_workflow( + workflow_dir, + "other-gate.yml", + _GOOD_WORKFLOW.replace(" merge_group:\n types: [checks_requested]\n", ""), + ) + inventory.write_text(_inventory(["gate.yml", "other-gate.yml"], ["CI Status Gate"])) + + assert _mod.main() == 1 + assert "other-gate.yml" in capsys.readouterr().out + + @pytest.mark.parametrize("filename", ["backend-ci.yml", "frontend-ci.yml"]) + @pytest.mark.parametrize("merge_group", [True, False]) + def test_mirrored_gate_requires_merge_group(self, sandbox, capsys, filename, merge_group): + workflow_dir, inventory = sandbox + _write_workflow(workflow_dir, "gate.yml", _GOOD_WORKFLOW) + body = _GOOD_WORKFLOW.replace("CI Status Gate", "Mirrored gate") + if not merge_group: + body = body.replace(" merge_group:\n types: [checks_requested]\n", "") + _write_workflow(workflow_dir, filename, body) + inventory.write_text(_inventory(["gate.yml", filename], ["CI Status Gate"])) + + assert _mod.main() == (0 if merge_group else 1) + out = capsys.readouterr().out + assert ("MERGE QUEUE" in out) == (not merge_group) + + class TestRealRepositoryContract: def test_live_repo_satisfies_the_contract(self, capsys): """Runs against the REAL .github/workflows and inventory. Fails if From 6dd7d73e3004d030857c6cf55b174a03e2e6a767 Mon Sep 17 00:00:00 2001 From: Christopher Sellers <1@christophersellers.com> Date: Wed, 28 Oct 2026 07:50:12 -0700 Subject: [PATCH 2/3] ci(pr-gate): look up backend runs by current event so merge_group waits work --- .github/workflows/pr-gate.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml index 0f7efabbe..eb2918ad3 100644 --- a/.github/workflows/pr-gate.yml +++ b/.github/workflows/pr-gate.yml @@ -227,7 +227,7 @@ jobs: repo, workflow_id: 'backend-ci.yml', head_sha: sha, - event: 'pull_request', + event: context.eventName, per_page: 20, }) ); From 292736fda7596d57c0e3dff6f943093a95742146 Mon Sep 17 00:00:00 2001 From: Christopher Sellers <1@christophersellers.com> Date: Wed, 28 Oct 2026 07:55:36 -0700 Subject: [PATCH 3/3] ci(deploy): serialize only the Railway rollout job, not CI waits --- .github/workflows/deploy.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 80c9ad66a..049df61d0 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -6,9 +6,10 @@ on: # the four rollouts between 2026-07-23 and 2026-07-29 (CI + CLI paths) all # passed /readiness on the first attempt, and per-container memory sits at # ~1.3 GB against a 24 GB replica limit. The wait-for-ci job below still - # blocks any SHA without a green Backend CI Status Gate, and the - # Concurrency preserves the in-flight rollout and keeps only the newest - # pending deploy, collapsing push storms without cancelling railway up. + # blocks any SHA without a green Backend CI Status Gate. Railway rollouts + # are serialized at job level while CI waits run in parallel. Concurrency + # preserves the in-flight rollout and keeps only the newest pending deploy, + # collapsing push storms without cancelling railway up. push: branches: ["main"] # Deploy only on backend-relevant changes. This list MUST stay a subset @@ -36,10 +37,6 @@ permissions: checks: read contents: read -concurrency: - group: deploy-main - cancel-in-progress: false - jobs: # Gate: manual deploys must wait for a green CI Status Gate check on the # selected ref before Railway deployment can start. @@ -80,6 +77,9 @@ jobs: deploy: name: Deploy RegEngine Service + concurrency: + group: deploy-main + cancel-in-progress: false needs: [wait-for-ci] runs-on: ubuntu-latest timeout-minutes: 30