Skip to content

Commit 4285688

Browse files
committed
Sync only the skills this CLI owns to basecamp/skills
basecamp/skills is shared by several CLIs, and every one of them ran the same sync script against one shared .managed-skills: publish skills/*, then delete every name in that file that the publisher's own tree lacks. So hey-cli's v0.1.1 release deleted skills/basecamp and skills/basecamp-doctor (basecamp/skills@08ef7ea), and basecamp-cli's v0.10.0 and v0.11.0 deleted skills/hey (728a916, 42716d7). Today the distribution repo holds only whichever CLI released last (basecamp/skills#5). Each publisher now owns a manifest of its own, .managed-skills.<source>, and reads only that file to decide what to remove. A skills/<name> goes only when this source's manifest lists it, this release no longer ships it, and no other source's manifest claims it — a name two manifests list is a collision to settle upstream, so it is warned about and left, and a name another source's manifest holds is refused for publishing. A target with no manifest for this source yet removes nothing; the old "no manifest, so everything under skills/ is ours" fallback is gone, since it is exactly what deleted the sibling's skills. The legacy .managed-skills stays, rewritten on every run as a comment-only tombstone. The pre-fix script skips every line it cannot parse as a skill name, so a sibling still running it deletes nothing; had the file been removed, that script's fallback would have claimed every skills/* directory. That is what makes the rollout order across CLIs irrelevant. A push rejected because a sibling published first ("fetch first" on a depth-1 clone, or non-fast-forward) drops the stale commit and applies the sync again from the remote's new tip, collision guard included. The token reaches git through a private GIT_CONFIG_GLOBAL insteadOf rewrite rather than the clone URL, and the checkout must be clean before the sync sweeps it into a commit. SYNC_SOURCE overrides the source name (the bot identity and commit message derive from it) so a test can play the other CLI, and SKILLS_TARGET points the script at an existing checkout instead of cloning; with DRY_RUN=local it applies and commits but skips the push. scripts/test-sync-skills.sh (make test-sync-skills, in bin/ci) builds a target reproducing basecamp/skills as the history above left it and runs the script interleaved as both CLIs, through a rejected push included. The script is the shared CLI seed's, byte-identical apart from the CLI_NAME default. It also gains SKILLS_SOURCE, as hey-cli's already had, and the manual recovery workflow adopts hey-cli's two-checkout pattern: sync logic from the dispatching ref, content from the release tag.
1 parent d120b3a commit 4285688

6 files changed

Lines changed: 655 additions & 139 deletions

File tree

‎.github/workflows/sync-skills.yml‎

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@
77
# a way back without cutting a new tag. This is it.
88
#
99
# scripts/sync-skills.sh mirrors the skills/ tree at the given ref into
10-
# basecamp/skills and no-ops when the content already matches, so re-running it
11-
# for an already-synced release is safe.
10+
# basecamp/skills — only this CLI's skills, tracked in its own manifest there
11+
# (.managed-skills.basecamp-cli); other CLIs' skills are never touched — and
12+
# no-ops when the content already matches, so re-running it for an
13+
# already-synced release is safe.
1214
name: Sync skills
1315

1416
on:
@@ -52,8 +54,8 @@ jobs:
5254
exit 1
5355
fi
5456
55-
# sync-skills.sh mirrors the tree wholesale, so syncing an older tag
56-
# would roll basecamp/skills back. Unlike the AUR, there is no
57+
# sync-skills.sh mirrors this CLI's skills wholesale, so syncing an
58+
# older tag would roll them back in basecamp/skills. Unlike the AUR, there is no
5759
# independent record of what the distribution repo currently holds, and
5860
# the only reason to run this by hand is that the newest release failed
5961
# to sync — so require exactly that release.
@@ -63,11 +65,24 @@ jobs:
6365
exit 1
6466
fi
6567
66-
# Check out the tag itself, not main: the sync must mirror the skills tree
67-
# as it was released, even if main has moved on since.
68+
# Two checkouts: sync logic from the dispatching ref, skills content from
69+
# the release tag. A defect in scripts/sync-skills.sh is one of the ways
70+
# the automatic sync fails, and running the tagged copy here would just
71+
# re-run the defective script — recovery must be able to run a fix merged
72+
# to main without cutting a new tag.
6873
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
6974
with:
70-
ref: ${{ inputs.tag }}
75+
persist-credentials: false
76+
77+
# The content still mirrors the skills tree as it was released, even if
78+
# main has moved on since: SKILLS_SOURCE points the script at this
79+
# checkout. Fully qualified: actions/checkout resolves a bare name as a
80+
# branch before a tag, so a branch sharing the tag's name would win and
81+
# mirror unreleased content.
82+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
83+
with:
84+
ref: refs/tags/${{ inputs.tag }}
85+
path: release
7186
persist-credentials: false
7287

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

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

93110
# DRY_RUN=remote, not local: the local path never clones basecamp/skills
94111
# and diffs against an empty repo, so every skill reads as newly added and
@@ -100,5 +117,6 @@ jobs:
100117
SKILLS_TOKEN: ${{ steps.skills-token.outputs.token }}
101118
RELEASE_TAG: ${{ inputs.tag }}
102119
SOURCE_SHA: ${{ steps.source.outputs.sha }}
120+
SKILLS_SOURCE: release/skills
103121
DRY_RUN: ${{ inputs.dry_run && 'remote' || '' }}
104122
run: scripts/sync-skills.sh

‎.github/workflows/test.yml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,9 @@ jobs:
181181
- name: Run BATS integration tests
182182
run: make test-e2e
183183

184+
- name: Test the skills sync
185+
run: make test-sync-skills
186+
184187
cli-surface:
185188
name: CLI Surface Check
186189
runs-on: ubuntu-latest

‎Makefile‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -383,7 +383,7 @@ check-smoke-coverage: build
383383

384384
# Run all checks (local CI gate)
385385
.PHONY: check
386-
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
386+
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
387387

388388
# Lint GitHub Actions workflows (requires actionlint + zizmor)
389389
.PHONY: lint-actions
@@ -564,6 +564,11 @@ sync-skills:
564564
RELEASE_TAG=$(TAG) SOURCE_SHA=$$(git rev-parse HEAD) DRY_RUN=local scripts/sync-skills.sh
565565

566566
# Sync skills (dry-run against real target repo)
567+
# Run the skills sync against a throwaway basecamp/skills, as two CLIs publishing in turn
568+
.PHONY: test-sync-skills
569+
test-sync-skills:
570+
scripts/test-sync-skills.sh
571+
567572
# Usage: make sync-skills-remote TAG=v1.2.3 SKILLS_TOKEN=ghp_...
568573
.PHONY: sync-skills-remote
569574
sync-skills-remote:
@@ -647,6 +652,7 @@ help:
647652
@echo ""
648653
@echo "Skills:"
649654
@echo " sync-skills Local dry-run of skill sync (TAG=v1.2.3)"
655+
@echo " test-sync-skills Run the skills sync as two CLIs against a throwaway target"
650656
@echo " sync-skills-remote Remote dry-run (TAG=v1.2.3 SKILLS_TOKEN=...)"
651657
@echo ""
652658
@echo " help Show this help"

‎RELEASING.md‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,28 @@ basecamp skill
7070
basecamp skill install
7171
```
7272

73+
## Skills sync
74+
75+
Stable releases mirror `skills/` into [basecamp/skills](https://github.com/basecamp/skills),
76+
which several CLIs share. `scripts/sync-skills.sh` owns only this CLI's skills there:
77+
it records the names it published in `.managed-skills.basecamp-cli` at the target root
78+
and removes a `skills/<name>` only when that manifest lists it, the release no longer
79+
ships it, and no other CLI's `.managed-skills.*` claims it (a collision is warned about
80+
and left alone), and refuses outright to publish a name another CLI's manifest holds.
81+
A target with no `.managed-skills.basecamp-cli` yet is a first run: nothing is removed.
82+
A push rejected because another CLI published first is retried by applying the whole
83+
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
84+
tombstone so a CLI still on the pre-fix script — which deleted everything its own tree
85+
lacked — deletes nothing (basecamp/skills#5).
86+
87+
If the release-time sync fails, recover with the `Sync skills` workflow
88+
(`workflow_dispatch`, stable tag, optional dry run). It refuses anything but the latest
89+
stable release so it cannot roll the distribution repo back, and it runs the sync script
90+
from the dispatching branch against the tag's skills tree — so when the failure was a
91+
defect in `sync-skills.sh` itself, merge the fix to main and dispatch; no new release
92+
needed. `scripts/test-sync-skills.sh` (`make test-sync-skills`, in `bin/ci`) pins the
93+
ownership contract by running the script as both CLIs against a throwaway target.
94+
7395
## Requirements
7496

7597
- On `main` branch with clean, synced working tree

0 commit comments

Comments
 (0)