Files
virtfusion-whmcs-module/.github/workflows/publish-release.yml
Prophet731 c90cbd7399 fix(ci): force-publish releases as non-draft + latest
softprops/action-gh-release@v2 has a long-standing intermittent bug
where it creates the release as a draft and silently fails to flip the
draft→published step, even though it logs "🎉 Release ready" and the
job exits successfully. v1.4.0, v1.4.1, and v1.4.2 all shipped as
drafts because of this — meaning the GitHub `releases/latest` API
returned v1.3.0, the documented install snippets and the new install.sh
would both download v1.3.0, and admins running the upgrade flow would
never actually get the storage-type-code fix.

Two changes:

  1. Pass `make_latest: 'true'` to the action so a successful create
     also explicitly marks the release as latest (when the action is
     working correctly).
  2. Add an unconditional follow-up step `gh release edit --draft=false
     --latest` that runs whenever the create step ran. If the action
     already published correctly, this is a no-op. If it failed to
     flip, we recover.

Token + variables go through `env:` blocks (not interpolated inline
into `run:`) to match the workflow injection guidance the rest of the
file already follows.

v1.4.0/1/2 were manually re-published with `gh release edit` as a
one-off cleanup; this fix prevents the same situation from recurring.
2026-04-26 02:42:21 -04:00

190 lines
8.0 KiB
YAML

name: Publish Release
# -----------------------------------------------------------------------------
# Release-notes strategy (in order of preference):
#
# 1. If CHANGELOG.md has a "## [X.Y.Z] - YYYY-MM-DD" section matching the
# tag version, use that section verbatim. This is the normal path —
# maintainers write release notes once in CHANGELOG and they flow to
# GitHub automatically with no re-typing.
#
# 2. Otherwise, fall back to grouping the commits in the tag range by
# conventional-commit prefix (feat / fix / refactor / docs / other).
# Keeps releases useful even if the maintainer forgot the CHANGELOG.
#
# 3. Append a compare link (PREV_TAG...TAG) at the bottom so readers can
# dive into the full diff in one click.
#
# Retag safety:
# When a tag is force-pushed (e.g. to fix a last-minute doc error), the
# workflow normally would overwrite any hand-edited release body. We guard
# against that by checking the current release body BEFORE running the
# generator — if a body is already present, we leave it alone. To
# intentionally regenerate, clear the body first via:
# gh release edit vX.Y.Z --notes ""
#
# Security note:
# All ${{ ... }} interpolation in this file flows through `env:` blocks
# rather than inline in `run:` commands. Shell scripts reference those
# env vars with $VAR, which is immune to the command-injection pattern
# that hits workflows interpolating untrusted event data directly.
# -----------------------------------------------------------------------------
on:
push:
tags:
- 'v*'
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
# Need full history for `git describe` to find the previous tag and
# for `git log PREV..HEAD` to enumerate commits in the release range.
fetch-depth: 0
- name: Derive versions
id: version
env:
REF: ${{ github.ref }}
run: |
TAG="${REF#refs/tags/}"
VERSION="${TAG#v}"
# Previous tag for compare link + commit range. Empty on first release.
PREV_TAG=$(git describe --tags --abbrev=0 "$TAG^" 2>/dev/null || echo "")
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "prev_tag=$PREV_TAG" >> "$GITHUB_OUTPUT"
echo "Tag: $TAG Version: $VERSION Previous: ${PREV_TAG:-<none>}"
- name: Check for existing release body
id: existing
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ steps.version.outputs.tag }}
REPO: ${{ github.repository }}
run: |
# If the release already has a non-empty body, skip generation so
# hand-edits survive tag re-pushes. Fresh releases (no body) proceed.
BODY=$(gh release view "$TAG" --repo "$REPO" --json body -q .body 2>/dev/null || echo "")
if [ -n "$(printf '%s' "$BODY" | tr -d '[:space:]')" ]; then
echo "Existing release body detected — preserving manual edits."
echo "skip=true" >> "$GITHUB_OUTPUT"
else
echo "No existing body (or empty) — will generate."
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- name: Generate release notes
if: steps.existing.outputs.skip != 'true'
env:
VERSION: ${{ steps.version.outputs.version }}
TAG: ${{ steps.version.outputs.tag }}
PREV_TAG: ${{ steps.version.outputs.prev_tag }}
REPO: ${{ github.repository }}
run: |
set -eo pipefail
# --- 1. Try extracting the section from CHANGELOG.md --------------
# Matches "## [1.2.0] ..." exactly and prints every line up to the
# next "## [" heading or EOF.
CHANGELOG_SECTION=""
if [ -f CHANGELOG.md ]; then
CHANGELOG_SECTION=$(awk -v ver="$VERSION" '
$0 ~ "^## \\[" ver "\\]" { found=1; next }
found && /^## \[/ { exit }
found { print }
' CHANGELOG.md)
fi
# --- 2. Commit-based fallback ------------------------------------
# Used only when CHANGELOG has no section for this version. Groups
# conventional-commit prefixes into readable categories; skips
# automated "chore(release): …" bump commits from display since
# they're noise in a release the reader is already looking at.
if [ -z "$(printf '%s' "$CHANGELOG_SECTION" | tr -d '[:space:]')" ]; then
echo "::warning::CHANGELOG.md has no section for [$VERSION]; falling back to commit-log grouping."
if [ -n "$PREV_TAG" ]; then RANGE="$PREV_TAG..HEAD"; else RANGE=""; fi
LOG=$(git log $RANGE --no-merges --pretty=format:'%s' \
| grep -vE '^chore\(release\)' || true)
# extract <regex> — prints matching commits as "- <rest>" with the
# conventional-commit "type(scope)?:" prefix stripped for readability.
extract() {
printf '%s\n' "$LOG" \
| grep -E "^($1)(\([^)]+\))?:" \
| sed -E "s/^($1)(\([^)]+\))?:[[:space:]]*/- /" \
|| true
}
FEATURES=$(extract 'feat')
FIXES=$(extract 'fix')
REFACTORS=$(extract 'refactor')
DOCS=$(extract 'docs')
OTHER=$(printf '%s\n' "$LOG" \
| grep -vE '^(feat|fix|refactor|docs|chore)(\([^)]+\))?:' \
| sed -E 's/^/- /' \
|| true)
{
[ -n "$FEATURES" ] && printf '### Features\n\n%s\n\n' "$FEATURES"
[ -n "$FIXES" ] && printf '### Bug Fixes\n\n%s\n\n' "$FIXES"
[ -n "$REFACTORS" ] && printf '### Changes\n\n%s\n\n' "$REFACTORS"
[ -n "$DOCS" ] && printf '### Documentation\n\n%s\n\n' "$DOCS"
[ -n "$OTHER" ] && printf '### Other\n\n%s\n\n' "$OTHER"
} > /tmp/generated.md
CHANGELOG_SECTION=$(cat /tmp/generated.md)
fi
# --- 3. Compose final body (content + compare footer) ------------
{
printf '%s\n' "$CHANGELOG_SECTION"
if [ -n "$PREV_TAG" ]; then
printf '\n---\n\n**Full Changelog:** [%s...%s](https://github.com/%s/compare/%s...%s)\n' \
"$PREV_TAG" "$TAG" "$REPO" "$PREV_TAG" "$TAG"
fi
} > /tmp/release-notes.md
echo "--- release notes ($(wc -c < /tmp/release-notes.md) bytes) ---"
head -20 /tmp/release-notes.md
echo "---"
- name: Create release
if: steps.existing.outputs.skip != 'true'
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.version.outputs.tag }}
name: ${{ steps.version.outputs.tag }}
body_path: /tmp/release-notes.md
draft: false
prerelease: false
make_latest: 'true'
# Belt-and-suspenders: action-gh-release@v2 has a long-standing
# intermittent bug where it creates the release as a draft and silently
# fails to flip the draft→published step, even though it reports success.
# When that happens the install script + README snippets resolve "latest"
# to whatever was last properly published, so users would get an old
# version. We explicitly flip to published + latest here as a safety net;
# if the action already did it correctly, this is a no-op.
#
# Security note: TAG and REPO are sourced from earlier `env:` blocks (not
# interpolated inline into the run command), matching the same pattern
# used elsewhere in this workflow.
- name: Force-publish release
if: steps.existing.outputs.skip != 'true'
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ steps.version.outputs.tag }}
REPO: ${{ github.repository }}
run: |
gh release edit "$TAG" --repo "$REPO" --draft=false --latest