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" 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 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