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" },