Files
camoufox/ci/run_typescript.py
Jake WriterandClaude Opus 5.5 11969fa44a Prerelease on every tested merge, promote by tag, and pair each library release with its browser (#810)
* Pair each library release with the browser build it was tested with

Nothing tied a library release to a browser build: `camoufox fetch` took the
newest build in a channel, and a launch used whatever config.json marked
active, so an upgraded library could run a browser it was never tested with,
and an old library would pick up a newer, incompatible browser.

A released package now carries browser-pin.json, naming the browser release
built from the same sources. With it, and no explicit choice by the user:

- fetch installs exactly that build (no prerelease prompt: it is the build
  this release was tested with, prerelease or not);
- a launch uses exactly that build, whatever else is installed or active,
  and reports it as not installed rather than falling back to another;
- the fetcher's automatic install (TypeScript's first run) takes only it.

An explicit `camoufox set` still wins, with a one-time warning at launch;
`camoufox set --release` returns to the paired build, and `camoufox active`
says which is in use. The checked-in pin is `{}`, so development checkouts
follow their channel as before.

Also: prerelease library versions (0.5.8b1, 0.5.8-beta.1) parse as their
release; they were read as 0.5.0.

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

* Release a prerelease on every tested merge; promote to stable by tag

Every merge to main whose tests pass now publishes a prerelease of all
three artifacts, and pushing vX.Y.Z on a tested main commit promotes it.

- Build and Release runs after Tests on main. It builds the browser only
  when its sources changed (ci.browser_inputs.source_digest: every browser
  input, not counting the release number). Each build gets the next unused
  beta.N on a release commit beside main -- main is protected -- and is
  published as a GitHub prerelease, not a draft, with its source digest in
  the notes.
- Publish to pypi follows it: <next>bN on PyPI, then Publish to npm puts
  <next>-beta.N under the `next` dist-tag. Both are stamped with the browser
  release built from the same sources.
- A vX.Y.Z tag is refused unless the commit is on main and `All tests
  passed` succeeded on it. The paired browser prerelease then becomes the
  stable, latest release (no rebuild, so users get the tested binaries), and
  X.Y.Z goes to PyPI and npm `latest`.

The tested commit travels between workflows as an artifact: a workflow_run
is told main's head, so two quick merges would otherwise publish the second,
untested one. ci/release.py holds the planning, stamping and promotion,
unit-tested in ci/tests/test_release.py.

Also fixes two checks that failed the manual release already: vermin
targeted Python 3.8 exactly, against a package that declares ^3.10 and a
code base that needs 3.9, and check-pack compared npm and PyPI prerelease
versions as strings, although each registry spells them differently.

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

* Test driver-only pull requests against the release paired with their sources

The scope step matched the release tag named by upstream.sh. With release
numbers now allocated per build, that number is a floor, not a release, so
driver-only pull requests would nearly always rebuild, or fetch a build other
than the one their sources produce. It now asks `ci.release paired` for the
release built from exactly this tree's browser sources, and fetch-browser
installs it through the same pin a released package carries.

Documents the release flow in ci/README.md.

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

* pythonlib: replace asyncio.to_thread so the 3.8 vermin gate passes

publish-pypi.yml checks the package with
`vermin . --eval-annotations --target=3.8 --violations camoufox/`, and
asyncio.to_thread (Python 3.9+) in _resolve_proxy_geo failed it, stopping
the 0.5.7 release. loop.run_in_executor does the same off-loop lookup.

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

* Pair with releases cut before the digest marker, and test the pairing against the step

The scope step now asks ci.release paired, which only knew releases whose notes
carry a source digest. None does yet: v156.0.1-beta.32, the release built from
main's sources, predates the marker. So every driver-only pull request would have
rebuilt the browser, the first merge would have cut a duplicate beta.33, and the
two scope tests in ci/tests/test_ci.py -- which ran the step in a scratch repo
where ci.release did not import -- failed.

find_paired falls back to the tag upstream.sh names when that release is
published (a prerelease counts; a draft does not) and no browser source changed
since, listing the files that did when they have. browser-plan and promote use
the same lookup. paired takes --root and --releases so the tests run the
workflow's own step against a scratch repo and a fixed release list.

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

* Release from one workflow, with trusted publishing

The release chain was four workflows linked by workflow_run, with the
tested commit carried between them as an artifact; a browser release
number committed beside main; pairing data in HTML comments in release
notes; a stored PyPI token; and packages rebuilt at each publish.

release.yml now does all of it with `needs`:

- On a push to main it calls tests.yml on the pushed commit (tests.yml
  loses its own push trigger), then builds the browser only when its
  sources changed, and publishes a library prerelease only when something
  a package ships changed. Docs- and CI-only merges publish nothing.
- A browser release's number lives only in its tag, which points at the
  tested main commit; `ci.release set-build` writes it into the build's
  working tree. Nothing is committed.
- Each browser release carries a manifest.json asset (source digest,
  commit), which is what a library pairs by. Builds are attested with
  actions/attest-build-provenance.
- Both packages are built once, in build-library, and the publish jobs
  upload exactly those files. PyPI and npm use trusted publishing; no
  credential is stored.
- A vX.Y.Z tag builds and checks both packages before promoting the
  paired browser and publishing.
- Every published library version is tagged (vX.Y.ZbN for a prerelease),
  which is how the next merge tells whether the library changed.
- A failed publish is retried with "Re-run failed jobs"; the retry-only
  workflow_dispatch path is gone.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 22:46:41 +00:00

177 lines
7.9 KiB
Python

#!/usr/bin/env python3
"""typescript gate: the npm package's own checks and test suite.
Two modes, two gates:
typescript (default) type check, lint, and the vitest suite. No
browser. Includes the golden tests that hold the TS
launcher to byte-for-byte parity with pythonlib, so a
pythonlib change that is not mirrored in typescript/
fails here, in tier 1, rather than after the build.
typescript_browser (--browser BINARY) the opt-in end-to-end suite: launches
the browser under test through the TS API and compares
what a page sees with what pythonlib's launch of the same
identity shows.
Run:
python3 -m ci.run_typescript
python3 -m ci.run_typescript --browser path/to/camoufox-bin
"""
from __future__ import annotations
import argparse
import os
import sys
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Dict, List, Optional
from . import results as evidence
from ._util import EVIDENCE_DIR, REPO_ROOT, WORK_DIR, run
TYPESCRIPT = REPO_ROOT / "typescript"
def parse_vitest_junit(path: Path) -> Dict[str, str]:
"""vitest junit XML -> {"tests/file.test.ts::suite > test": outcome}.
vitest puts the test file in `classname` and the describe path in `name`,
which already make a stable identity; pytest's dotted-module trimming in
ci/_pytest.py would mangle a `.test.ts` path.
"""
if not path.exists():
return {}
outcomes: Dict[str, str] = {}
for case in ET.parse(path).getroot().iter("testcase"):
tid = f"{case.get('classname', '')}::{case.get('name', '')}"
if case.find("error") is not None:
outcome = evidence.ERROR
elif case.find("failure") is not None:
outcome = evidence.FAIL
elif case.find("skipped") is not None:
outcome = evidence.SKIP
else:
outcome = evidence.PASS
if outcomes.get(tid) == evidence.PASS:
continue
outcomes[tid] = outcome
return outcomes
def main(argv: Optional[List[str]] = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--evidence-dir", type=Path, default=EVIDENCE_DIR)
parser.add_argument("--browser", type=Path, help="camoufox-bin for the end-to-end suite")
parser.add_argument("--python", type=Path, default=Path(sys.executable),
help="interpreter with pythonlib installed: the golden fixtures are "
"recorded from it, and the e2e suite compares against it")
parser.add_argument("--timeout", type=int, default=1800)
args = parser.parse_args(argv)
gate = "typescript_browser" if args.browser else "typescript"
result = evidence.GateResult(gate=gate)
if not (TYPESCRIPT / "package.json").is_file():
result.note("typescript/package.json does not exist")
result.finish(evidence.ERROR).save(args.evidence_dir)
return 1
env = dict(os.environ)
# tests/golden-setup.ts records the golden fixtures from this interpreter.
env["CAMOUFOX_PYTHON"] = str(args.python.absolute())
if args.browser:
env.update(
CAMOUFOX_E2E="1",
CAMOUFOX_EXECUTABLE=str(args.browser.resolve()),
CAMOUFOX_E2E_PYTHON=str(args.python.absolute()),
)
# Every test input must be something a checkout gets. A git-ignored file
# under src/, tests/ or scripts/ exists on the machine that made it and
# nowhere else, so the suite passes there and fails in CI -- which is how
# tests/fixtures/launch/ once went missing from a branch (an unanchored
# `launch` rule in the root .gitignore).
ignored = run(
["git", "ls-files", "--others", "--ignored", "--exclude-standard", "--directory",
"--", "typescript/src", "typescript/tests", "typescript/scripts"],
cwd=REPO_ROOT,
)
# The goldens tests/golden-setup.ts records are ignored on purpose, by
# typescript/tests/fixtures/.gitignore; anything another rule hides is stray.
candidates = [p for p in ignored.stdout.split() if "__pycache__" not in p]
rules = run(["git", "check-ignore", "--verbose", "--no-index", *candidates], cwd=REPO_ROOT) \
if candidates else None
generated = {
line.split("\t", 1)[1] for line in (rules.stdout.splitlines() if rules else [])
if line.startswith("typescript/tests/fixtures/.gitignore:")
}
stray = [p for p in candidates if p not in generated]
for path in stray:
result.note(f"git-ignored test input: {path}")
result.record("no test input is git-ignored", evidence.FAIL if stray else evidence.PASS)
install = run(["pnpm", "install", "--frozen-lockfile"], cwd=TYPESCRIPT, env=env,
timeout=600, tee=True, capture=False)
if not install.ok:
result.note(f"pnpm install exited {install.code}")
result.finish(evidence.ERROR).save(args.evidence_dir)
return 1
# Static checks are recorded as tests of their own, so the summary names
# which one failed instead of reporting a bare non-zero exit.
if not args.browser:
for script in ("typecheck", "check"):
proc = run(["pnpm", script], cwd=TYPESCRIPT, env=env, timeout=600, tee=True, capture=False)
result.record(f"pnpm {script}", evidence.PASS if proc.ok else evidence.FAIL)
# The tarball a user would install: builds, ships every data file, installs
# and imports in an empty project, and its CLI starts. release.yml runs
# the same check before uploading; running it here means a packaging mistake
# is caught on the pull request that makes it, not on release day.
if not args.browser:
build = run(["pnpm", "build"], cwd=TYPESCRIPT, env=env, timeout=600, tee=True, capture=False)
pack = build.ok and run(["node", "scripts/check-pack.mjs"], cwd=TYPESCRIPT, env=env,
timeout=900, tee=True, capture=False).ok
result.record("npm package (scripts/check-pack.mjs)", evidence.PASS if pack else evidence.FAIL)
junit = WORK_DIR / f"junit-{gate}.xml"
junit.parent.mkdir(parents=True, exist_ok=True)
# The browser gate runs the e2e file alone: the unit suite already ran in
# tier 1, and running it again here would need that job's prerequisites.
files = ["tests/e2e.test.ts"] if args.browser else []
proc = run(
["pnpm", "exec", "vitest", "run", "--config", "tests/vitest.config.ts",
"--reporter=default", "--reporter=junit", f"--outputFile.junit={junit}", *files],
cwd=TYPESCRIPT, env=env, timeout=args.timeout, tee=True, capture=False,
)
outcomes = parse_vitest_junit(junit)
if not outcomes:
result.note(f"vitest exited {proc.code} with no junit output; the suite did not run")
result.finish(evidence.ERROR).save(args.evidence_dir)
return 1
for tid, outcome in outcomes.items():
result.record(tid, outcome)
tally = result.tally()
result.artifacts.append(junit.name)
result.metrics["exit_code"] = proc.code
result.note(
f"{tally.get('pass', 0)} passed, {tally.get('fail', 0)} failed, "
f"{tally.get('error', 0)} errored, {tally.get('skip', 0)} skipped "
f"({tally.get('total', 0)} collected)"
)
failing = tally.get("fail", 0) + tally.get("error", 0)
e2e_passed = sum(1 for t, o in result.tests.items() if "e2e" in t and o == evidence.PASS)
result.metrics["e2e_passed"] = e2e_passed
if args.browser and e2e_passed == 0:
# An e2e run where every browser test skipped proved nothing.
result.note("no end-to-end test ran; CAMOUFOX_E2E did not take effect")
failing += 1
status = evidence.PASS if failing == 0 else evidence.FAIL
result.finish(status).save(args.evidence_dir)
return 0 if status == evidence.PASS else 1
if __name__ == "__main__":
sys.exit(main())