Skip to content

Create Release (auto) #56

Create Release (auto)

Create Release (auto) #56

name: Create Release
on:
workflow_dispatch:
inputs:
version_bump:
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: "auto"
type: choice
options:
- auto
- patch
- minor
- major
confirm_major:
description: "Confirm major version bump (required when 'major' is selected)"
required: false
type: boolean
default: false
pre_release:
description: "Create as pre-release"
required: false
type: boolean
default: false
dry_run:
description: "Dry run - only compute and display the next version without creating a release"
required: false
type: boolean
default: false
concurrency:
group: release-creation
cancel-in-progress: false
run-name: "Create Release (${{ github.event.inputs.version_bump }}${{ github.event.inputs.dry_run == 'true' && ' - dry run' || '' }})"
jobs:
create-release:
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}
steps:
# A GitHub App token is required (not the default GITHUB_TOKEN) because releases
# created with GITHUB_TOKEN do not trigger downstream `release: created` workflows
# due to GitHub's anti-recursion policy. Same pattern used in unstract-cloud.
- name: Generate GitHub App Token
id: generate-token
uses: actions/create-github-app-token@v3
with:
client-id: ${{ vars.PUSH_TO_MAIN_APP_CLIENT_ID }}
private-key: ${{ secrets.PUSH_TO_MAIN_APP_PRIVATE_KEY }}
owner: Zipstack
repositories: |
unstract
- name: Validate branch and determine release mode
id: branch-mode
env:
BRANCH: ${{ github.ref_name }}
run: |
if [[ "$BRANCH" == "main" ]]; then
MODE=main
HOTFIX_LINE=""
elif [[ "$BRANCH" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)-hotfix$ ]]; then
MODE=hotfix
HOTFIX_LINE="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}"
echo "Hotfix branch detected. Line: v${HOTFIX_LINE}.x"
else
echo "::error::Releases must be cut from 'main' or a 'vX.Y.Z-hotfix' branch. Got: '$BRANCH'"
exit 1
fi
echo "mode=$MODE" >> "$GITHUB_OUTPUT"
echo "hotfix_line=$HOTFIX_LINE" >> "$GITHUB_OUTPUT"
- name: Validate inputs for hotfix mode
if: steps.branch-mode.outputs.mode == 'hotfix'
env:
BUMP: ${{ github.event.inputs.version_bump }}
run: |
# '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
- name: Validate major bump confirmation
if: github.event.inputs.version_bump == 'major' && github.event.inputs.confirm_major != 'true'
run: |
echo "::error::Major version bump requires the 'Confirm major version bump' checkbox to be checked."
exit 1
- name: Fetch base release version
id: get-latest
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
MODE: ${{ steps.branch-mode.outputs.mode }}
HOTFIX_LINE: ${{ steps.branch-mode.outputs.hotfix_line }}
run: |
if [[ "$MODE" == "main" ]]; then
# Use the "latest" API (releases/latest) which respects the explicit
# "Set as latest release" flag. Hotfix releases are created with
# --latest=false, so this never returns a hotfix tag.
LATEST_TAG=$(gh api "repos/${{ github.repository }}/releases/latest" --jq '.tag_name // empty')
else
# For hotfixes, find the latest tag on this specific v<major>.<minor>.x line
LATEST_TAG=$(gh release list --repo "${{ github.repository }}" \
--limit 100 --exclude-drafts --exclude-pre-releases \
--json tagName \
--jq "[.[] | select(.tagName | startswith(\"v${HOTFIX_LINE}.\"))] | .[0].tagName // empty")
fi
if [[ -z "$LATEST_TAG" ]]; then
echo "::error::Could not find a base release to bump from (mode=$MODE)."
exit 1
fi
if [[ ! "$LATEST_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::Base release tag '$LATEST_TAG' does not match expected format 'vX.Y.Z'"
exit 1
fi
echo "latest_tag=$LATEST_TAG" >> "$GITHUB_OUTPUT"
echo "Base release: $LATEST_TAG"
- name: Ensure there are new commits to release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
LATEST_TAG: ${{ steps.get-latest.outputs.latest_tag }}
BRANCH: ${{ github.ref_name }}
DRY_RUN: ${{ github.event.inputs.dry_run }}
run: |
# Reject a no-op release: if the branch has no commits beyond the last release
# tag (e.g. main is already at v0.177.0), there is nothing to release. This
# prevents empty releases such as v0.177.1 pointing at the same commit as
# v0.177.0. The workflow has no local checkout, so use the compare API's
# `ahead_by` (commits on BRANCH not in LATEST_TAG). `// empty` stops a null/
# absent value from reaching the comparison as the literal "null"; we then
# require a non-negative integer and fail loudly on anything unexpected, so a
# transient API error is never mistaken for "no new commits".
AHEAD=$(gh api "repos/${{ github.repository }}/compare/${LATEST_TAG}...${BRANCH}" --jq '.ahead_by // empty') \
|| { echo "::error::Could not query commits since $LATEST_TAG (compare API failed)."; exit 1; }
if ! [[ "$AHEAD" =~ ^[0-9]+$ ]]; then
echo "::error::Unexpected ahead_by='${AHEAD:-<empty>}' from compare ${LATEST_TAG}...${BRANCH}; refusing to guess."
exit 1
fi
if [[ "$AHEAD" == "0" ]]; then
if [[ "$DRY_RUN" == "true" ]]; then
echo "::warning::No new commits on '$BRANCH' since $LATEST_TAG — a real run would be rejected (nothing to release)."
else
echo "::error::No new commits on '$BRANCH' since $LATEST_TAG — nothing to release. Aborting to avoid an empty release."
exit 1
fi
else
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 }}
# 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)
MINOR=$(echo "$VERSION" | cut -d. -f2)
PATCH=$(echo "$VERSION" | cut -d. -f3)
case "$BUMP_TYPE" in
patch)
PATCH=$((PATCH + 1))
;;
minor)
MINOR=$((MINOR + 1))
PATCH=0
;;
major)
MAJOR=$((MAJOR + 1))
MINOR=0
PATCH=0
;;
*)
echo "::error::Unknown bump type: '$BUMP_TYPE'"
exit 1
;;
esac
NEW_TAG="v${MAJOR}.${MINOR}.${PATCH}"
# Strict monotonic increase: equal is NOT allowed, sort -V -C alone
# accepts equal values (it checks non-decreasing order).
CURRENT_NUM="${LATEST_TAG#v}"
NEW_NUM="${NEW_TAG#v}"
if [[ "$CURRENT_NUM" == "$NEW_NUM" ]]; then
echo "::error::Computed version $NEW_TAG equals current version $LATEST_TAG"
exit 1
fi
if ! printf '%s\n%s\n' "$CURRENT_NUM" "$NEW_NUM" | sort -V -C; then
echo "::error::Computed version $NEW_TAG is not greater than current version $LATEST_TAG"
exit 1
fi
echo "new_tag=$NEW_TAG" >> "$GITHUB_OUTPUT"
echo "Current: $LATEST_TAG -> New: $NEW_TAG"
- name: Check for tag collision
id: collision-check
env:
GH_TOKEN: ${{ steps.generate-token.outputs.token }}
NEW_TAG: ${{ steps.compute-version.outputs.new_tag }}
run: |
# Guard against collisions with existing pre-releases or drafts
# (which are hidden by --exclude-pre-releases in the earlier lookup).
if gh release view "$NEW_TAG" --repo "${{ github.repository }}" >/dev/null 2>&1; then
echo "::error::A release with tag '$NEW_TAG' already exists (possibly as a pre-release or draft). Delete it first or pick a different bump type."
exit 1
fi
- name: Dry run summary
if: github.event.inputs.dry_run == 'true'
env:
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_DISPLAY |"
echo "| Next version | $NEW_TAG |"
echo "| Pre-release | $PRERELEASE |"
echo ""
echo "No release was created (dry run mode)."
} >> "$GITHUB_STEP_SUMMARY"
- name: Create GitHub release
id: create-release
if: github.event.inputs.dry_run != 'true'
env:
GH_TOKEN: ${{ steps.generate-token.outputs.token }}
NEW_TAG: ${{ steps.compute-version.outputs.new_tag }}
LATEST_TAG: ${{ steps.get-latest.outputs.latest_tag }}
MODE: ${{ steps.branch-mode.outputs.mode }}
PRERELEASE: ${{ github.event.inputs.pre_release }}
TARGET_BRANCH: ${{ github.ref_name }}
run: |
RELEASE_ARGS=(
"$NEW_TAG"
--title "$NEW_TAG"
--target "$TARGET_BRANCH"
--generate-notes
--notes-start-tag "$LATEST_TAG"
)
# Decide whether this release should become GitHub "latest".
#
# Rule: mark latest iff this version supersedes the current "latest" release
# (i.e. it is the newest stable release by semver). This replaces the old
# main-only heuristic, which left hotfixes on the CURRENT production line
# (e.g. latest v0.176.3, hotfix -> v0.176.4) incorrectly marked latest=false.
# Hotfixes on OLDER lines still stay latest=false because they do not
# supersede the current latest.
if [[ "$PRERELEASE" == "true" ]]; then
# A pre-release is never GitHub "latest" — skip the supersession check so we
# never emit --latest=true alongside --prerelease.
echo "ℹ️ Pre-release $NEW_TAG - not marking latest"
RELEASE_ARGS+=(--latest=false)
else
# Query the current "latest" release, distinguishing a genuine 404 (no latest
# release yet) from a transient API failure (rate limit / 5xx). On a transient
# failure we must NOT silently fall through to latest=true, or a back-version
# hotfix could wrongly steal "latest" — fail the job loudly instead. (Mirrors
# the existing guarded pattern in the "Fetch base release version" step above.)
if CURRENT_LATEST=$(gh api "repos/${{ github.repository }}/releases/latest" --jq '.tag_name // empty' 2>/tmp/gh_latest.err); then
:
elif grep -qiE 'HTTP 404|Not Found' /tmp/gh_latest.err; then
CURRENT_LATEST="" # genuinely no latest release yet
else
echo "::error::Could not determine the current latest release (transient gh API failure); refusing to guess the 'latest' flag."
cat /tmp/gh_latest.err >&2
exit 1
fi
if [[ -z "$CURRENT_LATEST" ]]; then
echo "✅ No existing 'latest' release - marking $NEW_TAG as latest"
RELEASE_ARGS+=(--latest=true)
elif [[ "$NEW_TAG" == "$CURRENT_LATEST" ]]; then
# Defensive/unreachable: the "Check for tag collision" step rejects a NEW_TAG
# that already exists, and CURRENT_LATEST is an existing release, so the two
# cannot be equal here. Kept only as a guard against a tie if this block is
# ever reused without that upstream check.
echo "ℹ️ $NEW_TAG equals current latest $CURRENT_LATEST - leaving latest unchanged"
RELEASE_ARGS+=(--latest=false)
else
NEWEST=$(printf '%s\n%s\n' "$NEW_TAG" "$CURRENT_LATEST" | sort -V | tail -n1)
if [[ "$NEWEST" == "$NEW_TAG" ]]; then
echo "✅ $NEW_TAG supersedes current latest $CURRENT_LATEST (mode: $MODE) - marking as latest"
RELEASE_ARGS+=(--latest=true)
else
echo "ℹ️ $NEW_TAG does not supersede current latest $CURRENT_LATEST (mode: $MODE) - NOT marking as latest"
RELEASE_ARGS+=(--latest=false)
fi
fi
fi
if [[ "$PRERELEASE" == "true" ]]; then
RELEASE_ARGS+=(--prerelease)
fi
gh release create --repo "${{ github.repository }}" "${RELEASE_ARGS[@]}"
echo "Release $NEW_TAG created successfully."
- name: Summary
if: success() && github.event.inputs.dry_run != 'true'
env:
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 ""
echo "| Field | Value |"
echo "|-------|-------|"
echo "| Mode | $MODE |"
echo "| Previous version | $LATEST_TAG |"
echo "| New version | $NEW_TAG |"
echo "| Bump type | $BUMP_DISPLAY |"
echo "| Pre-release | $PRERELEASE |"
echo "| Triggered by | ${{ github.actor }} |"
echo ""
echo "The [production build](${{ github.server_url }}/${{ github.repository }}/actions/workflows/production-build.yaml) workflow will be triggered automatically."
} >> "$GITHUB_STEP_SUMMARY"