fix: auto mode gets stuck in validation loop on pre-existing test failures #459
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 | |
| on: | |
| pull_request: | |
| types: [opened, synchronize, labeled, closed] | |
| branches: | |
| - main | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| jobs: | |
| # NOTE: Auto-labeling (including candidate-release) is handled by auto-label.yml | |
| # This workflow aggregates ALL merged candidate-release PRs since the last release | |
| # and creates a stable release PR with the appropriate semver bump. | |
| # Create/update release PR when a PR with candidate-release label is merged | |
| create-release-pr: | |
| name: Create Release PR | |
| if: | | |
| github.event.action == 'closed' && | |
| github.event.pull_request.merged == true && | |
| contains(github.event.pull_request.labels.*.name, 'candidate-release') && | |
| !startsWith(github.event.pull_request.head.ref, 'release/') | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 | |
| with: | |
| fetch-depth: 0 | |
| - name: Setup pnpm | |
| uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6 | |
| with: | |
| node-version: '20' | |
| cache: 'pnpm' | |
| - name: Collect merged PRs since last release | |
| id: collect | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| # Wait briefly for GitHub API consistency | |
| sleep 5 | |
| # Find latest release tag | |
| LATEST_TAG=$(git describe --tags --abbrev=0 --match 'v*' 2>/dev/null || echo "") | |
| if [ -n "$LATEST_TAG" ]; then | |
| TAG_DATE=$(git log -1 --format=%aI "$LATEST_TAG") | |
| echo "Latest release tag: $LATEST_TAG (date: $TAG_DATE)" | |
| else | |
| TAG_DATE="1970-01-01T00:00:00Z" | |
| echo "No release tags found, collecting all candidate-release PRs" | |
| fi | |
| # Query all merged PRs with candidate-release label since the tag date | |
| # Exclude release branch PRs | |
| PR_JSON=$(gh pr list \ | |
| --state merged \ | |
| --label "candidate-release" \ | |
| --base main \ | |
| --json number,title,mergedAt,headRefName \ | |
| --limit 100 \ | |
| --jq "[.[] | select(.mergedAt > \"$TAG_DATE\" and (.headRefName | startswith(\"release/\") | not))]" | |
| ) | |
| PR_COUNT=$(echo "$PR_JSON" | jq length) | |
| echo "Found $PR_COUNT merged PRs since $LATEST_TAG" | |
| if [ "$PR_COUNT" -eq 0 ]; then | |
| echo "No unreleased candidate PRs found, exiting" | |
| echo "skip=true" >> $GITHUB_OUTPUT | |
| exit 0 | |
| fi | |
| # Base64-encode to safely pass through GitHub Actions outputs | |
| echo "prs=$(echo "$PR_JSON" | base64 -w0)" >> $GITHUB_OUTPUT | |
| echo "pr_count=$PR_COUNT" >> $GITHUB_OUTPUT | |
| echo "skip=false" >> $GITHUB_OUTPUT | |
| - name: Determine version bump and build changes | |
| if: steps.collect.outputs.skip != 'true' | |
| id: bump | |
| env: | |
| PRS_B64: ${{ steps.collect.outputs.prs }} | |
| run: | | |
| PR_JSON=$(echo "$PRS_B64" | base64 -d) | |
| # Determine the highest bump type across all PRs | |
| # Only match Conventional Commits breaking syntax: feat!: or feat(scope)!: | |
| BUMP="patch" | |
| while IFS= read -r TITLE; do | |
| if [[ "$TITLE" == *"BREAKING"* ]] || [[ "$TITLE" =~ ^[a-z]+(\([a-z-]*\))?!: ]]; then | |
| BUMP="major" | |
| break | |
| elif [[ "$TITLE" == feat* ]] || [[ "$TITLE" == *"feat("* ]]; then | |
| BUMP="minor" | |
| fi | |
| done < <(echo "$PR_JSON" | jq -r '.[].title') | |
| echo "bump=$BUMP" >> $GITHUB_OUTPUT | |
| CURRENT_VERSION=$(node -p "require('./package.json').version") | |
| echo "current=$CURRENT_VERSION" >> $GITHUB_OUTPUT | |
| # Build changes JSON with type classification and cleaned titles | |
| CHANGES=$(echo "$PR_JSON" | jq '[.[] | { | |
| pr: .number, | |
| rawTitle: .title, | |
| type: ( | |
| if (.title | test("^feat"; "i")) then "Added" | |
| elif (.title | test("^fix"; "i")) then "Fixed" | |
| elif (.title | test("^docs"; "i")) then "Documentation" | |
| else "Changed" | |
| end | |
| ), | |
| title: (.title | sub("^[a-z]+\\(?[a-z-]*\\)?!?:\\s*"; "")) | |
| }]') | |
| echo "changes=$(echo "$CHANGES" | base64 -w0)" >> $GITHUB_OUTPUT | |
| echo "Version bump: $BUMP (current: $CURRENT_VERSION)" | |
| echo "Changes: $(echo "$CHANGES" | jq -r '.[] | " - #\(.pr): \(.rawTitle)"')" | |
| - name: Calculate new version | |
| if: steps.collect.outputs.skip != 'true' | |
| id: version | |
| env: | |
| CURRENT: ${{ steps.bump.outputs.current }} | |
| BUMP: ${{ steps.bump.outputs.bump }} | |
| run: | | |
| # Strip any prerelease suffix (transition from old beta versions) | |
| BASE=$(echo "$CURRENT" | sed 's/-.*$//') | |
| IFS='.' read -r MAJOR MINOR PATCH <<< "$BASE" | |
| 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=$NEW_VERSION" >> $GITHUB_OUTPUT | |
| echo "New version will be: $NEW_VERSION (bump: $BUMP)" | |
| - name: Check for existing release PR | |
| if: steps.collect.outputs.skip != 'true' | |
| id: check | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| # Search for any open PR with a release/v branch prefix | |
| EXISTING=$(gh pr list \ | |
| --state open \ | |
| --label release \ | |
| --json number,headRefName \ | |
| --jq '[.[] | select(.headRefName | startswith("release/v"))] | first // empty' | |
| ) | |
| if [ -n "$EXISTING" ] && [ "$EXISTING" != "null" ]; then | |
| EXISTING_NUMBER=$(echo "$EXISTING" | jq -r '.number') | |
| EXISTING_BRANCH=$(echo "$EXISTING" | jq -r '.headRefName') | |
| EXISTING_VERSION=$(echo "$EXISTING_BRANCH" | sed 's|release/v||') | |
| echo "found=true" >> $GITHUB_OUTPUT | |
| echo "number=$EXISTING_NUMBER" >> $GITHUB_OUTPUT | |
| echo "branch=$EXISTING_BRANCH" >> $GITHUB_OUTPUT | |
| echo "version=$EXISTING_VERSION" >> $GITHUB_OUTPUT | |
| echo "Found existing release PR #$EXISTING_NUMBER for v$EXISTING_VERSION" | |
| else | |
| echo "found=false" >> $GITHUB_OUTPUT | |
| echo "No existing release PR found" | |
| fi | |
| - name: Create or update release branch and PR | |
| if: steps.collect.outputs.skip != 'true' | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| NEW_VERSION: ${{ steps.version.outputs.new }} | |
| CURRENT_VERSION: ${{ steps.bump.outputs.current }} | |
| BUMP_TYPE: ${{ steps.bump.outputs.bump }} | |
| CHANGES_B64: ${{ steps.bump.outputs.changes }} | |
| EXISTING_FOUND: ${{ steps.check.outputs.found }} | |
| EXISTING_NUMBER: ${{ steps.check.outputs.number }} | |
| EXISTING_BRANCH: ${{ steps.check.outputs.branch }} | |
| EXISTING_VERSION: ${{ steps.check.outputs.version }} | |
| run: | | |
| TODAY=$(date +%Y-%m-%d) | |
| RELEASE_BRANCH="release/v${NEW_VERSION}" | |
| CHANGES_JSON=$(echo "$CHANGES_B64" | base64 -d) | |
| # Configure git | |
| git config user.name "github-actions[bot]" | |
| git config user.email "github-actions[bot]@users.noreply.github.com" | |
| # Handle the three cases for release branch | |
| if [ "$EXISTING_FOUND" = "true" ]; then | |
| if [ "$EXISTING_VERSION" = "$NEW_VERSION" ]; then | |
| # Same version — update existing branch by resetting to main | |
| echo "Updating existing release branch $RELEASE_BRANCH" | |
| git checkout "$RELEASE_BRANCH" | |
| git reset --hard origin/main | |
| else | |
| # Version changed — close old PR, delete old branch, start fresh | |
| echo "Version changed from v$EXISTING_VERSION to v$NEW_VERSION" | |
| gh pr close "$EXISTING_NUMBER" \ | |
| --comment "Superseded by new release PR for v${NEW_VERSION} (bump type changed to $BUMP_TYPE)" | |
| git push origin --delete "$EXISTING_BRANCH" 2>/dev/null || true | |
| git checkout main | |
| git pull origin main | |
| git checkout -b "$RELEASE_BRANCH" | |
| fi | |
| else | |
| # No existing release PR — create new branch | |
| git checkout main | |
| git pull origin main | |
| git checkout -b "$RELEASE_BRANCH" | |
| fi | |
| # Update package.json version | |
| npm version "$NEW_VERSION" --no-git-tag-version | |
| # Sync pnpm-lock.yaml with the updated package.json version | |
| pnpm install --lockfile-only | |
| # Update CHANGELOG.md with all changes | |
| NEW_VERSION="$NEW_VERSION" \ | |
| TODAY="$TODAY" \ | |
| CHANGES_JSON="$CHANGES_JSON" \ | |
| node .github/scripts/update-changelog.cjs | |
| # Commit changes | |
| git add package.json pnpm-lock.yaml CHANGELOG.md | |
| git commit -m "chore(release): bump version to ${NEW_VERSION}" | |
| # Push (force in case we reset an existing branch) | |
| git push origin "$RELEASE_BRANCH" --force | |
| # Build PR body with all included changes | |
| PR_CHANGES_LIST=$(echo "$CHANGES_JSON" | jq -r '.[] | "- #\(.pr): \(.rawTitle)"') | |
| PR_BODY=$(cat <<EOF | |
| ## Release v${NEW_VERSION} | |
| This PR was automatically created to prepare release v${NEW_VERSION}. | |
| ### Changes included | |
| ${PR_CHANGES_LIST} | |
| ### Version bump | |
| - Previous: ${CURRENT_VERSION} | |
| - New: ${NEW_VERSION} | |
| - Type: ${BUMP_TYPE} | |
| ### What happens when this PR is merged | |
| 1. Git tag \`v${NEW_VERSION}\` will be created | |
| 2. GitHub Release will be published | |
| 3. Package will be published to npm with \`latest\` tag | |
| --- | |
| This PR was auto-generated by the release workflow. | |
| EOF | |
| ) | |
| if [ "$EXISTING_FOUND" = "true" ] && [ "$EXISTING_VERSION" = "$NEW_VERSION" ]; then | |
| # Update existing PR body | |
| gh pr edit "$EXISTING_NUMBER" --body "$PR_BODY" | |
| echo "Updated existing release PR #${EXISTING_NUMBER}" | |
| else | |
| # Create new PR | |
| gh pr create \ | |
| --title "chore(release): v${NEW_VERSION}" \ | |
| --body "$PR_BODY" \ | |
| --label "release" \ | |
| --base main | |
| fi | |
| - name: Comment on source PR | |
| if: steps.collect.outputs.skip != 'true' | |
| uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 | |
| env: | |
| NEW_VERSION: ${{ steps.version.outputs.new }} | |
| EXISTING_FOUND: ${{ steps.check.outputs.found }} | |
| with: | |
| script: | | |
| const { owner, repo } = context.repo; | |
| const issue_number = context.payload.pull_request.number; | |
| const newVersion = process.env.NEW_VERSION; | |
| const existingFound = process.env.EXISTING_FOUND; | |
| const body = existingFound === 'true' | |
| ? `Your changes have been added to the existing release PR for **v${newVersion}**.` | |
| : `A release PR has been created for **v${newVersion}** including your changes!`; | |
| await github.rest.issues.createComment({ | |
| owner, repo, issue_number, | |
| body | |
| }); |