diff --git a/.github/actions/merge-approved-pr/action.yml b/.github/actions/merge-approved-pr/action.yml new file mode 100644 index 00000000..36eb85a5 --- /dev/null +++ b/.github/actions/merge-approved-pr/action.yml @@ -0,0 +1,273 @@ +name: Merge Approved PR +description: 'Merge a PR if it targets the expected branches and has release-team approval.' + +inputs: + pr-number: + required: true + description: 'The number of the pull request to process' + required-base-branch: + required: true + description: 'The required base branch name (e.g., main)' + head-branch-pattern: + required: true + description: 'Regex pattern for the required head branch (e.g., ^stable-main-[0-9]+\.[0-9]+\.[0-9]+$)' + merge-method: + required: false + default: 'merge' + description: "Merge method: 'merge' (merge commit) or 'squash' (squash merge)" + verify-version-bump: + required: false + default: 'false' + description: 'Whether to verify that this PR is purely a version bump' + github-token: + required: true + description: 'GitHub token with permissions to merge' + +runs: + using: composite + steps: + # Validate required inputs + - name: Validate Inputs + env: + PR_NUMBER: ${{ inputs.pr-number }} + REQUIRED_BASE_BRANCH: ${{ inputs.required-base-branch }} + HEAD_BRANCH_PATTERN: ${{ inputs.head-branch-pattern }} + MERGE_METHOD: ${{ inputs.merge-method }} + VERIFY_VERSION_BUMP: ${{ inputs.verify-version-bump }} + shell: bash + run: | + if [[ -z "$PR_NUMBER" ]]; then + echo "::error::Missing required input: 'pr-number' must be provided." + exit 1 + fi + if [[ -z "$REQUIRED_BASE_BRANCH" ]]; then + echo "::error::Missing required input: 'required-base-branch' must be provided." + exit 1 + fi + if [[ -z "$HEAD_BRANCH_PATTERN" ]]; then + echo "::error::Missing required input: 'head-branch-pattern' must be provided." + exit 1 + fi + if [[ "$MERGE_METHOD" != "merge" && "$MERGE_METHOD" != "squash" ]]; then + echo "::error::Invalid input: 'merge-method' must be either 'merge' or 'squash'. Got '$MERGE_METHOD'." + exit 1 + fi + if [[ "$VERIFY_VERSION_BUMP" != "true" && "$VERIFY_VERSION_BUMP" != "false" ]]; then + echo "::error::Invalid input: 'verify-version-bump' must be either 'true' or 'false'. Got '$VERIFY_VERSION_BUMP'." + exit 1 + fi + echo "Inputs validated: pr-number='$PR_NUMBER', required-base-branch='$REQUIRED_BASE_BRANCH', head-branch-pattern='$HEAD_BRANCH_PATTERN', merge-method='$MERGE_METHOD', verify-version-bump='$VERIFY_VERSION_BUMP'" + + # Fetch PR metadata (head and base branches) using the GitHub API + - name: Get PR Details + uses: actions/github-script@v7 + env: + PR_NUMBER: ${{ inputs.pr-number }} + with: + github-token: ${{ inputs.github-token }} + script: | + // Fetch full details of the pull request associated with the comment + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: parseInt(process.env.PR_NUMBER, 10), + }); + + // Export base/head refs for subsequent steps + core.exportVariable('PR_BASE_REF', pr.base.ref); + core.exportVariable('PR_HEAD_REF', pr.head.ref); + + # Verify that the PR targets the required base branch from the required head pattern + - name: Verify Branch Names + id: verify-branches + env: + REQUIRED_BASE_BRANCH: ${{ inputs.required-base-branch }} + HEAD_BRANCH_PATTERN: ${{ inputs.head-branch-pattern }} + shell: bash + run: | + # Compare actual branches against requirements + if [[ "$PR_BASE_REF" != "$REQUIRED_BASE_BRANCH" ]] || ! [[ "$PR_HEAD_REF" =~ $HEAD_BRANCH_PATTERN ]]; then + echo "Skipping: PR must be from '$HEAD_BRANCH_PATTERN' to '$REQUIRED_BASE_BRANCH'. Found $PR_HEAD_REF -> $PR_BASE_REF" + echo "should_skip=true" >> "$GITHUB_OUTPUT" + else + echo "Branches match requirements: Source '$PR_HEAD_REF' -> Target '$PR_BASE_REF'." + echo "should_skip=false" >> "$GITHUB_OUTPUT" + fi + + # Check if the PR has the required approval status + - name: Verify Approval + if: steps.verify-branches.outputs.should_skip != 'true' + uses: actions/github-script@v7 + env: + PR_NUMBER: ${{ inputs.pr-number }} + with: + github-token: ${{ inputs.github-token }} + script: | + // Fetch all reviews for the PR + const { data: reviews } = await github.rest.pulls.listReviews({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: parseInt(process.env.PR_NUMBER, 10), + }); + + // Fetch members of the release team + let teamMembers = []; + try { + // Note: This requires a token with 'read:org' scope if the team is in an organization. + // GITHUB_TOKEN typically does not have this scope. Use a PAT if this fails. + const { data: members } = await github.rest.teams.listMembersInOrg({ + org: context.repo.owner, + team_slug: 'release-team', + per_page: 100, + }); + teamMembers = members.map((m) => m.login); + } catch (error) { + // Fallback: If we can't fetch team members (e.g. due to token permissions), + // we can fail or fallback to author_association. + // Given the strict requirement for "Release Team", we must fail if we can't verify it. + console.log( + `Error fetching release-team members for org '${context.repo.owner}': ${error.message}`, + ); + console.log('Verify that the token has read:org permissions and the team exists.'); + core.setFailed(`Failed to fetch release-team members: ${error.message}`); + return; + } + + // Process reviews to find the latest state for each reviewer + const reviewerStates = {}; + for (const review of reviews) { + if (review.state === 'APPROVED' || review.state === 'CHANGES_REQUESTED') { + reviewerStates[review.user.login] = review.state; + } else if (review.state === 'DISMISSED') { + delete reviewerStates[review.user.login]; + } + } + + // Check for approval from a release-team member and no outstanding change requests + const states = Object.entries(reviewerStates); + const hasTeamApproval = states.some( + ([user, state]) => state === 'APPROVED' && teamMembers.includes(user), + ); + const hasChangesRequested = states.some(([, state]) => state === 'CHANGES_REQUESTED'); + + if (!hasTeamApproval) { + core.setFailed('Skipping: PR is not approved by a member of the release-team.'); + } else if (hasChangesRequested) { + core.setFailed('Skipping: PR has changes requested.'); + } else { + console.log('PR approval check passed.'); + } + + # If verify-version-bump is true, then verify that + # - the only change is in package.json + # - the only line changed is "version" + # - the version change is a valid semver bump + - name: Verify a version bump + if: ${{ steps.verify-branches.outputs.should_skip != 'true' && inputs.verify-version-bump == 'true' }} + uses: actions/github-script@v7 + env: + PR_NUMBER: ${{ inputs.pr-number }} + with: + github-token: ${{ inputs.github-token }} + script: | + const files = await github.paginate(github.rest.pulls.listFiles, { + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: parseInt(process.env.PR_NUMBER, 10), + per_page: 100, + }); + + // We expect exactly one changed file: package.json + if (files.length !== 1 || files[0].filename !== 'package.json') { + core.setFailed( + 'Validation failed: PR must only change package.json when verify-version-bump is enabled.', + ); + return; + } + + const patch = files[0].patch; + if (!patch) { + // If GitHub omitted the patch (e.g., too large), don't emit the flag. + core.setFailed( + 'Validation failed: Unable to retrieve the package.json patch to verify the version bump.', + ); + return; + } + + const changedLines = patch + .split('\n') + .filter((line) => (line.startsWith('+') || line.startsWith('-'))) + .filter((line) => !line.startsWith('+++') && !line.startsWith('---')); + + if (changedLines.length !== 2) { + core.setFailed( + 'Validation failed: package.json must change only the version line (one removal, one addition).', + ); + return; + } + + const oldLine = changedLines.find((line) => line.startsWith('-') && /"version"\s*:/.test(line)); + const newLine = changedLines.find((line) => line.startsWith('+') && /"version"\s*:/.test(line)); + if (!oldLine || !newLine) { + core.setFailed('Validation failed: package.json must change only the version field.'); + return; + } + + const semverRe = /"version"\s*:\s*"(\d+)\.(\d+)\.(\d+)"/; + const oldMatch = oldLine.match(semverRe); + const newMatch = newLine.match(semverRe); + if (!oldMatch || !newMatch) { + core.setFailed('Validation failed: version must be in MAJOR.MINOR.PATCH numeric form.'); + return; + } + + const oldVersion = `${oldMatch[1]}.${oldMatch[2]}.${oldMatch[3]}`; + const newVersion = `${newMatch[1]}.${newMatch[2]}.${newMatch[3]}`; + if (oldVersion === newVersion) { + core.setFailed( + 'Validation failed: package.json version must change (format-only edits are not allowed).', + ); + return; + } + + const [oldMajor, oldMinor, oldPatch] = oldMatch.slice(1).map((n) => parseInt(n, 10)); + const [newMajor, newMinor, newPatch] = newMatch.slice(1).map((n) => parseInt(n, 10)); + + const isMajorBump = newMajor === oldMajor + 1 && newMinor === 0 && newPatch === 0; + const isMinorBump = newMajor === oldMajor && newMinor === oldMinor + 1 && newPatch === 0; + const isPatchBump = newMajor === oldMajor && newMinor === oldMinor && newPatch === oldPatch + 1; + + if (isMajorBump || isMinorBump || isPatchBump) { + console.log( + `The only change is in package.json, and the only line changed is 'version' with a valid semver bump (${oldVersion} -> ${newVersion}).`, + ); + } else { + core.setFailed( + 'Validation failed: version must bump by exactly +1 major (reset to .0.0), +1 minor (reset patch to .0), or +1 patch.', + ); + } + + # Execute the merge if all checks pass + - name: Merge PR + if: steps.verify-branches.outputs.should_skip != 'true' + uses: actions/github-script@v7 + env: + PR_NUMBER: ${{ inputs.pr-number }} + MERGE_METHOD: ${{ inputs.merge-method }} + with: + github-token: ${{ inputs.github-token }} + script: | + try { + // Perform the merge using the requested method ('merge' or 'squash') + await github.rest.pulls.merge({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: parseInt(process.env.PR_NUMBER, 10), + merge_method: process.env.MERGE_METHOD, + }); + console.log( + `PR merged successfully: Source '${process.env.PR_HEAD_REF}' -> Target '${process.env.PR_BASE_REF}'.`, + ); + } catch (error) { + core.setFailed(`Merge failed: ${error.message}`); + } diff --git a/.github/workflows/merge-approved-pr.yml b/.github/workflows/merge-approved-pr.yml index 23c03ff3..c0e59f27 100644 --- a/.github/workflows/merge-approved-pr.yml +++ b/.github/workflows/merge-approved-pr.yml @@ -1,3 +1,6 @@ +# DEPRECATED: use .github/actions/merge-approved-pr instead +# Can be removed when we move to github-tools v2 + name: Merge Approved PR on: @@ -15,6 +18,16 @@ on: required: true type: string description: 'Regex pattern for the required head branch (e.g., ^stable-main-[0-9]+\.[0-9]+\.[0-9]+$)' + merge-method: + required: false + type: string + default: 'merge' + description: "Merge method: 'merge' (merge commit) or 'squash' (squash merge)" + verify-version-bump: + required: false + type: boolean + default: false + description: 'Whether to verify that this PR is purely a version bump' secrets: github-token: required: true @@ -24,136 +37,12 @@ jobs: merge-pr: runs-on: ubuntu-latest steps: - # Validate required inputs - - name: Validate Inputs - run: | - if [[ -z "${{ inputs.required-base-branch }}" ]]; then - echo "::error::Missing required input: 'required-base-branch' must be provided." - exit 1 - fi - if [[ -z "${{ inputs.head-branch-pattern }}" ]]; then - echo "::error::Missing required input: 'head-branch-pattern' must be provided." - exit 1 - fi - echo "Inputs validated: required-base-branch='${{ inputs.required-base-branch }}', head-branch-pattern='${{ inputs.head-branch-pattern }}'" - - # Fetch PR metadata (head and base branches) using the GitHub API - - name: Get PR Details - id: get-pr - uses: actions/github-script@v7 - with: - github-token: ${{ secrets.github-token }} - script: | - // Fetch full details of the pull request associated with the comment - const { data: pr } = await github.rest.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: ${{ inputs.pr-number }} - }); - - // Output the base and head branch names for subsequent steps - core.setOutput('base', pr.base.ref); - core.setOutput('head', pr.head.ref); - - # Verify that the PR targets the required base branch from the required head pattern - - name: Verify Branch Names - id: verify-branches - run: | - # Get required branch patterns from inputs - REQUIRED_BASE="${{ inputs.required-base-branch }}" - HEAD_PATTERN="${{ inputs.head-branch-pattern }}" - - # Get actual values from the previous step - ACTUAL_BASE="${{ steps.get-pr.outputs.base }}" - ACTUAL_HEAD="${{ steps.get-pr.outputs.head }}" - - # Compare actual branches against requirements - if [[ "$ACTUAL_BASE" != "$REQUIRED_BASE" ]] || ! [[ "$ACTUAL_HEAD" =~ $HEAD_PATTERN ]]; then - echo "Skipping: PR must be from '$HEAD_PATTERN' to '$REQUIRED_BASE'. Found $ACTUAL_HEAD -> $ACTUAL_BASE" - echo "should_skip=true" >> "$GITHUB_OUTPUT" - else - echo "Branches match requirements: Source '$ACTUAL_HEAD' -> Target '$ACTUAL_BASE'." - echo "should_skip=false" >> "$GITHUB_OUTPUT" - fi - - # Check if the PR has the required approval status - - name: Verify Approval - id: verify-approval - if: steps.verify-branches.outputs.should_skip != 'true' - uses: actions/github-script@v7 - with: - github-token: ${{ secrets.github-token }} - script: | - // Fetch all reviews for the PR - const { data: reviews } = await github.rest.pulls.listReviews({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: ${{ inputs.pr-number }} - }); - - // Fetch members of the release team - let teamMembers = []; - try { - // Note: This requires a token with 'read:org' scope if the team is in an organization. - // GITHUB_TOKEN typically does not have this scope. Use a PAT if this fails. - const { data: members } = await github.rest.teams.listMembersInOrg({ - org: context.repo.owner, - team_slug: 'release-team', - per_page: 100 - }); - teamMembers = members.map(m => m.login); - } catch (error) { - // Fallback: If we can't fetch team members (e.g. due to token permissions), - // we can fail or fallback to author_association. - // Given the strict requirement for "Release Team", we must fail if we can't verify it. - console.log(`Error fetching release-team members for org '${context.repo.owner}': ${error.message}`); - console.log('Verify that the token has read:org permissions and the team exists.'); - core.setFailed(`Failed to fetch release-team members: ${error.message}`); - return; - } - - // Process reviews to find the latest state for each reviewer - const reviewerStates = {}; - for (const review of reviews) { - if (review.state === 'APPROVED' || review.state === 'CHANGES_REQUESTED') { - reviewerStates[review.user.login] = review.state; - } else if (review.state === 'DISMISSED') { - delete reviewerStates[review.user.login]; - } - } - - // Check for approval from a release-team member and no outstanding change requests - const states = Object.entries(reviewerStates); - const hasTeamApproval = states.some(([user, state]) => state === 'APPROVED' && teamMembers.includes(user)); - const hasChangesRequested = states.some(([, state]) => state === 'CHANGES_REQUESTED'); - - if (!hasTeamApproval) { - core.setFailed('Skipping: PR is not approved by a member of the release-team.'); - } else if (hasChangesRequested) { - core.setFailed('Skipping: PR has changes requested.'); - } else { - console.log('PR approval check passed.'); - } - - # Execute the merge if all checks pass - - name: Merge PR - if: steps.verify-branches.outputs.should_skip != 'true' - uses: actions/github-script@v7 - env: - BASE_REF: ${{ steps.get-pr.outputs.base }} - HEAD_REF: ${{ steps.get-pr.outputs.head }} + - name: Merge approved PR + uses: ./.github/actions/merge-approved-pr with: + pr-number: ${{ inputs.pr-number }} + required-base-branch: ${{ inputs.required-base-branch }} + head-branch-pattern: ${{ inputs.head-branch-pattern }} + merge-method: ${{ inputs.merge-method }} + verify-version-bump: ${{ inputs.verify-version-bump && 'true' || 'false' }} github-token: ${{ secrets.github-token }} - script: | - try { - // Perform the merge using the 'merge' method (creates a merge commit, does not squash) - await github.rest.pulls.merge({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: ${{ inputs.pr-number }}, - merge_method: 'merge' - }); - console.log(`PR merged successfully: Source '${process.env.HEAD_REF}' -> Target '${process.env.BASE_REF}'.`); - } catch (error) { - core.setFailed(`Merge failed: ${error.message}`); - }