Skip to content
Open
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
38 changes: 38 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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})"
14 changes: 14 additions & 0 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 |
29 changes: 28 additions & 1 deletion internal/release/manifests_test.go
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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"
)
Expand Down Expand Up @@ -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)
Expand Down
Loading