diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2137eff6d..a7a9cafa6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -111,7 +111,23 @@ jobs: test-sqlite: needs: [changes, lint] - if: github.event_name != 'schedule' && (github.event_name != 'pull_request' || needs.changes.outputs.code == 'true' || needs.changes.outputs.deps == 'true' || needs.changes.outputs.workflows == 'true') + # `docs` is in this condition and NOT in the other test jobs' on purpose. + # Part of the suite this job runs takes documentation as its INPUT and + # asserts on its content — tests_py/scripts/test_codex_plugin_contract.py + # reads README.md and checks the canonical published identities (the + # hypermnesia-mcp-viz anchor, the viz/spec migration strings). Excluding + # '*.md' from the `code` filter therefore switched those guards OFF exactly + # when their subject changed: PR #509 was a README-only diff, every test job + # was skipped, it merged green, and the push to main went red on that test + # across five jobs (run 34238410970, 2026-09-08 — 7663 passed, 1 failed; + # fixed by #510). + # + # This job, not the 3.10-3.13 matrix, because it already runs THE FULL SUITE + # (see the "Scope" note on its pytest step) in one ~5-minute job, so it + # covers the doc-contract tests at the smallest cost that still closes the + # gap. Waking the whole matrix on every prose edit would undo the CI work in + # #475-#481. + if: github.event_name != 'schedule' && (github.event_name != 'pull_request' || needs.changes.outputs.code == 'true' || needs.changes.outputs.deps == 'true' || needs.changes.outputs.workflows == 'true' || needs.changes.outputs.docs == 'true') name: Test (SQLite backend) runs-on: ubuntu-latest # source: run 33806380625 (2026-09-03), max 292s; ceil(2 * 292 / 60). diff --git a/scripts/check_ci_gate_results.py b/scripts/check_ci_gate_results.py index 180c6c267..dad2d1440 100644 --- a/scripts/check_ci_gate_results.py +++ b/scripts/check_ci_gate_results.py @@ -82,6 +82,18 @@ def _required_jobs(flags: dict[str, bool], event_name: str, fork: bool) -> set[s required.add("docker-smoke") if flags["docker"] or event_name in ("schedule", "workflow_dispatch"): required.update(DOCKER_BUILD_JOBS) + # A docs-only PR must still run the one full-suite job. Part of that + # suite takes documentation as its INPUT — tests_py/scripts/ + # test_codex_plugin_contract.py asserts on README.md's canonical published + # identities — so excluding '*.md' from `code` switched those guards off + # exactly when their subject changed. PR #509 was a README-only diff, every + # test job skipped, it merged green, and the push to main went red on that + # test across five jobs (run 34238410970, 2026-09-08; fixed by #510). + # Mirrors ci.yml's test-sqlite predicate, which carries the same note; this + # module and that predicate must stay in exact agreement (see the docstring + # on check_policy). + if not full and event_name == "pull_request" and flags["docs"]: + required.add("test-sqlite") if fork: required.discard("mcp-host-config") return required diff --git a/tests_py/scripts/test_check_ci_gate_results.py b/tests_py/scripts/test_check_ci_gate_results.py index ca72e265b..291da84ef 100644 --- a/tests_py/scripts/test_check_ci_gate_results.py +++ b/tests_py/scripts/test_check_ci_gate_results.py @@ -32,10 +32,25 @@ def _skip(needs: dict, jobs: frozenset[str]) -> dict: class JustifiedSkips(unittest.TestCase): - def test_docs_only_runs_only_changes_and_lint(self) -> None: - needs = _skip(_needs("docs"), gate.CONDITIONAL_JOBS) + def test_docs_only_still_requires_the_full_suite_job(self) -> None: + """A docs-only PR may skip every conditional job except test-sqlite. + + Regression for the #509 -> #510 incident: README.md is the INPUT of + tests_py/scripts/test_codex_plugin_contract.py, so skipping every test + job on a docs-only diff switched that guard off exactly when its subject + changed. #509 merged green and main went red on the push (run + 34238410970). test-sqlite is the one job that runs the full suite. + """ + allowed = gate.CONDITIONAL_JOBS - {"test-sqlite"} + needs = _skip(_needs("docs"), allowed) self.assertEqual(gate.check(needs, "pull_request", _event()), []) + needs["test-sqlite"]["result"] = "skipped" + self.assertTrue( + gate.check(needs, "pull_request", _event()), + "skipping test-sqlite on a docs-only PR must not be justified", + ) + def test_docker_only_requires_docker_jobs(self) -> None: needs = _skip(_needs("docker"), gate.CODE_JOBS) self.assertEqual(gate.check(needs, "pull_request", _event()), []) @@ -193,7 +208,10 @@ def _run(self, needs: str) -> subprocess.CompletedProcess: ) def test_docs_only_cli_succeeds(self) -> None: - result = self._run(json.dumps(_skip(_needs("docs"), gate.CONDITIONAL_JOBS))) + # test-sqlite runs on a docs-only PR (it carries the doc-contract + # guards); every other conditional job may skip. + allowed = gate.CONDITIONAL_JOBS - {"test-sqlite"} + result = self._run(json.dumps(_skip(_needs("docs"), allowed))) self.assertEqual(result.returncode, 0, result.stderr) def test_malformed_json_cli_fails_with_diagnostic(self) -> None: