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 | |
| # Ported from app-main/.github/workflows/release-prepare.yml. Differences: | |
| # - Source-of-truth is package.json#version (not version.properties + VERSION). | |
| # - No versionCode (web has no app-store gating). | |
| # - push-and-dispatch is gated by the `web-production` environment so the | |
| # operator approves BEFORE the tag is pushed (diverges from Android's | |
| # "cancel between jobs" pattern — see RELEASE.md for rationale). | |
| 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.0.0-rc0 (overrides bump_kind/version_type). REQUIRED for first release.' | |
| type: string | |
| default: '' | |
| expected_current: | |
| description: 'Optional safety check: fail if current package.json#version does not match (e.g. 1.0.0-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 | |
| outputs: | |
| new_name: ${{ steps.plan.outputs.new_name }} | |
| 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 "Must dispatch from main, got ${GITHUB_REF}" >&2 | |
| exit 1 | |
| fi | |
| - name: Checkout main | |
| uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v6.0.2 | |
| with: | |
| ref: main | |
| fetch-depth: 0 | |
| persist-credentials: false | |
| - name: Verify main is green | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| set -euo pipefail | |
| sha=$(git rev-parse HEAD) | |
| echo "Checking check-runs for ${sha}" | |
| checks=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${sha}/check-runs?per_page=100" \ | |
| --jq '[.check_runs[] | select(.name != "Compute and validate" and .name != "Push and dispatch") | {name, status, conclusion}]') | |
| echo "$checks" | |
| failing=$(echo "$checks" | jq '[.[] | select(.status == "completed") | select(.conclusion != "success" and .conclusion != "neutral" and .conclusion != "skipped")] | length') | |
| pending=$(echo "$checks" | jq '[.[] | select(.status != "completed")] | length') | |
| echo "Failing: $failing, Pending: $pending" | |
| if [[ "$failing" != "0" ]]; then | |
| echo "::error::Refusing to bump: main has $failing failing checks." >&2 | |
| exit 1 | |
| fi | |
| if [[ "$pending" != "0" ]]; then | |
| echo "::warning::main has $pending pending checks; proceeding because pending checks are non-blocking." >&2 | |
| fi | |
| # Belt-and-braces: assert the expected check names are present and successful so | |
| # a commit that somehow skipped CI entirely can't sneak through. | |
| for expected in check test build smoke release-tooling; do | |
| ok=$(echo "$checks" | jq --arg n "$expected" '[.[] | select(.name == $n and .conclusion == "success")] | length') | |
| if [[ "$ok" == "0" ]]; then | |
| echo "::error::Required check '$expected' is missing or not successful on this commit." >&2 | |
| exit 1 | |
| fi | |
| done | |
| echo "All required checks present and successful." | |
| - name: Setup environment | |
| uses: ./.github/actions/common-setup | |
| - 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 | |
| } >> "$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 "Local tag v${NEW_NAME} already exists" >&2 | |
| exit 1 | |
| fi | |
| if git ls-remote --exit-code --tags origin "refs/tags/v${NEW_NAME}" >/dev/null; then | |
| echo "Remote tag v${NEW_NAME} already exists" >&2 | |
| exit 1 | |
| fi | |
| - name: Write step summary | |
| env: | |
| CURRENT_NAME: ${{ steps.plan.outputs.current_name }} | |
| NEW_NAME: ${{ steps.plan.outputs.new_name }} | |
| DRY_RUN: ${{ inputs.dry_run }} | |
| run: | | |
| set -euo pipefail | |
| { | |
| echo "## Release plan" | |
| echo | |
| echo "| | |" | |
| echo "|---|---|" | |
| echo "| Current | \`${CURRENT_NAME}\` |" | |
| echo "| New | \`${NEW_NAME}\` |" | |
| 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 | |
| # web-production gates the commit + tag push. Approve only after reviewing | |
| # the plan summary from Job 1. | |
| environment: web-production | |
| 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 }} | |
| 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@a8d616148505b5069dccd32f177bb87d7f39123b # 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@11bd71901bbe5b1630ceea73d27597364c9af683 # v6.0.2 | |
| with: | |
| ref: main | |
| fetch-depth: 0 | |
| persist-credentials: true | |
| token: ${{ steps.app-token.outputs.token }} | |
| - name: Setup environment | |
| uses: ./.github/actions/common-setup | |
| - 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 "Local tag v${NEW_NAME} already exists" >&2 | |
| exit 1 | |
| fi | |
| if git ls-remote --exit-code --tags origin "refs/tags/v${NEW_NAME}" >/dev/null; then | |
| echo "Remote tag v${NEW_NAME} already exists" >&2 | |
| 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 matches plan | |
| 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 package.json | |
| 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 "| Bump commit | on \`main\` |" | |
| echo "| Downstream | triggered \`release-tag.yml\` via tag push |" | |
| echo | |
| echo "Watch the [Tagged releases](../../actions/workflows/release-tag.yml) workflow for build + publish." | |
| } >> "$GITHUB_STEP_SUMMARY" |