Skip to content

Commit 824dfe7

Browse files
committed
Add merge workflow
1 parent c56f5ad commit 824dfe7

3 files changed

Lines changed: 230 additions & 1 deletion

File tree

.github/scripts/merge_pr.php

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
<?php
2+
3+
function try_run(array $args): bool {
4+
$command = implode(' ', array_map('escapeshellarg', $args));
5+
echo "> $command\n";
6+
passthru($command, $status);
7+
return $status === 0;
8+
}
9+
10+
function run(array $args, ?string $failure_message = null) {
11+
if (!try_run($args)) {
12+
throw new RuntimeException($failure_message ?? 'Command failed');
13+
}
14+
}
15+
16+
function origin_branch_exists(string $branch): bool {
17+
return try_run(['git', 'show-ref', '--verify', '--quiet', "refs/remotes/origin/$branch"]);
18+
}
19+
20+
function find_next_release_branch(string $current): ?string {
21+
if ($current === 'master') {
22+
return null;
23+
}
24+
25+
if (!preg_match('(^PHP-(?<major>\d+)\.(?<minor>\d+)$)', $current, $matches)) {
26+
return null;
27+
}
28+
29+
$major = $matches['major'];
30+
$minor = $matches['minor'];
31+
32+
$next = "PHP-$major." . ($minor + 1);
33+
if (origin_branch_exists($next)) {
34+
return $next;
35+
}
36+
37+
$next = 'PHP-' . ($major + 1) . '.0';
38+
if (origin_branch_exists($next)) {
39+
return $next;
40+
}
41+
42+
return 'master';
43+
}
44+
45+
function find_release_branches(string $target): array {
46+
$branches = [$target];
47+
while (null !== $next = find_next_release_branch(end($branches))) {
48+
$branches[] = $next;
49+
}
50+
return $branches;
51+
}
52+
53+
function merge_pr_into_target(string $pr_sha, string $pr_first_sha, string $target, string $message, string $description): string {
54+
$author = trim((string) shell_exec('git log -1 --format=' . escapeshellarg('%an <%ae>') . ' ' . escapeshellarg($pr_first_sha)));
55+
56+
run(['git', 'checkout', '-B', $target, "refs/remotes/origin/$target"]);
57+
run(['git', 'merge', '--squash', $pr_sha],
58+
failure_message: "Failed to squash PR into $target.");
59+
run(['git', 'commit', "--author=$author", '-m', $message, '-m', wrap_commit_message($description)]);
60+
$squashed_sha = trim((string) shell_exec('git rev-parse HEAD'));
61+
62+
return $squashed_sha;
63+
}
64+
65+
function merge_upwards(array $branches) {
66+
for ($i = 1; $i < count($branches); $i++) {
67+
$prev = $branches[$i - 1];
68+
$current = $branches[$i];
69+
run(['git', 'checkout', '-B', $current, "refs/remotes/origin/$current"]);
70+
run(['git', 'merge', '--no-ff', '--no-edit', $prev],
71+
failure_message: "Failed to merge $prev into $current.");
72+
}
73+
}
74+
75+
function push_pr_branch(string $url, string $branch, string $squashed_sha, string $original_sha) {
76+
run(['git', 'push', "--force-with-lease=$branch:$original_sha", $url, "$squashed_sha:refs/heads/$branch"],
77+
failure_message: 'Failed to push rebased PR branch.');
78+
}
79+
80+
function push_release_branches(array $branches) {
81+
run(['git', 'push', '--atomic', 'origin', ...$branches],
82+
failure_message: 'Failed to push release branches.');
83+
}
84+
85+
function wrap_commit_message(string $message, int $width = 80): string {
86+
$lines = explode("\n", $message);
87+
$result = [];
88+
$code_section = false;
89+
90+
foreach ($lines as $line) {
91+
if (preg_match('(^\s*```)', $line)) {
92+
$code_section = !$code_section;
93+
$result[] = $line;
94+
continue;
95+
}
96+
97+
if ($code_section) {
98+
$result[] = $line;
99+
continue;
100+
}
101+
102+
if ($line === '' || preg_match('(^\s)', $line)) {
103+
$result[] = $line;
104+
continue;
105+
}
106+
107+
$result[] = wordwrap($line, $width, "\n", false);
108+
}
109+
110+
return implode("\n", $result);
111+
}
112+
113+
function main(): int {
114+
$target = getenv('TARGET_BRANCH');
115+
$pr_number = getenv('PR_NUMBER');
116+
$pr_first_sha = getenv('PR_FIRST_SHA');
117+
$pr_sha = getenv('PR_SHA');
118+
$pr_ref = getenv('PR_REF');
119+
$pr_repo_url = getenv('PR_REPO_URL');
120+
$pr_title = getenv('PR_TITLE');
121+
$pr_description = getenv('PR_DESCRIPTION');
122+
123+
$release_branches = find_release_branches($target);
124+
125+
try {
126+
$squashed_sha = merge_pr_into_target($pr_sha, $pr_first_sha, $target, "$pr_title (GH-$pr_number)", $pr_description);
127+
merge_upwards($release_branches);
128+
push_pr_branch($pr_repo_url, $pr_ref, $squashed_sha, $pr_sha);
129+
push_release_branches($release_branches);
130+
} catch (Throwable $e) {
131+
if (false !== ($github_output = getenv('GITHUB_OUTPUT'))) {
132+
file_put_contents($github_output, "fail_reason<<EOF\n{$e->getMessage()}\nEOF\n", FILE_APPEND);
133+
}
134+
fwrite(STDERR, "::error::{$e->getMessage()}\n");
135+
return 1;
136+
}
137+
138+
return 0;
139+
}
140+
141+
exit(main());

.github/workflows/merge_pr.yml

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
name: Merge PR
2+
3+
on:
4+
pull_request_target:
5+
types: [labeled]
6+
7+
permissions:
8+
contents: read
9+
10+
jobs:
11+
merge_pr:
12+
name: Merge PR
13+
if: github.event.label.name == 'Merge'
14+
runs-on: ubuntu-latest
15+
permissions:
16+
contents: write
17+
pull-requests: write
18+
steps:
19+
- name: git checkout
20+
uses: actions/checkout@v6
21+
with:
22+
fetch-depth: 0
23+
filter: blob:none
24+
ref: ${{ github.event.pull_request.base.ref }}
25+
token: ${{ secrets.GITHUB_TOKEN }}
26+
27+
- name: git config
28+
run: |
29+
git config user.name "PHP GH Bot"
30+
git config user.email "gh-bot@php.net"
31+
git config merge.NEWS.name "Keep the NEWS file"
32+
git config merge.NEWS.driver "touch %A"
33+
git config merge.log true
34+
35+
- name: Fetch PR head
36+
env:
37+
PR_NUMBER: ${{ github.event.pull_request.number }}
38+
run: |
39+
git fetch origin "refs/pull/${PR_NUMBER}/head"
40+
41+
- name: Merge PR
42+
id: merge
43+
env:
44+
PR_NUMBER: ${{ github.event.pull_request.number }}
45+
PR_FIRST_SHA: $(git log --reverse --format=%H ${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }} | head -n1)
46+
PR_SHA: ${{ github.event.pull_request.head.sha }}
47+
PR_REF: ${{ github.event.pull_request.head.ref }}
48+
PR_REPO_URL: ${{ github.event.pull_request.head.repo.clone_url }}
49+
PR_TITLE: ${{ github.event.pull_request.title }}
50+
PR_DESCRIPTION: ${{ github.event.pull_request.body }}
51+
TARGET_BRANCH: ${{ github.event.pull_request.base.ref }}
52+
run: |
53+
# Use merge script from master to avoid syncing to lower branches.
54+
git show origin/master:.github/scripts/merge_pr.php > "$RUNNER_TEMP/merge_pr.php"
55+
php "$RUNNER_TEMP/merge_pr.php"
56+
57+
- name: Report failure
58+
if: failure()
59+
uses: actions/github-script@v9
60+
env:
61+
FAIL_REASON: ${{ steps.merge.outputs.fail_reason }}
62+
with:
63+
script: |
64+
const reason = process.env.FAIL_REASON || 'Unknown error.';
65+
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
66+
67+
await github.rest.issues.createComment({
68+
owner: context.repo.owner,
69+
repo: context.repo.repo,
70+
issue_number: context.issue.number,
71+
body: `Merge failed: ${reason}\n\n[View workflow run](${runUrl}).`,
72+
});
73+
74+
- name: Remove Merge label
75+
if: ${{ always() }}
76+
uses: actions/github-script@v9
77+
with:
78+
script: |
79+
try {
80+
await github.rest.issues.removeLabel({
81+
owner: context.repo.owner,
82+
repo: context.repo.repo,
83+
issue_number: context.issue.number,
84+
name: 'Merge',
85+
});
86+
} catch (e) {
87+
core.warning(`Could not remove the 'Merge' label: ${e.message}`);
88+
}

.github/workflows/test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ concurrency:
3434
jobs:
3535
GENERATE_MATRIX:
3636
name: Generate Matrix
37-
if: github.repository == 'php/php-src' || github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch'
37+
if: (github.repository == 'php/php-src' || github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch') && !contains(github.event.pull_request.labels.*.name, 'Merge')
3838
runs-on: ubuntu-latest
3939
outputs:
4040
all_variations: ${{ steps.set-matrix.outputs.all_variations }}

0 commit comments

Comments
 (0)