Skip to content

Sync only the skills this CLI owns to basecamp/skills - #708

Merged
jeremy merged 11 commits into
mainfrom
sync-skills-ownership
Sep 14, 2026
Merged

Sync only the skills this CLI owns to basecamp/skills#708
jeremy merged 11 commits into
mainfrom
sync-skills-ownership

Conversation

@jeremy

@jeremy jeremy commented Sep 13, 2026

Copy link
Copy Markdown
Member

The bug

basecamp/skills is shared by several CLIs, and each one's scripts/sync-skills.sh recorded what it published in the same file, .managed-skills, then deleted every name in that file its own skills/ tree lacked. So the CLIs took turns deleting each other's skills (basecamp/skills#5):

Today the distribution repo holds only the basecamp skills; HEY's is gone.

The fix

  • Per-source manifest. Each publisher owns .managed-skills.<source> at the target root (basecamp-cli, hey-cli, …), one sorted name per line, and reads only its own file to decide what to remove.
  • Removal only within own manifest, with a cross-manifest guard. skills/<name> is removed only when this source's manifest lists it, this release no longer ships it, and no other .managed-skills.* claims it. Two publishers claiming one name is a collision to settle upstream, so it is warned about and left alone. Nothing else ever calls rm -rf on the target besides the per-skill refresh (a name in this source's set, immediately re-copied).
  • No first-run fallback. A target with no .managed-skills.<source> yet removes nothing and writes the manifest from the current set. The old "no manifest, so everything under skills/ is ours" fallback is gone — it is exactly what deleted the sibling's skills.
  • Legacy tombstone. .managed-skills stays but is rewritten on every run as a comment-only file. The pre-fix script validates each line against ^[a-zA-Z0-9._-]+$ and skips the rest, so a CLI still running it deletes nothing; had the file been deleted instead, that script's fallback would have claimed every skills/*. This is what makes the rollout order across CLIs irrelevant.
  • SYNC_SOURCE overrides the source name (bot identity and commit message derive from it) so the test can play the other CLI; SKILLS_REPO_URL says where the target is cloned from and pushed to (the test points it at a local bare repository). The script always clones the target fresh and pushes only the commit it made — there is no checkout to hand it. DRY_RUN=local previews the copy offline against an empty tree; DRY_RUN=remote clones the real target and stops before committing. The copy filter is unchanged.

The script is byte-identical to the shared CLI seed's (seed/scripts/sync-skills.sh in basecamp/cli#78 at 1df3bfe5) apart from the CLI_NAME default, which is what carries the per-repo SYNC_SOURCE. The seed went three steps beyond the design, and this PR carries them:

  • Publish-side guard. The sync refuses outright (before touching anything) to publish a name another source's manifest holds, not only to delete one.
  • Fetch-first retry. A rejected push — fetch first on a depth-1 clone (the old non-fast-forward match never fires there) or non-fast-forward — drops the stale commit, resets to the remote's new tip and applies the whole sync again, collision guard included, before pushing once more; no rebase of decisions made against a stale tree.
  • Hygiene. SYNC_SOURCE and DRY_RUN are validated, and the token reaches git as a github.com-scoped http.extraheader (AUTHORIZATION: basic …, the form actions/checkout writes) in a private mode-600 GIT_CONFIG_GLOBAL removed with the tmpdir, so it appears in neither the remote URL nor any git argv — an insteadOf rewrite, the first cut here, still hands the credential-bearing URL to git-remote-https in argv.
  • The script also gains SKILLS_SOURCE, which hey-cli's already had, so the two scripts are now identical apart from the default source name, and the manual Sync skills workflow adopts hey-cli's two-checkout pattern (sync logic from the dispatching ref, content from the release tag).
  • Latest-release guard on the release-time job. The sync-skills job in release.yml now makes the check the manual workflow already makes — the tag must be the repository's latest stable release — 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 basecamp/skills back. The concurrency group serialises the syncs but never ordered them.

Migration walkthrough

A local bare clone of basecamp/skills main as of today as the target (SKILLS_REPO_URL pointed at it, so every clone and push lands there and nowhere else), then this branch's script run against it as basecamp-cli, then the sibling hey-cli branch's script run against the result:

=== BEFORE (basecamp/skills main @ 42716d7) ===
skills
skills/basecamp
skills/basecamp-doctor
--- .managed-skills
basecamp
basecamp-doctor

=== basecamp-cli script (SKILLS_REPO_URL=<local clone of basecamp/skills>) ===
Found 2 skill(s) in skills/: basecamp-doctor basecamp
Cloning basecamp/skills into /var/folders/64/xwfgjgld7f704v23qrmz7zr40000gn/T/tmp.2tEKUmRH55/skills...
Copying skills into /var/folders/64/xwfgjgld7f704v23qrmz7zr40000gn/T/tmp.2tEKUmRH55/skills/skills/...
No .managed-skills.basecamp-cli yet: first run for basecamp-cli, removing nothing

=== Changes ===
 .managed-skills                 |  6 ++++--
 .managed-skills.basecamp-cli    |  2 ++
 skills/basecamp-doctor/SKILL.md |  1 +
 skills/basecamp/SKILL.md        | 16 ++++++++++++----
 4 files changed, 19 insertions(+), 6 deletions(-)


Skills synced to basecamp/skills (main) from basecamp-cli v0.12.0

=== hey-cli script (same target) ===
Found 1 skill(s) in skills/: hey
Cloning basecamp/skills into /var/folders/64/xwfgjgld7f704v23qrmz7zr40000gn/T/tmp.fSUss8L1TP/skills...
Copying skills into /var/folders/64/xwfgjgld7f704v23qrmz7zr40000gn/T/tmp.fSUss8L1TP/skills/skills/...
No .managed-skills.hey-cli yet: first run for hey-cli, removing nothing

=== Changes ===
 .managed-skills.hey-cli |   1 +
 skills/hey/SKILL.md     | 812 ++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 813 insertions(+)


Skills synced to basecamp/skills (main) from hey-cli v1.5.0

=== AFTER: find skills -maxdepth 1 ===
skills
skills/basecamp
skills/basecamp-doctor
skills/hey

=== AFTER: cat .managed-skills* ===
--- .managed-skills
# Superseded by the per-source manifests (.managed-skills.<cli>), one per publishing CLI.
# Each CLI deletes only the skill directories listed in its own manifest.
# Kept so a CLI still running the pre-fix sync script deletes nothing: that script skips
# every line it cannot parse as a skill name and only deletes names it can.
--- .managed-skills.basecamp-cli
basecamp
basecamp-doctor
--- .managed-skills.hey-cli
hey

--- git log ---
cb1b346 hey-cli[bot] Sync skills from hey-cli v1.5.0
c5486a8 basecamp-cli[bot] Sync skills from basecamp-cli v0.12.0
42716d7 basecamp-cli[bot] Sync skills from basecamp-cli v0.11.0

So a hey-cli release after the sibling PR merges restores HEY's skill to basecamp/skills, and no release of either CLI touches the other's skills again.

Test

scripts/test-sync-skills.sh (the seed's test, plus an unset SKILLS_TOKEN up front so a token in the caller's environment cannot carry the github.com case to the real target; make test-sync-skills, in make check/bin/ci and the test workflow's e2e job) builds a throwaway target reproducing basecamp/skills as the history above left it — the basecamp skills present, skills/hey gone, the legacy .managed-skills listing the basecamp names, plus a README that must survive — and two fixture trees (one with a nested file, a *.go, a dotfile and a dot-directory that must not be copied). It runs the script interleaved hey-cli, basecamp-cli, hey-cli, basecamp-cli, asserting after each run that both sources' skills are present, each manifest lists exactly its own names and .managed-skills is the tombstone. Then: a skill dropped from one tree removes only that directory; a stale legacy manifest listing the sibling's names (a pre-fix sibling having run) deletes nothing; a name listed in both manifests survives with a warning; publishing a name another source owns is refused before anything changes; DRY_RUN=remote shows the diff and commits nothing; the DRY_RUN=local preview stays offline; a concurrent publisher winning the race to origin (a real push into a local bare repo) is absorbed by the fetch-first retry, and a concurrent claim on a name this source ships makes that retry refuse (and the sibling dropping the claim lets the next release publish); a github.com target without a token is refused before anything is cloned; and the commit author is <source>[bot].

scripts/sync-skills.sh is on the sensitive-change list, so expect the gate label on this PR.

The sibling PR in hey-cli carries the same change: basecamp/hey-cli#434

Copilot AI balanced review requested due to automatic review settings September 13, 2026 00:09
@github-actions

github-actions Bot commented Sep 13, 2026

Copy link
Copy Markdown

Sensitive Change Detection (shadow mode)

This PR modifies control-plane files:

  • .github/workflows/release.yml
  • .github/workflows/sync-skills.yml
  • .github/workflows/test.yml
  • scripts/sync-skills.sh

Shadow mode — this check is informational only. When activated, changes to these paths will require approval from a maintainer.

@github-actions github-actions Bot added tests Tests (unit and e2e) ci CI/CD workflows docs labels Sep 13, 2026
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 13, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-14T04:39:33.219694Z 4ec7f53 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Claimant detection can fail under pipefail, and active ownership collisions can still overwrite another publisher’s skill.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Prevents multiple CLIs from deleting each other’s distributed skills by introducing source-specific ownership manifests.

Changes:

  • Adds per-source manifests, collision safeguards, and legacy tombstone handling.
  • Adds comprehensive interleaved-publisher E2E coverage.
  • Updates manual recovery workflow and release documentation.

[!TIP]
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.

File summaries
File Description
scripts/sync-skills.sh Implements source-scoped synchronization.
e2e/sync_skills.bats Tests ownership and migration behavior.
.github/workflows/sync-skills.yml Separates recovery logic and release content checkouts.
RELEASING.md Documents synchronization and recovery.
Review details
  • Files reviewed: 4/4 changed files
  • Comments generated: 2
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread scripts/sync-skills.sh Outdated
Comment thread scripts/sync-skills.sh Outdated
Copilot AI review requested due to automatic review settings September 13, 2026 00:12

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Active and concurrent ownership collisions can still overwrite or delete another publisher’s skill.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (1)

scripts/sync-skills.sh:220

  • Current-source names bypass the only cross-manifest check here. Since copy_skills has already removed and recopied every current name, a second publisher that currently ships the same name silently overwrites the existing skill and leaves both manifests claiming it; no collision warning is emitted. This contradicts the stated “warn and leave it alone” collision contract. Check other manifests before copying current skills and skip or fail on an active collision; the collision test should cover both source trees shipping the same name.
    in_source_set "$previously_managed" && continue
  • Files reviewed: 4/4 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread scripts/sync-skills.sh Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

rm -rf "${target_dir:?}/${name}"

P2 Badge Check other claims before replacing a current skill

When a current source skill's name is already listed in another publisher's manifest, this unconditional rm -rf replaces that publisher's directory before other_claimants is ever consulted. A sequential sync then creates a second claim for the same name without warning, and subsequent releases alternate the distributed content according to which publisher ran last rather than leaving the collision alone. Check the other manifests for every current source name before copying, and abort or preserve the existing directory when a collision is found.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread scripts/sync-skills.sh Outdated
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.
Copilot AI review requested due to automatic review settings September 13, 2026 00:46
@jeremy
jeremy force-pushed the sync-skills-ownership branch from 8985dfc to 4285688 Compare September 13, 2026 00:46
@github-actions github-actions Bot removed the tests Tests (unit and e2e) label Sep 13, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Remote validation can miss additional push URLs and allow commits to reach an unintended destination.

Get a fresh assessment by requesting another Copilot review.

Review details
  • Files reviewed: 6/6 changed files
  • Comments generated: 3
  • Review effort level: Balanced

Comment thread scripts/sync-skills.sh Outdated
Comment thread scripts/sync-skills.sh Outdated
Comment thread scripts/test-sync-skills.sh Outdated
* origin/main:
  Skill: note that subcommand help omits the root persistent flags (#707)
  Pin the vendorHash refresh at basecamp/.github's merged revision (#709)
A remote can carry several url and pushurl entries, and git pushes to all
of them, but the target guard read one value per kind (`git config --get`
reports the last), so a supplied checkout with an extra push destination
could pass the check and send the sync commit there too. Read every value
and refuse on any that is not basecamp/skills; the test covers a second
push URL and a second fetch URL, foreign one first so a single-value read
cannot pass it.

Two comments still described the rebase a retried push used to do.

Mirrors the seed (basecamp/cli#78 @ 17c383a); the script stays identical
apart from the CLI_NAME default.
Copilot AI review requested due to automatic review settings September 13, 2026 01:59

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The remote validation can be bypassed by an ambient Git URL rewrite, allowing pushes to an unintended destination.

Get a fresh assessment by requesting another Copilot review.

Review details
  • Files reviewed: 6/6 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread scripts/sync-skills.sh Outdated
…ards

Mirrors basecamp/cli@966966e (the seed is the source of truth; only the
CLI_NAME default differs here).

SKILLS_TARGET let the script adopt an existing checkout of basecamp/skills.
Every release path clones its own target, and each review round found
another corner of "any checkout" to guard. The script now always clones
into a temp directory from SKILLS_REPO_URL (default
https://github.com/basecamp/skills.git, the token carried through the same
insteadOf rewrite as before), applies, pushes and cleans up, so the only
commit it can push is the one it made. The remote-URL, branch and
clean-tree asserts are gone with the knob; the retry from the fetched tip,
the per-source manifests, the collision guard, the tombstone and DRY_RUN
validation are unchanged.

The test points SKILLS_REPO_URL at a local bare repository and reads every
result back from a clone of its own; the race is staged with a post-commit
hook that pushes the sibling's commit between the script's clone and push.
Copilot AI review requested due to automatic review settings September 13, 2026 03:15

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Manifest symlinks can expose private configuration or overwrite another CLI’s skill content.

Get a fresh assessment by requesting another Copilot review.

Review details
  • Files reviewed: 6/6 changed files
  • Comments generated: 3
  • Review effort level: Balanced

Comment thread scripts/sync-skills.sh
Comment thread scripts/sync-skills.sh
Comment thread scripts/sync-skills.sh Outdated
Mirrors the seed (basecamp/cli#78 at 1df3bfe5). GIT_CONFIG_GLOBAL stands in for
the user's global file only. The system config and any GIT_CONFIG_COUNT settings
in the environment still reach every git call the script makes — the race test
injects its hooks path through exactly that — so the comment no longer claims the
ambient environment is kept out.
…release

The sync-skills job's concurrency group serialises the syncs but does not order
them by version. An older stable release's job can reach the sync after a newer
one has published — its run stalled, or its failed sync was rerun later, which is
what the failure issue tells the operator to do — and the script mirrors the
skills wholesale, so that run would roll basecamp/skills back to the older skill.

The job now makes the check the manual Sync skills workflow already makes: the
tag must be the repository's latest stable release, or the sync is skipped with
a warning. A failed lookup fails the job, so the failure issue is filed rather
than the sync skipped in silence.
Copilot AI review requested due to automatic review settings September 14, 2026 01:55
@jeremy

jeremy commented Sep 14, 2026

Copy link
Copy Markdown
Member Author

Review threads: 3 resolved (2 fixed, 1 declined with the reasoning in the thread).
Declined:

  • Sync only the skills this CLI owns to basecamp/skills #708 (comment) — validating manifest paths as non-symlink regular files: the only actor who can plant a symlink in basecamp/skills already writes every skill there directly, and the token it could reach is a one-hour installation token scoped to that same repository, masked in the log.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The tokenless test can inherit a real token and publish fixture content to the production skills repository.

Get a fresh assessment by requesting another Copilot review.

Review details
  • Files reviewed: 7/7 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread scripts/test-sync-skills.sh
Every case set SYNC_SOURCE to play hey-cli or basecamp-cli, so the one line
this copy of the script changes — the CLI_NAME default — never ran. A last case
runs the script with neither SYNC_SOURCE nor CLI_NAME set and checks the
manifest it writes, the bot it commits as and the summary line against the
source the Makefile expects, basecamp-cli. A copy left at the seed's placeholder,
or naming another CLI, fails here instead of publishing under that source's
manifest and identity.

Mirrors seed/scripts/test-sync-skills.sh from basecamp/cli@19b52ed.
Copilot AI review requested due to automatic review settings September 14, 2026 02:17

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Consecutive pre-fix publishers can overwrite the tombstone and resume deleting one another’s skills during migration.

Get a fresh assessment by requesting another Copilot review.

Review details
  • Files reviewed: 7/7 changed files
  • Comments generated: 2
  • Review effort level: Balanced

Comment thread scripts/sync-skills.sh Outdated
Comment thread Makefile Outdated
Copilot AI review requested due to automatic review settings September 14, 2026 04:18

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The URL rewrite still exposes the repository token in the Git transport helper’s process arguments.

Get a fresh assessment by requesting another Copilot review.

Review details
  • Files reviewed: 7/7 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread scripts/sync-skills.sh
Copilot AI review requested due to automatic review settings September 14, 2026 04:36

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approved

The implementation is well-tested and addresses the ownership race safely; only a minor PR-description correction remains.

Review details
  • Files reviewed: 7/7 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread scripts/sync-skills.sh
@jeremy

jeremy commented Sep 14, 2026

Copy link
Copy Markdown
Member Author

Review threads: 5 resolved (4 fixed, 1 declined with the reasoning in the thread).

Declined:

Fixed, and worth carrying to the sibling copies of the script (basecamp/cli#78, hey-cli#434, fizzy-cli#214), since both scripts are meant to stay identical apart from CLI_NAME:

  • scripts/sync-skills.sh: the token is sent as a github.com-scoped http.extraheader in the private gitconfig instead of an insteadOf rewrite, which git expanded into git-remote-https's argv (4ec7f53); the tombstone paragraph in the header is qualified (d332f39).
  • scripts/test-sync-skills.sh: unset SKILLS_TOKEN up front, so a token in the caller's environment cannot carry the github.com case to the real basecamp/skills (32393fd).
  • Makefile only (not shared): the remote dry-run heading moved back onto sync-skills-remote (ee9828f).

Copilot approved 4ec7f53; Codex last reviewed a8bb7f3 and has not re-reviewed since, so a re-request needs a human.

@jeremy
jeremy merged commit 2571e02 into main Sep 14, 2026
27 checks passed
@jeremy
jeremy deleted the sync-skills-ownership branch September 14, 2026 05:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci CI/CD workflows docs

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants