mirror of
https://github.com/daijro/camoufox.git
synced 2026-10-03 16: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>
253 lines
10 KiB
Python
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())
|