Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -236,3 +236,52 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HOMEBREW_TAP_TOKEN: ${{ steps.sdk-token.outputs.token }}
AUR_KEY: ${{ secrets.AUR_KEY }}

sync-skills:
name: Sync skills
needs: [release]
if: startsWith(github.ref, 'refs/tags/v')
Comment thread
jeremy marked this conversation as resolved.
continue-on-error: true
concurrency:
group: sync-skills
cancel-in-progress: false
runs-on: ubuntu-latest
Comment thread
jeremy marked this conversation as resolved.
timeout-minutes: 10
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6

- name: Generate token for skills repo
id: skills-token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
with:
app-id: ${{ vars.RELEASE_CLIENT_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
owner: basecamp
repositories: skills

- name: Sync skills to distribution repo
id: sync
env:
SKILLS_TOKEN: ${{ steps.skills-token.outputs.token }}
RELEASE_TAG: ${{ github.ref_name }}
SOURCE_SHA: ${{ github.sha }}
run: scripts/sync-skills.sh

- name: Notify on sync failure
if: failure()
env:
GH_TOKEN: ${{ steps.skills-token.outputs.token }}
run: |
TITLE="Skills sync failure"
BODY="The automatic skills sync from [basecamp-cli ${{ github.ref_name }}](https://github.com/basecamp/basecamp-cli/actions/runs/${{ github.run_id }}) failed. Check the workflow run for details."

# Check for existing open issue before creating a new one
existing=$(gh issue list --repo basecamp/skills --state open --search "in:title $TITLE" --json number,title --jq '[.[] | select(.title == "'"$TITLE"'")][0].number // empty' 2>/dev/null || true)
if [ -n "$existing" ]; then
gh issue comment --repo basecamp/skills "$existing" --body "$BODY" || true
else
gh issue create --repo basecamp/skills --title "$TITLE" --body "$BODY" || true
fi

# Always emit annotation so the failure is visible in the workflow summary
echo "::error::Skills sync to basecamp/skills failed for ${{ github.ref_name }}. See https://github.com/basecamp/basecamp-cli/actions/runs/${{ github.run_id }}"
19 changes: 19 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,21 @@ tools:
@command -v jq >/dev/null 2>&1 || echo "NOTE: jq is also required (install via your package manager)"


# Sync skills to basecamp/skills distribution repo
# Usage: make sync-skills TAG=v1.2.3
.PHONY: sync-skills
sync-skills:
@test -n "$(TAG)" || (echo "Usage: make sync-skills TAG=v1.2.3" && exit 1)
RELEASE_TAG=$(TAG) SOURCE_SHA=$$(git rev-parse HEAD) DRY_RUN=local scripts/sync-skills.sh

# Sync skills (dry-run against real target repo)
# Usage: make sync-skills-remote TAG=v1.2.3 SKILLS_TOKEN=ghp_...
.PHONY: sync-skills-remote
sync-skills-remote:
@test -n "$(TAG)" || (echo "Usage: make sync-skills-remote TAG=v1.2.3 SKILLS_TOKEN=..." && exit 1)
Comment thread
jeremy marked this conversation as resolved.
@test -n "$(SKILLS_TOKEN)" || (echo "Usage: make sync-skills-remote TAG=v1.2.3 SKILLS_TOKEN=..." && exit 1)
RELEASE_TAG=$(TAG) SOURCE_SHA=$$(git rev-parse HEAD) DRY_RUN=remote SKILLS_TOKEN=$(SKILLS_TOKEN) scripts/sync-skills.sh

# Show help
.PHONY: help
help:
Expand Down Expand Up @@ -463,4 +478,8 @@ help:
@echo " fuzz Run fuzz tests (30s each)"
@echo " fuzz-quick Run quick fuzz tests (10s each, for CI)"
@echo ""
@echo "Skills:"
@echo " sync-skills Local dry-run of skill sync (TAG=v1.2.3)"
@echo " sync-skills-remote Remote dry-run (TAG=v1.2.3 SKILLS_TOKEN=...)"
@echo ""
@echo " help Show this help"
223 changes: 223 additions & 0 deletions scripts/sync-skills.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
#!/usr/bin/env bash
set -euo pipefail

# Sync skills from basecamp-cli to basecamp/skills distribution repo.
#
# Env vars:
# SKILLS_TOKEN - GitHub token with push access to basecamp/skills (required unless DRY_RUN=local)
# RELEASE_TAG - Release tag, e.g. v1.2.3 (required)
# SOURCE_SHA - Source commit SHA (required)
# DRY_RUN - Optional: "local" (no network) or "remote" (clone but skip push)

RELEASE_TAG="${RELEASE_TAG:?RELEASE_TAG is required}"
SOURCE_SHA="${SOURCE_SHA:?SOURCE_SHA is required}"
DRY_RUN="${DRY_RUN:-}"

SKILLS_SOURCE="skills"
TARGET_REPO="basecamp/skills"
TARGET_BRANCH="main"

# --- Helpers ---

die() { echo "ERROR: $*" >&2; exit 1; }

assert_remote_url() {
local url
url=$(git -C "$1" remote get-url origin)
local stripped
stripped=$(echo "$url" | sed -E 's/\.git$//')
# Validate host + owner/repo for both HTTPS and SSH forms
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
}

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'"
}

# --- Discover skills ---

skill_dirs=()
for skill_md in "$SKILLS_SOURCE"/*/SKILL.md; do
[[ -f "$skill_md" ]] || continue
skill_dirs+=("$(dirname "$skill_md")")
done

[[ ${#skill_dirs[@]} -gt 0 ]] || die "no skills found under $SKILLS_SOURCE/*/SKILL.md"
echo "Found ${#skill_dirs[@]} skill(s): ${skill_dirs[*]}"

# --- Copy skills into target, excluding *.go and dotfiles ---

copy_skills() {
local target_dir="$1"
for skill_dir in "${skill_dirs[@]}"; do
local name
name=$(basename "$skill_dir")
rm -rf "${target_dir:?}/${name}"
mkdir -p "$target_dir/$name"
# Copy files, excluding *.go and dotfiles
find "$skill_dir" -mindepth 1 \
! -name '*.go' \
! -name '.*' \
! -path '*/.*' \
-type f \
-exec bash -c '
src="$1"; skill_dir="$2"; target_dir="$3"
rel="${src#$skill_dir/}"
mkdir -p "$(dirname "$target_dir/$rel")"
cp "$src" "$target_dir/$rel"
' _ {} "$skill_dir" "$target_dir/$name" \;
done
}

# --- DRY_RUN=local: copy into tmpdir, diff against empty baseline ---

if [[ "$DRY_RUN" == "local" ]]; then
tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT
echo "DRY_RUN=local: copying skills into $tmpdir"
copy_skills "$tmpdir"
echo ""
echo "=== Skills copied ==="
find "$tmpdir" -type f | sort | while read -r f; do
echo " ${f#$tmpdir/}"
done
echo ""
echo "=== Diff (against empty baseline) ==="
# Initialize as empty git repo to get a clean diff
git -C "$tmpdir" init -q
git -C "$tmpdir" add -A
git -C "$tmpdir" diff --cached --stat
echo ""
echo "DRY_RUN=local complete. No network operations performed."
exit 0
fi

# --- Clone target repo ---

[[ -n "${SKILLS_TOKEN:-}" ]] || die "SKILLS_TOKEN is required (set DRY_RUN=local for offline testing)"

tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT

echo "Cloning $TARGET_REPO into $tmpdir/skills..."
git clone --depth 1 --branch "$TARGET_BRANCH" \
"https://x-access-token:${SKILLS_TOKEN}@github.com/${TARGET_REPO}.git" \
"$tmpdir/skills"

target="$tmpdir/skills"

# --- Safety checks ---

assert_remote_url "$target"
assert_branch "$target"

# --- Copy skills ---

echo "Copying skills into target..."
copy_skills "$target"

# --- Remove stale skills using manifest ---
# .managed-skills lists skill names owned by this sync. Only those are
# candidates for deletion — other content in the target repo is untouched.

MANIFEST=".managed-skills"
source_skill_names=()
for skill_dir in "${skill_dirs[@]}"; do
source_skill_names+=("$(basename "$skill_dir")")
done

previously_managed_names=()
if [[ -f "$target/$MANIFEST" ]]; then
while IFS= read -r entry; do
[[ -z "$entry" ]] && continue
if [[ "$entry" == "." || "$entry" == ".." || ! "$entry" =~ ^[a-zA-Z0-9._-]+$ ]]; then
echo "WARNING: skipping invalid manifest entry: $entry" >&2
continue
fi
previously_managed_names+=("$entry")
done < "$target/$MANIFEST"
else
# No manifest yet (first run). basecamp-cli is the source of truth, so
# treat all */SKILL.md dirs in the target as managed. Any skill not in the
# source set will be removed. Use DRY_RUN=remote to preview before pushing.
for candidate in "$target"/*/SKILL.md; do
[[ -f "$candidate" ]] || continue
previously_managed_names+=("$(basename "$(dirname "$candidate")")")
done
fi

for previously_managed in "${previously_managed_names[@]}"; do
found=0
for name in "${source_skill_names[@]}"; do
[[ "$name" == "$previously_managed" ]] && found=1 && break
done
if [[ "$found" -eq 0 && -d "$target/$previously_managed" ]]; then
echo "Removing stale skill: $previously_managed"
rm -rf "${target:?}/$previously_managed"
fi
done

# Write current manifest
printf '%s\n' "${source_skill_names[@]}" | sort > "$target/$MANIFEST"

# --- 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

echo ""
echo "=== Changes ==="
git -C "$target" diff --cached --stat
echo ""

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 -C "$target" \
-c user.name="basecamp-cli[bot]" \
-c user.email="basecamp-cli[bot]@users.noreply.github.com" \
commit -m "$(cat <<EOF
Sync skills from basecamp-cli ${RELEASE_TAG}

Source: basecamp/basecamp-cli@${SOURCE_SHA}
EOF
)"

# --- Push (with one retry on non-fast-forward) ---

push_target() {
git -C "$target" push origin "$TARGET_BRANCH" 2>&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 ""
echo "Skills synced to $TARGET_REPO ($TARGET_BRANCH) from $RELEASE_TAG"
Loading