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>
105 lines
4.3 KiB
JavaScript
105 lines
4.3 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* The npm twin of `twine check`: prove the tarball `npm publish` would upload is
|
|
* a working package before it goes anywhere.
|
|
*
|
|
* 1. its version equals pythonlib's (the two launchers ship in lockstep, and
|
|
* a user comparing `camoufox version` across them should see one number);
|
|
* 2. it carries every file src/ reads at runtime -- the DATA_FILES it takes
|
|
* from pythonlib and any non-TS file under src/ -- since a file missing
|
|
* only shows up on a user's machine, as an ENOENT from inside dist/;
|
|
* 3. installed into an empty project, it imports and exposes its entry
|
|
* points, and its CLI starts.
|
|
*
|
|
* Run after `pnpm build`: node scripts/check-pack.mjs
|
|
*/
|
|
import { execFileSync } from "node:child_process";
|
|
import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join, relative } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const root = fileURLToPath(new URL("..", import.meta.url));
|
|
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
|
const problems = [];
|
|
|
|
// 1. version lockstep with pythonlib
|
|
const pyproject = readFileSync(join(root, "..", "pythonlib", "pyproject.toml"), "utf8");
|
|
const pyVersion = pyproject.match(/^version\s*=\s*"([^"]+)"/m)?.[1];
|
|
// One release, spelled for each registry: npm's 0.5.8-beta.2 is PyPI's 0.5.8b2.
|
|
const asPep440 = (v) => v.replace(/-beta\.(\d+)$/, "b$1");
|
|
if (pyVersion !== asPep440(pkg.version)) {
|
|
problems.push(`package.json version ${pkg.version} != pythonlib ${pyVersion}`);
|
|
}
|
|
if (pkg.private) problems.push("package.json is private: npm will refuse to publish it");
|
|
|
|
// 2. every runtime data file is in the tarball
|
|
// npm 10 still runs `prepare` (the build) on pack despite --ignore-scripts, and
|
|
// its banner lands on stdout ahead of the JSON.
|
|
const packOut = execFileSync("npm", ["pack", "--dry-run", "--json", "--ignore-scripts"], {
|
|
cwd: root,
|
|
encoding: "utf8",
|
|
});
|
|
const packed = JSON.parse(packOut.slice(packOut.search(/^\[/m)))[0];
|
|
const inTarball = new Set(packed.files.map((f) => f.path));
|
|
function walk(dir) {
|
|
return readdirSync(dir, { withFileTypes: true }).flatMap((e) =>
|
|
e.isDirectory() ? walk(join(dir, e.name)) : [join(dir, e.name)],
|
|
);
|
|
}
|
|
const src = join(root, "src");
|
|
for (const file of walk(src)) {
|
|
const rel = relative(src, file);
|
|
if (/\.ts$/.test(rel)) {
|
|
const js = `dist/${rel.replace(/\.ts$/, ".js")}`;
|
|
if (!rel.endsWith(".d.ts") && !inTarball.has(js)) problems.push(`missing ${js}`);
|
|
} else if (!inTarball.has(`dist/${rel}`)) {
|
|
problems.push(`missing dist/${rel} (a non-TS file under src/ that copy-files does not ship)`);
|
|
}
|
|
}
|
|
const { DATA_FILES } = await import(join(root, "dist", "paths.js"));
|
|
for (const name of DATA_FILES) {
|
|
if (!inTarball.has(`dist/data-files/${name}`)) problems.push(`missing dist/data-files/${name}`);
|
|
}
|
|
console.log(`${packed.filename}: ${packed.entryCount} files, ${(packed.size / 1e6).toFixed(1)} MB packed`);
|
|
|
|
// 3. installs and imports in a clean project
|
|
if (problems.length === 0) {
|
|
const tmp = mkdtempSync(join(tmpdir(), "camoufox-pack-"));
|
|
try {
|
|
const tgz = execFileSync("npm", ["pack", "--ignore-scripts", "--pack-destination", tmp], {
|
|
cwd: root,
|
|
encoding: "utf8",
|
|
})
|
|
.trim()
|
|
.split("\n")
|
|
.pop();
|
|
writeFileSync(join(tmp, "package.json"), '{"name":"pack-check","private":true,"type":"module"}');
|
|
execFileSync("npm", ["install", "--no-audit", "--no-fund", join(tmp, tgz), `playwright-core@${pkg.peerDependencies["playwright-core"]}`], {
|
|
cwd: tmp,
|
|
stdio: "inherit",
|
|
});
|
|
const probe = `
|
|
const m = await import(${JSON.stringify(pkg.name)});
|
|
for (const name of ["Camoufox", "NewBrowser", "launchOptions"]) {
|
|
if (typeof m[name] !== "function") throw new Error("missing export " + name);
|
|
}
|
|
console.log("exports:", Object.keys(m).length);
|
|
`;
|
|
execFileSync("node", ["--input-type=module", "-e", probe], { cwd: tmp, stdio: "inherit" });
|
|
for (const bin of Object.keys(pkg.bin ?? {})) {
|
|
execFileSync("npx", ["--no-install", bin, "--help"], { cwd: tmp, stdio: "ignore" });
|
|
}
|
|
} catch (err) {
|
|
problems.push(`clean install failed: ${err.message}`);
|
|
} finally {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
if (problems.length) {
|
|
console.error(problems.map((p) => ` - ${p}`).join("\n"));
|
|
process.exit(1);
|
|
}
|
|
console.log("pack check OK");
|