diff --git a/.github/actions/open-cli-bump-pr/action.yml b/.github/actions/open-cli-bump-pr/action.yml new file mode 100644 index 00000000..335f4aed --- /dev/null +++ b/.github/actions/open-cli-bump-pr/action.yml @@ -0,0 +1,60 @@ +name: Open a Cloudsmith CLI bump pull request +description: >- + Run a bump command in a clone of the calling repository and open or update a + pull request from Cloudsmith Bot that moves it to a Cloudsmith CLI release. + The command runs in the root of the clone, with VERSION in the environment. + +inputs: + version: + description: The Cloudsmith CLI version to move to, for example 1.29.1. + required: true + bump-command: + description: The shell command that changes the files of the repository. + required: true + merge: + description: Set to true to squash-merge the pull request when its checks pass. + required: false + default: "false" + token: + description: >- + A Cloudsmith Bot token that can push branches and open pull requests. A + pull request from GITHUB_TOKEN does not start the checks of the repository. + required: true + ssh-signing-key: + description: The private SSH key that signs the commits of Cloudsmith Bot. + required: true + +runs: + using: composite + steps: + - name: Set up commit signing as Cloudsmith Bot + shell: bash + env: + CLOUDSMITH_BOT_SSH_KEY: ${{ inputs.ssh-signing-key }} + run: | + set -euo pipefail + SIGNING_KEY="${RUNNER_TEMP}/cloudsmith-bot-signing-key" + printf '%s\n' "${CLOUDSMITH_BOT_SSH_KEY}" > "${SIGNING_KEY}" + chmod 600 "${SIGNING_KEY}" + ssh-keygen -y -f "${SIGNING_KEY}" > "${SIGNING_KEY}.pub" + git config --global user.name "Cloudsmith Bot" + git config --global user.email "vftbot@cloudsmith.io" + git config --global gpg.format ssh + git config --global user.signingkey "${SIGNING_KEY}.pub" + git config --global commit.gpgsign true + + - name: Open the bump pull request + shell: bash + env: + GH_TOKEN: ${{ inputs.token }} + VERSION: ${{ inputs.version }} + BUMP_COMMAND: ${{ inputs.bump-command }} + MERGE: ${{ inputs.merge }} + run: | + set -euo pipefail + gh auth setup-git + OPTIONS=() + if [ "${MERGE}" = "true" ]; then + OPTIONS+=(--merge) + fi + "${GITHUB_ACTION_PATH}/open-cli-bump-pr.sh" "${OPTIONS[@]}" "${GITHUB_REPOSITORY}" "${VERSION}" "${BUMP_COMMAND}" diff --git a/.github/actions/open-cli-bump-pr/open-cli-bump-pr.sh b/.github/actions/open-cli-bump-pr/open-cli-bump-pr.sh new file mode 100755 index 00000000..844383dd --- /dev/null +++ b/.github/actions/open-cli-bump-pr/open-cli-bump-pr.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# Opens or updates a pull request that moves a downstream repository to a +# Cloudsmith CLI release. +# The script clones REPOSITORY and runs COMMAND in the clone, with VERSION in +# the environment. It commits the changes to the release/cloudsmith-cli branch +# and opens a pull request against the default branch. A newer release +# replaces a bump that is not merged yet, but an older release never replaces +# a newer one. +# Usage: open-cli-bump-pr.sh [--merge] REPOSITORY VERSION COMMAND +# --merge Wait for the checks to pass, then squash-merge as an admin. +set -euo pipefail + +usage() { + echo "usage: $0 [--merge] OWNER/NAME MAJOR.MINOR.PATCH COMMAND" >&2 + exit 1 +} + +merge=false +while [[ "${1:-}" == --* ]]; do + case "$1" in + --merge) merge=true ;; + *) usage ;; + esac + shift +done +[[ $# -eq 3 ]] || usage +repository="$1" +version="$2" +update_command="$3" +[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || usage + +branch="release/cloudsmith-cli" +title="Bump cloudsmith-cli to v$version" +body="Automated update to Cloudsmith CLI v$version." +if [[ "$merge" == true ]]; then + body="$body The workflow merges it when the checks pass." +fi +clone="$(mktemp -d)" +trap 'rm -rf "$clone"' EXIT + +pending_bump_is_newer() { + local pending_version newest_version + git -C "$clone" fetch --quiet --depth 1 origin "$branch" + pending_version="$(git -C "$clone" log -1 --format=%s FETCH_HEAD | sed -n 's/^Bump cloudsmith-cli to v//p')" + newest_version="$(printf '%s\n' "$pending_version" "$version" | sort -V | tail -n 1)" + [[ "$pending_version" != "$version" && "$pending_version" == "$newest_version" ]] +} + +commit_update() { + git -C "$clone" checkout --quiet -b "$branch" + git -C "$clone" add --all + git -C "$clone" commit --quiet -m "$title" +} + +merge_when_checks_pass() { + # gh pr checks fails when no checks are registered yet. + sleep 60 + gh pr checks "$branch" --repo "$repository" --watch --fail-fast + gh pr merge "$branch" --repo "$repository" --admin --squash \ + --match-head-commit "$(git -C "$clone" rev-parse HEAD)" +} + +git clone --quiet --depth 1 "https://github.com/$repository.git" "$clone" +pr_number="$(gh pr list --repo "$repository" --head "$branch" --state open --json number --jq '.[].number')" +if [[ -n "$pr_number" ]] && pending_bump_is_newer; then + echo "pull request #$pr_number already moves $repository to a newer release; skipping" + exit 0 +fi +(cd "$clone" && VERSION="$version" bash -c "$update_command") +if [[ -z "$(git -C "$clone" status --porcelain)" ]]; then + echo "$repository already uses cloudsmith-cli v$version; skipping" + exit 0 +fi +commit_update +git -C "$clone" push --quiet --force origin "$branch" +if [[ -z "$pr_number" ]]; then + gh pr create --repo "$repository" --head "$branch" --title "$title" --body "$body" +else + gh pr edit "$pr_number" --repo "$repository" --title "$title" --body "$body" +fi +if [[ "$merge" == true ]]; then + merge_when_checks_pass +fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b3ef1e40..6f757529 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -532,142 +532,30 @@ jobs: "${TAGS[@]}" \ --push . - publish-homebrew: - # Renders packaging/homebrew/cloudsmith-cli.rb.tmpl against the archives - # that publish-cloudsmith just made live and opens a bump pull request - # against the Homebrew tap (/homebrew-cloudsmith-cli). When the tap - # checks pass, the job squash-merges the pull request with --admin. - # Without TAP_PUSH_TOKEN the rendered formula is only staged as a - # workflow artifact. - needs: [validate, publish-cloudsmith] + notify-downstream-repos: + # Sends a cloudsmith-cli-release event with the version to each repository + # in the matrix. The workflow in each repository opens a bump pull request. + needs: [validate, publish-github] runs-on: ubuntu-24.04 - timeout-minutes: 30 + timeout-minutes: 5 permissions: contents: read - env: - CLOUDSMITH_NAMESPACE: ${{ vars.CLOUDSMITH_NAMESPACE }} - CLOUDSMITH_REPO: ${{ vars.CLOUDSMITH_REPO }} - HAS_TAP_TOKEN: ${{ secrets.TAP_PUSH_TOKEN != '' }} - VERSION: ${{ needs.validate.outputs.version }} - # Targets shipped via Homebrew (subset of BINARY_TARGETS; Homebrew on - # Linux is glibc-based, so the gnu builds). - HOMEBREW_TARGETS: "macos-arm64 macos-x86_64 linux-x86_64-gnu linux-aarch64-gnu" + strategy: + fail-fast: false + matrix: + repository: + - homebrew-cloudsmith-cli + - cloudsmith-cli-action steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 - with: - pattern: cloudsmith-macos-* - merge-multiple: true - path: binaries - - - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 - with: - pattern: cloudsmith-linux-*-gnu - merge-multiple: true - path: binaries - - - name: Render the formula from the template - run: | - set -euo pipefail - FORMULA=rendered/cloudsmith-cli.rb - mkdir -p rendered - cp packaging/homebrew/cloudsmith-cli.rb.tmpl "${FORMULA}" - sed -i \ - -e "s/__VERSION__/${VERSION}/g" \ - -e "s/__NAMESPACE__/${CLOUDSMITH_NAMESPACE}/g" \ - -e "s/__REPO__/${CLOUDSMITH_REPO}/g" \ - "${FORMULA}" - for TARGET in ${HOMEBREW_TARGETS}; do - SHA_FILE="binaries/cloudsmith-${VERSION}-${TARGET}.tar.gz.sha256" - test -f "${SHA_FILE}" - SHA256="$(awk '{print $1}' "${SHA_FILE}")" - KEY="SHA256_$(printf '%s' "${TARGET}" | tr 'a-z-' 'A-Z_')" - sed -i "s/__${KEY}__/${SHA256}/g" "${FORMULA}" - done - if grep -n '__' "${FORMULA}"; then - echo "FAIL: unrendered placeholders remain" - exit 1 - fi - ruby -c "${FORMULA}" - - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: homebrew-formula - path: rendered/cloudsmith-cli.rb - retention-days: 14 - if-no-files-found: error - - - name: Verify the published downloads match the formula checksums - run: | - set -euo pipefail - for TARGET in ${HOMEBREW_TARGETS}; do - URL="https://dl.cloudsmith.io/public/${CLOUDSMITH_NAMESPACE}/${CLOUDSMITH_REPO}/raw/names/cloudsmith-cli-${TARGET}/versions/${VERSION}/cloudsmith-${VERSION}-${TARGET}.tar.gz" - EXPECTED="$(awk '{print $1}' "binaries/cloudsmith-${VERSION}-${TARGET}.tar.gz.sha256")" - ACTUAL="" - # Retry to absorb post-publish sync delay. - for ATTEMPT in 1 2 3 4 5 6 7 8 9 10; do - if ACTUAL="$(curl -fsSL "${URL}" | sha256sum | awk '{print $1}')"; then - break - fi - echo "attempt ${ATTEMPT}: ${URL} not ready; retrying" - sleep 30 - done - test "${ACTUAL}" = "${EXPECTED}" - echo "verified ${URL}" - done - - - name: Open and merge a bump pull request against the tap repository - if: env.HAS_TAP_TOKEN == 'true' + - name: Send the release event env: - GH_TOKEN: ${{ secrets.TAP_PUSH_TOKEN }} - CLOUDSMITH_BOT_SSH_KEY: ${{ secrets.CLOUDSMITH_BOT_SSH_KEY }} - TAP_REPO: ${{ github.repository_owner }}/homebrew-cloudsmith-cli + GH_TOKEN: ${{ secrets.DOWNSTREAM_RELEASE_PUSH_TOKEN }} + REPOSITORY: ${{ github.repository_owner }}/${{ matrix.repository }} + VERSION: ${{ needs.validate.outputs.version }} run: | - set -euo pipefail - BRANCH="release/cloudsmith-cli-v${VERSION}" - gh auth setup-git - git clone --depth 1 "https://github.com/${TAP_REPO}.git" tap - mkdir -p tap/Formula tap/Aliases - cp rendered/cloudsmith-cli.rb tap/Formula/cloudsmith-cli.rb - ln -sfn ../Formula/cloudsmith-cli.rb tap/Aliases/cloudsmith - if [ -z "$(git -C tap status --porcelain)" ]; then - echo "tap already up to date; skipping" - exit 0 - fi - SIGNING_KEY="${RUNNER_TEMP}/cloudsmith-bot-signing-key" - printf '%s\n' "${CLOUDSMITH_BOT_SSH_KEY}" > "${SIGNING_KEY}" - chmod 600 "${SIGNING_KEY}" - ssh-keygen -y -f "${SIGNING_KEY}" > "${SIGNING_KEY}.pub" - git -C tap config user.name "Cloudsmith Bot" - git -C tap config user.email "vftbot@cloudsmith.io" - git -C tap config gpg.format ssh - git -C tap config user.signingkey "${SIGNING_KEY}.pub" - git -C tap config commit.gpgsign true - git -C tap checkout -b "${BRANCH}" - git -C tap add Formula/cloudsmith-cli.rb Aliases/cloudsmith - git -C tap commit -m "Bump cloudsmith-cli to v${VERSION}" - git -C tap push --force origin "${BRANCH}" - if [ -z "$(gh pr list --repo "${TAP_REPO}" --head "${BRANCH}" --state open --json number --jq '.[].number')" ]; then - gh pr create --repo "${TAP_REPO}" \ - --head "${BRANCH}" --base main \ - --title "Bump cloudsmith-cli to v${VERSION}" \ - --body "Automated formula update for cloudsmith-cli v${VERSION}. The release workflow verified the formula checksums against the published downloads. The workflow merges it when the checks pass." - else - echo "pull request for ${BRANCH} already open" - fi - # The tap checks start when the pull request opens. Give them time to - # register, because gh pr checks fails when it finds no checks. - sleep 60 - gh pr checks "${BRANCH}" --repo "${TAP_REPO}" --watch --fail-fast - gh pr merge "${BRANCH}" --repo "${TAP_REPO}" --admin --squash \ - --match-head-commit "$(git -C tap rev-parse HEAD)" - - - name: Report a skipped tap update - if: env.HAS_TAP_TOKEN != 'true' - run: echo "TAP_PUSH_TOKEN not configured; formula staged as a workflow artifact only." + gh api "repos/${REPOSITORY}/dispatches" \ + -f event_type=cloudsmith-cli-release \ + -f "client_payload[version]=${VERSION}" publish-github: needs: diff --git a/packaging/homebrew/cloudsmith-cli.rb.tmpl b/packaging/homebrew/cloudsmith-cli.rb.tmpl deleted file mode 100644 index 66435157..00000000 --- a/packaging/homebrew/cloudsmith-cli.rb.tmpl +++ /dev/null @@ -1,48 +0,0 @@ -# Copyright 2026 Cloudsmith Ltd -# -# Template for the Homebrew formula published to the Homebrew tap -# repository. Rendered by the release workflow (publish-homebrew job); -# do not edit the rendered copy in the tap by hand. -class CloudsmithCli < Formula - desc "Official Cloudsmith Command-Line Interface" - homepage "https://docs.cloudsmith.com/developer-tools/cli" - version "__VERSION__" - license "Apache-2.0" - - if OS.mac? && Hardware::CPU.arm? - url "https://dl.cloudsmith.io/public/__NAMESPACE__/__REPO__/raw/names/cloudsmith-cli-macos-arm64/versions/__VERSION__/cloudsmith-__VERSION__-macos-arm64.tar.gz" - sha256 "__SHA256_MACOS_ARM64__" - elsif OS.mac? && Hardware::CPU.intel? - url "https://dl.cloudsmith.io/public/__NAMESPACE__/__REPO__/raw/names/cloudsmith-cli-macos-x86_64/versions/__VERSION__/cloudsmith-__VERSION__-macos-x86_64.tar.gz" - sha256 "__SHA256_MACOS_X86_64__" - elsif OS.linux? && Hardware::CPU.arm? && Hardware::CPU.is_64_bit? - url "https://dl.cloudsmith.io/public/__NAMESPACE__/__REPO__/raw/names/cloudsmith-cli-linux-aarch64-gnu/versions/__VERSION__/cloudsmith-__VERSION__-linux-aarch64-gnu.tar.gz" - sha256 "__SHA256_LINUX_AARCH64_GNU__" - elsif OS.linux? && Hardware::CPU.intel? && Hardware::CPU.is_64_bit? - url "https://dl.cloudsmith.io/public/__NAMESPACE__/__REPO__/raw/names/cloudsmith-cli-linux-x86_64-gnu/versions/__VERSION__/cloudsmith-__VERSION__-linux-x86_64-gnu.tar.gz" - sha256 "__SHA256_LINUX_X86_64_GNU__" - end - - # Placed after the url stanzas: a leading livecheck url is misdetected as the - # stable url by audit when every real url is inside a conditional. - livecheck do - url "https://dl.cloudsmith.io/public/__NAMESPACE__/__REPO__/raw/names/cloudsmith-cli-manifest-macos-arm64/versions/latest/manifest.txt" - regex(/^version=(\d+(?:\.\d+)+)$/i) - end - - # The bundled libraries are private to the PyInstaller bundle and are resolved - # via @rpath, so Homebrew must not rewrite their dylib IDs: the absolute Cellar - # path does not fit in the Mach-O header padding of prebuilt wheels such as - # pydantic_core, which fails the install. - preserve_rpath - - def install - # PyInstaller onedir bundle: the executable must stay next to _internal/. - libexec.install Dir["*"] - bin.write_exec_script libexec/"cloudsmith" - end - - test do - assert_match "CLI Package Version: #{version}", shell_output("#{bin}/cloudsmith --version") - end -end