Release prepare #1
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: Release prepare | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| bump_kind: | |
| description: 'How to bump the version (ignored if version_override is set)' | |
| type: choice | |
| options: [build, patch, minor, major] | |
| default: build | |
| version_type: | |
| description: 'Channel for the new version (keep-current preserves current type)' | |
| type: choice | |
| options: [keep-current, rc, beta] | |
| default: keep-current | |
| version_override: | |
| description: 'Explicit version, e.g. 1.8.0-rc0 (overrides bump_kind/version_type)' | |
| type: string | |
| default: '' | |
| expected_current: | |
| description: 'Safety check: fail if current version does not match (e.g. 1.7.1-rc0)' | |
| type: string | |
| default: '' | |
| dry_run: | |
| description: 'When true: compute and validate only. When false: commit, tag, push.' | |
| type: boolean | |
| default: true | |
| permissions: | |
| contents: read | |
| concurrency: | |
| group: release-prepare-main | |
| cancel-in-progress: false | |
| jobs: | |
| compute-and-validate: | |
| name: Compute and validate | |
| runs-on: ubuntu-22.04 | |
| permissions: | |
| contents: read | |
| checks: read | |
| outputs: | |
| new_name: ${{ steps.plan.outputs.new_name }} | |
| new_code: ${{ steps.plan.outputs.new_code }} | |
| current_name: ${{ steps.plan.outputs.current_name }} | |
| env: | |
| INPUT_BUMP_KIND: ${{ inputs.bump_kind }} | |
| INPUT_VERSION_TYPE: ${{ inputs.version_type }} | |
| INPUT_VERSION_OVERRIDE: ${{ inputs.version_override }} | |
| INPUT_EXPECTED_CURRENT: ${{ inputs.expected_current }} | |
| steps: | |
| - name: Guard ref must be main | |
| run: | | |
| if [[ "${GITHUB_REF}" != "refs/heads/main" ]]; then | |
| echo "::error::Must dispatch from main, got ${GITHUB_REF}" | |
| exit 1 | |
| fi | |
| - name: Pre-flight main health check | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| set -euo pipefail | |
| REPO="${GITHUB_REPOSITORY}" | |
| SHA="${GITHUB_SHA}" | |
| # Fetch required check contexts from the main branch ruleset. | |
| REQUIRED=$(gh api "/repos/${REPO}/rules/branches/main" \ | |
| --jq '[.[] | select(.type == "required_status_checks") | .parameters.required_status_checks[] | .context] | unique' \ | |
| 2>/dev/null || echo '[]') | |
| if [[ "$REQUIRED" == "[]" || -z "$REQUIRED" ]]; then | |
| echo "::warning::No required status checks found in branch ruleset - skipping pre-flight gate." | |
| exit 0 | |
| fi | |
| # GitHub check-run names are job names, not workflow names. Ignore this | |
| # release workflow's own jobs so a manual release run cannot gate itself. | |
| RELEASE_WORKFLOW_CHECKS='["Compute and validate","Push and dispatch"]' | |
| IGNORED_REQUIRED=$(echo "$REQUIRED" | jq --argjson ignored "$RELEASE_WORKFLOW_CHECKS" \ | |
| '[.[] | select(. as $name | ($ignored | index($name)))]') | |
| if [[ "$IGNORED_REQUIRED" != "[]" ]]; then | |
| echo "Ignoring Release prepare self-checks: $(echo "$IGNORED_REQUIRED" | jq -r 'join(", ")')" | |
| fi | |
| REQUIRED=$(echo "$REQUIRED" | jq --argjson ignored "$RELEASE_WORKFLOW_CHECKS" \ | |
| 'map(select(. as $name | ($ignored | index($name) | not)))') | |
| if [[ "$REQUIRED" == "[]" || -z "$REQUIRED" ]]; then | |
| echo "::warning::No external required status checks found after ignoring Release prepare self-checks - skipping pre-flight gate." | |
| exit 0 | |
| fi | |
| echo "Required checks: $(echo "$REQUIRED" | jq -r 'join(", ")')" | |
| # Fetch latest check-run per name for this commit (first 100, enough for CI). | |
| RUNS=$(gh api "/repos/${REPO}/commits/${SHA}/check-runs?per_page=100" \ | |
| --jq '.check_runs | group_by(.name) | map(sort_by(.started_at) | last | {name: .name, status: .status, conclusion: (.conclusion // "pending")})') | |
| FAILED=() | |
| PENDING=() | |
| while IFS= read -r check_name; do | |
| result=$(echo "$RUNS" | jq -r --arg name "$check_name" \ | |
| 'map(select(.name == $name)) | if length == 0 then "not_found" else last | "\(.status)|\(.conclusion)" end') | |
| IFS='|' read -r status conclusion <<< "$result" | |
| if [[ "$result" == "not_found" ]]; then | |
| PENDING+=("$check_name (not yet started)") | |
| elif [[ "$status" != "completed" ]]; then | |
| PENDING+=("$check_name ($status)") | |
| elif [[ "$conclusion" == "failure" || "$conclusion" == "cancelled" || "$conclusion" == "timed_out" ]]; then | |
| FAILED+=("$check_name: $conclusion") | |
| fi | |
| done < <(echo "$REQUIRED" | jq -r '.[]') | |
| if [[ ${#PENDING[@]} -gt 0 ]]; then | |
| { | |
| echo "### Pending required checks" | |
| echo "" | |
| echo "These checks have not yet completed. Release is proceeding; verify their outcomes:" | |
| echo "" | |
| for item in "${PENDING[@]}"; do | |
| echo "- $item" | |
| done | |
| echo "" | |
| } >> "${GITHUB_STEP_SUMMARY}" | |
| echo "::warning::${#PENDING[@]} required check(s) still pending. See step summary." | |
| fi | |
| if [[ ${#FAILED[@]} -gt 0 ]]; then | |
| for item in "${FAILED[@]}"; do | |
| echo "::error::Required check failed on main: $item" | |
| done | |
| echo "" | |
| echo "Fix the failing checks on main before dispatching a release." | |
| exit 1 | |
| fi | |
| echo "Pre-flight OK: all definitive required checks passed." | |
| - name: Checkout main | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2 | |
| with: | |
| ref: main | |
| fetch-depth: 0 | |
| persist-credentials: false | |
| - name: Compute and validate | |
| id: plan | |
| run: | | |
| set -euo pipefail | |
| args=("--mode=plan") | |
| if [[ -n "${INPUT_VERSION_OVERRIDE}" ]]; then | |
| args+=("--version-override=${INPUT_VERSION_OVERRIDE}") | |
| else | |
| args+=("--bump-kind=${INPUT_BUMP_KIND}") | |
| args+=("--version-type=${INPUT_VERSION_TYPE}") | |
| fi | |
| if [[ -n "${INPUT_EXPECTED_CURRENT}" ]]; then | |
| args+=("--expected-current=${INPUT_EXPECTED_CURRENT}") | |
| fi | |
| ./tools/release/bump.sh "${args[@]}" | tee plan.txt | |
| { | |
| grep -E '^current_name=' plan.txt | |
| grep -E '^new_name=' plan.txt | |
| grep -E '^new_code=' plan.txt | |
| } >> "$GITHUB_OUTPUT" | |
| - name: Tag collision check (local + remote) | |
| env: | |
| NEW_NAME: ${{ steps.plan.outputs.new_name }} | |
| run: | | |
| set -euo pipefail | |
| if git rev-parse --verify "refs/tags/v${NEW_NAME}" >/dev/null 2>&1; then | |
| echo "::error::Local tag v${NEW_NAME} already exists" | |
| exit 1 | |
| fi | |
| if git ls-remote --exit-code --tags origin "refs/tags/v${NEW_NAME}" >/dev/null; then | |
| echo "::error::Remote tag v${NEW_NAME} already exists" | |
| exit 1 | |
| fi | |
| - name: Write step summary | |
| env: | |
| CURRENT_NAME: ${{ steps.plan.outputs.current_name }} | |
| NEW_NAME: ${{ steps.plan.outputs.new_name }} | |
| NEW_CODE: ${{ steps.plan.outputs.new_code }} | |
| DRY_RUN: ${{ inputs.dry_run }} | |
| run: | | |
| set -euo pipefail | |
| { | |
| echo "## Release plan" | |
| echo "" | |
| echo "| | |" | |
| echo "|---|---|" | |
| echo "| Current | \`${CURRENT_NAME}\` |" | |
| echo "| New | \`${NEW_NAME}\` (code ${NEW_CODE}) |" | |
| echo "| Tag | \`v${NEW_NAME}\` |" | |
| echo "| Dry run | \`${DRY_RUN}\` |" | |
| echo "" | |
| echo "### bump.sh output" | |
| echo "" | |
| echo '```' | |
| cat plan.txt | |
| echo '```' | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| push-and-dispatch: | |
| name: Push and dispatch | |
| needs: compute-and-validate | |
| if: ${{ !inputs.dry_run }} | |
| runs-on: ubuntu-22.04 | |
| permissions: | |
| contents: read | |
| env: | |
| INPUT_BUMP_KIND: ${{ inputs.bump_kind }} | |
| INPUT_VERSION_TYPE: ${{ inputs.version_type }} | |
| INPUT_VERSION_OVERRIDE: ${{ inputs.version_override }} | |
| NEW_NAME: ${{ needs.compute-and-validate.outputs.new_name }} | |
| NEW_CODE: ${{ needs.compute-and-validate.outputs.new_code }} | |
| CURRENT_NAME_AT_PLAN: ${{ needs.compute-and-validate.outputs.current_name }} | |
| steps: | |
| - name: Mint App token | |
| id: app-token | |
| uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 #v3.1.1 | |
| with: | |
| client-id: ${{ secrets.RELEASE_APP_CLIENT_ID }} | |
| private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} | |
| - name: Resolve bot identity | |
| id: bot | |
| env: | |
| GH_TOKEN: ${{ steps.app-token.outputs.token }} | |
| APP_SLUG: ${{ steps.app-token.outputs.app-slug }} | |
| run: | | |
| set -euo pipefail | |
| user_id=$(gh api "/users/${APP_SLUG}%5Bbot%5D" --jq .id) | |
| echo "user_name=${APP_SLUG}[bot]" >> "$GITHUB_OUTPUT" | |
| echo "user_email=${user_id}+${APP_SLUG}[bot]@users.noreply.github.com" >> "$GITHUB_OUTPUT" | |
| - name: Checkout main with credentials | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2 | |
| with: | |
| ref: main | |
| fetch-depth: 0 | |
| persist-credentials: true | |
| token: ${{ steps.app-token.outputs.token }} | |
| - name: Verify state still matches plan from Job 1 | |
| run: | | |
| set -euo pipefail | |
| ./tools/release/bump.sh --mode=check --expected-current="${CURRENT_NAME_AT_PLAN}" | |
| - name: Re-check tag collision (state may have moved between jobs) | |
| run: | | |
| set -euo pipefail | |
| if git rev-parse --verify "refs/tags/v${NEW_NAME}" >/dev/null 2>&1; then | |
| echo "::error::Tag v${NEW_NAME} now exists (state moved between jobs)" | |
| exit 1 | |
| fi | |
| if git ls-remote --exit-code --tags origin "refs/tags/v${NEW_NAME}" >/dev/null; then | |
| echo "::error::Remote tag v${NEW_NAME} now exists (state moved between jobs)" | |
| exit 1 | |
| fi | |
| - name: Apply bump | |
| run: | | |
| set -euo pipefail | |
| args=("--mode=write" "--expected-current=${CURRENT_NAME_AT_PLAN}") | |
| if [[ -n "${INPUT_VERSION_OVERRIDE}" ]]; then | |
| args+=("--version-override=${INPUT_VERSION_OVERRIDE}") | |
| else | |
| args+=("--bump-kind=${INPUT_BUMP_KIND}") | |
| args+=("--version-type=${INPUT_VERSION_TYPE}") | |
| fi | |
| ./tools/release/bump.sh "${args[@]}" | |
| - name: Verify post-write state | |
| run: | | |
| set -euo pipefail | |
| ./tools/release/bump.sh --mode=check --expected-current="${NEW_NAME}" | |
| - name: Configure git identity | |
| env: | |
| BOT_USER_NAME: ${{ steps.bot.outputs.user_name }} | |
| BOT_USER_EMAIL: ${{ steps.bot.outputs.user_email }} | |
| run: | | |
| git config user.name "${BOT_USER_NAME}" | |
| git config user.email "${BOT_USER_EMAIL}" | |
| - name: Commit and tag | |
| run: | | |
| set -euo pipefail | |
| git add version.properties VERSION | |
| git commit -m "Release: ${NEW_NAME}" | |
| git tag -a "v${NEW_NAME}" -m "Release v${NEW_NAME}" | |
| - name: Atomic push (commit + tag) | |
| run: | | |
| set -euo pipefail | |
| git push --atomic origin "HEAD:refs/heads/main" "refs/tags/v${NEW_NAME}" | |
| - name: Write step summary | |
| run: | | |
| set -euo pipefail | |
| { | |
| echo "## Released" | |
| echo "" | |
| echo "| | |" | |
| echo "|---|---|" | |
| echo "| Tag | \`v${NEW_NAME}\` |" | |
| echo "| Version code | \`${NEW_CODE}\` |" | |
| echo "| Bump commit | on \`main\` |" | |
| echo "" | |
| echo "The App-token push triggers [Tagged releases](../../actions/workflows/release-tag.yml) automatically." | |
| } >> "$GITHUB_STEP_SUMMARY" |