Auto Release #354
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |