From a718d229009c8ade77b428eaac67af80d220648f Mon Sep 17 00:00:00 2001 From: dtzp555 Date: Sun, 24 May 2026 20:41:45 +1000 Subject: [PATCH] =?UTF-8?q?ci:=20D37=20=E2=80=94=20release.yml=20phase=5Fr?= =?UTF-8?q?olling=5Fmode=20gate=20(issue=20#17)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-6 F14: release.yml verified package.json/tag version match and extracted the matching CHANGELOG section, but did not verify that "## Unreleased" had been promoted to "## v" before the tag push. If someone tagged v0.1.0-bootstrap today, release.yml would extract the stale ## v0.1.0-bootstrap section, ignoring all D-day work folded under Unreleased — the exact failure mode documented in MEMORY.md 2026-04-21 ("release.yml would publish stale notes that ignored Unreleased amendments"). Option A applied: CI gate makes the policy enforceable. Manual checklist (Option B) was rejected because it relies on human memory, which is what produced the original failure. New step "Enforce phase_rolling_mode (Unreleased must be promoted)" runs after version-match check and before CHANGELOG extraction: 1. Extracts content between "## Unreleased" heading and the next "## " heading via awk. 2. Strips blank lines and parenthetical-sentinel lines via sed (acceptable forms: "(empty — Phase N entries land here once Phase N opens)", "(another sentinel)", multi-sentinel blocks). 3. If any non-trivial content remains, exits with ::error:: instructing the maintainer to promote Unreleased → ## v per CLAUDE.md release_kit.phase_rolling_mode. Locally dry-run against 4 cases: - Current CHANGELOG.md (sentinel-only Unreleased) → PASS - Synthetic CHANGELOG with bullet-list under Unreleased → gate FIRES, reports offending lines indented for readability - Synthetic CHANGELOG with no Unreleased section → PASS - Synthetic CHANGELOG with multiple parenthetical sentinels and intervening blank lines → PASS Authority: - CLAUDE.md release_kit.phase_rolling_mode (D33 F11 added the policy; this gate enforces it) - MEMORY.md 2026-04-21 OCP cross-machine sync entry (documents the same class of failure as a prior precedent) - Round-6 cold-audit Finding 14 Gate is purely additive — adds a check, does not modify existing release behavior. Fires only on tag push to v*.*.* — does not affect normal push/PR CI. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/release.yml | 38 +++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5227335..84565e0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,6 +36,44 @@ jobs: fi echo "Tag v${TAG_VERSION} matches package.json version ${PKG_VERSION}." + - name: Enforce phase_rolling_mode (Unreleased must be promoted) + shell: bash + run: | + set -euo pipefail + if [ ! -f CHANGELOG.md ]; then + echo "::warning::CHANGELOG.md not found; skipping phase_rolling_mode gate." + exit 0 + fi + # Per CLAUDE.md release_kit.phase_rolling_mode: a Phase-close PR must + # promote "## Unreleased" → "## v" before the tag is pushed. + # This gate catches the failure mode where someone tags without + # promoting — release.yml would otherwise extract a stale + # "## v" section and ignore D-day work folded into Unreleased. + # + # An "Unreleased" section is considered trivial (acceptable) when its + # body is empty or contains only blank lines and parenthetical sentinels + # like "(empty — Phase N entries land here once Phase N opens)". Any + # other line (bullet, paragraph, sub-heading) is treated as unpromoted + # content → the gate fires. + UNRELEASED_BODY="$(awk ' + /^## Unreleased$/ { found=1; next } + found && /^## / { exit } + found { print } + ' CHANGELOG.md)" + if [ -z "$UNRELEASED_BODY" ]; then + echo "No ## Unreleased section found — gate passes." + exit 0 + fi + # Strip blank lines and parenthetical-sentinel-only lines. + NON_TRIVIAL="$(printf '%s\n' "$UNRELEASED_BODY" \ + | sed -E '/^[[:space:]]*$/d; /^[[:space:]]*\(.*\)[[:space:]]*$/d')" + if [ -n "$NON_TRIVIAL" ]; then + echo "::error::CHANGELOG.md ## Unreleased section is non-trivial but tag v${{ steps.ver.outputs.version }} was pushed. Per CLAUDE.md release_kit.phase_rolling_mode, promote Unreleased → ## v before tagging. Offending content:" + printf '%s\n' "$NON_TRIVIAL" | sed 's/^/ /' + exit 1 + fi + echo "## Unreleased section is empty or sentinel-only — gate passes." + - name: Extract CHANGELOG section id: notes shell: bash