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
14 changes: 14 additions & 0 deletions .github/workflows/ci-web.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ on:
agent_session_web:
required: true
type: string
# A push's event.before (cmux-next-web.yml): subareas route from the whole push, not
# only its last commit. Empty on pull requests, which route from the merge commit.
push_base:
required: false
type: string
default: ""

permissions:
contents: read
Expand All @@ -36,10 +42,18 @@ jobs:
fetch-depth: 2
persist-credentials: false

# The push base is not in a depth-2 clone; fetch it alone (an unknown one runs everything).
- name: Fetch the push base
if: ${{ inputs.push_base != '' }}
env:
PUSH_BASE: ${{ inputs.push_base }}
run: git fetch --no-tags --depth=1 origin "$PUSH_BASE" || echo "::warning title=web routing::push base $PUSH_BASE is not fetchable; every web subarea runs"

- name: Classify web subareas
id: scope
env:
EVENT_NAME: ${{ github.event_name }}
WEB_SUBAREAS_BASE: ${{ inputs.push_base }}
run: python3 scripts/ci/web_subareas.py route

web-typecheck:
Expand Down
17 changes: 12 additions & 5 deletions .github/workflows/cmux-next-web.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,18 @@ name: cmux-next web
# checks: a base web red went unseen (react-apps-check, 2026-10-07), and
# gh-merge-green's base-aware excusal had no base verdict to compare a PR's web
# red with. Every push runs them here as the `web` job, under the same
# `web / ...` names as on a pull request. ci-web routes its subareas from the
# push's first-parent diff, so a push that touches no web path runs only its
# scope job and the Agent Session web tests. Linux only.
# `web / ...` names as on a pull request. ci-web routes its subareas from
# everything the push brought (event.before..sha), so a push that touches no web
# path runs only the scope job and the status job, two small Linux jobs. Linux only.
#
# Lawrence took the push trigger off on 2026-10-10 (cx-g34i: push CI is the Swift
# build only). Without it, direct pushes brought web reds nobody saw until a pull
# request tripped over them, and a pull request's new web red passed as a base red
# (#20633). Leo, 2026-10-19: the base stays green by construction. The routing keeps
# the cost of a Swift-only push to the two small jobs.
on:
# feat-cmux-next push CI is the Swift app build only (Lawrence, 2026-10-10);
# dispatch this workflow on a ref to run it.
push:
branches: [feat-cmux-next]
workflow_dispatch:
permissions:
contents: read
Expand All @@ -27,3 +33,4 @@ jobs:
web: ${{ 'true' }}
macos: ${{ 'false' }}
agent_session_web: ${{ 'true' }}
push_base: ${{ github.event_name == 'push' && github.event.before || '' }}
4 changes: 3 additions & 1 deletion .github/workflows/cmux-next.yml
Original file line number Diff line number Diff line change
Expand Up @@ -905,7 +905,9 @@ jobs:
retention-days: 1

checks:
if: ${{ inputs.dev_daily != true && (!(github.event_name == 'push' && github.ref == 'refs/heads/feat-cmux-next') && (inputs.same_tree_sha == '' && (github.event_name != 'workflow_dispatch' || github.ref == 'refs/heads/feat-cmux-next' || startsWith(github.ref, 'refs/heads/next-batch/')))) }}
# feat-cmux-next pushes run it again (Leo, 2026-10-19; cx-g34i had dropped it): a direct push's
# red shows on that push, and a pull request's red has a base run to be compared with.
if: ${{ inputs.dev_daily != true && (inputs.same_tree_sha == '' && (github.event_name != 'workflow_dispatch' || github.ref == 'refs/heads/feat-cmux-next' || startsWith(github.ref, 'refs/heads/next-batch/'))) }}
name: cmux-next checks (god files, concurrency, crash safety, l10n)
runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 10
Expand Down
7 changes: 5 additions & 2 deletions scripts/ci/web_subareas.py
Original file line number Diff line number Diff line change
Expand Up @@ -278,13 +278,16 @@ def route() -> int:
WebSubareas.all().emit(output)
return 0

# A feat-cmux-next push routes from what the push brought (event.before), not its last
# commit: a multi-commit push can end on a commit that touches no web path.
push_base = os.environ.get("WEB_SUBAREAS_BASE", "").strip()
base = subprocess.run(
["git", "rev-parse", "-q", "--verify", "HEAD^1"],
["git", "rev-parse", "-q", "--verify", f"{push_base}^{{commit}}" if push_base else "HEAD^1"],
text=True,
capture_output=True,
)
if base.returncode != 0:
print("comparison parent unavailable; running every web subarea")
print(f"comparison base {push_base or 'HEAD^1'} unavailable; running every web subarea")
WebSubareas.all().emit(output)
return 0

Expand Down
100 changes: 100 additions & 0 deletions tests/test_cmux_next_web_base_push.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""feat-cmux-next pushes run ci-web's checks, routed by everything the push brought.

With no push run, a web red that a direct push brings (219ae289fc's unformatted files,
2026-10-19) is seen only when a pull request trips over it, and every web red on a pull
request looks like a base red with no base run to compare it with (#20633 merged with a new
lint red beside the eager JS budget red). The scope job costs one small Linux job, so a push
that touches no web path runs nothing else.
"""
import os
import subprocess
import sys
import tempfile
from pathlib import Path

import yaml

ROOT = Path(__file__).resolve().parents[1]
WORKFLOW = ROOT / ".github/workflows/cmux-next-web.yml"
CI_WEB = ROOT / ".github/workflows/ci-web.yml"
HELPER = ROOT / "scripts/ci/web_subareas.py"


def test_every_feat_cmux_next_push_runs_the_web_checks() -> None:
doc = yaml.safe_load(WORKFLOW.read_text())
on = doc.get("on", doc.get(True))
push = on.get("push") if isinstance(on, dict) else None
assert push is not None, "cmux-next-web.yml has no push trigger"
assert push.get("branches") == ["feat-cmux-next"], push
# No paths filter: the scope job routes, so a web path list here could only drift.
assert "paths" not in push and "paths-ignore" not in push, push
web = doc["jobs"]["web"]
assert web["uses"] == "./.github/workflows/ci-web.yml"
assert web["with"].get("push_base") == "${{ github.event_name == 'push' && github.event.before || '' }}"


def test_ci_web_routes_from_the_push_base() -> None:
doc = yaml.safe_load(CI_WEB.read_text())
on = doc.get("on", doc.get(True))
assert on["workflow_call"]["inputs"]["push_base"]["required"] is False
steps = doc["jobs"]["web-subarea-scope"]["steps"]
scope = next(s for s in steps if s.get("id") == "scope")
assert scope["env"].get("WEB_SUBAREAS_BASE") == "${{ inputs.push_base }}"


def test_feat_cmux_next_pushes_run_the_cmux_next_checks_job() -> None:
doc = yaml.safe_load((ROOT / ".github/workflows/cmux-next.yml").read_text())
cond = doc["jobs"]["checks"]["if"]
assert "!(github.event_name == 'push' && github.ref == 'refs/heads/feat-cmux-next')" not in cond, cond


def git(cwd: Path, *args: str) -> str:
return subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True, text=True).stdout.strip()


def route(cwd: Path, base: str) -> dict[str, str]:
with tempfile.NamedTemporaryFile("r", delete=False) as out:
path = out.name
env = {**os.environ, "GITHUB_OUTPUT": path, "EVENT_NAME": "push", "WEB_SUBAREAS_BASE": base}
subprocess.run([sys.executable, str(HELPER), "route"], cwd=cwd, env=env, check=True, capture_output=True)
lines = Path(path).read_text().splitlines()
os.unlink(path)
return dict(line.split("=", 1) for line in lines)


def test_a_multi_commit_push_routes_every_commit_not_only_the_last() -> None:
with tempfile.TemporaryDirectory() as tmp:
repo = Path(tmp)
git(repo, "init", "-q")
git(repo, "config", "user.email", "t@example.com")
git(repo, "config", "user.name", "t")
(repo / "README.md").write_text("a\n")
git(repo, "add", "-A")
git(repo, "commit", "-qm", "base")
before = git(repo, "rev-parse", "HEAD")
(repo / "webviews/src").mkdir(parents=True)
(repo / "webviews/src/a.ts").write_text("export const a = 1\n")
git(repo, "add", "-A")
git(repo, "commit", "-qm", "web change")
(repo / "README.md").write_text("b\n")
git(repo, "add", "-A")
git(repo, "commit", "-qm", "docs change on top")
# HEAD^1 alone sees only README.md; the push range sees the webviews file too.
assert route(repo, "")["react_apps"] == "false"
assert route(repo, before)["react_apps"] == "true"
# A push base the clone cannot resolve (a force push, a shallow fetch) runs everything.
assert route(repo, "0" * 40)["react_apps"] == "true"


if __name__ == "__main__":
failed = 0
for name, fn in sorted(globals().items()):
if name.startswith("test_") and callable(fn):
try:
fn()
print(f"PASS {name}")
except Exception as err: # noqa: BLE001 - report every failure
failed += 1
print(f"FAIL {name}: {type(err).__name__} {err}")
sys.exit(1 if failed else 0)