From 85a47eb4a12f6b5857eec2990282ce8f3d160263 Mon Sep 17 00:00:00 2001 From: Ilija Tovilo Date: Thu, 21 May 2026 15:10:01 +0200 Subject: [PATCH 01/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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'];