Skip to content
Merged
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
23 changes: 23 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -549,7 +549,29 @@ jobs:
with:
persist-credentials: false

# An older stable release can reach this job after a newer one has
# published — its run stalled, or its failed sync was rerun later — and
# the script mirrors the skills wholesale, so it would roll basecamp/skills
# back. The concurrency group serialises the jobs but does not order them,
# so this is the check the manual Sync skills workflow makes: only the
# latest stable release publishes. A failed lookup fails the job, which
# files the issue below rather than skipping in silence.
- name: Confirm this is the latest stable release
id: latest
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 "publish=true" >> "$GITHUB_OUTPUT"
else
echo "::warning::Skipping the skills sync for ${TAG}: the latest stable release is ${latest}, and syncing an older tag would roll basecamp/skills back."
echo "publish=false" >> "$GITHUB_OUTPUT"
fi

- name: Generate token for skills repo
if: steps.latest.outputs.publish == 'true'
id: skills-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
Expand All @@ -560,6 +582,7 @@ jobs:
permission-contents: write

- name: Sync skills to distribution repo
if: steps.latest.outputs.publish == 'true'
id: sync
env:
SKILLS_TOKEN: ${{ steps.skills-token.outputs.token }}
Expand Down
40 changes: 29 additions & 11 deletions .github/workflows/sync-skills.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@
# a way back without cutting a new tag. This is it.
#
# scripts/sync-skills.sh mirrors the skills/ tree at the given ref into
# basecamp/skills and no-ops when the content already matches, so re-running it
# for an already-synced release is safe.
# basecamp/skills — only this CLI's skills, tracked in its own manifest there
# (.managed-skills.basecamp-cli); other CLIs' skills are never touched — and
# no-ops when the content already matches, so re-running it for an
# already-synced release is safe.
name: Sync skills

on:
Expand Down Expand Up @@ -52,8 +54,8 @@ jobs:
exit 1
fi

# sync-skills.sh mirrors the tree wholesale, so syncing an older tag
# would roll basecamp/skills back. Unlike the AUR, there is no
# sync-skills.sh mirrors this CLI's skills wholesale, so syncing an
# older tag would roll them back in basecamp/skills. Unlike the AUR, there is no
# independent record of what the distribution repo currently holds, and
# the only reason to run this by hand is that the newest release failed
# to sync — so require exactly that release.
Expand All @@ -63,11 +65,24 @@ jobs:
exit 1
fi

# Check out the tag itself, not main: the sync must mirror the skills tree
# as it was released, even if main has moved on since.
# Two checkouts: sync logic from the dispatching ref, skills content from
# the release tag. A defect in scripts/sync-skills.sh is one of the ways
# the automatic sync fails, and running the tagged copy here would just
# re-run the defective script — recovery must be able to run a fix merged
# to main without cutting a new tag.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.tag }}
persist-credentials: false

# The content still mirrors the skills tree as it was released, even if
# main has moved on since: SKILLS_SOURCE points the script at this
# checkout. Fully qualified: actions/checkout resolves a bare name as a
# branch before a tag, so a branch sharing the tag's name would win and
# mirror unreleased content.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: refs/tags/${{ inputs.tag }}
path: release
persist-credentials: false

# Needed for dry runs too: the honest preview clones the target, so it
Expand All @@ -83,12 +98,14 @@ jobs:
repositories: skills
permission-contents: ${{ inputs.dry_run && 'read' || 'write' }}

# github.sha is the dispatching ref's SHA (main), not the tag's, so resolve
# the commit actually checked out — otherwise the sync records the wrong
# provenance for the release it claims to mirror.
# github.sha is the dispatching ref's SHA (main), not the tag's, so
# resolve the tagged commit from its own checkout — otherwise the sync
# records the wrong provenance for the release it claims to mirror.
- name: Resolve the tagged commit
id: source
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
env:
TAG: ${{ inputs.tag }}
run: echo "sha=$(git -C release rev-parse --verify "refs/tags/${TAG}^{commit}")" >> "$GITHUB_OUTPUT"

# DRY_RUN=remote, not local: the local path never clones basecamp/skills
# and diffs against an empty repo, so every skill reads as newly added and
Expand All @@ -100,5 +117,6 @@ jobs:
SKILLS_TOKEN: ${{ steps.skills-token.outputs.token }}
RELEASE_TAG: ${{ inputs.tag }}
SOURCE_SHA: ${{ steps.source.outputs.sha }}
SKILLS_SOURCE: release/skills
DRY_RUN: ${{ inputs.dry_run && 'remote' || '' }}
run: scripts/sync-skills.sh
3 changes: 3 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,9 @@ jobs:
- name: Run BATS integration tests
run: make test-e2e

- name: Test the skills sync
run: make test-sync-skills

cli-surface:
name: CLI Surface Check
runs-on: ubuntu-latest
Expand Down
8 changes: 7 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -383,7 +383,7 @@ check-smoke-coverage: build

# Run all checks (local CI gate)
.PHONY: check
check: fmt-check vet lint lint-actions test test-e2e check-naming check-surface check-skill-drift check-bare-groups check-lint-lockstep check-smoke-coverage provenance-check tidy-check
check: fmt-check vet lint lint-actions test test-e2e test-sync-skills check-naming check-surface check-skill-drift check-bare-groups check-lint-lockstep check-smoke-coverage provenance-check tidy-check

# Lint GitHub Actions workflows (requires actionlint + zizmor)
.PHONY: lint-actions
Expand Down Expand Up @@ -563,6 +563,11 @@ sync-skills:
@test -n "$(TAG)" || (echo "Usage: make sync-skills TAG=v1.2.3" && exit 1)
RELEASE_TAG=$(TAG) SOURCE_SHA=$$(git rev-parse HEAD) DRY_RUN=local scripts/sync-skills.sh

# Run the skills sync against a throwaway basecamp/skills, as two CLIs publishing in turn
.PHONY: test-sync-skills
test-sync-skills:
EXPECTED_SOURCE=basecamp-cli scripts/test-sync-skills.sh

# Sync skills (dry-run against real target repo)
# Usage: make sync-skills-remote TAG=v1.2.3 SKILLS_TOKEN=ghp_...
.PHONY: sync-skills-remote
Expand Down Expand Up @@ -647,6 +652,7 @@ help:
@echo ""
@echo "Skills:"
@echo " sync-skills Local dry-run of skill sync (TAG=v1.2.3)"
@echo " test-sync-skills Run the skills sync as two CLIs against a throwaway target"
@echo " sync-skills-remote Remote dry-run (TAG=v1.2.3 SKILLS_TOKEN=...)"
@echo ""
@echo " help Show this help"
26 changes: 26 additions & 0 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,32 @@ basecamp skill
basecamp skill install
```

## Skills sync

Stable releases mirror `skills/` into [basecamp/skills](https://github.com/basecamp/skills),
which several CLIs share. `scripts/sync-skills.sh` owns only this CLI's skills there:
it records the names it published in `.managed-skills.basecamp-cli` at the target root
and removes a `skills/<name>` only when that manifest lists it, the release no longer
ships it, and no other CLI's `.managed-skills.*` claims it (a collision is warned about
and left alone), and refuses outright to publish a name another CLI's manifest holds.
A target with no `.managed-skills.basecamp-cli` yet is a first run: nothing is removed.
A push rejected because another CLI published first is retried by applying the whole
sync again from the remote's new tip, not by replaying the stale commit. The legacy shared `.managed-skills` is rewritten as a comment-only
tombstone so a CLI still on the pre-fix script — which deleted everything its own tree
lacked — deletes nothing (basecamp/skills#5).

If the release-time sync fails, recover with the `Sync skills` workflow
(`workflow_dispatch`, stable tag, optional dry run). It refuses anything but the latest
stable release so it cannot roll the distribution repo back, and it runs the sync script
from the dispatching branch against the tag's skills tree — so when the failure was a
defect in `sync-skills.sh` itself, merge the fix to main and dispatch; no new release
needed. The release-time job makes the same check before it publishes, so an older
release whose run stalls, or whose failed sync is rerun after a newer release has
shipped, skips the sync instead of rolling it back. The script always clones the target fresh and pushes only the commit it made,
so there is no checkout to hand it. `scripts/test-sync-skills.sh` (`make test-sync-skills`,
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.

## Requirements

- On `main` branch with clean, synced working tree
Expand Down
Loading
Loading