diff --git a/.github/workflows/merge-approved-pr.yml b/.github/workflows/merge-approved-pr.yml new file mode 100644 index 00000000..27f2cb7b --- /dev/null +++ b/.github/workflows/merge-approved-pr.yml @@ -0,0 +1,139 @@ +name: Merge Approved PR + +on: + workflow_call: + inputs: + pr-number: + required: true + type: number + description: 'The number of the pull request to process' + secrets: + github-token: + required: true + description: 'GitHub token with permissions to merge' + +jobs: + merge-pr: + runs-on: ubuntu-latest + steps: + # 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 'main' from 'stable-main-x.y.z' + - name: Verify Branch Names + id: verify-branches + run: | + # Define the required branch pattern + REQUIRED_BASE="main" + # Head branch must match pattern: stable-main-X.Y.Z where X, Y, Z are integers + HEAD_PATTERN="^stable-main-[0-9]+\.[0-9]+\.[0-9]+$" + + # 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 'stable-main-X.Y.Z' 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 }} + with: + 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}`); + } diff --git a/CHANGELOG.md b/CHANGELOG.md index f16c1d94..88ec4eab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.1.0] + +### Added + +- Add merging GitHub action ([#172](https://github.com/MetaMask/github-tools/pull/172)) + +### Changed + +- Bump `@metamask/auto-changelog` from `^5.1.0` to `^5.2.0` ([#175](https://github.com/MetaMask/github-tools/pull/175)) + - Adds deduplication for commits with no PR number in subject (non-"Squash & Merge" commits) + - Merge commits are now deduplicated using commit body instead of the generic merge subject + ## [1.0.0] ### Added @@ -23,5 +35,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Some inputs were renamed for consistency across actions. - Bump `actions/checkout` and `actions/setup-node` to `v6` ([#173](https://github.com/MetaMask/github-tools/pull/173)) -[Unreleased]: https://github.com/MetaMask/github-tools/compare/v1.0.0...HEAD +[Unreleased]: https://github.com/MetaMask/github-tools/compare/v1.1.0...HEAD +[1.1.0]: https://github.com/MetaMask/github-tools/compare/v1.0.0...v1.1.0 [1.0.0]: https://github.com/MetaMask/github-tools/releases/tag/v1.0.0 diff --git a/package.json b/package.json index e0440082..23a45966 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/github-tools", - "version": "1.0.0", + "version": "1.1.0", "private": true, "description": "Tools for interacting with the GitHub API to do metrics gathering", "repository": { @@ -26,7 +26,7 @@ "update-release-sheet": "node .github/scripts/update-release-sheet.mjs" }, "dependencies": { - "@metamask/auto-changelog": "^5.1.0", + "@metamask/auto-changelog": "^5.2.0", "@metamask/utils": "^7.1.0", "@octokit/graphql": "^7.0.1", "@octokit/request": "^8.1.1", diff --git a/yarn.lock b/yarn.lock index e2ff0012..1d0a9ce5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -941,9 +941,9 @@ __metadata: languageName: node linkType: hard -"@metamask/auto-changelog@npm:^5.1.0": - version: 5.1.0 - resolution: "@metamask/auto-changelog@npm:5.1.0" +"@metamask/auto-changelog@npm:^5.2.0": + version: 5.2.0 + resolution: "@metamask/auto-changelog@npm:5.2.0" dependencies: "@octokit/rest": "npm:^20.0.0" diff: "npm:^5.0.0" @@ -954,7 +954,7 @@ __metadata: prettier: ">=3.0.0" bin: auto-changelog: dist/cli.mjs - checksum: 10/ee9f651c313a2377a21a304383f56f1872b4ce6411a0b61cf0b70b23bff9d3204332cd5a052cdfcaf363ce15424a7a1fdbde99a6b4352f2472fbe6b6d27c5791 + checksum: 10/86ca4f56a0c86a1fabbe4b7b66a28bda39e59c51c996aadedeb5f341b3e93acfd7ca5dafebbd82852070f12324bbd83fc7cb979cb9f8b9ca8f5dded53f1fa5af languageName: node linkType: hard @@ -1014,7 +1014,7 @@ __metadata: dependencies: "@lavamoat/allow-scripts": "npm:^2.3.1" "@lavamoat/preinstall-always-fail": "npm:^1.0.0" - "@metamask/auto-changelog": "npm:^5.1.0" + "@metamask/auto-changelog": "npm:^5.2.0" "@metamask/eslint-config": "npm:^12.0.0" "@metamask/eslint-config-jest": "npm:^12.0.0" "@metamask/eslint-config-nodejs": "npm:^12.0.0"