Skip to content

Release prepare

Release prepare #8

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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # 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.
# NB: these are the `name:` fields from code-checks.yml (what GitHub reports
# as the check-run name), not the YAML job IDs.
expected_checks=(
"Type check"
"Unit tests"
"Production build"
"Smoke (real sync-server)"
"Release tooling"
"E2E smoke"
)
for expected in "${expected_checks[@]}"; 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@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: 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"