Files
camoufox/ci/run_patch_guards.py
T
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

145 lines
5.0 KiB
Python

#!/usr/bin/env python3
"""Patch-guard gates: tests/patches/*.py, one standalone guard per shipped behaviour.
Whether the patches *apply* is the build's job (scripts/patch.py fails it).
These check that what they do still works in the built browser -- the most
direct evidence that a Firefox bump did not quietly neuter a patch that still
applies cleanly, which is the failure mode a compile check cannot catch.
The guards fall into three groups, each its own suite and CI job:
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 guard is a standalone script exiting 0 or 1. Policy allows zero failures.
Every guard belongs to exactly one group (ci/tests checks this), so a new one
cannot be left out of CI.
Run:
python3 -m ci.run_patch_guards --binary /path/to/camoufox-bin
python3 -m ci.run_patch_guards --binary /path/to/camoufox-bin --group automation
"""
from __future__ import annotations
import argparse
import os
import sys
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from . import results as evidence
from ._util import EVIDENCE_DIR, REPO_ROOT, log, run
GUARD_DIR = REPO_ROOT / "tests" / "patches"
GROUPS: Dict[str, Tuple[str, ...]] = {
"spoofing": (
"animation-timing",
"fingerprint-setter-seal",
"media-devices-coherence",
"spoofed-voice-speaks",
"startup-prefs",
"system-ui-font-spoofing",
"touchscreen-digitizer",
"worker-config-reads",
),
"automation": (
"addons-install-once",
"force-scope-access",
"humanize-edge-deadlock",
"humanize-mouse-trajectory",
"input-ack-backstop",
"isolated-evaluate",
"main-world-eval",
"main-world-init-script",
"mouse-boundary-sweep",
"near-edge-mouse-deadlock",
"noop-mousemove-deadlock",
"trusted-events",
"visible-automation-cues",
),
"parity": (
"contentaccessible-parity",
"gfx-probes-packaged",
"hardware-acceleration-policy",
"popup-blocker-parity",
"search-service-init",
"stock-parity-probes",
"viewport-no-rdm",
"windows-exe-manifest",
),
}
def gate_name(group: Optional[str]) -> str:
"""patch_guards for the whole set, patch_guards_<group> for one group."""
return f"patch_guards_{group}" if group else "patch_guards"
def guards() -> List[Path]:
"""Every guard script. helpers.py is a library, not a guard."""
return sorted(p for p in GUARD_DIR.glob("*.py") if p.name != "helpers.py")
def main(argv: Optional[List[str]] = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--binary", type=Path)
parser.add_argument("--evidence-dir", type=Path, default=EVIDENCE_DIR)
parser.add_argument("--timeout", type=int, default=600, help="per guard")
parser.add_argument("--only", nargs="*", help="run only these guard names")
parser.add_argument("--group", choices=sorted(GROUPS), help="run one group (default: all)")
args = parser.parse_args(argv)
from ._pytest import built_binary
result = evidence.GateResult(gate=gate_name(args.group))
binary = args.binary or built_binary()
if not binary.exists():
result.note(f"no built binary at {binary}")
result.finish(evidence.ERROR).save(args.evidence_dir)
return 1
env = {
"CAMOUFOX_EXECUTABLE_PATH": str(binary),
# The guards drive the browser through the Python package, which resolves
# the binary from this variable rather than a packaged install.
"PYTHONPATH": os.pathsep.join(
filter(None, [str(REPO_ROOT / "pythonlib"), os.environ.get("PYTHONPATH", "")])
),
}
selected = [
g for g in guards()
if (not args.only or g.stem in args.only)
and (not args.group or g.stem in GROUPS[args.group])
]
if not selected:
result.note("no guards found -- tests/patches/ is empty or the filter matched nothing")
result.finish(evidence.ERROR).save(args.evidence_dir)
return 1
failed: List[str] = []
for guard in selected:
proc = run([sys.executable, str(guard)], cwd=REPO_ROOT, env=env, timeout=args.timeout)
outcome = evidence.PASS if proc.ok else evidence.FAIL
result.record(f"patches/{guard.name}", outcome)
if not proc.ok:
failed.append(guard.name)
tail = proc.combined().strip().splitlines()[-6:]
result.note(f"{guard.name} failed ({proc.code}): " + " | ".join(t.strip() for t in tail))
else:
log(f" ✓ {guard.name}")
passed = len(selected) - len(failed)
result.note(f"{passed}/{len(selected)} guards passed")
status = evidence.PASS if not failed else evidence.FAIL
result.finish(status).save(args.evidence_dir)
return 0 if status == evidence.PASS else 1
if __name__ == "__main__":
sys.exit(main())