Skip to content

Commit 74d6506

Browse files
authored
Promote a stable branch to each release for the plugin to install from (#925)
The 37signals marketplace installed the Claude Code and Codex plugin from main, so between releases a fresh install got skills that call commands no released CLI has. The marketplace will install from a stable branch instead (basecamp/plugins), and this keeps that branch on the latest release. A promote-stable job fast-forwards stable to the tag once the release has published, and only for the latest stable release, as the skills sync does. GitHub refuses the ref update unless it is a fast-forward, so stable can't move backwards. Moving it from the release workflow, not release.sh, means a release whose build fails never moves it, and no other repository changes per release.
1 parent b5dec14 commit 74d6506

3 files changed

Lines changed: 80 additions & 1 deletion

File tree

‎.github/workflows/release.yml‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -610,3 +610,41 @@ jobs:
610610
611611
# Always emit annotation so the failure is visible in the workflow summary
612612
echo "::error::Skills sync to basecamp/skills failed for ${REF_NAME}. See https://github.com/${{ github.repository }}/actions/runs/${RUN_ID}"
613+
614+
# The 37signals marketplace (basecamp/plugins) installs the Claude Code and
615+
# Codex plugin from the `stable` branch, so plugin users get the skills of the
616+
# latest release instead of whatever is on main. `stable` moves only once the
617+
# release has published, and only to the latest stable release, as the skills
618+
# sync does. force=false makes GitHub refuse anything but a fast-forward, so
619+
# `stable` can never move backwards or onto a commit off main's history.
620+
promote-stable:
621+
name: Promote stable branch
622+
needs: [release]
623+
if: startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-')
624+
concurrency:
625+
group: promote-stable
626+
cancel-in-progress: false
627+
runs-on: ubuntu-latest
628+
timeout-minutes: 5
629+
permissions:
630+
contents: write
631+
steps:
632+
- name: Fast-forward stable to this release
633+
env:
634+
GH_TOKEN: ${{ github.token }}
635+
TAG: ${{ github.ref_name }}
636+
run: |
637+
latest=$(gh release view --repo "${GITHUB_REPOSITORY}" --json tagName --jq .tagName)
638+
if [ "$TAG" != "$latest" ]; then
639+
echo "::warning::Leaving stable alone for ${TAG}: the latest stable release is ${latest}."
640+
exit 0
641+
fi
642+
643+
if gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/stable" --silent; then
644+
gh api --method PATCH "repos/${GITHUB_REPOSITORY}/git/refs/heads/stable" \
645+
-f sha="${GITHUB_SHA}" -F force=false --silent
646+
else
647+
gh api --method POST "repos/${GITHUB_REPOSITORY}/git/refs" \
648+
-f ref=refs/heads/stable -f sha="${GITHUB_SHA}" --silent
649+
fi
650+
echo "stable is now ${TAG} (${GITHUB_SHA})"

‎RELEASING.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ make release VERSION=0.2.0 DRY_RUN=1
3939
- Updates Scoop manifest (`basecamp-cli`) in `basecamp/homebrew-tap` for stable tags
4040
- Updates AUR `basecamp-cli` package for stable tags when `AUR_KEY` is configured
4141
- Verifies Nix flake builds successfully for stable tags
42+
- Fast-forwards the `stable` branch to the tag for the latest stable release, which is where the Claude Code and Codex plugin installs from
4243

4344
## Versioning
4445

@@ -60,6 +61,7 @@ manager channels and agent distribution metadata on the latest stable version.
6061
| Nix flake | Updates `nix/package.nix` and verifies the flake. | Nix metadata remains on the latest stable version. |
6162
| Claude plugin metadata | Updates `.claude-plugin/plugin.json`. | Plugin metadata remains on the latest stable version. |
6263
| Codex plugin metadata | Updates `.codex-plugin/plugin.json`. | Plugin metadata remains on the latest stable version. |
64+
| Plugin install source | Fast-forwards the `stable` branch, which the marketplace installs from. | `stable` remains on the latest stable release. |
6365
| Skills distribution | Syncs skills to the distribution repo. | Uses the skill embedded in the prerelease binary. |
6466

6567
The prerelease binary embeds the matching `skills/basecamp/SKILL.md`. Testers can
@@ -96,6 +98,17 @@ so there is no checkout to hand it. `scripts/test-sync-skills.sh` (`make test-sy
9698
in `bin/ci`) pins the ownership contract by running the script as both CLIs against a
9799
local bare repository — real clones, commits and pushes, no network.
98100

101+
## Plugin install source
102+
103+
The 37signals marketplace ([basecamp/plugins](https://github.com/basecamp/plugins))
104+
installs the `basecamp-cli` plugin for Claude Code and Codex from this repository's
105+
`stable` branch, not `main`, so plugin users get the skills of a release rather than
106+
whatever has merged since. The `promote-stable` release job fast-forwards `stable` to
107+
the tag once the release has published, and only for the latest stable release, so an
108+
older release's late or rerun job leaves it alone. GitHub refuses the update unless it
109+
is a fast-forward. If the job fails, the release is still out but plugin users stay on
110+
the previous one: rerun the job once the cause is fixed.
111+
99112
## Requirements
100113

101114
- On `main` branch with clean, synced working tree
@@ -185,6 +198,7 @@ make update-nix-hash
185198
| Homebrew cask (`basecamp-cli`) | `basecamp/homebrew-tap` Casks/ | GoReleaser (stable tags) |
186199
| Scoop (`basecamp-cli`) | `basecamp/homebrew-tap` root | GoReleaser (stable tags) |
187200
| AUR | `basecamp-cli` | `scripts/publish-aur.sh` (stable tags) |
201+
| Claude Code and Codex plugin | `basecamp-cli@37signals`, from the `stable` branch via [basecamp/plugins](https://github.com/basecamp/plugins) | `promote-stable` job in the release workflow (latest stable tag) |
188202
| deb/rpm/apk packages | GitHub Release assets | GoReleaser (nfpm) |
189203
| Nix flake | `flake.nix` in repo | Self-serve (`nix profile install github:basecamp/basecamp-cli`) |
190204
| go install | `go install github.com/basecamp/basecamp-cli/cmd/basecamp@latest` | Go module proxy |

‎internal/release/manifests_test.go‎

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
// Package release_test verifies the agent plugin manifests and release wiring.
22
//
33
// The repository root is the live plugin payload for both Claude Code and
4-
// Codex: the 37signals marketplace sources basecamp/basecamp-cli directly.
4+
// Codex: the 37signals marketplace sources basecamp/basecamp-cli at its
5+
// `stable` branch, which the release workflow moves to each stable release.
56
// These tests keep the two thin per-agent manifests consistent with each
67
// other and with the files they reference. Marketplace-shape enforcement
78
// (allowed fields, exact values) belongs to each agent's own validator.
@@ -20,6 +21,7 @@ import (
2021

2122
"github.com/stretchr/testify/assert"
2223
"github.com/stretchr/testify/require"
24+
"gopkg.in/yaml.v3"
2325

2426
"github.com/basecamp/basecamp-cli/internal/harness"
2527
)
@@ -245,6 +247,31 @@ func TestReleaseWiringSkipsPluginStampsForPrereleases(t *testing.T) {
245247
assert.Contains(t, afterGuard, `scripts/stamp-codex-plugin-version.sh "${VERSION}"`)
246248
}
247249

250+
func TestReleaseWorkflowPromotesStableOnlyAfterAStableRelease(t *testing.T) {
251+
root := repositoryRoot(t)
252+
var workflow struct {
253+
Jobs map[string]struct {
254+
Needs []string `yaml:"needs"`
255+
If string `yaml:"if"`
256+
Permissions map[string]string `yaml:"permissions"`
257+
Steps []struct {
258+
Run string `yaml:"run"`
259+
} `yaml:"steps"`
260+
} `yaml:"jobs"`
261+
}
262+
require.NoError(t, yaml.Unmarshal([]byte(readFile(t, filepath.Join(root, ".github", "workflows", "release.yml"))), &workflow))
263+
264+
job, ok := workflow.Jobs["promote-stable"]
265+
require.True(t, ok, "the marketplace installs the plugin from stable, so each stable release must move it")
266+
assert.Equal(t, []string{"release"}, job.Needs)
267+
assert.Contains(t, job.If, "!contains(github.ref_name, '-')")
268+
assert.Equal(t, "write", job.Permissions["contents"])
269+
270+
require.Len(t, job.Steps, 1)
271+
assert.Contains(t, job.Steps[0].Run, `if [ "$TAG" != "$latest" ]`)
272+
assert.Contains(t, job.Steps[0].Run, "-F force=false")
273+
}
274+
248275
func repositoryRoot(t *testing.T) string {
249276
t.Helper()
250277
_, file, _, ok := runtime.Caller(0)

0 commit comments

Comments
 (0)