Skip to content
Open
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
25 changes: 24 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ jobs:

- name: Run release quality gate
run: |
make check-release-lockstep fmt-check vet lint test test-e2e tidy-check check-surface
make check-release-lockstep fmt-check vet lint test test-e2e test-sync-skills tidy-check check-surface
go test -race -count=1 ./...

- name: Run govulncheck
Expand Down Expand Up @@ -565,7 +565,29 @@ jobs:
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Revalidate the latest release at publication time

When an older sync passes this check and then stalls during token generation or the sync script, a newer tag's independent release job can publish in the meantime; its sync job merely queues behind the older sync because only the sync jobs share the concurrency group. The older job then resumes and pushes its outdated skills even though this snapshot check previously succeeded. The gh release view documentation confirms that omitting a tag returns whichever release is latest at command execution, so it provides no protection after this step; revalidate immediately before the guarded push or serialize release publication with syncing.

Useful? React with 👍 / 👎.

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:
Expand All @@ -576,6 +598,7 @@ jobs:
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 }}
Expand Down
10 changes: 6 additions & 4 deletions .github/workflows/sync-skills.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@
# 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 and no-ops when the content already matches, so re-running it
# for an already-synced release is safe.
# basecamp/skills — only this CLI's skills, tracked in its own manifest there
# (.managed-skills.hey-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:
Expand Down Expand Up @@ -53,8 +55,8 @@ jobs:
exit 1
fi

# sync-skills.sh mirrors the tree wholesale, so syncing an older tag
# would roll basecamp/skills back. The only reason to run this by
# 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)
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,9 @@ jobs:
- name: Run bats suite
run: make test-e2e

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

installer-bash32:
name: Installer (bash 3.2)
runs-on: macos-latest
Expand Down
11 changes: 8 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: build test test-unit test-e2e test-smoke preview-callback coverage fmt fmt-check vet lint tidy tidy-check \
.PHONY: build test test-unit test-e2e test-sync-skills test-smoke preview-callback coverage fmt fmt-check vet lint tidy tidy-check \
race-test vuln gosec secrets replace-check check-toolchain check security \
release-check release test-release bench bench-save bench-compare \
check-surface update-surface check-surface-compat check-size check-lint-lockstep \
Expand All @@ -25,6 +25,7 @@ help:
@echo " make test-unit Run unit tests"
@echo " make test Alias for test-unit"
@echo " make test-e2e Run the bats suite (installer and script contracts)"
@echo " make test-sync-skills Run the skills sync as two CLIs against a throwaway target"
@echo " make test-smoke Run smoke tests against a live server"
@echo " make preview-callback Preview the OAuth callback screens in a browser"
@echo " make coverage Run cross-package coverage and enforce the 70.8% floor"
Expand All @@ -43,7 +44,7 @@ help:
@echo " make secrets Run gitleaks secret scan"
@echo " make replace-check Guard against replace directives in go.mod"
@echo ""
@echo " make check fmt-check + vet + lint + test-unit + tidy-check"
@echo " make check fmt-check + vet + lint + test-unit + test-sync-skills + tidy-check"
@echo " make security lint + vuln + gosec + secrets"
@echo " make release-check check + replace-check + vuln + gosec + race-test"
@echo " make release Run release preflight and tag (VERSION=v1.2.3 [DRY_RUN=1])"
Expand Down Expand Up @@ -103,6 +104,10 @@ preview-callback: check-toolchain
test-e2e:
@./tests/e2e/run.sh

# Run the skills sync against a throwaway basecamp/skills, as two CLIs publishing in turn
test-sync-skills:
EXPECTED_SOURCE=hey-cli scripts/test-sync-skills.sh

# Run smoke tests against a live HEY server.
# Requires: a running server (default http://app.hey.localhost:3003) and Chrome.
# Override defaults: make test-smoke HEY_SMOKE_BASE_URL=... HEY_SMOKE_EMAIL=... HEY_SMOKE_PASSWORD=...
Expand Down Expand Up @@ -171,7 +176,7 @@ replace-check:
fi

# Local CI gate
check: fmt-check vet lint test-unit tidy-check check-surface check-release-lockstep
check: fmt-check vet lint test-unit test-sync-skills tidy-check check-surface check-release-lockstep

# Verify every workflow lints with the same golangci-lint version
check-lint-lockstep:
Expand Down
31 changes: 24 additions & 7 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,13 +216,30 @@ account, store the private key as `AUR_KEY`.

## Skills sync

Stable releases mirror `skills/` into `basecamp/skills`. If that job fails, a
`skills-sync`-labeled issue is filed; recover with the `Sync skills` workflow
(`workflow_dispatch`, stable tag, optional dry run). It refuses anything but the
latest stable release so it cannot roll the distribution repo back, and it runs
the sync script from the dispatching branch against the tag's skills tree — so
when the failure was a defect in `sync-skills.sh` itself, merge the fix to main
and dispatch; no new release needed.
Stable releases mirror `skills/` into [basecamp/skills](https://github.com/basecamp/skills),
which several CLIs share. `scripts/sync-skills.sh` owns only this CLI's skills there:
it records the names it published in `.managed-skills.hey-cli` at the target root and
removes a `skills/<name>` only when that manifest lists it, the release no longer ships
it, and no other CLI's `.managed-skills.*` claims it (a collision is warned about and
left alone), and refuses outright to publish a name another CLI's manifest holds. A
target with no `.managed-skills.hey-cli` yet is a first run: nothing is removed. A push
rejected because another CLI published first is retried by applying the whole sync
again from the remote's new tip, not by replaying the stale commit. The legacy shared `.managed-skills` is rewritten as a comment-only tombstone
so a CLI still on the pre-fix script — which deleted everything its own tree lacked —
deletes nothing (basecamp/skills#5). The script always clones the target fresh and pushes
only the commit it made, so there is no checkout to hand it. `scripts/test-sync-skills.sh`
(`make test-sync-skills`, in `make check`) pins the contract by running the script as both
CLIs against a local bare repository — real clones, commits and pushes, no network.

If that job fails, a `skills-sync`-labeled issue is filed; recover with the
`Sync skills` workflow (`workflow_dispatch`, stable tag, optional dry run). It
refuses anything but the latest stable release so it cannot roll the distribution
repo back, and it runs the sync script from the dispatching branch against the
tag's skills tree — so when the failure was a defect in `sync-skills.sh` itself,
merge the fix to main and dispatch; no new release needed. The release-time job makes
the same check before it publishes, so an older release whose run stalls, or whose
failed sync is rerun after a newer release has shipped, skips the sync instead of
rolling it back.

## Local dry runs

Expand Down
Loading
Loading