# SECURITY NOTE: # This workflow uses pull_request_target with checkout from forks. # Only git operations (rebase, push) are performed - no build/test scripts. # Preflight checks require a repository collaborator to trigger rebase and, # for fork PRs, maintainer_can_modify (Allow edits from maintainers). # Do NOT add steps that execute untrusted code from the PR head. name: Rebase user branch on: pull_request_target: types: [labeled] workflow_dispatch: inputs: pull_number: description: 'Pull Request number to rebase' required: true type: string concurrency: group: rebase-pr-${{ github.event_name == 'workflow_dispatch' && inputs.pull_number || github.event.pull_request.number }} cancel-in-progress: true jobs: rebase: if: github.event_name == 'workflow_dispatch' || github.event.label.name == 'rebase-user-branch' runs-on: ubuntu-latest permissions: contents: write pull-requests: write env: WF_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} steps: - name: Resolve pull request id: pr uses: actions/github-script@v7 with: github-token: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }} script: | let pr; if (context.eventName === 'workflow_dispatch') { const pullNumber = Number.parseInt(context.payload.inputs.pull_number, 10); if (!Number.isFinite(pullNumber) || pullNumber <= 0) { core.setFailed('pull_number must be a positive integer'); return; } const { data } = await github.rest.pulls.get({ owner: context.repo.owner, repo: context.repo.repo, pull_number: pullNumber, }); pr = data; } else { pr = context.payload.pull_request; } core.setOutput('number', String(pr.number)); core.setOutput('state', pr.state); core.setOutput('head_sha', pr.head.sha); core.setOutput('head_ref', pr.head.ref); core.setOutput('head_repo_full_name', pr.head.repo.full_name); core.setOutput('head_repo_fork', pr.head.repo.fork ? 'true' : 'false'); core.setOutput('maintainer_can_modify', pr.maintainer_can_modify ? 'true' : 'false'); core.setOutput('base_ref', pr.base.ref); core.setOutput('base_repo_clone_url', pr.base.repo.clone_url); # Preconditions: # 1. Pull request must be open. # 2. Triggered by a repository collaborator (label sender or workflow_dispatch actor). # 3. For fork PRs, "Allow edits from maintainers" must be enabled. - name: Verify rebase preconditions id: preflight uses: actions/github-script@v7 with: github-token: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }} script: | const sender = context.eventName === 'workflow_dispatch' ? context.actor : context.payload.sender.login; let senderIsCollaborator = false; try { await github.rest.repos.checkCollaborator({ owner: context.repo.owner, repo: context.repo.repo, username: sender, }); senderIsCollaborator = true; } catch (error) { if (!error.status || error.status != 404) { throw error; } } const forkPushAllowed = '${{ steps.pr.outputs.head_repo_fork }}' !== 'true' || '${{ steps.pr.outputs.maintainer_can_modify }}' === 'true'; core.setOutput('sender', sender); core.setOutput('pr_is_open', '${{ steps.pr.outputs.state }}' === 'open' ? 'true' : 'false'); core.setOutput('sender_is_collaborator', senderIsCollaborator ? 'true' : 'false'); core.setOutput('fork_push_allowed', forkPushAllowed ? 'true' : 'false'); - name: Decline rebase — pull request is not open if: steps.preflight.outputs.pr_is_open != 'true' uses: actions/github-script@v7 with: script: | const sender = '${{ steps.preflight.outputs.sender }}'; const issueNumber = Number.parseInt('${{ steps.pr.outputs.number }}', 10); const footer = `\n\n---\n[Workflow run](${process.env.WF_RUN_URL})`; await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issueNumber, body: `⚠️ @${sender} Rebase declined: rebase can only be run on open pull requests.` + footer }); - name: Decline rebase — trigger is not a collaborator if: steps.preflight.outputs.pr_is_open == 'true' && steps.preflight.outputs.sender_is_collaborator != 'true' uses: actions/github-script@v7 with: script: | const sender = '${{ steps.preflight.outputs.sender }}'; const issueNumber = Number.parseInt('${{ steps.pr.outputs.number }}', 10); const footer = `\n\n---\n[Workflow run](${process.env.WF_RUN_URL})`; await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issueNumber, body: `⚠️ @${sender} Rebase declined, only collaborator can rebase user branch` + footer }); - name: Decline rebase — fork does not allow maintainer push if: steps.preflight.outputs.pr_is_open == 'true' && steps.preflight.outputs.sender_is_collaborator == 'true' && steps.preflight.outputs.fork_push_allowed != 'true' uses: actions/github-script@v7 with: script: | const sender = '${{ steps.preflight.outputs.sender }}'; const issueNumber = Number.parseInt('${{ steps.pr.outputs.number }}', 10); const footer = `\n\n---\n[Workflow run](${process.env.WF_RUN_URL})`; await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issueNumber, body: `⚠️ @${sender} Rebase declined: this PR is from a fork and **Allow edits from maintainers** is not enabled.\n\nThe PR author must enable it in the PR sidebar so maintainers can push the rebased branch. Alternatively, rebase locally and push to the fork.` + footer }); - name: Fail if preconditions are not met if: steps.preflight.outputs.pr_is_open != 'true' || steps.preflight.outputs.sender_is_collaborator != 'true' || steps.preflight.outputs.fork_push_allowed != 'true' run: exit 1 - name: Remove previous failure label uses: actions/github-script@v7 with: script: | const issueNumber = Number.parseInt('${{ steps.pr.outputs.number }}', 10); try { await github.rest.issues.removeLabel({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issueNumber, name: 'rebase-user-branch-failed' }); } catch (error) { if (!error.status || error.status != 404) { throw error; } } - name: Checkout PR branch uses: actions/checkout@v4 with: ref: ${{ steps.pr.outputs.head_sha }} repository: ${{ steps.pr.outputs.head_repo_full_name }} token: ${{ secrets.YDBOT_TOKEN }} fetch-depth: 0 filter: blob:none - name: Configure git run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - name: Fetch base branch env: BASE_REF: ${{ steps.pr.outputs.base_ref }} BASE_REPO: ${{ steps.pr.outputs.base_repo_clone_url }} run: | git remote set-url upstream "$BASE_REPO" 2>/dev/null || git remote add upstream "$BASE_REPO" git fetch upstream "refs/heads/$BASE_REF:refs/remotes/upstream/$BASE_REF" - name: Attempt rebase id: rebase continue-on-error: true env: BASE_REF: ${{ steps.pr.outputs.base_ref }} run: | if git rebase "upstream/$BASE_REF"; then echo "rebase_status=success" >> $GITHUB_OUTPUT else echo "rebase_status=failed" >> $GITHUB_OUTPUT git rebase --abort fi - name: Handle unexpected rebase failure if: steps.rebase.outputs.rebase_status == '' uses: actions/github-script@v7 with: script: | const issueNumber = Number.parseInt('${{ steps.pr.outputs.number }}', 10); const footer = `\n\n---\n[Workflow run](${process.env.WF_RUN_URL})`; await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issueNumber, body: '❌ **Rebase failed unexpectedly.**\n\nPlease check the workflow logs or try re-applying the `rebase-user-branch` label.' + footer }); - name: Fail on unexpected error if: steps.rebase.outputs.rebase_status == '' run: exit 1 - name: Push rebased branch id: push if: steps.rebase.outputs.rebase_status == 'success' continue-on-error: true env: HEAD_REF: ${{ steps.pr.outputs.head_ref }} run: | git push --force-with-lease origin HEAD:"$HEAD_REF" - name: Add push failure comment if: steps.rebase.outputs.rebase_status == 'success' && steps.push.outcome == 'failure' uses: actions/github-script@v7 with: script: | const issueNumber = Number.parseInt('${{ steps.pr.outputs.number }}', 10); const isFork = '${{ steps.pr.outputs.head_repo_fork }}' === 'true'; const footer = `\n\n---\n[Workflow run](${process.env.WF_RUN_URL})`; const forkHint = isFork ? '\n\nIf this is a fork PR, verify that **Allow edits from maintainers** is still enabled.' : ''; await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issueNumber, body: '⚠️ **Rebase succeeded but push failed.**\n\nThe remote branch may have been updated.' + forkHint + '\n\nPlease re-apply the `rebase-user-branch` label to retry.' + footer }); - name: Add success comment if: steps.push.outcome == 'success' uses: actions/github-script@v7 env: BASE_REF: ${{ steps.pr.outputs.base_ref }} with: script: | const baseRef = process.env.BASE_REF; const issueNumber = Number.parseInt('${{ steps.pr.outputs.number }}', 10); const footer = `\n\n---\n[Workflow run](${process.env.WF_RUN_URL})`; await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issueNumber, body: `✅ **Rebase complete!**\n\nPR has been automatically rebased onto \`${baseRef}\`.` + footer }); - name: Add failure comment for conflicts if: steps.rebase.outputs.rebase_status == 'failed' uses: actions/github-script@v7 env: BASE_REF: ${{ steps.pr.outputs.base_ref }} with: script: | const baseRef = process.env.BASE_REF; const issueNumber = Number.parseInt('${{ steps.pr.outputs.number }}', 10); const isFork = '${{ steps.pr.outputs.head_repo_fork }}' === 'true'; const upstreamUrl = 'https://github.com/' + context.repo.owner + '/' + context.repo.repo + '.git'; const instructions = isFork ? 'git remote set-url upstream ' + upstreamUrl + ' 2>/dev/null || git remote add upstream ' + upstreamUrl + '\n' + 'git fetch upstream\n' + 'git rebase upstream/' + baseRef : 'git fetch origin\n' + 'git rebase origin/' + baseRef; const footer = `\n\n---\n[Workflow run](${process.env.WF_RUN_URL})`; await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issueNumber, body: '❌ **Rebase failed due to conflicts.**\n\nPlease resolve them manually:\n```\n' + instructions + '\n```\nAfter resolving conflicts and pushing your branch, PR checks will restart automatically.' + footer }); - name: Fail the job if conflicts if: steps.rebase.outputs.rebase_status == 'failed' run: exit 1 - name: Fail if push failed if: steps.rebase.outputs.rebase_status == 'success' && steps.push.outcome == 'failure' run: exit 1 - name: Remove trigger label if: always() && steps.pr.outputs.number != '' uses: actions/github-script@v7 with: script: | const issueNumber = Number.parseInt('${{ steps.pr.outputs.number }}', 10); try { await github.rest.issues.removeLabel({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issueNumber, name: 'rebase-user-branch' }); } catch (error) { if (!error.status || error.status != 404) { throw error; } } - name: Mark rebase as failed if: always() && steps.pr.outputs.number != '' && steps.push.outcome != 'success' uses: actions/github-script@v7 with: script: | const issueNumber = Number.parseInt('${{ steps.pr.outputs.number }}', 10); await github.rest.issues.addLabels({ owner: context.repo.owner, repo: context.repo.repo, issue_number: issueNumber, labels: ['rebase-user-branch-failed'] });