Files
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

7.5 KiB

camoufox (TypeScript)

This is the JavaScript/TypeScript client for Camoufox. It is a port of the Python wrapper in ../pythonlib — it does not shell out to Python.

The two launchers are twins: they read the same properties.json, write the same chunked CAMOU_CONFIG, share the same browser install directory, and ship the same fingerprint presets, font/voice lists and GeoIP configuration. They draw the same identities too: fingerprints and WebGL devices come from a TypeScript port of fpgen using the same pinned model, and the per-identity draws (fonts, voices, GPU, media devices, noise seeds) use a bit-exact port of CPython's random, so a pinned identity presents identically from either language.

Installation

npm install @camoufox/camoufox playwright-core
# then download the browser
npx camoufox fetch

playwright-core is a peer dependency — bring your own version (<1.63, the same ceiling as the Python package). Node 22.15 or newer is required.

Usage

import { Camoufox } from "@camoufox/camoufox";

const browser = await Camoufox({
    // any Camoufox option, plus any Playwright Firefox launch option
    headless: true,
    os: "windows",
    geoip: true,
});

const page = await browser.newPage(); // a Playwright Page
await page.goto("https://example.com");
await browser.close();

Persistent profiles

const context = await Camoufox({ user_data_dir: "./profiles/alice" });
const page = await context.newPage();

Per-context identities

NewContext() gives each context its own fingerprint — real preset or fpgen-synthesised — with its own audio noise seed. The values are applied through addInitScript, so the setters self-destruct before any page script runs.

import { Camoufox, NewContext } from "@camoufox/camoufox";

const browser = await Camoufox({ headless: true });
const context = await NewContext(browser, {
    os: "macos",
    proxy: { server: "http://proxy:8080", username: "u", password: "p" },
});

When a proxy is given and no webrtc_ip/timezoneId is, both are resolved from the proxy's exit IP. If that lookup fails, NewContext() throws InvalidIP rather than open a context that would show the host's values.

Server mode

import { launchServer } from "@camoufox/camoufox";

const server = await launchServer({ headless: true, port: 9222 });
console.log(server.wsEndpoint());

Persistent contexts are not servable — Playwright's launchServer can only expose a pre-launched Browser.

Building launch options yourself

import { launchOptions } from "@camoufox/camoufox";
import { firefox } from "playwright-core";

const browser = await firefox.launch(await launchOptions({ os: "linux" }));

Options

Every option from the Python launch_options() is supported, with the same snake_case names: os, config, block_images, block_webrtc, block_webgl, disable_coop, webgl_config, geoip, geoip_db, humanize, locale, addons, fonts, custom_fonts_only, exclude_addons, screen, window, fingerprint, fingerprint_preset, ff_version, headless, main_world_eval, allow_addon_new_tab, executable_path, browser, firefox_user_prefs, proxy, enable_cache, args, env, i_know_what_im_doing, debug, virtual_display, pin_cpu_cores. Anything else is passed straight through to Playwright.

The returned launch options use Playwright's camelCase keys (executablePath, firefoxUserPrefs) rather than Python's snake_case. As in Python, headless: "virtual" is handled by Camoufox(), NewBrowser() and launchServer(), not by launchOptions().

CLI

camoufox sync                     # refresh the version catalogue
camoufox fetch [version]          # install the paired (or chosen) build, or a specific version
camoufox set [specifier]          # pin a version or channel; no specifier opens a picker
camoufox set --release            # go back to the build this release is paired with
camoufox set --geoip              # pick a GeoIP source
camoufox list [installed|all]     # list versions
camoufox remove [version]         # remove one version, or everything (--select to pick)
camoufox active                   # print the active version
camoufox path                     # print the install directory
camoufox version                  # version / storage info
camoufox test [url]               # open the Playwright inspector
camoufox server                   # launch a Playwright server

Each release of this package is paired with the one browser build it was built and tested with, the same build as the camoufox Python release of the same version. The first launch installs that build, and every launch uses it, until you choose another with camoufox set. A launch then warns that the build differs from the paired one. See the Python package's README, under "Which browser build is used".

The commands and pickers match the Python CLI. The one exception is gui, a PySide6 desktop app that only the Python package provides.

Development

The tests need a Python with pythonlib next to them, at the repo root (or point CAMOUFOX_PYTHON at one):

python3.14 -m venv .venv                                           # repo root
.venv/bin/pip install -r ci/requirements.txt -e pythonlib
.venv/bin/python scripts/pin-fpgen-model.py
cd typescript
pnpm install
pnpm build       # tsc -> dist/, then copy pythonlib's data files into dist/data-files
pnpm test        # records the golden fixtures from pythonlib, then vitest
pnpm check       # biome lint + format
pnpm typecheck   # tsc --noEmit

The data files (presets, fonts, voices, territoryInfo.xml, ...) are read from pythonlib/camoufox/, the only copy in the repo; DATA_FILES in src/paths.ts lists them, and the build copies them into the package.

Parity with pythonlib

The golden tests are what keep the two launchers twins. Before the suite runs, tests/golden-setup.ts runs the scripts under scripts/golden/, which put the Python code through hundreds of fixed inputs and record its output in tests/fixtures/ (git-ignored); the TS tests must reproduce it exactly -- launch_options() byte for byte, including the CAMOU_CONFIG blob. A pythonlib change that is not mirrored here fails pnpm test. Use Python 3.14: pySum() follows its sum(), and on 3.12/3.13 that one test skips.

The end-to-end suite launches a real browser through both launchers and compares what a page sees:

CAMOUFOX_E2E=1 CAMOUFOX_EXECUTABLE=/path/to/camoufox-bin pnpm test tests/e2e.test.ts

Releasing

The npm package and pythonlib are released together by .github/workflows/release.yml: a prerelease under the next dist-tag for every tested merge to main that changes the package, and a stable release under latest for a vX.Y.Z tag (see ci/README.md). One job builds both packages and runs scripts/check-pack.mjs (the version must equal pythonlib's; every data file must be in the tarball; the tarball must install and import in an empty project); PyPI gets its upload first, then npm gets that same tarball, published with trusted publishing (no token is stored).

Licence

MIT, like the Python package (LICENSE); the browser itself is MPL-2.0. The package contains ports of fpgen (Apache-2.0), CPython's random and NumPy's pairwise summation; their notices are in THIRD_PARTY_NOTICES.md, which ships with it.