diff --git a/.github/workflows/binaries.yml b/.github/workflows/binaries.yml new file mode 100644 index 00000000..185ce0ab --- /dev/null +++ b/.github/workflows/binaries.yml @@ -0,0 +1,420 @@ +name: binaries + +on: + workflow_dispatch: + inputs: + online_smoketest: + description: Run read-only online smoketests + type: boolean + default: true + workflow_call: + inputs: + online_smoketest: + description: Run read-only online and OIDC smoketests + type: boolean + required: false + default: false + secrets: + CLOUDSMITH_API_KEY: + description: Read-only API key for the online smoketests + required: false + pull_request: + paths: + - cloudsmith_cli/** + - packaging/** + - Dockerfile + - pyproject.toml + - uv.lock + - .github/workflows/binaries.yml + - .github/workflows/release.yml + +permissions: + contents: read + +concurrency: + group: binaries-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + inputs: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.12" + + - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + with: + enable-cache: true + + - name: Verify lockfile and binary constraints + run: | + set -euo pipefail + uv lock --check + uv export \ + --locked \ + --no-dev \ + --group binary \ + --extra all \ + --no-emit-project \ + --no-hashes \ + --no-header \ + -o /tmp/constraints.txt + cmp /tmp/constraints.txt packaging/constraints.txt + + - name: Validate Python distributions + run: | + set -euo pipefail + uv sync --locked --group release + uv build --clear + uv run --group release twine check dist/* + uv run --group release check-wheel-contents dist/*.whl + python - <<'PY' + import glob + import zipfile + + wheel = glob.glob("dist/*.whl") + if len(wheel) != 1: + raise SystemExit(f"expected one wheel, found: {wheel}") + with zipfile.ZipFile(wheel[0]) as archive: + names = archive.namelist() + forbidden = [ + name + for name in names + if name.startswith("packaging/") + or "/tests/" in name + or name.startswith("tests/") + ] + if forbidden: + raise SystemExit(f"wheel contains non-runtime files: {forbidden}") + PY + + matrix: + needs: inputs + runs-on: ubuntu-24.04 + timeout-minutes: 5 + outputs: + include: ${{ steps.gen.outputs.include }} + steps: + - id: gen + run: | + MATRIX=$(jq -c . <<'JSON' + {"include":[ + {"name":"linux-x86_64-gnu","build_runner":"ubuntu-24.04","mode":"glibc","build_image":"almalinux:8@sha256:4a87d2615a770506e204c27d6248ac97f4df67f4e41e2e9c47c81f0ed0be98cb","test_runner":"ubuntu-24.04","test_kind":"docker","test_images":"debian:11-slim@sha256:ff4b13408ab702565720c6b23582ebda7bfdddfe9ce2b8c5b49e6d40430fdb05 almalinux:8@sha256:4a87d2615a770506e204c27d6248ac97f4df67f4e41e2e9c47c81f0ed0be98cb","archive":".tar.gz"}, + {"name":"linux-x86_64-musl","build_runner":"ubuntu-24.04","mode":"alpine","build_image":"python:3.12-alpine@sha256:dbb1970cc04ce7d381c65efe8309c0c03d463e5b35c88f14d721796ad24cfbfd","test_runner":"ubuntu-24.04","test_kind":"docker","test_images":"alpine:3.20@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc","archive":".tar.gz"}, + {"name":"linux-aarch64-gnu","build_runner":"ubuntu-24.04-arm","mode":"glibc","build_image":"almalinux:8@sha256:4a87d2615a770506e204c27d6248ac97f4df67f4e41e2e9c47c81f0ed0be98cb","test_runner":"ubuntu-24.04-arm","test_kind":"docker","test_images":"debian:11-slim@sha256:ff4b13408ab702565720c6b23582ebda7bfdddfe9ce2b8c5b49e6d40430fdb05 almalinux:8@sha256:4a87d2615a770506e204c27d6248ac97f4df67f4e41e2e9c47c81f0ed0be98cb","archive":".tar.gz"}, + {"name":"linux-aarch64-musl","build_runner":"ubuntu-24.04-arm","mode":"alpine","build_image":"python:3.12-alpine@sha256:dbb1970cc04ce7d381c65efe8309c0c03d463e5b35c88f14d721796ad24cfbfd","test_runner":"ubuntu-24.04-arm","test_kind":"docker","test_images":"alpine:3.20@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc","archive":".tar.gz"}, + {"name":"macos-arm64","build_runner":"macos-14","mode":"native","build_image":"","test_runner":"macos-14","test_kind":"macos","test_images":"","archive":".tar.gz"}, + {"name":"macos-x86_64","build_runner":"macos-15-intel","mode":"native","build_image":"","test_runner":"macos-15-intel","test_kind":"macos","test_images":"","archive":".tar.gz"}, + {"name":"windows-x86_64","build_runner":"windows-2025","mode":"native","build_image":"","test_runner":"windows-2025","test_kind":"windows","test_images":"","archive":".zip"} + ]} + JSON + ) + echo "include=${MATRIX}" >> "$GITHUB_OUTPUT" + + build: + needs: matrix + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.matrix.outputs.include) }} + runs-on: ${{ matrix.build_runner }} + timeout-minutes: 45 + env: + PYTHON_VERSION: "3.12" + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - if: matrix.mode == 'native' + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - id: art + shell: bash + env: + TARGET: ${{ matrix.name }} + ARCHIVE: ${{ matrix.archive }} + run: echo "name=cloudsmith-$(cat cloudsmith_cli/data/VERSION)-${TARGET}${ARCHIVE}" >> "$GITHUB_OUTPUT" + + - if: matrix.mode == 'native' + shell: bash + env: + ART: ${{ steps.art.outputs.name }} + PYINSTALLER_CONFIG_DIR: ${{ runner.temp }}/pyinstaller + run: | + set -euo pipefail + python -m pip install \ + -c packaging/constraints.txt \ + ".[all]" \ + pyinstaller \ + pyinstaller-hooks-contrib + python -m PyInstaller --clean --noconfirm packaging/pyinstaller/cloudsmith.spec + mkdir -p out + case "${ART}" in + *.zip) (cd dist && 7z a -bso0 "../out/${ART}" cloudsmith) ;; + *) tar -czf "out/${ART}" -C dist cloudsmith ;; + esac + + - if: matrix.mode == 'alpine' + env: + ART: ${{ steps.art.outputs.name }} + BUILD_IMAGE: ${{ matrix.build_image }} + run: | + set -euo pipefail + mkdir -p out + docker run --rm \ + -e ART="${ART}" \ + -e PYINSTALLER_CONFIG_DIR=/tmp/pyinstaller \ + -v "${PWD}:/src" -w /src \ + "${BUILD_IMAGE}" sh -c ' + set -eu + apk add --no-cache build-base zlib-dev libffi-dev >/dev/null + python -m pip install \ + --root-user-action=ignore \ + -c packaging/constraints.txt \ + ".[all]" \ + pyinstaller \ + pyinstaller-hooks-contrib >/dev/null + python -m PyInstaller --clean --noconfirm packaging/pyinstaller/cloudsmith.spec + tar -czf "out/${ART}" -C dist cloudsmith + ' + + - if: matrix.mode == 'glibc' + env: + ART: ${{ steps.art.outputs.name }} + BUILD_IMAGE: ${{ matrix.build_image }} + run: | + set -euo pipefail + mkdir -p out + docker run --rm \ + -e ART="${ART}" \ + -e PYINSTALLER_CONFIG_DIR=/tmp/pyinstaller \ + -v "${PWD}:/src" -w /src \ + "${BUILD_IMAGE}" bash -c ' + set -euo pipefail + dnf install -y python3.12 python3.12-pip python3.12-devel gcc binutils file >/dev/null + python3.12 -m pip install \ + -c packaging/constraints.txt \ + ".[all]" \ + pyinstaller \ + pyinstaller-hooks-contrib >/dev/null + python3.12 -m PyInstaller --clean --noconfirm packaging/pyinstaller/cloudsmith.spec + MAXV="$( + while IFS= read -r -d "" candidate; do + if file "${candidate}" | grep -q ELF; then + objdump -T "${candidate}" 2>/dev/null \ + | grep -oE "GLIBC_[0-9.]+" || true + fi + done < <(find dist/cloudsmith -type f -print0) \ + | sed "s/GLIBC_//" | sort -V | tail -1 + )" + test -n "${MAXV}" + echo "glibc floor: ${MAXV}" + if [ "$(printf "2.28\n%s\n" "${MAXV}" | sort -V | tail -1)" != "2.28" ]; then + echo "FAIL: glibc floor ${MAXV} exceeds 2.28" + exit 1 + fi + tar -czf "out/${ART}" -C dist cloudsmith + ' + + - shell: bash + env: + ART: ${{ steps.art.outputs.name }} + run: | + set -euo pipefail + cd out + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "${ART}" | tee "${ART}.sha256" + else + shasum -a 256 "${ART}" | tee "${ART}.sha256" + fi + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: cloudsmith-${{ matrix.name }} + path: out/ + retention-days: 14 + if-no-files-found: error + + test: + needs: [matrix, build] + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.matrix.outputs.include) }} + runs-on: ${{ matrix.test_runner }} + timeout-minutes: 25 + env: + ONLINE: ${{ inputs.online_smoketest && '1' || '' }} + CLOUDSMITH_API_KEY: ${{ secrets.CLOUDSMITH_API_KEY }} + CLOUDSMITH_NAMESPACE: ${{ vars.CLOUDSMITH_NAMESPACE }} + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - id: art + shell: bash + env: + TARGET: ${{ matrix.name }} + ARCHIVE: ${{ matrix.archive }} + run: echo "name=cloudsmith-$(cat cloudsmith_cli/data/VERSION)-${TARGET}${ARCHIVE}" >> "$GITHUB_OUTPUT" + + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: cloudsmith-${{ matrix.name }} + path: ./dl + + - name: Verify archive and checksum + shell: bash + env: + ART: ${{ steps.art.outputs.name }} + run: | + set -euo pipefail + test -f "dl/${ART}" + test -f "dl/${ART}.sha256" + cd dl + if command -v sha256sum >/dev/null 2>&1; then + sha256sum -c "${ART}.sha256" + else + shasum -a 256 -c "${ART}.sha256" + fi + case "${ART}" in + *.zip) + 7z l -slt "${ART}" \ + | sed -n 's/^Path = //p' \ + | sed 's#\\#/#g' \ + | tail -n +2 \ + > entries.txt + ;; + *) tar -tzf "${ART}" > entries.txt ;; + esac + test -s entries.txt + if grep -Eq '(^|/)\.\.(/|$)|^/' entries.txt; then + echo "archive contains an unsafe path" + exit 1 + fi + if grep -Ev '^cloudsmith(/|$)' entries.txt; then + echo "archive contains files outside cloudsmith/" + exit 1 + fi + grep -Eq '^cloudsmith/cloudsmith(\.exe)?$' entries.txt + + - name: Require credentials for requested online smoketests + if: inputs.online_smoketest + shell: bash + run: test -n "${CLOUDSMITH_API_KEY:-}" + + - if: matrix.test_kind == 'docker' + env: + ART: ${{ steps.art.outputs.name }} + TEST_IMAGES: ${{ matrix.test_images }} + EXPECTED_VERSION: ${{ github.ref_name }} + run: | + set -euo pipefail + EXPECTED_VERSION=$(cat cloudsmith_cli/data/VERSION) + export EXPECTED_VERSION + tar -xzf "dl/${ART}" -C . + ASSERT_NO_PYTHON=1 + for IMAGE in ${TEST_IMAGES}; do + docker run --rm \ + -e ASSERT_NO_PYTHON="${ASSERT_NO_PYTHON}" \ + -e EXPECTED_VERSION \ + -v "${PWD}:/w" -w /w \ + "${IMAGE}" sh -c ' + if [ "${ASSERT_NO_PYTHON}" = 1 ]; then + for candidate in python python3 python3.10 python3.11 python3.12 python3.13 python3.14 pypy3; do + if command -v "${candidate}" >/dev/null 2>&1; then + echo "${candidate} present in clean-room image" + exit 1 + fi + done + fi + sh packaging/smoketest.sh cloudsmith/cloudsmith offline + ' + ASSERT_NO_PYTHON=0 + done + if [ -n "${ONLINE}" ] && [ -n "${CLOUDSMITH_API_KEY:-}" ]; then + IMAGE=${TEST_IMAGES%% *} + docker run --rm \ + -e CLOUDSMITH_API_KEY \ + -e CLOUDSMITH_NAMESPACE \ + -v "${PWD}:/w" -w /w \ + "${IMAGE}" sh -c ' + sh packaging/smoketest.sh cloudsmith/cloudsmith online + ' + fi + + - if: matrix.test_kind == 'macos' + env: + ART: ${{ steps.art.outputs.name }} + run: | + set -euo pipefail + export EXPECTED_VERSION + EXPECTED_VERSION=$(cat cloudsmith_cli/data/VERSION) + tar -xzf "dl/${ART}" -C . + env -i HOME="${HOME}" TMPDIR="${TMPDIR:-/tmp}" \ + "${PWD}/cloudsmith/cloudsmith" --version + sh packaging/smoketest.sh cloudsmith/cloudsmith offline + if [ -n "${ONLINE}" ] && [ -n "${CLOUDSMITH_API_KEY:-}" ]; then + sh packaging/smoketest.sh cloudsmith/cloudsmith online + fi + + - if: matrix.test_kind == 'windows' + shell: bash + env: + ART: ${{ steps.art.outputs.name }} + run: | + set -euo pipefail + export EXPECTED_VERSION + EXPECTED_VERSION=$(cat cloudsmith_cli/data/VERSION) + 7z x -bso0 "dl/${ART}" -o. + sh packaging/smoketest.sh cloudsmith/cloudsmith.exe offline + if [ -n "${ONLINE}" ] && [ -n "${CLOUDSMITH_API_KEY:-}" ]; then + sh packaging/smoketest.sh cloudsmith/cloudsmith.exe online + fi + + oidc: + needs: [matrix, build] + if: ${{ inputs.online_smoketest }} + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + id-token: write + contents: read + env: + CLOUDSMITH_ORG: ${{ vars.CLOUDSMITH_NAMESPACE }} + CLOUDSMITH_SERVICE_SLUG: ${{ vars.CLOUDSMITH_SERVICE_SLUG }} + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: cloudsmith-linux-x86_64-gnu + path: ./dl + + - name: Authenticate through GitHub OIDC + run: | + set -euo pipefail + VERSION=$(cat cloudsmith_cli/data/VERSION) + ART="cloudsmith-${VERSION}-linux-x86_64-gnu.tar.gz" + test -f "dl/${ART}" + tar -xzf "dl/${ART}" -C . + unset CLOUDSMITH_API_KEY + OUT=$(cloudsmith/cloudsmith whoami 2>&1) || { + printf '%s\n' "$OUT" + exit 1 + } + printf '%s\n' "$OUT" + if printf '%s' "$OUT" | grep -qiE 'anonymous|Nobody'; then + echo "FAIL: OIDC auth did not occur" + exit 1 + fi diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml index ac2d5d0b..dc5154e4 100644 --- a/.github/workflows/zizmor.yml +++ b/.github/workflows/zizmor.yml @@ -14,6 +14,10 @@ on: permissions: {} +concurrency: + group: security-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: zizmor: name: Scan GitHub Actions workflows diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 26d261af..fd3819c9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -83,6 +83,15 @@ repos: "-sn", # Don't display the score "--rcfile=.pylintrc", # Link to your config file ] + - id: binary-constraints + name: regenerate binary constraints + # Keep packaging/constraints.txt in sync with uv.lock for the PyInstaller + # build. Mirrors the CI "Verify lockfile and binary constraints" gate so + # drift is caught locally. Auto-regenerates (like black); re-stage on fail. + language: system + entry: uv export --locked --no-dev --group binary --extra all --no-emit-project --no-hashes --no-header -o packaging/constraints.txt + pass_filenames: false + files: ^(uv\.lock|pyproject\.toml|packaging/constraints\.txt)$ - repo: https://github.com/crate-ci/typos rev: v1.42.1 diff --git a/.typos.toml b/.typos.toml index bee0c0a0..7de47ad1 100644 --- a/.typos.toml +++ b/.typos.toml @@ -5,6 +5,8 @@ Mno-hime = "Mno-hime" [default.extend-words] # Proper names - do not correct hime = "hime" +# PyInstaller spec API: Analysis(datas=...) — not a typo of "data" +datas = "datas" McClory = "McClory" mcclory = "mcclory" Clory = "Clory" diff --git a/cloudsmith_cli/cli/commands/mcp.py b/cloudsmith_cli/cli/commands/mcp.py index 9a3f8041..20b2ee86 100644 --- a/cloudsmith_cli/cli/commands/mcp.py +++ b/cloudsmith_cli/cli/commands/mcp.py @@ -291,7 +291,7 @@ def configure(ctx, opts, client, is_global): # pylint: disable=unused-argument def _get_server_config(profile=None): """Determine the first available command configuration to run the MCP server.""" - # Check if running in a virtual environment + is_frozen = getattr(sys, "frozen", False) in_venv = hasattr(sys, "real_prefix") or ( hasattr(sys, "base_prefix") and sys.base_prefix != sys.prefix ) @@ -301,6 +301,9 @@ def _get_server_config(profile=None): if profile: base_args.extend(["-P", profile]) + if is_frozen: + return {"command": sys.executable, "args": base_args + ["mcp", "start"]} + # In a venv, always use python -m to ensure we use the venv's packages if in_venv: return { diff --git a/cloudsmith_cli/cli/tests/commands/test_credential_helper_install.py b/cloudsmith_cli/cli/tests/commands/test_credential_helper_install.py index 60c8037b..09391769 100644 --- a/cloudsmith_cli/cli/tests/commands/test_credential_helper_install.py +++ b/cloudsmith_cli/cli/tests/commands/test_credential_helper_install.py @@ -6,6 +6,7 @@ import json import os import stat +import sys from pathlib import Path from unittest.mock import patch @@ -716,3 +717,45 @@ def test_uninstall_tolerates_malformed_cred_helpers(tmp_path, monkeypatch): installer = DockerInstaller() # Must not raise installer.uninstall(bin_dir=str(bin_dir)) + + +# --------------------------------------------------------------------------- +# 18. frozen-binary launcher target (PyInstaller standalone) +# --------------------------------------------------------------------------- + + +def test_default_install_launcher_uses_bare_command(tmp_path, monkeypatch): + """A pip/source install writes a launcher that forwards to the bare + ``cloudsmith`` command resolved via PATH.""" + docker_dir = tmp_path / ".docker" + monkeypatch.setenv("DOCKER_CONFIG", str(docker_dir)) + bin_dir = tmp_path / "bin" + monkeypatch.setenv("PATH", str(bin_dir)) + + DockerInstaller().install(bin_dir=str(bin_dir), discover=False) + + body = (bin_dir / "docker-credential-cloudsmith").read_text(encoding="utf-8") + assert "exec cloudsmith credential-helper docker" in body + + +def test_frozen_install_launcher_targets_executable(tmp_path, monkeypatch): + """A frozen install writes a launcher that execs the absolute executable. + + A standalone binary is not guaranteed to be on PATH as ``cloudsmith``, so a + bare-command launcher would leave Docker unable to find the helper. + """ + docker_dir = tmp_path / ".docker" + monkeypatch.setenv("DOCKER_CONFIG", str(docker_dir)) + bin_dir = tmp_path / "bin" + monkeypatch.setenv("PATH", str(bin_dir)) + exe = tmp_path / "cloudsmith" + + with ( + patch.object(sys, "frozen", True, create=True), + patch.object(sys, "executable", str(exe)), + ): + DockerInstaller().install(bin_dir=str(bin_dir), discover=False) + + body = (bin_dir / "docker-credential-cloudsmith").read_text(encoding="utf-8") + assert str(exe) in body + assert "exec cloudsmith credential-helper" not in body diff --git a/cloudsmith_cli/cli/tests/commands/test_mcp.py b/cloudsmith_cli/cli/tests/commands/test_mcp.py index b95007bf..9507f8a7 100644 --- a/cloudsmith_cli/cli/tests/commands/test_mcp.py +++ b/cloudsmith_cli/cli/tests/commands/test_mcp.py @@ -1,6 +1,7 @@ import json import os import stat +import sys from pathlib import Path from unittest.mock import patch @@ -10,6 +11,7 @@ from ....cli.commands.mcp import ( _atomic_write_json, _configure_claude_code, + _get_server_config, _safe_update_json, configure_client, detect_available_clients, @@ -373,6 +375,18 @@ def test_server_respects_tool_filtering(self): SERVER_CONFIG = {"command": "cloudsmith", "args": ["mcp", "start"]} +class TestMCPServerConfig: + def test_frozen_executable_runs_mcp_directly(self): + with ( + patch.object(sys, "frozen", True, create=True), + patch.object(sys, "executable", "/opt/cloudsmith/cloudsmith"), + ): + assert _get_server_config("staging") == { + "command": "/opt/cloudsmith/cloudsmith", + "args": ["-P", "staging", "mcp", "start"], + } + + class TestMCPConfigureClaudeCode: def test_user_scope_merges_into_existing_claude_json(self, tmp_path): claude_json = tmp_path / ".claude.json" diff --git a/cloudsmith_cli/credential_helpers/docker/installer.py b/cloudsmith_cli/credential_helpers/docker/installer.py index 2609ed28..ea518f4d 100644 --- a/cloudsmith_cli/credential_helpers/docker/installer.py +++ b/cloudsmith_cli/credential_helpers/docker/installer.py @@ -11,6 +11,7 @@ import json import logging import os +import sys from pathlib import Path from ...core.cache_utils import merge_json_file @@ -56,6 +57,21 @@ class DockerInstaller: name = "docker" summary = "Docker credential helper for Cloudsmith registries" + @classmethod + def _resolve_target_cmd(cls) -> str: + """Return the command the launcher forwards to. + + A pip/source install resolves the bare ``cloudsmith`` command via + ``PATH``. A frozen standalone binary (PyInstaller) is not guaranteed + to be on ``PATH`` under that name, so point the launcher at the + absolute executable instead — mirroring the frozen handling in + :func:`cloudsmith_cli.cli.commands.mcp._get_server_config`. The path + is quoted so a directory containing spaces still execs correctly. + """ + if getattr(sys, "frozen", False): + return f'"{sys.executable}" credential-helper docker' + return cls.TARGET_CMD + def install( self, *, @@ -190,7 +206,9 @@ def mutate(config: dict) -> None: return actions # Real install - launcher_path = write_launcher(target_dir, self.LAUNCHER_NAME, self.TARGET_CMD) + launcher_path = write_launcher( + target_dir, self.LAUNCHER_NAME, self._resolve_target_cmd() + ) actions.append(f"wrote launcher {launcher_path}") changed = merge_json_file(config_path, mutate) diff --git a/packaging/constraints.txt b/packaging/constraints.txt new file mode 100644 index 00000000..16771205 --- /dev/null +++ b/packaging/constraints.txt @@ -0,0 +1,219 @@ +altgraph==0.17.5 + # via + # macholib + # pyinstaller +annotated-types==0.7.0 + # via pydantic +anyio==4.12.1 + # via + # httpx + # mcp + # sse-starlette + # starlette +attrs==26.1.0 + # via + # jsonschema + # referencing +awscrt==0.32.2 + # via botocore +backports-tarfile==1.2.0 ; python_full_version < '3.12' + # via jaraco-context +boto3==1.43.29 + # via cloudsmith-cli +botocore==1.43.29 + # via + # boto3 + # s3transfer +certifi==2026.1.4 + # via + # cloudsmith-api + # httpcore + # httpx + # requests +cffi==2.0.0 ; platform_python_implementation != 'PyPy' + # via cryptography +charset-normalizer==3.4.7 + # via requests +click==8.4.1 + # via + # click-configfile + # click-didyoumean + # cloudsmith-cli + # uvicorn +click-configfile==0.2.3 + # via cloudsmith-cli +click-didyoumean==0.3.1 + # via cloudsmith-cli +click-spinner==0.1.10 + # via cloudsmith-cli +cloudsmith-api==2.0.27 + # via cloudsmith-cli +colorama==0.4.6 ; sys_platform == 'win32' + # via click +configparser==7.2.0 + # via click-configfile +cryptography==49.0.0 + # via + # pyjwt + # secretstorage +exceptiongroup==1.3.1 ; python_full_version < '3.11' + # via anyio +h11==0.16.0 + # via + # httpcore + # uvicorn +httpcore==1.0.9 + # via httpx +httpx==0.28.1 + # via mcp +httpx-sse==0.4.3 + # via mcp +idna==3.18 + # via + # anyio + # httpx + # requests +importlib-metadata==9.0.0 ; python_full_version < '3.12' + # via keyring +jaraco-classes==3.4.0 + # via keyring +jaraco-context==6.1.2 + # via keyring +jaraco-functools==4.5.0 + # via keyring +jeepney==0.9.0 ; sys_platform == 'linux' + # via + # keyring + # secretstorage +jmespath==1.1.0 + # via + # boto3 + # botocore +json5==0.14.0 + # via cloudsmith-cli +jsonschema==4.26.0 + # via mcp +jsonschema-specifications==2025.9.1 + # via jsonschema +keyring==25.7.0 + # via cloudsmith-cli +macholib==1.16.4 ; sys_platform == 'darwin' + # via pyinstaller +markdown-it-py==4.2.0 + # via rich +mcp==1.27.2 + # via cloudsmith-cli +mdurl==0.1.2 + # via markdown-it-py +more-itertools==11.1.0 + # via + # jaraco-classes + # jaraco-functools +packaging==26.2 + # via + # pyinstaller + # pyinstaller-hooks-contrib + # wheel +pefile==2024.8.26 ; sys_platform == 'win32' + # via pyinstaller +pycparser==3.0 ; implementation_name != 'PyPy' and platform_python_implementation != 'PyPy' + # via cffi +pydantic==2.12.5 + # via + # mcp + # pydantic-settings +pydantic-core==2.41.5 + # via pydantic +pydantic-settings==2.14.1 + # via mcp +pygments==2.20.0 + # via rich +pyinstaller==6.20.0 +pyinstaller-hooks-contrib==2026.5 + # via pyinstaller +pyjwt==2.13.0 + # via + # cloudsmith-cli + # mcp +python-dateutil==2.9.0.post0 + # via + # botocore + # cloudsmith-api +python-dotenv==1.2.2 + # via pydantic-settings +python-multipart==0.0.32 + # via mcp +python-toon==0.1.2 + # via cloudsmith-cli +pywin32==312 ; sys_platform == 'win32' + # via mcp +pywin32-ctypes==0.2.3 ; sys_platform == 'win32' + # via + # keyring + # pyinstaller +referencing==0.37.0 + # via + # jsonschema + # jsonschema-specifications +requests==2.34.2 + # via + # cloudsmith-cli + # requests-toolbelt +requests-toolbelt==1.0.0 + # via cloudsmith-cli +rich==14.3.3 + # via cloudsmith-cli +rpds-py==0.30.0 + # via + # jsonschema + # referencing +s3transfer==0.18.0 + # via boto3 +secretstorage==3.5.0 ; sys_platform == 'linux' + # via keyring +semver==3.0.4 + # via cloudsmith-cli +setuptools==82.0.1 + # via + # pyinstaller + # pyinstaller-hooks-contrib +six==1.17.0 + # via + # click-configfile + # cloudsmith-api + # python-dateutil +sse-starlette==3.4.4 + # via mcp +starlette==1.3.1 + # via + # mcp + # sse-starlette +typing-extensions==4.15.0 + # via + # anyio + # cryptography + # exceptiongroup + # mcp + # pydantic + # pydantic-core + # pyjwt + # referencing + # starlette + # typing-inspection + # uvicorn +typing-inspection==0.4.2 + # via + # mcp + # pydantic + # pydantic-settings +urllib3==2.7.0 + # via + # botocore + # cloudsmith-api + # cloudsmith-cli + # requests +uvicorn==0.49.0 ; sys_platform != 'emscripten' + # via mcp +wheel==0.47.0 +zipp==4.1.0 ; python_full_version < '3.12' + # via importlib-metadata diff --git a/packaging/pyinstaller/cloudsmith.spec b/packaging/pyinstaller/cloudsmith.spec new file mode 100644 index 00000000..ed802e40 --- /dev/null +++ b/packaging/pyinstaller/cloudsmith.spec @@ -0,0 +1,70 @@ +# -*- mode: python ; coding: utf-8 -*- +# PyInstaller onedir spec for the Cloudsmith CLI. Built natively per target. +# onedir (not onefile): onefile re-extracts the whole bundle on every +# invocation (~6s/run); onedir starts in ~0.4s. Distributed as tar.gz/zip. + +from PyInstaller.utils.hooks import ( + collect_data_files, + collect_submodules, + copy_metadata, +) + +datas, binaries, hiddenimports = [], [], [] + +datas += collect_data_files( + "cloudsmith_cli", + includes=["data/*", "templates/*"], +) +datas += collect_data_files("mcp", includes=["py.typed"]) + +# mcp.cli imports the optional `typer` dependency. Keep mcp.client and exclude +# only the CLI package itself and its descendants. +hiddenimports += collect_submodules( + "mcp", + filter=lambda name: name != "mcp.cli" and not name.startswith("mcp.cli."), +) +hiddenimports += collect_submodules("keyring.backends") +hiddenimports += ["boto3", "botocore.exceptions"] + +for dist in ("cloudsmith-cli", "cloudsmith-api", "mcp", "keyring"): + datas += copy_metadata(dist) + +a = Analysis( + ["entry.py"], + pathex=[], + binaries=binaries, + datas=datas, + hiddenimports=hiddenimports, + excludes=[ + "tkinter", + "pytest", + "pylint", + "black", + "isort", + "mcp.cli", + "cloudsmith_cli.cli.tests", + "cloudsmith_cli.core.tests", + ], +) + +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name="cloudsmith", + console=True, + strip=False, + upx=False, +) + +coll = COLLECT( + exe, + a.binaries, + a.datas, + name="cloudsmith", + strip=False, + upx=False, +) diff --git a/packaging/pyinstaller/entry.py b/packaging/pyinstaller/entry.py new file mode 100644 index 00000000..fb949f83 --- /dev/null +++ b/packaging/pyinstaller/entry.py @@ -0,0 +1,70 @@ +import importlib +import os +import pkgutil +import sys + +import cloudsmith_cli +from cloudsmith_cli.cli.commands.main import main + + +def _force_utf8_output() -> None: + """Prevent UnicodeEncodeError on legacy Windows consoles. + + A frozen Windows console defaults to a legacy code page (e.g. cp1252) that + cannot encode the check/cross/warning UI glyphs the CLI prints; without + this, commands such as ``mcp configure`` and the download progress output + crash with UnicodeEncodeError. Reconfiguring the streams to UTF-8 is a + no-op on POSIX (already UTF-8) and is skipped when a stream cannot be + reconfigured (e.g. a redirected non-text stream). + """ + for stream in (sys.stdout, sys.stderr): + reconfigure = getattr(stream, "reconfigure", None) + if reconfigure is not None: + try: + reconfigure(encoding="utf-8", errors="backslashreplace") + except (ValueError, OSError): + pass + + +def _selftest() -> int: + """Import every bundled ``cloudsmith_cli`` module; fail on any ImportError. + + Runtime check that the freeze is complete: ``pkgutil.walk_packages`` + enumerates the package inside the onedir bundle (PyInstaller's pkgutil + runtime hook makes this work) and each module is imported. A module the + binary needs but PyInstaller did not collect surfaces here as an + ImportError instead of crashing a user at runtime. Triggered only by the + ``CLOUDSMITH_SELFTEST`` env var (set by the packaging smoketest), so it is + never reachable as a normal CLI command. Data-file, dist-metadata, and + dynamic-dispatch paths (which importing a module does not exercise) are + covered by the functional smoketest steps, not here. + """ + failed = [] + + def _onerror(name): + failed.append(f"{name}: {sys.exc_info()[1]!r}") + + count = 0 + for info in pkgutil.walk_packages( + cloudsmith_cli.__path__, "cloudsmith_cli.", onerror=_onerror + ): + count += 1 + try: + importlib.import_module(info.name) + except Exception as exc: # pylint: disable=broad-except + failed.append(f"{info.name}: {exc!r}") + + if count == 0: + failed.append("walk_packages enumerated 0 modules (frozen sweep broken)") + + for line in failed: + print(f"SELFTEST missing: {line}") + print(f"SELFTEST: {'FAIL' if failed else 'OK'} ({count} modules)") + return 1 if failed else 0 + + +if __name__ == "__main__": + _force_utf8_output() + if os.environ.get("CLOUDSMITH_SELFTEST"): + sys.exit(_selftest()) + main() # pylint: disable=no-value-for-parameter diff --git a/packaging/smoketest.sh b/packaging/smoketest.sh new file mode 100644 index 00000000..741a3ea1 --- /dev/null +++ b/packaging/smoketest.sh @@ -0,0 +1,227 @@ +#!/usr/bin/env sh +# Smoketest the standalone Cloudsmith binary. +# smoketest.sh [offline|online] +# offline: no network, runs in a clean no-Python environment. +# online: read-only API checks; needs CLOUDSMITH_API_KEY (+ CLOUDSMITH_NAMESPACE). +# Pass = exit 0 + expected output + no import/dep errors. + +set -eu + +BIN="${1:?usage: smoketest.sh [offline|online]}" +MODE="${2:-offline}" + +case "$BIN" in + */*) : ;; + *) BIN="./$BIN" ;; +esac +BIN="$(cd "$(dirname "$BIN")" && pwd)/$(basename "$BIN")" + +fail() { echo "SMOKETEST FAIL: $1" >&2; exit 1; } + +# Flag a missing native wheel / uncollected import. Not a generic traceback +# check: frozen stdio servers emit a benign "closed file" message at teardown. +no_dep_error() { + if printf '%s' "$1" | grep -Eq 'ModuleNotFoundError|ImportError|No module named|cannot import name|DLL load failed|failed to map segment|GLIBC_'; then + printf '%s\n' "$1" >&2 + fail "import/dep error during: $2" + fi +} + +# Run a read-only online command; a 429 is the shared org throttling, not a +# binary failure, so warn and pass. +online_call() { + _label="$1"; shift + _out=$("$BIN" "$@" 2>&1) || { + if printf '%s' "$_out" | grep -Eq '429|rate limit|Too Many Requests'; then + echo "WARN: rate-limited (429) on ${_label}; shared org throttling, not a binary failure" >&2 + return 0 + fi + printf '%s\n' "$_out"; fail "online ${_label} failed" + } + no_dep_error "$_out" "$_label" + printf '%s\n' "$_out" | head -15 +} + +echo "== binary: $BIN (mode=$MODE) ==" +ls -lh "$BIN" 2>/dev/null || true + +# Negative test: prove the import/dep detector actually fires, so a real +# missing-wheel error can never slip past it silently. +if ( no_dep_error "ModuleNotFoundError: No module named 'sanitycheck'" "gate-selftest" ) 2>/dev/null; then + fail "no_dep_error gate did not catch a planted import error (detector broken)" +fi +echo "== gate self-test OK (import/dep detector fires) ==" + +run_offline() { + echo "== self-import sweep (every bundled cloudsmith_cli module loads) ==" + # Runtime proof the freeze is complete: import every bundled module. Catches + # an uncollected module deterministically, replacing the static warn-file + # gate. Functional data/metadata/dynamic paths are covered by the steps below. + SWEEP=$( ( + unset CLOUDSMITH_API_KEY CLOUDSMITH_API_TOKEN 2>/dev/null + CLOUDSMITH_SELFTEST=1 PYTHON_KEYRING_BACKEND=keyring.backends.null.Keyring \ + "$BIN" 2>&1 + ) || true ) + no_dep_error "$SWEEP" "self-import sweep" + printf '%s\n' "$SWEEP" | tail -1 + printf '%s\n' "$SWEEP" | grep -q "SELFTEST: OK" \ + || fail "self-import sweep reported missing modules: $SWEEP" + + echo "== --version ==" + VERSION_OUT=$("$BIN" --version) || fail "--version exited nonzero" + printf '%s\n' "$VERSION_OUT" + if [ -n "${EXPECTED_VERSION:-}" ]; then + printf '%s\n' "$VERSION_OUT" | grep -Fq "CLI Package Version: ${EXPECTED_VERSION}" \ + || fail "--version did not report ${EXPECTED_VERSION}" + fi + + echo "== --help ==" + "$BIN" --help >/dev/null || fail "--help exited nonzero" + + echo "== mcp --help ==" + "$BIN" mcp --help >/dev/null || fail "mcp --help failed" + + echo "== per-command --help sweep (forces every command module to import) ==" + # Parse top-level command names from --help (first alias before any '|') and + # run --help on each. Catches a command whose module / option construction + # pulls an import PyInstaller did not collect. + # Stop at the blank line ending the Commands block so a wrapped epilog line + # (e.g. a docs URL) can never be parsed as a bogus command name. + CMDS=$("$BIN" --help 2>/dev/null | awk '/^Commands:/{f=1; next} f && /^[[:space:]]*$/{f=0} f && /^[[:space:]]+[a-z]/{print $1}' | cut -d'|' -f1) + [ -n "$CMDS" ] || fail "could not parse command list from --help" + for c in $CMDS; do + "$BIN" "$c" --help >/dev/null 2>&1 || fail "${c} --help failed" + done + echo "swept $(printf '%s\n' "$CMDS" | wc -l | tr -d ' ') commands" + + echo "== whoami (keyring/auth path) ==" + OUT=$( ( + unset CLOUDSMITH_API_KEY CLOUDSMITH_API_TOKEN 2>/dev/null + CLOUDSMITH_API_HOST=http://127.0.0.1:9 \ + CLOUDSMITH_OIDC_DISCOVERY_DISABLED=true \ + PYTHON_KEYRING_BACKEND=keyring.backends.null.Keyring \ + "$BIN" whoami 2>&1 + ) || true ) + no_dep_error "$OUT" "whoami" + printf '%s\n' "$OUT" | head -5 + + echo "== AWS OIDC dependency load ==" + OUT=$( ( + unset CLOUDSMITH_API_KEY CLOUDSMITH_API_TOKEN 2>/dev/null + AWS_ACCESS_KEY_ID=smoketest \ + AWS_SECRET_ACCESS_KEY=smoketest \ + AWS_EC2_METADATA_DISABLED=true \ + AWS_ENDPOINT_URL_STS=http://127.0.0.1:9 \ + AWS_MAX_ATTEMPTS=1 \ + CLOUDSMITH_API_HOST=http://127.0.0.1:9 \ + PYTHON_KEYRING_BACKEND=keyring.backends.null.Keyring \ + "$BIN" whoami 2>&1 + ) || true ) + no_dep_error "$OUT" "AWS OIDC dependency load" + + echo "== credential-helper docker (offline) ==" + OUT=$( ( + unset CLOUDSMITH_API_KEY CLOUDSMITH_API_TOKEN 2>/dev/null + printf 'docker.cloudsmith.io' | CLOUDSMITH_OIDC_DISCOVERY_DISABLED=true \ + PYTHON_KEYRING_BACKEND=keyring.backends.null.Keyring \ + "$BIN" credential-helper docker 2>&1 + ) || true ) + no_dep_error "$OUT" "credential-helper docker" + printf '%s\n' "$OUT" | grep -q "Unable to retrieve credentials" \ + || fail "credential-helper did not emit expected message; got: $OUT" + + echo "== credential-helper install docker (frozen launcher self-references binary) ==" + # A standalone binary is not guaranteed to be on PATH as `cloudsmith`, so the + # frozen install must point the docker-credential launcher at the absolute + # executable. Prove it by running the launcher with an empty PATH: a bare + # `cloudsmith` lookup would fail, so reaching the CLI proves the self-reference. + INSTALL_TMP="$(mktemp -d 2>/dev/null || echo /tmp/cloudsmith-credhelper-install)" + mkdir -p "$INSTALL_TMP/bin" "$INSTALL_TMP/docker" + OUT=$( ( + unset CLOUDSMITH_API_KEY CLOUDSMITH_API_TOKEN 2>/dev/null + DOCKER_CONFIG="$INSTALL_TMP/docker" \ + CLOUDSMITH_OIDC_DISCOVERY_DISABLED=true \ + PYTHON_KEYRING_BACKEND=keyring.backends.null.Keyring \ + "$BIN" credential-helper install docker \ + --bin-dir "$INSTALL_TMP/bin" --no-discover 2>&1 + ) || true ) + no_dep_error "$OUT" "credential-helper install docker" + case "$BIN" in + *.exe) + LAUNCHER="$INSTALL_TMP/bin/docker-credential-cloudsmith.cmd" + [ -f "$LAUNCHER" ] || fail "install did not write a launcher; got: $OUT" + grep -Eq '"[^"]*cloudsmith\.exe"' "$LAUNCHER" \ + || fail "frozen launcher must forward to the executable; got: $(cat "$LAUNCHER")" + ;; + *) + LAUNCHER="$INSTALL_TMP/bin/docker-credential-cloudsmith" + [ -f "$LAUNCHER" ] || fail "install did not write a launcher; got: $OUT" + LOUT=$( ( + unset CLOUDSMITH_API_KEY CLOUDSMITH_API_TOKEN 2>/dev/null + printf 'docker.cloudsmith.io' | PATH= \ + CLOUDSMITH_OIDC_DISCOVERY_DISABLED=true \ + PYTHON_KEYRING_BACKEND=keyring.backends.null.Keyring \ + "$LAUNCHER" 2>&1 + ) || true ) + no_dep_error "$LOUT" "frozen docker-credential launcher exec" + printf '%s' "$LOUT" | grep -q "Unable to retrieve credentials" \ + || fail "frozen launcher did not reach the CLI (bare cloudsmith off PATH?); got: $LOUT" + ;; + esac + + echo "== frozen mcp configure command ==" + CONFIG_TMP="$(mktemp -d 2>/dev/null || echo /tmp/cloudsmith-mcp-config)" + mkdir -p "$CONFIG_TMP" + ( + cd "$CONFIG_TMP" + HOME="$CONFIG_TMP" "$BIN" mcp configure --client cursor --local >/dev/null + ) || fail "mcp configure failed" + CONFIG="$CONFIG_TMP/.cursor/mcp.json" + [ -f "$CONFIG" ] || fail "mcp configure did not create cursor config" + case "$BIN" in + *.exe) + grep -Eq '"command":[[:space:]]*"[^"]*cloudsmith\.exe"' "$CONFIG" \ + || fail "frozen mcp config did not use the executable directly" + ;; + *) + grep -Fq "\"command\": \"$BIN\"" "$CONFIG" \ + || fail "frozen mcp config did not use the executable directly" + ;; + esac + if grep -Fq '"-m"' "$CONFIG"; then + fail "frozen mcp config contains an invalid Python -m invocation" + fi + +} + +run_online() { + [ -n "${CLOUDSMITH_API_KEY:-}" ] || fail "online mode but CLOUDSMITH_API_KEY is empty" + + # Auth + cloudsmith_api model deserialization. + echo "== whoami (online auth) ==" + online_call "whoami" whoami + + # Read-only listing — broader cloudsmith_api coverage. + if [ -n "${CLOUDSMITH_NAMESPACE:-}" ]; then + echo "== list repos $CLOUDSMITH_NAMESPACE (read-only) ==" + online_call "list repos" list repos "$CLOUDSMITH_NAMESPACE" + fi + + # Fetches the OpenAPI spec over httpx and builds pydantic tool models: + # exercises pydantic-core (deeper than the initialize handshake) plus the + # native jsonschema/rpds-py validation path and httpx TLS. + echo "== mcp list_tools (pydantic-core + jsonschema/rpds-py + httpx TLS) ==" + online_call "mcp list_tools" mcp list_tools + + # requests/urllib3 + certifi CA bundle + the semver version-compare path. + echo "== check service (requests/urllib3/certifi TLS + semver) ==" + online_call "check service" check service +} + +case "$MODE" in + offline) run_offline ;; + online) run_online ;; + *) fail "unknown mode: $MODE" ;; +esac + +echo "ALL SMOKETESTS PASSED ($MODE)"