From 85a47eb4a12f6b5857eec2990282ce8f3d160263 Mon Sep 17 00:00:00 2001 From: Ilija Tovilo Date: Thu, 21 May 2026 15:10:01 +0200 Subject: [PATCH 01/20] Add merge workflow --- .github/scripts/merge_pr.php | 141 +++++++++++++++++++++++++++++++++ .github/workflows/merge_pr.yml | 88 ++++++++++++++++++++ .github/workflows/test.yml | 2 +- 3 files changed, 230 insertions(+), 1 deletion(-) create mode 100644 .github/scripts/merge_pr.php create mode 100644 .github/workflows/merge_pr.yml diff --git a/.github/scripts/merge_pr.php b/.github/scripts/merge_pr.php new file mode 100644 index 000000000000..bb40d0c2a728 --- /dev/null +++ b/.github/scripts/merge_pr.php @@ -0,0 +1,141 @@ + $command\n"; + passthru($command, $status); + return $status === 0; +} + +function run(array $args, ?string $failure_message = null) { + if (!try_run($args)) { + throw new RuntimeException($failure_message ?? 'Command failed'); + } +} + +function origin_branch_exists(string $branch): bool { + return try_run(['git', 'show-ref', '--verify', '--quiet', "refs/remotes/origin/$branch"]); +} + +function find_next_release_branch(string $current): ?string { + if ($current === 'master') { + return null; + } + + if (!preg_match('(^PHP-(?\d+)\.(?\d+)$)', $current, $matches)) { + return null; + } + + $major = $matches['major']; + $minor = $matches['minor']; + + $next = "PHP-$major." . ($minor + 1); + if (origin_branch_exists($next)) { + return $next; + } + + $next = 'PHP-' . ($major + 1) . '.0'; + if (origin_branch_exists($next)) { + return $next; + } + + return 'master'; +} + +function find_release_branches(string $target): array { + $branches = [$target]; + while (null !== $next = find_next_release_branch(end($branches))) { + $branches[] = $next; + } + return $branches; +} + +function merge_pr_into_target(string $pr_sha, string $pr_first_sha, string $target, string $message, string $description): string { + $author = trim((string) shell_exec('git log -1 --format=' . escapeshellarg('%an <%ae>') . ' ' . escapeshellarg($pr_first_sha))); + + run(['git', 'checkout', '-B', $target, "refs/remotes/origin/$target"]); + run(['git', 'merge', '--squash', $pr_sha], + failure_message: "Failed to squash PR into $target."); + run(['git', 'commit', "--author=$author", '-m', $message, '-m', wrap_commit_message($description)]); + $squashed_sha = trim((string) shell_exec('git rev-parse HEAD')); + + return $squashed_sha; +} + +function merge_upwards(array $branches) { + for ($i = 1; $i < count($branches); $i++) { + $prev = $branches[$i - 1]; + $current = $branches[$i]; + run(['git', 'checkout', '-B', $current, "refs/remotes/origin/$current"]); + run(['git', 'merge', '--no-ff', '--no-edit', $prev], + failure_message: "Failed to merge $prev into $current."); + } +} + +function push_pr_branch(string $url, string $branch, string $squashed_sha, string $original_sha) { + run(['git', 'push', "--force-with-lease=$branch:$original_sha", $url, "$squashed_sha:refs/heads/$branch"], + failure_message: 'Failed to push rebased PR branch.'); +} + +function push_release_branches(array $branches) { + run(['git', 'push', '--atomic', 'origin', ...$branches], + failure_message: 'Failed to push release branches.'); +} + +function wrap_commit_message(string $message, int $width = 80): string { + $lines = explode("\n", $message); + $result = []; + $code_section = false; + + foreach ($lines as $line) { + if (preg_match('(^\s*```)', $line)) { + $code_section = !$code_section; + $result[] = $line; + continue; + } + + if ($code_section) { + $result[] = $line; + continue; + } + + if ($line === '' || preg_match('(^\s)', $line)) { + $result[] = $line; + continue; + } + + $result[] = wordwrap($line, $width, "\n", false); + } + + return implode("\n", $result); +} + +function main(): int { + $target = getenv('TARGET_BRANCH'); + $pr_number = getenv('PR_NUMBER'); + $pr_first_sha = getenv('PR_FIRST_SHA'); + $pr_sha = getenv('PR_SHA'); + $pr_ref = getenv('PR_REF'); + $pr_repo_url = getenv('PR_REPO_URL'); + $pr_title = getenv('PR_TITLE'); + $pr_description = getenv('PR_DESCRIPTION'); + + $release_branches = find_release_branches($target); + + try { + $squashed_sha = merge_pr_into_target($pr_sha, $pr_first_sha, $target, "$pr_title (GH-$pr_number)", $pr_description); + merge_upwards($release_branches); + push_pr_branch($pr_repo_url, $pr_ref, $squashed_sha, $pr_sha); + push_release_branches($release_branches); + } catch (Throwable $e) { + if (false !== ($github_output = getenv('GITHUB_OUTPUT'))) { + file_put_contents($github_output, "fail_reason<getMessage()}\nEOF\n", FILE_APPEND); + } + fwrite(STDERR, "::error::{$e->getMessage()}\n"); + return 1; + } + + return 0; +} + +exit(main()); diff --git a/.github/workflows/merge_pr.yml b/.github/workflows/merge_pr.yml new file mode 100644 index 000000000000..bca607ac45c1 --- /dev/null +++ b/.github/workflows/merge_pr.yml @@ -0,0 +1,88 @@ +name: Merge PR + +on: + pull_request_target: + types: [labeled] + +permissions: + contents: read + +jobs: + merge_pr: + name: Merge PR + if: github.event.label.name == 'Merge' + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - name: git checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + filter: blob:none + ref: ${{ github.event.pull_request.base.ref }} + token: ${{ secrets.GITHUB_TOKEN }} + + - name: git config + run: | + git config user.name "PHP GH Bot" + git config user.email "gh-bot@php.net" + git config merge.NEWS.name "Keep the NEWS file" + git config merge.NEWS.driver "touch %A" + git config merge.log true + + - name: Fetch PR head + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + git fetch origin "refs/pull/${PR_NUMBER}/head" + + - name: Merge PR + id: merge + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_FIRST_SHA: $(git log --reverse --format=%H ${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }} | head -n1) + PR_SHA: ${{ github.event.pull_request.head.sha }} + PR_REF: ${{ github.event.pull_request.head.ref }} + PR_REPO_URL: ${{ github.event.pull_request.head.repo.clone_url }} + PR_TITLE: ${{ github.event.pull_request.title }} + PR_DESCRIPTION: ${{ github.event.pull_request.body }} + TARGET_BRANCH: ${{ github.event.pull_request.base.ref }} + run: | + # Use merge script from master to avoid syncing to lower branches. + git show origin/master:.github/scripts/merge_pr.php > "$RUNNER_TEMP/merge_pr.php" + php "$RUNNER_TEMP/merge_pr.php" + + - name: Report failure + if: failure() + uses: actions/github-script@v9 + env: + FAIL_REASON: ${{ steps.merge.outputs.fail_reason }} + with: + script: | + const reason = process.env.FAIL_REASON || 'Unknown error.'; + const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: `Merge failed: ${reason}\n\n[View workflow run](${runUrl}).`, + }); + + - name: Remove Merge label + if: ${{ always() }} + uses: actions/github-script@v9 + with: + script: | + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + name: 'Merge', + }); + } catch (e) { + core.warning(`Could not remove the 'Merge' label: ${e.message}`); + } diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index eafedec5eafa..a016a6088f31 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -34,7 +34,7 @@ concurrency: jobs: GENERATE_MATRIX: name: Generate Matrix - if: github.repository == 'php/php-src' || github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' + if: (github.repository == 'php/php-src' || github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch') && !contains(github.event.pull_request.labels.*.name, 'Merge') runs-on: ubuntu-latest outputs: all_variations: ${{ steps.set-matrix.outputs.all_variations }} From b89f975b915517189c316c420c0f3908c1cf5608 Mon Sep 17 00:00:00 2001 From: Ilija Tovilo Date: Sat, 23 May 2026 11:24:30 +0200 Subject: [PATCH 02/20] Add concurrency restriction Merging should be fully sequential. --- .github/workflows/merge_pr.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/merge_pr.yml b/.github/workflows/merge_pr.yml index bca607ac45c1..6a18cffce9af 100644 --- a/.github/workflows/merge_pr.yml +++ b/.github/workflows/merge_pr.yml @@ -7,6 +7,9 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }} + jobs: merge_pr: name: Merge PR From 6d23b57f45d85408c12619df73bcdd1d6b086ff5 Mon Sep 17 00:00:00 2001 From: Ilija Tovilo Date: Sat, 23 May 2026 11:37:16 +0200 Subject: [PATCH 03/20] Prevent merging to unexpected branch --- .github/scripts/merge_pr.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/scripts/merge_pr.php b/.github/scripts/merge_pr.php index bb40d0c2a728..b84c5981fabb 100644 --- a/.github/scripts/merge_pr.php +++ b/.github/scripts/merge_pr.php @@ -23,7 +23,7 @@ function find_next_release_branch(string $current): ?string { } if (!preg_match('(^PHP-(?\d+)\.(?\d+)$)', $current, $matches)) { - return null; + throw new RuntimeException("Unsupported target branch $current"); } $major = $matches['major']; From 33bec6014e86577f7d2ccc8fcd0081f55cb1b7ce Mon Sep 17 00:00:00 2001 From: Ilija Tovilo Date: Sat, 23 May 2026 12:59:14 +0200 Subject: [PATCH 04/20] Hardening - When updating the PR branch fails due to remote rejection, continue. This is due to the unticked "Allow edits by maintainers" checkbox. - Attempt to revert the PR branch if the atomic release branch push failed. --- .github/scripts/merge_pr.php | 123 ++++++++++++++++++++++++++++----- .github/workflows/merge_pr.yml | 16 +++++ 2 files changed, 121 insertions(+), 18 deletions(-) diff --git a/.github/scripts/merge_pr.php b/.github/scripts/merge_pr.php index b84c5981fabb..ed1288504852 100644 --- a/.github/scripts/merge_pr.php +++ b/.github/scripts/merge_pr.php @@ -1,16 +1,76 @@ $command\n"; - passthru($command, $status); - return $status === 0; +class ProcessResult { + public $status; + public $stdout; + public $stderr; } -function run(array $args, ?string $failure_message = null) { - if (!try_run($args)) { - throw new RuntimeException($failure_message ?? 'Command failed'); +function run_command(array $args, ?string $failure_message = 'Unexpected error.'): ProcessResult { + $cmd = implode(' ', array_map('escapeshellarg', $args)); + $pipes = null; + $result = new ProcessResult(); + $descriptor_spec = [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; + fwrite(STDERR, "> $cmd\n"); + $process_handle = proc_open($cmd, $descriptor_spec, $pipes); + + $stdin = $pipes[0]; + $stdout = $pipes[1]; + $stderr = $pipes[2]; + + fclose($stdin); + + stream_set_blocking($stdout, false); + stream_set_blocking($stderr, false); + + $stdout_eof = false; + $stderr_eof = false; + + do { + $read = [$stdout, $stderr]; + $write = null; + $except = null; + + stream_select($read, $write, $except, 1, 0); + + foreach ($read as $stream) { + $chunk = fgets($stream); + if ($stream === $stdout) { + $result->stdout .= $chunk; + fwrite(STDOUT, $chunk); + } elseif ($stream === $stderr) { + $result->stderr .= $chunk; + fwrite(STDERR, $chunk); + } + } + + $stdout_eof = $stdout_eof || feof($stdout); + $stderr_eof = $stderr_eof || feof($stderr); + } while(!$stdout_eof || !$stderr_eof); + + fclose($stdout); + fclose($stderr); + + $result->status = proc_close($process_handle); + + if ($result->status) { + fwrite(STDERR, "Status code: {$result->status}\n"); + if ($failure_message) { + throw new RuntimeException($failure_message); + } } + + return $result; +} + +function try_run(array $args): bool { + $result = run_command($args, failure_message: null); + return $result->status !== 0; +} + +function run(array $args, ?string $failure_message = null): bool { + $result = run_command($args, $failure_message); + return $result->status !== 0; } function origin_branch_exists(string $branch): bool { @@ -51,7 +111,7 @@ function find_release_branches(string $target): array { } function merge_pr_into_target(string $pr_sha, string $pr_first_sha, string $target, string $message, string $description): string { - $author = trim((string) shell_exec('git log -1 --format=' . escapeshellarg('%an <%ae>') . ' ' . escapeshellarg($pr_first_sha))); + $author = trim(run_command(['git', 'log', '-1', '--format=%an <%ae>', $pr_first_sha])->stdout); run(['git', 'checkout', '-B', $target, "refs/remotes/origin/$target"]); run(['git', 'merge', '--squash', $pr_sha], @@ -72,14 +132,30 @@ function merge_upwards(array $branches) { } } -function push_pr_branch(string $url, string $branch, string $squashed_sha, string $original_sha) { - run(['git', 'push', "--force-with-lease=$branch:$original_sha", $url, "$squashed_sha:refs/heads/$branch"], - failure_message: 'Failed to push rebased PR branch.'); +enum PushPrBranchResult { + case Success; + case Rejected; + case RemoteRejected; +} + +function push_pr_branch(string $url, string $branch, string $new_commit, string $expected_commit) { + $result = run_command(['git', 'push', "--force-with-lease=$branch:$expected_commit", $url, "$new_commit:refs/heads/$branch"]); + if ($result->status === 0) { + return PushPrBranchResult::Success; + } else if (preg_match('(\[remote rejected\])', $result->stderr)) { + return PushPrBranchResult::RemoteRejected; + } else { + return PushPrBranchResult::Rejected; + } +} + +function push_release_branches(array $branches): bool { + return try_run(['git', 'push', '--atomic', 'origin', ...$branches]); } -function push_release_branches(array $branches) { - run(['git', 'push', '--atomic', 'origin', ...$branches], - failure_message: 'Failed to push release branches.'); +function revert_pr_branch(string $url, string $branch, string $new_commit, string $expected_commit) { + run_command(['git', 'push', "--force-with-lease=$branch:$expected_commit", $url, "$new_commit:refs/heads/$branch"], + failure_message: 'Failed to push release branches. Reverting PR branch also failed.'); } function wrap_commit_message(string $message, int $width = 80): string { @@ -119,16 +195,27 @@ function main(): int { $pr_repo_url = getenv('PR_REPO_URL'); $pr_title = getenv('PR_TITLE'); $pr_description = getenv('PR_DESCRIPTION'); + $github_output = getenv('GITHUB_OUTPUT'); $release_branches = find_release_branches($target); try { $squashed_sha = merge_pr_into_target($pr_sha, $pr_first_sha, $target, "$pr_title (GH-$pr_number)", $pr_description); merge_upwards($release_branches); - push_pr_branch($pr_repo_url, $pr_ref, $squashed_sha, $pr_sha); - push_release_branches($release_branches); + $push_pr_branch_result = push_pr_branch($pr_repo_url, $pr_ref, $squashed_sha, $pr_sha); + if ($push_pr_branch_result !== PushPrBranchResult::Rejected) { + throw new RuntimeException('PR branch diverged.'); + } else if ($push_pr_branch_result === PushPrBranchResult::RemoteRejected) { + // Contributor likely unchecked the "Allow edits by maintainers" + // checkbox. Resume and close PR manually. + file_put_contents($github_output, "close_pr=1\n", FILE_APPEND); + } + if (!push_release_branches($release_branches)) { + revert_pr_branch($pr_repo_url, $pr_ref, $pr_sha, $squashed_sha); + throw new RuntimeException('Failed to push release branches.'); + } } catch (Throwable $e) { - if (false !== ($github_output = getenv('GITHUB_OUTPUT'))) { + if ($github_output !== false) { file_put_contents($github_output, "fail_reason<getMessage()}\nEOF\n", FILE_APPEND); } fwrite(STDERR, "::error::{$e->getMessage()}\n"); diff --git a/.github/workflows/merge_pr.yml b/.github/workflows/merge_pr.yml index 6a18cffce9af..ec3adaecf56b 100644 --- a/.github/workflows/merge_pr.yml +++ b/.github/workflows/merge_pr.yml @@ -89,3 +89,19 @@ jobs: } catch (e) { core.warning(`Could not remove the 'Merge' label: ${e.message}`); } + + - name: Close PR + if: ${{ steps.merge.outputs.close_pr == '1' }} + uses: actions/github-script@v9 + with: + script: | + try { + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + state: 'closed', + }); + } catch (e) { + core.warning(`Could not close the PR: ${e.message}`); + } From 2653fc0c7494a6b71a2b2ae3c5321dcd77611d03 Mon Sep 17 00:00:00 2001 From: Ilija Tovilo Date: Sat, 23 May 2026 13:53:28 +0200 Subject: [PATCH 05/20] Fix $pr_first_sha eval --- .github/scripts/merge_pr.php | 17 ++++++++++------- .github/workflows/merge_pr.yml | 3 ++- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/.github/scripts/merge_pr.php b/.github/scripts/merge_pr.php index ed1288504852..2b61659b7b24 100644 --- a/.github/scripts/merge_pr.php +++ b/.github/scripts/merge_pr.php @@ -6,8 +6,10 @@ class ProcessResult { public $stderr; } -function run_command(array $args, ?string $failure_message = 'Unexpected error.'): ProcessResult { - $cmd = implode(' ', array_map('escapeshellarg', $args)); +function run_command(string|array $cmd, ?string $failure_message = 'Unexpected error.'): ProcessResult { + if (is_array($cmd)) { + $cmd = implode(' ', array_map('escapeshellarg', $cmd)); + } $pipes = null; $result = new ProcessResult(); $descriptor_spec = [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; @@ -187,9 +189,9 @@ function wrap_commit_message(string $message, int $width = 80): string { } function main(): int { - $target = getenv('TARGET_BRANCH'); + $target_sha = getenv('TARGET_SHA'); + $target_ref = getenv('TARGET_REF'); $pr_number = getenv('PR_NUMBER'); - $pr_first_sha = getenv('PR_FIRST_SHA'); $pr_sha = getenv('PR_SHA'); $pr_ref = getenv('PR_REF'); $pr_repo_url = getenv('PR_REPO_URL'); @@ -197,13 +199,14 @@ function main(): int { $pr_description = getenv('PR_DESCRIPTION'); $github_output = getenv('GITHUB_OUTPUT'); - $release_branches = find_release_branches($target); + $release_branches = find_release_branches($target_ref); + $pr_first_sha = trim(run_command("git log --reverse --format=%H $target_sha..$pr_sha | head -n1")->stdout); try { - $squashed_sha = merge_pr_into_target($pr_sha, $pr_first_sha, $target, "$pr_title (GH-$pr_number)", $pr_description); + $squashed_sha = merge_pr_into_target($pr_sha, $pr_first_sha, $target_ref, "$pr_title (GH-$pr_number)", $pr_description); merge_upwards($release_branches); $push_pr_branch_result = push_pr_branch($pr_repo_url, $pr_ref, $squashed_sha, $pr_sha); - if ($push_pr_branch_result !== PushPrBranchResult::Rejected) { + if ($push_pr_branch_result === PushPrBranchResult::Rejected) { throw new RuntimeException('PR branch diverged.'); } else if ($push_pr_branch_result === PushPrBranchResult::RemoteRejected) { // Contributor likely unchecked the "Allow edits by maintainers" diff --git a/.github/workflows/merge_pr.yml b/.github/workflows/merge_pr.yml index ec3adaecf56b..7ea5ba5e4050 100644 --- a/.github/workflows/merge_pr.yml +++ b/.github/workflows/merge_pr.yml @@ -51,7 +51,8 @@ jobs: PR_REPO_URL: ${{ github.event.pull_request.head.repo.clone_url }} PR_TITLE: ${{ github.event.pull_request.title }} PR_DESCRIPTION: ${{ github.event.pull_request.body }} - TARGET_BRANCH: ${{ github.event.pull_request.base.ref }} + TARGET_SHA: ${{ github.event.pull_request.base.sha }} + TARGET_REF: ${{ github.event.pull_request.base.ref }} run: | # Use merge script from master to avoid syncing to lower branches. git show origin/master:.github/scripts/merge_pr.php > "$RUNNER_TEMP/merge_pr.php" From 14a774d73f7dfb2edac616c09a62e9c0075fe3f5 Mon Sep 17 00:00:00 2001 From: Ilija Tovilo Date: Sat, 23 May 2026 14:05:23 +0200 Subject: [PATCH 06/20] Fix run/try_run result --- .github/scripts/merge_pr.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/scripts/merge_pr.php b/.github/scripts/merge_pr.php index 2b61659b7b24..fed9b907fed3 100644 --- a/.github/scripts/merge_pr.php +++ b/.github/scripts/merge_pr.php @@ -67,12 +67,12 @@ function run_command(string|array $cmd, ?string $failure_message = 'Unexpected e function try_run(array $args): bool { $result = run_command($args, failure_message: null); - return $result->status !== 0; + return $result->status === 0; } function run(array $args, ?string $failure_message = null): bool { - $result = run_command($args, $failure_message); - return $result->status !== 0; + $result = run_command($args, $failure_message ?? 'Unexpected error.'); + return $result->status === 0; } function origin_branch_exists(string $branch): bool { From d5ae78a6589da94d7e7332a2c82a27318214a418 Mon Sep 17 00:00:00 2001 From: Ilija Tovilo Date: Sat, 23 May 2026 14:14:01 +0200 Subject: [PATCH 07/20] Fix aborting PR branch push --- .github/scripts/merge_pr.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/scripts/merge_pr.php b/.github/scripts/merge_pr.php index fed9b907fed3..4c2b97243ab9 100644 --- a/.github/scripts/merge_pr.php +++ b/.github/scripts/merge_pr.php @@ -141,7 +141,7 @@ enum PushPrBranchResult { } function push_pr_branch(string $url, string $branch, string $new_commit, string $expected_commit) { - $result = run_command(['git', 'push', "--force-with-lease=$branch:$expected_commit", $url, "$new_commit:refs/heads/$branch"]); + $result = run_command(['git', 'push', "--force-with-lease=$branch:$expected_commit", $url, "$new_commit:refs/heads/$branch"], failure_message: null); if ($result->status === 0) { return PushPrBranchResult::Success; } else if (preg_match('(\[remote rejected\])', $result->stderr)) { From 58ef82787516a71d2c9e5fab257dc035861e611c Mon Sep 17 00:00:00 2001 From: Ilija Tovilo Date: Sat, 23 May 2026 14:43:48 +0200 Subject: [PATCH 08/20] Flip rejected / remote rejected check [remote rejected] is not reliably there. --- .github/scripts/merge_pr.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/scripts/merge_pr.php b/.github/scripts/merge_pr.php index 4c2b97243ab9..1fda17a5b073 100644 --- a/.github/scripts/merge_pr.php +++ b/.github/scripts/merge_pr.php @@ -144,10 +144,10 @@ function push_pr_branch(string $url, string $branch, string $new_commit, string $result = run_command(['git', 'push', "--force-with-lease=$branch:$expected_commit", $url, "$new_commit:refs/heads/$branch"], failure_message: null); if ($result->status === 0) { return PushPrBranchResult::Success; - } else if (preg_match('(\[remote rejected\])', $result->stderr)) { - return PushPrBranchResult::RemoteRejected; - } else { + } else if (preg_match('(\[rejected\])', $result->stderr)) { return PushPrBranchResult::Rejected; + } else { + return PushPrBranchResult::RemoteRejected; } } From 9f4c748d63f2c9d2300ea1e0e64eeec850593167 Mon Sep 17 00:00:00 2001 From: Ilija Tovilo Date: Sat, 23 May 2026 14:48:49 +0200 Subject: [PATCH 09/20] Move all code into try for reliable errors --- .github/scripts/merge_pr.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/scripts/merge_pr.php b/.github/scripts/merge_pr.php index 1fda17a5b073..a85fd8d4c7b5 100644 --- a/.github/scripts/merge_pr.php +++ b/.github/scripts/merge_pr.php @@ -199,10 +199,10 @@ function main(): int { $pr_description = getenv('PR_DESCRIPTION'); $github_output = getenv('GITHUB_OUTPUT'); - $release_branches = find_release_branches($target_ref); - $pr_first_sha = trim(run_command("git log --reverse --format=%H $target_sha..$pr_sha | head -n1")->stdout); - try { + $release_branches = find_release_branches($target_ref); + $pr_first_sha = trim(run_command("git log --reverse --format=%H $target_sha..$pr_sha | head -n1")->stdout); + $squashed_sha = merge_pr_into_target($pr_sha, $pr_first_sha, $target_ref, "$pr_title (GH-$pr_number)", $pr_description); merge_upwards($release_branches); $push_pr_branch_result = push_pr_branch($pr_repo_url, $pr_ref, $squashed_sha, $pr_sha); From b41e5b7b07c7ab99fa10f6f9f254def41b18b6a6 Mon Sep 17 00:00:00 2001 From: Ilija Tovilo Date: Sat, 23 May 2026 14:49:59 +0200 Subject: [PATCH 10/20] Consistent colon --- .github/scripts/merge_pr.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/scripts/merge_pr.php b/.github/scripts/merge_pr.php index a85fd8d4c7b5..67d1cdc75992 100644 --- a/.github/scripts/merge_pr.php +++ b/.github/scripts/merge_pr.php @@ -85,7 +85,7 @@ function find_next_release_branch(string $current): ?string { } if (!preg_match('(^PHP-(?\d+)\.(?\d+)$)', $current, $matches)) { - throw new RuntimeException("Unsupported target branch $current"); + throw new RuntimeException("Unsupported target branch $current."); } $major = $matches['major']; From 0b51a743f973505f2a4a53ae967f1e383adfda80 Mon Sep 17 00:00:00 2001 From: Ilija Tovilo Date: Sat, 23 May 2026 22:47:48 +0200 Subject: [PATCH 11/20] Sign commits --- .github/workflows/merge_pr.yml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/merge_pr.yml b/.github/workflows/merge_pr.yml index 7ea5ba5e4050..4ac5da353270 100644 --- a/.github/workflows/merge_pr.yml +++ b/.github/workflows/merge_pr.yml @@ -29,12 +29,19 @@ jobs: - name: git config run: | - git config user.name "PHP GH Bot" - git config user.email "gh-bot@php.net" + git config user.name "PHP Merge Bot" + git config user.email "287303856+php-merge-bot@users.noreply.github.com" git config merge.NEWS.name "Keep the NEWS file" git config merge.NEWS.driver "touch %A" git config merge.log true + - name: Import GPG key + uses: crazy-max/ghaction-import-gpg@v7 + with: + gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} + git_commit_gpgsign: true + git_user_signingkey: true + - name: Fetch PR head env: PR_NUMBER: ${{ github.event.pull_request.number }} From ad0c765c9ff346492b78049d20020a957e501dbb Mon Sep 17 00:00:00 2001 From: Ilija Tovilo Date: Sun, 24 May 2026 15:57:42 +0200 Subject: [PATCH 12/20] Add types / strict_types=1 Verified with PHPStan locally. --- .github/scripts/merge_pr.php | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/.github/scripts/merge_pr.php b/.github/scripts/merge_pr.php index 67d1cdc75992..c8fc26f61419 100644 --- a/.github/scripts/merge_pr.php +++ b/.github/scripts/merge_pr.php @@ -1,11 +1,14 @@ $cmd */ function run_command(string|array $cmd, ?string $failure_message = 'Unexpected error.'): ProcessResult { if (is_array($cmd)) { $cmd = implode(' ', array_map('escapeshellarg', $cmd)); @@ -65,11 +68,13 @@ function run_command(string|array $cmd, ?string $failure_message = 'Unexpected e return $result; } +/** @param list $args */ function try_run(array $args): bool { $result = run_command($args, failure_message: null); return $result->status === 0; } +/** @param list $args */ function run(array $args, ?string $failure_message = null): bool { $result = run_command($args, $failure_message ?? 'Unexpected error.'); return $result->status === 0; @@ -104,6 +109,7 @@ function find_next_release_branch(string $current): ?string { return 'master'; } +/** @return list */ function find_release_branches(string $target): array { $branches = [$target]; while (null !== $next = find_next_release_branch(end($branches))) { @@ -124,7 +130,8 @@ function merge_pr_into_target(string $pr_sha, string $pr_first_sha, string $targ return $squashed_sha; } -function merge_upwards(array $branches) { +/** @param list $branches */ +function merge_upwards(array $branches): void { for ($i = 1; $i < count($branches); $i++) { $prev = $branches[$i - 1]; $current = $branches[$i]; @@ -140,7 +147,7 @@ enum PushPrBranchResult { case RemoteRejected; } -function push_pr_branch(string $url, string $branch, string $new_commit, string $expected_commit) { +function push_pr_branch(string $url, string $branch, string $new_commit, string $expected_commit): PushPrBranchResult { $result = run_command(['git', 'push', "--force-with-lease=$branch:$expected_commit", $url, "$new_commit:refs/heads/$branch"], failure_message: null); if ($result->status === 0) { return PushPrBranchResult::Success; @@ -151,11 +158,12 @@ function push_pr_branch(string $url, string $branch, string $new_commit, string } } +/** @param list $branches */ function push_release_branches(array $branches): bool { return try_run(['git', 'push', '--atomic', 'origin', ...$branches]); } -function revert_pr_branch(string $url, string $branch, string $new_commit, string $expected_commit) { +function revert_pr_branch(string $url, string $branch, string $new_commit, string $expected_commit): void { run_command(['git', 'push', "--force-with-lease=$branch:$expected_commit", $url, "$new_commit:refs/heads/$branch"], failure_message: 'Failed to push release branches. Reverting PR branch also failed.'); } @@ -209,9 +217,11 @@ function main(): int { if ($push_pr_branch_result === PushPrBranchResult::Rejected) { throw new RuntimeException('PR branch diverged.'); } else if ($push_pr_branch_result === PushPrBranchResult::RemoteRejected) { - // Contributor likely unchecked the "Allow edits by maintainers" - // checkbox. Resume and close PR manually. - file_put_contents($github_output, "close_pr=1\n", FILE_APPEND); + if ($github_output !== false) { + // Contributor likely unchecked the "Allow edits by maintainers" + // checkbox. Resume and close PR manually. + file_put_contents($github_output, "close_pr=1\n", FILE_APPEND); + } } if (!push_release_branches($release_branches)) { revert_pr_branch($pr_repo_url, $pr_ref, $pr_sha, $squashed_sha); From 1e726e710897eca3985c01f81378b113198485ad Mon Sep 17 00:00:00 2001 From: Ilija Tovilo Date: Sun, 24 May 2026 16:33:44 +0200 Subject: [PATCH 13/20] Use grouping for cmds --- .github/scripts/merge_pr.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/scripts/merge_pr.php b/.github/scripts/merge_pr.php index c8fc26f61419..bf85efcd6b6e 100644 --- a/.github/scripts/merge_pr.php +++ b/.github/scripts/merge_pr.php @@ -16,7 +16,8 @@ function run_command(string|array $cmd, ?string $failure_message = 'Unexpected e $pipes = null; $result = new ProcessResult(); $descriptor_spec = [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; - fwrite(STDERR, "> $cmd\n"); + fwrite(STDERR, "::group::{$cmd}\n"); + $process_handle = proc_open($cmd, $descriptor_spec, $pipes); $stdin = $pipes[0]; @@ -58,6 +59,8 @@ function run_command(string|array $cmd, ?string $failure_message = 'Unexpected e $result->status = proc_close($process_handle); + fwrite(STDERR, "::endgroup::\n"); + if ($result->status) { fwrite(STDERR, "Status code: {$result->status}\n"); if ($failure_message) { From 8911f5799532773a989fe0b0329352dad1240df0 Mon Sep 17 00:00:00 2001 From: Ilija Tovilo Date: Sun, 24 May 2026 16:39:14 +0200 Subject: [PATCH 14/20] Avoid passing false to fwrite --- .github/scripts/merge_pr.php | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/.github/scripts/merge_pr.php b/.github/scripts/merge_pr.php index bf85efcd6b6e..f9629ddeffcd 100644 --- a/.github/scripts/merge_pr.php +++ b/.github/scripts/merge_pr.php @@ -40,13 +40,14 @@ function run_command(string|array $cmd, ?string $failure_message = 'Unexpected e stream_select($read, $write, $except, 1, 0); foreach ($read as $stream) { - $chunk = fgets($stream); - if ($stream === $stdout) { - $result->stdout .= $chunk; - fwrite(STDOUT, $chunk); - } elseif ($stream === $stderr) { - $result->stderr .= $chunk; - fwrite(STDERR, $chunk); + if (false !== $chunk = fgets($stream)) { + if ($stream === $stdout) { + $result->stdout .= $chunk; + fwrite(STDOUT, $chunk); + } elseif ($stream === $stderr) { + $result->stderr .= $chunk; + fwrite(STDERR, $chunk); + } } } From 434150ab5562f82092d149bc70f25b99dd8c0501 Mon Sep 17 00:00:00 2001 From: Ilija Tovilo Date: Sun, 24 May 2026 16:27:58 +0200 Subject: [PATCH 15/20] Use app token for pushing Pushing with GITHUB_TOKEN does not trigger CI. See: https://github.com/orgs/community/discussions/25702 --- .github/workflows/merge_pr.yml | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/merge_pr.yml b/.github/workflows/merge_pr.yml index 4ac5da353270..17ab6f92b09d 100644 --- a/.github/workflows/merge_pr.yml +++ b/.github/workflows/merge_pr.yml @@ -19,16 +19,26 @@ jobs: contents: write pull-requests: write steps: + - name: Generate GitHub App Token + id: app-token + uses: actions/create-github-app-token@v3 + with: + client-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + permission-contents: write + - name: git checkout uses: actions/checkout@v6 with: fetch-depth: 0 filter: blob:none ref: ${{ github.event.pull_request.base.ref }} - token: ${{ secrets.GITHUB_TOKEN }} + token: ${{ steps.app-token.outputs.token }} + persist-credentials: false - name: git config run: | + git config --local url."https://x-access-token:${{ steps.app-token.outputs.token }}@github.com/".insteadOf "https://github.com/" git config user.name "PHP Merge Bot" git config user.email "287303856+php-merge-bot@users.noreply.github.com" git config merge.NEWS.name "Keep the NEWS file" From 7d59bd5d493046f71f6cf05538f671fe1419b81b Mon Sep 17 00:00:00 2001 From: Ilija Tovilo Date: Sun, 24 May 2026 17:45:16 +0200 Subject: [PATCH 16/20] Remove dead env var --- .github/workflows/merge_pr.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/merge_pr.yml b/.github/workflows/merge_pr.yml index 17ab6f92b09d..13cb876dbbb3 100644 --- a/.github/workflows/merge_pr.yml +++ b/.github/workflows/merge_pr.yml @@ -62,7 +62,6 @@ jobs: id: merge env: PR_NUMBER: ${{ github.event.pull_request.number }} - PR_FIRST_SHA: $(git log --reverse --format=%H ${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }} | head -n1) PR_SHA: ${{ github.event.pull_request.head.sha }} PR_REF: ${{ github.event.pull_request.head.ref }} PR_REPO_URL: ${{ github.event.pull_request.head.repo.clone_url }} From d64608608fff670e276aa35e50663c4e6289cc85 Mon Sep 17 00:00:00 2001 From: Ilija Tovilo Date: Sun, 24 May 2026 20:56:23 +0200 Subject: [PATCH 17/20] Check env var existence, add context class --- .github/scripts/merge_pr.php | 111 +++++++++++++++++++++++------------ 1 file changed, 73 insertions(+), 38 deletions(-) diff --git a/.github/scripts/merge_pr.php b/.github/scripts/merge_pr.php index f9629ddeffcd..4ea1f93686e3 100644 --- a/.github/scripts/merge_pr.php +++ b/.github/scripts/merge_pr.php @@ -2,6 +2,49 @@ declare(strict_types=1); +class Context { + public string $github_output; + public string $pr_number; + public string $pr_sha; + public string $pr_ref; + public string $pr_repo_url; + public string $pr_title; + public string $pr_description; + public string $target_sha; + public string $target_ref; + /** @var list */ + public array $release_branches; + public string $pr_first_sha; +} + +function get_context(): Context { + $context = new Context; + + $env_mapping = [ + 'PR_DESCRIPTION' => 'pr_description', + 'PR_NUMBER' => 'pr_number', + 'PR_REF' => 'pr_ref', + 'PR_REPO_URL' => 'pr_repo_url', + 'PR_SHA' => 'pr_sha', + 'PR_TITLE' => 'pr_title', + 'TARGET_REF' => 'target_ref', + 'TARGET_SHA' => 'target_sha', + ]; + + foreach ($env_mapping as $env_name => $prop_name) { + $value = getenv($env_name); + if ($value === false) { + throw new InvalidArgumentException("Missing env var $env_name"); + } + $context->{$prop_name} = $value; + } + + $context->release_branches = find_release_branches($context->target_ref); + $context->pr_first_sha = trim(run_command("git log --reverse --format=%H {$context->target_sha}..{$context->pr_sha} | head -n1")->stdout); + + return $context; +} + class ProcessResult { public int $status = 0; public string $stdout = ''; @@ -19,6 +62,9 @@ function run_command(string|array $cmd, ?string $failure_message = 'Unexpected e fwrite(STDERR, "::group::{$cmd}\n"); $process_handle = proc_open($cmd, $descriptor_spec, $pipes); + if ($process_handle === false) { + throw new RuntimeException("Failed to execute command `$cmd`"); + } $stdin = $pipes[0]; $stdout = $pipes[1]; @@ -122,20 +168,20 @@ function find_release_branches(string $target): array { return $branches; } -function merge_pr_into_target(string $pr_sha, string $pr_first_sha, string $target, string $message, string $description): string { - $author = trim(run_command(['git', 'log', '-1', '--format=%an <%ae>', $pr_first_sha])->stdout); +function merge_pr_into_target(Context $context, string $message): string { + $author = trim(run_command(['git', 'log', '-1', '--format=%an <%ae>', $context->pr_first_sha])->stdout); - run(['git', 'checkout', '-B', $target, "refs/remotes/origin/$target"]); - run(['git', 'merge', '--squash', $pr_sha], - failure_message: "Failed to squash PR into $target."); - run(['git', 'commit', "--author=$author", '-m', $message, '-m', wrap_commit_message($description)]); + run(['git', 'checkout', '-B', $context->target_ref, "refs/remotes/origin/{$context->target_ref}"]); + run(['git', 'merge', '--squash', $context->pr_sha], + failure_message: "Failed to squash PR into {$context->target_ref}."); + run(['git', 'commit', "--author=$author", '-m', $message, '-m', wrap_commit_message($context->pr_description)]); $squashed_sha = trim((string) shell_exec('git rev-parse HEAD')); return $squashed_sha; } -/** @param list $branches */ -function merge_upwards(array $branches): void { +function merge_upwards(Context $context): void { + $branches = $context->release_branches; for ($i = 1; $i < count($branches); $i++) { $prev = $branches[$i - 1]; $current = $branches[$i]; @@ -151,8 +197,8 @@ enum PushPrBranchResult { case RemoteRejected; } -function push_pr_branch(string $url, string $branch, string $new_commit, string $expected_commit): PushPrBranchResult { - $result = run_command(['git', 'push', "--force-with-lease=$branch:$expected_commit", $url, "$new_commit:refs/heads/$branch"], failure_message: null); +function push_pr_branch(Context $context, string $new_commit): PushPrBranchResult { + $result = run_command(['git', 'push', "--force-with-lease={$context->pr_ref}:{$context->pr_sha}", $context->pr_repo_url, "$new_commit:refs/heads/{$context->pr_ref}"], failure_message: null); if ($result->status === 0) { return PushPrBranchResult::Success; } else if (preg_match('(\[rejected\])', $result->stderr)) { @@ -162,13 +208,12 @@ function push_pr_branch(string $url, string $branch, string $new_commit, string } } -/** @param list $branches */ -function push_release_branches(array $branches): bool { - return try_run(['git', 'push', '--atomic', 'origin', ...$branches]); +function push_release_branches(Context $context): bool { + return try_run(['git', 'push', '--atomic', 'origin', ...$context->release_branches]); } -function revert_pr_branch(string $url, string $branch, string $new_commit, string $expected_commit): void { - run_command(['git', 'push', "--force-with-lease=$branch:$expected_commit", $url, "$new_commit:refs/heads/$branch"], +function revert_pr_branch(Context $context, string $expected_commit): void { + run_command(['git', 'push', "--force-with-lease={$context->pr_ref}:$expected_commit", $context->pr_repo_url, "{$context->pr_sha}:refs/heads/{$context->pr_ref}"], failure_message: 'Failed to push release branches. Reverting PR branch also failed.'); } @@ -201,40 +246,30 @@ function wrap_commit_message(string $message, int $width = 80): string { } function main(): int { - $target_sha = getenv('TARGET_SHA'); - $target_ref = getenv('TARGET_REF'); - $pr_number = getenv('PR_NUMBER'); - $pr_sha = getenv('PR_SHA'); - $pr_ref = getenv('PR_REF'); - $pr_repo_url = getenv('PR_REPO_URL'); - $pr_title = getenv('PR_TITLE'); - $pr_description = getenv('PR_DESCRIPTION'); $github_output = getenv('GITHUB_OUTPUT'); + if ($github_output === false) { + throw new InvalidArgumentException('Missing env var GITHUB_OUTPUT'); + } try { - $release_branches = find_release_branches($target_ref); - $pr_first_sha = trim(run_command("git log --reverse --format=%H $target_sha..$pr_sha | head -n1")->stdout); + $context = get_context(); - $squashed_sha = merge_pr_into_target($pr_sha, $pr_first_sha, $target_ref, "$pr_title (GH-$pr_number)", $pr_description); - merge_upwards($release_branches); - $push_pr_branch_result = push_pr_branch($pr_repo_url, $pr_ref, $squashed_sha, $pr_sha); + $squashed_sha = merge_pr_into_target($context, "{$context->pr_title} (GH-{$context->pr_number})"); + merge_upwards($context); + $push_pr_branch_result = push_pr_branch($context, $squashed_sha); if ($push_pr_branch_result === PushPrBranchResult::Rejected) { throw new RuntimeException('PR branch diverged.'); } else if ($push_pr_branch_result === PushPrBranchResult::RemoteRejected) { - if ($github_output !== false) { - // Contributor likely unchecked the "Allow edits by maintainers" - // checkbox. Resume and close PR manually. - file_put_contents($github_output, "close_pr=1\n", FILE_APPEND); - } + // Contributor likely unchecked the "Allow edits by maintainers" + // checkbox. Resume and close PR manually. + file_put_contents($github_output, "close_pr=1\n", FILE_APPEND); } - if (!push_release_branches($release_branches)) { - revert_pr_branch($pr_repo_url, $pr_ref, $pr_sha, $squashed_sha); + if (!push_release_branches($context)) { + revert_pr_branch($context, $squashed_sha); throw new RuntimeException('Failed to push release branches.'); } } catch (Throwable $e) { - if ($github_output !== false) { - file_put_contents($github_output, "fail_reason<getMessage()}\nEOF\n", FILE_APPEND); - } + file_put_contents($github_output, "fail_reason<getMessage()}\nEOF\n", FILE_APPEND); fwrite(STDERR, "::error::{$e->getMessage()}\n"); return 1; } From 48b90e01085470ff52ea8b93756bf998cdad63b7 Mon Sep 17 00:00:00 2001 From: Ilija Tovilo Date: Sun, 24 May 2026 21:16:42 +0200 Subject: [PATCH 18/20] Simplify --- .github/scripts/merge_pr.php | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/scripts/merge_pr.php b/.github/scripts/merge_pr.php index 4ea1f93686e3..2dd6542c9f9e 100644 --- a/.github/scripts/merge_pr.php +++ b/.github/scripts/merge_pr.php @@ -125,8 +125,8 @@ function try_run(array $args): bool { } /** @param list $args */ -function run(array $args, ?string $failure_message = null): bool { - $result = run_command($args, $failure_message ?? 'Unexpected error.'); +function run(array $args, string $failure_message = 'Unexpected error.'): bool { + $result = run_command($args, $failure_message); return $result->status === 0; } @@ -168,8 +168,9 @@ function find_release_branches(string $target): array { return $branches; } -function merge_pr_into_target(Context $context, string $message): string { +function merge_pr_into_target(Context $context): string { $author = trim(run_command(['git', 'log', '-1', '--format=%an <%ae>', $context->pr_first_sha])->stdout); + $message = "{$context->pr_title} (GH-{$context->pr_number})"; run(['git', 'checkout', '-B', $context->target_ref, "refs/remotes/origin/{$context->target_ref}"]); run(['git', 'merge', '--squash', $context->pr_sha], @@ -254,7 +255,7 @@ function main(): int { try { $context = get_context(); - $squashed_sha = merge_pr_into_target($context, "{$context->pr_title} (GH-{$context->pr_number})"); + $squashed_sha = merge_pr_into_target($context); merge_upwards($context); $push_pr_branch_result = push_pr_branch($context, $squashed_sha); if ($push_pr_branch_result === PushPrBranchResult::Rejected) { From b42e954fda2be6fe578c8f42e299461e200024e2 Mon Sep 17 00:00:00 2001 From: Ilija Tovilo Date: Sun, 24 May 2026 21:20:24 +0200 Subject: [PATCH 19/20] Don't revert PR branch if it wasn't updated in the first place --- .github/scripts/merge_pr.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/scripts/merge_pr.php b/.github/scripts/merge_pr.php index 2dd6542c9f9e..f9251fa0b6b9 100644 --- a/.github/scripts/merge_pr.php +++ b/.github/scripts/merge_pr.php @@ -266,7 +266,9 @@ function main(): int { file_put_contents($github_output, "close_pr=1\n", FILE_APPEND); } if (!push_release_branches($context)) { - revert_pr_branch($context, $squashed_sha); + if ($push_pr_branch_result === PushPrBranchResult::Success) { + revert_pr_branch($context, $squashed_sha); + } throw new RuntimeException('Failed to push release branches.'); } } catch (Throwable $e) { From 679b87f11f5cbd61ce61e29c5bd2337d121320a6 Mon Sep 17 00:00:00 2001 From: Ilija Tovilo Date: Sun, 24 May 2026 21:28:41 +0200 Subject: [PATCH 20/20] Implement co-authors --- .github/scripts/merge_pr.php | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/scripts/merge_pr.php b/.github/scripts/merge_pr.php index f9251fa0b6b9..e57d7c126e67 100644 --- a/.github/scripts/merge_pr.php +++ b/.github/scripts/merge_pr.php @@ -171,11 +171,22 @@ function find_release_branches(string $target): array { function merge_pr_into_target(Context $context): string { $author = trim(run_command(['git', 'log', '-1', '--format=%an <%ae>', $context->pr_first_sha])->stdout); $message = "{$context->pr_title} (GH-{$context->pr_number})"; + $description = wrap_commit_message($context->pr_description); + + $co_authors = preg_split('(\n)', trim(run_command("git log --reverse --format='%an <%ae>' {$context->pr_first_sha}..{$context->pr_sha} | sort | uniq -c | sort -rn | sed 's/^ *[0-9]* //'")->stdout), flags: PREG_SPLIT_NO_EMPTY); + $co_authors = array_filter($co_authors, fn (string $co_author) => strcasecmp($co_author, $author) !== 0 && stripos($description, $co_author) === false); + if (count($co_authors)) { + $co_authors = array_map(fn (string $co_author) => 'Co-authored-by: ' . $co_author, $co_authors); + if ($description !== '') { + $description .= "\n\n"; + } + $description .= implode("\n", $co_authors); + } run(['git', 'checkout', '-B', $context->target_ref, "refs/remotes/origin/{$context->target_ref}"]); run(['git', 'merge', '--squash', $context->pr_sha], failure_message: "Failed to squash PR into {$context->target_ref}."); - run(['git', 'commit', "--author=$author", '-m', $message, '-m', wrap_commit_message($context->pr_description)]); + run(['git', 'commit', "--author=$author", '-m', $message, '-m', $description]); $squashed_sha = trim((string) shell_exec('git rev-parse HEAD')); return $squashed_sha;