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
9 changes: 5 additions & 4 deletions .github/workflows/web-validation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ on:
# The changes job owns routing; no separate fallback may report success.
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
# The merge queue waits for this required status on the merge group commit.
# The router has no diff for this event, so the full validation runs.
# Both web-validation and ci-status are required. CI owns tests and database
# checks on PRs/merge groups, where its Linux preflight gates the Mac workers.
# This workflow owns the production build and runs all checks standalone.
merge_group:
push:
branches: [main]
Expand Down Expand Up @@ -73,7 +74,7 @@ jobs:
tests:
name: web-tests
needs: changes
if: needs.changes.outputs.required == 'true'
if: needs.changes.outputs.required == 'true' && github.event_name != 'pull_request' && github.event_name != 'merge_group'
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 20
defaults:
Expand All @@ -97,7 +98,7 @@ jobs:
database:
name: web-database-tests
needs: changes
if: needs.changes.outputs.required == 'true'
if: needs.changes.outputs.required == 'true' && github.event_name != 'pull_request' && github.event_name != 'merge_group'
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 15
defaults:
Expand Down
14 changes: 12 additions & 2 deletions scripts/ci/detect_ci_change_areas.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,8 @@ def is_web_change(path: str) -> bool:
"Resources/agent-session-react/",
"Resources/agent-session-solid/",
"Resources/markdown-viewer/",
"config/",
"workers/",
)
):
return True
Expand All @@ -204,6 +206,12 @@ def is_web_change(path: str) -> bool:
"package.json",
"bun.lock",
"biome.json",
".vercelignore",
"vercel.json",
"bunfig.toml",
".npmrc",
".github/workflows/web-validation.yml",
"tests/test_web_validation.py",
"scripts/build-agent-session-web.sh",
"scripts/build-webviews-app.sh",
"scripts/check-webviews-react-compiler.mjs",
Expand Down Expand Up @@ -295,10 +303,12 @@ def classify_files(paths: Iterable[str], *, ci_workflow_linux_only: bool = False
agent_session_web = True
release_build = True
continue
if is_other_workflow_config(path) or is_guard_only_test(path, test_references):
continue
# Web validation's own inputs still select its checks in CI, even when
# the path is a workflow or guard that is neutral for macOS.
if is_web_change(path):
web = True
if is_other_workflow_config(path) or is_guard_only_test(path, test_references):
continue
if is_agent_session_web_change(path):
agent_session_web = True
if is_macos_change(path):
Expand Down
28 changes: 10 additions & 18 deletions scripts/ci/web_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,9 @@


def requires_web(paths: list[str]) -> bool:
return classify_files(paths).web or any(
path
in {
".vercelignore",
"vercel.json",
"bunfig.toml",
".npmrc",
"tests/test_web_validation.py",
# The CI router treats other workflow files as neutral, so this
# gate names its own.
".github/workflows/web-validation.yml",
}
or path.startswith(("config/", "workers/"))
for path in paths
)
# Both required workflows must agree: CI owns tests for PRs/merge groups,
# while this workflow owns the production build and standalone validation.
return classify_files(paths).web


def merge_parent(head: str) -> str:
Expand Down Expand Up @@ -64,7 +52,7 @@ def required_for_event(event: str, base: str, head: str) -> bool:
return not paths or requires_web(paths)


def failures(needs: dict) -> dict[str, str]:
def failures(needs: dict, event: str = "") -> dict[str, str]:
changes = needs.get("changes", {})
required = changes.get("outputs", {}).get("required")
if changes.get("result") != "success" or required not in {"true", "false"}:
Expand All @@ -73,7 +61,11 @@ def failures(needs: dict) -> dict[str, str]:
return {
job: needs.get(job, {}).get("result", "missing")
for job in sorted((set(needs) - {"changes"}) | {"build", "tests", "database"})
if needs.get(job, {}).get("result") not in allowed
if needs.get(job, {}).get("result") not in (
allowed | {"skipped"}
if event in {"pull_request", "merge_group"} and job in {"tests", "database"}
else allowed
)
}


Expand All @@ -90,7 +82,7 @@ def main() -> int:
print(value)
return 0
if sys.argv[1:] == ["check"]:
bad = failures(json.loads(os.environ["WEB_VALIDATION_NEEDS"]))
bad = failures(json.loads(os.environ["WEB_VALIDATION_NEEDS"]), os.environ.get("GITHUB_EVENT_NAME", ""))
for name, result in bad.items():
print(f"{name}: {result}", file=sys.stderr)
return int(bool(bad))
Expand Down
36 changes: 34 additions & 2 deletions tests/test_web_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,9 +116,41 @@ def git(*args):
capture_output=True)
self.assertEqual(output.read_text().strip(), "required=true")

def check_results(self, needs):
def test_ci_selects_every_input_previously_owned_by_web_validation(self):
for path in (
".vercelignore", "vercel.json", "bunfig.toml", ".npmrc",
".github/workflows/web-validation.yml", "tests/test_web_validation.py",
"config/iroh/managed-relay-catalog.json",
"workers/presence/src/generated/managedRelayCatalog.ts",
):
with self.subTest(path=path):
self.assertTrue(gate.classify_files([path]).web)

def test_pr_and_merge_group_checks_belong_to_ci(self):
delegated = {"changes": {"result": "success", "outputs": {"required": "true"}},
"build": {"result": "success"},
"tests": {"result": "skipped"}, "database": {"result": "skipped"}}
for event in ("pull_request", "merge_group"):
with self.subTest(event=event):
self.assertEqual(self.check_results(delegated, event), 0)
for result in ("failure", "cancelled", "skipped"):
self.assertNotEqual(self.check_results(
{**delegated, "build": {"result": result}}, event), 0)
for job in ("tests", "database"):
for result in ("failure", "cancelled"):
self.assertNotEqual(self.check_results(
{**delegated, job: {"result": result}}, event), 0)
missing = dict(delegated)
del missing[job]
self.assertNotEqual(self.check_results(missing, event), 0)
for event in ("push", "workflow_dispatch", "", "unknown"):
with self.subTest(event=event):
self.assertNotEqual(self.check_results(delegated, event), 0)

def check_results(self, needs, event="workflow_dispatch"):
return subprocess.run([sys.executable, str(ROOT / "scripts/ci/web_validation.py"), "check"],
env={**os.environ, "WEB_VALIDATION_NEEDS": json.dumps(needs)}, capture_output=True).returncode
env={**os.environ, "WEB_VALIDATION_NEEDS": json.dumps(needs),
"GITHUB_EVENT_NAME": event}, capture_output=True).returncode

def test_gate_rejects_missing_cancelled_failed_or_skipped_required_jobs(self):
good = {"changes": {"result": "success", "outputs": {"required": "true"}},
Expand Down
Loading