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 }}