Skip to content

Commit d191f50

Browse files
feat: auto-create/manage license fix PRs for failing PRs
Creates stacked PRs to fix license issues: - Detects when a PR needs license updates - Creates child PR: main <- PR:feature <- PR:license-fix - Tracks PRs with metadata and hash of license changes - Auto-closes if user fixes licenses manually - Auto-closes and recreates if dependencies change - Prevents multiple fix PRs for same base PR Rules: - Only targets PRs against main (not stacked PRs) - Only runs on ready-for-review PRs (not drafts) - Skips bots and forks - Hash-based detection avoids unnecessary work
1 parent 935f7d4 commit d191f50

File tree

2 files changed

+259
-4
lines changed

2 files changed

+259
-4
lines changed
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
name: Auto-fix License Files
2+
3+
# Automatically create/update/close PRs to fix license files on failing PRs
4+
# Creates: main <- PR:feature <- PR:license-fix
5+
# Only targets PRs against main, not stacked PRs
6+
7+
on:
8+
pull_request:
9+
types: [synchronize, opened, reopened, ready_for_review]
10+
paths:
11+
- "**.go"
12+
- go.mod
13+
- go.sum
14+
15+
permissions:
16+
contents: write
17+
pull-requests: write
18+
19+
jobs:
20+
auto-fix-licenses:
21+
runs-on: ubuntu-latest
22+
# Only run on PRs targeting main, from same repo, not drafts, not bots
23+
if: |
24+
github.event.pull_request.base.ref == 'main' &&
25+
github.event.pull_request.head.repo.full_name == github.repository &&
26+
github.event.pull_request.draft == false &&
27+
github.actor != 'dependabot[bot]'
28+
29+
steps:
30+
- name: Check out base PR branch
31+
uses: actions/checkout@v6
32+
with:
33+
ref: ${{ github.event.pull_request.head.ref }}
34+
fetch-depth: 0
35+
36+
- name: Set up Go
37+
uses: actions/setup-go@v6
38+
with:
39+
go-version-file: "go.mod"
40+
41+
- name: Regenerate licenses
42+
id: regen
43+
env:
44+
CI: "true"
45+
run: |
46+
export GOROOT=$(go env GOROOT)
47+
export PATH=${GOROOT}/bin:$PATH
48+
./script/licenses
49+
50+
# Check if licenses changed
51+
if git diff --exit-code --quiet third-party-licenses.*.md third-party/; then
52+
echo "needs_fix=false" >> $GITHUB_OUTPUT
53+
echo "✅ License files are up to date"
54+
else
55+
echo "needs_fix=true" >> $GITHUB_OUTPUT
56+
echo "📝 License files need updating"
57+
58+
# Compute hash of license changes only
59+
LICENSE_HASH=$(git diff third-party-licenses.*.md third-party/ | sha256sum | cut -c1-8)
60+
echo "license_hash=${LICENSE_HASH}" >> $GITHUB_OUTPUT
61+
echo "License changes hash: ${LICENSE_HASH}"
62+
fi
63+
64+
- name: Find existing license fix PR
65+
id: find_pr
66+
if: steps.regen.outputs.needs_fix == 'true'
67+
uses: actions/github-script@v7
68+
with:
69+
script: |
70+
const basePR = context.payload.pull_request.number;
71+
const baseBranch = context.payload.pull_request.head.ref;
72+
73+
// Search for existing auto-fix PR
74+
const { data: prs } = await github.rest.pulls.list({
75+
owner: context.repo.owner,
76+
repo: context.repo.repo,
77+
state: 'open',
78+
base: baseBranch,
79+
sort: 'created',
80+
direction: 'desc'
81+
});
82+
83+
// Find PR with our marker in the title or body
84+
const existingPR = prs.find(pr =>
85+
pr.title.includes('🤖 Auto-fix licenses') &&
86+
pr.body?.includes(`<!-- auto-fix-for-pr:${basePR} -->`)
87+
);
88+
89+
if (existingPR) {
90+
core.setOutput('exists', 'true');
91+
core.setOutput('pr_number', existingPR.number);
92+
core.setOutput('pr_branch', existingPR.head.ref);
93+
94+
// Extract hash from PR body
95+
const hashMatch = existingPR.body?.match(/<!-- license-hash:(\w+) -->/);
96+
const oldHash = hashMatch ? hashMatch[1] : '';
97+
core.setOutput('old_hash', oldHash);
98+
99+
core.info(`Found existing PR #${existingPR.number} with hash ${oldHash}`);
100+
} else {
101+
core.setOutput('exists', 'false');
102+
core.info('No existing auto-fix PR found');
103+
}
104+
105+
- name: Check if hash matches (user already fixed it)
106+
id: check_fixed
107+
if: steps.regen.outputs.needs_fix == 'false' && steps.find_pr.outputs.exists == 'true'
108+
uses: actions/github-script@v7
109+
with:
110+
script: |
111+
// User fixed licenses themselves, close our auto-fix PR
112+
const prNumber = ${{ steps.find_pr.outputs.pr_number }};
113+
114+
await github.rest.issues.createComment({
115+
owner: context.repo.owner,
116+
repo: context.repo.repo,
117+
issue_number: prNumber,
118+
body: `## ✅ Closing - licenses already fixed\n\nThe base PR #${context.payload.pull_request.number} now has up-to-date license files. This auto-fix PR is no longer needed.`
119+
});
120+
121+
await github.rest.pulls.update({
122+
owner: context.repo.owner,
123+
repo: context.repo.repo,
124+
pull_number: prNumber,
125+
state: 'closed'
126+
});
127+
128+
core.info(`Closed PR #${prNumber} as licenses are now fixed`);
129+
130+
- name: Close PR if hash changed (dependencies changed)
131+
id: check_hash_changed
132+
if: |
133+
steps.regen.outputs.needs_fix == 'true' &&
134+
steps.find_pr.outputs.exists == 'true' &&
135+
steps.find_pr.outputs.old_hash != '' &&
136+
steps.find_pr.outputs.old_hash != steps.regen.outputs.license_hash
137+
uses: actions/github-script@v7
138+
with:
139+
script: |
140+
const prNumber = ${{ steps.find_pr.outputs.pr_number }};
141+
const oldHash = '${{ steps.find_pr.outputs.old_hash }}';
142+
const newHash = '${{ steps.regen.outputs.license_hash }}';
143+
144+
await github.rest.issues.createComment({
145+
owner: context.repo.owner,
146+
repo: context.repo.repo,
147+
issue_number: prNumber,
148+
body: `## 🔄 Closing - dependencies changed\n\nThe base PR #${context.payload.pull_request.number} has different license requirements now.\n\n- Old hash: \`${oldHash}\`\n- New hash: \`${newHash}\`\n\nA new auto-fix PR will be created.`
149+
});
150+
151+
await github.rest.pulls.update({
152+
owner: context.repo.owner,
153+
repo: context.repo.repo,
154+
pull_number: prNumber,
155+
state: 'closed'
156+
});
157+
158+
core.setOutput('create_new', 'true');
159+
core.info(`Closed PR #${prNumber} due to hash change`);
160+
161+
- name: Create or update license fix PR
162+
if: |
163+
steps.regen.outputs.needs_fix == 'true' &&
164+
(steps.find_pr.outputs.exists == 'false' || steps.check_hash_changed.outputs.create_new == 'true')
165+
uses: actions/github-script@v7
166+
with:
167+
script: |
168+
const basePR = context.payload.pull_request.number;
169+
const baseBranch = context.payload.pull_request.head.ref;
170+
const licenseHash = '${{ steps.regen.outputs.license_hash }}';
171+
const branchName = `auto-fix/licenses-for-pr-${basePR}`;
172+
173+
// Create new branch from base PR
174+
const { data: baseRef } = await github.rest.git.getRef({
175+
owner: context.repo.owner,
176+
repo: context.repo.repo,
177+
ref: `heads/${baseBranch}`
178+
});
179+
180+
try {
181+
await github.rest.git.createRef({
182+
owner: context.repo.owner,
183+
repo: context.repo.repo,
184+
ref: `refs/heads/${branchName}`,
185+
sha: baseRef.object.sha
186+
});
187+
} catch (error) {
188+
// Branch might exist, update it
189+
await github.rest.git.updateRef({
190+
owner: context.repo.owner,
191+
repo: context.repo.repo,
192+
ref: `heads/${branchName}`,
193+
sha: baseRef.object.sha,
194+
force: true
195+
});
196+
}
197+
198+
// Checkout the new branch and commit license changes
199+
await exec.exec('git', ['fetch', 'origin', branchName]);
200+
await exec.exec('git', ['checkout', '-B', branchName, `origin/${branchName}`]);
201+
await exec.exec('git', ['config', 'user.name', 'github-actions[bot]']);
202+
await exec.exec('git', ['config', 'user.email', '41898282+github-actions[bot]@users.noreply.github.com']);
203+
204+
// Regenerate licenses on this branch
205+
process.env.CI = 'true';
206+
const goRoot = (await exec.getExecOutput('go', ['env', 'GOROOT'])).stdout.trim();
207+
process.env.GOROOT = goRoot;
208+
process.env.PATH = `${goRoot}/bin:${process.env.PATH}`;
209+
await exec.exec('./script/licenses');
210+
211+
await exec.exec('git', ['add', 'third-party', 'third-party-licenses.*.md']);
212+
await exec.exec('git', ['commit', '-m', `chore: auto-fix license files for PR #${basePR}`]);
213+
await exec.exec('git', ['push', 'origin', branchName, '--force']);
214+
215+
// Create PR
216+
const { data: newPR } = await github.rest.pulls.create({
217+
owner: context.repo.owner,
218+
repo: context.repo.repo,
219+
title: `🤖 Auto-fix licenses for PR #${basePR}`,
220+
head: branchName,
221+
base: baseBranch,
222+
body: `## Automated License Update
223+
224+
This PR automatically updates license files for PR #${basePR}.
225+
226+
### What happened
227+
Dependencies were added/updated in #${basePR}, which requires regenerating license documentation.
228+
229+
### What to do
230+
- **Option 1:** Merge this PR to add the license updates to #${basePR}
231+
- **Option 2:** Manually run \`./script/licenses\` in #${basePR} and push (this PR will auto-close)
232+
233+
This PR will automatically close if:
234+
- License files in #${basePR} are updated manually
235+
- Dependencies in #${basePR} change (a new PR will be created)
236+
237+
<!-- auto-fix-for-pr:${basePR} -->
238+
<!-- license-hash:${licenseHash} -->`
239+
});
240+
241+
core.info(`Created auto-fix PR #${newPR.number}`);

.github/workflows/license-check.yml

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -112,16 +112,30 @@ Please pull the latest changes before pushing again.`
112112
);
113113
114114
const isEmpty = nonLicenseFiles.length === 0;
115-
core.setOutput('is_empty', isEmpty);
116115
117-
if (isEmpty) {
118-
core.info('PR only contains license file changes - will close as stale');
116+
// Only close if the PR title/body suggests it wasn't meant to be about licenses
117+
// or if it was created by a bot (which might auto-update dependencies)
118+
const isLicenseFocused =
119+
pr.title.toLowerCase().includes('licen') ||
120+
pr.title.toLowerCase().includes('third-party') ||
121+
pr.body?.toLowerCase().includes('update.*licen');
122+
123+
const shouldClose = isEmpty && !isLicenseFocused && pr.user.type !== 'Bot';
124+
125+
core.setOutput('should_close', shouldClose);
126+
127+
if (isEmpty && isLicenseFocused) {
128+
core.info('PR only has license files but appears to be intentionally about licenses - keeping open');
129+
} else if (isEmpty && pr.user.type === 'Bot') {
130+
core.info('PR is from a bot and only has license files - keeping open (might be dependabot)');
131+
} else if (shouldClose) {
132+
core.info('PR only contains license file changes and appears stale - will close');
119133
} else {
120134
core.info(`PR has ${nonLicenseFiles.length} non-license file changes - keeping open`);
121135
}
122136
123137
- name: Close stale license-only PR
124-
if: steps.changes.outputs.changed == 'true' && steps.empty_check.outputs.is_empty == 'true'
138+
if: steps.changes.outputs.changed == 'true' && steps.empty_check.outputs.should_close == 'true'
125139
uses: actions/github-script@v7
126140
with:
127141
script: |

0 commit comments

Comments
 (0)