name: CI # Two jobs, in order: # # gate — `task check` (structure, frontmatter, manifest/tag parity) plus # `task test` (hooks, doctor, release-skip). Runs on every push and # every PR. Before this existed the suite ran nowhere: CI reported # green having tested nothing (#22). # # tag — auto-tags a push to main with a semver bump derived from the latest # commit footer: # Bump-Type: major → vX.0.0 # Bump-Type: minor → v0.Y.0 # (default) → v0.0.Z (patch) # Skips when the commit carries the `Skip-Release: true` trailer (the # tagger's own manifest bump) to prevent recursion. Gated on `gate`, # so a red check cannot ship a tag. "on": push: branches: ["**"] pull_request: branches: [main] jobs: gate: name: Check / Test runs-on: self-hosted steps: - uses: actions/checkout@v4 with: # `task check` compares plugin.json's version to `git describe`; # a shallow checkout has no tags and would skip that assertion. fetch-depth: 0 - name: Verify toolchain run: task --version - name: Structure gate run: task check - name: Shell test suite run: task test tag: name: Tag release needs: gate runs-on: self-hosted if: github.event_name == 'push' && github.ref == 'refs/heads/main' steps: - name: checkout uses: actions/checkout@v4 with: fetch-depth: 0 token: ${{ secrets.GITEA_TOKEN }} - name: derive next version id: bump run: | set -euo pipefail last_commit=$(git log -1 --pretty=%B) # Delegated to bin/should-skip-release so the rule is covered by # tests/release_skip_test.sh rather than living untested in YAML. if printf '%s\n' "$last_commit" | sh bin/should-skip-release; then echo "skipping — commit carries the Skip-Release trailer" echo "skip=true" >> "$GITHUB_OUTPUT" exit 0 fi latest=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") major=$(echo "$latest" | sed -E 's/^v([0-9]+)\.([0-9]+)\.([0-9]+)$/\1/') minor=$(echo "$latest" | sed -E 's/^v([0-9]+)\.([0-9]+)\.([0-9]+)$/\2/') patch=$(echo "$latest" | sed -E 's/^v([0-9]+)\.([0-9]+)\.([0-9]+)$/\3/') bump=$(printf '%s' "$last_commit" | grep -iE '^Bump-Type:' | head -1 | awk '{print tolower($2)}' || true) case "$bump" in major) major=$((major + 1)); minor=0; patch=0 ;; minor) minor=$((minor + 1)); patch=0 ;; *) patch=$((patch + 1)) ;; esac next="v${major}.${minor}.${patch}" echo "previous=$latest" echo "bump=${bump:-patch}" echo "next=$next" echo "tag=$next" >> "$GITHUB_OUTPUT" echo "skip=false" >> "$GITHUB_OUTPUT" - name: tag + push if: steps.bump.outputs.skip != 'true' env: TAG: ${{ steps.bump.outputs.tag }} GITEA_ACTOR: ${{ gitea.actor }} GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} run: | set -euo pipefail git config user.name "${GITEA_ACTOR}" git config user.email "${GITEA_ACTOR}@noreply.gitea.d-ma.be" # The tag goes up FIRST and on its own. It names the commit this run # checked out and can never be rejected, however far main has moved. git tag -a "$TAG" -m "release $TAG" git push origin "$TAG" echo "pushed $TAG" # The manifest bump is a separate concern and must land on whatever # main is NOW. The runner is maxParallel=1 and shared with other # repos, so a job routinely starts several commits after its own — # pushing this run's stale HEAD to main is then rejected # non-fast-forward, which is how v0.10.0 ended up tagged with the # manifest still reading 0.9.0. # # Claude Code gates plugin updates on this field: if it does not # move, installed hosts never see the release. `task check` asserts # the manifest is not behind the latest tag, so a failure here is a # red main, not a cosmetic miss. manifest=.claude-plugin/plugin.json version="${TAG#v}" for attempt in 1 2 3; do git fetch --quiet origin main git checkout --quiet -B release-bump origin/main current=$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$manifest" | head -1) if [ "$current" = "$version" ]; then echo "manifest already at $version" exit 0 fi # A later run may have bumped past us while this one waited. if [ "$(sh bin/newer-version "$current" "$version")" = "$current" ]; then echo "manifest is $current, already newer than $version — leaving it" exit 0 fi sed -i "s/\"version\"[[:space:]]*:[[:space:]]*\"$current\"/\"version\": \"$version\"/" "$manifest" git add "$manifest" # The trailer below stops this push from re-triggering the tagger. git commit -m "chore(release): plugin.json version $version" -m "Skip-Release: true" # skills#46. This commit is pushed with secrets.GITEA_TOKEN, and a # push authenticated with the Actions token does NOT trigger # workflows -- deliberate platform behaviour to stop CI recursing. # `Skip-Release: true` stops the tagger recursing; nothing stopped # the whole run, `gate` included, from never firing on this commit. # # So every release left main's HEAD with zero CI runs. Measured: # 6cc224f (0.19.4) and f031c2b (0.19.5) each have none, and the # koala cluster's CIHeadUncovered{repo=skills} had been firing on it # for 85 hours -- an alert that can only be cleared by an unrelated # human push. # # The commit is not cosmetic: plugin.json's version is what Claude # Code gates plugin updates on, `task check` asserts it is not # behind the latest tag, and the sed-and-retry logic above already # shipped a mismatched manifest once (v0.10.0 reading 0.9.0). # # Gating it HERE rather than re-triggering CI afterwards: the commit # is produced here, so it can be validated here, at the cost of one # `task check` instead of a second full run -- and with no recursion # risk to reintroduce. if ! task check; then echo "::error::the release-bump commit fails task check -- not pushing it" >&2 exit 1 fi if git push origin HEAD:main; then echo "bumped $manifest to $version" exit 0 fi echo "main moved while bumping — retry $attempt" done echo "could not land the manifest bump after 3 attempts" >&2 exit 1