From 18bff469cb8aec03573ae6e5405c05d4c62495ab Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Fri, 11 Sep 2026 20:39:13 +0800 Subject: [PATCH] emrg: run CI for every PR base branch, not just master `test.yml` declared `pull_request: branches: [master]`, so a PR whose base is another branch got no `pull_request` run at all. Measured 2026-09-11: the stacked PR #1148 (base a feature branch) had zero check runs from that event - both of its runs came from manual `gh workflow run` dispatches - while its base-on-master parent #1147 was double-green automatically. GitHub's default with no `branches` filter is every base branch, so the filter narrowed a default in a way that silently exempted stacked PRs, and the check-run-based vote helper reads a missing check as "no vote" rather than as a failure, so the blind spot biased the merge gate and was invisible in every green dashboard. Drops the filter and adds `tests/test_test_workflow_covers_pr_bases.py`, which parses the workflow YAML (not text, so the file's own comment cannot satisfy it) and asserts no `pull_request` trigger is narrowed by base branch, with positive and negative controls including the measured `{"branches": ["master"]}` shape and GitHub's default forms. `push: branches: [master]` is untouched - PRs are the branch-side gate. --- .github/workflows/test.yml | 7 +- Agent.md | 2 +- tests/test_test_workflow_covers_pr_bases.py | 98 +++++++++++++++++++++ 3 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 tests/test_test_workflow_covers_pr_bases.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 69a6e86c..69ce87b4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,8 +3,13 @@ name: Test on: push: branches: [master] + # No `branches` filter on purpose: a base branch can be any branch. Filtering to + # master gave the stacked PR #1148 (base = a feature branch) **zero** check runs, + # while its base-on-master parent #1147 was double-green automatically - the gate + # only ran when someone remembered `gh workflow run test.yml`, and the + # check-run-based vote helper reads a missing check as "no vote" rather than as a + # failure. Covered by tests/test_test_workflow_covers_pr_bases.py. pull_request: - branches: [master] workflow_dispatch: jobs: diff --git a/Agent.md b/Agent.md index 497c2f72..4c12a923 100644 --- a/Agent.md +++ b/Agent.md @@ -119,7 +119,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design: pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.token; python -m emrg ``` -Python: `uv run pytest tests/ -v` (1477) — import check: `uv run python -c "from emrg.client.app import run_client" +Python: `uv run pytest tests/ -v` (1484) — import check: `uv run python -c "from emrg.client.app import run_client" GUI: `cd emrg/gui && npm test` (100: 44 daemon_client + 20 conn-manager + 7 integration + 7 nav-policy + 7 gui-state + 6 build-config + 4 boot-contract + 3 preload-api + 2 theme-guard) — syntax: `node --check main.js preload.js daemon_client.js` Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (514: 5 snapshot-store + 9 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 13 markdown + 21 transcript + 11 TranscriptView + 15 history + 31 composer + 41 Composer + 6 LinkDialog + 16 sidebar + 17 Sidebar + 9 fileTree + 9 FileTree + 16 resultPanel + 8 ResultPanel + 27 workspaceView + 10 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog + 10 dialogLists + 3 HelpDialog + 9 MemoryDialog + 6 SkillsDialog + 8 openSession + 6 WelcomeDialog + 9 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 18 daemonBridge + 7 DaemonBridgeProvider + 30 Shell + 15 DialogHost + 20 SettingsPanel + 6 TaskFormDialog + 5 RantDialog + 4 vendorMarkdown) + `npm run build` → `renderer/dist/` CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文) diff --git a/tests/test_test_workflow_covers_pr_bases.py b/tests/test_test_workflow_covers_pr_bases.py new file mode 100644 index 00000000..8e6d4655 --- /dev/null +++ b/tests/test_test_workflow_covers_pr_bases.py @@ -0,0 +1,98 @@ +"""A base branch can be any branch, not just `master`. + +Why this module exists +---------------------- +`test.yml` declared `pull_request: branches: [master]`, so a PR whose base is +another branch got **no `pull_request` run at all** - the gate ran only when a +human remembered `gh workflow run test.yml`, and nothing said so. + +Measured 2026-09-11 (`cyc20260911-202014`): `#1148` (base +`feature/conflict-classifier-multiline-count`) had **zero** check runs from the +`pull_request` event - both of its runs came from `gh workflow run test.yml` +dispatched by hand during review - while its parent `#1147` (base `master`) was +double-green automatically. The blind spot is silent by construction: the +check-run-based vote helper reads a missing check as **no vote recorded** rather +than as a failure, so the missing gate biases the merge gate and is invisible in +every green dashboard. GitHub's own default for a `pull_request` trigger with no +`branches` filter is "every base branch", so the filter narrowed a default in a way +that silently exempted the repo's stacked-PR workflow. + +The rule, and its boundary +-------------------------- +* every workflow that runs on `pull_request` **must not narrow the trigger by base + branch**. The assertion is on parsed YAML, not text: the file quotes this very + shape in a comment, and a substring search over the file matches its own prose + (the failure mode `tests/test_ci_workflow_toolchain.py` records one level up - a + guard whose evidence is its own prose cannot notice the subject disappearing). +* `master` **push** is still allowed to be the only push trigger: PRs are the + branch-side gate, and the post-merge run on master is a different signal. + +Deliberately **not** asserted: that any particular workflow exists, or what it +runs. Only the trigger's reach is pinned here. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml + +REPO_ROOT = Path(__file__).resolve().parent.parent +WORKFLOWS = REPO_ROOT / ".github" / "workflows" + + +def _pull_request_triggeres() -> list[tuple[Path, object]]: + """(path, pull_request trigger value) for every workflow that reacts to PRs.""" + found = [] + for path in sorted(WORKFLOWS.glob("*.yml")): + spec = yaml.safe_load(path.read_text(encoding="utf-8")) + # PyYAML parses the bare `on:` key as the boolean True (YAML 1.1). + triggers = spec.get("on", spec.get(True)) + if not isinstance(triggers, dict): + continue + if "pull_request" in triggers: + found.append((path, triggers["pull_request"])) + return found + + +def test_at_least_one_workflow_reacts_to_pull_requests() -> None: + """Negative control: the premise must be real, or the rule below is vacuous.""" + assert _pull_request_triggeres(), "no workflow declares a pull_request trigger" + + +def test_no_pull_request_trigger_is_narrowed_by_base_branch() -> None: + offenders = [] + for path, trigger in _pull_request_triggeres(): + # A trigger with no options at all, or with any option other than a + # branches filter, is fine - `pull_request:` and `pull_request: {types: ...}` + # both mean "every base branch". + if isinstance(trigger, dict) and "branches" in trigger: + offenders.append(f"{path.name}: {trigger['branches']}") + assert not offenders, ( + "these PR triggers restrict the base branch, so a PR based on another " + "branch gets no CI run (a stacked PR gets none): " + + "; ".join(offenders) + + " - drop the `branches` filter to cover every base, as GitHub's default " + "does" + ) + + +@pytest.mark.parametrize( + "trigger,should_be_an_offender", + [ + # GitHub's defaults: absent key, empty mapping, `types` only. + (None, False), + ({}, False), + ({"types": ["opened", "synchronize"]}, False), + # The measured shape, with the list and with a bare scalar. + ({"branches": ["master"]}, True), + ({"branches": "master"}, True), + ], +) +def test_the_detector_fires_on_the_narrowed_shape_and_only_that( + trigger: object, should_be_an_offender: bool +) -> None: + """The predicate itself, both ways - a rule nobody can trip is not a rule.""" + is_offender = isinstance(trigger, dict) and "branches" in trigger + assert is_offender is should_be_an_offender