Skip to content
Merged
117 changes: 117 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -150,3 +150,120 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
AUR_KEY: ${{ secrets.AUR_KEY }}

# Mirrors skills/ into basecamp/skills, the distribution repo shared by every
# CLI. The script owns only this CLI's skills there (.managed-skills.fizzy-cli)
# and never touches a sibling's. continue-on-error: a stale skills repo must
# not fail a release that has already shipped; the failure step files an
# issue instead, and sync-skills.yml is the manual way back.
sync-skills:
name: Sync skills
needs: [release]
if: startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-')
Comment thread
jeremy marked this conversation as resolved.
continue-on-error: true
concurrency:
group: sync-skills
cancel-in-progress: false
runs-on: ubuntu-latest
environment: release
timeout-minutes: 10
permissions:
contents: read
issues: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

# An older stable release can reach this job after a newer one has
# published — its run stalled, or its failed sync was rerun later — and
# the script mirrors the skills wholesale, so it would roll basecamp/skills
# back. The concurrency group serialises the jobs but does not order them,
# so this is the check the manual Sync skills workflow makes: only the
# latest stable release publishes. A failed lookup fails the job, which
# files the issue below rather than skipping in silence.
- name: Confirm this is the latest stable release
id: latest
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.ref_name }}
run: |
latest=$(gh release view --repo "${GITHUB_REPOSITORY}" --json tagName --jq .tagName)
if [ "$TAG" = "$latest" ]; then
echo "publish=true" >> "$GITHUB_OUTPUT"
else
echo "::warning::Skipping the skills sync for ${TAG}: the latest stable release is ${latest}, and syncing an older tag would roll basecamp/skills back."
echo "publish=false" >> "$GITHUB_OUTPUT"
fi

- name: Generate token for skills repo
if: steps.latest.outputs.publish == 'true'
id: skills-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ vars.RELEASE_CLIENT_ID }}
Comment thread
jeremy marked this conversation as resolved.
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
owner: basecamp
repositories: skills
permission-contents: write

- name: Sync skills to distribution repo
if: steps.latest.outputs.publish == 'true'
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: ${{ github.token }}
REPO_SLUG: ${{ github.repository }}
REF_NAME: ${{ github.ref_name }}
RUN_ID: ${{ github.run_id }}
run: |
RUN_URL="https://github.com/${REPO_SLUG}/actions/runs/${RUN_ID}"
LABEL="skills-sync"
TITLE="Skills sync failure"
BODY="The automatic skills sync to basecamp/skills for [${REF_NAME}](${RUN_URL}) failed. Check the workflow run for details; once fixed, rerun the failed jobs in that run or dispatch the Sync skills workflow for ${REF_NAME}."

# Filed on this repo (the only one github.token can write to), one
# open issue per outage. Deduped by label, not title: a title lookup
# misses the moment someone retitles the issue while triaging it.
# The lookup fails closed — an API error must not read as "no open
# issue" and file a duplicate; the annotation below still records
# the failure.
notify_status=0
if ! matches=$(gh issue list --repo "$REPO_SLUG" --state open --label "$LABEL" \
--json number --jq '[.[].number] | join(" ")'); then
echo "::error::Could not list open '${LABEL}' issues in ${REPO_SLUG}. Filing nothing rather than risk a duplicate."
notify_status=2
else
read -ra issues <<< "$matches"
case "${#issues[@]}" in
0)
echo "No open '${LABEL}' issue; filing one."
# --force makes this idempotent, so the label exists before the
# first issue carries it (gh issue create refuses an unknown label).
gh label create "$LABEL" --repo "$REPO_SLUG" --force \
--color d93f0b --description "The release-time sync to basecamp/skills failed" \
&& gh issue create --repo "$REPO_SLUG" --title "$TITLE" --body "$BODY" --label "$LABEL" \
|| notify_status=$?
;;
1)
echo "Commenting on existing '${LABEL}' issue #${issues[0]}."
gh issue comment --repo "$REPO_SLUG" "${issues[0]}" --body "$BODY" || notify_status=$?
;;
*)
# Picking one arbitrarily would scatter one outage's history
# across issues a human already decided to keep separate.
echo "::error::${#issues[@]} open issues carry the '${LABEL}' label (${issues[*]}). Refusing to guess which one to update — consolidate them, then re-run."
notify_status=3
;;
esac
fi

echo "::error::Skills sync to basecamp/skills failed for ${REF_NAME}. See ${RUN_URL}"
exit "$notify_status"
1 change: 1 addition & 0 deletions .github/workflows/sensitive-change-gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ jobs:
with:
extra-patterns: |
scripts/publish-aur.sh
scripts/sync-skills.sh
permissions:
contents: read
pull-requests: write
119 changes: 119 additions & 0 deletions .github/workflows/sync-skills.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# Manual skills sync, for when the release-time sync could not run.
#
# The sync-skills job in release.yml is `needs: [release]`, so anything that
# fails the release job after publication skips the sync entirely and leaves
# basecamp/skills stale against a shipped release. A skipped or failed sync
# needs a way back without cutting a new tag. This is it.
#
# scripts/sync-skills.sh mirrors the skills/ tree at the given ref into
# basecamp/skills — only this CLI's skills, tracked in its own manifest there
# (.managed-skills.fizzy-cli); other CLIs' skills are never touched — and no-ops
# when the content already matches, so re-running it for an already-synced
# release is safe.
name: Sync skills

on:
workflow_dispatch:
inputs:
tag:
description: 'Release tag to sync from, with the v prefix (e.g. v4.1.0)'
required: true
type: string
dry_run:
description: 'Preview the sync without pushing'
required: false
default: false
type: boolean

permissions: {}

# Shared with release.yml's sync-skills job so a manual run cannot interleave
# with an automatic one.
concurrency:
group: sync-skills
cancel-in-progress: false

jobs:
sync:
name: Sync skills
runs-on: ubuntu-latest
timeout-minutes: 10
environment: release
permissions:
contents: read
steps:
- name: Validate tag input
env:
TAG: ${{ inputs.tag }}
GH_TOKEN: ${{ github.token }}
run: |
# No prerelease suffix: the automatic sync in release.yml excludes
# prereleases deliberately, and this path must not smuggle prerelease
# content into the distribution repo's main branch.
if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::Invalid tag '${TAG}' — expected a stable v-prefixed semver tag (e.g. v4.1.0)"
exit 1
fi

# sync-skills.sh mirrors this CLI's skills wholesale, so syncing an
# older tag would roll them back in basecamp/skills. The only reason to run this by
# hand is that the newest release failed to sync — so require exactly
# that release.
latest=$(gh release view --repo "${GITHUB_REPOSITORY}" --json tagName --jq .tagName)
if [ "$TAG" != "$latest" ]; then
echo "::error::Refusing to sync ${TAG}: the latest stable release is ${latest}. Syncing an older tag would roll basecamp/skills back."
exit 1
fi

# Two checkouts: sync logic from the dispatching ref, skills content from
# the release tag. A defect in scripts/sync-skills.sh is one of the ways
# the automatic sync fails, and running the tagged copy here would just
# re-run the defective script — recovery must be able to run a fix merged
# to main without cutting a new tag.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
Comment thread
jeremy marked this conversation as resolved.

# The content still mirrors the skills tree as it was released, even if
# main has moved on since: SKILLS_SOURCE points the script at this
# checkout. Fully qualified: actions/checkout resolves a bare name as a
# branch before a tag, so a branch sharing the tag's name would win and
# mirror unreleased content.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: refs/tags/${{ inputs.tag }}
path: release
persist-credentials: false

# Needed for dry runs too: the honest preview clones the target, so it
# cannot run tokenless. Dry runs get a read-only token, which is also what
# stops DRY_RUN=remote from pushing even if the script were wrong.
- name: Generate token for skills repo
id: skills-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ vars.RELEASE_CLIENT_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
owner: basecamp
repositories: skills
permission-contents: ${{ inputs.dry_run && 'read' || 'write' }}

# github.sha is the dispatching ref's SHA (main), not the tag's, so
# resolve the tagged commit from its own checkout — otherwise the sync
# records the wrong provenance for the release it claims to mirror.
- name: Resolve the tagged commit
id: source
env:
TAG: ${{ inputs.tag }}
run: echo "sha=$(git -C release rev-parse --verify "refs/tags/${TAG}^{commit}")" >> "$GITHUB_OUTPUT"

# DRY_RUN=remote clones the actual target and stops before the push, so
# the preview shows the real diff including deletions.
- name: Sync skills to distribution repo
env:
SKILLS_TOKEN: ${{ steps.skills-token.outputs.token }}
RELEASE_TAG: ${{ inputs.tag }}
SOURCE_SHA: ${{ steps.source.outputs.sha }}
SKILLS_SOURCE: release/skills
DRY_RUN: ${{ inputs.dry_run && 'remote' || '' }}
run: scripts/sync-skills.sh
3 changes: 3 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ jobs:
- name: Run tests
run: go test -v ./...

- name: Test the skills sync
run: make test-sync-skills

- name: Build
run: go build -o bin/fizzy ./cmd/fizzy

Expand Down
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ fizzy-cli/
│ ├── mcpserver/ # `fizzy mcp` MCP server (catalog/ synced from fizzy-mcp-server)
│ └── render/ # Output rendering (styled, markdown, columns)
├── e2e/ # Go integration tests
├── skills/ # Agent skills
├── skills/ # Agent skills (mirrored to basecamp/skills on release)
└── .claude-plugin/ # Claude Code integration
```

Expand Down Expand Up @@ -113,7 +113,7 @@ Token-based via personal access tokens. Run `fizzy setup` for interactive config

## Checks

`make check` runs `fmt-check vet lint tidy-check race-test`. There is no `surface-check`
`make check` runs `fmt-check vet lint tidy-check race-test test-sync-skills`. There is no `surface-check`
in that list, but the surface gate still runs: `race-test` is
`go test -race -count=1 ./internal/...`, which includes
`internal/commands.TestSurfaceSnapshot`.
Expand Down
13 changes: 9 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: test test-unit test-e2e e2e test-go test-file e2e-file test-run e2e-run build clean tidy help \
.PHONY: test test-unit test-sync-skills test-e2e e2e test-go test-file e2e-file test-run e2e-run build clean tidy help \
check-toolchain fmt fmt-check vet lint tidy-check race-test vuln secrets \
replace-check security check release-check release tools \
surface-snapshot surface-check lint-actions
Expand Down Expand Up @@ -28,6 +28,7 @@ help:
@echo "Usage:"
@echo " make build Build the CLI"
@echo " make test-unit Run unit tests (no API required)"
@echo " make test-sync-skills Test the skills sync script (no API required)"
@echo " make e2e Run owner-only CLI contract e2e tests"
@echo " make test-e2e Alias for e2e"
@echo " make test Alias for e2e"
Expand All @@ -50,7 +51,7 @@ help:
@echo ""
@echo " make lint-actions Lint GitHub Actions workflows"
@echo " make security lint + vuln + secrets"
@echo " make check fmt-check + vet + lint + test-unit + tidy-check"
@echo " make check fmt-check + vet + lint + tidy-check + race-test + test-sync-skills"
@echo " make release-check check + replace-check + vuln + race-test"
@echo " make release Run release preflight and tag"
@echo " make tools Install dev tools"
Expand Down Expand Up @@ -92,6 +93,10 @@ build: check-toolchain
test-unit: check-toolchain
go test -v ./internal/...

# Test the skills sync script against a throwaway basecamp/skills checkout (no network)
test-sync-skills:
EXPECTED_SOURCE=fizzy-cli scripts/test-sync-skills.sh

# Run e2e tests (requires API credentials)
e2e: build
@if [ -z "$$FIZZY_TEST_TOKEN" ]; then echo "Error: FIZZY_TEST_TOKEN not set"; exit 1; fi
Expand Down Expand Up @@ -175,8 +180,8 @@ replace-check:
# Security suite
security: lint vuln secrets

# Local CI gate (fmt, vet, lint, tidy, race-test)
check: fmt-check vet lint tidy-check race-test
# Local CI gate (fmt, vet, lint, tidy, race-test, skills sync test)
check: fmt-check vet lint tidy-check race-test test-sync-skills
Comment thread
jeremy marked this conversation as resolved.

# Release preflight
release-check: check replace-check vuln
Expand Down
Loading
Loading