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>
This commit is contained in:
Jake Writer
2026-09-29 22:46:41 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 0cb8c60bb9
commit 11969fa44a
29 changed files with 2243 additions and 470 deletions
+23 -7
View File
@@ -125,9 +125,19 @@ Synced 26 versions from 2 repos.
<hr width=50>
### Which browser build is used
Each camoufox release is paired with the one browser build it was built and tested with. By default, `camoufox fetch` installs exactly that build and every launch uses it. That holds even when other builds are installed, and even when the paired build is a prerelease (a prerelease of this package pairs with a prerelease browser). Upgrading the package therefore never runs a browser it was not tested with. Run `camoufox fetch` after upgrading to install the new pairing.
Choosing a channel or a build with `camoufox set` overrides the pairing. The choice is kept, and a launch warns that the build differs from the paired one. `camoufox set --release` goes back to the paired build.
A development checkout (installed from the repository, not from PyPI) is paired with nothing and follows its channel, `official/stable` by default.
<hr width=50>
### `set`
Choose a version channel or pin a specific version. Can also be called with a specifier to activate directly.
Choose a version channel or pin a specific version, overriding the paired build. Can also be called with a specifier to activate directly.
Interactive selector:
@@ -135,10 +145,10 @@ Interactive selector:
> camoufox set
```
You can also pass a specifier to pin a specific version or choose a channel to follow directly. This will pull the latest stable version from the official repo on `camoufox fetch`.
You can also pass a specifier to pin a specific version or choose a channel to follow directly. Following `official/stable` pulls the latest stable version from the official repo on `camoufox fetch`:
```bash
> camoufox set official/stable # Default setting
> camoufox set official/stable
```
Follow latest prerelease version from the official repo, if applicable:
@@ -153,6 +163,12 @@ Pin a specific version:
> camoufox set official/stable/134.0.2-beta.20
```
Go back to the build this release is paired with:
```bash
> camoufox set --release
```
<hr width=50>
### `active`
@@ -160,8 +176,8 @@ Pin a specific version:
Prints the current active version string:
```bash
> camoufox active # Default channel is active
official/stable
> camoufox active # A released package uses its paired build by default
official/prerelease/156.0.1-beta.33 (1a2b3c4d) (paired with this release)
```
```bash
@@ -177,10 +193,10 @@ coryking/stable/142.0.1-fork.26 (not installed)
### `fetch`
Install the latest version from the active channel. By default, this is official/stable. This will also automatically sync repository assets.
Install the browser build this release is paired with, or, after `camoufox set`, the latest version from the chosen channel. This will also automatically sync repository assets.
```bash
> camoufox fetch # Install the latest in the channel
> camoufox fetch # Install the paired build (or the latest in the chosen channel)
```
To download the latest from a different channel, or pin a version:
+46 -2
View File
@@ -38,6 +38,7 @@ from .multiversion import (
save_config,
save_repo_cache,
)
from .browser_pin import effective_pin, load_pin
from .pkgman import (
INSTALL_DIR,
AvailableVersion,
@@ -268,6 +269,7 @@ def fetch(version):
repo_name = None
repo_data = None
ver_data = None
paired = False
if version:
parts = version.lower().split("/")
if len(parts) == 1:
@@ -286,6 +288,17 @@ def fetch(version):
repo_data = _repo_data(cache, repo_name)
if repo_data is not None:
ver_data = _pin_target(repo_data, config["pinned"], config.get("pinned_sha"))
elif pin := effective_pin(config):
# This library release pairs with exactly one browser build.
repo_name = pin.repo_name
repo_data = _repo_data(cache, repo_name)
if repo_data is not None:
ver_data = _pin_target(repo_data, pin.spec, None)
if ver_data is None:
rprint(f"{pin.tag}, the browser this camoufox release pairs with, is not in "
f"{repo_name}'s releases. Run 'camoufox sync' and try again.", fg="red")
return
paired = True
else:
channel = config.get("channel") or get_default_channel()
repo_name, ctype = (channel.split("/", 1) + ["stable"])[:2]
@@ -312,7 +325,11 @@ def fetch(version):
)
repo_config = RepoConfig.find_by_name(repo_data["name"])
try:
CamoufoxUpdate(repo_config=repo_config, selected_version=selected).update()
# The paired build is what this release was tested with; installing it
# needs no "this is a prerelease" confirmation even when it is one.
CamoufoxUpdate(repo_config=repo_config, selected_version=selected).update(
i_know_what_im_doing=paired
)
except Exception as e:
msg = str(e)
if "404" in msg or "Not Found" in msg:
@@ -426,7 +443,11 @@ def _set_pinned(repo_name: str, channel_type: str, ver_data: dict, inst, sha: Op
@cli.command(name="set")
@click.argument("specifier", required=False)
@click.option("--geoip", is_flag=True, help="Select GeoIP source instead")
def set_cmd(specifier, geoip):
@click.option(
"--release", "paired", is_flag=True,
help="Forget any explicit choice and use the browser this camoufox release pairs with",
)
def set_cmd(specifier, geoip, paired):
"""
\b
Set the active Camoufox version to use & fetch.
@@ -441,6 +462,19 @@ def set_cmd(specifier, geoip):
_select_geoip_source()
return
if paired:
pin = load_pin()
if pin is None:
rprint("This camoufox is a development copy; it pairs with no particular browser.", fg="yellow")
return
config = load_config()
for key in ("channel", "pinned", "pinned_sha", "active_version"):
config.pop(key, None)
save_config(config)
click.secho(f"Using the paired browser: {pin.repo_name} {pin.spec} ({pin.tag})", fg="green")
click.secho("Run 'camoufox fetch' if it is not installed.", fg="yellow")
return
if specifier:
parts = specifier.lower().split("/")
@@ -1020,6 +1054,16 @@ def active_cmd():
sha8 = (v.sha256 or "")[:8]
return f"{v.channel_path} ({sha8})" if sha8 else v.channel_path
pin = effective_pin(config)
if pin:
target = _find_installed(f"{pin.repo_name}/{pin.spec}")
if target:
click.echo(f"{_label(target)} (paired with this release)")
else:
click.echo(f"{pin.repo_name}/{pin.spec} (paired with this release) ", nl=False)
rprint("(not fetched)", fg="yellow")
return
if pinned:
pinned_sha = config.get("pinned_sha")
if pinned_sha:
+3 -1
View File
@@ -180,7 +180,9 @@ async def _launch(
async def _resolve_proxy_geo(proxy: Dict[str, str]) -> Tuple[str, str]:
"""The proxy's exit IP and timezone, looked up off the event loop."""
return await asyncio.to_thread(proxy_exit_geo, Proxy(**proxy).as_string())
# run_in_executor rather than asyncio.to_thread, which needs Python 3.9.
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, proxy_exit_geo, Proxy(**proxy).as_string())
async def AsyncNewContext(
+1
View File
@@ -0,0 +1 @@
{}
+99
View File
@@ -0,0 +1,99 @@
"""
The browser build this copy of the library was released with.
Every published camoufox package (PyPI and npm) is stamped at release time with
the one browser release built from the same sources: `browser-pin.json` names
it. By default the library fetches and launches exactly that build, so a
library upgrade can never silently run against a browser whose Juggler, prefs
or patches it was not tested with -- and a new browser release can never be
picked up by an older library.
A user who explicitly chooses something else (`camoufox set official/stable`,
`camoufox set 156.0.1-beta.33`, ...) keeps that choice; launching then warns
that the build differs from the one this library was released with.
A development checkout carries an empty pin (`{}`): nothing is pinned and the
channel logic applies unchanged, because there is no release to pair with yet.
The release workflow writes the real file (ci/release.py stamp).
"""
import warnings
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, Optional
import orjson
PIN_FILE = Path(__file__).parent / "browser-pin.json"
@dataclass(frozen=True)
class BrowserPin:
tag: str
repo: str
repo_name: str
version: str
build: str
@property
def spec(self) -> str:
"""`<firefox version>-<build>`, the form `camoufox set` and the cache use."""
return f"{self.version}-{self.build}"
def load_pin(path: Optional[Path] = None) -> Optional[BrowserPin]:
"""The stamped pin, or None for an unpinned (development) copy."""
try:
data: Dict[str, Any] = orjson.loads((path or PIN_FILE).read_bytes())
except (FileNotFoundError, orjson.JSONDecodeError):
return None
if not data or not data.get("tag"):
return None
return BrowserPin(
tag=data["tag"],
repo=data["repo"],
repo_name=data["repo_name"].lower(),
version=data["version"],
build=data["build"],
)
def is_explicit_choice(config: Dict[str, Any]) -> bool:
"""Whether the user chose a channel or a build themselves."""
return bool(config.get("channel") or config.get("pinned"))
def effective_pin(config: Optional[Dict[str, Any]] = None) -> Optional[BrowserPin]:
"""The package pin, unless the user explicitly chose otherwise."""
pin = load_pin()
if pin is None:
return None
if config is None:
from .multiversion import load_config
config = load_config()
return None if is_explicit_choice(config) else pin
def matches(pin: BrowserPin, repo_name: str, version: str, build: str) -> bool:
return (repo_name.lower(), version, build) == (pin.repo_name, pin.version, pin.build)
_warned = False
def warn_if_unpaired(repo_name: str, version: str, build: str) -> None:
"""Warn once when an explicitly chosen build is not the one this library pairs with."""
global _warned
pin = load_pin()
if _warned or pin is None or matches(pin, repo_name, version, build):
return
_warned = True
warnings.warn(
f"Launching {repo_name.lower()} {version}-{build}, which you selected explicitly. "
f"This camoufox release was built and tested with {pin.repo_name} {pin.spec} "
f"({pin.tag}); other builds may not be compatible. "
"Run `camoufox set --release` to go back to the paired build.",
RuntimeWarning,
stacklevel=3,
)
+14
View File
@@ -323,6 +323,20 @@ def get_active_path() -> Optional[Path]:
config = load_config()
active = config.get('active_version')
# A released library launches the build it was released with, whatever
# happens to be marked active, unless the user explicitly chose otherwise.
from .browser_pin import effective_pin, matches
pin = effective_pin(config)
if pin:
for inst in list_installed():
if matches(pin, inst.repo_name, inst.version.version or '', inst.version.build):
if active != inst.relative_path:
config['active_version'] = inst.relative_path
save_config(config)
return inst.path
return None
if active:
path = INSTALL_DIR / active
if path.exists() and (path / 'version.json').exists():
+40 -22
View File
@@ -128,6 +128,10 @@ def _parse_semver(version: str) -> Tuple[int, ...]:
Parse a semver string into a comparable tuple
"""
version = version.lstrip('^~')
# A prerelease compares as its release: 0.5.8b1 (PEP 440) and 0.5.8-beta.1
# (semver) are both 0.5.8 -- not 0.5.0, which is what splitting on '.'
# alone made of them.
version = re.split(r'(?<=\d)(?:[-+]|(?:a|b|rc|dev|\.dev|\.post)\d*)', version, maxsplit=1)[0]
parts = []
for part in version.split('.'):
try:
@@ -580,8 +584,15 @@ class CamoufoxFetcher(GitHubDownloader):
return None
version = Version(build=match['build'], version=match['version'])
# A released library installs exactly the browser it was released with
# (browser_pin.py), unless the user explicitly chose something else.
from .browser_pin import effective_pin, matches
pin = effective_pin()
if pin and not matches(pin, self.repo_config.name, match['version'], match['build']):
return None
is_prerelease = bool(release and release.get('prerelease')) or version.is_alpha
if not self.repo_config.is_version_supported(version, is_prerelease):
if not pin and not self.repo_config.is_version_supported(version, is_prerelease):
return None
digest = asset.get('digest') or ''
@@ -732,6 +743,22 @@ def list_available_versions(
return versions
def _not_installed_message() -> str:
"""What is missing -- the paired build, a pin, or a channel -- and how to get it."""
from .browser_pin import effective_pin
from .multiversion import get_default_channel, load_config
config = load_config()
pin = effective_pin(config)
pinned = config.get("pinned")
channel = config.get("channel") or get_default_channel()
if pin:
missing = f"{pin.repo_name} {pin.spec}, the browser this camoufox release pairs with,"
else:
missing = f"{channel}/{pinned}" if pinned else channel
return f"{missing} is not installed. Please run `camoufox fetch` to install."
def installed_verstr() -> str:
"""
Get the full version string of the active install
@@ -740,16 +767,13 @@ def installed_verstr() -> str:
active = get_active_path()
if active is None:
from .multiversion import get_default_channel, load_config
raise CamoufoxNotInstalled(_not_installed_message())
version = Version.from_path(active)
if active.parent.parent.name == "browsers":
from .browser_pin import warn_if_unpaired
config = load_config()
pinned = config.get("pinned")
channel = config.get("channel") or get_default_channel()
active_display = f"{channel}/{pinned}" if pinned else channel
raise CamoufoxNotInstalled(
f"{active_display} is not installed. " f"Please run `camoufox fetch` to install."
)
return Version.from_path(active).full_string
warn_if_unpaired(active.parent.name, version.version or "", version.build)
return version.full_string
def _root_install_supported() -> bool:
@@ -787,24 +811,18 @@ def camoufox_path(download_if_missing: bool = True) -> Path:
if not os.path.exists(INSTALL_DIR) or not os.listdir(INSTALL_DIR):
if not download_if_missing:
from .multiversion import load_config, get_default_channel
config = load_config()
pinned = config.get("pinned")
channel = config.get("channel") or get_default_channel()
if pinned:
active_display = f"{channel}/{pinned}"
else:
active_display = channel
raise CamoufoxNotInstalled(
f"{active_display} is not installed. " f"Please run `camoufox fetch` to install."
)
raise CamoufoxNotInstalled(_not_installed_message())
elif os.path.exists(INSTALL_DIR) and _root_install_supported():
return INSTALL_DIR
else:
if not download_if_missing:
from .browser_pin import effective_pin
# Other builds are installed, but not the one this release pairs with.
if effective_pin():
raise CamoufoxNotInstalled(_not_installed_message())
raise UnsupportedVersion("Camoufox executable is outdated.")
CamoufoxFetcher().install()
+146
View File
@@ -0,0 +1,146 @@
"""A released library runs the browser it was released with, and nothing else by default.
Each published package is stamped with `browser-pin.json` naming the browser
release built from the same sources. These tests hold the three places that
decide which build is used -- the active install at launch, the asset the
fetcher accepts, and the explicit-choice escape hatch -- to that pairing.
"""
import json
import warnings
import pytest
from camoufox import browser_pin, multiversion, pkgman
from camoufox.exceptions import CamoufoxNotInstalled
PIN = {
"tag": "v156.0.1-beta.33",
"repo": "daijro/camoufox",
"repo_name": "Official",
"version": "156.0.1",
"build": "beta.33",
}
def _setup(tmp_path, monkeypatch, *, pin, installed, config):
root = tmp_path / "cache"
root.mkdir()
(root / ".0.5_FLAG").write_text("")
(root / "repo_cache.json").write_text("{}")
(root / "config.json").write_text(json.dumps(config))
for spec in installed:
version, build = spec.split("-", 1)
d = root / "browsers" / "official" / spec
d.mkdir(parents=True)
(d / "version.json").write_text(json.dumps({"version": version, "build": build}))
pin_file = tmp_path / "browser-pin.json"
pin_file.write_text(json.dumps(pin))
monkeypatch.setattr(browser_pin, "PIN_FILE", pin_file)
monkeypatch.setattr(browser_pin, "_warned", False)
for module in (pkgman, multiversion):
monkeypatch.setattr(module, "INSTALL_DIR", root)
monkeypatch.setattr(multiversion, "BROWSERS_DIR", root / "browsers")
monkeypatch.setattr(multiversion, "CONFIG_FILE", root / "config.json")
monkeypatch.setattr(multiversion, "COMPAT_FLAG", root / ".0.5_FLAG")
return root
@pytest.mark.parametrize("content", ["{}", '{"tag": null}', "", "not json"])
def test_an_empty_or_missing_pin_pins_nothing(tmp_path, content):
f = tmp_path / "browser-pin.json"
f.write_text(content)
assert browser_pin.load_pin(f) is None
assert browser_pin.load_pin(tmp_path / "absent.json") is None
def test_the_checked_in_pin_is_empty():
"""main pins nothing; only the release workflow writes a real pin."""
assert json.loads(browser_pin.PIN_FILE.read_text()) == {}
def test_launch_uses_the_paired_build_even_when_another_is_marked_active(tmp_path, monkeypatch):
root = _setup(
tmp_path, monkeypatch, pin=PIN,
installed=["156.0.1-beta.33", "157.0-beta.34"],
config={"active_version": "browsers/official/157.0-beta.34"},
)
assert multiversion.get_active_path() == root / "browsers/official/156.0.1-beta.33"
assert pkgman.installed_verstr() == "156.0.1-beta.33"
def test_a_newer_build_is_not_used_when_the_paired_one_is_missing(tmp_path, monkeypatch):
_setup(tmp_path, monkeypatch, pin=PIN, installed=["157.0-beta.34"], config={})
assert multiversion.get_active_path() is None
with pytest.raises(CamoufoxNotInstalled, match="156.0.1-beta.33"):
pkgman.installed_verstr()
def test_an_explicit_choice_is_kept_and_warned_about(tmp_path, monkeypatch):
root = _setup(
tmp_path, monkeypatch, pin=PIN,
installed=["156.0.1-beta.33", "157.0-beta.34"],
config={"channel": "official/stable", "active_version": "browsers/official/157.0-beta.34"},
)
assert multiversion.get_active_path() == root / "browsers/official/157.0-beta.34"
with pytest.warns(RuntimeWarning, match="v156.0.1-beta.33"):
assert pkgman.installed_verstr() == "157.0-beta.34"
with warnings.catch_warnings():
warnings.simplefilter("error")
pkgman.installed_verstr() # once per process, not per launch
def test_an_explicit_choice_of_the_paired_build_does_not_warn(tmp_path, monkeypatch):
_setup(
tmp_path, monkeypatch, pin=PIN, installed=["156.0.1-beta.33"],
config={"pinned": "156.0.1-beta.33", "channel": "official/prerelease",
"active_version": "browsers/official/156.0.1-beta.33"},
)
with warnings.catch_warnings():
warnings.simplefilter("error")
assert pkgman.installed_verstr() == "156.0.1-beta.33"
def test_without_a_pin_the_channel_logic_is_unchanged(tmp_path, monkeypatch):
root = _setup(
tmp_path, monkeypatch, pin={}, installed=["156.0.1-beta.33", "157.0-beta.34"],
config={"active_version": "browsers/official/157.0-beta.34"},
)
assert multiversion.get_active_path() == root / "browsers/official/157.0-beta.34"
@pytest.mark.parametrize("config,accepts_newer", [({}, False), ({"channel": "official/stable"}, True)])
def test_the_fetcher_accepts_only_the_paired_asset_by_default(tmp_path, monkeypatch, config, accepts_newer):
_setup(tmp_path, monkeypatch, pin=PIN, installed=[], config=config)
fetcher = pkgman.CamoufoxFetcher.__new__(pkgman.CamoufoxFetcher)
fetcher.repo_config = pkgman.RepoConfig.get_default()
fetcher.pattern = fetcher.repo_config.build_pattern(spoof_os="lin", spoof_arch="x86_64")
fetcher.installed_sha256 = None
fetcher.installed_created_at = None
def asset(version, build):
return {"name": f"camoufox-{version}-{build}-lin.x86_64.zip",
"browser_download_url": f"https://example.invalid/{version}-{build}.zip"}
assert fetcher.check_asset(asset("156.0.1", "beta.33"), {"prerelease": True}) is not None
newer = fetcher.check_asset(asset("157.0", "beta.34"), {"prerelease": False})
assert (newer is not None) is accepts_newer
@pytest.mark.parametrize("raw,expected", [
("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)),
])
def test_prerelease_library_versions_parse_as_their_release(raw, expected):
assert pkgman._parse_semver(raw) == expected
def test_a_missing_paired_build_is_reported_as_not_installed_not_outdated(tmp_path, monkeypatch):
_setup(tmp_path, monkeypatch, pin=PIN, installed=["157.0-beta.34"], config={})
with pytest.raises(CamoufoxNotInstalled, match="pairs with"):
pkgman.camoufox_path(download_if_missing=False)