Skip to content

Commit 7ff114a

Browse files
authored
fix: auto-rebase skips failed PRs and eliminates notification spam (#6924)
- Replace peter-evans/rebase@v4 with custom github-script that wraps each PR in its own try/catch — failed rebases are skipped instead of stopping the entire workflow - Use execFileSync (no shell) to prevent injection via branch names - Add merge-base check to skip PRs already up-to-date (no useless push) - Add concurrency group to prevent overlapping rebase runs - Handle deleted forks (null head.repo) and missing maintainer access - Sequence conflict-check after auto-rebase via workflow_run instead of running both on push:master in parallel — prevents false-alarm 'merge conflicts' comments on PRs that auto-rebase is about to fix - Suppress commentOnClean to avoid 'resolved' comment spam
1 parent d412335 commit 7ff114a

2 files changed

Lines changed: 163 additions & 11 deletions

File tree

.github/workflows/auto-rebase.yml

Lines changed: 155 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,164 @@ permissions:
99
contents: write
1010
pull-requests: write
1111

12+
# Prevent two rebase runs from fighting over the same PR branches
13+
concurrency:
14+
group: auto-rebase
15+
cancel-in-progress: true
16+
1217
jobs:
1318
rebase:
1419
name: Auto-rebase all open PRs onto latest master
1520
runs-on: ubuntu-latest
1621
steps:
17-
- uses: peter-evans/rebase@v4
22+
- uses: actions/checkout@v4
23+
with:
24+
fetch-depth: 0
25+
token: ${{ secrets.GITHUB_TOKEN }}
26+
27+
- name: Rebase open PRs
28+
uses: actions/github-script@v7
1829
with:
19-
base: master
20-
exclude-labels: |
21-
no-rebase
22-
wip
23-
do-not-merge
24-
exclude-drafts: true
30+
script: |
31+
const { execFileSync } = require('child_process');
32+
const excludeLabels = ['no-rebase', 'wip', 'do-not-merge'];
33+
34+
// Safe git helper — no shell, prevents injection via branch names
35+
// or committer fields
36+
const git = (...args) =>
37+
execFileSync('git', args, { encoding: 'utf8', stdio: 'pipe' });
38+
39+
// Safe cleanup helpers — never throw
40+
const tryGit = (...args) => {
41+
try { git(...args); } catch {}
42+
};
43+
const cleanupIteration = (remoteName) => {
44+
tryGit('rebase', '--abort');
45+
tryGit('checkout', 'master');
46+
tryGit('branch', '-D', '__rebase_tmp');
47+
if (remoteName) tryGit('remote', 'remove', remoteName);
48+
};
49+
50+
// Fetch all open, non-draft PRs targeting master
51+
const pulls = await github.paginate(
52+
github.rest.pulls.list,
53+
{
54+
owner: context.repo.owner,
55+
repo: context.repo.repo,
56+
state: 'open',
57+
base: 'master',
58+
per_page: 100
59+
}
60+
);
61+
62+
// Filter out drafts and excluded labels
63+
const eligible = pulls.filter(pr => {
64+
if (pr.draft) return false;
65+
const labels = pr.labels.map(l => l.name);
66+
return !excludeLabels.some(ex => labels.includes(ex));
67+
});
68+
69+
if (eligible.length === 0) {
70+
core.info('No eligible pull requests found.');
71+
return;
72+
}
73+
74+
core.info(`Found ${eligible.length} eligible PR(s) to rebase.`);
75+
76+
let rebased = 0;
77+
let skipped = 0;
78+
const failures = [];
79+
80+
for (const pr of eligible) {
81+
const headRef = pr.head.ref;
82+
const headRepo = pr.head.repo;
83+
const prNumber = pr.number;
84+
const remoteName = `__pr${prNumber}`;
85+
86+
core.info(`\n--- PR #${prNumber}: ${pr.title} (${headRef}) ---`);
87+
88+
// Skip PRs whose fork has been deleted (head.repo becomes null)
89+
if (!headRepo) {
90+
core.warning(
91+
`PR #${prNumber}: skipped — source repository was deleted.`
92+
);
93+
skipped++;
94+
failures.push(`#${prNumber} (repo deleted)`);
95+
continue;
96+
}
97+
98+
// Skip PRs from forks that don't allow maintainer edits
99+
if (headRepo.full_name !== `${context.repo.owner}/${context.repo.repo}`) {
100+
if (!pr.maintainer_can_modify) {
101+
core.warning(
102+
`PR #${prNumber}: skipped — fork does not allow maintainer edits.`
103+
);
104+
skipped++;
105+
failures.push(`#${prNumber} (no maintainer access)`);
106+
continue;
107+
}
108+
}
109+
110+
try {
111+
// Clean up any leftover state from a previous iteration
112+
cleanupIteration(null);
113+
114+
const remoteUrl = headRepo.clone_url;
115+
116+
// Add remote for this PR (remove first if exists from a prior run)
117+
tryGit('remote', 'remove', remoteName);
118+
git('remote', 'add', remoteName, remoteUrl);
119+
git('fetch', remoteName, headRef);
120+
121+
// Checkout the PR branch locally
122+
git('checkout', '-b', '__rebase_tmp', `${remoteName}/${headRef}`);
123+
124+
// Check if already up-to-date with master
125+
const mergeBase = git('merge-base', 'HEAD', 'origin/master').trim();
126+
const masterHead = git('rev-parse', 'origin/master').trim();
127+
128+
if (mergeBase === masterHead) {
129+
core.info(`PR #${prNumber}: already up to date.`);
130+
cleanupIteration(remoteName);
131+
continue;
132+
}
133+
134+
// Preserve the committer identity from the PR's last commit
135+
const committerName = git('log', '-1', '--format=%cn').trim();
136+
const committerEmail = git('log', '-1', '--format=%ce').trim();
137+
git('config', 'user.name', committerName);
138+
git('config', 'user.email', committerEmail);
139+
140+
// Attempt rebase onto latest master
141+
git('rebase', 'origin/master');
142+
143+
// Push rebased branch back (force-with-lease for safety)
144+
git('push', '--force-with-lease', remoteName, `HEAD:${headRef}`);
145+
146+
core.info(`PR #${prNumber}: successfully rebased.`);
147+
rebased++;
148+
149+
// Clean up this iteration's remote and branch
150+
cleanupIteration(remoteName);
151+
} catch (err) {
152+
// Rebase failed (conflicts, permissions, etc.) — skip and continue
153+
core.warning(
154+
`PR #${prNumber}: rebase failed, skipping. Error: ${err.message}`
155+
);
156+
skipped++;
157+
failures.push(`#${prNumber}`);
158+
159+
// Best-effort cleanup so the next iteration starts clean
160+
cleanupIteration(remoteName);
161+
}
162+
}
163+
164+
core.info(`\n=== Summary ===`);
165+
core.info(`Rebased: ${rebased}`);
166+
core.info(`Skipped/failed: ${skipped}`);
167+
if (failures.length > 0) {
168+
core.info(`Failed PRs: ${failures.join(', ')}`);
169+
}
170+
core.info(`Total eligible: ${eligible.length}`);
171+
172+
core.setOutput('rebased-count', rebased);

.github/workflows/conflict-check.yml

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
name: Label Conflicting PRs
22

33
on:
4-
push:
5-
branches: [master]
4+
# Run AFTER auto-rebase finishes, not in parallel with it.
5+
# This ensures we only label PRs that auto-rebase could NOT fix
6+
# (real conflicts), avoiding false-alarm notifications.
7+
workflow_run:
8+
workflows: ["Auto Rebase PRs"]
9+
types: [completed]
10+
# Also run when a contributor pushes to their PR branch
611
pull_request_target:
712
types: [synchronize]
813

@@ -39,5 +44,4 @@ jobs:
3944
```
4045
4146
> **Tip:** Enable "Allow edits from maintainers" on this PR so we can auto-rebase for you next time. This only grants access to your PR branch. Your fork's other branches are not affected.
42-
commentOnClean: |
43-
Merge conflicts resolved. Ready for review.
47+
commentOnClean: ""

0 commit comments

Comments
 (0)