Skip to content

Auto Release

Auto Release #354

Workflow file for this run

name: Auto Release
# Automatically creates a release tag on each successful CI run on main
# Uses Conventional Commits to determine version bump type:
# - feat: or feat(scope): → Minor version bump
# - fix: or fix(scope): → Patch version bump
# - BREAKING CHANGE: or type!: → Major version bump
# - Other types (docs, chore, refactor, etc.) → Patch bump
#
# Note: Since main is a protected branch, this workflow only creates tags.
# Git tags are the source of truth for versioning.
#
# IMPORTANT: Uses a GitHub App token to push tags so that the Release
# workflow is triggered. GITHUB_TOKEN doesn't trigger other workflows.
# GitHub App tokens work like PATs for triggering workflows but don't expire.
on:
# Triggered automatically when CI workflow completes on main
workflow_run:
workflows: ["CI"]
types: [completed]
branches: [main, master]
# Manual trigger for ad-hoc releases
workflow_dispatch:
inputs:
bump_type:
description: 'Force version bump type'
required: false
type: choice
options:
- auto
- patch
- minor
- major
default: auto
skip_release:
description: 'Skip creating release (just update version)'
required: false
type: boolean
default: false
# Prevent concurrent releases racing against a moved HEAD (issue #441).
# Keyed on branch so overlapping workflow_run/workflow_dispatch invocations
# for the same branch are serialized instead of both checking out and
# tagging from a HEAD that shifted mid-run.
concurrency:
group: auto-release-${{ github.event.workflow_run.head_branch || github.ref_name }}
cancel-in-progress: false
permissions:
contents: write
# Read-only: lets the "check" job query the Checks API for the "CI
# Success" check-run conclusion on the triggering commit (issue #440),
# via the default GITHUB_TOKEN rather than the RELEASE_APP token — the
# app's installation permissions aren't governed by this block, and we
# don't want the release gate depending on RELEASE_APP also having
# Checks:read granted.
checks: read
jobs:
# Check if we should create a release
check:
name: Check Release Conditions
runs-on: ubuntu-latest
# Run for manual trigger, or any completed CI workflow_run — regardless of
# the run's *aggregate* conclusion. We deliberately do NOT gate on
# `github.event.workflow_run.conclusion` here (issue #440): that field
# reflects ALL jobs in the CI run, including the deliberately-optional
# "E2E Tests (live API)" job. When E2E fails but every required job
# (including "CI Success") passed, the aggregate conclusion is still
# "failure", which used to skip this job entirely and silently swallow
# the release with nothing red visible anywhere. Instead we always run
# this job for workflow_run events and let the "Check CI Success job
# conclusion" step below query the specific "CI Success" check-run —
# that's the actual required gate.
if: >
github.event_name == 'workflow_dispatch' ||
github.event_name == 'workflow_run'
outputs:
should_release: ${{ steps.check.outputs.should_release }}
last_tag: ${{ steps.check.outputs.last_tag }}
steps:
- name: Generate GitHub App token
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v2
with:
app-id: ${{ secrets.RELEASE_APP_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
with:
fetch-depth: 0
# Use GitHub App token — doesn't expire and works in workflow_run context
token: ${{ steps.app-token.outputs.token }}
# For workflow_run, checkout the commit that triggered CI
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
# Issue #440: `github.event.workflow_run.conclusion` aggregates every
# job in the triggering CI run, including the deliberately-optional
# "E2E Tests (live API)" job (see ci.yml's `ci-success` job, which
# marks e2e-tests as not required). That means a stale-token E2E
# failure flips the whole workflow_run to "failure" even though the
# "CI Success" gate job itself passed — so we must check that specific
# check-run's conclusion via the Checks API instead of trusting the
# aggregate. workflow_dispatch has no associated CI run, so it always
# passes this gate.
- name: Check CI Success job conclusion
id: ci-success-check
if: github.event_name == 'workflow_run'
env:
# Read-only Checks API call — use the default GITHUB_TOKEN (scoped
# via the `checks: read` permission above), not the RELEASE_APP
# token. That keeps this gate from depending on RELEASE_APP also
# having Checks:read granted (the app token is only needed later,
# for the tag push that must trigger downstream workflows).
GH_TOKEN: ${{ github.token }}
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
run: |
# List check-runs for the triggering commit and find the one named
# "CI Success" (the ci.yml job that aggregates all *required*
# checks — see ci.yml's ci-success job). Paginate defensively even
# though this SHA should have a small, bounded number of check-runs.
#
# This must never hard-crash the job on API failure — that would
# silently block every release until someone notices, recreating
# the exact "nothing red visible" failure mode issue #440
# complains about, just moved to a different call site. Treat any
# API error the same as "not found": loud warning, non-blocking
# default of "missing" that the next step treats as a failed gate.
CONCLUSION=$(gh api \
--paginate \
"repos/${{ github.repository }}/commits/${HEAD_SHA}/check-runs" \
--jq '.check_runs[] | select(.name == "CI Success") | .conclusion' \
2>/tmp/ci-success-check-err | head -1) || {
echo "::warning::gh api check-runs call failed for ${HEAD_SHA}: $(cat /tmp/ci-success-check-err 2>/dev/null). Treating 'CI Success' as not-passed."
echo "conclusion=missing" >> "$GITHUB_OUTPUT"
exit 0
}
if [[ -z "$CONCLUSION" ]]; then
echo "::warning::Could not find a 'CI Success' check-run for ${HEAD_SHA}; treating as not-passed."
echo "conclusion=missing" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "CI Success check-run conclusion: $CONCLUSION"
echo "conclusion=$CONCLUSION" >> "$GITHUB_OUTPUT"
- name: Check release conditions
id: check
env:
CI_SUCCESS_CONCLUSION: ${{ steps.ci-success-check.outputs.conclusion }}
run: |
# Gate on the specific "CI Success" check-run conclusion rather
# than the aggregate workflow_run.conclusion (issue #440). Skipped
# entirely for workflow_dispatch, which has no associated CI run.
if [[ "${{ github.event_name }}" == "workflow_run" && "$CI_SUCCESS_CONCLUSION" != "success" ]]; then
echo "Skipping: 'CI Success' check-run did not pass (conclusion: ${CI_SUCCESS_CONCLUSION:-unknown})"
echo "should_release=false" >> $GITHUB_OUTPUT
exit 0
fi
# Skip if this push was from the release workflow or auto-release itself
COMMIT_MSG=$(git log -1 --pretty=%B)
if [[ "$COMMIT_MSG" == *"[skip ci]"* ]] || [[ "$COMMIT_MSG" == *"[auto-release]"* ]]; then
echo "Skipping: commit contains skip marker"
echo "should_release=false" >> $GITHUB_OUTPUT
exit 0
fi
# Defense-in-depth: also skip release package update commits
if [[ "$COMMIT_MSG" == "chore(release): update CHANGELOG and packages"* ]]; then
echo "Skipping: package update commit from release workflow"
echo "should_release=false" >> $GITHUB_OUTPUT
exit 0
fi
# Skip if this is a tag push (handled by release.yml)
if [[ "$GITHUB_REF" == refs/tags/* ]]; then
echo "Skipping: tag push (handled by release.yml)"
echo "should_release=false" >> $GITHUB_OUTPUT
exit 0
fi
# Get the last release tag
LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
echo "last_tag=$LAST_TAG" >> $GITHUB_OUTPUT
if [[ -z "$LAST_TAG" ]]; then
echo "No previous tags found - will create initial release"
echo "should_release=true" >> $GITHUB_OUTPUT
else
# Check if there are commits since the last tag
COMMITS_SINCE=$(git rev-list "${LAST_TAG}..HEAD" --count)
if [[ "$COMMITS_SINCE" -gt 0 ]]; then
echo "Found $COMMITS_SINCE commits since $LAST_TAG"
echo "should_release=true" >> $GITHUB_OUTPUT
else
echo "No new commits since $LAST_TAG"
echo "should_release=false" >> $GITHUB_OUTPUT
fi
fi
# Determine version bump and create release
release:
name: Create Release
runs-on: ubuntu-latest
needs: [check]
if: needs.check.outputs.should_release == 'true'
steps:
- name: Generate GitHub App token
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v2
with:
app-id: ${{ secrets.RELEASE_APP_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
with:
fetch-depth: 0
# Use GitHub App token to push tags — triggers the Release workflow.
# GITHUB_TOKEN pushes don't trigger other workflows (prevents loops).
# App tokens don't expire unlike PATs.
token: ${{ steps.app-token.outputs.token }}
ref: main
- name: Verify we're on the right commit
run: |
EXPECTED_SHA="${{ github.event.workflow_run.head_sha || github.sha }}"
CURRENT_SHA=$(git rev-parse HEAD)
if [[ "$EXPECTED_SHA" != "$CURRENT_SHA" ]]; then
echo "Warning: HEAD ($CURRENT_SHA) differs from expected ($EXPECTED_SHA)"
echo "This can happen if main moved since CI completed"
fi
- name: Configure Git
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Get current version
id: current
run: |
# Get version from the last tag (source of truth)
LAST_TAG="${{ needs.check.outputs.last_tag }}"
if [[ -n "$LAST_TAG" ]]; then
# Strip 'v' prefix if present
VERSION="${LAST_TAG#v}"
else
VERSION="0.0.0"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Current version: $VERSION (from tag: ${LAST_TAG:-none})"
- name: Analyze commits for version bump
id: analyze
run: |
LAST_TAG="${{ needs.check.outputs.last_tag }}"
BUMP_TYPE="patch" # Default to patch
# If manual trigger with specific bump type
if [[ "${{ github.event_name }}" == "workflow_dispatch" && "${{ inputs.bump_type }}" != "auto" ]]; then
BUMP_TYPE="${{ inputs.bump_type }}"
echo "Using manual bump type: $BUMP_TYPE"
echo "bump_type=$BUMP_TYPE" >> $GITHUB_OUTPUT
exit 0
fi
# Analyze commits since last tag (or all commits if no tag)
if [[ -n "$LAST_TAG" ]]; then
COMMITS=$(git log "${LAST_TAG}..HEAD" --pretty=format:"%s" --no-merges)
else
COMMITS=$(git log --pretty=format:"%s" --no-merges -50)
fi
echo "Analyzing commits:"
echo "$COMMITS"
echo ""
# Check for breaking changes first (highest priority)
if echo "$COMMITS" | grep -qiE "^[a-z]+(\(.+\))?!:|BREAKING[ -]CHANGE"; then
BUMP_TYPE="major"
echo "Found BREAKING CHANGE - major bump"
# Check for features
elif echo "$COMMITS" | grep -qiE "^feat(\(.+\))?:"; then
BUMP_TYPE="minor"
echo "Found feat commits - minor bump"
# Everything else is a patch
else
BUMP_TYPE="patch"
echo "No feat or breaking changes - patch bump"
fi
echo "bump_type=$BUMP_TYPE" >> $GITHUB_OUTPUT
- name: Calculate new version
id: newversion
run: |
CURRENT="${{ steps.current.outputs.version }}"
BUMP="${{ steps.analyze.outputs.bump_type }}"
# Parse current version
IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT"
# Calculate new version
case "$BUMP" in
major)
NEW_VERSION="$((MAJOR + 1)).0.0"
;;
minor)
NEW_VERSION="${MAJOR}.$((MINOR + 1)).0"
;;
patch)
NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))"
;;
esac
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
echo "Bumping $CURRENT -> $NEW_VERSION ($BUMP)"
# Release tag is created from current HEAD. Git tags are the source of truth.
- name: Create and push tag
run: |
NEW_VERSION="${{ steps.newversion.outputs.new_version }}"
# Create annotated tag from current HEAD (no commit needed)
git tag -a "v${NEW_VERSION}" -m "Release v${NEW_VERSION}"
git push origin "v${NEW_VERSION}"
echo "Created and pushed tag v${NEW_VERSION}"
# Issue #441: release.yml (on.push.tags) occasionally never fires for a
# freshly-pushed tag when two merges/tag-pushes land close together.
# Self-heal regardless of root cause: poll for the expected release.yml
# run for a few minutes, and if it never shows up, dispatch it directly.
#
# Best-effort: the tag itself was already created and pushed successfully
# by the previous step, so a failure here (e.g. the RELEASE_APP token
# lacking Actions read/write scopes) must not fail this job — it should
# only be logged loudly so it can be investigated. If `gh` calls below
# start failing with permission errors, grant the RELEASE_APP GitHub App
# "Actions: Read and write" permission.
- name: Verify Release workflow triggered, self-heal if not
continue-on-error: true
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
NEW_VERSION: ${{ steps.newversion.outputs.new_version }}
run: |
TAG="v${NEW_VERSION}"
POLL_ATTEMPTS=18 # 18 * 10s = 3 minutes
ATTEMPT=0
FOUND="false"
echo "Polling for a release.yml run triggered by tag ${TAG}..."
while [[ "$ATTEMPT" -lt "$POLL_ATTEMPTS" ]]; do
RUN_COUNT=$(gh run list \
--repo "${{ github.repository }}" \
--workflow=release.yml \
--json headBranch,event \
--jq "[.[] | select(.headBranch == \"${TAG}\" and .event == \"push\")] | length") || {
echo "::warning::gh run list failed (possibly missing Actions:read permission on RELEASE_APP). Skipping self-heal poll."
exit 0
}
if [[ "$RUN_COUNT" -gt 0 ]]; then
echo "Found release.yml run for tag ${TAG} after ${ATTEMPT} poll(s)."
FOUND="true"
break
fi
ATTEMPT=$((ATTEMPT + 1))
sleep 10
done
if [[ "$FOUND" != "true" ]]; then
echo "No release.yml run detected for tag ${TAG} after ${POLL_ATTEMPTS} polls (~3 min)."
echo "Dispatching release.yml manually as a self-heal measure."
if ! gh workflow run release.yml \
--repo "${{ github.repository }}" \
--ref "${TAG}" \
-f version="${NEW_VERSION}"; then
echo "::warning::Failed to dispatch release.yml for tag ${TAG} (possibly missing Actions:write permission on RELEASE_APP). Manual dispatch required: gh workflow run release.yml --ref ${TAG} -f version=${NEW_VERSION}"
fi
fi
- name: Release Summary
run: |
NEW_VERSION="${{ steps.newversion.outputs.new_version }}"
PREV_VERSION="${{ steps.current.outputs.version }}"
BUMP="${{ steps.analyze.outputs.bump_type }}"
echo "## Auto-Release Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "- **New Version**: v${NEW_VERSION}" >> $GITHUB_STEP_SUMMARY
echo "- **Previous Version**: v${PREV_VERSION}" >> $GITHUB_STEP_SUMMARY
echo "- **Bump Type**: ${BUMP}" >> $GITHUB_STEP_SUMMARY
echo "- **Trigger**: ${{ github.event_name }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "The release workflow will now build and publish the release." >> $GITHUB_STEP_SUMMARY