chore(ci): gate the release tagger on check + test
The repo had a `task check` structure gate and a 28-case shell test suite that nothing ever ran. The only workflow auto-tagged every push to main, so CI reported green having tested nothing — a gate that fails open, and the reason `mathias/skills` shows up as repository-blocked in the estate backlog classification. release.yml becomes ci.yml with two jobs: `gate` runs check + test on every push and PR; `tag` keeps the existing semver-from-trailer logic but declares `needs: gate`, so a red check cannot ship a tag. The pull_request trigger also gives `tbd_ship` a CI run to read on a branch head — without one it fails closed and never merges (#22). The tagger now pushes the manifest bump commit and the tag in a single `git push`. Two pushes left a window where the gate ran `task check` on the bump commit before the tag existed, and the manifest/tag parity assertion would fail on a commit that was in fact correct. tests/ci_gate_test.sh pins the wiring: PR trigger present, both tasks run, tag needs gate, no second ungated workflow file. Refs: #22 Bump-Type: minor
This commit is contained in:
@@ -1,23 +1,52 @@
|
|||||||
name: release
|
name: CI
|
||||||
|
|
||||||
# Auto-tag every push to main with a semver bump derived from the latest
|
# 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:
|
# commit footer:
|
||||||
# Bump-Type: major → vX.0.0
|
# Bump-Type: major → vX.0.0
|
||||||
# Bump-Type: minor → v0.Y.0
|
# Bump-Type: minor → v0.Y.0
|
||||||
# (default) → v0.0.Z (patch)
|
# (default) → v0.0.Z (patch)
|
||||||
#
|
# Skips when the commit carries the `Skip-Release: true` trailer (the
|
||||||
# Skips when the latest commit is itself the auto-tagger (commit message
|
# tagger's own manifest bump) to prevent recursion. Gated on `gate`,
|
||||||
# contains "[skip-release]") to prevent recursion. Tag is pushed back
|
# so a red check cannot ship a tag.
|
||||||
# to the repo using the GITEA_TOKEN provided by act_runner.
|
|
||||||
|
|
||||||
on:
|
"on":
|
||||||
push:
|
push:
|
||||||
branches:
|
branches: ["**"]
|
||||||
- main
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
tag:
|
gate:
|
||||||
|
name: Check / Test
|
||||||
runs-on: self-hosted
|
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:
|
steps:
|
||||||
- name: checkout
|
- name: checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -72,16 +101,19 @@ jobs:
|
|||||||
manifest=.claude-plugin/plugin.json
|
manifest=.claude-plugin/plugin.json
|
||||||
version="${TAG#v}"
|
version="${TAG#v}"
|
||||||
current=$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$manifest" | head -1)
|
current=$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$manifest" | head -1)
|
||||||
|
git tag -a "$TAG" -m "release $TAG"
|
||||||
|
|
||||||
if [ "$current" != "$version" ]; then
|
if [ "$current" != "$version" ]; then
|
||||||
sed -i "s/\"version\"[[:space:]]*:[[:space:]]*\"$current\"/\"version\": \"$version\"/" "$manifest"
|
sed -i "s/\"version\"[[:space:]]*:[[:space:]]*\"$current\"/\"version\": \"$version\"/" "$manifest"
|
||||||
git add "$manifest"
|
git add "$manifest"
|
||||||
# The trailer below stops this push from re-triggering the tagger.
|
# 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 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"
|
echo "bumped $manifest to $version"
|
||||||
fi
|
else
|
||||||
|
|
||||||
git tag -a "$TAG" -m "release $TAG"
|
|
||||||
# gitea-actions checkout uses an x-access-token URL; reuse it.
|
|
||||||
git push origin "$TAG"
|
git push origin "$TAG"
|
||||||
|
fi
|
||||||
echo "pushed $TAG"
|
echo "pushed $TAG"
|
||||||
@@ -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 ]
|
||||||
Reference in New Issue
Block a user