diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/ci.yml similarity index 63% rename from .gitea/workflows/release.yml rename to .gitea/workflows/ci.yml index 9ffcdbc..55b87f7 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/ci.yml @@ -1,23 +1,52 @@ -name: release +name: CI -# Auto-tag every 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) +# Two jobs, in order: # -# Skips when the latest commit is itself the auto-tagger (commit message -# contains "[skip-release]") to prevent recursion. Tag is pushed back -# to the repo using the GITEA_TOKEN provided by act_runner. +# 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: +"on": push: - branches: - - main + branches: ["**"] + pull_request: + branches: [main] jobs: - tag: + 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 @@ -72,16 +101,19 @@ jobs: 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" - git push origin HEAD:main + # 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 - - git tag -a "$TAG" -m "release $TAG" - # gitea-actions checkout uses an x-access-token URL; reuse it. - git push origin "$TAG" echo "pushed $TAG" diff --git a/tests/ci_gate_test.sh b/tests/ci_gate_test.sh new file mode 100644 index 0000000..c410955 --- /dev/null +++ b/tests/ci_gate_test.sh @@ -0,0 +1,57 @@ +#!/bin/sh +# Tests for the CI gate workflow. +# +# The regression these exist for: this repo had a release workflow that +# auto-tagged every push to main, and a `task check` / `task test` suite that +# nothing ever ran. CI reported green having tested nothing — a gate that +# fails *open*. These assertions pin the wiring so it cannot silently regress. + +set -u + +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +SUT="$ROOT/.gitea/workflows/ci.yml" + +pass=0 +fail=0 + +ok() { pass=$((pass + 1)); printf ' ok %s\n' "$1"; } +no() { fail=$((fail + 1)); printf ' FAIL %s\n' "$1"; } + +if [ ! -f "$SUT" ]; then + printf 'FAIL: %s does not exist\n' "$SUT" + exit 1 +fi +ok 'ci.yml exists' + +# A pull_request trigger is what gives tbd_ship a CI run to read on the head +# commit of a branch; without one it fails closed and never merges (#22). +grep -qE '^ pull_request:' "$SUT" \ + && ok 'runs on pull_request' \ + || no 'runs on pull_request' + +grep -qE '^ +push:' "$SUT" \ + && ok 'runs on push' \ + || no 'runs on push' + +grep -qE '^ +- *(run: *)?task check$|task check' "$SUT" \ + && ok 'gate runs task check' \ + || no 'gate runs task check' + +grep -qE 'task test' "$SUT" \ + && ok 'gate runs task test' \ + || no 'gate runs task test' + +# The point of the gate: tagging must not happen on a red check. +tag_job=$(awk '/^ tag:/{f=1} f&&/^ [a-z][a-z_-]*:/&&!/^ tag:/{f=0} f' "$SUT") +printf '%s' "$tag_job" | grep -qE '^ +needs: *gate' \ + && ok 'tag job needs the gate' \ + || no 'tag job needs the gate' + +# The old workflow file must be gone, or two workflows race on the same push +# and the ungated one still tags. +[ ! -f "$ROOT/.gitea/workflows/release.yml" ] \ + && ok 'ungated release.yml removed' \ + || no 'ungated release.yml removed' + +printf '\nci_gate: %d passed, %d failed\n' "$pass" "$fail" +[ "$fail" -eq 0 ]