Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 87 additions & 6 deletions .github/workflows/create-release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ on:
workflow_dispatch:
inputs:
version_bump:
description: "Version bump type (hotfix branches force 'patch')"
description: "Version bump ('auto' = minor if a FEAT/GATED-FEAT PR merged since the last release, else patch; hotfix branches force 'patch')"
required: true
default: "patch"
default: "auto"
type: choice
options:
- auto
- patch
- minor
- major
Expand Down Expand Up @@ -39,6 +40,11 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: write
# 'auto' version_bump classifies merged PR titles via `gh pr list`, which
# needs read access to pull requests. Declaring an explicit permissions
# block sets every unlisted scope to 'none', so without this the query 403s
# and 'auto' falls back to patch (with a warning) on every run.
pull-requests: read
defaults:
run:
shell: bash -euo pipefail {0}
Expand Down Expand Up @@ -82,7 +88,9 @@ jobs:
env:
BUMP: ${{ github.event.inputs.version_bump }}
run: |
if [[ "$BUMP" != "patch" ]]; then
# 'auto' is allowed here — the "Resolve auto bump" step maps it to 'patch'
# on a hotfix line (hotfixes are patch-only by definition).
if [[ "$BUMP" != "patch" && "$BUMP" != "auto" ]]; then
echo "::error::Hotfix branches only support 'patch' bumps. Got: '$BUMP'"
exit 1
fi
Expand Down Expand Up @@ -158,11 +166,78 @@ jobs:
echo "✅ $AHEAD new commit(s) on '$BRANCH' since $LATEST_TAG"
fi

- name: Resolve auto bump
id: resolve-bump
if: github.event.inputs.version_bump == 'auto'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
MODE: ${{ steps.branch-mode.outputs.mode }}
LATEST_TAG: ${{ steps.get-latest.outputs.latest_tag }}
run: |
# 'auto' only chooses minor-vs-patch on main; hotfix lines are patch-only.
if [[ "$MODE" != "main" ]]; then
echo "resolved=patch" >> "$GITHUB_OUTPUT"
echo "::notice::auto bump on hotfix line -> patch"
exit 0
fi

# Classify PRs merged into main after the base release was published:
# any [FEAT]/[GATED-FEAT] title (the only feature PR types in the
# contribution guide) => minor, otherwise patch. Every failure below
# degrades to patch — never over-bump the public version, and never
# hard-fail the release on a transient query hiccup. '// empty' keeps a
# JSON null from leaking through as the literal "null" (matches the
# get-latest step's convention).
SINCE=$(gh api "repos/${{ github.repository }}/releases/tags/$LATEST_TAG" --jq '.published_at // empty' 2>/tmp/gh_since.err || echo "")
if [[ -z "$SINCE" ]]; then
echo "::warning::Could not resolve publish time for $LATEST_TAG; defaulting to patch."
cat /tmp/gh_since.err >&2 || true
echo "resolved=patch" >> "$GITHUB_OUTPUT"
exit 0
fi

LIMIT=200
if ! PR_JSON=$(gh pr list --repo "${{ github.repository }}" \
--base main --state merged --limit "$LIMIT" --search "merged:>$SINCE" \
--json title 2>/tmp/gh_pr.err); then
echo "::warning::PR classification query failed; defaulting to patch."
cat /tmp/gh_pr.err >&2 || true # surface 403/permission vs rate-limit/5xx
RESOLVED=patch
else
# Parse defensively: malformed/non-array stdout must degrade to patch,
# not crash the job under `set -e`/pipefail.
TOTAL=$(jq 'length' <<<"$PR_JSON" 2>/dev/null) || TOTAL=""
FEAT_COUNT=$(jq '[.[] | select(.title | test("\\[(FEAT|GATED-FEAT)\\]"; "i"))] | length' <<<"$PR_JSON" 2>/dev/null) || FEAT_COUNT=""
if ! [[ "$TOTAL" =~ ^[0-9]+$ && "$FEAT_COUNT" =~ ^[0-9]+$ ]]; then
echo "::warning::PR classification returned unparseable output; defaulting to patch."
RESOLVED=patch
elif [[ "$FEAT_COUNT" -gt 0 ]]; then
RESOLVED=minor
else
RESOLVED=patch
# Truncation only changes the outcome when no FEAT was found within
# the cap, so only warn in that case (a FEAT beyond it could be missed).
if [[ "$TOTAL" -ge "$LIMIT" ]]; then
echo "::warning::merged-PR query hit the $LIMIT-result cap since $SINCE; a FEAT PR beyond it may be missed — double-check the bump (override with version_bump=minor if needed)."
fi
fi
fi

echo "resolved=$RESOLVED" >> "$GITHUB_OUTPUT"
if [[ "$RESOLVED" == "minor" ]]; then
echo "::notice::auto bump: $FEAT_COUNT FEAT/GATED-FEAT PR(s) merged since $LATEST_TAG -> minor"
else
echo "::notice::auto bump: no FEAT/GATED-FEAT PR merged since $LATEST_TAG -> patch. Re-run with version_bump=minor to override."
fi

- name: Compute next version
id: compute-version
env:
LATEST_TAG: ${{ steps.get-latest.outputs.latest_tag }}
BUMP_TYPE: ${{ github.event.inputs.version_bump }}
# Non-auto: use the raw input. Auto: use the resolved value, falling back
# to 'patch' (never back to 'auto', which would hit compute-version's
# unknown-bump error).
BUMP_TYPE: ${{ github.event.inputs.version_bump != 'auto' && github.event.inputs.version_bump || steps.resolve-bump.outputs.resolved || 'patch' }}
run: |
VERSION="${LATEST_TAG#v}"
MAJOR=$(echo "$VERSION" | cut -d. -f1)
Expand Down Expand Up @@ -225,17 +300,20 @@ jobs:
LATEST_TAG: ${{ steps.get-latest.outputs.latest_tag }}
NEW_TAG: ${{ steps.compute-version.outputs.new_tag }}
BUMP: ${{ github.event.inputs.version_bump }}
RESOLVED: ${{ steps.resolve-bump.outputs.resolved }}
PRERELEASE: ${{ github.event.inputs.pre_release }}
MODE: ${{ steps.branch-mode.outputs.mode }}
run: |
BUMP_DISPLAY="$BUMP"
[[ "$BUMP" == "auto" ]] && BUMP_DISPLAY="auto -> ${RESOLVED}"
{
echo "## Dry Run Summary"
echo ""
echo "| Field | Value |"
echo "|-------|-------|"
echo "| Mode | $MODE |"
echo "| Current version | $LATEST_TAG |"
echo "| Bump type | $BUMP |"
echo "| Bump type | $BUMP_DISPLAY |"
echo "| Next version | $NEW_TAG |"
echo "| Pre-release | $PRERELEASE |"
echo ""
Expand Down Expand Up @@ -324,9 +402,12 @@ jobs:
LATEST_TAG: ${{ steps.get-latest.outputs.latest_tag }}
NEW_TAG: ${{ steps.compute-version.outputs.new_tag }}
BUMP: ${{ github.event.inputs.version_bump }}
RESOLVED: ${{ steps.resolve-bump.outputs.resolved }}
PRERELEASE: ${{ github.event.inputs.pre_release }}
MODE: ${{ steps.branch-mode.outputs.mode }}
run: |
BUMP_DISPLAY="$BUMP"
[[ "$BUMP" == "auto" ]] && BUMP_DISPLAY="auto -> ${RESOLVED}"
{
echo "## Release Created"
echo ""
Expand All @@ -335,7 +416,7 @@ jobs:
echo "| Mode | $MODE |"
echo "| Previous version | $LATEST_TAG |"
echo "| New version | $NEW_TAG |"
echo "| Bump type | $BUMP |"
echo "| Bump type | $BUMP_DISPLAY |"
echo "| Pre-release | $PRERELEASE |"
echo "| Triggered by | ${{ github.actor }} |"
echo ""
Expand Down
Loading