fix(v3.9.4.2): harden 4 CI discipline gates from PR #149 codex post-ship review

Codex post-ship review on the PR #149 squash (commit be49a42) surfaced 4 P2
findings where the new gates would silently fail to enforce their intent.
Fixes:

F1 — harness-retirement-monthly.yml: scheduled runs lacked repo context for
`gh issue create`, so the monthly audit issue could not be opened.
Add `GH_REPO: ${{ github.repository }}`.

F2 — release-cooldown.yml: PREV_TAG lookup walked all tags, so a non-release
tag (e.g., the legacy `academic-research-skills--v3.7.0` plugin tag) could
shadow the previous release and bypass classification. Filter to `v*` tags.

F3 — release-cooldown.yml: hotfix detection read only the pointed commit
subject with a regex requiring a 4-segment tag for the `hot-fix` spelling.
v3.9.2 (subject: "Phase scope inflation hot-fix") was classified as
non-hotfix under the old logic. Now also reads annotated tag subject via
`git for-each-ref` and accepts both `hotfix` and `hot-fix` spellings.

F4 — test-count-monotonic.yml: `pytest --collect-only -q | grep -c '::'`
swallowed pytest's non-zero exit on collection errors. A broken import
during collection could still produce a count high enough to pass the
monotonic check. Run pytest separately, fail on any non-zero exit other
than 5 (no tests collected), then count from the captured output.

Smoke test (local, against repo's real tags) confirms:
- v3.9.4.1 → hotfix=true (4-segment + commit + annotated tag all match)
- v3.9.2 → hotfix=true (now caught via `hot-fix` spelling; old regex missed)
- v3.9.3, v3.9.4, v3.8.0 → hotfix=false (correct, normal releases)
- `git tag -l 'v*'` excludes the 1 known non-release tag

Closes #152.
This commit is contained in:
Imbad0202
2026-05-19 11:21:50 +08:00
parent be49a42940
commit 8121dfaa69
3 changed files with 47 additions and 8 deletions
@@ -37,6 +37,7 @@ jobs:
- name: Open audit tracking issue
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
MONTH: ${{ steps.month.outputs.label }}
run: |
set -euo pipefail
+17 -6
View File
@@ -37,22 +37,33 @@ jobs:
echo "New tag: $NEW_TAG"
# Find previous tag (by creator date, exclude current)
PREV_TAG=$(git tag --sort=-creatordate | grep -v "^${NEW_TAG}$" | head -1 || true)
# Find previous release tag (v*, by creator date, exclude current).
# Filtering to v* avoids non-release tags (e.g., test, internal markers)
# being picked as PREV_TAG and bypassing the cooldown classification.
PREV_TAG=$(git tag -l 'v*' --sort=-creatordate | grep -v "^${NEW_TAG}$" | head -1 || true)
if [ -z "$PREV_TAG" ]; then
echo "No previous tag — first release, skip cooldown."
echo "No previous release tag — first release, skip cooldown."
exit 0
fi
echo "Previous tag: $PREV_TAG"
# Was the previous tag a hotfix?
# Heuristic: tag name has 4 dot-segments (v3.9.4.1) OR pointed commit subject matches hotfix pattern.
PREV_SUBJECT=$(git log -1 --format=%s "$PREV_TAG")
# Three signals (any one is sufficient):
# a) Tag name has 4 dot-segments (e.g., v3.9.4.1)
# b) Pointed commit subject matches hotfix pattern
# c) Annotated tag subject (if any) matches hotfix pattern
# The regex accepts both 'hotfix' and 'hot-fix' spellings.
PREV_COMMIT_SUBJECT=$(git log -1 --format=%s "$PREV_TAG")
PREV_TAG_SUBJECT=$(git for-each-ref "refs/tags/${PREV_TAG}" --format='%(subject)')
HOTFIX_REGEX='(hot-?fix|post-ship|^fix\(v)'
IS_HOTFIX="false"
if [[ "$PREV_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
IS_HOTFIX="true"
fi
if echo "$PREV_SUBJECT" | grep -qiE '(hotfix|post-ship|^fix\(v)'; then
if echo "$PREV_COMMIT_SUBJECT" | grep -qiE "$HOTFIX_REGEX"; then
IS_HOTFIX="true"
fi
if [ -n "$PREV_TAG_SUBJECT" ] && echo "$PREV_TAG_SUBJECT" | grep -qiE "$HOTFIX_REGEX"; then
IS_HOTFIX="true"
fi
echo "Previous tag classified as hotfix: $IS_HOTFIX"
+29 -2
View File
@@ -50,7 +50,23 @@ jobs:
id: head
run: |
set -euo pipefail
COUNT=$(pytest --collect-only -q 2>/dev/null | grep -c '::' || true)
# Run pytest collection separately so collection errors fail the gate.
# Without this, `pytest ... | grep -c` would mask non-zero pytest exits
# (e.g., import errors during collection) and let a broken PR pass.
set +e
pytest --collect-only -q > pytest-collect.txt 2> pytest-collect.err
PYTEST_EXIT=$?
set -e
# Tolerate exit 5 (no tests collected — empty repo edge case);
# any other non-zero exit is a real collection failure.
if [ "$PYTEST_EXIT" -ne 0 ] && [ "$PYTEST_EXIT" -ne 5 ]; then
echo "::error::pytest collection failed on head (exit $PYTEST_EXIT)"
cat pytest-collect.err >&2 || true
tail -20 pytest-collect.txt >&2 || true
exit 1
fi
# grep -c returns 1 when no matches — tolerate only that case.
COUNT=$(grep -c '::' pytest-collect.txt || true)
echo "Head test count: $COUNT"
echo "count=$COUNT" >> "$GITHUB_OUTPUT"
@@ -76,7 +92,18 @@ jobs:
working-directory: ../base
run: |
set -euo pipefail
COUNT=$(pytest --collect-only -q 2>/dev/null | grep -c '::' || true)
# Same discipline as the head count: collection errors fail the gate.
set +e
pytest --collect-only -q > pytest-collect.txt 2> pytest-collect.err
PYTEST_EXIT=$?
set -e
if [ "$PYTEST_EXIT" -ne 0 ] && [ "$PYTEST_EXIT" -ne 5 ]; then
echo "::error::pytest collection failed on base (exit $PYTEST_EXIT)"
cat pytest-collect.err >&2 || true
tail -20 pytest-collect.txt >&2 || true
exit 1
fi
COUNT=$(grep -c '::' pytest-collect.txt || true)
echo "Base test count: $COUNT"
echo "count=$COUNT" >> "$GITHUB_OUTPUT"