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>
195 lines
6.4 KiB
TypeScript
195 lines
6.4 KiB
TypeScript
import * as fs from "node:fs";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import { describe, expect, it } from "vitest";
|
|
import { FileNotFoundError } from "../src/exceptions.js";
|
|
import {
|
|
AvailableVersion,
|
|
formatAssetDate,
|
|
LOCAL_DATA,
|
|
loadYaml,
|
|
OS_ARCH_MATRIX,
|
|
OS_NAME,
|
|
pkgmanDeps,
|
|
RepoConfig,
|
|
Version,
|
|
} from "../src/pkgman.js";
|
|
|
|
describe("Version ordering", () => {
|
|
it("orders alpha < beta < numeric builds", () => {
|
|
const alpha = new Version("alpha.5");
|
|
const beta = new Version("beta.5");
|
|
expect(alpha.lessThan(beta)).toBe(true);
|
|
expect(beta.lessThan(alpha)).toBe(false);
|
|
});
|
|
|
|
it("orders numerically within a channel", () => {
|
|
expect(new Version("beta.9").lessThan(new Version("beta.20"))).toBe(true);
|
|
expect(new Version("beta.20").lessThan(new Version("beta.9"))).toBe(false);
|
|
});
|
|
|
|
it("treats equal builds as equal", () => {
|
|
expect(new Version("beta.20").equals(new Version("beta.20"))).toBe(true);
|
|
});
|
|
|
|
it("reports the full version string", () => {
|
|
expect(new Version("beta.28", "152.0.4").fullString).toBe(
|
|
"152.0.4-beta.28",
|
|
);
|
|
});
|
|
|
|
it("detects the alpha channel", () => {
|
|
expect(new Version("alpha.26").isAlpha).toBe(true);
|
|
expect(new Version("beta.26").isAlpha).toBe(false);
|
|
});
|
|
|
|
it("accepts builds inside the supported range", () => {
|
|
// CONSTRAINTS is alpha.1 <= v < 1, raised by the Playwright floor
|
|
// (beta.30 from Playwright 1.61).
|
|
const saved = pkgmanDeps.resolvedPlaywrightVersion;
|
|
try {
|
|
pkgmanDeps.resolvedPlaywrightVersion = () => [1, 60, 0];
|
|
expect(new Version("beta.28").isSupported()).toBe(true);
|
|
expect(new Version("alpha.1").isSupported()).toBe(true);
|
|
pkgmanDeps.resolvedPlaywrightVersion = () => [1, 62, 0];
|
|
expect(new Version("beta.28").isSupported()).toBe(false);
|
|
expect(new Version("beta.30").isSupported()).toBe(true);
|
|
expect(new Version("1").isSupported()).toBe(false);
|
|
} finally {
|
|
pkgmanDeps.resolvedPlaywrightVersion = saved;
|
|
}
|
|
});
|
|
|
|
it("reads version.json like the Python twin (release/tag win over build)", () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "camoufox-vj-"));
|
|
try {
|
|
const write = (data: object) =>
|
|
fs.writeFileSync(path.join(dir, "version.json"), JSON.stringify(data));
|
|
write({ version: "1.0", build: "beta.1" });
|
|
expect(Version.fromPath(dir).fullString).toBe("1.0-beta.1");
|
|
write({ version: "1.0", build: "beta.1", release: "beta.2" });
|
|
expect(Version.fromPath(dir).build).toBe("beta.2");
|
|
write({ version: "1.0", tag: "beta.3" });
|
|
expect(Version.fromPath(dir).build).toBe("beta.3");
|
|
fs.rmSync(path.join(dir, "version.json"));
|
|
expect(() => Version.fromPath(dir)).toThrow(FileNotFoundError);
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("RepoConfig", () => {
|
|
it("parses the comma-separated fallback repo list", () => {
|
|
const official = RepoConfig.findByName("Official");
|
|
expect(official).toBeDefined();
|
|
expect(official?.repos).toEqual(["daijro/camoufox", "camoufox/camoufox"]);
|
|
expect(official?.repo).toBe("daijro/camoufox");
|
|
});
|
|
|
|
it("defaults to the repo named in the config", () => {
|
|
expect(RepoConfig.getDefaultName()).toBe("Official");
|
|
expect(RepoConfig.getDefault().name).toBe("Official");
|
|
});
|
|
|
|
it("builds an asset regex that captures name/version/build", () => {
|
|
const config = RepoConfig.getDefault();
|
|
const pattern = config.buildPattern("lin", "x86_64");
|
|
const match = pattern.exec("camoufox-152.0.4-beta.28-lin.x86_64.zip");
|
|
expect(match?.groups?.version).toBe("152.0.4");
|
|
expect(match?.groups?.build).toBe("beta.28");
|
|
});
|
|
|
|
it("does not match another platform's asset", () => {
|
|
const pattern = RepoConfig.getDefault().buildPattern("lin", "x86_64");
|
|
expect(pattern.exec("camoufox-152.0.4-beta.28-win.x86_64.zip")).toBeNull();
|
|
});
|
|
|
|
it("applies the stable channel's build floor", () => {
|
|
const official = RepoConfig.getDefault();
|
|
// Official pins stable to beta.19+; prerelease is unbounded.
|
|
expect(official.isVersionSupported(new Version("beta.28"), false)).toBe(
|
|
true,
|
|
);
|
|
expect(official.isVersionSupported(new Version("beta.10"), false)).toBe(
|
|
false,
|
|
);
|
|
expect(official.isVersionSupported(new Version("alpha.1"), true)).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
it("treats a repo with no browser constraints as unbounded", () => {
|
|
const coryking = RepoConfig.findByName("CoryKing");
|
|
expect(coryking?.isVersionSupported(new Version("beta.1"), false)).toBe(
|
|
true,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("platform matrix", () => {
|
|
it("knows the arches the current OS ships", () => {
|
|
expect(OS_ARCH_MATRIX[OS_NAME].length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
describe("formatAssetDate", () => {
|
|
it("omits the year for the current year", () => {
|
|
const now = new Date("2026-08-02T00:00:00Z");
|
|
expect(formatAssetDate("2026-03-14T10:00:00Z", now)).toMatch(/^Mar 1[34]$/);
|
|
});
|
|
|
|
it("includes the year for another year", () => {
|
|
const now = new Date("2026-08-02T00:00:00Z");
|
|
expect(formatAssetDate("2024-03-14T10:00:00Z", now)).toMatch(
|
|
/^Mar 1[34], 2024$/,
|
|
);
|
|
});
|
|
|
|
it("returns empty for missing or unparseable input", () => {
|
|
expect(formatAssetDate(undefined)).toBe("");
|
|
expect(formatAssetDate("not-a-date")).toBe("");
|
|
});
|
|
});
|
|
|
|
describe("repos.yml", () => {
|
|
it("is the file the Python package ships", () => {
|
|
const shipped = fs.readFileSync(
|
|
path.join(import.meta.dirname, "../../pythonlib/camoufox/repos.yml"),
|
|
"utf-8",
|
|
);
|
|
const ours = fs.readFileSync(path.join(LOCAL_DATA, "repos.yml"), "utf-8");
|
|
expect(ours).toBe(shipped);
|
|
expect(loadYaml("repos.yml").default.browser).toBe("Official");
|
|
});
|
|
});
|
|
|
|
describe("AvailableVersion.toMetadata", () => {
|
|
it("writes unknown fields as null, as orjson does for None", () => {
|
|
const v = new AvailableVersion({
|
|
version: new Version("beta.30", "152.0.4"),
|
|
url: "u",
|
|
isPrerelease: false,
|
|
});
|
|
expect(JSON.stringify(v.toMetadata())).toBe(
|
|
'{"version":"152.0.4","build":"beta.30","prerelease":false,"asset_id":null,"asset_size":null,"asset_updated_at":null,"sha256":null,"created_at":null}',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("parseSemver", () => {
|
|
// Mirrors test_prerelease_library_versions_parse_as_their_release.
|
|
it.each([
|
|
["0.5.8", [0, 5, 8]],
|
|
["0.5.8b1", [0, 5, 8]],
|
|
["0.5.8rc2", [0, 5, 8]],
|
|
["0.5.8.dev3", [0, 5, 8]],
|
|
["0.5.8-beta.1", [0, 5, 8]],
|
|
["^0.5.0", [0, 5, 0]],
|
|
["1", [1, 0, 0]],
|
|
])("%s parses as its release", async (raw, expected) => {
|
|
const { parseSemver } = await import("../src/pkgman.js");
|
|
expect(parseSemver(raw)).toEqual(expected);
|
|
});
|
|
});
|