-
Notifications
You must be signed in to change notification settings - Fork 5
383 lines (352 loc) · 18.8 KB
/
Copy pathlint.yml
File metadata and controls
383 lines (352 loc) · 18.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
name: Lint
# Lint gate for `develop` — warn locally, fix-and-account here.
#
# The model (settled with Cail + Sacha, 2026-06-23/30; autofix added 2026-07-10):
# * Locally, ruff auto-applies safe fixes and only WARNS on the rest (see
# .pre-commit-config.yaml) — you commit freely.
# * Getting into `develop` is gated. This job runs the FULL ruff policy
# (`ruff check .` + `ruff format --check .`, region-aware per pyproject.toml).
# What happens next depends on the event:
#
# - On a same-repo PR (head branch lives in THIS repo, not a fork) → the gate
# doesn't just report; it FIXES. If the first pass isn't clean it runs
# `ruff check --fix-only` + `ruff format`, commits the diff as the
# github-actions bot, and pushes it back to the PR branch. Then it re-runs
# ruff on the fixed tree IN THE SAME RUN and gates on THAT: if formatting +
# safe fixes cleaned everything → job GREEN, comment says autofix was
# pushed; if anything survives (unsafe/judgement lint — undefined names,
# unused vars) → job RED, comment lists ONLY the residual (the mechanical
# stuff is already fixed). Contributors mostly never touch ruff by hand.
#
# - On a fork PR (read-only token, can't push to the fork's branch) → the old
# behaviour: run the checks, and on failure (1) fail RED so the check blocks
# the merge, (2) post/update a COMMENT on the PR with the full violation
# list. The comment turns green when ruff passes.
#
# - On a direct push to develop → there's no PR to comment on, so on failure
# it opens (or updates) ONE lint-debt issue for the committer, @-mentioning
# and assigning them. Auto-closes when their next push is clean. (No autofix
# here — pushing a bot commit onto develop would be its own event.)
#
# * If ruff itself can't run (network, bad version), the job goes red but says
# nothing — that's infra, not the committer's lint debt.
#
# Triggers: pushes to `develop` and PRs targeting `develop` only — other
# branches stay quiet (too noisy otherwise). `workflow_dispatch` is for manual
# testing of the gate itself.
#
# Why `pull_request_target` for PRs: commenting on (and pushing to) a PR needs a
# write token, and PRs from forks (e.g. sachaguer/) get a read-only token under
# the plain `pull_request` event. `pull_request_target` runs this workflow from
# the BASE branch (so the workflow definition is trusted) with a write token,
# while we check out the PR head ONLY to lint it. Two hardening measures make
# running tooling over untrusted PR code safe here: ruff is a static analyzer (it
# parses files, never imports/executes them), and `uvx --no-config` makes uv
# ignore any `uv.toml`/`[tool.uv]` in the PR tree, so a malicious PR can't
# redirect ruff's download to a trojaned index. The checkout also drops its git
# credentials. (A PR can still edit `[tool.ruff]` to weaken its own policy, but
# that's visible in the diff and reviewed like any other change.)
#
# Why the autofix push is safe under pull_request_target: we run ONLY ruff over
# the untrusted tree (static, never executes PR code), and we push back ONLY the
# diff ruff itself produced — no PR-authored script runs with our write token.
# The push uses the workflow token explicitly (the checkout keeps
# persist-credentials: false), and a GITHUB_TOKEN push does NOT trigger a new
# workflow run — so no recursion, but also no fresh CI on the bot commit, which
# is exactly why we re-lint and gate in THIS run rather than waiting for a rerun.
on:
push:
branches: [develop]
pull_request_target:
branches: [develop]
workflow_dispatch:
# One lint run per branch / PR; newer pushes cancel older in-flight runs.
concurrency:
group: lint-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: write # push ruff autofix commit back to a same-repo PR branch
issues: write # develop-push lint-debt issue
pull-requests: write # PR lint comment
jobs:
lint:
name: Ruff (check + format)
runs-on: ubuntu-latest
steps:
- name: Checkout (PR head on pull_request_target, else the pushed ref)
uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || github.sha }}
persist-credentials: false
- name: Install uv
uses: astral-sh/setup-uv@v3
# Is this a PR whose head branch lives in THIS repo (not a fork)? Only then
# can we push an autofix commit back to it with the workflow token.
- name: Decide whether autofix can push
id: mode
shell: bash
run: |
if [ "${{ github.event_name }}" = "pull_request_target" ] && \
[ "${{ github.event.pull_request.head.repo.full_name }}" = "${{ github.repository }}" ]; then
echo "autofix=true" >> "$GITHUB_OUTPUT"
else
echo "autofix=false" >> "$GITHUB_OUTPUT"
fi
# Run the checks WITHOUT failing the step — we post feedback before turning
# the job red. `ruff@<pin>` matches the pre-commit version; `--no-config`
# neutralizes any uv config in the (untrusted) PR tree.
# ruff exit codes: 0 = clean, 1 = violations, >=2 = ruff/uvx error.
- name: Run ruff
id: ruff
shell: bash
run: |
set +e
# Inline annotations on the diff (ruff's GitHub format → step log).
uvx --no-config ruff@0.15.18 check . --output-format=github
# Readable output for the PR comment / issue body, plus exit codes.
uvx --no-config ruff@0.15.18 check . --output-format=concise > check.txt 2>&1; check_rc=$?
uvx --no-config ruff@0.15.18 format --check . > format.txt 2>&1; fmt_rc=$?
{
echo "### \`ruff check .\`"
if [ "$check_rc" -eq 0 ]; then echo; echo "✅ clean"; else echo; echo '```'; cat check.txt; echo '```'; fi
echo
echo "### \`ruff format --check .\`"
if [ "$fmt_rc" -eq 0 ]; then echo; echo "✅ clean"; else echo; echo '```'; cat format.txt; echo '```'; fi
} > report.md
# A tooling error (rc >= 2) is not lint debt — block, but say nothing.
if [ "$check_rc" -ge 2 ] || [ "$fmt_rc" -ge 2 ]; then
echo "tool_error=true" >> "$GITHUB_OUTPUT"
else
echo "tool_error=false" >> "$GITHUB_OUTPUT"
fi
if [ "$check_rc" -eq 0 ] && [ "$fmt_rc" -eq 0 ]; then
echo "passed=true" >> "$GITHUB_OUTPUT"
else
echo "passed=false" >> "$GITHUB_OUTPUT"
fi
# ── Autofix (same-repo PRs only) ──────────────────────────────────────────
# The first pass found something on a branch we can push to: apply ruff's
# own fixes (safe lint fixes + formatting), commit the diff as the bot, and
# push it back. SAFE under pull_request_target: only ruff runs over the PR
# tree (static, never executes it), and only ruff's own diff is pushed — no
# PR-authored code touches our write token. Then re-lint the FIXED tree in
# this same run: a GITHUB_TOKEN push doesn't trigger a new workflow, so the
# residual pass/fail we compute here is what the gate reports.
- name: Ruff autofix + push (same-repo PR)
id: autofix
if: >-
steps.mode.outputs.autofix == 'true' &&
steps.ruff.outputs.tool_error == 'false' &&
steps.ruff.outputs.passed == 'false'
shell: bash
run: |
# `check --fix-only` exits 1 when unfixable violations remain even
# after applying every safe fix, so don't let that abort the step.
uvx --no-config ruff@0.15.18 check --fix-only . || true
uvx --no-config ruff@0.15.18 format . || true
set -e
if git diff --quiet; then
# ruff couldn't fix anything (all issues are unsafe/judgement calls):
# nothing to push. The gate falls back to the first-pass result and
# the original report already lists these — no autofix comment.
echo "pushed=false" >> "$GITHUB_OUTPUT"
exit 0
fi
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
git add -A
git commit -m "ruff autofix (format + safe lint fixes)" \
-m "Pushed by the lint gate."
# Push back to the PR HEAD branch. The checkout kept
# persist-credentials: false, so authenticate the push explicitly with
# the workflow token via the remote URL.
BRANCH='${{ github.event.pull_request.head.ref }}'
REPO='${{ github.event.pull_request.head.repo.full_name }}'
git push "https://x-access-token:${{ github.token }}@github.com/${REPO}.git" "HEAD:${BRANCH}"
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
echo "pushed=true" >> "$GITHUB_OUTPUT"
# Re-lint the FIXED tree — this is what the gate reports (no rerun comes).
set +e
uvx --no-config ruff@0.15.18 check . --output-format=concise > check2.txt 2>&1; check_rc=$?
uvx --no-config ruff@0.15.18 format --check . > format2.txt 2>&1; fmt_rc=$?
set -e
{
echo "### Residual \`ruff check .\` (after autofix)"
if [ "$check_rc" -eq 0 ]; then echo; echo "✅ clean"; else echo; echo '```'; cat check2.txt; echo '```'; fi
echo
echo "### Residual \`ruff format --check .\` (after autofix)"
if [ "$fmt_rc" -eq 0 ]; then echo; echo "✅ clean"; else echo; echo '```'; cat format2.txt; echo '```'; fi
} > residual.md
if [ "$check_rc" -eq 0 ] && [ "$fmt_rc" -eq 0 ]; then
echo "residual_passed=true" >> "$GITHUB_OUTPUT"
else
echo "residual_passed=false" >> "$GITHUB_OUTPUT"
fi
# Resolve the outcome the gate reports. For a same-repo PR that got an
# autofix push, the residual pass/fail on the FIXED tree supersedes the
# first pass (that's what's now on the branch); otherwise the first pass
# stands. `pushed`/`residual` are surfaced so the comment can say so.
- name: Resolve gate outcome
id: gate
if: steps.ruff.outputs.tool_error == 'false'
shell: bash
run: |
if [ "${{ steps.autofix.outputs.pushed }}" = "true" ]; then
echo "passed=${{ steps.autofix.outputs.residual_passed }}" >> "$GITHUB_OUTPUT"
echo "autofixed=true" >> "$GITHUB_OUTPUT"
echo "sha=${{ steps.autofix.outputs.sha }}" >> "$GITHUB_OUTPUT"
echo "report_file=residual.md" >> "$GITHUB_OUTPUT"
else
echo "passed=${{ steps.ruff.outputs.passed }}" >> "$GITHUB_OUTPUT"
echo "autofixed=false" >> "$GITHUB_OUTPUT"
echo "report_file=report.md" >> "$GITHUB_OUTPUT"
fi
# Feedback is a side effect — never let it red a clean run.
- name: Tell the author (PR comment) or record it (develop-push issue)
if: steps.ruff.outputs.tool_error == 'false'
continue-on-error: true
uses: actions/github-script@v7
env:
PASSED: ${{ steps.gate.outputs.passed }}
AUTOFIXED: ${{ steps.gate.outputs.autofixed }}
AUTOFIX_SHA: ${{ steps.gate.outputs.sha }}
REPORT_FILE: ${{ steps.gate.outputs.report_file }}
with:
script: |
const fs = require('fs');
const passed = process.env.PASSED === 'true';
const autofixed = process.env.AUTOFIXED === 'true';
const autofixSha = (process.env.AUTOFIX_SHA || '').slice(0, 7);
const { owner, repo } = context.repo;
const runUrl = `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`;
const report = passed ? '' : fs.readFileSync(process.env.REPORT_FILE, 'utf8');
// ---- PR: speak on the PR itself (comment, auto-updating) ----------
if (context.eventName === 'pull_request_target') {
const pr = context.payload.pull_request;
const author = pr.user.login;
const MARKER = '<!-- ruff-lint-gate -->';
const comments = await github.paginate(github.rest.issues.listComments, {
owner, repo, issue_number: pr.number, per_page: 100,
});
const mine = comments.find(c => c.body && c.body.includes(MARKER));
if (passed) {
// Clean now. If we got here by pushing an autofix, say so (the
// push is why the branch changed under the author). Otherwise
// only update an existing comment to green — don't post on a PR
// that was never dirty.
const body = autofixed
? `🤖 **autofix pushed \`${autofixSha}\`, ruff is clean** — formatting and safe lint fixes were applied for you; nothing else to do. ${MARKER}`
: `✅ **ruff is clean** — nothing to fix here. ${MARKER}`;
if (mine) {
await github.rest.issues.updateComment({ owner, repo, comment_id: mine.id, body });
} else if (autofixed) {
await github.rest.issues.createComment({ owner, repo, issue_number: pr.number, body });
}
core.info('PR clean.');
return;
}
const intro = autofixed
? [
`### 🔴 ruff — residual issues after autofix`,
``,
`@${author} — I pushed \`${autofixSha}\` with the formatting and safe lint fixes, but these need a human and still block the merge into \`develop\`:`,
]
: [
`### 🔴 ruff found lint / format issues`,
``,
`@${author} — these block the merge into \`develop\`. Full list below (also surfaced as annotations in the CI run):`,
];
const body = [
...intro,
``,
report,
``,
`---`,
`[CI run](${runUrl}) · _Updates on every push and turns green when ruff passes — nothing else to do._`,
MARKER,
].join('\n');
if (mine) {
await github.rest.issues.updateComment({ owner, repo, comment_id: mine.id, body });
core.info(`Updated PR comment ${mine.id}.`);
} else {
await github.rest.issues.createComment({ owner, repo, issue_number: pr.number, body });
core.info('Posted PR comment.');
}
return;
}
// ---- Direct push to develop: per-committer issue (no PR exists) ---
const login = context.actor;
const where = `push to \`${context.ref.replace('refs/heads/', '')}\` (\`${context.sha.slice(0, 7)}\`)`;
const title = `Lint debt: @${login}`;
const LABEL = 'lint-debt';
try {
await github.rest.issues.getLabel({ owner, repo, name: LABEL });
} catch {
try {
await github.rest.issues.createLabel({
owner, repo, name: LABEL, color: 'd93f0b',
description: 'Auto-filed lint failures from the develop gate',
});
} catch (e) { core.info(`label create skipped: ${e.message}`); }
}
const open = await github.paginate(github.rest.issues.listForRepo, {
owner, repo, state: 'open', labels: LABEL, per_page: 100,
});
const existing = open.find(i => i.title === title && !i.pull_request);
if (passed) {
if (existing) {
await github.rest.issues.createComment({
owner, repo, issue_number: existing.number,
body: `✅ Lint is clean as of ${where}. Closing — thanks!`,
});
await github.rest.issues.update({ owner, repo, issue_number: existing.number, state: 'closed' });
core.info(`Closed #${existing.number} (clean).`);
} else {
core.info('Clean, no open lint-debt issue to close.');
}
return;
}
const body = [
`@${login} — ruff flagged lint issues in **${where}** (pushed straight to \`develop\`).`,
``,
`This doesn't block local work (pre-commit only warns), but \`develop\` stays red until it's clean. Fix and push again — **this issue auto-closes when CI goes green.**`,
``,
report,
``,
`---`,
`[CI run](${runUrl}) · _Auto-filed by the lint gate; updated in place, closed when clean._`,
`<!-- lint-debt-for: ${login} -->`,
].join('\n');
let number;
if (existing) {
await github.rest.issues.update({ owner, repo, issue_number: existing.number, body });
await github.rest.issues.createComment({
owner, repo, issue_number: existing.number,
body: `🔴 Still failing as of ${where}. [run](${runUrl})`,
});
number = existing.number;
core.info(`Updated #${number}.`);
} else {
const created = await github.rest.issues.create({ owner, repo, title, body, labels: [LABEL] });
number = created.data.number;
core.info(`Opened #${number}.`);
}
try {
await github.rest.issues.addAssignees({ owner, repo, issue_number: number, assignees: [login] });
} catch (e) {
core.info(`assign skipped (${login} not assignable): ${e.message}`);
}
# Red → blocks the merge. `always()` so a hiccup in the feedback step above
# can't suppress the red on genuine lint debt; a tooling error also reds.
# A tooling error means `gate` was skipped (its outputs are empty), so test
# it first; otherwise the resolved gate outcome (post-autofix on same-repo
# PRs, first-pass elsewhere) decides.
- name: Fail the job if the gate didn't pass
if: always() && (steps.ruff.outputs.tool_error == 'true' || steps.gate.outputs.passed == 'false')
run: |
if [ "${{ steps.ruff.outputs.tool_error }}" = "true" ]; then
echo "::error::ruff could not run (network / version) — gate inconclusive, blocking."
else
echo "::error::ruff found lint/format issues — see the PR comment (or lint-debt issue)."
fi
exit 1