Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand All @@ -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"
},
Expand All @@ -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"
},
Expand All @@ -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"
},
Expand Down
67 changes: 67 additions & 0 deletions .github/workflows/pin-releases.yml
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +50 to +51
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
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 20 additions & 2 deletions bin/check-marketplace
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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"
Expand Down
50 changes: 50 additions & 0 deletions bin/pin-releases
Original file line number Diff line number Diff line change
@@ -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)
6 changes: 6 additions & 0 deletions release-pins.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"basecamp-cli": "basecamp/basecamp-cli",
"basecamp": "basecamp/basecamp-cli",
"hey": "basecamp/hey-cli",
"fizzy": "basecamp/fizzy-cli"
}
Loading