From f08ae6bd5299d41640e3933206117c0e50083d4b Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 12 Sep 2026 17:09:21 -0700 Subject: [PATCH 1/8] Seed the skills sync with per-source manifests and the skills/ layout 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 // at the target root, a layout never adopted, and tracked ownership in one shared .managed-skills. The real CLIs' scripts write skills// 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// layout, a manifest per publishing source (.managed-skills.), 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. --- .github/workflows/release.yml | 1 - .github/workflows/sensitive-change-gate.yml | 1 + .github/workflows/test.yml | 3 + AGENTS.md | 16 + Makefile | 11 +- README.md | 10 +- actions/sync-skills/action.yml | 123 +------ prompts/seed-cli.md | 3 +- scripts/sync-skills.sh | 159 +------- seed/.github/workflows/release.yml | 2 +- seed/.github/workflows/test.yml | 3 + seed/Makefile | 10 +- seed/scripts/sync-skills.sh | 388 ++++++++++++++------ seed/scripts/test-sync-skills.sh | 271 ++++++++++++++ 14 files changed, 627 insertions(+), 374 deletions(-) create mode 100755 seed/scripts/test-sync-skills.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 802bee8..1d9f4c4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -170,7 +170,6 @@ jobs: - name: Sync skills if: steps.check.outputs.ready == 'true' env: - CLI_NAME: cli SKILLS_TOKEN: ${{ steps.skills-token.outputs.token }} RELEASE_TAG: ${{ github.ref_name }} SOURCE_SHA: ${{ github.sha }} diff --git a/.github/workflows/sensitive-change-gate.yml b/.github/workflows/sensitive-change-gate.yml index 0af3330..2e8a075 100644 --- a/.github/workflows/sensitive-change-gate.yml +++ b/.github/workflows/sensitive-change-gate.yml @@ -12,6 +12,7 @@ jobs: with: extra-patterns: | scripts/sync-skills.sh + seed/scripts/sync-skills.sh permissions: contents: read pull-requests: write diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8f1e1e6..704135f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -38,6 +38,9 @@ jobs: - name: Test run: go test -v ./... + - name: Test the skills sync + run: seed/scripts/test-sync-skills.sh + lint: name: Lint runs-on: ubuntu-latest diff --git a/AGENTS.md b/AGENTS.md index ab31a8c..67a092b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,6 +53,22 @@ When authoring new seed templates: - Keep generated code minimal — point to shared packages where possible - Test by running the `prompts/seed-cli.md` prompt end-to-end +## Skills sync + +Every CLI publishes its `skills//` trees into `skills//` at the root of +`basecamp/skills` (the layout `npx skills add basecamp/skills` reads). The one +implementation is `seed/scripts/sync-skills.sh`; `scripts/sync-skills.sh` here execs it +with `SYNC_SOURCE=cli`, and `actions/sync-skills` runs it from the action's checkout. +Change the seed script, never a copy. + +Several CLIs share that target, so each one owns `.managed-skills.` there +(`` is the publishing repo: `hey-cli`, `basecamp-cli`, `cli`) and removes only +skill directories its own manifest lists, that its skill set no longer has, and that no +other manifest claims. The legacy shared `.managed-skills` is rewritten as a comment-only +tombstone so a sibling still on the pre-fix script deletes nothing (basecamp/skills#5). +`seed/scripts/test-sync-skills.sh` runs the script as two CLIs against a throwaway +target and is part of `make check`. + ## Rubric [RUBRIC.md](RUBRIC.md) defines the quality standard for 37signals Go CLIs. Two profiles: diff --git a/Makefile b/Makefile index 008dcad..1c56bee 100644 --- a/Makefile +++ b/Makefile @@ -1,14 +1,19 @@ .DEFAULT_GOAL := check -.PHONY: check test test-race vet lint fmt fmt-check bench check-all \ +.PHONY: check test test-sync-skills test-race vet lint fmt fmt-check bench check-all \ tidy tidy-check replace-check vuln secrets security release-check release # Default target: fast checks for inner-loop dev. -check: fmt-check vet test +check: fmt-check vet test test-sync-skills test: go test ./... +# The skills sync (seed/scripts/sync-skills.sh, which scripts/sync-skills.sh runs) +# against a throwaway basecamp/skills, as two CLIs publishing in turn +test-sync-skills: + seed/scripts/test-sync-skills.sh + test-race: go test -race ./... @@ -79,7 +84,7 @@ lint-actions: zizmor . # Full suite: everything CI runs. -check-all: fmt-check vet lint lint-actions test-race bench tidy-check +check-all: fmt-check vet lint lint-actions test-race test-sync-skills bench tidy-check # Full pre-flight for release release-check: check-all replace-check vuln secrets diff --git a/README.md b/README.md index 558495a..92c1431 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ Reusable composite actions in `actions/`: |--------|-------------| | `rubric-check` | Score a built CLI binary against the 37signals CLI rubric | | `surface-compat` | Fail CI if CLI flags or subcommands were removed (breaking change) | -| `sync-skills` | Sync embedded SKILL.md files to the `basecamp/skills` distribution repo on release | +| `sync-skills` | Publish embedded skills to the `basecamp/skills` distribution repo on release (runs `seed/scripts/sync-skills.sh`) | Usage in a workflow: @@ -86,12 +86,18 @@ The `skills/` directory contains agent skills distributed via `basecamp/skills`: - `rubric-audit` — Audit a Go CLI against the rubric +On release, `scripts/sync-skills.sh` publishes each one to `skills//` in +`basecamp/skills`, where every 37signals CLI publishes its own. Each publisher owns a +manifest there, `.managed-skills.`, and only ever removes skills it listed — +the scheme, and the seed script every CLI runs, are described in +`seed/scripts/sync-skills.sh`. + ## Development Requires Go 1.24+. ``` -make check # fmt-check + vet + test (inner-loop dev) +make check # fmt-check + vet + test + test-sync-skills (inner-loop dev) make test # go test ./... make test-race # go test -race ./... make lint # golangci-lint run diff --git a/actions/sync-skills/action.yml b/actions/sync-skills/action.yml index b8de04c..825245a 100644 --- a/actions/sync-skills/action.yml +++ b/actions/sync-skills/action.yml @@ -1,5 +1,9 @@ name: Sync Skills -description: Sync embedded SKILL.md files to basecamp/skills distribution repo +description: Publish embedded skills to the basecamp/skills distribution repo + +# Runs seed/scripts/sync-skills.sh from this action's own checkout, so the sync +# logic lives in one place. The script's header documents the manifest scheme and +# every env var; the inputs here map onto those one to one. inputs: skills-token: @@ -12,10 +16,18 @@ inputs: description: The source commit SHA required: true cli-name: - description: The CLI name (used as directory prefix in skills repo) + description: The CLI name; the publishing source is -cli required: true + source: + description: Override the publishing source name (default -cli) + required: false + default: "" + skills-source: + description: Directory holding the skills tree + required: false + default: skills dry-run: - description: '"local" to skip push, "remote" to skip commit+push, empty for real run' + description: '"local" to skip the push, "remote" to skip commit and push, empty for a real run' required: false default: "" @@ -29,105 +41,8 @@ runs: RELEASE_TAG: ${{ inputs.release-tag }} SOURCE_SHA: ${{ inputs.source-sha }} CLI_NAME: ${{ inputs.cli-name }} + SYNC_SOURCE: ${{ inputs.source }} + SKILLS_SOURCE: ${{ inputs.skills-source }} DRY_RUN: ${{ inputs.dry-run }} - run: | - set -euo pipefail - SKILLS_REPO="basecamp/skills" - SKILLS_DIR="skills" - MANAGED_MANIFEST=".managed-skills" - - # Clone the skills repo - WORK_DIR=$(mktemp -d) - trap 'rm -rf "$WORK_DIR"' EXIT - - echo "::group::Clone skills repo" - if ! git clone "https://x-access-token:${SKILLS_TOKEN}@github.com/${SKILLS_REPO}.git" "$WORK_DIR/skills-repo" 2>&1 | grep -v 'x-access-token'; then - echo "::error::Failed to clone ${SKILLS_REPO}" - exit 1 - fi - echo "::endgroup::" - - TARGET_DIR="${WORK_DIR}/skills-repo" - - # Collect skill directories - SKILL_DIRS=() - for skill_dir in ${SKILLS_DIR}/*/; do - if [[ -f "${skill_dir}/SKILL.md" ]]; then - SKILL_DIRS+=("$skill_dir") - fi - done - - if [[ ${#SKILL_DIRS[@]} -eq 0 ]]; then - echo "No skills found in ${SKILLS_DIR}/" - exit 0 - fi - - echo "Found ${#SKILL_DIRS[@]} skill(s) to sync" - - # Copy skills - MANAGED_SKILLS=() - for skill_dir in "${SKILL_DIRS[@]}"; do - skill_name=$(basename "$skill_dir") - dest="${TARGET_DIR}/${CLI_NAME}/${skill_name}" - echo " Syncing ${skill_name}..." - mkdir -p "$dest" - # Preserve subdirectory structure; exclude Go sources and dotfiles - (cd "$skill_dir" && find . -type f ! -name '*.go' ! -name '.*' | while read -r f; do - mkdir -p "$dest/$(dirname "$f")" - cp "$f" "$dest/$f" - done) - MANAGED_SKILLS+=("${CLI_NAME}/${skill_name}") - done - - # Update manifest - MANIFEST_PATH="${TARGET_DIR}/${MANAGED_MANIFEST}" - if [[ -f "$MANIFEST_PATH" ]]; then - grep -v "^${CLI_NAME}/" "$MANIFEST_PATH" > "${MANIFEST_PATH}.tmp" || true - mv "${MANIFEST_PATH}.tmp" "$MANIFEST_PATH" - fi - for skill in "${MANAGED_SKILLS[@]}"; do - echo "$skill" >> "$MANIFEST_PATH" - done - sort -u -o "$MANIFEST_PATH" "$MANIFEST_PATH" - - # Remove stale skills - if [[ -d "${TARGET_DIR}/${CLI_NAME}" ]]; then - for existing in "${TARGET_DIR}/${CLI_NAME}"/*/; do - existing_name=$(basename "$existing") - found=false - for skill_dir in "${SKILL_DIRS[@]}"; do - [[ "$(basename "$skill_dir")" == "$existing_name" ]] && found=true && break - done - if [[ "$found" == "false" ]]; then - echo " Removing stale: ${existing_name}" - rm -rf "$existing" - fi - done - fi - - [[ "$DRY_RUN" == "remote" ]] && echo "DRY_RUN=remote: done" && exit 0 - - # Commit - cd "$TARGET_DIR" - git add -A - if git diff --cached --quiet; then - echo "No changes to commit" - exit 0 - fi - - git config user.name "${CLI_NAME}-cli[bot]" - git config user.email "${CLI_NAME}-cli[bot]@users.noreply.github.com" - git commit -m "Sync ${CLI_NAME} skills from ${RELEASE_TAG} - - Source: ${SOURCE_SHA}" - - [[ "$DRY_RUN" == "local" ]] && echo "DRY_RUN=local: done" && exit 0 - - # Push - echo "::group::Push to ${SKILLS_REPO}" - if ! git push origin main; then - git pull --rebase origin main - git push origin main - fi - echo "::endgroup::" - echo "Skills synced successfully" + ACTION_PATH: ${{ github.action_path }} + run: bash "${ACTION_PATH}/../../seed/scripts/sync-skills.sh" diff --git a/prompts/seed-cli.md b/prompts/seed-cli.md index 487efff..9ff3244 100644 --- a/prompts/seed-cli.md +++ b/prompts/seed-cli.md @@ -96,7 +96,8 @@ You are creating a new Go CLI for a 37signals product using the seed templates. - `seed/scripts/check-cli-surface-diff.sh` → `scripts/check-cli-surface-diff.sh` (copy; chmod +x) - `seed/scripts/collect-profile.sh` → `scripts/collect-profile.sh` (copy; chmod +x) - `seed/scripts/publish-aur.sh` → `scripts/publish-aur.sh` (copy; chmod +x) - - `seed/scripts/sync-skills.sh` → `scripts/sync-skills.sh` (copy; chmod +x) + - `seed/scripts/sync-skills.sh` → `scripts/sync-skills.sh` (copy; chmod +x; set `CLI_NAME`) + - `seed/scripts/test-sync-skills.sh` → `scripts/test-sync-skills.sh` (copy; chmod +x) **GitHub infra (copy as-is unless .tmpl):** - `seed/.github/workflows/test.yml` → `.github/workflows/test.yml` (update env vars, GOPRIVATE) diff --git a/scripts/sync-skills.sh b/scripts/sync-skills.sh index 5c383e6..fb7c007 100755 --- a/scripts/sync-skills.sh +++ b/scripts/sync-skills.sh @@ -1,157 +1,12 @@ #!/usr/bin/env bash -# sync-skills.sh — Sync embedded skills to basecamp/skills distribution repo. +# sync-skills.sh — Publish this repo's skills (skills/rubric-audit) to basecamp/skills. # -# Run from CI on release (tag push). Copies skills/*/SKILL.md to the -# basecamp/skills repo, commits, and pushes. -# -# Required env vars: -# SKILLS_TOKEN — GitHub token with push access to basecamp/skills -# RELEASE_TAG — The release tag (e.g., v1.2.3) -# SOURCE_SHA — The source commit SHA -# -# Optional env vars: -# DRY_RUN — "local" to skip push, "remote" to skip commit+push +# The implementation is the seed's, seed/scripts/sync-skills.sh, run as it is: one +# script, exercised here before any CLI inherits it. Only the identity differs — +# this repo is basecamp/cli, so the publishing source is `cli`, not `-cli`. +# Knobs and env vars are documented in the seed script's header. set -euo pipefail -CLI_NAME="${CLI_NAME:-cli}" -SKILLS_REPO="basecamp/skills" -SKILLS_DIR="skills" -MANAGED_MANIFEST=".managed-skills" - -: "${RELEASE_TAG:?RELEASE_TAG is required}" -: "${SOURCE_SHA:?SOURCE_SHA is required}" - -DRY_RUN="${DRY_RUN:-}" -SKILLS_TOKEN="${SKILLS_TOKEN:-}" - -# SKILLS_TOKEN is required unless running a local dry-run -if [ -z "$SKILLS_TOKEN" ] && [ "$DRY_RUN" != "local" ]; then - echo "Error: SKILLS_TOKEN is required (set DRY_RUN=local to skip clone)" >&2 - exit 1 -fi - -# Clone the skills repo (skipped for local dry-run) -WORK_DIR=$(mktemp -d) -trap 'rm -rf "$WORK_DIR"' EXIT - -if [ "$DRY_RUN" = "local" ] && [ -z "$SKILLS_TOKEN" ]; then - echo "Local dry-run: creating stub target directory..." - mkdir -p "$WORK_DIR/skills-repo" - (cd "$WORK_DIR/skills-repo" && git init -q) -else - echo "Cloning ${SKILLS_REPO}..." - # Use a temp gitconfig so the token never appears in process args - TEMP_GITCONFIG="${WORK_DIR}/.gitconfig" - cat > "$TEMP_GITCONFIG" < "${MANIFEST_PATH}.tmp" || true - mv "${MANIFEST_PATH}.tmp" "$MANIFEST_PATH" -fi - -# Append current skills -for skill in "${MANAGED_SKILLS[@]}"; do - echo "$skill" >> "$MANIFEST_PATH" -done -sort -u -o "$MANIFEST_PATH" "$MANIFEST_PATH" - -# Check for stale skills to remove -if [[ -d "${TARGET_DIR}/${CLI_NAME}" ]]; then - for existing in "${TARGET_DIR}/${CLI_NAME}"/*/; do - existing_name=$(basename "$existing") - found=false - for skill_dir in "${SKILL_DIRS[@]}"; do - if [[ "$(basename "$skill_dir")" == "$existing_name" ]]; then - found=true - break - fi - done - if [[ "$found" == "false" ]]; then - echo " Removing stale skill: ${existing_name}" - rm -rf "$existing" - fi - done -fi - -if [[ "$DRY_RUN" == "remote" ]]; then - echo "DRY_RUN=remote: skipping commit and push" - exit 0 -fi - -# Commit and push -cd "$TARGET_DIR" -git add -A - -if git diff --cached --quiet; then - echo "No changes to commit" - exit 0 -fi - -git config user.name "${CLI_NAME}-cli[bot]" -git config user.email "${CLI_NAME}-cli[bot]@users.noreply.github.com" - -git commit -m "$(cat </ tree (SKILL.md and its +# supporting files; no *.go, no dotfiles) into skills// at the root of +# basecamp/skills — the layout `npx skills add basecamp/skills` reads — then commits +# as [bot] and pushes. +# +# Several CLIs publish into that one repo, so each owns a manifest of its own at the +# target root, .managed-skills., listing the skill names it has published, +# one per line. A skills/ directory is removed only when all of these hold: +# this source's manifest lists it, this source's current skill set no longer has +# it, and no other source's manifest claims it. A name two sources claim is a +# collision to settle upstream, never one a release resolves by deletion — the +# script warns and leaves the directory, and refuses outright to publish a name +# another source's manifest holds. Nothing else in the target is ever deleted: with +# no manifest yet, the first run publishes and removes nothing. +# +# The shared manifest the pre-fix scripts kept, .managed-skills, is rewritten on +# every run as a comment-only tombstone. The pre-fix script skips any line it cannot +# parse as a skill name, but treats a missing file as licence to own every skills/* +# directory — so the tombstone is what stops an un-upgraded sibling from deleting +# anyone's skills, whichever CLI upgrades first (basecamp/skills#5). # # Required env vars: -# SKILLS_TOKEN — GitHub token with push access to basecamp/skills -# RELEASE_TAG — The release tag (e.g., v1.2.3) -# SOURCE_SHA — The source commit SHA +# RELEASE_TAG — the release tag (e.g. v1.2.3) +# SOURCE_SHA — the source commit SHA +# SKILLS_TOKEN — GitHub token with push access to basecamp/skills; not needed +# for DRY_RUN=local, nor when SKILLS_TARGET is set # # Optional env vars: -# DRY_RUN — "local" to skip push, "remote" to skip commit+push +# CLI_NAME — this CLI's name; the publishing source is -cli +# SYNC_SOURCE — the publishing repo's name (default: -cli). Names the +# manifest, the bot and the commit; the test sets it to play +# another CLI +# SKILLS_SOURCE — directory holding the skills tree (default: skills). A manual +# recovery workflow can point it at a checkout of the release tag +# so the sync logic comes from a newer ref than the content +# SKILLS_TARGET — an existing checkout of basecamp/skills to sync into instead of +# cloning; the remote-URL and branch asserts still run against it +# DRY_RUN — "local": no network. Without SKILLS_TARGET, copy into an empty +# tmpdir and print what would be published; with it, apply and +# commit there but do not push. +# "remote": clone (or use SKILLS_TARGET), apply, print the diff, +# and stop before committing # # TODO: Replace CLI_NAME with your CLI name. set -euo pipefail CLI_NAME="${CLI_NAME:-mycli}" -SKILLS_REPO="basecamp/skills" -SKILLS_DIR="skills" -MANAGED_MANIFEST=".managed-skills" +SYNC_SOURCE="${SYNC_SOURCE:-${CLI_NAME}-cli}" +RELEASE_TAG="${RELEASE_TAG:?RELEASE_TAG is required}" +SOURCE_SHA="${SOURCE_SHA:?SOURCE_SHA is required}" +SKILLS_SOURCE="${SKILLS_SOURCE:-skills}" +SKILLS_TARGET="${SKILLS_TARGET:-}" +SKILLS_TOKEN="${SKILLS_TOKEN:-}" +DRY_RUN="${DRY_RUN:-}" -: "${RELEASE_TAG:?RELEASE_TAG is required}" -: "${SOURCE_SHA:?SOURCE_SHA is required}" +TARGET_REPO="basecamp/skills" +TARGET_BRANCH="main" +SKILLS_SUBDIR="skills" +LEGACY_MANIFEST=".managed-skills" +MANIFEST="${LEGACY_MANIFEST}.${SYNC_SOURCE}" +# The commit's provenance line; GITHUB_REPOSITORY is exact in CI, the default holds +# for the basecamp org's naming. +SOURCE_REPO="${GITHUB_REPOSITORY:-basecamp/${SYNC_SOURCE}}" -DRY_RUN="${DRY_RUN:-}" -SKILLS_TOKEN="${SKILLS_TOKEN:-}" +# --- Helpers --- -# SKILLS_TOKEN is required unless running a local dry-run -if [ -z "$SKILLS_TOKEN" ] && [ "$DRY_RUN" != "local" ]; then - echo "Error: SKILLS_TOKEN is required (set DRY_RUN=local to skip clone)" >&2 - exit 1 -fi +die() { echo "ERROR: $*" >&2; exit 1; } +warn() { echo "WARNING: $*" >&2; } -# Clone the skills repo (skipped for local dry-run) -WORK_DIR=$(mktemp -d) -trap 'rm -rf "$WORK_DIR"' EXIT +# A skill directory name or a source name: nothing a path could smuggle in. +plain_name() { + [[ "$1" != "." && "$1" != ".." && "$1" =~ ^[a-zA-Z0-9._-]+$ ]] +} -if [ "$DRY_RUN" = "local" ] && [ -z "$SKILLS_TOKEN" ]; then - echo "Local dry-run: creating stub target directory..." - mkdir -p "$WORK_DIR/skills-repo" - (cd "$WORK_DIR/skills-repo" && git init -q) -else - echo "Cloning ${SKILLS_REPO}..." - # Use a temp gitconfig so the token never appears in process args - TEMP_GITCONFIG="${WORK_DIR}/.gitconfig" - cat > "$TEMP_GITCONFIG" < "$GIT_CONFIG_GLOBAL" +chmod 600 "$GIT_CONFIG_GLOBAL" +if [[ -n "$SKILLS_TOKEN" ]]; then + cat >> "$GIT_CONFIG_GLOBAL" < "${MANIFEST_PATH}.tmp" || true - mv "${MANIFEST_PATH}.tmp" "$MANIFEST_PATH" -fi +# --- Write this source's manifest and the legacy tombstone --- -# Append current skills -for skill in "${MANAGED_SKILLS[@]}"; do - echo "$skill" >> "$MANIFEST_PATH" -done -sort -u -o "$MANIFEST_PATH" "$MANIFEST_PATH" - -# Check for stale skills to remove -if [[ -d "${TARGET_DIR}/${CLI_NAME}" ]]; then - for existing in "${TARGET_DIR}/${CLI_NAME}"/*/; do - existing_name=$(basename "$existing") - found=false - for skill_dir in "${SKILL_DIRS[@]}"; do - if [[ "$(basename "$skill_dir")" == "$existing_name" ]]; then - found=true - break - fi - done - if [[ "$found" == "false" ]]; then - echo " Removing stale skill: ${existing_name}" - rm -rf "$existing" - fi - done -fi +printf '%s\n' "${skill_names[@]}" | LC_ALL=C sort > "${target}/${MANIFEST}" -if [[ "$DRY_RUN" == "remote" ]]; then - echo "DRY_RUN=remote: skipping commit and push" +cat > "${target}/${LEGACY_MANIFEST}" <<'TOMBSTONE' +# Superseded by the per-source manifests (.managed-skills.), 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. +TOMBSTONE + +# --- Commit --- + +git -C "$target" add -A + +if git -C "$target" diff --cached --quiet; then + echo "No changes to commit. Skills are already up to date." exit 0 fi -# Commit and push -cd "$TARGET_DIR" -git add -A +echo "" +echo "=== Changes ===" +git -C "$target" diff --cached --stat +echo "" -if git diff --cached --quiet; then - echo "No changes to commit" +if [[ "$DRY_RUN" == "remote" ]]; then + echo "DRY_RUN=remote: skipping commit and push." + echo "" + echo "=== Full diff ===" + git -C "$target" diff --cached exit 0 fi -git config user.name "${CLI_NAME}-cli[bot]" -git config user.email "${CLI_NAME}-cli[bot]@users.noreply.github.com" +git -C "$target" \ + -c user.name="${SYNC_SOURCE}[bot]" \ + -c user.email="${SYNC_SOURCE}[bot]@users.noreply.github.com" \ + commit -q -m "$(cat <&1 +} + +if ! output=$(push_target); then + if echo "$output" | grep -qi "non-fast-forward"; then + echo "Push rejected (non-fast-forward). Pulling with rebase and retrying..." + git -C "$target" pull --rebase origin "$TARGET_BRANCH" + if ! retry_output=$(push_target); then + echo "$retry_output" >&2 + die "Push failed after retry" + fi + else + echo "$output" >&2 + die "Push failed" + fi fi -echo "Skills synced successfully" +echo "" +echo "Skills synced to ${TARGET_REPO} (${TARGET_BRANCH}) from ${SYNC_SOURCE} ${RELEASE_TAG}" diff --git a/seed/scripts/test-sync-skills.sh b/seed/scripts/test-sync-skills.sh new file mode 100755 index 0000000..f672947 --- /dev/null +++ b/seed/scripts/test-sync-skills.sh @@ -0,0 +1,271 @@ +#!/usr/bin/env bash +# test-sync-skills.sh — Run sync-skills.sh as two CLIs against one throwaway +# basecamp/skills checkout and prove neither deletes the other's skills. +# +# The target starts in the state basecamp/skills#5 left it: basecamp-cli's skills +# and the shared .managed-skills listing them. Then hey-cli and basecamp-cli sync +# in turn, one loses a skill, a pre-fix sibling rewrites the legacy manifest, and +# two manifests claim one name — after each step both sources' skills must be +# where they belong. Everything runs with DRY_RUN=local and SKILLS_TARGET, so no +# network and no token. +# +# Usage: scripts/test-sync-skills.sh (tests scripts/sync-skills.sh) +# SYNC_SCRIPT=path/to/sync-skills.sh scripts/test-sync-skills.sh + +set -euo pipefail + +here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +SYNC_SCRIPT="${SYNC_SCRIPT:-${here}/sync-skills.sh}" +[[ -x "$SYNC_SCRIPT" ]] || { echo "ERROR: ${SYNC_SCRIPT} is not an executable script" >&2; exit 1; } + +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT + +# The test's own git calls see only this config; the script brings its own. +export GIT_CONFIG_GLOBAL="${work}/gitconfig" GIT_CONFIG_NOSYSTEM=1 +printf '[user]\n\tname = test\n\temail = test@example.com\n' > "$GIT_CONFIG_GLOBAL" + +target="${work}/target" +out="${work}/out" +failures=0 + +# --- Assertions --- + +ok() { echo "ok - $*"; } +not_ok() { echo "not ok - $*"; failures=$((failures + 1)); } + +assert() { # description, command... + local desc="$1" + shift + if "$@"; then ok "$desc"; else not_ok "$desc"; fi +} + +assert_skill() { assert "skills/$1 present" test -f "${target}/skills/$1/SKILL.md"; } +assert_no_skill() { assert "skills/$1 absent" test ! -e "${target}/skills/$1"; } +assert_no_path() { assert "$1 not published" test ! -e "${target}/skills/$1"; } +assert_content() { # path, expected content + assert "$1 holds '$2'" test "$(cat "${target}/$1")" = "$2" +} +assert_manifest() { # source, names... + local source="$1" want + shift + want=$(printf '%s\n' "$@") + assert ".managed-skills.${source} lists exactly: $*" test "$(cat "${target}/.managed-skills.${source}")" = "$want" +} +assert_no_manifest() { assert ".managed-skills.$1 absent" test ! -e "${target}/.managed-skills.$1"; } +assert_tombstone() { + if [[ -f "${target}/.managed-skills" ]] && ! grep -qv '^#' "${target}/.managed-skills" && grep -q 'Superseded' "${target}/.managed-skills"; then + ok ".managed-skills is the comment-only tombstone" + else + not_ok ".managed-skills is the comment-only tombstone" + fi +} +assert_author() { assert "last commit authored by $1[bot]" test "$(git -C "$target" log -1 --format=%an)" = "$1[bot]"; } +assert_clean() { assert "target working tree committed clean" test -z "$(git -C "$target" status --porcelain)"; } +assert_output() { assert "output says: $1" grep -q -- "$1" "$out"; } +assert_head() { # expected sha, description + assert "$2" test "$(git -C "$target" rev-parse HEAD)" = "$1" +} + +# --- Running the script --- + +sync() { # source, fixture, [VAR=value...] + local source="$1" fixture="$2" + shift 2 + if env "$@" SYNC_SOURCE="$source" SKILLS_SOURCE="${fixture}/skills" \ + RELEASE_TAG=v9.9.9 SOURCE_SHA=0123abcd "$SYNC_SCRIPT" > "$out" 2>&1; then + ok "sync as ${source} succeeded" + else + not_ok "sync as ${source} succeeded" + sed 's/^/ /' "$out" + fi +} + +sync_local() { sync "$1" "$2" DRY_RUN=local SKILLS_TARGET="$target"; } + +sync_expecting_failure() { # source, fixture, [VAR=value...] + local source="$1" fixture="$2" + shift 2 + if env "$@" SYNC_SOURCE="$source" SKILLS_SOURCE="${fixture}/skills" \ + RELEASE_TAG=v9.9.9 SOURCE_SHA=0123abcd "$SYNC_SCRIPT" > "$out" 2>&1; then + not_ok "sync as ${source} refused" + sed 's/^/ /' "$out" + else + ok "sync as ${source} refused" + fi +} + +# --- Fixtures --- + +write_skill() { # dir, content + mkdir -p "$1" + echo "$2" > "$1/SKILL.md" +} + +a="${work}/hey-cli" +b="${work}/basecamp-cli" + +write_skill "${a}/skills/hey" "hey v2" +mkdir -p "${a}/skills/hey/reference" "${a}/skills/hey/.cache" +echo "nested" > "${a}/skills/hey/reference/commands.md" +echo "package hey" > "${a}/skills/hey/embed.go" +echo "secret" > "${a}/skills/hey/.env" +echo "cached" > "${a}/skills/hey/.cache/index" +write_skill "${a}/skills/hey-doctor" "hey-doctor v2" + +write_skill "${b}/skills/basecamp" "basecamp v2" +write_skill "${b}/skills/basecamp-doctor" "basecamp-doctor v2" + +# The target as basecamp/skills#5 left it: only basecamp-cli's skills survive, +# and the shared manifest names them. +git init -q -b main "$target" +git -C "$target" remote add origin https://github.com/basecamp/skills.git +write_skill "${target}/skills/basecamp" "basecamp v1" +write_skill "${target}/skills/basecamp-doctor" "basecamp-doctor v1" +printf 'basecamp\nbasecamp-doctor\n' > "${target}/.managed-skills" +echo "# skills" > "${target}/README.md" +git -C "$target" add -A +git -C "$target" commit -q -m "State after basecamp/skills#5" + +# --- Interleaved syncs: A, B, A, B --- + +echo "# hey-cli syncs first: restores its skills, touches nothing else" +sync_local hey-cli "$a" +assert_skill hey +assert_skill hey-doctor +assert_skill basecamp +assert_skill basecamp-doctor +assert_content skills/basecamp/SKILL.md "basecamp v1" +assert_content skills/hey/reference/commands.md "nested" +assert_no_path hey/embed.go +assert_no_path hey/.env +assert_no_path hey/.cache +assert_manifest hey-cli hey hey-doctor +assert_no_manifest basecamp-cli +assert_tombstone +assert_author hey-cli +assert_clean +assert_output "first run for hey-cli, removing nothing" + +echo "# basecamp-cli syncs: refreshes its skills, leaves hey-cli's" +sync_local basecamp-cli "$b" +assert_skill hey +assert_skill hey-doctor +assert_skill basecamp +assert_skill basecamp-doctor +assert_content skills/basecamp/SKILL.md "basecamp v2" +assert_manifest hey-cli hey hey-doctor +assert_manifest basecamp-cli basecamp basecamp-doctor +assert_tombstone +assert_author basecamp-cli +assert_clean + +echo "# both sync again with nothing new: no commits, nothing lost" +head_before=$(git -C "$target" rev-parse HEAD) +sync_local hey-cli "$a" +assert_output "No changes to commit" +sync_local basecamp-cli "$b" +assert_output "No changes to commit" +assert_head "$head_before" "HEAD unchanged by the no-op syncs" +assert_skill hey +assert_skill hey-doctor +assert_skill basecamp +assert_skill basecamp-doctor +assert_manifest hey-cli hey hey-doctor +assert_manifest basecamp-cli basecamp basecamp-doctor +assert_tombstone + +# --- hey-cli drops a skill: only that directory goes --- + +echo "# hey-cli drops hey-doctor" +rm -rf "${a}/skills/hey-doctor" +sync_local hey-cli "$a" +assert_output "Removing stale skill: hey-doctor" +assert_no_skill hey-doctor +assert_skill hey +assert_skill basecamp +assert_skill basecamp-doctor +assert_manifest hey-cli hey +assert_manifest basecamp-cli basecamp basecamp-doctor +assert_author hey-cli + +# --- A pre-fix sibling rewrote the legacy manifest: still nothing of B's goes --- + +echo "# a pre-fix basecamp-cli rewrites .managed-skills with its own names" +printf 'basecamp\nbasecamp-doctor\n' > "${target}/.managed-skills" +git -C "$target" commit -q -am "Sync skills from basecamp-cli v0.0.0 (pre-fix script)" +sync_local hey-cli "$a" +assert_skill basecamp +assert_skill basecamp-doctor +assert_skill hey +assert_tombstone +assert_manifest basecamp-cli basecamp basecamp-doctor + +# --- Two manifests claim one name: removal is refused with a warning --- + +echo "# hey-cli's manifest also lists basecamp, which basecamp-cli owns" +printf 'basecamp\nhey\n' > "${target}/.managed-skills.hey-cli" +git -C "$target" commit -q -am "Collision: hey-cli claims basecamp" +sync_local hey-cli "$a" +assert_skill basecamp +assert_content skills/basecamp/SKILL.md "basecamp v2" +assert_output "WARNING: skills/basecamp is no longer in hey-cli's skills but basecamp-cli lists it" +assert_manifest hey-cli hey +assert_manifest basecamp-cli basecamp basecamp-doctor + +# --- Publishing a name another source owns is refused before anything changes --- + +echo "# hey-cli ships a skill named basecamp" +write_skill "${a}/skills/basecamp" "hey-cli's basecamp" +head_before=$(git -C "$target" rev-parse HEAD) +sync_expecting_failure hey-cli "$a" DRY_RUN=local SKILLS_TARGET="$target" +assert_output "ERROR: skills/basecamp is published by basecamp-cli" +assert_content skills/basecamp/SKILL.md "basecamp v2" +assert_head "$head_before" "HEAD unchanged by the refused sync" +assert_clean +rm -rf "${a}/skills/basecamp" + +# --- DRY_RUN=remote applies and shows the diff but commits nothing --- + +echo "# DRY_RUN=remote against the checkout" +echo "hey v3" > "${a}/skills/hey/SKILL.md" +head_before=$(git -C "$target" rev-parse HEAD) +sync hey-cli "$a" DRY_RUN=remote SKILLS_TARGET="$target" +assert_output "DRY_RUN=remote: skipping commit and push" +assert_output "+hey v3" +assert_head "$head_before" "HEAD unchanged by DRY_RUN=remote" +git -C "$target" reset -q --hard + +# --- DRY_RUN=local with no target: no network, lists what would be published --- + +echo "# DRY_RUN=local without SKILLS_TARGET" +sync hey-cli "$a" DRY_RUN=local +assert_output "skills/hey/SKILL.md" +assert_output "skills/hey/reference/commands.md" +assert_output "No network operations performed" +if grep -q "embed.go" "$out"; then not_ok "preview leaves out embed.go"; else ok "preview leaves out embed.go"; fi + +# --- Safety asserts on the checkout --- + +echo "# a checkout that is not basecamp/skills on main is refused" +wrong="${work}/wrong-remote" +git init -q -b main "$wrong" +git -C "$wrong" remote add origin https://github.com/basecamp/other.git +git -C "$wrong" commit -q --allow-empty -m "init" +sync_expecting_failure hey-cli "$a" DRY_RUN=local SKILLS_TARGET="$wrong" +assert_output "does not point to github.com/basecamp/skills" + +git -C "$target" checkout -q -b not-main +sync_expecting_failure hey-cli "$a" DRY_RUN=local SKILLS_TARGET="$target" +assert_output "checked-out branch is 'not-main', expected 'main'" +git -C "$target" checkout -q main + +# --- Verdict --- + +echo "" +if [[ "$failures" -eq 0 ]]; then + echo "sync-skills: all assertions passed" +else + echo "sync-skills: ${failures} assertion(s) failed" >&2 + exit 1 +fi From fcd18c785733d51b3ee7158abb80c8bbee3725f5 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 12 Sep 2026 17:17:48 -0700 Subject: [PATCH 2/8] Retry the push a racing publisher rejects, and refuse a dirty checkout 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. --- seed/scripts/sync-skills.sh | 35 ++++++++++++++++++++++---------- seed/scripts/test-sync-skills.sh | 29 ++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 11 deletions(-) diff --git a/seed/scripts/sync-skills.sh b/seed/scripts/sync-skills.sh index 58f4702..a05b19b 100755 --- a/seed/scripts/sync-skills.sh +++ b/seed/scripts/sync-skills.sh @@ -20,7 +20,11 @@ # every run as a comment-only tombstone. The pre-fix script skips any line it cannot # parse as a skill name, but treats a missing file as licence to own every skills/* # directory — so the tombstone is what stops an un-upgraded sibling from deleting -# anyone's skills, whichever CLI upgrades first (basecamp/skills#5). +# anyone's skills, whichever CLI upgrades first (basecamp/skills#5). One case is +# accepted: a skill a still-pre-fix sibling drops after the tombstone exists stays +# behind in the target (that script has no names left to delete by, and the +# sibling's own first run here removes nothing) — a lingering directory to remove +# by hand, which beats guessing ownership from the legacy file. # # Required env vars: # RELEASE_TAG — the release tag (e.g. v1.2.3) @@ -115,9 +119,11 @@ claimed_by_other() { return 1 } +# The URL as configured: `remote get-url` would show it after any insteadOf rewrite, +# so an operator's rewrite could satisfy this check with a repo that is not the target. assert_remote_url() { local url stripped - url=$(git -C "$1" remote get-url origin) + url=$(git -C "$1" config --get remote.origin.url) stripped="${url%.git}" case "$stripped" in "https://github.com/${TARGET_REPO}") ;; @@ -196,11 +202,16 @@ fi # --- Git configuration for the target --- # -# A private global config for every git call below: the token goes in as a URL +# A private global config for every git call below: the bot is the identity for +# the commit and for the rebase a retried push needs, the token goes in as a URL # rewrite so it never appears in argv or in the remote URL, and nothing from the # ambient environment (signing, hooks, defaults) reaches the target. export GIT_CONFIG_GLOBAL="${tmpdir}/gitconfig" -: > "$GIT_CONFIG_GLOBAL" +cat > "$GIT_CONFIG_GLOBAL" <> "$GIT_CONFIG_GLOBAL" <&1 } if ! output=$(push_target); then - if echo "$output" | grep -qi "non-fast-forward"; then - echo "Push rejected (non-fast-forward). Pulling with rebase and retrying..." + if echo "$output" | grep -Eqi "fetch first|non-fast-forward"; then + echo "Push rejected (the remote has moved). Pulling with rebase and retrying..." git -C "$target" pull --rebase origin "$TARGET_BRANCH" if ! retry_output=$(push_target); then echo "$retry_output" >&2 diff --git a/seed/scripts/test-sync-skills.sh b/seed/scripts/test-sync-skills.sh index f672947..1ca4b39 100755 --- a/seed/scripts/test-sync-skills.sh +++ b/seed/scripts/test-sync-skills.sh @@ -245,8 +245,37 @@ assert_output "skills/hey/reference/commands.md" assert_output "No network operations performed" if grep -q "embed.go" "$out"; then not_ok "preview leaves out embed.go"; else ok "preview leaves out embed.go"; fi +# --- Another publisher pushes first: the push is retried after a rebase --- + +echo "# a concurrent publisher wins the race to origin" +origin="${work}/origin.git" +git init -q --bare -b main "$origin" +git -C "$target" push -q "$origin" main +sibling="${work}/sibling" +git clone -q "$origin" "$sibling" +echo "# skills (sibling)" > "${sibling}/README.md" +git -C "$sibling" commit -q -am "Sync skills from basecamp-cli v0.0.1 (concurrent)" +git -C "$sibling" push -q origin main +echo "hey v4" > "${a}/skills/hey/SKILL.md" +# A real push, with github.com/basecamp/skills routed to the local bare repo through +# the environment — the script's private gitconfig cannot hide that. +sync hey-cli "$a" SKILLS_TARGET="$target" \ + GIT_CONFIG_COUNT=1 "GIT_CONFIG_KEY_0=url.${origin}.insteadOf" GIT_CONFIG_VALUE_0=https://github.com/basecamp/skills.git +assert_output "Push rejected" +assert_output "Skills synced to basecamp/skills" +assert "origin main holds the sibling's commit then the sync" \ + test "$(git -C "$origin" log --format=%s -2 main | tr '\n' '|')" = "Sync skills from hey-cli v9.9.9|Sync skills from basecamp-cli v0.0.1 (concurrent)|" +assert_content skills/hey/SKILL.md "hey v4" +assert_clean + # --- Safety asserts on the checkout --- +echo "# a checkout with uncommitted changes is refused" +echo "stray" > "${target}/stray.txt" +sync_expecting_failure hey-cli "$a" DRY_RUN=local SKILLS_TARGET="$target" +assert_output "has uncommitted changes" +rm "${target}/stray.txt" + echo "# a checkout that is not basecamp/skills on main is refused" wrong="${work}/wrong-remote" git init -q -b main "$wrong" From 5aec9a174c07783142510f518fdfc2de844ab5b5 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 12 Sep 2026 17:29:40 -0700 Subject: [PATCH 3/8] Apply the sync again from the remote's tip when a push is rejected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- actions/sync-skills/action.yml | 9 +- seed/scripts/sync-skills.sh | 148 ++++++++++++++++++------------- seed/scripts/test-sync-skills.sh | 26 ++++++ 3 files changed, 118 insertions(+), 65 deletions(-) diff --git a/actions/sync-skills/action.yml b/actions/sync-skills/action.yml index 825245a..5fd19f2 100644 --- a/actions/sync-skills/action.yml +++ b/actions/sync-skills/action.yml @@ -45,4 +45,11 @@ runs: SKILLS_SOURCE: ${{ inputs.skills-source }} DRY_RUN: ${{ inputs.dry-run }} ACTION_PATH: ${{ github.action_path }} - run: bash "${ACTION_PATH}/../../seed/scripts/sync-skills.sh" + run: | + # The release workflows check this before generating a token; the action + # has no step in front of it, so a CLI with no skills yet is a no-op here. + if ! compgen -G "${SKILLS_SOURCE}/*/SKILL.md" > /dev/null; then + echo "No skill files found under ${SKILLS_SOURCE}/ — skipping sync" + exit 0 + fi + bash "${ACTION_PATH}/../../seed/scripts/sync-skills.sh" diff --git a/seed/scripts/sync-skills.sh b/seed/scripts/sync-skills.sh index a05b19b..873b422 100755 --- a/seed/scripts/sync-skills.sh +++ b/seed/scripts/sync-skills.sh @@ -106,12 +106,13 @@ read_manifest() { # Print the other source whose manifest claims a name, if any. claimed_by_other() { - local name="$1" file other + local name="$1" file other listed for file in "${target}/${LEGACY_MANIFEST}".*; do [[ -f "$file" ]] || continue other="${file##*/"${LEGACY_MANIFEST}".}" [[ "$other" == "$SYNC_SOURCE" ]] && continue - if read_manifest "$file" | grep -qxF -- "$name"; then + listed=$(read_manifest "$file") + if grep -qxF -- "$name" <<< "$listed"; then echo "$other" return 0 fi @@ -119,18 +120,21 @@ claimed_by_other() { return 1 } -# The URL as configured: `remote get-url` would show it after any insteadOf rewrite, -# so an operator's rewrite could satisfy this check with a repo that is not the target. +# The URLs as configured: `remote get-url` would show them after any insteadOf +# rewrite, so an operator's rewrite could pass this check with a repo that is not +# the target. A push URL of its own is where `git push origin` would actually go. assert_remote_url() { - local url stripped - url=$(git -C "$1" config --get remote.origin.url) - stripped="${url%.git}" - case "$stripped" in - "https://github.com/${TARGET_REPO}") ;; - https://x-access-token:*@github.com/"${TARGET_REPO}") ;; - "git@github.com:${TARGET_REPO}") ;; - *) die "origin remote '$(echo "$url" | sed -E 's#(https://[^:@]+:)[^@]*@#\1***@#')' does not point to github.com/${TARGET_REPO}" ;; - esac + local kind url + for kind in url pushurl; do + url=$(git -C "$1" config --get "remote.origin.${kind}" || true) + [[ -z "$url" && "$kind" == pushurl ]] && continue + case "${url%.git}" in + "https://github.com/${TARGET_REPO}") ;; + https://x-access-token:*@github.com/"${TARGET_REPO}") ;; + "git@github.com:${TARGET_REPO}") ;; + *) die "origin ${kind} '$(echo "$url" | sed -E 's#(https://[^:@]+:)[^@]*@#\1***@#')' does not point to github.com/${TARGET_REPO}" ;; + esac + done } assert_branch() { @@ -238,56 +242,69 @@ assert_branch "$target" # `git add -A` below would sweep anything else in the checkout into the sync commit. [[ -z "$(git -C "$target" status --porcelain)" ]] || die "${target} has uncommitted changes; commit or stash them before syncing into it" -# --- Refuse a name another source has published --- - -for name in "${skill_names[@]}"; do - if other=$(claimed_by_other "$name"); then - die "skills/${name} is published by ${other} (listed in ${LEGACY_MANIFEST}.${other}); rename the skill or settle ownership upstream" - fi -done - -# --- Publish this source's skills --- +# --- Apply the sync to the target's working tree --- +# +# Every decision here is made against the tree as it stands, so a retry after a +# rejected push runs this again from the remote's new tip instead of replaying +# decisions made against a stale one. -echo "Copying skills into ${target}/${SKILLS_SUBDIR}/..." -copy_skills "${target}/${SKILLS_SUBDIR}" +apply_sync() { + local name other + local previously_published=() -# --- Remove what this source published before and no longer has --- + # Refuse a name another source has published + for name in "${skill_names[@]}"; do + if other=$(claimed_by_other "$name"); then + die "skills/${name} is published by ${other} (listed in ${LEGACY_MANIFEST}.${other}); rename the skill or settle ownership upstream" + fi + done -previously_published=() -while IFS= read -r name; do - previously_published+=("$name") -done < <(read_manifest "${target}/${MANIFEST}") + echo "Copying skills into ${target}/${SKILLS_SUBDIR}/..." + copy_skills "${target}/${SKILLS_SUBDIR}" -if [[ ! -f "${target}/${MANIFEST}" ]]; then - echo "No ${MANIFEST} yet: first run for ${SYNC_SOURCE}, removing nothing" -fi + # Remove what this source published before and no longer has + while IFS= read -r name; do + previously_published+=("$name") + done < <(read_manifest "${target}/${MANIFEST}") -for name in ${previously_published[@]+"${previously_published[@]}"}; do - in_list "$name" "${skill_names[@]}" && continue - if other=$(claimed_by_other "$name"); then - warn "skills/${name} is no longer in ${SYNC_SOURCE}'s skills but ${other} lists it in ${LEGACY_MANIFEST}.${other}; leaving it in place" - continue + if [[ ! -f "${target}/${MANIFEST}" ]]; then + echo "No ${MANIFEST} yet: first run for ${SYNC_SOURCE}, removing nothing" fi - if [[ -d "${target}/${SKILLS_SUBDIR}/${name}" ]]; then - echo "Removing stale skill: ${name}" - rm -rf "${target:?}/${SKILLS_SUBDIR}/${name}" - fi -done -# --- Write this source's manifest and the legacy tombstone --- - -printf '%s\n' "${skill_names[@]}" | LC_ALL=C sort > "${target}/${MANIFEST}" + for name in ${previously_published[@]+"${previously_published[@]}"}; do + in_list "$name" "${skill_names[@]}" && continue + if other=$(claimed_by_other "$name"); then + warn "skills/${name} is no longer in ${SYNC_SOURCE}'s skills but ${other} lists it in ${LEGACY_MANIFEST}.${other}; leaving it in place" + continue + fi + if [[ -d "${target}/${SKILLS_SUBDIR}/${name}" ]]; then + echo "Removing stale skill: ${name}" + rm -rf "${target:?}/${SKILLS_SUBDIR}/${name}" + fi + done -cat > "${target}/${LEGACY_MANIFEST}" <<'TOMBSTONE' + # This source's manifest, and the legacy tombstone + printf '%s\n' "${skill_names[@]}" | LC_ALL=C sort > "${target}/${MANIFEST}" + cat > "${target}/${LEGACY_MANIFEST}" <<'TOMBSTONE' # Superseded by the per-source manifests (.managed-skills.), 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. TOMBSTONE -# --- Commit --- + git -C "$target" add -A +} + +commit_sync() { + git -C "$target" commit -q -m "$(cat <&1 } if ! output=$(push_target); then - if echo "$output" | grep -Eqi "fetch first|non-fast-forward"; then - echo "Push rejected (the remote has moved). Pulling with rebase and retrying..." - git -C "$target" pull --rebase origin "$TARGET_BRANCH" - if ! retry_output=$(push_target); then - echo "$retry_output" >&2 - die "Push failed after retry" - fi - else + if ! echo "$output" | grep -Eqi "fetch first|non-fast-forward"; then echo "$output" >&2 die "Push failed" fi + echo "Push rejected (the remote has moved). Applying the sync again from its new tip..." + git -C "$target" fetch -q origin "$TARGET_BRANCH" + git -C "$target" reset -q --hard FETCH_HEAD + apply_sync + if git -C "$target" diff --cached --quiet; then + echo "Nothing left to publish: the remote already holds these skills." + exit 0 + fi + commit_sync + if ! retry_output=$(push_target); then + echo "$retry_output" >&2 + die "Push failed after retry" + fi fi echo "" diff --git a/seed/scripts/test-sync-skills.sh b/seed/scripts/test-sync-skills.sh index 1ca4b39..9f74689 100755 --- a/seed/scripts/test-sync-skills.sh +++ b/seed/scripts/test-sync-skills.sh @@ -266,8 +266,29 @@ assert_output "Skills synced to basecamp/skills" assert "origin main holds the sibling's commit then the sync" \ test "$(git -C "$origin" log --format=%s -2 main | tr '\n' '|')" = "Sync skills from hey-cli v9.9.9|Sync skills from basecamp-cli v0.0.1 (concurrent)|" assert_content skills/hey/SKILL.md "hey v4" +assert_content README.md "# skills (sibling)" assert_clean +echo "# a concurrent publisher claims a name this source ships: the retry refuses" +git -C "$sibling" pull -q origin main +printf 'basecamp\nbasecamp-doctor\nhey\n' > "${sibling}/.managed-skills.basecamp-cli" +git -C "$sibling" commit -q -am "Collision: basecamp-cli claims hey (concurrent)" +git -C "$sibling" push -q origin main +echo "hey v5" > "${a}/skills/hey/SKILL.md" +sync_expecting_failure hey-cli "$a" SKILLS_TARGET="$target" \ + GIT_CONFIG_COUNT=1 "GIT_CONFIG_KEY_0=url.${origin}.insteadOf" GIT_CONFIG_VALUE_0=https://github.com/basecamp/skills.git +assert_output "Push rejected" +assert_output "ERROR: skills/hey is published by basecamp-cli" +assert "origin main tip is the sibling's commit" \ + test "$(git -C "$origin" log -1 --format=%s main)" = "Collision: basecamp-cli claims hey (concurrent)" +assert_content skills/hey/SKILL.md "hey v4" +assert_clean +git -C "$sibling" checkout -q HEAD~1 -- .managed-skills.basecamp-cli +git -C "$sibling" commit -q -am "basecamp-cli drops its claim on hey" +git -C "$sibling" push -q origin main +git -C "$target" fetch -q "$origin" main +git -C "$target" reset -q --hard FETCH_HEAD + # --- Safety asserts on the checkout --- echo "# a checkout with uncommitted changes is refused" @@ -284,6 +305,11 @@ git -C "$wrong" commit -q --allow-empty -m "init" sync_expecting_failure hey-cli "$a" DRY_RUN=local SKILLS_TARGET="$wrong" assert_output "does not point to github.com/basecamp/skills" +git -C "$target" remote set-url --push origin https://github.com/someone/skills.git +sync_expecting_failure hey-cli "$a" DRY_RUN=local SKILLS_TARGET="$target" +assert_output "origin pushurl 'https://github.com/someone/skills.git' does not point to github.com/basecamp/skills" +git -C "$target" config --unset remote.origin.pushurl + git -C "$target" checkout -q -b not-main sync_expecting_failure hey-cli "$a" DRY_RUN=local SKILLS_TARGET="$target" assert_output "checked-out branch is 'not-main', expected 'main'" From 17c383af6371296d5c5fcbb49e59a202d74880b1 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 12 Sep 2026 18:59:27 -0700 Subject: [PATCH 4/8] Check every URL the target remote could push to 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. --- seed/scripts/sync-skills.sh | 26 +++++++++++++++----------- seed/scripts/test-sync-skills.sh | 14 +++++++++++++- 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/seed/scripts/sync-skills.sh b/seed/scripts/sync-skills.sh index 873b422..ac14dce 100755 --- a/seed/scripts/sync-skills.sh +++ b/seed/scripts/sync-skills.sh @@ -122,18 +122,22 @@ claimed_by_other() { # The URLs as configured: `remote get-url` would show them after any insteadOf # rewrite, so an operator's rewrite could pass this check with a repo that is not -# the target. A push URL of its own is where `git push origin` would actually go. +# the target. Push URLs of its own are where `git push origin` would actually go, +# and a remote may carry several of either kind, each one a push destination. assert_remote_url() { - local kind url + local kind url urls for kind in url pushurl; do - url=$(git -C "$1" config --get "remote.origin.${kind}" || true) - [[ -z "$url" && "$kind" == pushurl ]] && continue - case "${url%.git}" in - "https://github.com/${TARGET_REPO}") ;; - https://x-access-token:*@github.com/"${TARGET_REPO}") ;; - "git@github.com:${TARGET_REPO}") ;; - *) die "origin ${kind} '$(echo "$url" | sed -E 's#(https://[^:@]+:)[^@]*@#\1***@#')' does not point to github.com/${TARGET_REPO}" ;; - esac + urls=$(git -C "$1" config --get-all "remote.origin.${kind}" || true) + [[ -z "$urls" && "$kind" == url ]] && die "origin has no url configured" + while IFS= read -r url; do + [[ -n "$url" ]] || continue + case "${url%.git}" in + "https://github.com/${TARGET_REPO}") ;; + https://x-access-token:*@github.com/"${TARGET_REPO}") ;; + "git@github.com:${TARGET_REPO}") ;; + *) die "origin ${kind} '$(echo "$url" | sed -E 's#(https://[^:@]+:)[^@]*@#\1***@#')' does not point to github.com/${TARGET_REPO}" ;; + esac + done <<< "$urls" done } @@ -207,7 +211,7 @@ fi # --- Git configuration for the target --- # # A private global config for every git call below: the bot is the identity for -# the commit and for the rebase a retried push needs, the token goes in as a URL +# the commit, and for the one a rejected push makes again, the token goes in as a URL # rewrite so it never appears in argv or in the remote URL, and nothing from the # ambient environment (signing, hooks, defaults) reaches the target. export GIT_CONFIG_GLOBAL="${tmpdir}/gitconfig" diff --git a/seed/scripts/test-sync-skills.sh b/seed/scripts/test-sync-skills.sh index 9f74689..d8543b5 100755 --- a/seed/scripts/test-sync-skills.sh +++ b/seed/scripts/test-sync-skills.sh @@ -245,7 +245,7 @@ assert_output "skills/hey/reference/commands.md" assert_output "No network operations performed" if grep -q "embed.go" "$out"; then not_ok "preview leaves out embed.go"; else ok "preview leaves out embed.go"; fi -# --- Another publisher pushes first: the push is retried after a rebase --- +# --- Another publisher pushes first: the sync is applied again from its tip --- echo "# a concurrent publisher wins the race to origin" origin="${work}/origin.git" @@ -310,6 +310,18 @@ sync_expecting_failure hey-cli "$a" DRY_RUN=local SKILLS_TARGET="$target" assert_output "origin pushurl 'https://github.com/someone/skills.git' does not point to github.com/basecamp/skills" git -C "$target" config --unset remote.origin.pushurl +echo "# every push URL, and every fetch URL, is a push destination: one bad one is refused" +git -C "$target" remote set-url --push origin git@github.com:someone/skills.git +git -C "$target" remote set-url --push --add origin https://github.com/basecamp/skills.git +sync_expecting_failure hey-cli "$a" DRY_RUN=local SKILLS_TARGET="$target" +assert_output "origin pushurl 'git@github.com:someone/skills.git' does not point to github.com/basecamp/skills" +git -C "$target" config --unset-all remote.origin.pushurl +git -C "$target" config --replace-all remote.origin.url https://github.com/someone/skills.git +git -C "$target" remote set-url --add origin https://github.com/basecamp/skills.git +sync_expecting_failure hey-cli "$a" DRY_RUN=local SKILLS_TARGET="$target" +assert_output "origin url 'https://github.com/someone/skills.git' does not point to github.com/basecamp/skills" +git -C "$target" config --replace-all remote.origin.url https://github.com/basecamp/skills.git + git -C "$target" checkout -q -b not-main sync_expecting_failure hey-cli "$a" DRY_RUN=local SKILLS_TARGET="$target" assert_output "checked-out branch is 'not-main', expected 'main'" From 966966e19d7ad43cf5be6f7a52beb5fb2719abda Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sat, 12 Sep 2026 20:12:20 -0700 Subject: [PATCH 5/8] Always clone the skills target fresh; retire SKILLS_TARGET and its guards 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. --- AGENTS.md | 7 +- seed/scripts/sync-skills.sh | 108 ++++++---------- seed/scripts/test-sync-skills.sh | 203 ++++++++++++++++--------------- 3 files changed, 143 insertions(+), 175 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 67a092b..854241c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,8 +66,11 @@ Several CLIs share that target, so each one owns `.managed-skills.` ther skill directories its own manifest lists, that its skill set no longer has, and that no other manifest claims. The legacy shared `.managed-skills` is rewritten as a comment-only tombstone so a sibling still on the pre-fix script deletes nothing (basecamp/skills#5). -`seed/scripts/test-sync-skills.sh` runs the script as two CLIs against a throwaway -target and is part of `make check`. +The script always clones the target fresh (from `SKILLS_REPO_URL`, default +`https://github.com/basecamp/skills.git`) and pushes only the commit it made, so there is +no checkout to hand it. `seed/scripts/test-sync-skills.sh` runs the script as two CLIs +against a local bare repository — real clones, commits and pushes, no network — and is +part of `make check`. ## Rubric diff --git a/seed/scripts/sync-skills.sh b/seed/scripts/sync-skills.sh index ac14dce..7cd5ef5 100755 --- a/seed/scripts/sync-skills.sh +++ b/seed/scripts/sync-skills.sh @@ -1,10 +1,12 @@ #!/usr/bin/env bash # sync-skills.sh — Publish this CLI's skills to the basecamp/skills distribution repo. # -# Runs from CI on a release tag. Mirrors each skills// tree (SKILL.md and its -# supporting files; no *.go, no dotfiles) into skills// at the root of -# basecamp/skills — the layout `npx skills add basecamp/skills` reads — then commits -# as [bot] and pushes. +# Runs from CI on a release tag. Clones basecamp/skills fresh into a temp directory, +# mirrors each skills// tree (SKILL.md and its supporting files; no *.go, no +# dotfiles) into skills// at its root — the layout `npx skills add +# basecamp/skills` reads — then commits as [bot], pushes, and removes the +# clone. The script never adopts an existing checkout: the only commit it can push +# is the one it made, against the tip it cloned or fetched. # # Several CLIs publish into that one repo, so each owns a manifest of its own at the # target root, .managed-skills., listing the skill names it has published, @@ -27,26 +29,27 @@ # by hand, which beats guessing ownership from the legacy file. # # Required env vars: -# RELEASE_TAG — the release tag (e.g. v1.2.3) -# SOURCE_SHA — the source commit SHA -# SKILLS_TOKEN — GitHub token with push access to basecamp/skills; not needed -# for DRY_RUN=local, nor when SKILLS_TARGET is set +# RELEASE_TAG — the release tag (e.g. v1.2.3) +# SOURCE_SHA — the source commit SHA +# SKILLS_TOKEN — GitHub token with push access to basecamp/skills; not needed +# for DRY_RUN=local, nor when SKILLS_REPO_URL is not on github.com # # Optional env vars: -# CLI_NAME — this CLI's name; the publishing source is -cli -# SYNC_SOURCE — the publishing repo's name (default: -cli). Names the -# manifest, the bot and the commit; the test sets it to play -# another CLI -# SKILLS_SOURCE — directory holding the skills tree (default: skills). A manual -# recovery workflow can point it at a checkout of the release tag -# so the sync logic comes from a newer ref than the content -# SKILLS_TARGET — an existing checkout of basecamp/skills to sync into instead of -# cloning; the remote-URL and branch asserts still run against it -# DRY_RUN — "local": no network. Without SKILLS_TARGET, copy into an empty -# tmpdir and print what would be published; with it, apply and -# commit there but do not push. -# "remote": clone (or use SKILLS_TARGET), apply, print the diff, -# and stop before committing +# CLI_NAME — this CLI's name; the publishing source is -cli +# SYNC_SOURCE — the publishing repo's name (default: -cli). Names the +# manifest, the bot and the commit; the test sets it to play +# another CLI +# SKILLS_SOURCE — directory holding the skills tree (default: skills). A manual +# recovery workflow can point it at a checkout of the release tag +# so the sync logic comes from a newer ref than the content +# SKILLS_REPO_URL — where basecamp/skills is cloned from and pushed to (default: +# https://github.com/basecamp/skills.git). The test points it at +# a local bare repository so a real push lands somewhere it can +# read back +# DRY_RUN — "local": no network; copy into an empty tmpdir and print what +# would be published. +# "remote": clone, apply, print the diff, and stop before +# committing # # TODO: Replace CLI_NAME with your CLI name. @@ -57,12 +60,12 @@ SYNC_SOURCE="${SYNC_SOURCE:-${CLI_NAME}-cli}" RELEASE_TAG="${RELEASE_TAG:?RELEASE_TAG is required}" SOURCE_SHA="${SOURCE_SHA:?SOURCE_SHA is required}" SKILLS_SOURCE="${SKILLS_SOURCE:-skills}" -SKILLS_TARGET="${SKILLS_TARGET:-}" SKILLS_TOKEN="${SKILLS_TOKEN:-}" DRY_RUN="${DRY_RUN:-}" TARGET_REPO="basecamp/skills" TARGET_BRANCH="main" +SKILLS_REPO_URL="${SKILLS_REPO_URL:-https://github.com/${TARGET_REPO}.git}" SKILLS_SUBDIR="skills" LEGACY_MANIFEST=".managed-skills" MANIFEST="${LEGACY_MANIFEST}.${SYNC_SOURCE}" @@ -120,33 +123,6 @@ claimed_by_other() { return 1 } -# The URLs as configured: `remote get-url` would show them after any insteadOf -# rewrite, so an operator's rewrite could pass this check with a repo that is not -# the target. Push URLs of its own are where `git push origin` would actually go, -# and a remote may carry several of either kind, each one a push destination. -assert_remote_url() { - local kind url urls - for kind in url pushurl; do - urls=$(git -C "$1" config --get-all "remote.origin.${kind}" || true) - [[ -z "$urls" && "$kind" == url ]] && die "origin has no url configured" - while IFS= read -r url; do - [[ -n "$url" ]] || continue - case "${url%.git}" in - "https://github.com/${TARGET_REPO}") ;; - https://x-access-token:*@github.com/"${TARGET_REPO}") ;; - "git@github.com:${TARGET_REPO}") ;; - *) die "origin ${kind} '$(echo "$url" | sed -E 's#(https://[^:@]+:)[^@]*@#\1***@#')' does not point to github.com/${TARGET_REPO}" ;; - esac - done <<< "$urls" - done -} - -assert_branch() { - local branch - branch=$(git -C "$1" rev-parse --abbrev-ref HEAD) - [[ "$branch" == "$TARGET_BRANCH" ]] || die "checked-out branch is '$branch', expected '$TARGET_BRANCH'" -} - # --- Validate the knobs --- plain_name "$SYNC_SOURCE" || die "SYNC_SOURCE '$SYNC_SOURCE' is not a plain name" @@ -187,9 +163,9 @@ copy_skills() { tmpdir=$(mktemp -d) trap 'rm -rf "$tmpdir"' EXIT -# --- DRY_RUN=local without a target: what would be published --- +# --- DRY_RUN=local: what would be published --- -if [[ "$DRY_RUN" == "local" && -z "$SKILLS_TARGET" ]]; then +if [[ "$DRY_RUN" == "local" ]]; then preview="${tmpdir}/preview" echo "DRY_RUN=local: copying skills into ${preview}" copy_skills "${preview}/${SKILLS_SUBDIR}" @@ -221,30 +197,19 @@ cat > "$GIT_CONFIG_GLOBAL" <> "$GIT_CONFIG_GLOBAL" < "$GIT_CONFIG_GLOBAL" +# A file:// URL rather than a path: git ignores --depth for a path, and the +# script's clone is shallow, so the retry has to fetch as it would from GitHub. +origin="${work}/origin.git" +origin_url="file://${origin}" target="${work}/target" out="${work}/out" failures=0 @@ -61,38 +68,54 @@ assert_tombstone() { fi } assert_author() { assert "last commit authored by $1[bot]" test "$(git -C "$target" log -1 --format=%an)" = "$1[bot]"; } -assert_clean() { assert "target working tree committed clean" test -z "$(git -C "$target" status --porcelain)"; } assert_output() { assert "output says: $1" grep -q -- "$1" "$out"; } assert_head() { # expected sha, description - assert "$2" test "$(git -C "$target" rev-parse HEAD)" = "$1" + assert "$2" test "$(git -C "$origin" rev-parse main)" = "$1" } +origin_head() { git -C "$origin" rev-parse main; } + # --- Running the script --- +# +# Every run clones origin afresh, as a release would clone basecamp/skills, and +# the target clone is brought to origin's tip afterwards for the assertions. + +refresh_target() { + git -C "$target" fetch -q origin main + git -C "$target" reset -q --hard FETCH_HEAD +} sync() { # source, fixture, [VAR=value...] local source="$1" fixture="$2" shift 2 - if env "$@" SYNC_SOURCE="$source" SKILLS_SOURCE="${fixture}/skills" \ + if env SKILLS_REPO_URL="$origin_url" "$@" SYNC_SOURCE="$source" SKILLS_SOURCE="${fixture}/skills" \ RELEASE_TAG=v9.9.9 SOURCE_SHA=0123abcd "$SYNC_SCRIPT" > "$out" 2>&1; then ok "sync as ${source} succeeded" else not_ok "sync as ${source} succeeded" sed 's/^/ /' "$out" fi + refresh_target } -sync_local() { sync "$1" "$2" DRY_RUN=local SKILLS_TARGET="$target"; } - sync_expecting_failure() { # source, fixture, [VAR=value...] local source="$1" fixture="$2" shift 2 - if env "$@" SYNC_SOURCE="$source" SKILLS_SOURCE="${fixture}/skills" \ + if env SKILLS_REPO_URL="$origin_url" "$@" SYNC_SOURCE="$source" SKILLS_SOURCE="${fixture}/skills" \ RELEASE_TAG=v9.9.9 SOURCE_SHA=0123abcd "$SYNC_SCRIPT" > "$out" 2>&1; then not_ok "sync as ${source} refused" sed 's/^/ /' "$out" else ok "sync as ${source} refused" fi + refresh_target +} + +# Commit the target's working tree and push it, as a hand-made or pre-fix commit +publish() { # message + git -C "$target" add -A + git -C "$target" commit -q -m "$1" + git -C "$target" push -q origin main } # --- Fixtures --- @@ -118,19 +141,20 @@ write_skill "${b}/skills/basecamp-doctor" "basecamp-doctor v2" # The target as basecamp/skills#5 left it: only basecamp-cli's skills survive, # and the shared manifest names them. +git init -q --bare -b main "$origin" git init -q -b main "$target" -git -C "$target" remote add origin https://github.com/basecamp/skills.git +git -C "$target" remote add origin "$origin_url" write_skill "${target}/skills/basecamp" "basecamp v1" write_skill "${target}/skills/basecamp-doctor" "basecamp-doctor v1" printf 'basecamp\nbasecamp-doctor\n' > "${target}/.managed-skills" echo "# skills" > "${target}/README.md" -git -C "$target" add -A -git -C "$target" commit -q -m "State after basecamp/skills#5" +publish "State after basecamp/skills#5" # --- Interleaved syncs: A, B, A, B --- echo "# hey-cli syncs first: restores its skills, touches nothing else" -sync_local hey-cli "$a" +sync hey-cli "$a" +assert_output "Skills synced to basecamp/skills" assert_skill hey assert_skill hey-doctor assert_skill basecamp @@ -144,11 +168,12 @@ assert_manifest hey-cli hey hey-doctor assert_no_manifest basecamp-cli assert_tombstone assert_author hey-cli -assert_clean assert_output "first run for hey-cli, removing nothing" +assert "origin main is the seed commit then hey-cli's sync" \ + test "$(git -C "$origin" log --format=%s -2 main | tr '\n' '|')" = "Sync skills from hey-cli v9.9.9|State after basecamp/skills#5|" echo "# basecamp-cli syncs: refreshes its skills, leaves hey-cli's" -sync_local basecamp-cli "$b" +sync basecamp-cli "$b" assert_skill hey assert_skill hey-doctor assert_skill basecamp @@ -158,15 +183,14 @@ assert_manifest hey-cli hey hey-doctor assert_manifest basecamp-cli basecamp basecamp-doctor assert_tombstone assert_author basecamp-cli -assert_clean echo "# both sync again with nothing new: no commits, nothing lost" -head_before=$(git -C "$target" rev-parse HEAD) -sync_local hey-cli "$a" +head_before=$(origin_head) +sync hey-cli "$a" assert_output "No changes to commit" -sync_local basecamp-cli "$b" +sync basecamp-cli "$b" assert_output "No changes to commit" -assert_head "$head_before" "HEAD unchanged by the no-op syncs" +assert_head "$head_before" "origin main unchanged by the no-op syncs" assert_skill hey assert_skill hey-doctor assert_skill basecamp @@ -179,7 +203,7 @@ assert_tombstone echo "# hey-cli drops hey-doctor" rm -rf "${a}/skills/hey-doctor" -sync_local hey-cli "$a" +sync hey-cli "$a" assert_output "Removing stale skill: hey-doctor" assert_no_skill hey-doctor assert_skill hey @@ -193,8 +217,8 @@ assert_author hey-cli echo "# a pre-fix basecamp-cli rewrites .managed-skills with its own names" printf 'basecamp\nbasecamp-doctor\n' > "${target}/.managed-skills" -git -C "$target" commit -q -am "Sync skills from basecamp-cli v0.0.0 (pre-fix script)" -sync_local hey-cli "$a" +publish "Sync skills from basecamp-cli v0.0.0 (pre-fix script)" +sync hey-cli "$a" assert_skill basecamp assert_skill basecamp-doctor assert_skill hey @@ -205,8 +229,8 @@ assert_manifest basecamp-cli basecamp basecamp-doctor echo "# hey-cli's manifest also lists basecamp, which basecamp-cli owns" printf 'basecamp\nhey\n' > "${target}/.managed-skills.hey-cli" -git -C "$target" commit -q -am "Collision: hey-cli claims basecamp" -sync_local hey-cli "$a" +publish "Collision: hey-cli claims basecamp" +sync hey-cli "$a" assert_skill basecamp assert_content skills/basecamp/SKILL.md "basecamp v2" assert_output "WARNING: skills/basecamp is no longer in hey-cli's skills but basecamp-cli lists it" @@ -217,115 +241,96 @@ assert_manifest basecamp-cli basecamp basecamp-doctor echo "# hey-cli ships a skill named basecamp" write_skill "${a}/skills/basecamp" "hey-cli's basecamp" -head_before=$(git -C "$target" rev-parse HEAD) -sync_expecting_failure hey-cli "$a" DRY_RUN=local SKILLS_TARGET="$target" +head_before=$(origin_head) +sync_expecting_failure hey-cli "$a" assert_output "ERROR: skills/basecamp is published by basecamp-cli" assert_content skills/basecamp/SKILL.md "basecamp v2" -assert_head "$head_before" "HEAD unchanged by the refused sync" -assert_clean +assert_head "$head_before" "origin main unchanged by the refused sync" rm -rf "${a}/skills/basecamp" -# --- DRY_RUN=remote applies and shows the diff but commits nothing --- +# --- DRY_RUN=remote clones and shows the diff but commits nothing --- -echo "# DRY_RUN=remote against the checkout" +echo "# DRY_RUN=remote against origin" echo "hey v3" > "${a}/skills/hey/SKILL.md" -head_before=$(git -C "$target" rev-parse HEAD) -sync hey-cli "$a" DRY_RUN=remote SKILLS_TARGET="$target" +head_before=$(origin_head) +sync hey-cli "$a" DRY_RUN=remote assert_output "DRY_RUN=remote: skipping commit and push" assert_output "+hey v3" -assert_head "$head_before" "HEAD unchanged by DRY_RUN=remote" -git -C "$target" reset -q --hard +assert_head "$head_before" "origin main unchanged by DRY_RUN=remote" +assert_content skills/hey/SKILL.md "hey v2" -# --- DRY_RUN=local with no target: no network, lists what would be published --- +# --- DRY_RUN=local: no clone at all, lists what would be published --- -echo "# DRY_RUN=local without SKILLS_TARGET" -sync hey-cli "$a" DRY_RUN=local +echo "# DRY_RUN=local never reaches the repository" +sync hey-cli "$a" DRY_RUN=local SKILLS_REPO_URL="file://${work}/nowhere.git" assert_output "skills/hey/SKILL.md" assert_output "skills/hey/reference/commands.md" assert_output "No network operations performed" if grep -q "embed.go" "$out"; then not_ok "preview leaves out embed.go"; else ok "preview leaves out embed.go"; fi +# --- Without a token, github.com is refused before anything is cloned --- + +echo "# the default target needs SKILLS_TOKEN" +sync_expecting_failure hey-cli "$a" SKILLS_REPO_URL=https://github.com/basecamp/skills.git +assert_output "ERROR: SKILLS_TOKEN is required" +if grep -q "Cloning" "$out"; then not_ok "refused before cloning"; else ok "refused before cloning"; fi + # --- Another publisher pushes first: the sync is applied again from its tip --- +# +# The script clones afresh, so the sibling's push has to land after that clone and +# before the push. A post-commit hook, reached through the GIT_CONFIG_* environment +# the script's private gitconfig cannot hide, pushes the sibling's commit at exactly +# that moment; the script's push is then rejected as "fetch first". -echo "# a concurrent publisher wins the race to origin" -origin="${work}/origin.git" -git init -q --bare -b main "$origin" -git -C "$target" push -q "$origin" main sibling="${work}/sibling" -git clone -q "$origin" "$sibling" +git clone -q "$origin_url" "$sibling" +hooks="${work}/hooks" +mkdir -p "$hooks" +printf '#!/usr/bin/env bash\ngit -C "%s" push -q origin main\n' "$sibling" > "${hooks}/post-commit" +chmod +x "${hooks}/post-commit" +racing() { # source, fixture: sync with the sibling pushing between clone and push + sync "$1" "$2" GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.hooksPath "GIT_CONFIG_VALUE_0=${hooks}" +} +racing_expecting_failure() { + sync_expecting_failure "$1" "$2" GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.hooksPath "GIT_CONFIG_VALUE_0=${hooks}" +} + +echo "# a concurrent publisher wins the race to origin" echo "# skills (sibling)" > "${sibling}/README.md" git -C "$sibling" commit -q -am "Sync skills from basecamp-cli v0.0.1 (concurrent)" -git -C "$sibling" push -q origin main +sibling_head=$(git -C "$sibling" rev-parse HEAD) echo "hey v4" > "${a}/skills/hey/SKILL.md" -# A real push, with github.com/basecamp/skills routed to the local bare repo through -# the environment — the script's private gitconfig cannot hide that. -sync hey-cli "$a" SKILLS_TARGET="$target" \ - GIT_CONFIG_COUNT=1 "GIT_CONFIG_KEY_0=url.${origin}.insteadOf" GIT_CONFIG_VALUE_0=https://github.com/basecamp/skills.git +racing hey-cli "$a" assert_output "Push rejected" assert_output "Skills synced to basecamp/skills" assert "origin main holds the sibling's commit then the sync" \ test "$(git -C "$origin" log --format=%s -2 main | tr '\n' '|')" = "Sync skills from hey-cli v9.9.9|Sync skills from basecamp-cli v0.0.1 (concurrent)|" +assert "the sync commit was made on the sibling's tip" test "$(git -C "$origin" rev-parse main^)" = "$sibling_head" assert_content skills/hey/SKILL.md "hey v4" assert_content README.md "# skills (sibling)" -assert_clean +assert_author hey-cli echo "# a concurrent publisher claims a name this source ships: the retry refuses" git -C "$sibling" pull -q origin main printf 'basecamp\nbasecamp-doctor\nhey\n' > "${sibling}/.managed-skills.basecamp-cli" git -C "$sibling" commit -q -am "Collision: basecamp-cli claims hey (concurrent)" -git -C "$sibling" push -q origin main +sibling_head=$(git -C "$sibling" rev-parse HEAD) echo "hey v5" > "${a}/skills/hey/SKILL.md" -sync_expecting_failure hey-cli "$a" SKILLS_TARGET="$target" \ - GIT_CONFIG_COUNT=1 "GIT_CONFIG_KEY_0=url.${origin}.insteadOf" GIT_CONFIG_VALUE_0=https://github.com/basecamp/skills.git +racing_expecting_failure hey-cli "$a" assert_output "Push rejected" assert_output "ERROR: skills/hey is published by basecamp-cli" -assert "origin main tip is the sibling's commit" \ - test "$(git -C "$origin" log -1 --format=%s main)" = "Collision: basecamp-cli claims hey (concurrent)" +assert_head "$sibling_head" "origin main tip is the sibling's commit" assert_content skills/hey/SKILL.md "hey v4" -assert_clean + +echo "# the sibling drops its claim: the next release publishes" git -C "$sibling" checkout -q HEAD~1 -- .managed-skills.basecamp-cli git -C "$sibling" commit -q -am "basecamp-cli drops its claim on hey" git -C "$sibling" push -q origin main -git -C "$target" fetch -q "$origin" main -git -C "$target" reset -q --hard FETCH_HEAD - -# --- Safety asserts on the checkout --- - -echo "# a checkout with uncommitted changes is refused" -echo "stray" > "${target}/stray.txt" -sync_expecting_failure hey-cli "$a" DRY_RUN=local SKILLS_TARGET="$target" -assert_output "has uncommitted changes" -rm "${target}/stray.txt" - -echo "# a checkout that is not basecamp/skills on main is refused" -wrong="${work}/wrong-remote" -git init -q -b main "$wrong" -git -C "$wrong" remote add origin https://github.com/basecamp/other.git -git -C "$wrong" commit -q --allow-empty -m "init" -sync_expecting_failure hey-cli "$a" DRY_RUN=local SKILLS_TARGET="$wrong" -assert_output "does not point to github.com/basecamp/skills" - -git -C "$target" remote set-url --push origin https://github.com/someone/skills.git -sync_expecting_failure hey-cli "$a" DRY_RUN=local SKILLS_TARGET="$target" -assert_output "origin pushurl 'https://github.com/someone/skills.git' does not point to github.com/basecamp/skills" -git -C "$target" config --unset remote.origin.pushurl - -echo "# every push URL, and every fetch URL, is a push destination: one bad one is refused" -git -C "$target" remote set-url --push origin git@github.com:someone/skills.git -git -C "$target" remote set-url --push --add origin https://github.com/basecamp/skills.git -sync_expecting_failure hey-cli "$a" DRY_RUN=local SKILLS_TARGET="$target" -assert_output "origin pushurl 'git@github.com:someone/skills.git' does not point to github.com/basecamp/skills" -git -C "$target" config --unset-all remote.origin.pushurl -git -C "$target" config --replace-all remote.origin.url https://github.com/someone/skills.git -git -C "$target" remote set-url --add origin https://github.com/basecamp/skills.git -sync_expecting_failure hey-cli "$a" DRY_RUN=local SKILLS_TARGET="$target" -assert_output "origin url 'https://github.com/someone/skills.git' does not point to github.com/basecamp/skills" -git -C "$target" config --replace-all remote.origin.url https://github.com/basecamp/skills.git - -git -C "$target" checkout -q -b not-main -sync_expecting_failure hey-cli "$a" DRY_RUN=local SKILLS_TARGET="$target" -assert_output "checked-out branch is 'not-main', expected 'main'" -git -C "$target" checkout -q main +sync hey-cli "$a" +assert_output "Skills synced to basecamp/skills" +assert_content skills/hey/SKILL.md "hey v5" +assert_manifest basecamp-cli basecamp basecamp-doctor +assert_manifest hey-cli hey # --- Verdict --- From 1df3bfe506442f90d72c204f6f8a7efceed4053e Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sun, 13 Sep 2026 18:50:41 -0700 Subject: [PATCH 6/8] Say what the private gitconfig replaces, and what it does not MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- seed/scripts/sync-skills.sh | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/seed/scripts/sync-skills.sh b/seed/scripts/sync-skills.sh index 7cd5ef5..9af3745 100755 --- a/seed/scripts/sync-skills.sh +++ b/seed/scripts/sync-skills.sh @@ -187,9 +187,11 @@ fi # --- Git configuration for the target --- # # A private global config for every git call below: the bot is the identity for -# the commit, and for the one a rejected push makes again, the token goes in as a URL -# rewrite so it never appears in argv or in the remote URL, and nothing from the -# ambient environment (signing, hooks, defaults) reaches the target. +# the commit, and for the one a rejected push makes again, and the token goes in as +# a URL rewrite so it never appears in argv or in the remote URL. Only the user's +# global file is replaced (~/.gitconfig: identity, signing, credential helpers, hooks +# path); the system config and any GIT_CONFIG_COUNT/GIT_CONFIG_KEY_* settings in the +# environment still apply — the test's race case injects a hooks path that way. export GIT_CONFIG_GLOBAL="${tmpdir}/gitconfig" cat > "$GIT_CONFIG_GLOBAL" < Date: Sun, 13 Sep 2026 19:17:36 -0700 Subject: [PATCH 7/8] Run the sync as the source its CLI_NAME default names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every case set SYNC_SOURCE to play hey-cli or basecamp-cli, so the one line each CLI's 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 expected source: a CLI's Makefile passes its own name in EXPECTED_SOURCE, the seed reads its CLI_NAME line. A copy left at the seed's placeholder, or naming another CLI, fails here instead of publishing under that source's manifest and identity. The three ways of running the script now go through one run_sync, which is where the release identifiers and the origin URL live. --- seed/scripts/test-sync-skills.sh | 57 ++++++++++++++++++++++++++++---- 1 file changed, 50 insertions(+), 7 deletions(-) diff --git a/seed/scripts/test-sync-skills.sh b/seed/scripts/test-sync-skills.sh index 3e18ba3..84b7734 100755 --- a/seed/scripts/test-sync-skills.sh +++ b/seed/scripts/test-sync-skills.sh @@ -8,12 +8,16 @@ # from a clone of its own. It starts in the state basecamp/skills#5 left it: # basecamp-cli's skills and the shared .managed-skills listing them. Then # hey-cli and basecamp-cli sync in turn, one loses a skill, a pre-fix sibling -# rewrites the legacy manifest, two manifests claim one name, and a sibling -# wins the race to push — after each step both sources' skills must be where -# they belong. No network and no token. +# rewrites the legacy manifest, two manifests claim one name, a sibling wins +# the race to push, and the script runs as the source its own CLI_NAME default +# names — after each step both sources' skills must be where they belong. No +# network and no token. # # Usage: scripts/test-sync-skills.sh (tests scripts/sync-skills.sh) # SYNC_SCRIPT=path/to/sync-skills.sh scripts/test-sync-skills.sh +# EXPECTED_SOURCE=-cli scripts/test-sync-skills.sh +# (the source the script publishes as when nothing names one; a CLI's +# Makefile passes its own, the default is read off the script's CLI_NAME line) set -euo pipefail @@ -85,11 +89,17 @@ refresh_target() { git -C "$target" reset -q --hard FETCH_HEAD } +# The script against origin with the identifiers a release carries; the caller's +# VAR=value pairs go in front of the fixed ones, so only SKILLS_REPO_URL can be +# overridden (DRY_RUN=local points it nowhere to prove it is never reached). +run_sync() { # [VAR=value...] + env SKILLS_REPO_URL="$origin_url" "$@" RELEASE_TAG=v9.9.9 SOURCE_SHA=0123abcd "$SYNC_SCRIPT" > "$out" 2>&1 +} + sync() { # source, fixture, [VAR=value...] local source="$1" fixture="$2" shift 2 - if env SKILLS_REPO_URL="$origin_url" "$@" SYNC_SOURCE="$source" SKILLS_SOURCE="${fixture}/skills" \ - RELEASE_TAG=v9.9.9 SOURCE_SHA=0123abcd "$SYNC_SCRIPT" > "$out" 2>&1; then + if run_sync "$@" SYNC_SOURCE="$source" SKILLS_SOURCE="${fixture}/skills"; then ok "sync as ${source} succeeded" else not_ok "sync as ${source} succeeded" @@ -101,8 +111,7 @@ sync() { # source, fixture, [VAR=value...] sync_expecting_failure() { # source, fixture, [VAR=value...] local source="$1" fixture="$2" shift 2 - if env SKILLS_REPO_URL="$origin_url" "$@" SYNC_SOURCE="$source" SKILLS_SOURCE="${fixture}/skills" \ - RELEASE_TAG=v9.9.9 SOURCE_SHA=0123abcd "$SYNC_SCRIPT" > "$out" 2>&1; then + if run_sync "$@" SYNC_SOURCE="$source" SKILLS_SOURCE="${fixture}/skills"; then not_ok "sync as ${source} refused" sed 's/^/ /' "$out" else @@ -111,6 +120,20 @@ sync_expecting_failure() { # source, fixture, [VAR=value...] refresh_target } +# The script with neither SYNC_SOURCE nor CLI_NAME set, so the source is the one +# the CLI_NAME default line names — the line each CLI edits, which the other +# runs here never reach because they set SYNC_SOURCE to play another CLI. +sync_as_default() { # fixture + local fixture="$1" + if (unset CLI_NAME SYNC_SOURCE; run_sync SKILLS_SOURCE="${fixture}/skills"); then + ok "sync as the default source succeeded" + else + not_ok "sync as the default source succeeded" + sed 's/^/ /' "$out" + fi + refresh_target +} + # Commit the target's working tree and push it, as a hand-made or pre-fix commit publish() { # message git -C "$target" add -A @@ -332,6 +355,26 @@ assert_content skills/hey/SKILL.md "hey v5" assert_manifest basecamp-cli basecamp basecamp-doctor assert_manifest hey-cli hey +# --- The CLI_NAME default: the one line each CLI's copy of the script changes --- +# +# A copy whose default names another CLI, or still names the seed's placeholder, +# fails here rather than publishing under that source's manifest and bot identity. +# The CLI's Makefile says which source to expect; the seed expects what its own +# CLI_NAME line says. Last, because in hey-cli's or basecamp-cli's repository this +# is that CLI's own sync, which rightly rewrites its manifest from the new tree. + +echo "# with nothing set, the script publishes as the source its CLI_NAME default names" +default_source="${EXPECTED_SOURCE:-$(sed -n 's/^CLI_NAME=.*CLI_NAME:-\([a-z0-9-]*\)}.*/\1/p' "$SYNC_SCRIPT")-cli}" +assert "a default source is known (${default_source})" test "$default_source" != "-cli" +c="${work}/default" +write_skill "${c}/skills/default-skill" "default-skill v1" +sync_as_default "$c" +assert_output "Skills synced to basecamp/skills (main) from ${default_source} v9.9.9" +assert_skill default-skill +assert_manifest "$default_source" default-skill +assert_author "$default_source" +assert_tombstone + # --- Verdict --- echo "" From 0649a4d5e9b0f7ae8c82b33307bd7c82ddc2b903 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sun, 13 Sep 2026 22:07:08 -0700 Subject: [PATCH 8/8] Send the token as a github.com-scoped Authorization header, not a URL rewrite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The insteadOf rewrite kept the token out of the outer git argv and the remote URL, but git expands the rewrite before invoking git-remote-https, so that helper still received https://x-access-token:@github.com/... as an argument (GIT_TRACE=1 shows it). The private gitconfig now carries http.https://github.com/.extraheader with the basic-auth form actions/checkout writes: the helper sees the plain URL, and the token lives only in the mode-600 file until the tmpdir goes. A bogus token is rejected with "Authentication failed", so the header is sent and honored. The test unsets SKILLS_TOKEN up front: its token-required case points the script at the real basecamp/skills, and a token inherited from the caller's environment would have let it clone and publish the fixtures there. The header now says exactly what the tombstone shields — the sources that have upgraded — rather than "anyone's skills": a pre-fix sibling still rewrites .managed-skills with its own names and a second pre-fix sibling still deletes those, the basecamp/skills#5 clobber confined to the CLIs yet to upgrade. --- seed/scripts/sync-skills.sh | 31 ++++++++++++++++++++----------- seed/scripts/test-sync-skills.sh | 5 +++++ 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/seed/scripts/sync-skills.sh b/seed/scripts/sync-skills.sh index 9af3745..a947b54 100755 --- a/seed/scripts/sync-skills.sh +++ b/seed/scripts/sync-skills.sh @@ -22,11 +22,16 @@ # every run as a comment-only tombstone. The pre-fix script skips any line it cannot # parse as a skill name, but treats a missing file as licence to own every skills/* # directory — so the tombstone is what stops an un-upgraded sibling from deleting -# anyone's skills, whichever CLI upgrades first (basecamp/skills#5). One case is -# accepted: a skill a still-pre-fix sibling drops after the tombstone exists stays -# behind in the target (that script has no names left to delete by, and the -# sibling's own first run here removes nothing) — a lingering directory to remove -# by hand, which beats guessing ownership from the legacy file. +# this source's skills, whichever CLI upgrades first (basecamp/skills#5). It shields +# only the sources that have upgraded: a pre-fix sibling still writes its own names +# to .managed-skills, and a second pre-fix sibling still deletes those — the #5 +# clobber, confined to the CLIs yet to upgrade and gone once each has; nothing the +# target holds can make that script delete less, since a file it cannot read widens +# its reach to every skills/* directory. One more case is accepted: a skill a +# still-pre-fix sibling drops after the tombstone exists stays behind in the target +# (that script has no names left to delete by, and the sibling's own first run here +# removes nothing) — a lingering directory to remove by hand, which beats guessing +# ownership from the legacy file. # # Required env vars: # RELEASE_TAG — the release tag (e.g. v1.2.3) @@ -51,6 +56,7 @@ # "remote": clone, apply, print the diff, and stop before # committing # +# # TODO: Replace CLI_NAME with your CLI name. set -euo pipefail @@ -188,10 +194,13 @@ fi # # A private global config for every git call below: the bot is the identity for # the commit, and for the one a rejected push makes again, and the token goes in as -# a URL rewrite so it never appears in argv or in the remote URL. Only the user's -# global file is replaced (~/.gitconfig: identity, signing, credential helpers, hooks -# path); the system config and any GIT_CONFIG_COUNT/GIT_CONFIG_KEY_* settings in the -# environment still apply — the test's race case injects a hooks path that way. +# an Authorization header scoped to github.com, the way actions/checkout sends it — +# not as a URL rewrite, which git expands before handing the URL to git-remote-https +# in argv. The remote URL stays clean; the token is only in this file, mode 600, +# removed with the tmpdir. Only the user's global file is replaced (~/.gitconfig: +# identity, signing, credential helpers, hooks path); the system config and any +# GIT_CONFIG_COUNT/GIT_CONFIG_KEY_* settings in the environment still apply — the +# test's race case injects a hooks path that way. export GIT_CONFIG_GLOBAL="${tmpdir}/gitconfig" cat > "$GIT_CONFIG_GLOBAL" <> "$GIT_CONFIG_GLOBAL" < "$GIT_CONFIG_GLOBAL" +# The token-required case points the script at the real basecamp/skills; a token +# inherited from the caller's environment would let it clone and publish the +# fixtures there. +unset SKILLS_TOKEN + # A file:// URL rather than a path: git ignores --depth for a path, and the # script's clone is shallow, so the retry has to fetch as it would from GitHub. origin="${work}/origin.git"