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" # Claude Code gates plugin updates on plugin.json's version field: # if it does not move, installed hosts never see the new tag. Bump # it in the same commit train so the tag and the manifest agree — # `task check` enforces that equality on every run. manifest=.claude-plugin/plugin.json version="${TAG#v}" current=$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$manifest" | head -1) git tag -a "$TAG" -m "release $TAG" if [ "$current" != "$version" ]; then 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" # Commit and tag go up in ONE push: the gate job runs `task check` # on the bump commit, and that asserts manifest == latest tag. Two # separate pushes leave a window where it legitimately fails. git push origin HEAD:main "$TAG" echo "bumped $manifest to $version" else git push origin "$TAG" fi echo "pushed $TAG"