mirror of
https://github.com/dtzp555-max/olp.git
synced 2026-07-21 21:15:10 +00:00
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<version>" 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<version>
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 <noreply@anthropic.com>
112 lines
4.3 KiB
YAML
112 lines
4.3 KiB
YAML
name: Auto Release on Tag
|
|
|
|
on:
|
|
push:
|
|
tags:
|
|
- 'v*.*.*'
|
|
|
|
permissions:
|
|
contents: write
|
|
|
|
jobs:
|
|
release:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
with:
|
|
fetch-depth: 0
|
|
|
|
- name: Extract version from tag
|
|
id: ver
|
|
run: echo "version=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
|
|
|
|
- name: Verify package.json version matches tag
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
if [ ! -f package.json ]; then
|
|
echo "::warning::package.json not found; skipping version-match check."
|
|
exit 0
|
|
fi
|
|
PKG_VERSION="$(node -p "require('./package.json').version")"
|
|
TAG_VERSION="${{ steps.ver.outputs.version }}"
|
|
if [ "$PKG_VERSION" != "$TAG_VERSION" ]; then
|
|
echo "::error::Tag v${TAG_VERSION} does not match package.json version ${PKG_VERSION}. Bump package.json before tagging (Iron Rule 5)."
|
|
exit 1
|
|
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<version>" before the tag is pushed.
|
|
# This gate catches the failure mode where someone tags without
|
|
# promoting — release.yml would otherwise extract a stale
|
|
# "## v<version>" 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<version> 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
|
|
run: |
|
|
set -euo pipefail
|
|
VERSION="${{ steps.ver.outputs.version }}"
|
|
if [ ! -f CHANGELOG.md ]; then
|
|
echo "No CHANGELOG.md found; using minimal release notes"
|
|
echo "Release v${VERSION}" > /tmp/release-notes.md
|
|
exit 0
|
|
fi
|
|
awk -v ver="v${VERSION}" '
|
|
$0 ~ "^## " ver { found=1; print; next }
|
|
found && /^## v/ { exit }
|
|
found { print }
|
|
' CHANGELOG.md > /tmp/release-notes.md
|
|
if [ ! -s /tmp/release-notes.md ]; then
|
|
echo "No matching section in CHANGELOG for v${VERSION}; using minimal notes"
|
|
echo "Release v${VERSION}" > /tmp/release-notes.md
|
|
fi
|
|
|
|
- name: Create GitHub Release
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
run: |
|
|
set -euo pipefail
|
|
VERSION="${{ steps.ver.outputs.version }}"
|
|
if gh release view "v${VERSION}" >/dev/null 2>&1; then
|
|
echo "Release v${VERSION} already exists - skipping"
|
|
exit 0
|
|
fi
|
|
gh release create "v${VERSION}" \
|
|
--title "v${VERSION}" \
|
|
--notes-file /tmp/release-notes.md \
|
|
--latest
|