From e18f840fcb8f072aea87917b0028d2b803d7c74e Mon Sep 17 00:00:00 2001 From: Howard Braham Date: Sun, 22 Feb 2026 15:10:45 -0800 Subject: [PATCH 1/5] ci: improve merge-approved-pr.yml --- .github/workflows/merge-approved-pr.yml | 132 +++++++++++++++++++----- 1 file changed, 106 insertions(+), 26 deletions(-) diff --git a/.github/workflows/merge-approved-pr.yml b/.github/workflows/merge-approved-pr.yml index 23c03ff3..5ea33cdb 100644 --- a/.github/workflows/merge-approved-pr.yml +++ b/.github/workflows/merge-approved-pr.yml @@ -15,11 +15,27 @@ 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 description: 'GitHub token with permissions to merge' +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 }} + jobs: merge-pr: runs-on: ubuntu-latest @@ -27,15 +43,19 @@ jobs: # Validate required inputs - name: Validate Inputs run: | - if [[ -z "${{ inputs.required-base-branch }}" ]]; then + if [[ -z "$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 + if [[ -z "$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 }}'" + 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 + echo "Inputs validated: required-base-branch='$REQUIRED_BASE_BRANCH', head-branch-pattern='$HEAD_BRANCH_PATTERN', merge-method='$MERGE_METHOD'" # Fetch PR metadata (head and base branches) using the GitHub API - name: Get PR Details @@ -48,31 +68,23 @@ jobs: const { data: pr } = await github.rest.pulls.get({ owner: context.repo.owner, repo: context.repo.repo, - pull_number: ${{ inputs.pr-number }} + pull_number: Number(process.env.PR_NUMBER) }); - // Output the base and head branch names for subsequent steps - core.setOutput('base', pr.base.ref); - core.setOutput('head', pr.head.ref); + // 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 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" + 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 '$ACTUAL_HEAD' -> Target '$ACTUAL_BASE'." + echo "Branches match requirements: Source '$PR_HEAD_REF' -> Target '$PR_BASE_REF'." echo "should_skip=false" >> "$GITHUB_OUTPUT" fi @@ -88,7 +100,7 @@ jobs: const { data: reviews } = await github.rest.pulls.listReviews({ owner: context.repo.owner, repo: context.repo.repo, - pull_number: ${{ inputs.pr-number }} + pull_number: Number(process.env.PR_NUMBER) }); // Fetch members of the release team @@ -135,25 +147,93 @@ jobs: 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 }} + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.github-token }} + script: | + const files = await github.paginate(github.rest.pulls.listFiles, { + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: Number(process.env.PR_NUMBER), + 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 [oldMajor, oldMinor, oldPatch] = oldMatch.slice(1).map((n) => Number(n)); + const [newMajor, newMinor, newPatch] = newMatch.slice(1).map((n) => Number(n)); + + 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 (${oldMatch[1]}.${oldMatch[2]}.${oldMatch[3]} -> ${newMatch[1]}.${newMatch[2]}.${newMatch[3]}).`, + ); + } + 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: - 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) + // 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: ${{ inputs.pr-number }}, - merge_method: 'merge' + pull_number: Number(process.env.PR_NUMBER), + merge_method: process.env.MERGE_METHOD }); - console.log(`PR merged successfully: Source '${process.env.HEAD_REF}' -> Target '${process.env.BASE_REF}'.`); + 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}`); } From 6f193b3e1408084bada6933c8abcb1fb3a2b58fc Mon Sep 17 00:00:00 2001 From: Howard Braham Date: Sun, 22 Feb 2026 17:16:12 -0800 Subject: [PATCH 2/5] addressed Copilot feedback --- .github/workflows/merge-approved-pr.yml | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/merge-approved-pr.yml b/.github/workflows/merge-approved-pr.yml index 5ea33cdb..0cbba5d4 100644 --- a/.github/workflows/merge-approved-pr.yml +++ b/.github/workflows/merge-approved-pr.yml @@ -202,6 +202,13 @@ jobs: 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) => Number(n)); const [newMajor, newMinor, newPatch] = newMatch.slice(1).map((n) => Number(n)); @@ -211,10 +218,9 @@ jobs: 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 (${oldMatch[1]}.${oldMatch[2]}.${oldMatch[3]} -> ${newMatch[1]}.${newMatch[2]}.${newMatch[3]}).`, + `The only change is in package.json, and the only line changed is 'version' with a valid semver bump (${oldVersion} -> ${newVersion}).`, ); - } - else { + } 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.'); } From 52b4d6dfb95d1868c5fcd3a80911cafc86eb8de1 Mon Sep 17 00:00:00 2001 From: Howard Braham Date: Mon, 23 Feb 2026 14:09:25 -0800 Subject: [PATCH 3/5] convert to an action --- .github/actions/merge-approved-pr/action.yml | 273 +++++++++++++++++++ .github/workflows/merge-approved-pr.yml | 214 +-------------- 2 files changed, 280 insertions(+), 207 deletions(-) create mode 100644 .github/actions/merge-approved-pr/action.yml diff --git a/.github/actions/merge-approved-pr/action.yml b/.github/actions/merge-approved-pr/action.yml new file mode 100644 index 00000000..d53ceb1b --- /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: Number(process.env.PR_NUMBER), + }); + + // 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: Number(process.env.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.'); + } + + # 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: Number(process.env.PR_NUMBER), + 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) => Number(n)); + const [newMajor, newMinor, newPatch] = newMatch.slice(1).map((n) => Number(n)); + + 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: Number(process.env.PR_NUMBER), + 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 0cbba5d4..490c0b6e 100644 --- a/.github/workflows/merge-approved-pr.yml +++ b/.github/workflows/merge-approved-pr.yml @@ -30,216 +30,16 @@ on: required: true description: 'GitHub token with permissions to merge' -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 }} - jobs: merge-pr: runs-on: ubuntu-latest steps: - # Validate required inputs - - name: Validate Inputs - run: | - 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 - echo "Inputs validated: required-base-branch='$REQUIRED_BASE_BRANCH', head-branch-pattern='$HEAD_BRANCH_PATTERN', merge-method='$MERGE_METHOD'" - - # 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: Number(process.env.PR_NUMBER) - }); - - // 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 - 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 - 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: Number(process.env.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.'); - } - - # 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 }} - uses: actions/github-script@v7 - with: - github-token: ${{ secrets.github-token }} - script: | - const files = await github.paginate(github.rest.pulls.listFiles, { - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: Number(process.env.PR_NUMBER), - 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) => Number(n)); - const [newMajor, newMinor, newPatch] = newMatch.slice(1).map((n) => Number(n)); - - 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 + - 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 requested method ('merge' or 'squash') - await github.rest.pulls.merge({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: Number(process.env.PR_NUMBER), - 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}`); - } From b3bb8af4615828ac86280b85cd3d6a7732d3b689 Mon Sep 17 00:00:00 2001 From: Howard Braham Date: Tue, 24 Feb 2026 09:52:17 -0800 Subject: [PATCH 4/5] add DEPRECATION notice --- .github/workflows/merge-approved-pr.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/merge-approved-pr.yml b/.github/workflows/merge-approved-pr.yml index 490c0b6e..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: From ad58d2df466330ae33098d1d071be5d9d32cf1ac Mon Sep 17 00:00:00 2001 From: Howard Braham Date: Tue, 24 Feb 2026 09:57:51 -0800 Subject: [PATCH 5/5] parseInt Co-authored-by: Maarten Zuidhoorn --- .github/actions/merge-approved-pr/action.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/actions/merge-approved-pr/action.yml b/.github/actions/merge-approved-pr/action.yml index d53ceb1b..36eb85a5 100644 --- a/.github/actions/merge-approved-pr/action.yml +++ b/.github/actions/merge-approved-pr/action.yml @@ -70,7 +70,7 @@ runs: const { data: pr } = await github.rest.pulls.get({ owner: context.repo.owner, repo: context.repo.repo, - pull_number: Number(process.env.PR_NUMBER), + pull_number: parseInt(process.env.PR_NUMBER, 10), }); // Export base/head refs for subsequent steps @@ -107,7 +107,7 @@ runs: const { data: reviews } = await github.rest.pulls.listReviews({ owner: context.repo.owner, repo: context.repo.repo, - pull_number: Number(process.env.PR_NUMBER), + pull_number: parseInt(process.env.PR_NUMBER, 10), }); // Fetch members of the release team @@ -173,7 +173,7 @@ runs: const files = await github.paginate(github.rest.pulls.listFiles, { owner: context.repo.owner, repo: context.repo.repo, - pull_number: Number(process.env.PR_NUMBER), + pull_number: parseInt(process.env.PR_NUMBER, 10), per_page: 100, }); @@ -230,8 +230,8 @@ runs: return; } - const [oldMajor, oldMinor, oldPatch] = oldMatch.slice(1).map((n) => Number(n)); - const [newMajor, newMinor, newPatch] = newMatch.slice(1).map((n) => Number(n)); + 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; @@ -262,7 +262,7 @@ runs: await github.rest.pulls.merge({ owner: context.repo.owner, repo: context.repo.repo, - pull_number: Number(process.env.PR_NUMBER), + pull_number: parseInt(process.env.PR_NUMBER, 10), merge_method: process.env.MERGE_METHOD, }); console.log(