From 0953da965cba21cdf800f84c16eef0aa50fff60f Mon Sep 17 00:00:00 2001 From: Bartosz Blizniak Date: Fri, 19 Jun 2026 10:40:45 +0100 Subject: [PATCH 1/2] feat: publish standalone binaries via the release + Docker pipelines (ENG-12827) Wire the standalone PyInstaller binaries (ENG-12826) into the release and Docker pipelines, dogfooding the freshly-built cloudsmith binary to authenticate (GitHub OIDC) and publish: - Publish per-platform archives + SHA256 to Cloudsmith and build/push the container image, using the built binary + OIDC and the Docker credential helper instead of cloudsmith-cli-action / static-key docker login. - GPG-sign the Linux archives (detached .sig sibling files). - Idempotent, immutable-repo-safe publishing (skip already-published artifacts/tags). - Tag raw uploads (os/arch/libc/target/type) for queryable CI selection. - Dockerfile: multi-stage Alpine, musl binary, non-root, OCI labels; Docker Hub floating tags. - CI hardening + perf: single-source target list, composite bootstrap action, GH_REPO for gh, trimmed clean-room docker runs, pinned runners. - Redact PII from online smoketest logs (SMOKETEST_DEBUG for detail). Co-Authored-By: Claude Opus 4.8 --- .../setup-cloudsmith-binary/action.yml | 34 + .github/workflows/binaries.yml | 15 +- .github/workflows/lint.yml | 2 +- .github/workflows/release.yml | 681 ++++++++++++++---- .github/workflows/test.yml | 2 +- CHANGELOG.md | 15 + Dockerfile | 51 +- README.md | 24 + packaging/smoketest.sh | 11 +- 9 files changed, 654 insertions(+), 181 deletions(-) create mode 100644 .github/actions/setup-cloudsmith-binary/action.yml diff --git a/.github/actions/setup-cloudsmith-binary/action.yml b/.github/actions/setup-cloudsmith-binary/action.yml new file mode 100644 index 00000000..7ff09e6c --- /dev/null +++ b/.github/actions/setup-cloudsmith-binary/action.yml @@ -0,0 +1,34 @@ +name: Set up cloudsmith binary +description: >- + Extract the linux-x86_64-gnu cloudsmith binary archive onto the runner and put + it on PATH so jobs can dogfood the built CLI. + +inputs: + version: + description: Release version (matches the binaries/cloudsmith--... archive). + required: true + add-local-bin: + description: Also create ~/.local/bin and add it to PATH (for the docker credential helper launcher). + required: false + default: "false" + +runs: + using: composite + steps: + - shell: bash + env: + VERSION: ${{ inputs.version }} + ADD_LOCAL_BIN: ${{ inputs.add-local-bin }} + run: | # zizmor: ignore[github-env] + set -euo pipefail + ARCHIVE="binaries/cloudsmith-${VERSION}-linux-x86_64-gnu.tar.gz" + test -f "${ARCHIVE}" + DEST="${RUNNER_TEMP}/cloudsmith-cli" + mkdir -p "${DEST}" + tar -xzf "${ARCHIVE}" -C "${DEST}" + chmod +x "${DEST}/cloudsmith/cloudsmith" + echo "${DEST}/cloudsmith" >> "$GITHUB_PATH" + if [ "${ADD_LOCAL_BIN}" = "true" ]; then + mkdir -p "${HOME}/.local/bin" + echo "${HOME}/.local/bin" >> "$GITHUB_PATH" + fi diff --git a/.github/workflows/binaries.yml b/.github/workflows/binaries.yml index 185ce0ab..d25ef6ae 100644 --- a/.github/workflows/binaries.yml +++ b/.github/workflows/binaries.yml @@ -105,9 +105,9 @@ jobs: 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-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","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-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","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"}, @@ -186,15 +186,23 @@ jobs: tar -czf "out/${ART}" -C dist cloudsmith ' + # Cache pip's download cache only (not site-packages); resolution still runs. + - if: matrix.mode == 'glibc' + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: ${{ github.workspace }}/.pip-cache + key: pip-glibc-${{ matrix.name }}-${{ hashFiles('packaging/constraints.txt', 'uv.lock') }} + - if: matrix.mode == 'glibc' env: ART: ${{ steps.art.outputs.name }} BUILD_IMAGE: ${{ matrix.build_image }} run: | set -euo pipefail - mkdir -p out + mkdir -p out .pip-cache docker run --rm \ -e ART="${ART}" \ + -e PIP_CACHE_DIR=/src/.pip-cache \ -e PYINSTALLER_CONFIG_DIR=/tmp/pyinstaller \ -v "${PWD}:/src" -w /src \ "${BUILD_IMAGE}" bash -c ' @@ -315,7 +323,6 @@ jobs: 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) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 5ec4b3aa..8a51ab7c 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -17,7 +17,7 @@ concurrency: jobs: lint: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7dffd8a8..5cdb8811 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,163 +1,528 @@ -name: Create Zipapp and Release +name: Release on: - push: - tags: - - "v*" + push: + tags: + - "v*" + +permissions: + contents: read + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +env: + # Single source of truth for binary targets (also update the binaries.yml matrix). + BINARY_TARGETS: "linux-x86_64-gnu linux-x86_64-musl linux-aarch64-gnu linux-aarch64-musl macos-arm64 macos-x86_64 windows-x86_64" jobs: - # Build and publish to GitHub, Cloudsmith (zipapp + Docker) - build: - name: Build and publish artifacts - runs-on: ubuntu-latest - permissions: - id-token: write - contents: write + validate: + runs-on: ubuntu-24.04 + outputs: + version: ${{ steps.version.outputs.version }} + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - id: version + name: Validate tag and package version + run: | + set -euo pipefail + VERSION=$(cat cloudsmith_cli/data/VERSION) + test "${GITHUB_REF_NAME}" = "v${VERSION}" + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + + binaries: + name: Binaries + needs: validate + uses: ./.github/workflows/binaries.yml + with: + online_smoketest: true + permissions: + contents: read + id-token: write + secrets: + CLOUDSMITH_API_KEY: ${{ secrets.CLOUDSMITH_API_KEY }} + + python-distributions: + needs: validate + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.10" + + - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 # zizmor: ignore[cache-poisoning] + with: + enable-cache: true + + - name: Build and validate once + env: + VERSION: ${{ needs.validate.outputs.version }} + run: | + set -euo pipefail + uv sync --locked --group release + uv build --clear + SDIST="dist/cloudsmith_cli-${VERSION}.tar.gz" + WHEEL="dist/cloudsmith_cli-${VERSION}-py3-none-any.whl" + test -f "${SDIST}" + test -f "${WHEEL}" + uv run --group release twine check "${SDIST}" "${WHEEL}" + uv run --group release check-wheel-contents "${WHEEL}" + python - <<'PY' + import os + import zipfile + + wheel = f"dist/cloudsmith_cli-{os.environ['VERSION']}-py3-none-any.whl" + with zipfile.ZipFile(wheel) 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 + python -m venv /tmp/cloudsmith-release-install + /tmp/cloudsmith-release-install/bin/python -m pip install "${WHEEL}" + OUTPUT=$(/tmp/cloudsmith-release-install/bin/cloudsmith --version) + printf '%s\n' "${OUTPUT}" + printf '%s\n' "${OUTPUT}" | grep -Fq "CLI Package Version: ${VERSION}" + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: python-distributions + path: dist/ + retention-days: 14 + if-no-files-found: error + + sign-linux: + needs: [validate, binaries] + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + env: + VERSION: ${{ needs.validate.outputs.version }} + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: cloudsmith-linux-* + merge-multiple: true + path: binaries + + - name: Import signing key and create detached signatures + env: + GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} + GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} + run: | + set -euo pipefail + export GNUPGHOME="$(mktemp -d)" + chmod 700 "$GNUPGHOME" + printf 'allow-loopback-pinentry\n' > "$GNUPGHOME/gpg-agent.conf" + printf '%s' "$GPG_PRIVATE_KEY" | gpg --batch --import + for TARGET in ${BINARY_TARGETS}; do + case "${TARGET}" in + linux-*) ;; + *) continue ;; + esac + ARCHIVE="binaries/cloudsmith-${VERSION}-${TARGET}.tar.gz" + test -f "${ARCHIVE}" + printf '%s' "${GPG_PASSPHRASE}" \ + | gpg --batch --yes --pinentry-mode loopback --passphrase-fd 0 \ + --detach-sign --output "${ARCHIVE}.sig" "${ARCHIVE}" + gpg --verify "${ARCHIVE}.sig" "${ARCHIVE}" + done + gpgconf --kill gpg-agent || true + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: linux-signatures + path: binaries/*.sig + if-no-files-found: error + retention-days: 14 + + stage-github: + needs: [validate, binaries, python-distributions, sign-linux] + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: write + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + VERSION: ${{ needs.validate.outputs.version }} + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: cloudsmith-* + merge-multiple: true + path: binaries + + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: python-distributions + path: python + + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: linux-signatures + path: binaries + + - name: Stage verified assets on a draft release + run: | + set -euo pipefail + ASSETS=() + SUM_ARCHIVES=() + for TARGET in ${BINARY_TARGETS}; do + EXT=.tar.gz + if [ "${TARGET}" = windows-x86_64 ]; then + EXT=.zip + fi + ARCHIVE="binaries/cloudsmith-${VERSION}-${TARGET}${EXT}" + CHECKSUM="${ARCHIVE}.sha256" + test -f "${ARCHIVE}" + test -f "${CHECKSUM}" + ASSETS+=("${ARCHIVE}" "${CHECKSUM}") + SUM_ARCHIVES+=("cloudsmith-${VERSION}-${TARGET}${EXT}") + SIGNATURE="${ARCHIVE}.sig" + if [ -f "${SIGNATURE}" ]; then + ASSETS+=("${SIGNATURE}") + fi + done + SDIST="python/cloudsmith_cli-${VERSION}.tar.gz" + WHEEL="python/cloudsmith_cli-${VERSION}-py3-none-any.whl" + test -f "${SDIST}" + test -f "${WHEEL}" + ASSETS+=("${SDIST}" "${WHEEL}") + ( + cd binaries + sha256sum "${SUM_ARCHIVES[@]}" > SHA256SUMS + ) + ASSETS+=("binaries/SHA256SUMS") + + if gh release view "${GITHUB_REF_NAME}" >/dev/null 2>&1; then + test "$(gh release view "${GITHUB_REF_NAME}" --json isDraft -q .isDraft)" = true + else + gh release create "${GITHUB_REF_NAME}" \ + --draft \ + --verify-tag \ + --title "Release ${GITHUB_REF_NAME}" \ + --generate-notes + fi + gh release upload "${GITHUB_REF_NAME}" --clobber "${ASSETS[@]}" + + publish-cloudsmith: + needs: [validate, stage-github, python-distributions, sign-linux] + runs-on: ubuntu-24.04 + timeout-minutes: 30 + permissions: + contents: read + id-token: write + env: + CLOUDSMITH_NAMESPACE: ${{ vars.CLOUDSMITH_NAMESPACE }} + CLOUDSMITH_REPO: ${{ vars.CLOUDSMITH_REPO }} + CLOUDSMITH_ORG: ${{ vars.CLOUDSMITH_NAMESPACE }} + CLOUDSMITH_SERVICE_SLUG: ${{ vars.CLOUDSMITH_SERVICE_SLUG }} + VERSION: ${{ needs.validate.outputs.version }} + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: cloudsmith-* + merge-multiple: true + path: binaries + + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: python-distributions + path: python + + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: linux-signatures + path: binaries + + - name: Set up the built cloudsmith binary on the runner + uses: ./.github/actions/setup-cloudsmith-binary + with: + version: ${{ needs.validate.outputs.version }} + + - name: Pre-authenticate with OIDC + run: | + set -euo pipefail + cloudsmith whoami + + - name: Publish binaries and Python distributions + run: | + set -euo pipefail + + # Immutable repo: skip already-published files (no overwrite). Existence + # via the OIDC-authed CLI, anchored filename query, counting results. + package_count() { + # Args: . filename:^...$ is exact here + # (Cloudsmith search isn't PCRE; escaping dots breaks it). + cloudsmith ls packages "$1" -q "filename:^$2\$" -F json \ + | python3 -c 'import sys, json; print(len(json.load(sys.stdin).get("data", [])))' + } + + push_raw_if_absent() { + # Args: + local repo="$1" file="$2" name="$3" tags="$4" + local base + base="$(basename "${file}")" + if [ "$(package_count "${repo}" "${base}")" != "0" ]; then + echo "skip (already published): ${file}" + return 0 + fi + cloudsmith push raw \ + "${repo}" \ + "${file}" \ + --name "${name}" \ + --version "${VERSION}" \ + --tags "${tags}" + } + + push_python_if_absent() { + # Args: + local repo="$1" file="$2" + local base + base="$(basename "${file}")" + if [ "$(package_count "${repo}" "${base}")" != "0" ]; then + echo "skip (already published): ${file}" + return 0 + fi + cloudsmith push python \ + "${repo}" \ + "${file}" + } + + for TARGET in ${BINARY_TARGETS}; do + EXT=.tar.gz + if [ "${TARGET}" = windows-x86_64 ]; then + EXT=.zip + fi + ARCHIVE="binaries/cloudsmith-${VERSION}-${TARGET}${EXT}" + test -f "${ARCHIVE}" + + # Queryable tags for CI artifact selection (e.g. version:X AND tag:linux + # AND tag:x86_64 AND tag:musl): type, os, arch, libc (linux), full-target. + OS="${TARGET%%-*}" + REST="${TARGET#*-}" + case "${OS}" in + linux) + ARCH="${REST%-*}" + LIBC="${REST##*-}" + COMMON="${OS},${ARCH},${LIBC},${TARGET}" + ;; + *) + ARCH="${REST}" + COMMON="${OS},${ARCH},${TARGET}" + ;; + esac + + push_raw_if_absent \ + "${CLOUDSMITH_NAMESPACE}/${CLOUDSMITH_REPO}" \ + "${ARCHIVE}" \ + "cloudsmith-cli-${TARGET}" \ + "standalone-binary,${COMMON}" + case "${TARGET}" in + linux-*) + test -f "${ARCHIVE}.sig" + push_raw_if_absent \ + "${CLOUDSMITH_NAMESPACE}/${CLOUDSMITH_REPO}" \ + "${ARCHIVE}.sig" \ + "cloudsmith-cli-${TARGET}-sig" \ + "signature,${COMMON}" + ;; + esac + done + push_python_if_absent \ + "${CLOUDSMITH_NAMESPACE}/cli" \ + "python/cloudsmith_cli-${VERSION}.tar.gz" + push_python_if_absent \ + "${CLOUDSMITH_NAMESPACE}/cli" \ + "python/cloudsmith_cli-${VERSION}-py3-none-any.whl" + + publish-pypi: + needs: [validate, stage-github, python-distributions] + runs-on: ubuntu-24.04 + timeout-minutes: 10 + environment: release + permissions: + id-token: write + contents: read + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: python-distributions + path: dist + + - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 + with: + packages-dir: dist/ + skip-existing: true + + publish-containers: + needs: [validate, stage-github] + runs-on: ubuntu-24.04 + timeout-minutes: 30 + permissions: + contents: read + id-token: write + env: + CLOUDSMITH_NAMESPACE: ${{ vars.CLOUDSMITH_NAMESPACE }} + CLOUDSMITH_REPO: ${{ vars.CLOUDSMITH_REPO }} + CLOUDSMITH_SERVICE_SLUG: ${{ vars.CLOUDSMITH_SERVICE_SLUG }} + CLOUDSMITH_ORG: ${{ vars.CLOUDSMITH_NAMESPACE }} + DOCKERHUB_USER: ${{ secrets.DOCKERHUB_USER }} + HAS_DOCKERHUB: ${{ secrets.DOCKERHUB_PAT != '' }} + VERSION: ${{ needs.validate.outputs.version }} + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: cloudsmith-linux-*-musl + merge-multiple: true + path: binaries + + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: cloudsmith-linux-x86_64-gnu + merge-multiple: true + path: binaries + + - name: Set up the built cloudsmith binary on the runner + uses: ./.github/actions/setup-cloudsmith-binary + with: + version: ${{ needs.validate.outputs.version }} + add-local-bin: "true" + + - uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0 + + - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + + - name: Authenticate to docker.cloudsmith.io via OIDC credential helper + run: | + set -euo pipefail + cloudsmith whoami + cloudsmith credential-helper install docker + + - name: Authenticate to Docker Hub + if: env.HAS_DOCKERHUB == 'true' env: - CLOUDSMITH_NAMESPACE: ${{ vars.CLOUDSMITH_NAMESPACE }} - CLOUDSMITH_REPO: ${{ vars.CLOUDSMITH_REPO }} - CLOUDSMITH_SVC_SLUG: ${{ vars.CLOUDSMITH_SVC_SLUG }} - DOCKERHUB_USER: ${{ secrets.DOCKERHUB_USER }} - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - with: - persist-credentials: false - - - name: Set up Python 3.10 - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 - with: - python-version: '3.10' - - - name: Install build dependencies - run: | - python -m pip install --upgrade pip - pip install pex setuptools wheel - - - name: Get version - id: get_version - run: echo "VERSION=$(cat cloudsmith_cli/data/VERSION)" >> $GITHUB_ENV - - - name: Create multi-platform Zipapp with PEX - run: | - pex . \ - --output-file "cloudsmith.pyz" \ - --console-script cloudsmith \ - --python-shebang "/usr/bin/env python3" \ - --venv \ - --complete-platform .github/.platforms/linux-x86_64-py310.json \ - --complete-platform .github/.platforms/linux-aarch64-py310.json \ - --complete-platform .github/.platforms/linux-x86_64-musl-py310.json \ - --complete-platform .github/.platforms/linux-aarch64-musl-py310.json \ - --complete-platform .github/.platforms/macos-arm64-py310.json \ - --complete-platform .github/.platforms/windows-x86_64-py310.json \ - --complete-platform .github/.platforms/linux-x86_64-py311.json \ - --complete-platform .github/.platforms/linux-aarch64-py311.json \ - --complete-platform .github/.platforms/linux-x86_64-musl-py311.json \ - --complete-platform .github/.platforms/linux-aarch64-musl-py311.json \ - --complete-platform .github/.platforms/macos-arm64-py311.json \ - --complete-platform .github/.platforms/windows-x86_64-py311.json \ - --complete-platform .github/.platforms/linux-x86_64-py312.json \ - --complete-platform .github/.platforms/linux-aarch64-py312.json \ - --complete-platform .github/.platforms/linux-x86_64-musl-py312.json \ - --complete-platform .github/.platforms/linux-aarch64-musl-py312.json \ - --complete-platform .github/.platforms/macos-arm64-py312.json \ - --complete-platform .github/.platforms/windows-x86_64-py312.json \ - --complete-platform .github/.platforms/linux-x86_64-py313.json \ - --complete-platform .github/.platforms/linux-aarch64-py313.json \ - --complete-platform .github/.platforms/linux-x86_64-musl-py313.json \ - --complete-platform .github/.platforms/linux-aarch64-musl-py313.json \ - --complete-platform .github/.platforms/macos-arm64-py313.json \ - --complete-platform .github/.platforms/windows-x86_64-py313.json \ - --complete-platform .github/.platforms/linux-x86_64-py314.json \ - --complete-platform .github/.platforms/linux-aarch64-py314.json \ - --complete-platform .github/.platforms/linux-x86_64-musl-py314.json \ - --complete-platform .github/.platforms/linux-aarch64-musl-py314.json \ - --complete-platform .github/.platforms/macos-arm64-py314.json \ - --complete-platform .github/.platforms/windows-x86_64-py314.json - - - name: Create Release and Upload Asset - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh release create "${GITHUB_REF_NAME}" ./cloudsmith.pyz --title "Release v${VERSION}" --notes "" - - - name: Install and authenticate Cloudsmith CLI - uses: cloudsmith-io/cloudsmith-cli-action@159f1619275d5d3147f059c3cc110938ec221d16 # v2.0.3 - with: - oidc-namespace: ${{ vars.CLOUDSMITH_NAMESPACE }} - oidc-service-slug: ${{ vars.CLOUDSMITH_SVC_SLUG }} - - - name: Push Zipapp to Cloudsmith - id: push_zipapp - run: cloudsmith push raw "${CLOUDSMITH_NAMESPACE}/${CLOUDSMITH_REPO}" "./cloudsmith.pyz" --name cloudsmith-cli --version "${VERSION}" - - - name: Build Python packages - run: python setup.py sdist bdist_wheel - - - name: Push source distribution to Cloudsmith - run: cloudsmith push python "${CLOUDSMITH_NAMESPACE}/cli" "dist/cloudsmith_cli-${VERSION}.tar.gz" - - - name: Push wheel to Cloudsmith - run: cloudsmith push python "${CLOUDSMITH_NAMESPACE}/cli" "dist/cloudsmith_cli-${VERSION}-py2.py3-none-any.whl" - - - name: Set up QEMU for multi-arch - uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - - - name: Push Dockerised CLI to Cloudsmith (multi-arch) - id: push_dockerised_cli_cloudsmith - run: | - echo "${CLOUDSMITH_API_KEY}" | docker login docker.cloudsmith.io -u "${CLOUDSMITH_SVC_SLUG}" --password-stdin - docker buildx build \ - --platform linux/amd64,linux/arm64 \ - --build-arg "CLOUDSMITH_CLI_VERSION=${VERSION}" \ - --build-arg "CLOUDSMITH_NAMESPACE=${CLOUDSMITH_NAMESPACE}" \ - --build-arg "CLOUDSMITH_REPO=${CLOUDSMITH_REPO}" \ - -t "docker.cloudsmith.io/${CLOUDSMITH_NAMESPACE}/${CLOUDSMITH_REPO}/cloudsmith-cli:${VERSION}" \ - --push . - - - name: Push Dockerised CLI to DockerHub (multi-arch) - id: push_dockerised_cli_dockerhub - env: - DOCKERHUB_PAT: ${{ secrets.DOCKERHUB_PAT }} - run: | - echo "${DOCKERHUB_PAT}" | docker login -u "${DOCKERHUB_USER}" --password-stdin - docker buildx build \ - --platform linux/amd64,linux/arm64 \ - --build-arg "CLOUDSMITH_CLI_VERSION=${VERSION}" \ - --build-arg "CLOUDSMITH_NAMESPACE=${CLOUDSMITH_NAMESPACE}" \ - --build-arg "CLOUDSMITH_REPO=${CLOUDSMITH_REPO}" \ - -t "cloudsmith/cloudsmith-cli:${VERSION}" \ - --push . - - # Publish Python packages to PyPI - publish-pypi: - name: Publish to PyPI - runs-on: ubuntu-latest - environment: release - permissions: - id-token: write - contents: read - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - with: - persist-credentials: false - - - name: Set up Python 3.10 - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 - with: - python-version: '3.10' - - - name: Install build dependencies - run: | - python -m pip install --upgrade pip - pip install setuptools wheel - - - name: Build packages - run: python setup.py sdist bdist_wheel - - - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 - with: - packages-dir: dist/ + DOCKERHUB_PAT: ${{ secrets.DOCKERHUB_PAT }} + run: | + set -euo pipefail + echo "${DOCKERHUB_PAT}" \ + | docker login \ + -u "${DOCKERHUB_USER}" \ + --password-stdin + + - name: Build once and push the same image to both registries + run: | + set -euo pipefail + AMD64="binaries/cloudsmith-${VERSION}-linux-x86_64-musl.tar.gz" + ARM64="binaries/cloudsmith-${VERSION}-linux-aarch64-musl.tar.gz" + test -f "${AMD64}" + test -f "${AMD64}.sha256" + test -f "${ARM64}" + test -f "${ARM64}.sha256" + + # Immutable Cloudsmith repo: push the tag only if absent (inspect runs + # post-auth, so non-zero = not present). Docker Hub is mutable. + CLOUDSMITH_IMAGE="docker.cloudsmith.io/${CLOUDSMITH_NAMESPACE}/${CLOUDSMITH_REPO}/cloudsmith-cli:${VERSION}" + TAGS=() + if docker buildx imagetools inspect "${CLOUDSMITH_IMAGE}" >/dev/null 2>&1; then + echo "skip (already published): ${CLOUDSMITH_IMAGE}" + else + TAGS+=(-t "${CLOUDSMITH_IMAGE}") + fi + # Docker Hub (mutable): pinned + floating tags (latest/major/major.minor). + # Cloudsmith (immutable) keeps only the pinned :${VERSION} tag. + if [ "${HAS_DOCKERHUB}" = "true" ]; then + HUB="cloudsmith/cloudsmith-cli" + MAJOR="${VERSION%%.*}" + MINOR="${VERSION#*.}"; MINOR="${MINOR%%.*}" + TAGS+=(-t "${HUB}:${VERSION}") + TAGS+=(-t "${HUB}:${MAJOR}" -t "${HUB}:${MAJOR}.${MINOR}" -t "${HUB}:latest") + fi + + if [ "${#TAGS[@]}" -eq 0 ]; then + echo "image ${VERSION} already published; skipping" + exit 0 + fi + + docker buildx build \ + --platform linux/amd64,linux/arm64 \ + --build-arg "CLOUDSMITH_CLI_VERSION=${VERSION}" \ + --build-arg "VCS_REF=${GITHUB_SHA}" \ + "${TAGS[@]}" \ + --push . + + publish-github: + needs: + - validate + - stage-github + - publish-cloudsmith + - publish-pypi + - publish-containers + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: write + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + VERSION: ${{ needs.validate.outputs.version }} + steps: + - name: Assert every expected asset is present + run: | + set -euo pipefail + PRESENT=$(gh release view "${GITHUB_REF_NAME}" --json assets -q '.assets[].name') + EXPECTED=() + for TARGET in ${BINARY_TARGETS}; do + EXT=.tar.gz + if [ "${TARGET}" = windows-x86_64 ]; then + EXT=.zip + fi + EXPECTED+=("cloudsmith-${VERSION}-${TARGET}${EXT}") + EXPECTED+=("cloudsmith-${VERSION}-${TARGET}${EXT}.sha256") + case "${TARGET}" in + linux-*) EXPECTED+=("cloudsmith-${VERSION}-${TARGET}${EXT}.sig") ;; + esac + done + EXPECTED+=("SHA256SUMS") + EXPECTED+=("cloudsmith_cli-${VERSION}.tar.gz") + EXPECTED+=("cloudsmith_cli-${VERSION}-py3-none-any.whl") + MISSING=() + for ASSET in "${EXPECTED[@]}"; do + if ! printf '%s\n' "${PRESENT}" | grep -Fxq "${ASSET}"; then + MISSING+=("${ASSET}") + fi + done + if [ "${#MISSING[@]}" -ne 0 ]; then + echo "FAIL: release is missing expected assets:" + printf ' %s\n' "${MISSING[@]}" + exit 1 + fi + echo "All ${#EXPECTED[@]} expected assets present." + + - name: Publish the fully replicated draft release + run: gh release edit "${GITHUB_REF_NAME}" --draft=false diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e7b04c9d..c82affd9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,7 +18,7 @@ concurrency: jobs: pytest: name: Run tests (Python ${{ matrix.python-version }}) - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 strategy: fail-fast: false matrix: diff --git a/CHANGELOG.md b/CHANGELOG.md index 45b6213a..f45220c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] +### Added + +- Standalone, self-contained CLI binaries built with PyInstaller for Linux (x86_64/aarch64, glibc and musl), macOS (arm64/x86_64) and Windows (x86_64). Each release attaches the per-platform archives and SHA256 checksums to the GitHub release and pushes them to Cloudsmith. The binaries bundle Python and all native dependencies, so no Python installation is required. +- Linux binary archives are GPG-signed. Each `cloudsmith--linux-*.tar.gz` ships a detached `.sig` alongside it, verifiable with `gpg --verify` against the published Cloudsmith CLI signing key. +- Released binaries are tagged on Cloudsmith by platform — `os`, `arch`, `libc` (Linux), the full target, and a type tag (`standalone-binary`/`signature`) — so CI/CD can select the right artifact via the package query API, for example `version:1.19.0 AND tag:standalone-binary AND tag:linux AND tag:x86_64 AND tag:musl`. + +### Changed + +- The official Docker image now ships the standalone musl binary on a plain Alpine base instead of the Python zipapp — the image no longer contains a Python runtime. +- The Docker image now carries standard OCI labels (`org.opencontainers.image.*`: source, version, revision, licenses); the Docker Hub image additionally publishes the conventional floating tags (`latest`, major, and major.minor). + +### Removed + +- The multi-platform PEX zipapp (`cloudsmith.pyz`) is no longer built or published; the standalone per-platform binaries replace it. Anything that consumed `cloudsmith.pyz` from GitHub releases or the Cloudsmith raw repository (for example `cloudsmith-cli-action` with `executable: true`) must switch to the new binary archives. + ## [1.19.0] - 2026-06-11 ### Added diff --git a/Dockerfile b/Dockerfile index 8997fb10..0e0f70f3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,24 +1,45 @@ -FROM python:3.12-alpine +ARG ALPINE_IMAGE=alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d -LABEL maintainer="support@cloudsmith.io" -LABEL description="Official Cloudsmith CLI, now served in a handy container" +FROM ${ALPINE_IMAGE} AS unpack -ENV PYTHONDONTWRITEBYTECODE=1 \ - PIP_NO_CACHE_DIR=1 \ - PATH="/opt/cloudsmith:${PATH}" +ARG TARGETARCH +ARG CLOUDSMITH_CLI_VERSION + +COPY binaries/ /tmp/binaries/ + +RUN set -eu; \ + case "${TARGETARCH}" in \ + amd64) CS_ARCH="x86_64" ;; \ + arm64) CS_ARCH="aarch64" ;; \ + *) echo "Unsupported architecture: ${TARGETARCH}" >&2; exit 1 ;; \ + esac; \ + ARCHIVE="cloudsmith-${CLOUDSMITH_CLI_VERSION}-linux-${CS_ARCH}-musl.tar.gz"; \ + cd /tmp/binaries; \ + sha256sum -c "${ARCHIVE}.sha256"; \ + mkdir -p /opt; \ + tar -xzf "${ARCHIVE}" -C /opt + +FROM ${ALPINE_IMAGE} -RUN apk add --no-cache curl bash ca-certificates ARG CLOUDSMITH_CLI_VERSION -ARG CLOUDSMITH_NAMESPACE -ARG CLOUDSMITH_REPO +ARG VCS_REF + +LABEL maintainer="support@cloudsmith.io" \ + org.opencontainers.image.title="Cloudsmith CLI" \ + org.opencontainers.image.description="Official Cloudsmith CLI" \ + org.opencontainers.image.vendor="Cloudsmith" \ + org.opencontainers.image.url="https://cloudsmith.com" \ + org.opencontainers.image.source="https://github.com/cloudsmith-io/cloudsmith-cli" \ + org.opencontainers.image.documentation="https://docs.cloudsmith.com/developer-tools/cli" \ + org.opencontainers.image.licenses="Apache-2.0" \ + org.opencontainers.image.version="${CLOUDSMITH_CLI_VERSION}" \ + org.opencontainers.image.revision="${VCS_REF}" + +ENV PATH="/opt/cloudsmith:${PATH}" -RUN mkdir -p /opt/cloudsmith \ - && curl -1sLf -o /opt/cloudsmith/cloudsmith "https://dl.cloudsmith.io/public/${CLOUDSMITH_NAMESPACE}/${CLOUDSMITH_REPO}/raw/names/cloudsmith-cli/versions/${CLOUDSMITH_CLI_VERSION}/cloudsmith.pyz" \ - && chmod +x /opt/cloudsmith/cloudsmith +COPY --from=unpack /opt/cloudsmith /opt/cloudsmith -# Run as a non-root user RUN adduser -D -u 1000 cloudsmith USER cloudsmith -# Default command -ENTRYPOINT [ "cloudsmith" ] +ENTRYPOINT ["cloudsmith"] diff --git a/README.md b/README.md index f235cf2e..8b3ef966 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,30 @@ Or you can get the latest pre-release version from Cloudsmith: pip install --upgrade cloudsmith-cli --extra-index-url=https://dl.cloudsmith.io/public/cloudsmith/cli/python/index/ ``` +### Standalone Binaries + +Each release also ships self-contained binaries that bundle Python and all dependencies — no Python installation required. Download the archive for your platform from the [GitHub releases page](https://github.com/cloudsmith-io/cloudsmith-cli/releases) (or the [Cloudsmith CLI repository](https://cloudsmith.io/~cloudsmith/repos/cli/packages/)), verify it against the accompanying `.sha256` file, extract it, and add the extracted `cloudsmith` directory to your `PATH`: + +```bash +tar -xzf cloudsmith--.tar.gz +./cloudsmith/cloudsmith --version +``` + +Available targets: `linux-x86_64-gnu`, `linux-x86_64-musl`, `linux-aarch64-gnu`, `linux-aarch64-musl`, `macos-arm64`, `macos-x86_64`, and `windows-x86_64` (as a `.zip`). + +Standalone binaries include all optional features, including AWS OIDC support. + +#### Verifying Linux binaries + +The four Linux archives (`linux-x86_64-gnu`, `linux-x86_64-musl`, `linux-aarch64-gnu`, `linux-aarch64-musl`) are GPG-signed with a detached binary signature published alongside each archive as `.sig`. To verify, import the Cloudsmith CLI release public key (published alongside the releases) and check the signature: + +``` +gpg --import cloudsmith-cli-release-key.asc +gpg --verify cloudsmith--linux-x86_64-gnu.tar.gz.sig cloudsmith--linux-x86_64-gnu.tar.gz +``` + +A successful verification reports a good signature from the Cloudsmith CLI release key. (The macOS and Windows binaries are not GPG-signed; native signing for those platforms is tracked separately.) + ### Optional Dependencies The CLI supports optional extras for additional functionality: diff --git a/packaging/smoketest.sh b/packaging/smoketest.sh index 741a3ea1..862487d1 100644 --- a/packaging/smoketest.sh +++ b/packaging/smoketest.sh @@ -29,6 +29,8 @@ no_dep_error() { # Run a read-only online command; a 429 is the shared org throttling, not a # binary failure, so warn and pass. +# Asserts success without printing the raw response (PII / repo names). The +# dep-error detector still sees the full output; SMOKETEST_DEBUG=1 prints it. online_call() { _label="$1"; shift _out=$("$BIN" "$@" 2>&1) || { @@ -36,10 +38,15 @@ online_call() { 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" + [ "${SMOKETEST_DEBUG:-0}" = "1" ] && printf '%s\n' "$_out" >&2 + fail "online ${_label} failed (set SMOKETEST_DEBUG=1 for output)" } no_dep_error "$_out" "$_label" - printf '%s\n' "$_out" | head -15 + if [ "${SMOKETEST_DEBUG:-0}" = "1" ]; then + printf '%s\n' "$_out" | head -15 + else + echo "${_label}: OK" + fi } echo "== binary: $BIN (mode=$MODE) ==" From 0c612409751f2bb77755c598adb6fe7ca8355d08 Mon Sep 17 00:00:00 2001 From: Bartosz Blizniak Date: Fri, 19 Jun 2026 13:48:02 +0100 Subject: [PATCH 2/2] fix(review): address PR #319 review feedback - setup-cloudsmith-binary: verify the .sha256 checksum before extracting the archive onto PATH, so a corrupted/unexpected artifact is never executed (the .sha256 is already downloaded alongside the archive). - release.yml: document at the binaries call site that publishing is OIDC-only and the API key secret is required solely for the read-only online smoketest (fail-fast when online_smoketest is requested); the OIDC online path is covered by the binaries `oidc` job. Co-Authored-By: Claude Opus 4.8 --- .github/actions/setup-cloudsmith-binary/action.yml | 4 ++++ .github/workflows/release.yml | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/.github/actions/setup-cloudsmith-binary/action.yml b/.github/actions/setup-cloudsmith-binary/action.yml index 7ff09e6c..eb6694da 100644 --- a/.github/actions/setup-cloudsmith-binary/action.yml +++ b/.github/actions/setup-cloudsmith-binary/action.yml @@ -23,6 +23,10 @@ runs: set -euo pipefail ARCHIVE="binaries/cloudsmith-${VERSION}-linux-x86_64-gnu.tar.gz" test -f "${ARCHIVE}" + # Verify the accompanying checksum before extracting onto PATH so a + # corrupted or unexpected artifact never gets executed. + test -f "${ARCHIVE}.sha256" + ( cd binaries && sha256sum -c "$(basename "${ARCHIVE}").sha256" ) DEST="${RUNNER_TEMP}/cloudsmith-cli" mkdir -p "${DEST}" tar -xzf "${ARCHIVE}" -C "${DEST}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5cdb8811..6af71e8c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,6 +38,10 @@ jobs: name: Binaries needs: validate uses: ./.github/workflows/binaries.yml + # Publishing authenticates via OIDC (no API key). The online smoketest is a + # separate read-only check that does still need CLOUDSMITH_API_KEY: with + # online_smoketest: true the reusable workflow fails fast if it is missing. + # The OIDC online path is covered separately by the binaries `oidc` job. with: online_smoketest: true permissions: