Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
231 changes: 231 additions & 0 deletions .github/scripts/merge_pr.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
<?php

class ProcessResult {
public $status;
public $stdout;
public $stderr;
}

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']];
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 ?? 'Unexpected error.');
return $result->status === 0;
}

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 {
Comment thread
iluuu1994 marked this conversation as resolved.
if ($current === 'master') {
return null;
}

if (!preg_match('(^PHP-(?<major>\d+)\.(?<minor>\d+)$)', $current, $matches)) {
throw new RuntimeException("Unsupported target branch $current.");
}

$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(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],
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.");
}
}

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"], failure_message: null);
if ($result->status === 0) {
return PushPrBranchResult::Success;
} else if (preg_match('(\[rejected\])', $result->stderr)) {
return PushPrBranchResult::Rejected;
} else {
return PushPrBranchResult::RemoteRejected;
}
}

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) {
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 {
$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_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');

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);
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 ($github_output !== false) {
file_put_contents($github_output, "fail_reason<<EOF\n{$e->getMessage()}\nEOF\n", FILE_APPEND);
}
fwrite(STDERR, "::error::{$e->getMessage()}\n");
return 1;
}

return 0;
}

exit(main());
108 changes: 108 additions & 0 deletions .github/workflows/merge_pr.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
name: Merge PR

on:
pull_request_target:
types: [labeled]
Comment on lines +4 to +5
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using pull_request_target should be avoided. I would suggest using workflow_dispatch that gets triggered by a separate small workflow that runs on issues: labeled, validates the label/actor/PR, then calls the dispatch workflow.


permissions:
contents: read

Comment thread
iluuu1994 marked this conversation as resolved.
concurrency:
group: ${{ github.workflow }}

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 }}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please check that merging with GITHUB_TOKEN triggers CI for merged branches, This might need an app 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)
Comment thread
iluuu1994 marked this conversation as resolved.
PR_SHA: ${{ github.event.pull_request.head.sha }}
PR_REF: ${{ github.event.pull_request.head.ref }}
Comment on lines +48 to +50
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There should be a check to stop this workflow before pushing changes if the PR_SHA does not match the PR head as it changed between the label event and the workflow merging and pushing. This can happen when the nightly workflow is running and other workflows runs are delayed.

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_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"
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}`);
}

- 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}`);
}
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down