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
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 @@ -564,6 +564,11 @@ sync-skills:
RELEASE_TAG=$(TAG) SOURCE_SHA=$$(git rev-parse HEAD) DRY_RUN=local scripts/sync-skills.sh

# Sync skills (dry-run against real target repo)
# Run the skills sync against a throwaway basecamp/skills, as two CLIs publishing in turn
.PHONY: test-sync-skills
test-sync-skills:
scripts/test-sync-skills.sh

# Usage: make sync-skills-remote TAG=v1.2.3 SKILLS_TOKEN=ghp_...
.PHONY: sync-skills-remote
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"
24 changes: 24 additions & 0 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,30 @@ 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 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