ci: prepare CI workflows for a GitHub merge queue (#2721)

Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
João Salavisa
2026-08-16 20:10:03 +01:00
committed by GitHub
parent 032b693eb2
commit 5dcac3f259
12 changed files with 1372 additions and 278 deletions
+208
View File
@@ -0,0 +1,208 @@
#!/usr/bin/env python3
"""Apply a GitHub `paths:` filter inside a job instead of on the trigger.
Why this exists
---------------
A workflow that is skipped by a trigger-level `paths:` filter reports **no
conclusion at all**. GitHub's own documentation is explicit that "checks
associated with that workflow will remain in a 'Pending' state", and a required
check that stays pending blocks a pull request from entering a merge queue and
blocks the merge group from ever merging.
The `merge_group` event makes this worse: it supports `types:` and `branches:`
filters only -- `paths:` is not available -- so a trigger-level filter cannot
even be mirrored onto the queue side.
The fix is to move the filter one level down. The workflow triggers
unconditionally, a cheap `changes` job runs this script to decide whether the
expensive jobs are relevant, and an always-running gate job reports the single
conclusion the merge queue waits on. Jobs skipped by an `if:` conditional are
fine here because they are never themselves the required check.
Usage
-----
git diff --name-only -z "$BASE" HEAD | python3 .github/scripts/changed-paths.py
Reads the pattern list from `$PATTERNS` (override with `--patterns-env`), one
pattern per line, in the same syntax and with the same ordering semantics as a
workflow's `paths:` list. Writes `run=true` or `run=false` to stdout and, when
`$GITHUB_OUTPUT` is set, appends it there as a step output.
Pass `-z` to the diff. Git quotes any path outside ASCII under its default
`core.quotePath=true`, so plain `--name-only` reports `apps/web/café.ts` as the
literal 24-character string `"apps/web/caf\303\251.ts"`. That matches no
pattern, so a pull request touching only such a file selects nothing, every job
is skipped, and the gate reports success having tested none of it -- the exact
silent pass this whole mechanism exists to prevent. `-z` emits raw bytes with a
NUL after each path and suppresses the quoting.
Stdin is split on NUL when it contains one and on newlines otherwise, so both
forms work. The detection is not ambiguous: `-z` terminates every path with a
NUL, so any non-empty `-z` output contains at least one.
Pattern syntax
--------------
Supported: `*` (any run of characters except `/`), `**` (any run including
`/`), `?` (one character except `/`), and a leading `!` for negation. A file's
verdict is decided by the LAST pattern that matches it, which is what GitHub
does, so `!**/*.md` after `apps/web/**` excludes Markdown under `apps/web`.
Deliberately unsupported: `+`, `[...]` character classes, and `{...}` braces.
GitHub accepts the first two; this script rejects any pattern containing them
rather than silently matching them as literals, because a filter that quietly
stops matching is exactly the failure this file exists to prevent.
"""
from __future__ import annotations
import argparse
import os
import re
import sys
# Characters GitHub's filter-pattern syntax gives a meaning that this script
# does not implement. Rejected loudly rather than treated as literals.
UNSUPPORTED_METACHARACTERS = ("+", "[", "]", "{", "}")
def compile_pattern(pattern: str) -> re.Pattern[str]:
"""Translate one GitHub filter pattern into an anchored regex.
`**` is translated before `*` so that the single-segment wildcard never
consumes half of a double one.
"""
parts: list[str] = []
index = 0
while index < len(pattern):
char = pattern[index]
if char == "*":
if pattern.startswith("**", index):
parts.append(".*")
index += 2
else:
parts.append("[^/]*")
index += 1
elif char == "?":
parts.append("[^/]")
index += 1
else:
parts.append(re.escape(char))
index += 1
return re.compile("".join(parts) + r"\Z")
class Filter:
"""An ordered `paths:` list, evaluated last-match-wins per file."""
def __init__(self, patterns: list[str]) -> None:
self.rules: list[tuple[bool, re.Pattern[str]]] = []
for raw in patterns:
pattern = raw.strip()
# Blank lines and `#` comments keep the YAML block readable.
if not pattern or pattern.startswith("#"):
continue
included = True
if pattern.startswith("!"):
included = False
pattern = pattern[1:]
# A bare `!` compiles to a regex matching only the empty string,
# which no real path is. It would append a rule that can never fire
# and read like an exclusion that works.
if not pattern:
raise ValueError(
f"pattern {raw!r} is a bare negation with no path. Use "
"'!path/pattern' to exclude specific files."
)
found = [c for c in UNSUPPORTED_METACHARACTERS if c in pattern]
if found:
raise ValueError(
f"pattern {raw!r} uses unsupported metacharacter(s) "
f"{''.join(found)!r}. changed-paths.py implements *, ** and ? "
"only; extend it (and its tests) before using more."
)
self.rules.append((included, compile_pattern(pattern)))
if not self.rules:
raise ValueError("no patterns were supplied")
def includes(self, path: str) -> bool:
"""Whether `path` survives the filter. Later patterns win."""
verdict = False
for included, regex in self.rules:
if regex.match(path):
verdict = included
return verdict
def matches_any(self, paths: list[str]) -> bool:
return any(self.includes(path) for path in paths)
def read_changed_paths(data: bytes) -> list[str]:
"""Split a diff's path list, preferring the NUL-delimited form.
Taken as bytes rather than text because `git diff -z` emits raw filesystem
bytes, which need not be valid UTF-8. `surrogateescape` keeps an
undecodable name intact and matchable instead of failing the step.
"""
separator = b"\0" if b"\0" in data else b"\n"
return [
decoded
for chunk in data.split(separator)
if (decoded := chunk.decode("utf-8", errors="surrogateescape").strip())
]
def main(argv: list[str]) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--patterns-env",
default="PATTERNS",
help="environment variable holding the newline-separated pattern list",
)
args = parser.parse_args(argv)
raw_patterns = os.environ.get(args.patterns_env)
if raw_patterns is None:
print(
f"::error::{args.patterns_env} is unset; changed-paths.py has no "
"filter to apply.",
file=sys.stderr,
)
return 2
try:
path_filter = Filter(raw_patterns.splitlines())
except ValueError as error:
print(f"::error::{error}", file=sys.stderr)
return 2
changed = read_changed_paths(sys.stdin.buffer.read())
run = path_filter.matches_any(changed)
# The matching subset is the useful half of the log: "no relevant changes"
# is indistinguishable from "the filter is broken" without it.
if run:
print("Relevant changed files:")
for path in changed:
if path_filter.includes(path):
print(f" {path}")
else:
print(f"No relevant changes among {len(changed)} changed file(s).")
value = "true" if run else "false"
github_output = os.environ.get("GITHUB_OUTPUT")
if github_output:
with open(github_output, "a", encoding="utf-8") as handle:
handle.write(f"run={value}\n")
print(f"run={value}")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+355
View File
@@ -0,0 +1,355 @@
#!/usr/bin/env python3
"""Tests for the in-job `paths:` filter used by the merge-queue gating jobs.
Two halves. The first exercises the matcher directly. The second reads the
`PATTERNS` blocks out of the workflows that actually use it and asserts they
still select what their trigger-level `paths:` filters used to select -- a
regex that silently stops matching would hand every merge queue entry a full
E2E run, or worse, skip one that mattered.
"""
from pathlib import Path
import os
import re
import subprocess
import sys
import tempfile
import unittest
sys.path.insert(0, str(Path(__file__).resolve().parent))
from importlib import import_module
changed_paths = import_module("changed-paths")
REPO_ROOT = Path(__file__).resolve().parents[2]
WORKFLOWS = REPO_ROOT / ".github" / "workflows"
SCRIPT = Path(__file__).resolve().parent / "changed-paths.py"
# Every workflow that moved its filter into a `changes` job, with one path that
# must still select it and one that must not.
# Required checks with no `changes` job: cheap enough to always run, so their
# filtering was removed outright rather than moved. They still have to keep the
# merge_group trigger and stay unfiltered, and nothing else asserts it --
# `lint-action-pinning.yml` is covered by the frontend and release contract
# tests, but these two had no guard at all.
ALWAYS_RUN_WORKFLOWS = (
"architecture-lint.yml",
"lint-harness-files.yml",
)
GATED_WORKFLOWS = {
"backend-tests.yml": (
["apps/backend/internal/orchestrator/orchestrator.go"],
["apps/web/components/App.tsx", "apps/backend/AGENTS.md"],
),
"frontend-tests.yml": (
["apps/web/components/App.tsx"],
["apps/backend/internal/orchestrator/orchestrator.go", "apps/web/README.md"],
),
"e2e-tests.yml": (
["apps/web/e2e/specs/kanban.spec.ts"],
["docs/public/install.md", "README.md"],
),
}
def patterns_block(workflow: str) -> str:
"""Return the `PATTERNS:` heredoc-style YAML block of the `changes` job."""
text = (WORKFLOWS / workflow).read_text(encoding="utf-8")
match = re.search(r"(?m)^ PATTERNS: \|\n((?:^ .*\n|^\n)+)", text)
if match is None:
raise AssertionError(
f"{workflow} has no `PATTERNS: |` block in its `changes` job. The "
"gating job cannot filter anything without one, so every job it "
"guards would run on every event -- or none would."
)
return match.group(1)
class MatcherTest(unittest.TestCase):
def test_double_star_crosses_slashes_and_single_star_does_not(self) -> None:
crossing = changed_paths.Filter(["apps/web/**"])
self.assertTrue(crossing.includes("apps/web/components/deep/App.tsx"))
flat = changed_paths.Filter(["apps/web/*"])
self.assertTrue(flat.includes("apps/web/package.json"))
self.assertFalse(flat.includes("apps/web/components/App.tsx"))
def test_question_mark_matches_one_non_slash_character(self) -> None:
path_filter = changed_paths.Filter(["apps/?eb/main.ts"])
self.assertTrue(path_filter.includes("apps/web/main.ts"))
self.assertFalse(path_filter.includes("apps//eb/main.ts"))
def test_patterns_are_anchored_at_both_ends(self) -> None:
path_filter = changed_paths.Filter(["Makefile"])
self.assertTrue(path_filter.includes("Makefile"))
self.assertFalse(path_filter.includes("apps/backend/Makefile"))
self.assertFalse(path_filter.includes("Makefile.bak"))
def test_last_matching_pattern_decides(self) -> None:
"""`!**/*.md` after an include is an exclusion, not an extra include."""
path_filter = changed_paths.Filter(["apps/web/**", "!**/*.md"])
self.assertFalse(path_filter.includes("apps/web/README.md"))
self.assertTrue(path_filter.includes("apps/web/components/App.tsx"))
# Order matters: reversing the list makes the negation dead.
reversed_filter = changed_paths.Filter(["!**/*.md", "apps/web/**"])
self.assertTrue(reversed_filter.includes("apps/web/README.md"))
def test_a_single_relevant_file_selects_the_workflow(self) -> None:
path_filter = changed_paths.Filter(["apps/web/**", "!**/*.md"])
self.assertTrue(
path_filter.matches_any(["apps/web/README.md", "apps/web/lib/api.ts"]),
"A docs change bundled with a code change must still run the job.",
)
self.assertFalse(path_filter.matches_any(["apps/web/README.md"]))
self.assertFalse(
path_filter.matches_any([]),
"An empty diff selects nothing; the caller decides whether an "
"unresolvable base means 'run everything'.",
)
def test_blank_lines_and_comments_are_ignored(self) -> None:
path_filter = changed_paths.Filter(["", "# a note", "apps/web/**", " "])
self.assertTrue(path_filter.includes("apps/web/main.tsx"))
def test_unsupported_metacharacters_are_rejected(self) -> None:
for pattern in ("apps/web/*.[jt]s", "apps/web/a+b", "apps/{web,cli}/**"):
with self.subTest(pattern=pattern):
with self.assertRaises(ValueError):
changed_paths.Filter([pattern])
def test_an_empty_pattern_list_is_rejected(self) -> None:
with self.assertRaises(ValueError):
changed_paths.Filter(["", "# only comments"])
def test_a_bare_negation_is_rejected(self) -> None:
"""`!` alone compiles to a rule that can never match anything.
It reads like an exclusion and silently is not one, so reject it the
same way an unsupported metacharacter is rejected.
"""
with self.assertRaises(ValueError):
changed_paths.Filter(["apps/web/**", "!"])
class ChangedPathParsingTest(unittest.TestCase):
"""`git diff -z` output, which is where the quoting trap lives."""
def test_nul_delimited_input_is_split_on_nul(self) -> None:
data = "apps/web/a.ts\0apps/web/b.ts\0".encode("utf-8")
self.assertEqual(
changed_paths.read_changed_paths(data),
["apps/web/a.ts", "apps/web/b.ts"],
)
def test_newline_delimited_input_still_works(self) -> None:
data = b"apps/web/a.ts\napps/web/b.ts\n"
self.assertEqual(
changed_paths.read_changed_paths(data),
["apps/web/a.ts", "apps/web/b.ts"],
)
def test_a_non_ascii_path_survives_and_still_matches(self) -> None:
"""The regression this parsing exists for.
Under git's default `core.quotePath=true`, plain `--name-only` reports
this file as the literal string `"apps/web/caf\\303\\251.ts"`, quotes
and all. That matches no pattern, so a pull request touching only it
would skip every job and report a green gate having tested nothing.
`-z` emits the raw bytes instead.
"""
raw = "apps/web/café.ts\0".encode("utf-8")
parsed = changed_paths.read_changed_paths(raw)
self.assertEqual(parsed, ["apps/web/café.ts"])
self.assertTrue(changed_paths.Filter(["apps/web/**"]).matches_any(parsed))
quoted = ['"apps/web/caf\\303\\251.ts"']
self.assertFalse(
changed_paths.Filter(["apps/web/**"]).matches_any(quoted),
"The quoted form must not match -- that is the bug, and this "
"assertion is what proves the -z form above is doing the work.",
)
def test_a_path_containing_a_newline_survives_nul_splitting(self) -> None:
data = "apps/web/we ird\nname.ts\0apps/web/b.ts\0".encode("utf-8")
self.assertEqual(
changed_paths.read_changed_paths(data),
["apps/web/we ird\nname.ts", "apps/web/b.ts"],
)
def test_undecodable_bytes_do_not_crash_the_step(self) -> None:
parsed = changed_paths.read_changed_paths(b"apps/web/\xff\xfe.ts\0")
self.assertEqual(len(parsed), 1)
self.assertTrue(changed_paths.Filter(["apps/web/**"]).matches_any(parsed))
def test_an_empty_diff_yields_no_paths(self) -> None:
self.assertEqual(changed_paths.read_changed_paths(b""), [])
self.assertEqual(changed_paths.read_changed_paths(b"\0"), [])
class CommandLineTest(unittest.TestCase):
def run_script(self, patterns: str, changed: str) -> tuple[int, str, str]:
with tempfile.TemporaryDirectory() as tmp:
output = Path(tmp) / "github_output"
output.touch()
env = {
**os.environ,
"PATTERNS": patterns,
"GITHUB_OUTPUT": str(output),
}
result = subprocess.run(
[sys.executable, str(SCRIPT)],
input=changed,
env=env,
capture_output=True,
text=True,
)
return result.returncode, result.stdout, output.read_text(encoding="utf-8")
def test_writes_the_step_output(self) -> None:
code, stdout, step_output = self.run_script(
"apps/web/**\n!**/*.md\n", "apps/web/lib/api.ts\n"
)
self.assertEqual(code, 0)
self.assertIn("run=true", stdout)
self.assertEqual(step_output, "run=true\n")
def test_reports_false_without_a_relevant_change(self) -> None:
code, stdout, step_output = self.run_script(
"apps/web/**\n!**/*.md\n", "docs/public/install.md\napps/web/README.md\n"
)
self.assertEqual(code, 0)
self.assertIn("run=false", stdout)
self.assertEqual(step_output, "run=false\n")
def test_a_missing_pattern_variable_fails_loudly(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
env = {k: v for k, v in os.environ.items() if k != "PATTERNS"}
env["GITHUB_OUTPUT"] = str(Path(tmp) / "out")
result = subprocess.run(
[sys.executable, str(SCRIPT)],
input="apps/web/lib/api.ts\n",
env=env,
capture_output=True,
text=True,
)
self.assertNotEqual(
result.returncode,
0,
"An unset PATTERNS must fail the step. Defaulting to 'run nothing' "
"would pass a merge queue entry that was never tested.",
)
class WorkflowPatternsTest(unittest.TestCase):
"""The lists in the workflows must still mean what their filters meant."""
def test_each_gating_workflow_selects_and_rejects_the_expected_paths(self) -> None:
for workflow, (selected, rejected) in GATED_WORKFLOWS.items():
with self.subTest(workflow=workflow):
path_filter = changed_paths.Filter(patterns_block(workflow).splitlines())
for path in selected:
self.assertTrue(
path_filter.includes(path),
f"{workflow} must still run for {path}.",
)
for path in rejected:
self.assertFalse(
path_filter.includes(path),
f"{workflow} must not be selected by {path} alone.",
)
def test_every_gating_workflow_filters_on_its_own_definition(self) -> None:
"""A change to the workflow file must run the jobs it defines.
This is the trigger-level `paths:` entry each of these workflows
carried for itself. Losing it means a PR that rewrites the E2E workflow
never runs it.
"""
for workflow in GATED_WORKFLOWS:
with self.subTest(workflow=workflow):
path_filter = changed_paths.Filter(patterns_block(workflow).splitlines())
self.assertTrue(
path_filter.includes(f".github/workflows/{workflow}"),
f"{workflow} must select itself.",
)
def test_gating_workflows_carry_no_trigger_level_path_filter(self) -> None:
"""The whole point: these workflows must always report a conclusion.
A `paths:` key back on `pull_request` or `merge_group` reintroduces the
pending-forever check that blocks a merge queue.
"""
for workflow in GATED_WORKFLOWS:
with self.subTest(workflow=workflow):
text = (WORKFLOWS / workflow).read_text(encoding="utf-8")
triggers = text.partition("\nconcurrency:")[0]
self.assertNotIn(
"\n paths:",
triggers,
f"{workflow} must not filter its triggers by path. The "
"filter belongs in the `changes` job, where a skip still "
"reports a conclusion.",
)
self.assertIn(
"\n merge_group:\n",
triggers,
f"{workflow} must keep its merge_group trigger; without it "
"the merge queue waits forever for a check that never runs.",
)
def test_each_changes_job_feeds_the_script_a_nul_delimited_diff(self) -> None:
"""`-z` is load-bearing and the script cannot enforce it from inside.
Newline-delimited input still parses, by design, so dropping `-z` from
a workflow fails nothing at runtime -- it just quietly restores the
quoting bug for non-ASCII paths, where the gate passes without having
tested the change. This is the only place that can catch it.
"""
for workflow in GATED_WORKFLOWS:
with self.subTest(workflow=workflow):
text = (WORKFLOWS / workflow).read_text(encoding="utf-8")
self.assertIn(
'git diff --name-only -z "${BASE}" HEAD',
text,
f"{workflow}'s `changes` job must pass -z to git diff. "
"Without it git quotes non-ASCII paths, they match no "
"pattern, and every job is skipped on a green gate.",
)
def test_always_run_gates_keep_merge_group_and_stay_unfiltered(self) -> None:
"""Same two properties, for the gates that have no `changes` job.
These are required checks too. Adding a `paths:` filter back, or
dropping the merge_group trigger, stalls the queue on that check just
as surely as it would for the heavy workflows above -- and until now
nothing failed when it happened.
"""
for workflow in ALWAYS_RUN_WORKFLOWS:
with self.subTest(workflow=workflow):
text = (WORKFLOWS / workflow).read_text(encoding="utf-8")
triggers = text.partition("\nconcurrency:")[0]
self.assertIn(
"\n merge_group:\n",
triggers,
f"{workflow} must keep its merge_group trigger; without it "
"the merge queue waits forever for a check that never runs.",
)
self.assertNotIn(
"\n paths:",
triggers,
f"{workflow} must not filter its triggers by path. It has "
"no `changes` job to move the filter into, so a filter "
"here means the check simply stops reporting.",
)
if __name__ == "__main__":
unittest.main()
@@ -31,10 +31,6 @@ LINT_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "lint-action-pinning.yml"
STEP_MARKER = " - name: Run tests\n"
NEXT_STEP_MARKER = "\n - name: "
# The subjects above that live outside `.github/`, and so are not covered by
# this job's `.github/workflows/**` trigger.
UNTRIGGERED_SUBJECTS = ("apps/web/vitest.config.ts", "apps/.npmrc")
def run_tests_step(workflow: str) -> str:
"""Return the body of the `Run tests` step, up to the following step."""
@@ -44,26 +40,12 @@ def run_tests_step(workflow: str) -> str:
return remainder.partition(NEXT_STEP_MARKER)[0]
def trigger_paths(workflow: str, trigger: str) -> str:
"""Return the `paths:` list of `trigger`'s block under `on:`.
Scoped to the `paths:` key rather than the whole trigger block. GitHub
rejects `paths` and `paths-ignore` on the same event, so renaming the key
inverts the filter wholesale: the job would then run on everything *except*
these files, and a match against the block as a whole would not notice.
"""
def trigger_block(workflow: str, trigger: str) -> str:
"""Return `trigger`'s block under `on:`, up to the next top-level key."""
block = re.search(rf"(?m)^ {trigger}:\n(?:^ {{4}}.*\n|^\n)*", workflow)
if block is None:
raise AssertionError(f"lint-action-pinning.yml has no {trigger} trigger")
paths = re.search(r"(?m)^ paths:\n((?:^ - .*\n)+)", block.group(0))
if paths is None:
raise AssertionError(
f"lint-action-pinning.yml's {trigger} trigger has no `paths:` list. A "
"`paths-ignore:` key in its place inverts the filter, and the job stops "
"running for the very files listed under it."
)
return paths.group(1)
return block.group(0)
class FrontendTestsWorkflowContractTest(unittest.TestCase):
@@ -109,35 +91,40 @@ class FrontendTestsWorkflowContractTest(unittest.TestCase):
def test_this_contract_runs_when_its_subjects_change(self) -> None:
"""The assertions above are only worth anything if CI runs them.
`lint-action-pinning.yml` triggers on `.github/workflows/**`, which
covers `frontend-tests.yml` but neither subject in
`UNTRIGGERED_SUBJECTS`. `frontend-tests.yml` does not cover
`apps/.npmrc` either, so without an explicit trigger here a PR that
deletes `production=false` and nothing else runs no job at all and the
assertion above never executes. Mirrors the equivalent test in
`release-workflow-contract_test.py`.
Two of this file's subjects, `apps/web/vitest.config.ts` and
`apps/.npmrc`, live outside `.github/`. `lint-action-pinning.yml` used
to name them in a hand-maintained `paths:` list so that a PR deleting
`production=false` and nothing else still brought this job up; every
new subject meant remembering to extend that list, and every omission
was a silently unguarded file.
The step assertion at the end is weaker than the path ones by
The list is gone. `lint-action-pinning.yml` now runs on every push and
every pull request, which subsumes the old guarantee for every subject
at once -- and is what lets it be a merge-queue required check, since a
path-filtered workflow reports no conclusion when it is skipped and a
required check that never reports blocks the queue forever.
The step assertion at the end is weaker than the trigger ones by
construction: this file runs only from that step, so the PR that deletes
it also deletes the only thing that would fail. It holds for local runs
and for a step whose command is renamed or moved elsewhere. Keeping the
job a required check is what covers outright removal, and that lives in
branch protection, not here.
the repository ruleset, not here.
"""
lint_workflow = LINT_WORKFLOW.read_text(encoding="utf-8")
for trigger in ("push", "pull_request"):
paths = trigger_paths(lint_workflow, trigger)
for trigger in ("push", "pull_request", "merge_group"):
block = trigger_block(lint_workflow, trigger)
for subject in UNTRIGGERED_SUBJECTS:
self.assertIn(
f' - "{subject}"\n',
paths,
f"{subject} must be a {trigger} path trigger in "
"lint-action-pinning.yml. It is a subject of this contract "
"but lives outside .github/, so nothing else brings this "
"job up when it changes.",
)
self.assertNotIn(
" paths:",
block,
f"lint-action-pinning.yml's {trigger} trigger must stay "
"unfiltered. A `paths:` list there reintroduces both failure "
"modes it was removed for: subjects outside the list stop "
"being guarded, and a skipped run reports no conclusion, which "
"a merge queue waits on forever.",
)
self.assertIn(
"\n run: python3 .github/scripts/frontend-tests-workflow-contract_test.py\n",
@@ -442,32 +442,33 @@ class ReleaseWorkflowContractTest(unittest.TestCase):
self.assertEqual(primary_fingerprints[0], documented_fingerprint.group(1))
def test_release_contract_ci_runs_when_key_or_release_documentation_changes(self) -> None:
for trigger in ("push", "pull_request"):
"""This contract must run on every change, not a listed subset.
`lint-action-pinning.yml` used to name each subject of this file --
the signing key, the release docs, every `scripts/release/` helper --
in a hand-maintained `paths:` list, so that changing one brought the
job up. That list is gone: the workflow now triggers on every push and
every pull request, which covers every subject here and every one added
later without anybody remembering to extend a list.
The change was forced by the merge queue. A workflow skipped by a
`paths:` filter reports no conclusion at all, and a required check that
never reports blocks a pull request from entering the queue.
"""
for trigger in ("push", "pull_request", "merge_group"):
trigger_block = re.search(
rf" {trigger}:\n.*?(?=\n [a-z_]+:|\nconcurrency:)",
LINT_WORKFLOW,
re.DOTALL,
)
self.assertIsNotNone(trigger_block)
self.assertIn('".github/release-signing-key.asc"', trigger_block.group(0))
self.assertIn('"docs/public/release-process.md"', trigger_block.group(0))
self.assertIn('"scripts/release/package-npm-runtime.sh"', trigger_block.group(0))
self.assertIn('"scripts/release/publish-npm.sh"', trigger_block.group(0))
self.assertIn('"scripts/release/publish-npm.test.mjs"', trigger_block.group(0))
self.assertIn('"scripts/release/npm-packages.sh"', trigger_block.group(0))
self.assertIn('"scripts/release/npm-view-version.sh"', trigger_block.group(0))
self.assertIn('"scripts/release/npm-view-version.test.mjs"', trigger_block.group(0))
self.assertIn('"scripts/release/nightly-version.mjs"', trigger_block.group(0))
self.assertIn('"scripts/release/nightly-version.test.mjs"', trigger_block.group(0))
self.assertIn('"scripts/release/nightly-release.sh"', trigger_block.group(0))
self.assertIn('"scripts/release/nightly-release.test.mjs"', trigger_block.group(0))
self.assertIn('"Makefile"', trigger_block.group(0))
self.assertIn('"apps/backend/Makefile"', trigger_block.group(0))
self.assertIn('"scripts/release/package-bundle.sh"', trigger_block.group(0))
self.assertIn('"scripts/release/runtime-bundle.test.sh"', trigger_block.group(0))
self.assertIn(
'"scripts/release/validate-darwin-arm64-helper.mjs"',
self.assertNotIn(
" paths:",
trigger_block.group(0),
f"lint-action-pinning.yml's {trigger} trigger must stay "
"unfiltered. A `paths:` list there both un-guards every "
"subject it omits and makes the check unreportable in a merge "
"queue.",
)
setup_node = (
+38 -11
View File
@@ -5,11 +5,15 @@ on:
branches: [main]
pull_request:
branches: [main]
merge_group:
types: [checks_requested]
concurrency:
# Pull requests can supersede one another safely. Pushes compare each commit
# with its own before-SHA, so never cancel a push run before that comparison
# completes.
# completes. Merge-queue runs are keyed by their own merge-group SHA for the
# same reason, and are likewise never cancelled: the queue reads a cancelled
# check as a failure and ejects the pull request.
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.ref || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
@@ -31,19 +35,42 @@ jobs:
- name: Run linter tests
run: python3 scripts/lint-architecture.test.py
- name: Lint pull request architecture
if: github.event_name == 'pull_request'
# One step rather than one per event. Each event names the baseline
# commit differently, and a `merge_group` run has neither of the two the
# split steps used to read: no `event.pull_request`, no `event.before`.
# Under the old shape it would have run the linter tests and silently
# linted nothing, which is the worst possible outcome for a required
# check — green, and having compared the branch against nothing.
- name: Resolve the architecture baseline
id: baseline
env:
ARCHITECTURE_BASE_REF: ${{ github.event.pull_request.base.sha }}
run: >-
python3 scripts/lint-architecture.py --all
--baseline-base-ref "$ARCHITECTURE_BASE_REF"
--allow-missing-base-baseline
EVENT_NAME: ${{ github.event_name }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
MERGE_GROUP_BASE_SHA: ${{ github.event.merge_group.base_sha }}
PUSH_BEFORE: ${{ github.event.before }}
run: |
set -euo pipefail
- name: Lint pushed architecture
if: github.event_name == 'push'
case "${EVENT_NAME}" in
pull_request) BASE_REF="${PR_BASE_SHA}" ;;
# The queue branch is cut from this commit, so it is the merge
# group's equivalent of the pull request's base.
merge_group) BASE_REF="${MERGE_GROUP_BASE_SHA}" ;;
push) BASE_REF="${PUSH_BEFORE}" ;;
*) BASE_REF="" ;;
esac
if [[ -z "${BASE_REF}" ]]; then
echo "No baseline commit for event '${EVENT_NAME}'." >&2
exit 1
fi
echo "base-ref=${BASE_REF}" >> "$GITHUB_OUTPUT"
echo "Architecture baseline: ${BASE_REF}"
- name: Lint architecture
env:
ARCHITECTURE_BASE_REF: ${{ github.event.before }}
ARCHITECTURE_BASE_REF: ${{ steps.baseline.outputs.base-ref }}
run: >-
python3 scripts/lint-architecture.py --all
--baseline-base-ref "$ARCHITECTURE_BASE_REF"
+156 -27
View File
@@ -1,32 +1,25 @@
name: Backend Tests
# No trigger-level `paths:` filter, deliberately. A workflow skipped by one
# reports no conclusion at all, and a required check that never reports leaves
# a pull request unable to enter a merge queue and a merge group unable to
# leave it. `merge_group` cannot carry a `paths:` filter in the first place.
# The filter lives in the `changes` job below instead, and the `test` job
# reports the single conclusion the queue waits on. See docs/ci-merge-queue.md.
on:
push:
branches: [main]
paths:
- "apps/backend/**"
# check-make-shells also lints the root Makefile's $(shell ...) probes, so
# a change there has to be able to trip it.
- "Makefile"
- "scripts/check-make-shells"
- ".github/workflows/backend-tests.yml"
- "!**/*.md"
- "!**/*.mdx"
- "!**/*.markdown"
pull_request:
branches: [main]
paths:
- "apps/backend/**"
- "Makefile"
- "scripts/check-make-shells"
- ".github/workflows/backend-tests.yml"
- "!**/*.md"
- "!**/*.mdx"
- "!**/*.markdown"
merge_group:
types: [checks_requested]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
# Never cancel a merge-queue run. Its ref is unique per queue entry, so a
# cancellation here could only come from a mis-keyed group, and the queue
# would read the cancelled check as a failure and eject the pull request.
cancel-in-progress: ${{ github.event_name != 'merge_group' }}
# Least-privilege default. The Linux static/test jobs and the Windows `test-windows`
# job only read source and run tests — no PR/issue writes, no release ops.
@@ -39,8 +32,78 @@ permissions:
packages: read
jobs:
# The former trigger-level `paths:` list, applied one level down so a skip
# still produces a conclusion. `.github/scripts/changed-paths.py` implements
# the same last-match-wins semantics GitHub applies to `paths:`.
changes:
name: Detect relevant changes
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
run: ${{ steps.detect.outputs.run }}
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
# The base commit has to be present locally for the diff below. Every
# other job in this workflow already pays for a full clone.
fetch-depth: 0
persist-credentials: false
- name: Detect relevant changes
id: detect
env:
PATTERNS: |
apps/backend/**
# check-make-shells also lints the root Makefile's $(shell ...)
# probes, so a change there has to be able to trip it.
Makefile
scripts/check-make-shells
.github/workflows/backend-tests.yml
.github/scripts/changed-paths.py
!**/*.md
!**/*.mdx
!**/*.markdown
EVENT_NAME: ${{ github.event_name }}
BASE_REF: ${{ github.base_ref }}
MERGE_GROUP_BASE_SHA: ${{ github.event.merge_group.base_sha }}
PUSH_BEFORE: ${{ github.event.before }}
run: |
set -euo pipefail
case "${EVENT_NAME}" in
# The checkout is the merge ref, whose first parent is the base
# branch tip. Diffing against that tip directly collapses to a
# two-dot diff over everything main landed since the PR opened, so
# take the fork point instead.
pull_request) BASE="$(git merge-base HEAD "origin/${BASE_REF}")" ;;
# The queue branch is cut from this commit, so it is a true ancestor.
merge_group) BASE="${MERGE_GROUP_BASE_SHA}" ;;
push) BASE="${PUSH_BEFORE}" ;;
*) BASE="" ;;
esac
# Fail open. A first push to a branch, a force push, or an unknown
# event leaves no usable base, and running the suite needlessly is
# cheaper than merging something no job ever looked at.
if [[ -z "${BASE}" || "${BASE}" == "0000000000000000000000000000000000000000" ]] \
|| ! git cat-file -e "${BASE}^{commit}" 2>/dev/null; then
echo "No usable base commit for '${EVENT_NAME}'; running everything."
echo "run=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# `-z`, not plain --name-only: git quotes non-ASCII paths under its
# default core.quotePath, and a quoted path matches no pattern, so a
# change to one would skip every job and still report success.
git diff --name-only -z "${BASE}" HEAD \
| python3 .github/scripts/changed-paths.py
static_checks:
name: Backend Static Checks
needs: changes
if: needs.changes.outputs.run == 'true'
runs-on: ubuntu-latest
# Pre-baked image bundles Go, golangci-lint, go-licenses, Docker CLI, Node,
# pnpm, and the Playwright base — see .github/docker/ci-base/Dockerfile.
@@ -94,13 +157,23 @@ jobs:
- name: Resolve base SHA
shell: bash
working-directory: ${{ github.workspace }}
env:
EVENT_NAME: ${{ github.event_name }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
MERGE_GROUP_BASE_SHA: ${{ github.event.merge_group.base_sha }}
PUSH_BEFORE: ${{ github.event.before }}
run: |
set -euo pipefail
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
BASE_SHA="${{ github.event.pull_request.base.sha }}"
# A merge_group run has no pull request behind it, so neither
# `pull_request.base.sha` nor `event.before` exists. The queue branch
# is cut from `merge_group.base_sha`, which is the equivalent base.
if [[ "${EVENT_NAME}" == "pull_request" ]]; then
BASE_SHA="${PR_BASE_SHA}"
elif [[ "${EVENT_NAME}" == "merge_group" ]]; then
BASE_SHA="${MERGE_GROUP_BASE_SHA}"
else
BASE_SHA="${{ github.event.before }}"
BASE_SHA="${PUSH_BEFORE}"
fi
if [[ -z "${BASE_SHA}" || "${BASE_SHA}" == "0000000000000000000000000000000000000000" ]]; then
@@ -144,6 +217,8 @@ jobs:
test_shards:
name: Backend Tests (${{ matrix.shard }}/2)
needs: changes
if: needs.changes.outputs.run == 'true'
runs-on: ubuntu-latest
strategy:
fail-fast: false
@@ -255,6 +330,8 @@ jobs:
test_ambient_env:
name: Backend (ambient integration env)
needs: changes
if: needs.changes.outputs.run == 'true'
runs-on: ubuntu-latest
# test_shards runs in the same clean container and never sets these vars,
# so it cannot catch a package that starts reading them again — the class
@@ -298,28 +375,78 @@ jobs:
GITLAB_TOKEN: glpat-ambient-ci-not-a-real-token
run: go test -race ./internal/gitlab/... ./internal/agentctl/server/process/...
# The required check. Runs on every event, including the ones where every job
# above is correctly skipped, so the merge queue always has a conclusion to
# read instead of a check that stays pending forever.
#
# `needs` covers every other job in this workflow on purpose. A merge queue
# gates on check names an admin selects by hand, and a job missing from this
# list is a job whose failure would never block a merge — silently, and
# without appearing anywhere in the ruleset. `postgres-boot` and
# `test-windows` were outside it before this workflow became a gate.
test:
name: Run Backend Tests
runs-on: ubuntu-latest
needs: [static_checks, test_shards, test_ambient_env]
needs:
- changes
- static_checks
- test_shards
- test_ambient_env
- postgres-boot
- test-windows
if: ${{ always() }}
steps:
- name: Require backend checks to pass
env:
CHANGES_RESULT: ${{ needs.changes.result }}
STATIC_CHECKS_RESULT: ${{ needs.static_checks.result }}
TEST_SHARDS_RESULT: ${{ needs.test_shards.result }}
TEST_AMBIENT_ENV_RESULT: ${{ needs.test_ambient_env.result }}
POSTGRES_BOOT_RESULT: ${{ needs['postgres-boot'].result }}
TEST_WINDOWS_RESULT: ${{ needs['test-windows'].result }}
run: |
set -euo pipefail
if [[ "${STATIC_CHECKS_RESULT}" != "success" || "${TEST_SHARDS_RESULT}" != "success" || "${TEST_AMBIENT_ENV_RESULT}" != "success" ]]; then
echo "Static checks: ${STATIC_CHECKS_RESULT}"
echo "Test shards: ${TEST_SHARDS_RESULT}"
echo "Ambient env tests: ${TEST_AMBIENT_ENV_RESULT}"
echo "Change detection: ${CHANGES_RESULT}"
echo "Static checks: ${STATIC_CHECKS_RESULT}"
echo "Test shards: ${TEST_SHARDS_RESULT}"
echo "Ambient env tests: ${TEST_AMBIENT_ENV_RESULT}"
echo "Postgres boot: ${POSTGRES_BOOT_RESULT}"
echo "Windows tests: ${TEST_WINDOWS_RESULT}"
# Change detection itself must succeed: if it errored we do not know
# whether the skips below were deliberate.
if [[ "${CHANGES_RESULT}" != "success" ]]; then
echo "Change detection did not succeed; refusing to report a pass." >&2
exit 1
fi
# `skipped` is a pass — the change-detection job decided nothing this
# workflow covers was touched. `cancelled` is not: a cancelled job
# never reached a verdict, and treating that as a pass would let an
# untested merge group through.
#
# This gate is the one check a human opens when a merge is blocked, so
# name the job that failed here rather than making them correlate the
# list above by eye.
for entry in \
"static_checks:${STATIC_CHECKS_RESULT}" \
"test_shards:${TEST_SHARDS_RESULT}" \
"test_ambient_env:${TEST_AMBIENT_ENV_RESULT}" \
"postgres-boot:${POSTGRES_BOOT_RESULT}" \
"test-windows:${TEST_WINDOWS_RESULT}"; do
name="${entry%%:*}"
result="${entry#*:}"
if [[ "${result}" != "success" && "${result}" != "skipped" ]]; then
echo "${name} finished with result: ${result}" >&2
exit 1
fi
done
postgres-boot:
name: Backend Postgres
needs: changes
if: needs.changes.outputs.run == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
@@ -386,6 +513,8 @@ jobs:
test-windows:
name: Backend (windows)
needs: changes
if: needs.changes.outputs.run == 'true'
runs-on: windows-latest
# The race-sensitive Windows package suite can exceed 15 minutes on a
# busy hosted runner; keep the cap finite while allowing normal completion.
+159 -23
View File
@@ -1,28 +1,18 @@
name: E2E Tests
# No trigger-level `paths:` filter, deliberately. A workflow skipped by one
# reports no conclusion at all, and a required check that never reports leaves
# a pull request unable to enter a merge queue and a merge group unable to
# leave it. `merge_group` cannot carry a `paths:` filter in the first place.
# The filter lives in the `changes` job below instead, and `e2e-gate` reports
# the single conclusion the queue waits on. See docs/ci-merge-queue.md.
on:
push:
branches: [main]
paths:
- "apps/backend/**"
- "apps/desktop/**"
- "apps/web/**"
- "apps/packages/**"
- "apps/pnpm-lock.yaml"
- "apps/pnpm-workspace.yaml"
- ".github/workflows/e2e-tests.yml"
- "!**/*.md"
pull_request:
branches: [main]
paths:
- "apps/backend/**"
- "apps/desktop/**"
- "apps/web/**"
- "apps/packages/**"
- "apps/pnpm-lock.yaml"
- "apps/pnpm-workspace.yaml"
- ".github/workflows/e2e-tests.yml"
- "!**/*.md"
merge_group:
types: [checks_requested]
# Manual trigger for verifying CI on draft PRs and ad-hoc reruns. The
# default sharded run uses the same ref the user picks in the dispatch UI.
workflow_dispatch:
@@ -35,7 +25,10 @@ on:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
# Never cancel a merge-queue run. Its ref is unique per queue entry, so a
# cancellation here could only come from a mis-keyed group, and the queue
# would read the cancelled check as a failure and eject the pull request.
cancel-in-progress: ${{ github.event_name != 'merge_group' }}
permissions:
contents: read
@@ -46,8 +39,77 @@ permissions:
packages: read
jobs:
# The former trigger-level `paths:` list, applied one level down so a skip
# still produces a conclusion. `.github/scripts/changed-paths.py` implements
# the same last-match-wins semantics GitHub applies to `paths:`.
changes:
name: Detect relevant changes
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
run: ${{ steps.detect.outputs.run }}
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
# The base commit has to be present locally for the diff below.
fetch-depth: 0
persist-credentials: false
- name: Detect relevant changes
id: detect
env:
PATTERNS: |
apps/backend/**
apps/desktop/**
apps/web/**
apps/packages/**
apps/pnpm-lock.yaml
apps/pnpm-workspace.yaml
.github/workflows/e2e-tests.yml
.github/scripts/changed-paths.py
!**/*.md
EVENT_NAME: ${{ github.event_name }}
BASE_REF: ${{ github.base_ref }}
MERGE_GROUP_BASE_SHA: ${{ github.event.merge_group.base_sha }}
PUSH_BEFORE: ${{ github.event.before }}
run: |
set -euo pipefail
case "${EVENT_NAME}" in
# The checkout is the merge ref, whose first parent is the base
# branch tip. Diffing against that tip directly collapses to a
# two-dot diff over everything main landed since the PR opened, so
# take the fork point instead.
pull_request) BASE="$(git merge-base HEAD "origin/${BASE_REF}")" ;;
# The queue branch is cut from this commit, so it is a true ancestor.
merge_group) BASE="${MERGE_GROUP_BASE_SHA}" ;;
push) BASE="${PUSH_BEFORE}" ;;
# workflow_dispatch is an explicit request to run the suite.
*) BASE="" ;;
esac
# Fail open. A first push to a branch, a force push, or a manual
# dispatch leaves no usable base, and running the suite needlessly is
# cheaper than merging something no job ever looked at.
if [[ -z "${BASE}" || "${BASE}" == "0000000000000000000000000000000000000000" ]] \
|| ! git cat-file -e "${BASE}^{commit}" 2>/dev/null; then
echo "No usable base commit for '${EVENT_NAME}'; running everything."
echo "run=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# `-z`, not plain --name-only: git quotes non-ASCII paths under its
# default core.quotePath, and a quoted path matches no pattern, so a
# change to one would skip every job and still report success.
git diff --name-only -z "${BASE}" HEAD \
| python3 .github/scripts/changed-paths.py
build:
name: Build
needs: changes
if: needs.changes.outputs.run == 'true'
runs-on: ubuntu-latest
# Pre-baked image bundles Go, Node 24, pnpm 9.15.9 — see
# .github/docker/ci-base/Dockerfile. Uses the `build` tag (with Go)
@@ -234,7 +296,8 @@ jobs:
e2e:
name: E2E Shard ${{ matrix.shard }}/14
needs: build
needs: [changes, build]
if: needs.changes.outputs.run == 'true'
runs-on: ubuntu-latest
# Pre-baked image bundles Node 24, pnpm 9.15.9, and the Playwright browser
# binaries (Chromium etc.) — see .github/docker/ci-base/Dockerfile. This is
@@ -361,7 +424,8 @@ jobs:
# Sharded 6 ways — each shard is its own runner. See apps/web/e2e/README.md.
e2e-containers:
name: E2E Containers Shard ${{ matrix.shard }}/6
needs: build
needs: [changes, build]
if: needs.changes.outputs.run == 'true'
# This job intentionally runs on the host ubuntu runner (NOT the kandev-ci
# container image): the containers project needs direct access to the
# host Docker daemon, and a docker-in-docker setup would break the tests'
@@ -410,6 +474,10 @@ jobs:
# Their read-only GITHUB_TOKEN can still be rejected by GHCR during an
# explicit login because it belongs to the base repository, not the
# contributor's fork. Keep authenticated pulls for trusted runs.
#
# A merge_group run has no `github.event.pull_request` at all, so the
# first operand decides it. That is the right answer: the queue branch
# lives in this repository and its token is a base-repository token.
- name: Log in to GHCR
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
@@ -531,6 +599,8 @@ jobs:
desktop-e2e:
name: Desktop E2E Smoke
needs: changes
if: needs.changes.outputs.run == 'true'
runs-on: ubuntu-latest
# Includes Ubuntu WebKit/Tauri system package installation; GitHub apt mirrors
# can spend more than 20 minutes there before tests start.
@@ -589,8 +659,12 @@ jobs:
e2e-report:
name: Merge E2E Reports
needs: [e2e, e2e-containers, desktop-e2e]
if: always()
needs: [changes, e2e, e2e-containers, desktop-e2e]
# `always()` so a failed shard still gets its report merged, but not when
# change detection skipped the shards outright: there would be no blob
# reports to download, and the artifact step would fail on an empty run.
# `e2e-gate` below, not this job, is what the merge queue waits on.
if: always() && needs.changes.outputs.run == 'true'
runs-on: ubuntu-latest
# Pre-baked image bundles Node 24, pnpm 9.15.9, and the Playwright CLI.
# `runtime` tag — no Go needed for merging blob reports.
@@ -949,3 +1023,65 @@ jobs:
echo "desktop-e2e finished with result: $DESKTOP_E2E_RESULT" >&2
exit 1
fi
# The required check. Runs on every event, including the ones where every job
# above is correctly skipped, so the merge queue always has a conclusion to
# read instead of a check that stays pending forever.
#
# A single gate is what makes this workflow usable from a ruleset at all: the
# alternative is naming all 14 E2E shards, all 6 container shards, and the
# desktop smoke individually, and renumbering the ruleset every time a shard
# count changes.
e2e-gate:
name: E2E Tests Passed
runs-on: ubuntu-latest
needs: [changes, build, e2e, e2e-containers, desktop-e2e, e2e-report]
if: always()
steps:
- name: Require the E2E jobs to have passed or been skipped
env:
CHANGES_RESULT: ${{ needs.changes.result }}
BUILD_RESULT: ${{ needs.build.result }}
E2E_RESULT: ${{ needs.e2e.result }}
E2E_CONTAINERS_RESULT: ${{ needs['e2e-containers'].result }}
DESKTOP_E2E_RESULT: ${{ needs['desktop-e2e'].result }}
E2E_REPORT_RESULT: ${{ needs['e2e-report'].result }}
run: |
set -euo pipefail
echo "Change detection: ${CHANGES_RESULT}"
echo "Build: ${BUILD_RESULT}"
echo "E2E shards: ${E2E_RESULT}"
echo "E2E container shards: ${E2E_CONTAINERS_RESULT}"
echo "Desktop smoke: ${DESKTOP_E2E_RESULT}"
echo "Report merge: ${E2E_REPORT_RESULT}"
# Change detection itself must succeed: if it errored we do not know
# whether the skips below were deliberate.
if [[ "${CHANGES_RESULT}" != "success" ]]; then
echo "Change detection did not succeed; refusing to report a pass." >&2
exit 1
fi
# `skipped` is a pass — the change-detection job decided nothing this
# workflow covers was touched. `cancelled` is not: a cancelled job
# never reached a verdict, and treating that as a pass would let an
# untested merge group through.
#
# This gate is the one check a human opens when a merge is blocked, so
# name the job that failed here rather than making them correlate the
# list above against twenty shard names.
for entry in \
"build:${BUILD_RESULT}" \
"e2e:${E2E_RESULT}" \
"e2e-containers:${E2E_CONTAINERS_RESULT}" \
"desktop-e2e:${DESKTOP_E2E_RESULT}" \
"e2e-report:${E2E_REPORT_RESULT}"; do
name="${entry%%:*}"
result="${entry#*:}"
if [[ "${result}" != "success" && "${result}" != "skipped" ]]; then
echo "${name} finished with result: ${result}" >&2
exit 1
fi
done
+159 -38
View File
@@ -1,42 +1,25 @@
name: Frontend Tests
# No trigger-level `paths:` filter, deliberately. A workflow skipped by one
# reports no conclusion at all, and a required check that never reports leaves
# a pull request unable to enter a merge queue and a merge group unable to
# leave it. `merge_group` cannot carry a `paths:` filter in the first place.
# The filter lives in the `changes` job below instead, and `frontend-gate`
# reports the single conclusion the queue waits on. See docs/ci-merge-queue.md.
on:
push:
branches: [main]
paths:
- "apps/cli/**"
- "apps/package.json"
- "apps/prettier.config.mjs"
- "apps/.prettierignore"
- "apps/web/**"
- "apps/packages/**"
- "apps/pnpm-lock.yaml"
- "apps/backend/go.mod"
- "apps/backend/go.sum"
- ".github/workflows/frontend-tests.yml"
- "!**/*.md"
- "!**/*.mdx"
- "!**/*.markdown"
pull_request:
branches: [main]
paths:
- "apps/cli/**"
- "apps/package.json"
- "apps/prettier.config.mjs"
- "apps/.prettierignore"
- "apps/web/**"
- "apps/packages/**"
- "apps/pnpm-lock.yaml"
- "apps/backend/go.mod"
- "apps/backend/go.sum"
- ".github/workflows/frontend-tests.yml"
- "!**/*.md"
- "!**/*.mdx"
- "!**/*.markdown"
merge_group:
types: [checks_requested]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
# Never cancel a merge-queue run. Its ref is unique per queue entry, so a
# cancellation here could only come from a mis-keyed group, and the queue
# would read the cancelled check as a failure and eject the pull request.
cancel-in-progress: ${{ github.event_name != 'merge_group' }}
# Least-privilege: the `frontend` job only reads source and runs tests.
# `packages: read` lets the container job pull the kandev-ci image from GHCR.
@@ -45,8 +28,82 @@ permissions:
packages: read
jobs:
# The former trigger-level `paths:` list, applied one level down so a skip
# still produces a conclusion. `.github/scripts/changed-paths.py` implements
# the same last-match-wins semantics GitHub applies to `paths:`.
changes:
name: Detect relevant changes
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
run: ${{ steps.detect.outputs.run }}
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
# The base commit has to be present locally for the diff below. Every
# other job in this workflow already pays for a full clone.
fetch-depth: 0
persist-credentials: false
- name: Detect relevant changes
id: detect
env:
PATTERNS: |
apps/cli/**
apps/package.json
apps/prettier.config.mjs
apps/.prettierignore
apps/web/**
apps/packages/**
apps/pnpm-lock.yaml
apps/backend/go.mod
apps/backend/go.sum
.github/workflows/frontend-tests.yml
.github/scripts/changed-paths.py
!**/*.md
!**/*.mdx
!**/*.markdown
EVENT_NAME: ${{ github.event_name }}
BASE_REF: ${{ github.base_ref }}
MERGE_GROUP_BASE_SHA: ${{ github.event.merge_group.base_sha }}
PUSH_BEFORE: ${{ github.event.before }}
run: |
set -euo pipefail
case "${EVENT_NAME}" in
# The checkout is the merge ref, whose first parent is the base
# branch tip. Diffing against that tip directly collapses to a
# two-dot diff over everything main landed since the PR opened, so
# take the fork point -- the same correction the steps below make.
pull_request) BASE="$(git merge-base HEAD "origin/${BASE_REF}")" ;;
# The queue branch is cut from this commit, so it is a true ancestor.
merge_group) BASE="${MERGE_GROUP_BASE_SHA}" ;;
push) BASE="${PUSH_BEFORE}" ;;
*) BASE="" ;;
esac
# Fail open. A first push to a branch, a force push, or an unknown
# event leaves no usable base, and running the suite needlessly is
# cheaper than merging something no job ever looked at.
if [[ -z "${BASE}" || "${BASE}" == "0000000000000000000000000000000000000000" ]] \
|| ! git cat-file -e "${BASE}^{commit}" 2>/dev/null; then
echo "No usable base commit for '${EVENT_NAME}'; running everything."
echo "run=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# `-z`, not plain --name-only: git quotes non-ASCII paths under its
# default core.quotePath, and a quoted path matches no pattern, so a
# change to one would skip every job and still report success.
git diff --name-only -z "${BASE}" HEAD \
| python3 .github/scripts/changed-paths.py
frontend:
name: Run Frontend Lint, Tests, and Build
needs: changes
if: needs.changes.outputs.run == 'true'
runs-on: ubuntu-latest
# Pre-baked image bundles Node 24, pnpm 9.15.9, Go, and go-licenses —
# see .github/docker/ci-base/Dockerfile.
@@ -101,6 +158,11 @@ jobs:
- name: Check formatting
shell: bash
env:
EVENT_NAME: ${{ github.event_name }}
BASE_REF: ${{ github.base_ref }}
MERGE_GROUP_BASE_SHA: ${{ github.event.merge_group.base_sha }}
PUSH_BEFORE: ${{ github.event.before }}
run: |
set -euo pipefail
@@ -112,10 +174,16 @@ jobs:
# extra files are already formatted), but it is the same trap the i18n
# ratchet below hit for real, so do not copy the old idiom. The fork
# point is what "changed in this PR" actually means.
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
BASE_SHA="$(git merge-base HEAD "origin/${{ github.base_ref }}")"
#
# `merge_group` has no PR context at all. Its own base_sha is the
# commit the queue branch was cut from, which is already the fork
# point, so no correction is needed there.
if [[ "${EVENT_NAME}" == "pull_request" ]]; then
BASE_SHA="$(git merge-base HEAD "origin/${BASE_REF}")"
elif [[ "${EVENT_NAME}" == "merge_group" ]]; then
BASE_SHA="${MERGE_GROUP_BASE_SHA}"
else
BASE_SHA="${{ github.event.before }}"
BASE_SHA="${PUSH_BEFORE}"
fi
if [[ -z "${BASE_SHA}" || "${BASE_SHA}" == "0000000000000000000000000000000000000000" ]]; then
@@ -183,6 +251,10 @@ jobs:
shell: bash
# The job defaults to `apps`; these scripts live one level deeper.
working-directory: apps/web
env:
EVENT_NAME: ${{ github.event_name }}
MERGE_GROUP_BASE_SHA: ${{ github.event.merge_group.base_sha }}
PUSH_BEFORE: ${{ github.event.before }}
run: |
set -euo pipefail
@@ -194,14 +266,23 @@ jobs:
# (#2160). The scripts' own default, `merge-base HEAD origin/main`,
# resolves to the merge ref's base-side parent and is correct here;
# origin/main is present because the checkout is fetch-depth: 0.
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
if [[ "${EVENT_NAME}" == "pull_request" ]]; then
node scripts/run-i18n-ratchet.mjs
exit 0
fi
# push: HEAD *is* the new base branch tip, so there is no fork point to
# find and the previous tip is the only meaningful base.
BASE_SHA="${{ github.event.before }}"
# merge_group: the queue branch is cut from base_sha and carries only
# the queued pull requests on top, so that IS the fork point. The
# scripts' `origin/main` default is wrong here — main may already have
# moved past what this merge group was built on.
if [[ "${EVENT_NAME}" == "merge_group" ]]; then
BASE_SHA="${MERGE_GROUP_BASE_SHA}"
else
# push: HEAD *is* the new base branch tip, so there is no fork point to
# find and the previous tip is the only meaningful base.
BASE_SHA="${PUSH_BEFORE}"
fi
if [[ -z "${BASE_SHA}" || "${BASE_SHA}" == "0000000000000000000000000000000000000000" ]]; then
echo "No usable base SHA; skipping the new-code ratchet."
exit 0
@@ -223,15 +304,24 @@ jobs:
- name: Run E2E sleep ratchet
shell: bash
working-directory: apps/web
env:
EVENT_NAME: ${{ github.event_name }}
MERGE_GROUP_BASE_SHA: ${{ github.event.merge_group.base_sha }}
PUSH_BEFORE: ${{ github.event.before }}
run: |
set -euo pipefail
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
if [[ "${EVENT_NAME}" == "pull_request" ]]; then
node scripts/check-new-e2e-sleeps.mjs
exit 0
fi
BASE_SHA="${{ github.event.before }}"
if [[ "${EVENT_NAME}" == "merge_group" ]]; then
BASE_SHA="${MERGE_GROUP_BASE_SHA}"
else
BASE_SHA="${PUSH_BEFORE}"
fi
if [[ -z "${BASE_SHA}" || "${BASE_SHA}" == "0000000000000000000000000000000000000000" ]]; then
echo "No usable base SHA; skipping the E2E sleep ratchet."
exit 0
@@ -256,3 +346,34 @@ jobs:
- name: Build
run: pnpm --filter @kandev/web build
# The required check. Runs on every event, including the ones where the
# `frontend` job above is correctly skipped, so the merge queue always has a
# conclusion to read instead of a check that stays pending forever.
frontend-gate:
name: Frontend Tests Passed
runs-on: ubuntu-latest
needs: [changes, frontend]
if: always()
steps:
- name: Require the frontend job to have passed or been skipped
env:
CHANGES_RESULT: ${{ needs.changes.result }}
FRONTEND_RESULT: ${{ needs.frontend.result }}
run: |
set -euo pipefail
# `skipped` is a pass: the change-detection job decided nothing this
# workflow covers was touched. `cancelled` is not — a cancelled job
# never reached a verdict, and treating that as a pass would let an
# untested merge group through.
if [[ "${CHANGES_RESULT}" != "success" ]]; then
echo "Change detection finished with result: ${CHANGES_RESULT}" >&2
exit 1
fi
if [[ "${FRONTEND_RESULT}" != "success" && "${FRONTEND_RESULT}" != "skipped" ]]; then
echo "Frontend job finished with result: ${FRONTEND_RESULT}" >&2
exit 1
fi
echo "Change detection: ${CHANGES_RESULT}; frontend: ${FRONTEND_RESULT}"
+20 -61
View File
@@ -1,74 +1,30 @@
name: Lint Action Pinning
# No trigger-level `paths:` filter, deliberately. A workflow skipped by one
# reports no conclusion at all, and a required check that never reports leaves
# a pull request unable to enter a merge queue and a merge group unable to
# leave it. `merge_group` cannot carry a `paths:` filter in the first place.
#
# The heavier gates push their filtering down into a `changes` job (see
# `.github/scripts/changed-paths.py`). This one is deliberately unfiltered
# instead: the list it replaced had grown to thirty-odd entries that each had
# to be added by hand whenever a contract test gained a new subject, and every
# omission was a silently unguarded file. Always running is both cheaper to
# reason about and strictly safer. See docs/ci-merge-queue.md.
on:
push:
branches: [main]
paths:
- ".github/workflows/**"
- ".github/scripts/lint-action-pinning.py"
- ".github/scripts/lint-action-pinning_test.py"
- ".github/scripts/claude-code-review-workflow-contract_test.py"
- ".github/scripts/frontend-tests-workflow-contract_test.py"
- "apps/web/vitest.config.ts"
- "apps/.npmrc"
- ".github/scripts/preview-env-workflow-contract_test.py"
- ".github/scripts/collect-macos-desktop-diagnostics.sh"
- ".github/scripts/release-workflow-contract_test.py"
- ".github/release-signing-key.asc"
- "docs/public/release-process.md"
- "Makefile"
- "apps/backend/Makefile"
- "scripts/release/package-bundle.sh"
- "scripts/release/runtime-bundle.test.sh"
- "scripts/release/validate-darwin-arm64-helper.mjs"
- "scripts/release/npm-packages.sh"
- "scripts/release/nightly-version.mjs"
- "scripts/release/nightly-version.test.mjs"
- "scripts/release/nightly-release.sh"
- "scripts/release/nightly-release.test.mjs"
- "scripts/release/npm-view-version.sh"
- "scripts/release/npm-view-version.test.mjs"
- "scripts/release/package-npm-runtime.sh"
- "scripts/release/publish-npm.sh"
- "scripts/release/publish-npm.test.mjs"
- "scripts/release/retry-ghcr-command.sh"
- "scripts/release/retry-ghcr-command.test.sh"
pull_request:
branches: [main]
paths:
- ".github/workflows/**"
- ".github/scripts/lint-action-pinning.py"
- ".github/scripts/lint-action-pinning_test.py"
- ".github/scripts/claude-code-review-workflow-contract_test.py"
- ".github/scripts/frontend-tests-workflow-contract_test.py"
- "apps/web/vitest.config.ts"
- "apps/.npmrc"
- ".github/scripts/preview-env-workflow-contract_test.py"
- ".github/scripts/collect-macos-desktop-diagnostics.sh"
- ".github/scripts/release-workflow-contract_test.py"
- ".github/release-signing-key.asc"
- "docs/public/release-process.md"
- "Makefile"
- "apps/backend/Makefile"
- "scripts/release/package-bundle.sh"
- "scripts/release/runtime-bundle.test.sh"
- "scripts/release/validate-darwin-arm64-helper.mjs"
- "scripts/release/npm-packages.sh"
- "scripts/release/nightly-version.mjs"
- "scripts/release/nightly-version.test.mjs"
- "scripts/release/nightly-release.sh"
- "scripts/release/nightly-release.test.mjs"
- "scripts/release/npm-view-version.sh"
- "scripts/release/npm-view-version.test.mjs"
- "scripts/release/package-npm-runtime.sh"
- "scripts/release/publish-npm.sh"
- "scripts/release/publish-npm.test.mjs"
- "scripts/release/retry-ghcr-command.sh"
- "scripts/release/retry-ghcr-command.test.sh"
merge_group:
types: [checks_requested]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
# Never cancel a merge-queue run. Its ref is unique per queue entry, so a
# cancellation here could only come from a mis-keyed group, and the queue
# would read the cancelled check as a failure and eject the pull request.
cancel-in-progress: ${{ github.event_name != 'merge_group' }}
# Least-privilege: only reads source.
permissions:
@@ -88,6 +44,9 @@ jobs:
- name: Test action-pinning lint
run: python3 .github/scripts/lint-action-pinning_test.py
- name: Test in-job path filter
run: python3 .github/scripts/changed-paths_test.py
- name: Test Claude Code review workflow contract
run: python3 .github/scripts/claude-code-review-workflow-contract_test.py
+17 -57
View File
@@ -1,70 +1,30 @@
name: Lint Harness Files
# No trigger-level `paths:` filter, deliberately. A workflow skipped by one
# reports no conclusion at all, and a required check that never reports leaves
# a pull request unable to enter a merge queue and a merge group unable to
# leave it. `merge_group` cannot carry a `paths:` filter in the first place.
#
# The heavier gates push their filtering down into a `changes` job (see
# `.github/scripts/changed-paths.py`). This job is a checkout plus four short
# linters, so the whole list is replaced by simply always running: cheaper than
# the job that would decide whether to run it, and it closes the gap where a
# harness file outside the old list changed and nothing checked it.
# See docs/ci-merge-queue.md.
on:
push:
branches: [main]
paths:
- ".github/workflows/lint-harness-files.yml"
- ".github/scripts/lint-harness-files.py"
- "scripts/lint-harness-files.test.py"
- ".pre-commit-config.yaml"
- "AGENTS.md"
- "CLAUDE.md"
- "**/AGENTS.md"
- "**/CLAUDE.md"
- "**/.cursorrules"
- ".agents/skills/**/*.md"
- ".augment/skills/**/*.md"
- ".claude/skills/**/*.md"
- ".cursor/skills/**/*.md"
- ".opencode/skills/**/*.md"
- "apps/backend/internal/office/configloader/skills/**/*.md"
- ".agents/agents/**/*.md"
- ".opencode/agents/**/*.md"
- ".claude/agents/**/*.md"
- ".codex/agents/**/*.toml"
- ".codex/config.toml"
- ".claude/settings.json"
- ".claude/rules/**/*.md"
- ".cursor/rules/**/*.mdc"
- ".claude/commands/**/*.md"
- "apps/web/lib/plugins/types.ts"
- "scripts/validate-plugin-api-claims.mjs"
- "scripts/validate-plugin-api-claims.test.mjs"
pull_request:
branches: [main]
paths:
- ".github/workflows/lint-harness-files.yml"
- ".github/scripts/lint-harness-files.py"
- "scripts/lint-harness-files.test.py"
- ".pre-commit-config.yaml"
- "AGENTS.md"
- "CLAUDE.md"
- "**/AGENTS.md"
- "**/CLAUDE.md"
- "**/.cursorrules"
- ".agents/skills/**/*.md"
- ".augment/skills/**/*.md"
- ".claude/skills/**/*.md"
- ".cursor/skills/**/*.md"
- ".opencode/skills/**/*.md"
- "apps/backend/internal/office/configloader/skills/**/*.md"
- ".agents/agents/**/*.md"
- ".opencode/agents/**/*.md"
- ".claude/agents/**/*.md"
- ".codex/agents/**/*.toml"
- ".codex/config.toml"
- ".claude/settings.json"
- ".claude/rules/**/*.md"
- ".cursor/rules/**/*.mdc"
- ".claude/commands/**/*.md"
- "apps/web/lib/plugins/types.ts"
- "scripts/validate-plugin-api-claims.mjs"
- "scripts/validate-plugin-api-claims.test.mjs"
merge_group:
types: [checks_requested]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
# Never cancel a merge-queue run. Its ref is unique per queue entry, so a
# cancellation here could only come from a mis-keyed group, and the queue
# would read the cancelled check as a failure and eject the pull request.
cancel-in-progress: ${{ github.event_name != 'merge_group' }}
permissions:
contents: read
@@ -58,9 +58,16 @@ describe("sleep ratchet wiring", () => {
// would move the slice boundary and quietly shrink what is asserted
// (review finding). This is strictly tighter than the slice was — it pins
// the invocation, its bare form, and the early exit, in order.
//
// How the event name is read is deliberately NOT pinned. It was
// `"${{ github.event_name }}"` inline until the merge-queue work moved it
// to an `EVENT_NAME` env var, which changed nothing about the guarantee
// here and broke this assertion anyway. What matters is that the
// pull_request branch runs the ratchet bare and returns: the trailing
// newline after the script name is what rejects a smuggled `--base`.
expect(step).toMatch(
new RegExp(
`if \\[\\[ "\\$\\{\\{ github.event_name \\}\\}" == "pull_request" \\]\\];? then\\n` +
`== "pull_request" \\]\\];? then\\n` +
`\\s+node scripts/${RATCHET_SCRIPT}\\n` +
`\\s+exit 0\\n`,
),
+204
View File
@@ -0,0 +1,204 @@
# Enabling the merge queue
Everything in this document except the final section is already in the
repository. Nothing here is switched on: the workflows are prepared, and the
ruleset change that turns a merge queue on is a separate, deliberate decision.
## Why
`main`'s ruleset (id `13341245`) has `deletion`, `non_fast_forward`,
`required_linear_history`, and `pull_request` rules, and no
`required_status_checks` rule at all. Merges are not gated on CI in any way.
That is how #2681 (`38c94e7d0`) landed red. Its E2E checks were fully green, but
they had run 23 hours earlier against a merge ref that predated the retry loop
added by #2569 (`15788438b`). Neither change is wrong on its own; together they
put a roughly 4.5-second floor under content search, just over the 5-second
default assertion in the test #2681 added. Both pull requests were green, and
their merge was not.
Required status checks alone would not have caught it -- #2681's checks were
green. A merge queue would: it builds the actual merge result and runs the
checks against that, at merge time, in queue order.
## What changed in the workflows
### The `merge_group` trigger
Six workflows gained `merge_group: types: [checks_requested]`:
| Workflow | Gate job | Check name to require |
| ------------------------ | -------------- | --------------------------------------- |
| `e2e-tests.yml` | `e2e-gate` | `E2E Tests Passed` |
| `backend-tests.yml` | `test` | `Run Backend Tests` |
| `frontend-tests.yml` | `frontend-gate`| `Frontend Tests Passed` |
| `architecture-lint.yml` | `lint` | `Architecture boundaries do not regress`|
| `lint-action-pinning.yml`| `lint` | `All action refs are SHA-pinned` |
| `lint-harness-files.yml` | `lint` | `Harness files pass lint` |
Deliberately excluded, because they cannot report in a queue and would stall it
forever, or because their redness is not a property of the merge:
- `pr-title.yml`, `claude-code-review.yml`, `opencode-code-review.yml`,
`preview-env.yml`, `notify-docs.yml` -- all `pull_request` or
`pull_request_target` scoped. There is no pull request in a merge group, so
they never run and never report.
- `cargo-audit.yml` -- also runs on a weekly `schedule`, by design: a newly
published RUSTSEC advisory turns it red with no change to the code. In a queue
that would block every merge in the repository until somebody bumped a crate.
It stays a pull-request and scheduled check.
- `ci-base-image.yml`, `universal-rebuild.yml` -- these publish images rather
than test a merge.
- `release.yml` and the plugin registry workflows -- out of scope.
### The path-filter problem, and the approach chosen
Most of these workflows were heavily path-filtered (`apps/web/**`, `!**/*.md`,
and so on). That is a problem for a queue in two independent ways.
1. A workflow skipped by a trigger-level `paths:` filter reports **no
conclusion at all**. GitHub's documentation is explicit that "checks
associated with that workflow will remain in a 'Pending' state". A required
check that stays pending blocks a pull request from being added to the queue,
and blocks a merge group from ever merging.
2. `merge_group` does not support `paths:` in the first place. It takes `types:`
and `branches:` only. So the filters could not simply be mirrored onto the
queue side even if we wanted them there.
Two approaches were available: mirror each filter onto an inverse trigger with a
"skipped reports success" shim job, or make the gating jobs always run and
short-circuit internally.
**The shim was rejected, and the internal short-circuit implemented.** The
deciding factor is that `paths` with `!` exclusions has no exact inverse. Take
frontend-tests' old filter, `apps/web/**` followed by `!**/*.md`. A pull request
that touches only `apps/web/README.md` is skipped by that filter, and is *also*
skipped by the mirrored `paths-ignore` list, because every changed file appears
in it. Both halves skip, no conclusion is ever reported, and the queue waits
forever -- the exact failure the shim exists to prevent, reintroduced by the
shim. Making the inversion correct would mean hand-deriving a second pattern
list per workflow and keeping the two in sync by eye. Worse, the shim needs a
job whose *name* matches the real check, which for `e2e-tests.yml` would mean
reproducing fourteen shard names and six container shard names.
So: the trigger-level `paths:` filters are gone, and each expensive workflow
grew a cheap `changes` job that applies the same list one level down.
```
changes ──> static_checks ─┐
└─> test_shards ───┼──> test <- the required check, if: always()
└─> ... ──┘
```
`.github/scripts/changed-paths.py` implements GitHub's filter-pattern semantics
(`*`, `**`, `?`, leading `!`, last match wins) and is unit-tested in
`changed-paths_test.py`, which also asserts the pattern lists still live in the
workflows and still select what they used to. It rejects `+`, `[...]`, and
`{...}` rather than quietly matching them as literals.
Two properties of this shape matter:
- **The gate job always runs.** It is `if: always()` and treats a `skipped`
dependency as a pass and a `cancelled` one as a failure. This is on purpose:
it means the required check never depends on how GitHub reports a
conditionally-skipped job, only on a conclusion the workflow computes itself.
- **The gate covers every job in its workflow.** A queue gates on check names an
admin picks by hand, so a job outside the gate's `needs` is a job whose
failure would never block a merge, silently. `backend-tests.yml`'s
`postgres-boot` and `test-windows` were outside its aggregate before this
change and are now inside it.
Two workflows -- `lint-action-pinning.yml` and `lint-harness-files.yml` -- have
no `changes` job. They are a checkout plus a handful of short linters, so they
simply always run: cheaper than the job that would decide whether to run them,
and it closes the gap where a subject outside their hand-maintained lists
changed and nothing checked it. Their contract tests were updated to assert the
absence of a filter rather than the presence of specific entries.
### PR context in a merge group
A `merge_group` run has no `github.event.pull_request` and no
`github.event.before`. Every expression that read one has been made explicit
about the event it is on, and given the merge-group equivalent,
`github.event.merge_group.base_sha` -- the commit the queue branch was cut from,
which is the merge group's base:
- `architecture-lint.yml` -- the two mutually exclusive `pull_request` / `push`
lint steps were collapsed into one, behind a baseline-resolving step. Under
the old shape a merge-group run would have executed the linter's self-tests
and then linted nothing at all, reporting green having compared the branch
against no baseline.
- `backend-tests.yml` -- `Resolve base SHA`.
- `frontend-tests.yml` -- `Check formatting`, the i18n new-code ratchet, and the
E2E sleep ratchet.
- `e2e-tests.yml` -- the GHCR login guard reads
`github.event_name != 'pull_request' || <fork check>`, which resolves to the
first operand on a merge group. That is correct: the queue branch lives in
this repository and its token is a base-repository token.
Concurrency groups were checked too. None of the six keyed on a PR number
(`preview-env.yml` does, and it is not a gate). All six now set
`cancel-in-progress: ${{ github.event_name != 'merge_group' }}`, because a queue
reads a cancelled check as a failure and ejects the pull request.
## Enabling it
1. Add a `merge_queue` rule to the `main` ruleset.
2. Add a `required_status_checks` rule and select exactly the six check names in
the table above. Do **not** select any per-shard name (`E2E Shard 3/14`,
`Backend Tests (1/2)`, ...) -- they are covered by the gates, and a queue
configured against them breaks the next time a shard count changes.
3. Do **not** select `lint` (from `pr-title.yml`), `Validate public docs`, or
any `claude-review-*` / `opencode-review-*` / `deploy-*` check. Those never
run in a merge group and the queue will wait on them indefinitely.
### Caveat: the ruleset can still be bypassed
The `main` ruleset lists one bypass actor:
```json
{ "actor_type": "OrganizationAdmin", "bypass_mode": "always" }
```
`bypass_mode: always` means an organization admin can merge past whatever is
configured here, queue included, without a review request or a temporary
override. Configuring required checks constrains everybody else; it does not
constrain an org admin. If the point of enabling the queue is that nothing red
reaches `main`, that entry needs to change as well -- to `pull_request` mode, or
removed -- and that is a separate decision about who is trusted to break glass.
## What is verified, and what is not
Verified locally on this branch:
- `actionlint` v1.7.12 is clean on every file touched here. Its one repository
finding is pre-existing and in `release.yml` (`queue: max` under
`concurrency`, a key it does not know).
- `zizmor` v1.29.0, run over the six workflows before and after: one finding
removed (a `template-injection` error in `frontend-tests.yml`, from moving
`github.base_ref` out of an inline expression into `env:`), none added.
- Every `.github/scripts/*_test.py` contract test passes, as does
`lint-action-pinning.py` over all 18 workflows.
- `changed-paths_test.py` passes, and fails when the guarantees are broken:
restoring a `paths:` filter to a gating trigger, deleting a `merge_group`
trigger, and reordering `!**/*.md` above the includes were each confirmed to
fail it.
Not verified, and not verifiable until a queue is switched on:
- **No `merge_group` event has ever fired against these workflows.** Everything
about the merge-group path -- that `github.event.merge_group.base_sha` is
populated as expected, that the `changes` job resolves a usable base from it,
that the gate jobs report under the names in the table -- is read from
GitHub's documented payload, not observed.
- Whether GitHub blocks a pull request from *entering* the queue on a check that
reports nothing. The design assumes it does, which is why the filters moved;
if it turns out not to, the change is still correct, just more cautious than
it needed to be.
- The cost. Every pull request now starts six workflows instead of however many
its paths selected, and each merge group runs the full E2E suite. The
`changes` jobs keep the expensive work filtered, but the floor per pull
request is higher than it was.
The cheapest way to close the first gap is to enable the queue on a low-traffic
day and watch the first merge group's check names against the table above.