Skip to content

Seed the skills sync with per-source manifests and the skills/ layout - #78

Open
jeremy wants to merge 5 commits into
mainfrom
sync-skills-ownership
Open

Seed the skills sync with per-source manifests and the skills/ layout#78
jeremy wants to merge 5 commits into
mainfrom
sync-skills-ownership

Conversation

@jeremy

@jeremy jeremy commented Sep 13, 2026

Copy link
Copy Markdown
Member

Fixes the seed's skills sync so future CLIs inherit the fix for basecamp/skills#5 — the hey-cli bot deleting the Basecamp skills and the basecamp-cli bot deleting hey, on alternate releases.

Two ways the seed had drifted from reality

  1. Layout. All three copies here published to <cli>/<skill>/ at the target root. That layout was never adopted: github.com/basecamp/skills holds skills/<name>/, which is what npx skills add basecamp/skills reads and what basecamp-cli's and hey-cli's real scripts write. The seed now writes skills/<name>/.
  2. Ownership. All three tracked ownership in one shared .managed-skills. The real CLIs' scripts (which had diverged from the seed) put bare names in that same file, and each one deletes every name not its own — that is Add CI automation: benchmarks, AI labeler, path labeler #5. And the seed's removal listed the <cli>/ directory, so with the skills/<name> layout it needs a manifest to know what it owns at all.

The design

Each publishing source owns .managed-skills.<source> at the target root (hey-cli, basecamp-cli, cli for this repo), one sorted name per line. A skills/<name> directory is removed only when this source's manifest lists it, its current skill set no longer has it, and no other source's manifest claims it — a name two sources claim gets a warning and is left alone, and the script refuses outright to publish a name another source's manifest holds (a collision to settle upstream, never one a release resolves by clobbering). With no manifest yet the first run removes nothing; the old "no manifest ⇒ every skills/* is mine" fallback is gone. The legacy .managed-skills is rewritten on every run as a comment-only tombstone: the pre-fix script skips lines it cannot parse as a skill name but treats a missing file as licence to own everything, so the tombstone is what makes the rollout order irrelevant.

Identity derives from SYNC_SOURCE (default <CLI_NAME>-cli): the manifest name, <source>[bot], and Sync skills from <source> <tag>. The safety asserts (remote URL, branch), the copy filter (no *.go, no dotfiles), SKILLS_SOURCE and both DRY_RUN modes come from hey-cli's script; the token stays in a private temp gitconfig insteadOf rewrite (the seed's approach) rather than in the clone URL. The push retry changed shape in review: a rejected push (fetch first — what a fresh clone actually gets — or non-fast-forward) drops the commit, fetches the remote's tip and applies the whole sync again against it, collision guard included, rather than rebasing decisions made against a stale tree. SKILLS_TARGET=<checkout> syncs into an existing checkout instead of cloning (refused if dirty, or if its fetch or push URL is not basecamp/skills), which is what the test uses.

One script, not three

  • seed/scripts/sync-skills.sh is the implementation.
  • scripts/sync-skills.sh (this repo's own, for skills/rubric-audit) is a thin wrapper that execs it with SYNC_SOURCE=cli. A copy would recreate the drift this PR removes.
  • actions/sync-skills/action.yml runs it via ${{ github.action_path }}/../../seed/scripts/sync-skills.sh instead of carrying an inlined third copy. Nothing consumes this action — not hey-cli, basecamp-cli, fizzy-cli or the seed's own release.yml (all run: scripts/sync-skills.sh directly), and a GitHub code search for basecamp/cli/actions/sync-skills finds no uses. Keeping it is now cheap (a mapping of inputs to env vars), but deleting it would be the more honest change; happy to drop it here or in a follow-up.

Test

seed/scripts/test-sync-skills.sh builds a throwaway basecamp/skills in the state #5 left it (skills/basecamp, skills/basecamp-doctor, legacy .managed-skills listing them) and two fixture trees (hey-cli with a nested file, a *.go, a dotfile and a dot-directory that must not be copied; basecamp-cli), then runs the script interleaved — A, B, A, B — asserting after each run that both sources' skills are present, the manifests list exactly their own names, .managed-skills is the tombstone, and the author is <source>[bot]. Then: hey-cli drops a skill (only that directory goes); a pre-fix sibling rewrites the legacy manifest (nothing of B's goes); both manifests claim basecamp (survives, with the warning); hey-cli ships a skill named basecamp (refused, target untouched); DRY_RUN=remote and tokenless DRY_RUN=local; a real push racing a sibling against a local bare origin (retry lands), and the same race where the sibling claims a name this source ships (retry refuses); a dirty checkout; and the remote-URL, push-URL and branch asserts. Three deliberate mutants of the script (tombstone dropped, collision guard dropped, dot-directory filter dropped) each fail exactly the assertions aimed at them.

Wired into make check / make check-all here and in the seed Makefile, the Test job in .github/workflows/test.yml, and the seed's test and release workflows. prompts/seed-cli.md copies the test alongside the script.

Also

  • .github/workflows/sensitive-change-gate.yml now gates seed/scripts/sync-skills.sh too, since that is where the push logic lives.
  • CLI_NAME: cli is dropped from this repo's release workflow — the wrapper owns the identity (it would otherwise have read as cli-cli).
  • Docs: AGENTS.md (new "Skills sync" section), README.md (actions table, Skills section), prompts/seed-cli.md. RUBRIC.md 3A.5 still points at scripts/sync-skills.sh, which stays right for a generated CLI.

basecamp-cli and hey-cli are getting the same script in sibling PRs (links to follow).

The sync logic lived three times (scripts/, seed/scripts/, the composite
action), and all three diverged from what basecamp/skills actually holds:
they published to <cli>/<skill>/ at the target root, a layout never
adopted, and tracked ownership in one shared .managed-skills. The real
CLIs' scripts write skills/<name>/ and bare names in that shared file,
and each deletes every name not its own (basecamp/skills#5).

One script now, seed/scripts/sync-skills.sh: the skills/<name>/ layout,
a manifest per publishing source (.managed-skills.<source>), removal only
of names this source listed and no longer ships and no other source
claims, a refusal to publish a name another source owns, no first-run
fallback, and the legacy .managed-skills rewritten as a comment-only
tombstone so an un-upgraded sibling deletes nothing. The cli repo's own
scripts/sync-skills.sh execs it as source `cli`; the composite action
runs it from its checkout.

seed/scripts/test-sync-skills.sh runs the script as hey-cli and
basecamp-cli in turn against a throwaway target and is part of
make check in both this repo and the seed.
Copilot AI balanced review requested due to automatic review settings September 13, 2026 00:09
@github-actions

Copy link
Copy Markdown

Sensitive Change Detection (shadow mode)

This PR modifies control-plane files:

  • .github/workflows/release.yml
  • .github/workflows/sensitive-change-gate.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.

@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-13T03:17:34.352274Z 966966e 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.

@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

Here are some automated review suggestions for this pull request.

Reviewed commit: f08ae6bd52

ℹ️ 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 seed/scripts/sync-skills.sh Outdated
Comment thread seed/scripts/sync-skills.sh Outdated
Comment thread seed/scripts/sync-skills.sh Outdated

Copilot AI 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.

🟡 Changes recommended

Unresolved critical and moderate findings affect retry safety, ownership protection, push destinations, cleanup, and target integrity.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Centralizes skills synchronization with per-source manifests, the skills/<name>/ layout, and expanded integration coverage.

Changes:

  • Replaces duplicated sync scripts with the seed implementation and wrappers.
  • Adds ownership manifests, collision protection, tombstone migration, and filtering.
  • Wires tests into Makefiles and CI; updates workflows and documentation.
File summaries
File Reviewed changes
seed/scripts/test-sync-skills.sh Multi-source integration tests; nit (1 vote): documented mutant checks are not executed.
seed/scripts/sync-skills.sh Canonical sync implementation; unresolved critical findings cover retry safety (2 votes), push-URL validation (1), and pipefail ownership checks (1); moderate findings cover dirty targets (2), empty-source cleanup (1), and -n manifest/source emission (1 each).
seed/Makefile Wires sync tests into seed checks.
seed/.github/workflows/test.yml Adds seed sync testing to CI.
seed/.github/workflows/release.yml Updates seed release validation and sync invocation.
scripts/sync-skills.sh Thin wrapper using the repository’s sync identity.
README.md Documents the action and skills synchronization.
prompts/seed-cli.md Updates generated seed files and test instructions.
Makefile Adds repository sync test integration.
AGENTS.md Documents skills-sync architecture and guidance.
actions/sync-skills/action.yml Delegates action execution to the seed implementation.
.github/workflows/test.yml Adds sync coverage to repository CI.
.github/workflows/sensitive-change-gate.yml Extends sensitive-change protection to the canonical script.
.github/workflows/release.yml Removes redundant CLI identity configuration.
Review details

Suppressed comments (4)

seed/scripts/sync-skills.sh:154

  • An empty skills tree is rejected before the stale-manifest cleanup can run. Both release workflows gate this script on ls skills/*/SKILL.md, so deleting a source's last skill will skip the sync and leave its previously published directory and manifest entry forever. Permit an existing-but-empty source tree to run the removal path (while still failing for a missing source), and update the workflow precheck.
[[ ${#skill_names[@]} -gt 0 ]] || die "no skills found under ${SKILLS_SOURCE}/*/SKILL.md"

seed/scripts/sync-skills.sh:96

  • plain_name permits skill names such as -n, but echo "$line" treats those as echo options instead of emitting the manifest entry. As a result, ownership checks and stale-removal logic can miss a valid -n entry and overwrite another source's skill. Emit manifest lines with printf '%s\n' "$line" instead.
    if plain_name "$line"; then
      echo "$line"

seed/scripts/sync-skills.sh:111

  • plain_name also permits a source name such as -n; echo "$other" then emits an empty value, so claimed_by_other reports a collision with a blank source and produces an unusable manifest filename in its warning/error path. Use printf '%s\n' "$other" when returning the source name.
    if read_manifest "$file" | grep -qxF -- "$name"; then
      echo "$other"

seed/scripts/test-sync-skills.sh:267

  • The description says this test runs three deliberate mutants, but the test only invokes the supplied SYNC_SCRIPT; it never copies or modifies a mutant or executes a second script. A regression that removes the tombstone, collision guard, or dot-directory filter would therefore not fail these assertions. Add the mutation checks or remove that claim.
# --- Verdict ---

echo ""
if [[ "$failures" -eq 0 ]]; then
  echo "sync-skills: all assertions passed"
  • Files reviewed: 14/14 changed files
  • Comments generated: 4
  • Review effort level: Lite (auto)

Note

Copilot is running an experiment and ran this review at Lite.


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

Comment thread seed/scripts/sync-skills.sh Outdated
Comment thread seed/scripts/sync-skills.sh Outdated
Comment thread seed/scripts/sync-skills.sh Outdated
Comment thread seed/scripts/sync-skills.sh Outdated
A fresh clone whose push loses a race to a sibling's release is rejected
as "fetch first", not "non-fast-forward", so the retry inherited from
hey-cli's script never ran; match both, and give the temp gitconfig the
bot identity so the rebase in the retry has a committer. The test now
races a sibling against a local bare origin and asserts the retry lands.

A SKILLS_TARGET checkout with uncommitted changes is refused, since
`git add -A` would sweep them into the sync commit. The remote-URL assert
reads the configured URL rather than the insteadOf-rewritten one, which
is also what lets the test route pushes to the bare repo.

The header names the one rollout case the tombstone leaves behind: a
skill a still-pre-fix sibling drops stays in the target until removed
by hand.

@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

Here are some automated review suggestions for this pull request.

Reviewed commit: fcd18c7857

ℹ️ 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 seed/scripts/sync-skills.sh Outdated
Comment thread seed/scripts/sync-skills.sh
Rebasing the already-made commit replayed decisions taken against a
stale tree: a sibling that claimed a name in the meantime would have had
it deleted or double-claimed. Now the rejected commit is dropped, the
remote's tip fetched, and the sync applied afresh — collision guard
included — before the one retry. The race test asserts that a sibling
claiming a name this source ships makes the retry refuse.

The remote assert covers a separate push URL, the collision lookup reads
each manifest into a variable rather than through a pipe grep -q can
close early under pipefail, and the composite action keeps its old
no-skills no-op in front of the now strict script.
jeremy added a commit to basecamp/fizzy-cli that referenced this pull request Sep 13, 2026
The seed (basecamp/cli#78) went further than the hey-cli copy taken
first: a rejected push re-applies the sync from the remote's new tip
(matching git's "fetch first" as well as "non-fast-forward"), a name
another source's manifest holds is refused before anything is copied,
SYNC_SOURCE and DRY_RUN are validated, a dirty checkout is refused, and
the token reaches git through a private config rather than the remote
URL. basecamp-cli and hey-cli are converging on the same file, so every
publisher runs one script; only the CLI_NAME default differs here.

The seed's test-sync-skills.sh replaces the bash port of the BATS file:
it covers the same ownership cases plus the racing publisher, the
publish-side refusal, DRY_RUN=remote and the dirty-tree guard.
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.
jeremy added a commit to basecamp/basecamp-cli that referenced this pull request Sep 13, 2026
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.

@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

Here are some automated review suggestions for this pull request.

Reviewed commit: 17c383af63

ℹ️ 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 seed/scripts/sync-skills.sh Outdated
Comment thread seed/scripts/sync-skills.sh Outdated
Comment thread seed/scripts/sync-skills.sh Outdated
…ards

SKILLS_TARGET let the script adopt an existing checkout of basecamp/skills.
It existed so the test could run the sync against a prepared history, and it
was offered to operators as a convenience, but every release path clones its
own target. Each review round then found another corner of "any checkout":
a dirty tree, a fork as the remote, a fork-style push URL, several URLs, an
insteadOf rewrite, unpublished local commits, a linked worktree. Six guards
for a seam production never uses is a sign the seam is the problem.

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. The
only commit it can push is the one it made, against the tip it cloned or
fetched, so the remote-URL, branch and clean-tree asserts have nothing left
to check and are gone with the knob. The retry after a rejected push, the
per-source manifests, the collision guard, the tombstone and DRY_RUN
validation are unchanged. DRY_RUN=local is now only the offline preview;
DRY_RUN=remote still clones and stops before committing.

The test points SKILLS_REPO_URL at a local bare repository and reads every
result back from a clone of its own, so each case is a real clone, commit
and push. The race is staged with a post-commit hook reached through the
GIT_CONFIG_* environment, which pushes the sibling's commit between the
script's clone and its push; a script that does not retry "fetch first",
or that replays the stale commit instead of applying the sync again from
the fetched tip, fails those cases. A case also proves the default target
is refused without a token before anything is cloned.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants