From 97fd60645d2bb401d3e3a99f789e6943389c265b Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Tue, 20 Oct 2026 10:48:24 -0400 Subject: [PATCH] Promote a stable branch to each release for the plugin to install from 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. --- .github/workflows/release.yml | 38 ++++++++++++++++++++++++++++++ RELEASING.md | 14 +++++++++++ internal/release/manifests_test.go | 29 ++++++++++++++++++++++- 3 files changed, 80 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 34d1801c6..365bb3293 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -610,3 +610,41 @@ jobs: # Always emit annotation so the failure is visible in the workflow summary echo "::error::Skills sync to basecamp/skills failed for ${REF_NAME}. See https://github.com/${{ github.repository }}/actions/runs/${RUN_ID}" + + # The 37signals marketplace (basecamp/plugins) installs the Claude Code and + # Codex plugin from the `stable` branch, so plugin users get the skills of the + # latest release instead of whatever is on main. `stable` moves only once the + # release has published, and only to the latest stable release, as the skills + # sync does. force=false makes GitHub refuse anything but a fast-forward, so + # `stable` can never move backwards or onto a commit off main's history. + promote-stable: + name: Promote stable branch + needs: [release] + if: startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-') + concurrency: + group: promote-stable + cancel-in-progress: false + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: write + steps: + - name: Fast-forward stable to this release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.ref_name }} + run: | + latest=$(gh release view --repo "${GITHUB_REPOSITORY}" --json tagName --jq .tagName) + if [ "$TAG" != "$latest" ]; then + echo "::warning::Leaving stable alone for ${TAG}: the latest stable release is ${latest}." + exit 0 + fi + + if gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/stable" --silent; then + gh api --method PATCH "repos/${GITHUB_REPOSITORY}/git/refs/heads/stable" \ + -f sha="${GITHUB_SHA}" -F force=false --silent + else + gh api --method POST "repos/${GITHUB_REPOSITORY}/git/refs" \ + -f ref=refs/heads/stable -f sha="${GITHUB_SHA}" --silent + fi + echo "stable is now ${TAG} (${GITHUB_SHA})" diff --git a/RELEASING.md b/RELEASING.md index 3c9d64a29..983fd89fc 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -39,6 +39,7 @@ make release VERSION=0.2.0 DRY_RUN=1 - Updates Scoop manifest (`basecamp-cli`) in `basecamp/homebrew-tap` for stable tags - Updates AUR `basecamp-cli` package for stable tags when `AUR_KEY` is configured - Verifies Nix flake builds successfully for stable tags + - Fast-forwards the `stable` branch to the tag for the latest stable release, which is where the Claude Code and Codex plugin installs from ## Versioning @@ -60,6 +61,7 @@ manager channels and agent distribution metadata on the latest stable version. | Nix flake | Updates `nix/package.nix` and verifies the flake. | Nix metadata remains on the latest stable version. | | Claude plugin metadata | Updates `.claude-plugin/plugin.json`. | Plugin metadata remains on the latest stable version. | | Codex plugin metadata | Updates `.codex-plugin/plugin.json`. | Plugin metadata remains on the latest stable version. | +| Plugin install source | Fast-forwards the `stable` branch, which the marketplace installs from. | `stable` remains on the latest stable release. | | Skills distribution | Syncs skills to the distribution repo. | Uses the skill embedded in the prerelease binary. | 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 in `bin/ci`) pins the ownership contract by running the script as both CLIs against a local bare repository — real clones, commits and pushes, no network. +## Plugin install source + +The 37signals marketplace ([basecamp/plugins](https://github.com/basecamp/plugins)) +installs the `basecamp-cli` plugin for Claude Code and Codex from this repository's +`stable` branch, not `main`, so plugin users get the skills of a release rather than +whatever has merged since. The `promote-stable` release job fast-forwards `stable` to +the tag once the release has published, and only for the latest stable release, so an +older release's late or rerun job leaves it alone. GitHub refuses the update unless it +is a fast-forward. If the job fails, the release is still out but plugin users stay on +the previous one: rerun the job once the cause is fixed. + ## Requirements - On `main` branch with clean, synced working tree @@ -185,6 +198,7 @@ make update-nix-hash | Homebrew cask (`basecamp-cli`) | `basecamp/homebrew-tap` Casks/ | GoReleaser (stable tags) | | Scoop (`basecamp-cli`) | `basecamp/homebrew-tap` root | GoReleaser (stable tags) | | AUR | `basecamp-cli` | `scripts/publish-aur.sh` (stable tags) | +| 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) | | deb/rpm/apk packages | GitHub Release assets | GoReleaser (nfpm) | | Nix flake | `flake.nix` in repo | Self-serve (`nix profile install github:basecamp/basecamp-cli`) | | go install | `go install github.com/basecamp/basecamp-cli/cmd/basecamp@latest` | Go module proxy | diff --git a/internal/release/manifests_test.go b/internal/release/manifests_test.go index 24ba5678e..84f100530 100644 --- a/internal/release/manifests_test.go +++ b/internal/release/manifests_test.go @@ -1,7 +1,8 @@ // Package release_test verifies the agent plugin manifests and release wiring. // // The repository root is the live plugin payload for both Claude Code and -// Codex: the 37signals marketplace sources basecamp/basecamp-cli directly. +// Codex: the 37signals marketplace sources basecamp/basecamp-cli at its +// `stable` branch, which the release workflow moves to each stable release. // These tests keep the two thin per-agent manifests consistent with each // other and with the files they reference. Marketplace-shape enforcement // (allowed fields, exact values) belongs to each agent's own validator. @@ -20,6 +21,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" "github.com/basecamp/basecamp-cli/internal/harness" ) @@ -245,6 +247,31 @@ func TestReleaseWiringSkipsPluginStampsForPrereleases(t *testing.T) { assert.Contains(t, afterGuard, `scripts/stamp-codex-plugin-version.sh "${VERSION}"`) } +func TestReleaseWorkflowPromotesStableOnlyAfterAStableRelease(t *testing.T) { + root := repositoryRoot(t) + var workflow struct { + Jobs map[string]struct { + Needs []string `yaml:"needs"` + If string `yaml:"if"` + Permissions map[string]string `yaml:"permissions"` + Steps []struct { + Run string `yaml:"run"` + } `yaml:"steps"` + } `yaml:"jobs"` + } + require.NoError(t, yaml.Unmarshal([]byte(readFile(t, filepath.Join(root, ".github", "workflows", "release.yml"))), &workflow)) + + job, ok := workflow.Jobs["promote-stable"] + require.True(t, ok, "the marketplace installs the plugin from stable, so each stable release must move it") + assert.Equal(t, []string{"release"}, job.Needs) + assert.Contains(t, job.If, "!contains(github.ref_name, '-')") + assert.Equal(t, "write", job.Permissions["contents"]) + + require.Len(t, job.Steps, 1) + assert.Contains(t, job.Steps[0].Run, `if [ "$TAG" != "$latest" ]`) + assert.Contains(t, job.Steps[0].Run, "-F force=false") +} + func repositoryRoot(t *testing.T) string { t.Helper() _, file, _, ok := runtime.Caller(0)