Found from the other end: the koala cluster's CIHeadUncovered{repo=skills}
alert had been firing for 85 hours, and it is a true positive.
mathias/skills main HEAD f031c2b
workflow runs for that sha 0
Not a one-off. The previous release-bump commit has the same property:
6cc224f chore(release): plugin.json version 0.19.4 -> 0 runs
f031c2b chore(release): plugin.json version 0.19.5 -> 0 runs
The `tag` job pushes the manifest bump itself with secrets.GITEA_TOKEN, and a
push authenticated with the Actions token does not trigger workflows -- that is
deliberate platform behaviour to stop CI recursing. The `Skip-Release: true`
trailer stops the TAGGER recursing and works; nothing stopped the whole run,
`gate` included, from never firing on that commit.
So after every release, main's HEAD is a commit no CI has seen, until an
unrelated human push moves it. CIHeadUncovered therefore cannot clear on its
own -- and an alert that can only be cleared by doing something unrelated is
worse than a noisy one, because it teaches the reader that red is the normal
state of that rule.
The commit is not cosmetic either. 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 that writes it already shipped a mismatched
manifest once -- v0.10.0 tagged with the manifest still reading 0.9.0, per the
comment above that very loop.
Fix: run `task check` on the bump commit before pushing it. The commit is
produced in that step, so it can be validated in that step -- one check instead
of a second full CI run, and no recursion risk to reintroduce.
Considered and rejected: re-triggering CI with a different credential after the
push (costs a full run per release and brings back what the token behaviour
exists to prevent), and teaching CIHeadUncovered to ignore manifest-only
commits (narrowing the measure to fit the system, and it would hide a real gap
too).
The assertion in tests/ci_gate_test.sh had to be anchored with ^[^#]*: the tag
job's own prose says "`task check` asserts the manifest is not behind the
latest tag", so a bare grep matched the comment explaining the problem and
reported the problem fixed.
Closes #46.
172 lines
7.1 KiB
YAML
172 lines
7.1 KiB
YAML
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
|