From 2433e8425d8c4ef20d5fc3a9aba9db678f587c2a Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Mon, 19 Oct 2026 14:57:35 -0400 Subject: [PATCH] Pin the CLI plugins to their latest releases basecamp-cli, hey and fizzy installed from their default branch, while their CLIs install from releases. A skill on main can then call a flag no released CLI has: basecamp-cli's connect skill runs `basecamp auth agent connect --json`, which only unreleased main supports (basecamp/basecamp-cli#852), so the released 0.13.2 CLI refuses it. release-pins.json names each plugin's CLI repo. bin/pin-releases moves each entry's sha to its latest release's commit, never backwards, and an hourly workflow opens a pull request when one moved. bin/check-marketplace fails when a pinned plugin has no sha. --- .claude-plugin/marketplace.json | 12 ++++-- .github/workflows/pin-releases.yml | 67 ++++++++++++++++++++++++++++++ README.md | 16 +++++++ bin/check-marketplace | 22 +++++++++- bin/pin-releases | 50 ++++++++++++++++++++++ release-pins.json | 6 +++ 6 files changed, 167 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/pin-releases.yml create mode 100755 bin/pin-releases create mode 100644 release-pins.json diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 2426006..b30c76c 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -11,7 +11,8 @@ "description": "Deprecated alias for basecamp-cli, kept so existing installs keep working. Install basecamp-cli instead, or run `basecamp setup` to switch.", "source": { "source": "url", - "url": "https://github.com/basecamp/basecamp-cli.git" + "url": "https://github.com/basecamp/basecamp-cli.git", + "sha": "d742884a950a1c1376e3b6cb00a3c6d97d5dfa25" }, "category": "productivity" }, @@ -20,7 +21,8 @@ "description": "CLI-based Basecamp for Claude Code and Codex, driven by the basecamp command. Create todos, track work, link code to projects.", "source": { "source": "url", - "url": "https://github.com/basecamp/basecamp-cli.git" + "url": "https://github.com/basecamp/basecamp-cli.git", + "sha": "d742884a950a1c1376e3b6cb00a3c6d97d5dfa25" }, "category": "productivity" }, @@ -29,7 +31,8 @@ "description": "HEY integration for Claude Code. Read email, manage todos, track time.", "source": { "source": "github", - "repo": "basecamp/hey-cli" + "repo": "basecamp/hey-cli", + "sha": "76ea03f5e52651521fef86edc24224ca4033d374" }, "category": "productivity" }, @@ -38,7 +41,8 @@ "description": "Fizzy integration for Claude Code. Manage boards, cards, track work, and link code to projects.", "source": { "source": "github", - "repo": "basecamp/fizzy-cli" + "repo": "basecamp/fizzy-cli", + "sha": "3834efe78385a70fa6e4a7f17a9def3ff69f9f23" }, "category": "productivity" }, diff --git a/.github/workflows/pin-releases.yml b/.github/workflows/pin-releases.yml new file mode 100644 index 0000000..2bac046 --- /dev/null +++ b/.github/workflows/pin-releases.yml @@ -0,0 +1,67 @@ +name: Pin releases + +# Moves each plugin in release-pins.json to its CLI's latest release +# (bin/pin-releases) and opens a pull request when one moved. A pull request +# opened with this workflow's token starts no other workflow, so Check doesn't +# run on it: this runs bin/check-marketplace itself before pushing. + +on: + schedule: + - cron: '23 * * * *' + workflow_dispatch: + +permissions: {} + +concurrency: + group: pin-releases + cancel-in-progress: false + +jobs: + pin: + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: Pin each plugin to its CLI's latest release + env: + GH_TOKEN: ${{ github.token }} + run: bin/pin-releases | tee "$RUNNER_TEMP/pins.txt" + + - run: bin/check-marketplace + + - name: Open or update the pull request + env: + GH_TOKEN: ${{ github.token }} + run: | + branch=pin-releases + if git diff --quiet; then + echo "Every plugin is at its latest release." + exit 0 + fi + + gh auth setup-git + # The same pins as the open pull request: nothing to push. + if git fetch --quiet origin "$branch" 2>/dev/null && + git diff --quiet FETCH_HEAD -- .claude-plugin/marketplace.json; then + echo "The open pull request already has these pins." + exit 0 + fi + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git switch --quiet -c "$branch" + git commit --quiet -am "Pin plugins to their latest releases" + git push --quiet --force origin "$branch" + + body=$(printf 'From `bin/pin-releases`:\n\n```\n%s\n```\n' "$(cat "$RUNNER_TEMP/pins.txt")") + if [ "$(gh pr view "$branch" --json state --jq .state 2>/dev/null)" = "OPEN" ]; then + gh pr edit "$branch" --body "$body" + else + gh pr create --head "$branch" --title "Pin plugins to their latest releases" --body "$body" + fi diff --git a/README.md b/README.md index 3d92acd..159992f 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,22 @@ once it's listed. | [hey](https://github.com/basecamp/hey-cli) | Read email, manage todos, track time. | | [fizzy](https://github.com/basecamp/fizzy-cli) | Manage boards and cards, track work, link code to projects. | +### Pinned to releases + +`basecamp-cli`, `hey` and `fizzy` install from their CLI's latest release, not +its default branch: a plugin's skills and hooks call the CLI you installed, and +you install that from a release. `release-pins.json` names each one's repo, and +its marketplace entry carries the `sha` of that release's commit. + +Every hour, the Pin releases workflow runs `bin/pin-releases`, which moves each +pin to the newest release and never back, and opens a pull request when one +moved. Merging it ships that release's plugin. `bin/check-marketplace` fails +when a pinned plugin has no sha. + +Claude Code updates an installed plugin only when its `plugin.json` version +changes, so a CLI's release has to stamp its version there for the pin to reach +existing installs. + ### `basecamp` is now `basecamp-cli` The CLI plugin used to be `basecamp@37signals`. It's now diff --git a/bin/check-marketplace b/bin/check-marketplace index 76591df..c527781 100755 --- a/bin/check-marketplace +++ b/bin/check-marketplace @@ -4,6 +4,9 @@ # 1. It parses, and every plugin name is unique. # 2. A plugin named "basecamp", if listed, has exactly the same source as # "basecamp-cli": a deprecated alias for it. +# 3. Every plugin in release-pins.json is listed, and its source carries the +# full sha of a commit (bin/pin-releases), so it installs a released CLI's +# plugin rather than the default branch. # # "basecamp@37signals" used to be the CLI plugin, now "basecamp-cli". During the # deprecation window the alias keeps existing installs loading, and @@ -24,11 +27,13 @@ set -euo pipefail root="$(cd "$(dirname "$0")/.." && pwd)" marketplace="$root/.claude-plugin/marketplace.json" -python3 - "$marketplace" <<'PY' -import collections, json, sys +python3 - "$marketplace" "$root/release-pins.json" <<'PY' +import collections, json, re, sys with open(sys.argv[1]) as f: plugins = json.load(f)["plugins"] +with open(sys.argv[2]) as f: + pins = json.load(f) names = [p["name"] for p in plugins] failed = False @@ -50,6 +55,19 @@ if alias is not None: ) failed = True +for name, repo in pins.items(): + plugin = by_name.get(name) + if plugin is None: + print(f"error: release-pins.json pins {name!r}, which marketplace.json doesn't list", file=sys.stderr) + failed = True + elif not re.fullmatch(r"[0-9a-f]{40}", str(plugin["source"].get("sha", ""))): + print( + f"error: {name!r} must be pinned to a release of {repo} by a full commit sha.\n" + "Run bin/pin-releases. See release-pins.json.", + file=sys.stderr, + ) + failed = True + sys.exit(1 if failed else 0) PY echo "marketplace.json OK" diff --git a/bin/pin-releases b/bin/pin-releases new file mode 100755 index 0000000..f06cfd3 --- /dev/null +++ b/bin/pin-releases @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Pin each plugin in release-pins.json to the commit of its CLI's latest release. +# +# bin/pin-releases rewrite .claude-plugin/marketplace.json, print one line per plugin +# +# A CLI plugin's skills and hooks run the CLI its user installed, and users +# install the CLI from releases. Installed from the default branch, a plugin can +# call flags no released CLI has. So these entries carry the sha of the latest +# release's commit, and the scheduled Pin releases workflow moves it forward. +# +# A pin never moves backwards: if the latest release is not ahead of the pinned +# commit (a release deleted, a tag moved), the entry keeps its pin and the line +# says why. Needs gh, authenticated for reading the CLIs' public repos. +set -euo pipefail + +root="$(cd "$(dirname "$0")/.." && pwd)" +cd "$root" + +while read -r name repo; do + tag=$(gh api "repos/$repo/releases/latest" --jq .tag_name) + sha=$(gh api "repos/$repo/commits/$tag" --jq .sha) + pinned=$(jq -r --arg n "$name" '.plugins[] | select(.name == $n) | .source.sha // ""' .claude-plugin/marketplace.json) + + if [[ "$pinned" == "$sha" ]]; then + echo "$name: $repo $tag ($sha), unchanged" + continue + fi + + if [[ -n "$pinned" ]]; then + status=$(gh api "repos/$repo/compare/$pinned...$sha" --jq .status) + if [[ "$status" != "ahead" ]]; then + echo "$name: kept at $pinned: the latest release, $repo $tag ($sha), is not ahead of it (GitHub compares it as $status)" + continue + fi + fi + + python3 - "$name" "$sha" <<'PY' +import json, sys + +name, sha = sys.argv[1:] +path = ".claude-plugin/marketplace.json" +with open(path) as f: + marketplace = json.load(f) +(plugin,) = [p for p in marketplace["plugins"] if p["name"] == name] +plugin["source"]["sha"] = sha +with open(path, "w") as f: + f.write(json.dumps(marketplace, indent=2, ensure_ascii=False) + "\n") +PY + echo "$name: $repo $tag ($sha), was ${pinned:-the default branch}" +done < <(jq -r 'to_entries[] | "\(.key) \(.value)"' release-pins.json) diff --git a/release-pins.json b/release-pins.json new file mode 100644 index 0000000..8f7c5f8 --- /dev/null +++ b/release-pins.json @@ -0,0 +1,6 @@ +{ + "basecamp-cli": "basecamp/basecamp-cli", + "basecamp": "basecamp/basecamp-cli", + "hey": "basecamp/hey-cli", + "fizzy": "basecamp/fizzy-cli" +}