Prepare Release PR #40
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: Prepare Release PR | |
| on: | |
| schedule: | |
| - cron: '0 20 * * 0,3' | |
| workflow_dispatch: | |
| inputs: | |
| force-release: | |
| description: Force a release even when no open release PR exists | |
| required: false | |
| default: false | |
| type: boolean | |
| permissions: | |
| contents: read | |
| concurrency: | |
| group: release | |
| cancel-in-progress: false | |
| jobs: | |
| prepare-release: | |
| name: Prepare Release PR | |
| runs-on: ubuntu-latest | |
| steps: | |
| - id: get-app-token | |
| name: Get GitHub App Installation Token | |
| uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 | |
| with: | |
| app-id: ${{ secrets.APPLICATION_ID }} | |
| private-key: ${{ secrets.APPLICATION_PRIVATE_KEY }} | |
| owner: ${{ github.repository_owner}} | |
| - id: discover-release-pr | |
| name: Discover existing release PR | |
| env: | |
| GH_REPO: ${{ github.repository }} | |
| GH_TOKEN: ${{ steps.get-app-token.outputs.token }} | |
| run: | | |
| PR_NUMBER=$(gh pr list --head next --base release --state open --json number --jq '.[0].number // empty') | |
| echo "pr-number=${PR_NUMBER}" >> "$GITHUB_OUTPUT" | |
| if [ -n "$PR_NUMBER" ]; then | |
| echo 'has-open-pr=true' >> "$GITHUB_OUTPUT" | |
| else | |
| echo 'has-open-pr=false' >> "$GITHUB_OUTPUT" | |
| fi | |
| - id: git-user | |
| name: Get GitHub App User ID and Setup Git user | |
| env: | |
| GH_TOKEN: ${{ steps.get-app-token.outputs.token }} | |
| run: | | |
| name="${{ steps.get-app-token.outputs.app-slug }}[bot]" | |
| user_id=$(gh api "/users/${name}" --jq .id) | |
| email="${user_id}+${name}@users.noreply.github.com" | |
| echo "user-email=${email}" >> "$GITHUB_OUTPUT" | |
| echo "user-name=${name}" >> "$GITHUB_OUTPUT" | |
| git config --global user.email "${email}" | |
| git config --global user.name "${name}" | |
| - name: Checkout `release` branch | |
| uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 | |
| with: | |
| fetch-depth: 0 | |
| filter: blob:none | |
| ref: release | |
| show-progress: false | |
| token: ${{ steps.get-app-token.outputs.token }} | |
| - name: Reset to last release tag and merge `main` | |
| run: | | |
| set -euo pipefail | |
| last_tag=$(git tag --list 'v*' --sort=-version:refname | { grep -vF '+harness' || true; } | head -1) | |
| echo "Resetting to ${last_tag}" | |
| git reset --hard "${last_tag}" | |
| git fetch origin main | |
| if ! git merge --no-ff -Xtheirs -m 'skip: merge main [skip release]' origin/main; then | |
| # Typical trigger: rename/rename on dist/artifact-*.js (the bundle hash | |
| # changes every commit on main). -Xtheirs handles content conflicts but | |
| # cannot resolve rename/rename. Bias fully to main's tree on conflict — | |
| # the release branch's purpose is to mirror main. | |
| echo "Merge had unresolvable conflicts; taking main's tree verbatim." | |
| git merge --abort | |
| git checkout origin/main -- . | |
| git add -A | |
| git commit --no-verify -m 'skip: merge main [skip release] (conflict-resolved)' | |
| fi | |
| - name: Setup Node.js and Bun | |
| uses: ./.github/actions/setup | |
| - id: release-preview | |
| name: Release Preview | |
| run: node --experimental-strip-types --experimental-transform-types scripts/release/preview-next-release.ts --to HEAD | |
| - if: steps.release-preview.outputs.next_version != '' | |
| name: Push `next` branch | |
| run: git push --no-verify --force-with-lease origin HEAD:next | |
| - if: steps.release-preview.outputs.next_version != '' | |
| name: Generate PR body | |
| run: | | |
| VERSION="${{ steps.release-preview.outputs.next_version }}" | |
| last_tag=$(git tag --list 'v*' --sort=-version:refname | { grep -vF '+harness' || true; } | head -1) | |
| COMMIT_LIST=$(git log "${last_tag}..HEAD" --format='* %s (%h)' --no-merges --max-count=50) | |
| TIMESTAMP=$(date -u +"%Y-%m-%d %H:%M UTC") | |
| cat > /tmp/pr-body.md <<EOF | |
| ## Pending Release: v${VERSION} | |
| **Merge this PR with a merge commit to trigger a release.** | |
| ### Commits Since Last Release | |
| EOF | |
| echo "${COMMIT_LIST:-No new commits}" >> /tmp/pr-body.md | |
| echo "" >> /tmp/pr-body.md | |
| echo "---" >> /tmp/pr-body.md | |
| echo "*Auto-generated by the release pipeline. Updated: ${TIMESTAMP}*" >> /tmp/pr-body.md | |
| - if: steps.release-preview.outputs.next_version != '' | |
| env: | |
| GH_TOKEN: ${{ steps.get-app-token.outputs.token }} | |
| id: upsert-release-pr | |
| name: Create or Update Release PR | |
| run: | | |
| PR_NUMBER="${{ steps.discover-release-pr.outputs.pr-number }}" | |
| VERSION="${{ steps.release-preview.outputs.next_version }}" | |
| TITLE="chore(release): pending release v${VERSION}" | |
| if [ -n "$PR_NUMBER" ]; then | |
| gh pr edit "$PR_NUMBER" --title "$TITLE" --body-file /tmp/pr-body.md | |
| else | |
| gh pr create --head next --base release --title "$TITLE" --body-file /tmp/pr-body.md | |
| PR_NUMBER=$(gh pr list --head next --base release --state open --json number --jq '.[0].number // empty') | |
| fi | |
| echo "pr-number=${PR_NUMBER}" >> "$GITHUB_OUTPUT" | |
| - if: steps.release-preview.outputs.next_version != '' | |
| env: | |
| GH_TOKEN: ${{ steps.get-app-token.outputs.token }} | |
| PR_NUMBER: ${{ steps.upsert-release-pr.outputs.pr-number }} | |
| name: Wait for release PR mergeability | |
| run: | | |
| if [ -z "$PR_NUMBER" ]; then | |
| echo 'Missing release PR number after upsert' | |
| exit 1 | |
| fi | |
| attempt=0 | |
| while [ "$attempt" -lt 10 ]; do | |
| MERGE_STATE=$(gh pr view "$PR_NUMBER" --json mergeStateStatus --jq '.mergeStateStatus') | |
| if [ "$MERGE_STATE" = 'DIRTY' ] || [ "$MERGE_STATE" = 'BLOCKED' ]; then | |
| echo "Release PR is not mergeable: ${MERGE_STATE}" | |
| exit 1 | |
| fi | |
| if [ "$MERGE_STATE" = 'CLEAN' ] || [ "$MERGE_STATE" = 'HAS_HOOKS' ] || [ "$MERGE_STATE" = 'UNSTABLE' ]; then | |
| exit 0 | |
| fi | |
| sleep 2 | |
| attempt=$((attempt + 1)) | |
| done | |
| echo 'Timed out waiting for release PR mergeability' | |
| exit 1 | |
| - if: steps.release-preview.outputs.next_version == '' && steps.discover-release-pr.outputs.has-open-pr == 'true' | |
| env: | |
| GH_TOKEN: ${{ steps.get-app-token.outputs.token }} | |
| PR_NUMBER: ${{ steps.discover-release-pr.outputs.pr-number }} | |
| name: Close stale pending release PR | |
| run: gh pr close "$PR_NUMBER" --comment "Closing stale pending release PR because the latest preview predicts no release." | |
| - if: steps.release-preview.outputs.next_version == '' | |
| name: Delete stale `next` branch | |
| run: git push --no-verify origin --delete next || true | |
| - id: merge-policy | |
| if: steps.release-preview.outputs.next_version != '' | |
| name: Decide whether to merge release PR now | |
| run: | | |
| SHOULD_MERGE=false | |
| if [ "${{ github.event_name }}" = 'schedule' ]; then | |
| SHOULD_MERGE=true | |
| fi | |
| if [ "${{ github.event_name }}" = 'workflow_dispatch' ] && [ "${{ github.event.inputs.force-release }}" = 'true' ]; then | |
| SHOULD_MERGE=true | |
| fi | |
| if [ "${{ github.event_name }}" = 'workflow_dispatch' ] && [ "${{ steps.discover-release-pr.outputs.has-open-pr }}" = 'true' ]; then | |
| SHOULD_MERGE=true | |
| fi | |
| echo "should-merge=${SHOULD_MERGE}" >> "$GITHUB_OUTPUT" | |
| - if: steps.release-preview.outputs.next_version != '' && steps.merge-policy.outputs.should-merge == 'true' | |
| env: | |
| GH_TOKEN: ${{ steps.get-app-token.outputs.token }} | |
| PR_NUMBER: ${{ steps.upsert-release-pr.outputs.pr-number }} | |
| name: Merge release PR with merge commit | |
| run: | | |
| if [ -n "$PR_NUMBER" ]; then | |
| gh pr merge "$PR_NUMBER" --merge --delete-branch=false | |
| fi |