Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion Agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` (1483) — import check: `uv run python -c "from emrg.client.app import run_client"
Python: `uv run pytest tests/ -v` (1490) — 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 上下文)
Expand Down
98 changes: 98 additions & 0 deletions tests/test_test_workflow_covers_pr_bases.py
Original file line number Diff line number Diff line change
@@ -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
Loading