mirror of
https://github.com/daijro/camoufox.git
synced 2026-10-03 08:00:19 +00:00
* 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>
177 lines
7.9 KiB
Python
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())
|