From 78a9364db66e3fc5f8fd54e83e49b934e503dc7f Mon Sep 17 00:00:00 2001 From: Ali Khokhar Date: Fri, 25 Sep 2026 02:27:31 -0700 Subject: [PATCH] Publish FCC releases to PyPI and enforce version policy (#1896) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why FCC currently installs from GitHub's moving main archive. Publish versioned packages to PyPI after merge and enforce which changes require a release so the installer can use published versions consistently. ## How - Require exactly one patch, minor, or major increment from the target branch for changes under `assets/`, `scripts/`, or `src/`, or to `.python-version`, `pyproject.toml`, or `uv.lock`. Forbid version changes for other-only PRs. Ignore only FCC's own version values when classifying metadata changes, and reject stale, skipped, noncanonical, or mismatched versions. Minor and major increments reset all lower components to zero. Multiple commits in a PR share one version bump. - Run the native Windows Muse lifecycle tests only on Windows, preserving both Windows PowerShell and PowerShell 7 coverage. - Fix browser-test synchronization: hold only the intended stale provider response, and wait for account-status route capture before releasing its response. - Run required `Version policy` in normal pull-request CI, including this PR. Keep strict up-to-date checks and FCC 6.2.64. Accepted scope: the check catches version mistakes. Changes to the checker or CI itself are reviewed like other repository code, rather than adding tamper-resistant enforcement. - Move dependency installation into the shared CI setup. Cache-warming and publishing jobs opt out, preserving their dedicated behavior. - Rename the cache workflow to `post-merge.yml`. Its three-platform cache matrix and independent Linux publishing job share one workflow queue with cancellation disabled. GitHub orders queued runs by arrival at the concurrency group. - Build and validate the exact merged commit's wheel and source archive, then publish changed versions through uv Trusted Publishing using the main-only `pypi` environment. Documentation-only merges skip publication. Rerun a failed release's original workflow to retry that commit. - Replace the installers' GitHub ZIP requirement with the PyPI package name, retaining force reinstall, package refresh, Python selection, voice extras, and backend options. Existing `fcc-update` commands already invoke these installers. - Accepted rollout choice: installations can briefly fail after this first merge until the first PyPI publication succeeds. There is no archive fallback. Later releases leave the previous published version available if publication fails.

RetriggerConfidence Score: 5/5

No outstanding findings block merging.
Summary The PR adds version-policy CI and post-merge PyPI publishing, updates installers to use the published package, and limits native Windows Muse lifecycle tests to Windows. No new findings remain.
Reviews (4) · Last reviewed commit: ["Limit native Windows Muse lifecycle test..."](https://github.com/alishahryar1/free-claude-code/commit/4dc704e93c20637ca7554b9475816afbd21af92b) --- .github/actions/ci-environment/action.yml | 8 + .github/workflows/dependency-cache.yml | 72 ----- .github/workflows/post-merge.yml | 142 ++++++++++ .github/workflows/tests.yml | 41 ++- e2e/test_admin_providers.py | 11 +- e2e/test_connected_accounts.py | 7 +- pyproject.toml | 3 +- scripts/check_version_policy.py | 124 +++++++++ scripts/install.ps1 | 5 +- scripts/install.sh | 5 +- scripts/validate_release.py | 110 ++++++++ tests/contracts/test_uv_policy.py | 23 +- tests/installers/test_installers.py | 20 +- .../installers/test_muse_windows_lifecycle.py | 5 + tests/scripts/test_release_package.py | 122 +++++++++ tests/scripts/test_release_workflow.py | 107 ++++++++ tests/scripts/test_version_policy.py | 245 ++++++++++++++++++ uv.lock | 4 +- 18 files changed, 949 insertions(+), 105 deletions(-) delete mode 100644 .github/workflows/dependency-cache.yml create mode 100644 .github/workflows/post-merge.yml create mode 100644 scripts/check_version_policy.py create mode 100644 scripts/validate_release.py create mode 100644 tests/scripts/test_release_package.py create mode 100644 tests/scripts/test_release_workflow.py create mode 100644 tests/scripts/test_version_policy.py diff --git a/.github/actions/ci-environment/action.yml b/.github/actions/ci-environment/action.yml index aa8db79dbd..b8a330c812 100644 --- a/.github/actions/ci-environment/action.yml +++ b/.github/actions/ci-environment/action.yml @@ -11,6 +11,9 @@ inputs: lookup-only: description: Check dependency cache availability without downloading it default: "false" + sync-dependencies: + description: Install the locked project and development dependencies after cache restoration. + default: "true" prepare-node: description: Prepare the Node runtime used by pytest subprocess contracts. default: "false" @@ -142,3 +145,8 @@ runs: key: ${{ steps.identity.outputs.dependencies-prefix }}${{ steps.dependencies.outputs.hash }} restore-keys: ${{ steps.identity.outputs.dependencies-prefix }} lookup-only: ${{ inputs.lookup-only }} + + - name: Install dependencies + if: inputs.sync-dependencies == 'true' + shell: bash + run: uv sync --locked --group dev diff --git a/.github/workflows/dependency-cache.yml b/.github/workflows/dependency-cache.yml deleted file mode 100644 index 27c54e31e9..0000000000 --- a/.github/workflows/dependency-cache.yml +++ /dev/null @@ -1,72 +0,0 @@ -name: Dependency Cache - -env: - CI_UV_VERSION: "0.12.13" - UV_MALWARE_CHECK: "1" - UV_PREVIEW_FEATURES: "malware-check" - -on: - workflow_dispatch: - push: - branches: [main] - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - warm-dependency-cache: - name: Warm dependency cache (${{ matrix.os }}) - if: github.ref == 'refs/heads/main' - runs-on: ${{ matrix.runner }} - timeout-minutes: 15 - permissions: - contents: read - strategy: - fail-fast: false - matrix: - include: - - os: Linux - runner: ubuntu-latest - - os: Windows - runner: windows-latest - - os: macOS - runner: macos-latest - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - with: - fetch-depth: 1 - - - name: Set up CI environment - id: environment - uses: ./.github/actions/ci-environment - with: - uv-version: ${{ env.CI_UV_VERSION }} - runner-label: ${{ matrix.runner }} - lookup-only: "true" - - - name: Save Python - if: steps.environment.outputs.python-hit != 'true' - uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 - with: - path: ${{ steps.environment.outputs.python-path }} - key: ${{ steps.environment.outputs.python-key }} - - - name: Restore compatible dependency cache - if: steps.environment.outputs.dependencies-hit != 'true' - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 - with: - path: ${{ steps.environment.outputs.dependencies-path }} - key: ${{ steps.environment.outputs.dependencies-key }} - restore-keys: ${{ steps.environment.outputs.dependencies-prefix }} - - - name: Warm dependency cache - if: steps.environment.outputs.dependencies-hit != 'true' - run: uv sync --locked --group dev --no-install-workspace - - - name: Save dependency cache - if: steps.environment.outputs.dependencies-hit != 'true' - uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 - with: - path: ${{ steps.environment.outputs.dependencies-path }} - key: ${{ steps.environment.outputs.dependencies-key }} diff --git a/.github/workflows/post-merge.yml b/.github/workflows/post-merge.yml new file mode 100644 index 0000000000..55ea434354 --- /dev/null +++ b/.github/workflows/post-merge.yml @@ -0,0 +1,142 @@ +name: Post merge + +env: + CI_UV_VERSION: "0.12.13" + UV_MALWARE_CHECK: "1" + UV_PREVIEW_FEATURES: "malware-check" + +on: + workflow_dispatch: + push: + branches: [main] + +concurrency: + group: post-merge-main + cancel-in-progress: false + queue: max + +jobs: + warm-dependency-cache: + name: Warm dependency cache (${{ matrix.os }}) + if: github.ref == 'refs/heads/main' + runs-on: ${{ matrix.runner }} + timeout-minutes: 15 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - os: Linux + runner: ubuntu-latest + - os: Windows + runner: windows-latest + - os: macOS + runner: macos-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ github.sha }} + fetch-depth: 1 + + - name: Set up CI environment + id: environment + uses: ./.github/actions/ci-environment + with: + uv-version: ${{ env.CI_UV_VERSION }} + runner-label: ${{ matrix.runner }} + lookup-only: "true" + sync-dependencies: "false" + + - name: Save Python + if: steps.environment.outputs.python-hit != 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 + with: + path: ${{ steps.environment.outputs.python-path }} + key: ${{ steps.environment.outputs.python-key }} + + - name: Restore compatible dependency cache + if: steps.environment.outputs.dependencies-hit != 'true' + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 + with: + path: ${{ steps.environment.outputs.dependencies-path }} + key: ${{ steps.environment.outputs.dependencies-key }} + restore-keys: ${{ steps.environment.outputs.dependencies-prefix }} + + - name: Warm dependency cache + if: steps.environment.outputs.dependencies-hit != 'true' + run: uv sync --locked --group dev --no-install-workspace + + - name: Save dependency cache + if: steps.environment.outputs.dependencies-hit != 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 + with: + path: ${{ steps.environment.outputs.dependencies-path }} + key: ${{ steps.environment.outputs.dependencies-key }} + + publish: + name: Publish to PyPI + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 15 + environment: pypi + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ github.sha }} + fetch-depth: 2 + + - name: Fetch previous revision + env: + BEFORE_SHA: ${{ github.event.before }} + run: | + if [ -n "$BEFORE_SHA" ]; then + git fetch --no-tags --depth=1 origin "$BEFORE_SHA" + fi + + - name: Set up CI environment + uses: ./.github/actions/ci-environment + with: + uv-version: ${{ env.CI_UV_VERSION }} + runner-label: ubuntu-latest + lookup-only: "true" + sync-dependencies: "false" + + - name: Detect release + id: release + env: + BEFORE_SHA: ${{ github.event.before }} + HEAD_SHA: ${{ github.sha }} + run: | + uv run --no-project --python "$(cat .python-version)" python - <<'PY' + import os + import subprocess + import tomllib + from pathlib import Path + + head = os.environ["HEAD_SHA"] + before = os.environ.get("BEFORE_SHA") or f"{head}^" + def version(revision): + result = subprocess.run( + ["git", "show", f"{revision}:pyproject.toml"], + check=True, capture_output=True, text=True, + ) + return tomllib.loads(result.stdout)["project"]["version"] + current, previous = version(head), version(before) + with Path(os.environ["GITHUB_OUTPUT"]).open("a", encoding="utf-8") as output: + output.write(f"changed={str(current != previous).lower()}\n") + print(f"Release version: {previous} -> {current}") + PY + + - name: Build and validate release + if: steps.release.outputs.changed == 'true' + run: | + uv build --no-sources --out-dir "$RUNNER_TEMP/fcc-release" + uv run --no-project --python "$(cat .python-version)" python scripts/validate_release.py "$RUNNER_TEMP/fcc-release" + + - name: Publish release + if: steps.release.outputs.changed == 'true' + run: uv publish --trusted-publishing always "$RUNNER_TEMP/fcc-release/"* diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b51c64b18b..ca05888751 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,6 +1,7 @@ # Branch protection: require every status check this workflow reports, for example: # Ban suppressions and legacy annotations # ruff-format, ruff-check, ty +# Version policy # pytest (Linux), pytest (Windows), pytest (macOS) # playwright (Linux), playwright (Windows), playwright (macOS) # GitHub may prefix with the workflow name (e.g. "CI / ruff-format"); use the names the branch protection UI offers after a run. @@ -20,6 +21,37 @@ concurrency: cancel-in-progress: true jobs: + version-policy: + name: Version policy + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ github.event.pull_request.head.sha }} + repository: ${{ github.event.pull_request.head.repo.full_name }} + fetch-depth: 0 + + - name: Fetch target revision + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + BASE_REPOSITORY: ${{ github.event.pull_request.base.repo.full_name }} + run: git fetch --no-tags "https://github.com/$BASE_REPOSITORY.git" "$BASE_SHA" + + - name: Set up CI environment + uses: ./.github/actions/ci-environment + with: + uv-version: ${{ env.CI_UV_VERSION }} + runner-label: ubuntu-latest + + - name: Check version policy + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: uv run --locked --group dev python scripts/check_version_policy.py --base "$BASE_SHA" --head "$HEAD_SHA" + no-suppressions-or-legacy-annotations: name: Ban suppressions and legacy annotations runs-on: ubuntu-latest @@ -70,9 +102,6 @@ jobs: uv-version: ${{ env.CI_UV_VERSION }} runner-label: ubuntu-latest - - name: Install dependencies - run: uv sync --locked --group dev - - name: Run run: ${{ matrix.run }} @@ -106,9 +135,6 @@ jobs: runner-label: ${{ matrix.runner }} prepare-node: "true" - - name: Install dependencies - run: uv sync --locked --group dev - - name: Run pytest run: uv run --locked pytest -v --tb=short --durations=50 @@ -141,9 +167,6 @@ jobs: uv-version: ${{ env.CI_UV_VERSION }} runner-label: ${{ matrix.runner }} - - name: Install dependencies - run: uv sync --locked --group dev - - name: Install Chromium run: uv run --locked playwright install ${{ matrix.os == 'Linux' && '--with-deps chromium' || 'chromium' }} diff --git a/e2e/test_admin_providers.py b/e2e/test_admin_providers.py index 0de403b0a1..76197b6783 100644 --- a/e2e/test_admin_providers.py +++ b/e2e/test_admin_providers.py @@ -167,7 +167,14 @@ def test_delayed_startup_status_does_not_replace_a_manual_provider_check( startup: list[Route] = [] manual: list[Route] = [] - page.route("**/admin/api/status", lambda route: startup.append(route)) + def hold_first_status(route: Route) -> None: + # Only the stale snapshot is held. Later refreshes must finish normally. + if startup: + route.continue_() + else: + startup.append(route) + + page.route("**/admin/api/status", hold_first_status) page.route( "**/admin/api/providers/open_router/test", lambda route: manual.append(route) ) @@ -195,7 +202,7 @@ def test_delayed_startup_status_does_not_replace_a_manual_provider_check( result = page.locator('[data-provider-check-result="open_router"]') expect(result).to_have_text(expected) with page.expect_response("**/admin/api/status") as response: - startup.pop(0).fulfill(json=snapshot) + startup[0].fulfill(json=snapshot) response.value.finished() page.wait_for_function("!state.startupRequest") expect(result).to_have_text(expected) diff --git a/e2e/test_connected_accounts.py b/e2e/test_connected_accounts.py index 63cfc72a4f..f141740033 100644 --- a/e2e/test_connected_accounts.py +++ b/e2e/test_connected_accounts.py @@ -163,6 +163,9 @@ def _open(page: Page, admin_base_url: str) -> None: def test_account_modes_wait_for_status_and_recover_after_load_failure( page: Page, admin_base_url: str, accounts: _Accounts ) -> None: + page.expose_function( + "pendingAccountStatusCount", lambda: len(accounts.pending_status) + ) accounts.hold_status.add("github_copilot") page.goto(f"{admin_base_url}/admin") copilot = page.locator('[data-provider="github_copilot"]') @@ -170,7 +173,7 @@ def test_account_modes_wait_for_status_and_recover_after_load_failure( expect(copilot.get_by_role("button", name="Loading…", exact=True)).to_be_disabled() expect(copilot.get_by_role("button", name="Connect", exact=True)).to_have_count(0) expect(openai.get_by_role("button", name="Connect", exact=True)).to_be_enabled() - assert len(accounts.pending_status) == 1 + page.wait_for_function("async () => await window.pendingAccountStatusCount() === 1") accounts.pending_status.pop().fulfill( status=503, json={"detail": "Account status unavailable."} @@ -181,7 +184,7 @@ def test_account_modes_wait_for_status_and_recover_after_load_failure( expect(copilot.get_by_role("button", name="Connect", exact=True)).to_have_count(0) copilot.get_by_role("button", name="Retry", exact=True).click() expect(copilot.get_by_role("button", name="Loading…", exact=True)).to_be_disabled() - assert len(accounts.pending_status) == 1 + page.wait_for_function("async () => await window.pendingAccountStatusCount() === 1") accounts.hold_status.clear() accounts.pending_status.pop().fulfill(json=accounts.statuses["github_copilot"]) diff --git a/pyproject.toml b/pyproject.toml index 54b7896e78..6f550c2563 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "free-claude-code" -version = "6.2.63" +version = "6.2.64" description = "Local proxy connecting coding agents to OpenAI-compatible AI providers" readme = "README.md" license = "AGPL-3.0-only" @@ -83,6 +83,7 @@ explicit = true [dependency-groups] dev = [ + "packaging>=26.0", "pytest>=9.1.1", "pytest-asyncio>=1.4.0", "pytest-cov>=7.1.0", diff --git a/scripts/check_version_policy.py b/scripts/check_version_policy.py new file mode 100644 index 0000000000..6514ea8758 --- /dev/null +++ b/scripts/check_version_policy.py @@ -0,0 +1,124 @@ +"""Require one version increment exactly when a PR changes release inputs.""" + +import argparse +import subprocess +import tomllib + +import tomlkit +from packaging.version import Version + +RELEASE_FILES = {".python-version", "pyproject.toml", "uv.lock"} +RELEASE_DIRS = ("assets/", "scripts/", "src/") + + +def git(*args: str) -> str: + return subprocess.run( + ["git", *args], check=True, capture_output=True, text=True, encoding="utf-8" + ).stdout.strip() + + +def blob(revision: str, path: str) -> str | None: + result = subprocess.run( + ["git", "show", f"{revision}:{path}"], + check=False, + capture_output=True, + text=True, + encoding="utf-8", + ) + return result.stdout if result.returncode == 0 else None + + +def root_package(document: dict) -> dict: + entries = [ + entry + for entry in document.get("package", []) + if entry.get("name") == "free-claude-code" + and entry.get("source") == {"editable": "."} + ] + if len(entries) != 1: + raise ValueError("uv.lock must contain exactly one editable FCC package") + return entries[0] + + +def version_at(revision: str) -> str: + project = tomllib.loads(blob(revision, "pyproject.toml") or "")["project"] + if project["name"] != "free-claude-code": + raise ValueError("Project name must be free-claude-code") + value = project["version"] + if not isinstance(value, str): + raise ValueError("Project version must be a string") + parsed = Version(value) + if value != f"{parsed.major}.{parsed.minor}.{parsed.micro}": + raise ValueError("Project version must use the format MAJOR.MINOR.PATCH") + lock = root_package(tomllib.loads(blob(revision, "uv.lock") or "")) + if lock["version"] != value: + raise ValueError("pyproject.toml and uv.lock versions must agree") + return value + + +def without_release_version(text: str | None, path: str) -> str | None: + if text is None: + return None + document = tomlkit.parse(text) + if path == "pyproject.toml": + document["project"]["version"] = "FCC_VERSION" + else: + for package in document["package"]: + if package["name"] == "free-claude-code" and package.get("source") == { + "editable": "." + }: + package["version"] = "FCC_VERSION" + return tomlkit.dumps(document) + + +def check(base: str, head: str) -> None: + base = git("rev-parse", "--verify", "--end-of-options", f"{base}^{{commit}}") + head = git("rev-parse", "--verify", "--end-of-options", f"{head}^{{commit}}") + ancestor = git("merge-base", base, head) + old, new = version_at(base), version_at(head) + changed = git("diff", "--no-renames", "--name-only", "-z", ancestor, head).split( + "\0" + ) + release_paths = [] + for path in changed: + if path not in RELEASE_FILES and not path.startswith(RELEASE_DIRS): + continue + if path in {"pyproject.toml", "uv.lock"} and without_release_version( + blob(ancestor, path), path + ) == without_release_version(blob(head, path), path): + continue + release_paths.append(path) + print(f"FCC version: {old} -> {new}") + print("Release changes: " + (", ".join(release_paths) or "none")) + previous = Version(old) + allowed = { + f"{previous.major}.{previous.minor}.{previous.micro + 1}", + f"{previous.major}.{previous.minor + 1}.0", + f"{previous.major + 1}.0.0", + } + if release_paths and new not in allowed: + raise ValueError( + "Version must increase by exactly one patch, minor, or major increment " + "from the target branch, resetting lower components to zero. " + f"Allowed versions: {', '.join(sorted(allowed))}" + ) + if not release_paths and new != old: + raise ValueError("Version must stay unchanged without release changes") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--base", required=True) + parser.add_argument("--head", required=True) + args = parser.parse_args() + try: + check(args.base, args.head) + except (ValueError, KeyError, TypeError, subprocess.CalledProcessError) as error: + print(f"Version policy failed: {error}") + return 1 + print("Version policy passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 94eef9aeac..1a6a569a3e 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -12,7 +12,6 @@ Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" $ProgressPreference = "SilentlyContinue" -$RepoArchiveUrl = "https://github.com/Alishahryar1/free-claude-code/archive/refs/heads/main.zip" # Windows on ARM emulates x64, whose Python package ecosystem has broader wheel support. $PythonRequest = "cpython-3.14.0-windows-x86_64-none" $MinUvVersion = "0.12.13" @@ -1395,9 +1394,9 @@ function Ensure-Uv { function Get-PackageSpec { if ($VoiceLocal) { - return "free-claude-code[voice_local] @ $RepoArchiveUrl" + return "free-claude-code[voice_local]" } - return "free-claude-code @ $RepoArchiveUrl" + return "free-claude-code" } function Install-FreeClaudeCode { diff --git a/scripts/install.sh b/scripts/install.sh index 937b707d8b..06ab26455d 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1,7 +1,6 @@ #!/bin/sh set -eu -REPO_ARCHIVE_URL="https://github.com/Alishahryar1/free-claude-code/archive/refs/heads/main.zip" PYTHON_VERSION="3.14.0" MIN_UV_VERSION="0.12.13" CLAUDE_INSTALL_URL="https://claude.ai/install.sh" @@ -1297,9 +1296,9 @@ validate_args() { package_spec() { if [ "$voice_local" -eq 1 ]; then - printf 'free-claude-code[voice_local] @ %s' "$REPO_ARCHIVE_URL" + printf '%s' 'free-claude-code[voice_local]' else - printf 'free-claude-code @ %s' "$REPO_ARCHIVE_URL" + printf '%s' 'free-claude-code' fi } diff --git a/scripts/validate_release.py b/scripts/validate_release.py new file mode 100644 index 0000000000..02f80297c8 --- /dev/null +++ b/scripts/validate_release.py @@ -0,0 +1,110 @@ +"""Check the exact FCC distributions that will be published.""" + +import argparse +import configparser +import subprocess +import tarfile +import tomllib +import zipfile +from email.parser import BytesParser +from pathlib import Path + + +def require_files(names: set[str], expected: set[str]) -> None: + missing = expected - names + if missing: + raise ValueError("Missing release files: " + ", ".join(sorted(missing))) + + +def validate(directory: Path) -> None: + project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))[ + "project" + ] + name, version = project["name"], project["version"] + stem = f"{name.replace('-', '_')}-{version}" + wheel_path = directory / f"{stem}-py3-none-any.whl" + sdist_path = directory / f"{stem}.tar.gz" + # uv build creates a .gitignore alongside its distributions. + artifacts = {path for path in directory.iterdir() if path.name != ".gitignore"} + if artifacts != {wheel_path, sdist_path}: + raise ValueError( + "Release directory must contain only the current wheel and sdist" + ) + tracked = subprocess.run( + ["git", "ls-files", "-z", "src/free_claude_code"], + check=True, + capture_output=True, + text=True, + encoding="utf-8", + ).stdout + sources = set(filter(None, tracked.split("\0"))) + if "src/free_claude_code/__init__.py" not in sources: + raise ValueError("Missing tracked FCC package sources") + with zipfile.ZipFile(wheel_path) as archive: + wheel = { + file: archive.read(file) + for file in archive.namelist() + if not file.endswith("/") + } + with tarfile.open(sdist_path) as archive: + source = {} + for file in archive: + stream = archive.extractfile(file) if file.isfile() else None + if stream is not None: + source[file.name] = stream.read() + wheel_info = f"{stem}.dist-info" + updater_names = {"fcc-update", "fcc-update.cmd"} + require_files( + set(wheel), + {file.removeprefix("src/") for file in sources} + | {f"{stem}.data/scripts/{script}" for script in updater_names} + | {f"{wheel_info}/METADATA", f"{wheel_info}/entry_points.txt"}, + ) + require_files( + set(source), + {f"{stem}/{file}" for file in sources} + | {f"{stem}/scripts/update/{script}" for script in updater_names} + | { + f"{stem}/{file}" + for file in ("pyproject.toml", "README.md", "LICENSE", "PKG-INFO") + }, + ) + for data in (wheel[f"{wheel_info}/METADATA"], source[f"{stem}/PKG-INFO"]): + metadata = BytesParser().parsebytes(data) + if metadata["Name"] != name or metadata["Version"] != version: + raise ValueError( + "Release metadata does not match the project name and version" + ) + entrypoints = configparser.ConfigParser() + entrypoints.read_string(wheel[f"{wheel_info}/entry_points.txt"].decode()) + for project_key, section in ( + ("scripts", "console_scripts"), + ("gui-scripts", "gui_scripts"), + ): + actual = dict(entrypoints[section]) if section in entrypoints else {} + if actual != project.get(project_key, {}): + raise ValueError(f"Release entry points do not match {project_key}") + print(f"Validated {wheel_path.name} and {sdist_path.name}") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("directory", type=Path) + args = parser.parse_args() + try: + validate(args.directory) + except ( + ValueError, + KeyError, + OSError, + zipfile.BadZipFile, + tarfile.TarError, + subprocess.CalledProcessError, + ) as error: + print(f"Release validation failed: {error}") + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/contracts/test_uv_policy.py b/tests/contracts/test_uv_policy.py index 7559ad3935..ae66e85d68 100644 --- a/tests/contracts/test_uv_policy.py +++ b/tests/contracts/test_uv_policy.py @@ -8,7 +8,7 @@ CI_SETUP = Path(".github/actions/ci-environment/action.yml") UV_WORKFLOWS = ( Path(".github/workflows/tests.yml"), - Path(".github/workflows/dependency-cache.yml"), + Path(".github/workflows/post-merge.yml"), ) REQUIRED_TEST_RUNNERS = { "Linux": "ubuntu-latest", @@ -94,3 +94,24 @@ def test_uv_workflows_share_managed_python_cache_policy() -> None: assert "/tmp/" not in workflow assert "uv-deps-v" not in workflow assert "uv-python-v" not in workflow + + +def test_shared_setup_installs_dependencies_except_for_post_merge_jobs() -> None: + setup = yaml.safe_load(CI_SETUP.read_text(encoding="utf-8")) + assert setup["inputs"]["sync-dependencies"]["default"] == "true" + sync = next( + step + for step in setup["runs"]["steps"] + if step.get("name") == "Install dependencies" + ) + assert sync["run"] == "uv sync --locked --group dev" + assert sync["if"] == "inputs.sync-dependencies == 'true'" + for path in UV_WORKFLOWS: + jobs = yaml.safe_load(path.read_text(encoding="utf-8"))["jobs"] + for job in jobs.values(): + for step in job["steps"]: + if step.get("uses") == "./.github/actions/ci-environment": + assert step["with"].get("sync-dependencies", "true") == ( + "false" if path.name == "post-merge.yml" else "true" + ) + assert step.get("run") != "uv sync --locked --group dev" diff --git a/tests/installers/test_installers.py b/tests/installers/test_installers.py index d899582c8a..6750230dfd 100644 --- a/tests/installers/test_installers.py +++ b/tests/installers/test_installers.py @@ -838,8 +838,7 @@ def test_install_sh_fresh_install_is_verified(posix_harness: PosixHarness) -> No assert any( call.startswith( "uv:tool install --force --refresh-package free-claude-code " - "--python 3.14.0 free-claude-code @ " - "https://github.com/Alishahryar1/free-claude-code/archive/refs/heads/main.zip" + "--python 3.14.0 free-claude-code" ) for call in calls ) @@ -1761,11 +1760,11 @@ def test_install_sh_rejects_unparseable_existing_uv( @pytest.mark.parametrize( ("args", "package"), [ - ((), "free-claude-code @ "), - (("--voice-local",), "free-claude-code[voice_local] @ "), + ((), "free-claude-code"), + (("--voice-local",), "free-claude-code[voice_local]"), ( ("--voice-local", "--torch-backend", "cu130"), - "free-claude-code[voice_local] @ ", + "free-claude-code[voice_local]", ), ], ) @@ -3188,8 +3187,7 @@ def test_install_ps1_fresh_install_is_verified( call.startswith( "uv:tool install --force --refresh-package free-claude-code " "--python cpython-3.14.0-windows-x86_64-none " - '"free-claude-code @ ' - 'https://github.com/Alishahryar1/free-claude-code/archive/refs/heads/main.zip"' + "free-claude-code" ) for call in calls ) @@ -4105,11 +4103,11 @@ def test_install_ps1_rejects_unparseable_existing_uv( @pytest.mark.parametrize( ("args", "package"), [ - ((), "free-claude-code @ "), - (("-VoiceLocal",), "free-claude-code[voice_local] @ "), + ((), "free-claude-code"), + (("-VoiceLocal",), "free-claude-code[voice_local]"), ( ("-VoiceLocal", "-TorchBackend", "cu130"), - "free-claude-code[voice_local] @ ", + "free-claude-code[voice_local]", ), ], ) @@ -4218,7 +4216,7 @@ def test_installers_use_native_clients_and_single_python_selection() -> None: assert "git --version" not in text assert ( "https://github.com/Alishahryar1/free-claude-code/archive/refs/heads/main.zip" - in text + not in text ) assert "python install" not in text assert "--refresh-package" in text diff --git a/tests/installers/test_muse_windows_lifecycle.py b/tests/installers/test_muse_windows_lifecycle.py index a6f6e2ff5d..80eeb63f45 100644 --- a/tests/installers/test_muse_windows_lifecycle.py +++ b/tests/installers/test_muse_windows_lifecycle.py @@ -9,6 +9,11 @@ import pytest +pytestmark = pytest.mark.skipif( + os.name != "nt", + reason="Tests the native Windows Muse installer and uninstaller", +) + def _repo_root() -> Path: return Path(__file__).resolve().parents[2] diff --git a/tests/scripts/test_release_package.py b/tests/scripts/test_release_package.py new file mode 100644 index 0000000000..38293376ef --- /dev/null +++ b/tests/scripts/test_release_package.py @@ -0,0 +1,122 @@ +"""Reject incomplete built distributions before they can be uploaded.""" + +import io +import subprocess +import sys +import tarfile +import zipfile +from pathlib import Path + +import pytest + +from tests.scripts.test_version_policy import git, write + +VALIDATOR = Path(__file__).resolve().parents[2] / "scripts/validate_release.py" + + +@pytest.fixture +def release(tmp_path): + repo = tmp_path / "project" + repo.mkdir() + git(repo, "init") + files = { + "pyproject.toml": '[project]\nname="free-claude-code"\nversion="1.2.3"\n[project.scripts]\nfcc-server="free_claude_code.cli:serve"\n[project.gui-scripts]\nfcc-desktop="free_claude_code.desktop:launch"\n', + "README.md": "readme", + "LICENSE": "license", + "scripts/update/fcc-update": "#!/bin/sh\n", + "scripts/update/fcc-update.cmd": "@echo off\n", + "src/free_claude_code/__init__.py": "", + "src/free_claude_code/cli.py": "", + "src/free_claude_code/desktop.py": "", + "src/free_claude_code/assets/icon.png": "icon", + "src/free_claude_code/api/admin_static/index.html": "page", + } + for name, content in files.items(): + write(repo, name, content) + git(repo, "add", ".") + metadata = "Name: free-claude-code\nVersion: 1.2.3\n" + wheel = { + name.removeprefix("src/"): content + for name, content in files.items() + if name.startswith("src/") + } + for name in ("fcc-update", "fcc-update.cmd"): + wheel[f"free_claude_code-1.2.3.data/scripts/{name}"] = files[ + f"scripts/update/{name}" + ] + wheel["free_claude_code-1.2.3.dist-info/METADATA"] = metadata + wheel["free_claude_code-1.2.3.dist-info/entry_points.txt"] = ( + "[console_scripts]\nfcc-server=free_claude_code.cli:serve\n" + "[gui_scripts]\nfcc-desktop=free_claude_code.desktop:launch\n" + ) + sdist = {f"free_claude_code-1.2.3/{name}": body for name, body in files.items()} + sdist["free_claude_code-1.2.3/PKG-INFO"] = metadata + return repo, wheel, sdist + + +def validate(release, tmp_path): + repo, wheel, sdist = release + dist = tmp_path / "built" + dist.mkdir() + (dist / ".gitignore").write_text("*") + with zipfile.ZipFile( + dist / "free_claude_code-1.2.3-py3-none-any.whl", "w" + ) as archive: + for name, body in wheel.items(): + archive.writestr(name, body) + with tarfile.open(dist / "free_claude_code-1.2.3.tar.gz", "w:gz") as archive: + for name, body in sdist.items(): + data = body.encode() + info = tarfile.TarInfo(name) + info.size = len(data) + archive.addfile(info, io.BytesIO(data)) + return subprocess.run( + [sys.executable, str(VALIDATOR), str(dist)], + cwd=repo, + capture_output=True, + text=True, + check=False, + ) + + +def test_complete_release_passes(release, tmp_path): + result = validate(release, tmp_path) + assert result.returncode == 0, result.stdout + result.stderr + + +@pytest.mark.parametrize("archive", [1, 2]) +@pytest.mark.parametrize( + "suffix", + ["assets/icon.png", "scripts/fcc-update.cmd", "entry_points.txt", "METADATA"], +) +def test_missing_wheel_or_sdist_content_fails(release, tmp_path, archive, suffix): + if archive == 2: + suffix = { + "scripts/fcc-update.cmd": "scripts/update/fcc-update.cmd", + "entry_points.txt": "pyproject.toml", + "METADATA": "PKG-INFO", + }.get(suffix, suffix) + entries = release[archive] + missing = next(name for name in entries if name.endswith(suffix)) + del entries[missing] + result = validate(release, tmp_path) + assert result.returncode != 0 + assert "missing" in result.stdout.lower() + + +def test_wrong_version_or_entrypoint_fails(release, tmp_path): + release[1]["free_claude_code-1.2.3.dist-info/entry_points.txt"] = ( + "[console_scripts]\nfcc-server=wrong:command\n" + ) + result = validate(release, tmp_path) + assert result.returncode != 0 + assert "entry" in result.stdout.lower() + + +def test_mismatched_metadata_fails(release, tmp_path): + release[2]["free_claude_code-1.2.3/PKG-INFO"] = ( + "Name: free-claude-code\nVersion: 0.1.0\n" + ) + result = validate(release, tmp_path) + assert result.returncode != 0 + assert "metadata" in result.stdout.lower() diff --git a/tests/scripts/test_release_workflow.py b/tests/scripts/test_release_workflow.py new file mode 100644 index 0000000000..d0facb74c2 --- /dev/null +++ b/tests/scripts/test_release_workflow.py @@ -0,0 +1,107 @@ +"""Release decisions and workflow permissions are explicit contracts.""" + +import os +import subprocess +import sys +from pathlib import Path + +import pytest +import yaml + +from tests.scripts.test_version_policy import commit, git, version, write + +ROOT = Path(__file__).resolve().parents[2] +WORKFLOW = ROOT / ".github/workflows/post-merge.yml" + + +def workflow(): + return yaml.safe_load(WORKFLOW.read_text(encoding="utf-8")) + + +def test_post_merge_serializes_runs_and_separates_publish_permissions(): + config = workflow() + assert config["concurrency"] == { + "group": "post-merge-main", + "cancel-in-progress": False, + "queue": "max", + } + assert len(config["jobs"]) == 2 + cache = config["jobs"]["warm-dependency-cache"] + assert {entry["os"] for entry in cache["strategy"]["matrix"]["include"]} == { + "Linux", + "Windows", + "macOS", + } + publish = config["jobs"]["publish"] + assert "needs" not in publish + assert publish["environment"] == "pypi" + assert publish["permissions"] == {"contents": "read", "id-token": "write"} + assert cache["permissions"] == {"contents": "read"} + for job in (cache, publish): + assert job["if"] == "github.ref == 'refs/heads/main'" + checkout = next( + step + for step in job["steps"] + if step.get("uses", "").startswith("actions/checkout@") + ) + assert checkout["with"]["ref"] == "${{ github.sha }}" + for name in ("Build and validate release", "Publish release"): + step = next(step for step in publish["steps"] if step.get("name") == name) + assert step["if"] == "steps.release.outputs.changed == 'true'" + + +@pytest.mark.parametrize("bump", [False, True]) +@pytest.mark.parametrize("manual", [False, True]) +def test_release_detection_uses_the_triggering_revision(tmp_path, bump, manual): + git(tmp_path, "init", "-b", "main") + version(tmp_path, "1.2.3") + before = commit(tmp_path) + version(tmp_path, "1.2.4" if bump else "1.2.3") + write(tmp_path, "README.md", "changed\n") + head = commit(tmp_path) + # A later checkout/commit must not change the queued run's release decision. + version(tmp_path, "9.0.0") + commit(tmp_path) + steps = workflow()["jobs"]["publish"]["steps"] + run = next(step["run"] for step in steps if step.get("id") == "release") + source = run.split("<<'PY'\n", 1)[1].rsplit("\nPY", 1)[0] + output = tmp_path / "outputs" + result = subprocess.run( + [sys.executable, "-c", source], + cwd=tmp_path, + env=os.environ + | { + "BEFORE_SHA": "" if manual else before, + "HEAD_SHA": head, + "GITHUB_OUTPUT": str(output), + }, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stdout + result.stderr + assert output.read_text().strip() == f"changed={str(bump).lower()}" + + +def test_version_check_receives_pr_base_and_head_without_publish_permission(): + config = yaml.safe_load( + (ROOT / ".github/workflows/tests.yml").read_text(encoding="utf-8") + ) + assert set(config[True]) == {"pull_request"} + job = config["jobs"]["version-policy"] + checkout = next( + step + for step in job["steps"] + if step.get("uses", "").startswith("actions/checkout@") + ) + assert checkout["with"]["ref"] == "${{ github.event.pull_request.head.sha }}" + assert job["name"] == "Version policy" + assert job["permissions"] == {"contents": "read"} + step = next( + step for step in job["steps"] if step.get("name") == "Check version policy" + ) + assert step["env"] == { + "BASE_SHA": "${{ github.event.pull_request.base.sha }}", + "HEAD_SHA": "${{ github.event.pull_request.head.sha }}", + } + assert '--base "$BASE_SHA" --head "$HEAD_SHA"' in step["run"] diff --git a/tests/scripts/test_version_policy.py b/tests/scripts/test_version_policy.py new file mode 100644 index 0000000000..bcbbb5b32f --- /dev/null +++ b/tests/scripts/test_version_policy.py @@ -0,0 +1,245 @@ +"""Exercise version policy against real, isolated Git history.""" + +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +CHECKER = Path(__file__).resolve().parents[2] / "scripts/check_version_policy.py" + + +def git(repo: Path, *args: str) -> str: + result = subprocess.run( + ["git", "-C", str(repo), *args], + check=True, + capture_output=True, + text=True, + env=os.environ + | { + "GIT_CONFIG_GLOBAL": os.devnull, + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_AUTHOR_NAME": "Test", + "GIT_AUTHOR_EMAIL": "test@example.invalid", + "GIT_COMMITTER_NAME": "Test", + "GIT_COMMITTER_EMAIL": "test@example.invalid", + }, + ) + return result.stdout.strip() + + +def write(repo: Path, path: str, content: str) -> None: + target = repo / path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content, encoding="utf-8") + + +def version(repo: Path, value: str, lock: str | None = None) -> None: + write( + repo, + "pyproject.toml", + f'[project]\nname = "free-claude-code"\nversion = "{value}"\n', + ) + write( + repo, + "uv.lock", + f'[[package]]\nname = "free-claude-code"\nversion = "{lock or value}"\nsource = {{ editable = "." }}\n', + ) + + +def commit(repo: Path) -> str: + git(repo, "add", ".") + git(repo, "commit", "--no-gpg-sign", "-m", "test change") + return git(repo, "rev-parse", "HEAD") + + +@pytest.fixture +def history(tmp_path): + git(tmp_path, "init", "-b", "main") + git(tmp_path, "config", "core.autocrlf", "false") + version(tmp_path, "1.2.3") + write(tmp_path, "src/file.py", "original\n") + return tmp_path, commit(tmp_path) + + +def check(repo: Path, base: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, str(CHECKER), "--base", base, "--head", "HEAD"], + cwd=repo, + capture_output=True, + text=True, + check=False, + ) + + +@pytest.mark.parametrize( + "path", + [ + "assets/a.svg", + "scripts/a.sh", + "src/a.py", + ".python-version", + "pyproject.toml", + "uv.lock", + ], +) +@pytest.mark.parametrize("bump", [False, True]) +def test_release_changes_require_increase(history, path, bump): + repo, base = history + if bump: + version(repo, "1.2.4") + if path in {"pyproject.toml", "uv.lock"}: + with (repo / path).open("a", encoding="utf-8") as stream: + stream.write("# non-version change\n") + else: + write(repo, path, "changed\n") + commit(repo) + result = check(repo, base) + assert (result.returncode == 0) is bump, result.stdout + result.stderr + assert path in result.stdout + + +@pytest.mark.parametrize( + "path", + [ + "README.md", + "tests/test_example.py", + ".github/workflows/ci.yml", + "scripts-other/a.py", + ], +) +@pytest.mark.parametrize("bump", [False, True]) +def test_excluded_only_changes_forbid_increase(history, path, bump): + repo, base = history + if bump: + version(repo, "1.2.4") + write(repo, path, "documentation\n") + commit(repo) + result = check(repo, base) + assert (result.returncode == 0) is not bump, result.stdout + result.stderr + + +def test_version_only_change_does_not_justify_itself(history): + repo, base = history + version(repo, "1.2.4") + commit(repo) + result = check(repo, base) + assert result.returncode != 0 + assert "without release changes" in result.stdout + + +@pytest.mark.parametrize( + "value,allowed", + [ + ("1.2.4", True), + ("1.3.0", True), + ("2.0.0", True), + ("1.2.5", False), + ("1.4.0", False), + ("3.0.0", False), + ("1.3.1", False), + ("2.1.0", False), + ("2.0.1", False), + ("1.2.4rc1", False), + ("1.2.4.post1", False), + ("1.2.4.dev1", False), + ("1.2.4+local", False), + ("1!1.2.4", False), + ("1.2.4.0", False), + ("v1.2.4", False), + ("01.2.4", False), + ], +) +def test_release_requires_exactly_one_version_increment(history, value, allowed): + repo, base = history + version(repo, value) + write(repo, "src/file.py", "changed\n") + commit(repo) + result = check(repo, base) + assert (result.returncode == 0) is allowed, result.stdout + result.stderr + + +@pytest.mark.parametrize("final_version,allowed", [("1.2.4", True), ("1.2.5", False)]) +def test_multiple_pr_commits_share_one_bump(history, final_version, allowed): + repo, base = history + version(repo, "1.2.4") + write(repo, "src/file.py", "first change\n") + commit(repo) + version(repo, final_version) + write(repo, "src/file.py", "second change\n") + commit(repo) + result = check(repo, base) + assert (result.returncode == 0) is allowed, result.stdout + result.stderr + + +@pytest.mark.parametrize( + "value,lock", [("1.2.2", None), ("1.2.4", "1.2.3"), ("invalid", None)] +) +def test_invalid_or_inconsistent_versions_fail(history, value, lock): + repo, base = history + version(repo, value, lock) + write(repo, "src/file.py", "changed\n") + commit(repo) + assert check(repo, base).returncode != 0 + + +@pytest.mark.parametrize("move", [False, True]) +def test_deleting_or_moving_out_of_release_directory_requires_bump(history, move): + repo, base = history + if move: + (repo / "src/file.py").rename(repo / "file with spaces.py") + else: + (repo / "src/file.py").unlink() + commit(repo) + result = check(repo, base) + assert result.returncode != 0 + assert "src/file.py" in result.stdout + + +def test_other_lock_package_version_is_a_release_change(history): + repo, _ = history + with (repo / "uv.lock").open("a", encoding="utf-8") as stream: + stream.write('[[package]]\nname = "other"\nversion = "1.0"\n') + base = commit(repo) + lock = repo / "uv.lock" + lock.write_text(lock.read_text().replace('version = "1.0"', 'version = "2.0"')) + commit(repo) + result = check(repo, base) + assert result.returncode != 0 + assert "uv.lock" in result.stdout + + +def test_stale_release_cannot_reuse_new_main_version(history): + repo, original = history + version(repo, "1.2.4") + write(repo, "src/main.py", "main change\n") + base = commit(repo) + git(repo, "checkout", "-b", "pr", original) + version(repo, "1.2.4") + write(repo, "src/pr.py", "pr change\n") + commit(repo) + result = check(repo, base) + assert result.returncode != 0 + assert "must increase" in result.stdout + + +def test_unrelated_main_changes_are_not_attributed_to_docs_pr(history): + repo, original = history + write(repo, "src/main.py", "main change\n") + base = commit(repo) + git(repo, "checkout", "-b", "pr", original) + write(repo, "README.md", "docs\n") + commit(repo) + result = check(repo, base) + assert result.returncode == 0, result.stdout + result.stderr + + +def test_checker_reads_commits_not_uncommitted_files(history): + repo, base = history + write(repo, "README.md", "docs\n") + commit(repo) + version(repo, "9.9.9") + write(repo, "src/file.py", "uncommitted\n") + result = check(repo, base) + assert result.returncode == 0, result.stdout + result.stderr diff --git a/uv.lock b/uv.lock index a31ba1de4f..0853fc127e 100644 --- a/uv.lock +++ b/uv.lock @@ -599,7 +599,7 @@ wheels = [ [[package]] name = "free-claude-code" -version = "6.2.63" +version = "6.2.64" source = { editable = "." } dependencies = [ { name = "aiohttp" }, @@ -640,6 +640,7 @@ voice-local = [ [package.dev-dependencies] dev = [ + { name = "packaging" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, @@ -687,6 +688,7 @@ provides-extras = ["voice-local"] [package.metadata.requires-dev] dev = [ + { name = "packaging", specifier = ">=26.0" }, { name = "pytest", specifier = ">=9.1.1" }, { name = "pytest-asyncio", specifier = ">=1.4.0" }, { name = "pytest-cov", specifier = ">=7.1.0" },