-
Notifications
You must be signed in to change notification settings - Fork 8.1k
Add merge workflow #22120
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Add merge workflow #22120
Changes from all commits
85a47eb
b89f975
6d23b57
33bec60
2653fc0
14a774d
d5ae78a
58ef827
9f4c748
b41e5b7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 { | ||
| 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()); | ||
| 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
|
|
||
| permissions: | ||
| contents: read | ||
|
|
||
|
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 }} | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
|
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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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}`); | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.