Files
camoufox/ci/browser_inputs.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

253 lines
10 KiB
Python

#!/usr/bin/env python3
"""Split the browser's inputs into what needs a compiler and what does not.
Juggler is mostly JavaScript, and JavaScript does not need libxul relinked. But
a full build is what every change to `additions/` used to cost: 24 minutes, of
which ccache reported 98.63% hits -- so almost none of it was compiling C++. It
was Rust, linking, and packaging, none of which a `.js` file affects.
The saving is real and the trap is sharp, so this module is deliberately small
and deliberately paranoid.
**The hash IS the classification.** There is no "did only JS change?" diff here,
because a diff answers the wrong question -- it compares against the pull
request's base, while what matters is whether the cached browser was built from
the same native sources. So: hash every input that can change compiled output.
If that hash matches a cached browser, the compiled half is identical *by
construction*, whatever the diff says, and the resources can simply be laid over
it.
**Two things it would be easy to get wrong, and how they are closed:**
`additions/juggler/` is not all JavaScript. It also holds the screencast
encoder and the remote-debugging pipe -- 5 .cpp, 5 .h, 2 .idl, 3
components.conf, 4 moz.build -- which are compiled into libxul. Only the files
`jar.mn` actually lists as packaged resources are treated as resources.
Everything else, including anything with an extension nobody has thought about
yet, counts as native and forces a build. Fail closed.
The source-to-destination mapping is per-file, not a prefix rule. jar.mn maps
`TargetRegistry.js` to `content/TargetRegistry.js` (a level added),
`content/FrameTree.js` to `content/content/FrameTree.js` (preserved), and
`content/JugglerFrameChild.sys.mjs` to `content/JugglerFrameChild.sys.mjs` (a
level dropped). Two files in the same source directory land at different
depths. Assuming a prefix would write one of them to the wrong path, leave the
old copy in place, and run a browser with stale Juggler -- while every suite
reported green, because the browser works fine, it is just not the one under
review. So the mapping is read from jar.mn, never inferred.
jar.mn itself is native: it decides both the mapping and what is packaged at
all, so changing it must force a real build rather than a re-overlay.
Run:
python3 -m ci.browser_inputs --digest # the native-inputs hash
python3 -m ci.browser_inputs --list # what feeds that hash
python3 -m ci.browser_inputs --resources # source -> path in the dist
python3 -m ci.browser_inputs --overlay DIR # lay resources over a dist/bin
"""
from __future__ import annotations
import argparse
import hashlib
import re
import shutil
import sys
from pathlib import Path
from typing import Dict, List, Optional
REPO_ROOT = Path(__file__).resolve().parent.parent
# The same set `resolve` greps to decide whether the browser changed at all.
# Kept in step by ci/tests/test_ci.py, because a path that can change the binary
# and is not hashed here would be served a stale browser.
BROWSER_DIRS = ("patches", "additions", "settings", "assets", "scripts")
BROWSER_FILES = ("upstream.sh", "Makefile")
# Scripts under scripts/ that cannot change compiled output, and so must not
# invalidate a 665 MB cached browser.
#
# scripts/ holds the build machinery -- patch.py, copy-additions.sh, package.py
# -- so hashing the directory wholesale is the right default. It also holds
# tools that operate on the PYTHON package's data files and are never invoked
# by a build, and those cost an hour each time they are touched: editing
# clean-fingerprint-data.py, which rewrites pythonlib JSON, forced a full
# rebuild of a browser whose sources had not moved (measured 2026-09-17).
#
# Nothing is excluded on the grounds that it "looks unrelated". An entry here
# is checked by ci/tests/test_ci.py against the build's own entry points, so a
# script that IS reachable from a build cannot sit in this list: getting that
# wrong serves a stale binary to every suite downstream, which is far worse
# than an unnecessary rebuild.
NON_NATIVE_SCRIPTS = frozenset(
{
"scripts/clean-fingerprint-data.py",
"scripts/cursor-demo.py",
}
)
# Where a build can reach a script from. Used by the test above, not here.
BUILD_ENTRY_POINTS = (
"Makefile",
"multibuild.py",
"scripts/patch.py",
"scripts/package.py",
"scripts/copy-additions.sh",
"scripts/_mixin.py",
)
JUGGLER = Path("additions") / "juggler"
JAR_MN = JUGGLER / "jar.mn"
# `content/Helper.js (Helper.js)` -- destination first, source in parentheses.
_ENTRY = re.compile(r"^\s*(\S+)\s+\((\S+)\)\s*$")
# `% content juggler %content/` -- the chrome package this jar registers.
_PACKAGE = re.compile(r"^\s*%\s+content\s+(\S+)\s+%")
def jar_entries(root: Optional[Path] = None) -> Dict[str, str]:
"""{repo-relative source: path inside the built dist}, straight from jar.mn.
The dist path is `chrome/<package>/<destination>` -- observed directly
against a build: `content/Helper.js` in jar.mn is
`dist/bin/chrome/juggler/content/Helper.js` on disk.
"""
root = root or REPO_ROOT
text = (root / JAR_MN).read_text(encoding="utf-8")
package = "juggler"
for line in text.splitlines():
found = _PACKAGE.match(line)
if found:
package = found.group(1)
break
entries: Dict[str, str] = {}
for line in text.splitlines():
if line.lstrip().startswith("#") or line.lstrip().startswith("%"):
continue
found = _ENTRY.match(line)
if not found:
continue
destination, source = found.group(1), found.group(2)
entries[str(JUGGLER / source)] = f"chrome/{package}/{destination}"
return entries
def resource_sources(root: Optional[Path] = None) -> set:
"""Repo-relative paths that are packaged as-is and never compiled."""
return set(jar_entries(root))
def native_inputs(root: Optional[Path] = None) -> List[str]:
"""Every browser input that is not a packaged resource, sorted.
Anything unrecognised lands here rather than being skipped: a new file type
under additions/ forces a build until somebody decides otherwise, which is
the safe direction to be wrong in.
"""
root = root or REPO_ROOT
resources = resource_sources(root)
found: List[str] = []
for name in BROWSER_DIRS:
base = root / name
if not base.is_dir():
continue
for path in base.rglob("*"):
if not path.is_file():
continue
rel = str(path.relative_to(root))
if rel not in resources and rel not in NON_NATIVE_SCRIPTS:
found.append(rel)
for name in BROWSER_FILES:
if (root / name).is_file():
found.append(name)
return sorted(found)
def native_digest(root: Optional[Path] = None) -> str:
"""A hash of the compiled browser's inputs. Same hash, same binary."""
root = root or REPO_ROOT
digest = hashlib.sha256()
for rel in native_inputs(root):
digest.update(rel.encode("utf-8"))
digest.update(b"\0")
digest.update(hashlib.sha256((root / rel).read_bytes()).digest())
return digest.hexdigest()[:32]
def source_inputs(root: Optional[Path] = None) -> List[str]:
"""Every file that goes into the browser, compiled or packaged, sorted."""
root = root or REPO_ROOT
return sorted(set(native_inputs(root)) | resource_sources(root))
def source_digest(root: Optional[Path] = None) -> str:
"""A hash of everything the browser is built from, except its release number.
This is what pairs a library release with a browser release: two commits
with the same source digest produce the same browser, so a library built
from either may name that browser's release. The release number is left
out because the release workflow writes it into the build's working tree
from the release tag (ci/release.py set-build), and that must not make the
browser look different from the main commit it was built from.
"""
root = root or REPO_ROOT
digest = hashlib.sha256()
for rel in source_inputs(root):
data = (root / rel).read_bytes()
if rel == "upstream.sh":
data = b"\n".join(
line for line in data.splitlines() if not line.strip().startswith(b"release=")
)
digest.update(rel.encode("utf-8"))
digest.update(b"\0")
digest.update(hashlib.sha256(data).digest())
return digest.hexdigest()[:32]
def overlay(dist_bin: Path, root: Optional[Path] = None) -> List[str]:
"""Lay the current resources over an already-built dist/bin.
Every entry is written, not just the changed ones: the set is then always
exactly what jar.mn says, so a stale file cannot survive. (A resource
*removed* from jar.mn is handled by jar.mn being a native input -- that
forces a real build rather than an overlay.)
"""
root = root or REPO_ROOT
written = []
for source, destination in sorted(jar_entries(root).items()):
target = dist_bin / destination
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(root / source, target)
written.append(destination)
return written
def main(argv: Optional[List[str]] = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--digest", action="store_true")
parser.add_argument("--source-digest", action="store_true")
parser.add_argument("--list", action="store_true")
parser.add_argument("--resources", action="store_true")
parser.add_argument("--overlay", type=Path, metavar="DIST_BIN")
args = parser.parse_args(argv)
if args.digest:
print(native_digest())
if args.source_digest:
print(source_digest())
if args.list:
for rel in native_inputs():
print(rel)
if args.resources:
for source, destination in sorted(jar_entries().items()):
print(f"{source} -> {destination}")
if args.overlay:
written = overlay(args.overlay)
print(f"overlaid {len(written)} resource(s) onto {args.overlay}")
return 0
if __name__ == "__main__":
sys.exit(main())