Files
Jake WriterandClaude Opus 5.5 e92caec332 CI: release npm with PyPI, split the patch guards by kind, run memory growth on every PR (#789)
* ci: split the patch guards by kind, and run memory growth on every PR

Patch guards: one 11-minute job ran all 26 guards and the Playwright
skiplist audit. Whether the patches apply is the build's check; the
guards test that what they do still works, and fall into three kinds,
now three jobs beside the skiplist audit:

  spoofing    a spoofed value still reaches the page and holds together
  automation  Playwright stays invisible to the page and never deadlocks it
  parity      what a page, or the OS, can observe matches stock Firefox

Each writes its own suite (patch_guards_<group>), so a failure names the
kind that broke. GROUPS in ci/run_patch_guards.py assigns every guard to
exactly one, and a self-test fails on a guard in none. One job id with a
matrix, so everything that needs patch-guards is unchanged.

Memory growth: ~38 minutes in one process kept it on the schedule and
out of the gate. ci.run_native --shard i/n runs every n-th collected
test, and the growth job is a 7-way matrix -- one test per runner, about
six minutes each -- on every pull request, required by the summary and
the gate. summarize.py already folds <suite>-<i>of<n> results back into
one suite, as it does for Playwright.

CONTRIBUTING.md now says why the stealth check skips on a fork pull
request: GitHub gives secrets only to branches in this repository.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ci(release): publish to npm after PyPI, from the same commit

The two launchers ship at one version, but were released by two
unrelated, hand-started workflows, so npm could get a release PyPI did
not. "Publish to pypi" is now the one place a release starts:

1. it calls publish-npm.yml as a dry run -- every check, the build, the
   pack check and `npm publish --dry-run` -- so a broken npm package stops
   the release before anything is uploaded;
2. it uploads to PyPI;
3. its success triggers publish-npm.yml (workflow_run), which publishes
   the commit PyPI was released from.

publish-npm.yml stays the file that publishes, because npm's trusted
publisher is tied to its name. Started by hand it only retries the npm
half, and refuses unless PyPI already has the version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(guards): contentaccessible-parity reads its probe's marked result line

The live probe runs in a child process and the parent parsed its whole
stdout as JSON. On a machine whose cache has no addons yet, the first
Camoufox launch downloads uBlock Origin and prints its progress to
stdout first, so the parse failed ("Expecting value: line 2 column 1").
It only ever passed because another guard launched Camoufox earlier in
the same job; split into its own leg, it ran first on a fresh runner.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ci(guards): group the three guards main added since the split

addons-install-once, viewport-no-rdm and worker-config-reads landed on main
after the groups were drawn; the one-group-each self-test caught them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(guards): gfx-probes gives a blocklisted launch a second try

The blocklist signature means gfxInfo is empty. Missing probes cause that on
every launch; a present glxtest that fails or times out on a loaded runner
causes it once in a while, which failed stock parity on this PR. Relaunch once
before failing, and print what the probes wrote to stderr.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 21:42:47 +00:00

337 lines
15 KiB
Python

#!/usr/bin/env python3
"""Fold every result file into one verdict and one readable table.
Runs last in CI. Three jobs:
* **Merge shards.** The upstream suite is split across parallel runners, so
`playwright-3of6` and its siblings are folded back into one record
before anything is judged.
* **Decide.** A required suite that produced no result file is a failure, not
a skip -- otherwise deleting a job would be the cheapest way to a green
tick. Anything that failed is a failure.
One narrow exception, opt-in per suite via `--allow-skip`: a suite that ran
and recorded SKIP with a reason. That exists for the stealth check, which
depends on a separate service -- when sundial is down the browser was never
measured, and blocking every merge in the repository on somebody else's
outage is the wrong answer. It is still not reported as a pass.
* **Report.** Writes the Markdown that lands in the job summary and the pull
request. The stealth line is a grade and a count; it never names a vector.
It also validates `ci/skiplist.yml`: an entry with no reason fails the run,
because a skiplist that can grow silently is a way to make any test disappear.
Run:
python3 -m ci.summarize --results-dir .ci-work/results
python3 -m ci.summarize --require build playwright --markdown out.md
"""
from __future__ import annotations
import argparse
import re
import sys
from collections import defaultdict
from pathlib import Path
from typing import Dict, List, Optional
from . import results
from ._util import REPO_ROOT, RESULTS_DIR, SKIPLIST_PATH, log, summary, write_json
# "playwright-3of6" -> "playwright"
_SHARD_SUFFIX = re.compile(r"-\d+of\d+$")
# Counts every shard contributes its own share of. Summing rather than taking
# the first shard's is the difference between "6 tests fell back to the main
# world" and "1 did", on a six-way shard -- and this number is the one being
# watched over time, so a sixth of it is worse than none.
_SUMMED_METRICS = frozenset({"main_world_fallback_count", "isolated_world_failures"})
# Lists of test identities, which are disjoint across shards by construction.
_UNIONED_METRICS = frozenset({"main_world_fallbacks"})
# Presented in this order; anything unexpected is appended.
_ORDER = [
"native_rules", "pythonlib", "typescript", "patches_apply", "build", "patch_guards",
"patch_guards_spoofing", "patch_guards_automation", "patch_guards_parity",
"skiplist_audit", "native_browser", "build_tester", "playwright", "typescript_browser",
"native_growth", "sundial",
]
def merge_shards(records: Dict[str, dict]) -> Dict[str, dict]:
"""Fold `<name>-<i>of<n>` records back into `<name>`."""
grouped: Dict[str, List[dict]] = defaultdict(list)
for name, record in records.items():
grouped[_SHARD_SUFFIX.sub("", name)].append(record)
merged: Dict[str, dict] = {}
for name, parts in grouped.items():
if len(parts) == 1:
merged[name] = parts[0]
continue
tests: Dict[str, str] = {}
notes: List[str] = []
artifacts: List[str] = []
metrics: Dict[str, object] = {}
statuses = []
for part in sorted(parts, key=lambda p: str(p.get("gate"))):
for tid, outcome in (part.get("tests") or {}).items():
# A test that passed on any shard passed; shards are disjoint,
# so this only matters if a retry moved one.
if tests.get(tid) != results.PASS:
tests[tid] = outcome
notes.extend(part.get("notes") or [])
artifacts.extend(part.get("artifacts") or [])
statuses.append(part.get("status"))
for key, value in (part.get("metrics") or {}).items():
if key in _SUMMED_METRICS and isinstance(value, (int, float)):
metrics[key] = metrics.get(key, 0) + value
elif key in _UNIONED_METRICS and isinstance(value, list):
metrics[key] = sorted(set(metrics.get(key, [])) | set(value))
else:
# Everything else is a property of the run as a whole (the
# resolved tag, the browser version) and is identical across
# shards, so the first one is the answer.
metrics.setdefault(key, value)
metrics.pop("shard", None)
metrics["shards"] = len(parts)
tally: Dict[str, int] = {}
for outcome in tests.values():
tally[outcome] = tally.get(outcome, 0) + 1
tally["total"] = len(tests)
metrics["tally"] = tally
merged[name] = {
"gate": name,
"status": (
results.ERROR if results.ERROR in statuses
else results.FAIL if results.FAIL in statuses
else results.PASS
),
"tests": tests,
"metrics": metrics,
"notes": notes,
"artifacts": artifacts,
"run_id": parts[0].get("run_id"),
}
log(f"merged {len(parts)} shards of {name}: {tally.get('total', 0)} tests")
return merged
def validate_skiplist(path: Optional[Path] = None) -> List[str]:
"""Every skip needs a reason, and every `replaced-by` has to point at a real
file. Returns the problems found.
The second check is what keeps the "upstream expectations that encode a
stock-Firefox quirk" section honest. Those entries claim a Camoufox-owned
test took over guarding the behaviour; if that file is renamed or deleted the
claim silently becomes false and the behaviour stops being tested by anything.
"""
path = path or SKIPLIST_PATH
if not path.exists():
return []
import yaml
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
problems = []
for index, entry in enumerate(data.get("skip") or []):
if not isinstance(entry, dict):
problems.append(f"skiplist entry {index} is not a mapping")
continue
target = entry.get("module") or entry.get("test") or entry.get("pattern")
if not target:
problems.append(f"skiplist entry {index} names no module, test or pattern")
if not str(entry.get("reason", "")).strip():
problems.append(
f"skiplist entry {target!r} has no reason. A skip without a stated reason "
"is indistinguishable from hiding a failure."
)
replacement = str(entry.get("replaced-by", "")).strip()
if replacement and not (REPO_ROOT / replacement).is_file():
problems.append(
f"skiplist entry {target!r} says it is replaced by {replacement!r}, "
"which does not exist. Either restore that test or stop claiming "
"the behaviour is still covered."
)
return problems
def _firefox_generation(browser_version: str) -> str:
"""`152.0.4` -> `152`. A missing or malformed version is not worth failing on."""
head = browser_version.split(".")[0].strip()
return head if head.isdigit() else "?"
def render(merged: Dict[str, dict], required: List[str], problems: List[str], meta: Dict[str, str]) -> str:
ok = not problems
lines = [
"## " + ("✅ Tests passed" if ok else "❌ Tests failed"),
"",
f"Camoufox `{meta.get('browser_version', '?')}` "
f"(`{meta.get('browser_release') or 'no release tag'}`), built on Firefox "
f"`{_firefox_generation(meta.get('browser_version', ''))}`, tested against Playwright "
f"`{meta.get('playwright_tag', '?')}` — "
# Say WHY that tag, because the Firefox it pins is rarely the Firefox
# being tested and the bare pair reads like a mismatch. Playwright
# releases trail Firefox and skip generations (it went 151 -> 153,
# never pinning 152), so an exact match is the exception.
+ (meta.get("version_note") or f"which targets Firefox `{meta.get('playwright_firefox', '?')}`")
+ ".",
"",
"| Suite | Result | Detail |",
"| --- | --- | --- |",
]
ordered = [n for n in _ORDER if n in merged] + [n for n in merged if n not in _ORDER]
for name in ordered:
record = merged[name]
status = record.get("status", "error")
icon = {"pass": "✅", "fail": "❌", "error": "💥", "skip": "⏭️"}.get(status, "❓")
tally = (record.get("metrics") or {}).get("tally") or {}
if status == "skip":
# A skipped suite has a reason and no numbers; the reason is the
# only useful thing to show, and showing a grade of "?" next to it
# would read as a measurement that came back empty.
detail = (record.get("notes") or ["skipped"])[-1]
elif name == "sundial":
# Grade and counts only. Never a category, never a vector.
# Named distinctly from the shard `metrics` above so the self-test
# can hold just these reads to sundial's publishable whitelist.
sundial_metrics = record.get("metrics") or {}
detail = (
f"grade **{sundial_metrics.get('grade', '?')}** — "
f"{sundial_metrics.get('checks_passed', 0)}"
f"/{sundial_metrics.get('checks_total', 0)} "
f"in-scope checks passed"
)
elif tally:
failed = tally.get("fail", 0) + tally.get("error", 0)
detail = f"{tally.get('pass', 0)} passed, {failed} failed, {tally.get('total', 0)} collected"
shards = (record.get("metrics") or {}).get("shards")
if shards:
detail += f" (across {shards} shards)"
# Published deliberately. These tests pass, so they do not show up
# in the failure count -- but the number is the isolated-world
# conformance gap, and a silent change in it is exactly what this
# line exists to make visible between one run and the next.
fallbacks = (record.get("metrics") or {}).get("main_world_fallback_count")
if fallbacks is not None:
detail += f", {fallbacks} via main-world fallback"
else:
detail = (record.get("notes") or ["—"])[-1]
lines.append(f"| `{name}` | {icon} {status} | {detail} |")
for name in required:
if name not in merged:
lines.append(f"| `{name}` | 🚫 missing | required, but produced no result |")
if problems:
lines += ["", "### What failed", ""]
lines += [f"- {p}" for p in problems]
lines += [
"",
"<sub>The Playwright suite is upstream playwright-python at the tag above, "
"fetched fresh, with [`tests/camoufox/`](tests/camoufox) overlaid. It runs with "
"world isolation on — the configuration Camoufox ships — and only the failures "
"are re-run with it off; those count as passes and are reported above as "
"main-world fallbacks, which is the size of the isolated-world gap. Tests "
"Camoufox cannot pass by design are deselected via "
"[`ci/skiplist.yml`](ci/skiplist.yml) — each with a stated reason. The stealth "
"check reports a grade only; its per-vector detail is deliberately never "
"published.</sub>",
]
return "\n".join(lines)
def main(argv: Optional[List[str]] = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--results-dir", type=Path, default=RESULTS_DIR)
parser.add_argument("--require", nargs="*", default=[], help="suites that must have reported")
parser.add_argument("--markdown", type=Path)
parser.add_argument("--out", type=Path)
parser.add_argument("--browser-version", default="")
parser.add_argument("--browser-release", default="")
parser.add_argument("--playwright-tag", default="")
parser.add_argument("--playwright-firefox", default="")
parser.add_argument("--version-note", default="", help="how ci.versions chose the suite")
parser.add_argument("--allow-failure", nargs="*", default=[],
help="suites whose failure is reported but not fatal")
parser.add_argument("--allow-skip", nargs="*", default=[],
help=(
"suites allowed to record a reasoned SKIP without failing the "
"run. For a suite that depends on something outside this "
"repository -- the stealth check needs sundial to be up."
))
args = parser.parse_args(argv)
records = results.load_all(args.results_dir)
merged = merge_shards(records)
problems: List[str] = []
problems.extend(validate_skiplist())
for name in args.require:
if name not in merged:
problems.append(
f"`{name}` is required but produced no result file. A suite that did not "
"run has not passed."
)
for name, record in sorted(merged.items()):
if record.get("status") == results.PASS:
continue
note = (record.get("notes") or ["see the job log"])[-1]
if record.get("status") == results.SKIP and name in args.allow_skip:
# Reported, never silent: it is on the summary table with its reason
# and an explicit skip icon. What it is not is a merge block.
log(f"{name} was skipped: {note}", level="WARN")
continue
if name in args.allow_failure:
log(f"{name} failed but is advisory on this run", level="WARN")
continue
problems.append(f"`{name}` reported {record.get('status')}: {note}")
meta = {
"browser_version": args.browser_version,
"browser_release": args.browser_release,
"playwright_tag": args.playwright_tag,
"playwright_firefox": args.playwright_firefox,
"version_note": args.version_note,
}
markdown = render(merged, args.require, problems, meta)
print()
print(markdown)
print()
summary(markdown)
if args.markdown:
args.markdown.parent.mkdir(parents=True, exist_ok=True)
args.markdown.write_text(markdown + "\n", encoding="utf-8")
if args.out:
write_json(args.out, {
"ok": not problems,
"problems": problems,
"suites": {
name: {
"status": r.get("status"),
"metrics": r.get("metrics"),
"notes": r.get("notes"),
}
for name, r in merged.items()
},
**meta,
})
if problems:
log(f"{len(problems)} problem(s)", level="ERROR")
for problem in problems:
log(f" {problem}", level="ERROR")
return 1
log("all suites passed")
return 0
if __name__ == "__main__":
sys.exit(main())