Repository navigation
435 lines (418 loc) · 23.1 KB
/
Copy pathcursor-approve.yml
File metadata and controls
435 lines (418 loc) · 23.1 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
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
name: Cursor approve
# Decide-and-approve half of cursor-approve: run the axis workflows (e.g.
# axis-correctness.yml, axis-conformance.yml) only after a cursor-review round
# passed (`approve_gate == 'pass'`), then call this TWICE:
#
# phase: start — before the axis jobs (they `needs:` it): withdraws this
# identity's own approvals — unless cursor-review ran with
# `defer_approval: true` (recommended: then this phase's
# decide is the only approver), its decide has ALREADY
# approved when it reports approve_gate == pass, and that
# approval must not stand while the axes judge (on
# approve_gate == capped, its own standing
# REQUEST_CHANGES too) — then writes the status card (every axis pending, or "Round
# limit reached" when cursor-review reported
# approve_gate == capped). cursor-review's decide has
# already written the card for this round, on every
# outcome; this overwrites it on the outcomes it runs for.
# phase: decide — after them: downloads the axis-* artifacts of THIS run,
# runs .github/cursor-approve/aggregate.py decide (each axis
# read only from its own axis-<name> artifact, and only for
# the commit_sha it stamped), posts the approval through
# .github/cursor-review/auto-approve.py approve-external
# (head and base re-read, reviewed-SHA marker, own stale
# approvals withdrawn, never over needs-human-review or
# skip-cursor-review, both re-read live at decide time;
# on not_approved / error / superseded / retargeted it leaves a
# standing REQUEST_CHANGES instead; on needs_human /
# vetoed it withdraws its own instead) and rewrites the
# card with the verdicts. The card's table shows each
# axis's `headline`, with its full summary and confidence
# in a collapsed block under the result; the same step
# also writes them to this run's job summary.
#
# No job here checks out PR code; the only checkout is this repo at
# workflows_ref, for the scripts.
#
# Inputs:
# workflows_ref REQUIRED, no default: the same commit SHA as the uses: pin
# (checked against job.workflow_sha).
# phase start | decide
# commit_sha the PR head the axes judged (cursor-review's reviewed head)
# axes comma-separated axes the caller ran, e.g. correctness,conformance
# max_yellow_axes 0..3 (default 0), strictly below the number of axes
# round cursor-review's `round` output
# max_rounds cursor-review's `max_rounds` output
# approve_gate cursor-review's `approve_gate` output (start phase)
# approve_max_severity cursor-review's threshold (decide phase); with
# poster_login, an approval auto-resolves cursor-review's
# own at-or-below-threshold threads, as cursor-review does
# poster_login the login cursor-review posts findings under
# (`<app-slug>[bot]` under its GitHub App, else
# github-actions[bot]); empty = resolve nothing
# approve_scope cursor-review's `approve_scope_effective` output (decide
# phase): `delta` lets an above-threshold thread that round
# marked non-gating stop blocking auto-resolution, as
# cursor-review's own decide does. Default `full`: a marked
# thread blocks like any other (fail-closed)
# approver_environment the caller-repo environment holding APPROVER_TOKEN
# (an environment secret, deployment branches = the base
# branch only), declared on the `approve` job. Default '' =
# none. See docs/callers/cursor-review.md#trust-model.
# prior_rounds 0..3 (default 0 = off): how many earlier rounds of each
# axis's own verdicts it sees. decide writes them to the
# card as one base64 sentinel line; start reads them back
# (`card.py prior`) into the `prior-axes` artifact the axis
# workflows take with the same input. Pass it to BOTH phases
# and to every axis. 0 leaves every prompt and card unchanged.
# Secrets:
# APPROVER_TOKEN token of the approving identity. Empty → warn and exit 0;
# there is deliberately no fallback to an App or GITHUB_TOKEN.
#
# Caller example: docs/callers/cursor-approve.md.
on:
workflow_call:
inputs:
workflows_ref:
description: >-
Commit SHA of Comfy-Org/github-workflows to load the scripts from —
the SAME SHA as the uses: pin. Required; deliberately no default.
type: string
required: true
phase:
type: string
required: true
commit_sha:
type: string
required: true
axes:
type: string
required: true
max_yellow_axes:
type: number
default: 0
round:
type: string
default: ''
max_rounds:
type: string
default: ''
approve_gate:
type: string
default: ''
approve_max_severity:
type: string
default: ''
poster_login:
type: string
default: ''
approve_scope:
type: string
default: full
prior_rounds:
type: number
default: 0
approver_environment:
description: >-
The caller-repo environment that holds APPROVER_TOKEN. With a
deployment-branch policy of the base branch only, the token is
unreachable from `pull_request` runs. Empty (default) = no environment.
type: string
default: ''
secrets:
APPROVER_TOKEN:
required: false
permissions:
contents: read
env:
CURSOR_REVIEW_ASSETS: ${{ github.workspace }}/_cursor_review_assets/.github/cursor-review
CURSOR_APPROVE_ASSETS: ${{ github.workspace }}/_cursor_review_assets/.github/cursor-approve
jobs:
approve:
name: 'cursor-approve (${{ inputs.phase }})'
runs-on: ubuntu-latest
timeout-minutes: 10
# The caller environment holding APPROVER_TOKEN; '' = none (the trust
# model in docs/callers/cursor-review.md#trust-model). Only under
# `pull_request_target`: any other event runs on `refs/pull/<n>/merge`,
# which a base-branch-only deployment policy rejects before a step runs.
environment: ${{ github.event_name == 'pull_request_target' && inputs.approver_environment || '' }}
permissions:
contents: read
actions: read
steps:
- name: Check the approver token
id: token
env:
APPROVER_TOKEN: ${{ secrets.APPROVER_TOKEN }}
PHASE: ${{ inputs.phase }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PRIOR_ROUNDS: ${{ inputs.prior_rounds }}
EVENT_NAME: ${{ github.event_name }}
APPROVER_ENVIRONMENT: ${{ inputs.approver_environment }}
run: |
case "$PHASE" in
start|decide) ;;
*) echo "::error::phase must be start or decide, got '$PHASE'"; exit 1 ;;
esac
# A `type: number` can render as `1.0`.
case "$PRIOR_ROUNDS" in
0|1|2|3|0.0|1.0|2.0|3.0) ;;
*) echo "::error::prior_rounds must be an integer from 0 to 3, got '$PRIOR_ROUNDS'"; exit 1 ;;
esac
if [ -z "$PR_NUMBER" ]; then
echo "::error::cursor-approve runs on a pull_request(_target) event only"; exit 1
fi
# Only `pull_request_target` loads the caller from the base branch; on
# any other event a PR can read APPROVER_TOKEN. Post no card and no
# approval as it — but still withdraw that identity's own marked
# approvals (`withdraw_only`), so one it posted earlier at this head
# does not keep satisfying branch protection.
if [ -n "$APPROVER_TOKEN" ] && [ "$EVENT_NAME" != "pull_request_target" ]; then
echo "::error::APPROVER_TOKEN on a '$EVENT_NAME' run is readable by any PR-head workflow, so this run posts no approval (it still withdraws earlier ones); call this reusable from pull_request_target with approver_environment (docs/callers/cursor-review.md#trust-model)"
echo "present=false" >> "$GITHUB_OUTPUT"
echo "withdraw_only=true" >> "$GITHUB_OUTPUT"
exit 0
fi
if [ -n "$APPROVER_TOKEN" ] && [ -z "$APPROVER_ENVIRONMENT" ]; then
echo "::warning::APPROVER_TOKEN without approver_environment is a repo secret that any PR-head pull_request workflow can read; move it into a base-branch-only environment and pass approver_environment (docs/callers/cursor-review.md#trust-model)"
fi
if [ -z "$APPROVER_TOKEN" ]; then
echo "::warning::APPROVER_TOKEN is empty — cursor-approve posts no card and no approval (there is no fallback identity)."
echo "present=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "present=true" >> "$GITHUB_OUTPUT"
- name: Require a pinned workflows_ref
# workflows_ref has no default on purpose. GitHub does NOT enforce
# `required: true` for workflow_call inputs, so an omitted input arrives
# as '' and actions/checkout would silently fall back to this repo's
# default branch — running MUTABLE scripts under a pin that claims
# otherwise. Fail fast instead. (BE-5546)
env:
WORKFLOWS_REF: ${{ inputs.workflows_ref }}
WORKFLOW_SHA: ${{ job.workflow_sha }}
run: |
# actions/checkout reads `ref` through core.getInput, which TRIMS, so a
# whitespace-only value is an empty ref to IT while sailing past a bare
# -z test here. Compare the stripped form, and echo only that: dropping
# newlines also stops a multi-line value from smuggling a ::workflow
# command:: into the log, and from satisfying the line-oriented grep
# below on one 40-hex line among many.
REF="$(printf '%s' "$WORKFLOWS_REF" | tr -d '[:space:]')"
if [ -z "$REF" ]; then
echo "::error::workflows_ref is required; pin it to the same commit SHA as the uses: line (see header example)"
exit 1
fi
if ! printf '%s' "$REF" | grep -Eq '^[0-9a-f]{40}$'; then
echo "::warning::workflows_ref '$REF' is not a full 40-hex commit SHA — branch and tag refs are mutable and can skew between jobs mid-run"
fi
# Equality with the commit the caller's `uses:` line resolved to — the
# same guard public-repo-hygiene.yml runs, and the check that turns
# "workflows_ref must be the same SHA as uses:" from a header comment
# into a fact. `job.workflow_sha` is computed by the RUNNER from the
# `uses:` pin, so a caller cannot set it. Without this, a caller can
# pin `uses:` to one commit and load this workflow's prompts, scripts
# and model list from another. (`job.workflow_sha`, NOT
# `github.job_workflow_sha` — that one is an OIDC token claim, absent
# from the `github` context, and expands to ''.) Both sides are
# lower-cased: hex is case-insensitive, and unlike hygiene's the
# 40-hex test above only WARNS, so an upper-case ref naming the RIGHT
# commit reaches here and must not fail. $REF itself is left alone so
# the messages quote what the caller wrote. (BE-15927)
REF_LC="$(printf '%s' "$REF" | tr 'A-Z' 'a-z')"
SHA="$(printf '%s' "$WORKFLOW_SHA" | tr -d '[:space:]' | tr 'A-Z' 'a-z')"
if [ -z "$SHA" ]; then
# Cannot check, NOT a mismatch. `job.workflow_sha` is the `job`-context
# accessor added in Actions runner v2.334.0, so only an older runner
# gets here. GitHub stops queueing jobs to a self-hosted runner more
# than 30 days behind the latest release, so on any pool — hosted
# `ubuntu-latest`, or the caller's `runs_on` pool the read-only
# model jobs may use — that is a transient window. Warn and continue: a
# property nothing could EVALUATE is not evidence of a violation, and
# failing would take the whole review down on a caller that changed
# nothing. (public-repo-hygiene.yml fails closed in this branch on
# purpose — it is the checker whose own integrity rests on the
# equality. Here the ledger's warn-and-degrade posture is the fit.)
echo "::warning::job.workflow_sha is empty (Actions runner older than v2.334.0?), so workflows_ref could not be cross-checked against the uses: pin. The empty and 40-hex tests above still apply."
elif [ "$REF_LC" != "$SHA" ]; then
echo "::error::workflows_ref ($REF) is not the commit this workflow was called at ($SHA). The uses: pin and workflows_ref MUST name the same commit — otherwise the workflow is pinned but its prompts and scripts are loaded from a different version. Set both to $SHA. (The bump-callers fleet rewrites both together; Dependabot rewrites only uses:, so a caller that lets Dependabot bump this reusable needs a dependabot.yml ignore entry for Comfy-Org/github-workflows.)"
exit 1
fi
- name: Load cursor-approve scripts
if: steps.token.outputs.present == 'true' || steps.token.outputs.withdraw_only == 'true'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: Comfy-Org/github-workflows
ref: ${{ inputs.workflows_ref }}
path: _cursor_review_assets
persist-credentials: false
- name: Resolve the approver login
id: login
if: steps.token.outputs.present == 'true' || steps.token.outputs.withdraw_only == 'true'
env:
GH_TOKEN: ${{ secrets.APPROVER_TOKEN }}
# Captured, not inlined into the echo: `echo "login=$(...)"` discards the
# substitution's exit status, so a token that cannot read GET /user would
# write `login=` and every later step would match no card and no review.
run: |
login="$(gh api user --jq .login)"
if [ -z "$login" ]; then
echo "::error::could not resolve the APPROVER_TOKEN identity (GET /user returned no login)"; exit 1
fi
echo "login=$login" >> "$GITHUB_OUTPUT"
- name: Withdraw approvals while the axes run
# Also, in either phase, on a trigger that may not approve (see the
# token step).
if: (steps.token.outputs.present == 'true' && inputs.phase == 'start') || steps.token.outputs.withdraw_only == 'true'
env:
GH_TOKEN: ${{ secrets.APPROVER_TOKEN }}
LOGIN: ${{ steps.login.outputs.login }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
# `capped` also withdraws the standing request for changes: on the
# round-cap path no cursor-review decide ran to do it.
APPROVE_GATE: ${{ inputs.approve_gate }}
run: |
python3 "$CURSOR_REVIEW_ASSETS/auto-approve.py" withdraw --repo "$REPO" \
--pr-number "$PR_NUMBER" --approver-login "$LOGIN" --approve-gate "$APPROVE_GATE"
- name: Write the start card
# !cancelled(): a red withdrawal above must not skip the card; it only
# needs the identity the login step resolved.
if: ${{ !cancelled() && steps.token.outputs.present == 'true' && steps.login.outputs.login != '' && inputs.phase == 'start' }}
env:
GH_TOKEN: ${{ secrets.APPROVER_TOKEN }}
LOGIN: ${{ steps.login.outputs.login }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
COMMIT_SHA: ${{ inputs.commit_sha }}
AXES: ${{ inputs.axes }}
ROUND: ${{ inputs.round }}
MAX_ROUNDS: ${{ inputs.max_rounds }}
APPROVE_GATE: ${{ inputs.approve_gate }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
python3 "$CURSOR_APPROVE_ASSETS/card.py" start --repo "$REPO" --pr-number "$PR_NUMBER" \
--login "$LOGIN" --commit-sha "$COMMIT_SHA" --axes "$AXES" --round "$ROUND" \
--max-rounds "$MAX_ROUNDS" --approve-gate "$APPROVE_GATE" --run-url "$RUN_URL"
- name: Read the prior axis verdicts
# The workflow, not the agent, reads the card: the axis jobs hold
# `contents: read` and no token that can read PR comments. Read after
# the start card is written; upsert carried the sentinel through it.
# continue-on-error: history is an aid, so a failed read is no history,
# never a failed start phase (which would skip the axes).
if: ${{ !cancelled() && steps.token.outputs.present == 'true' && steps.login.outputs.login != '' && inputs.phase == 'start' && inputs.prior_rounds > 0 }}
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.APPROVER_TOKEN }}
LOGIN: ${{ steps.login.outputs.login }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
ROUND: ${{ inputs.round }}
PRIOR_ROUNDS: ${{ inputs.prior_rounds }}
run: |
mkdir -p "$RUNNER_TEMP/prior"
python3 "$CURSOR_APPROVE_ASSETS/card.py" prior --repo "$REPO" --pr-number "$PR_NUMBER" \
--login "$LOGIN" --round "$ROUND" --prior-rounds "$PRIOR_ROUNDS" \
--out "$RUNNER_TEMP/prior/prior-axes.json"
- name: Upload the prior axis verdicts
# NOT axis-*: decide downloads `axis-*`, and this must never be read
# as a verdict (a test pins the name).
if: ${{ !cancelled() && steps.token.outputs.present == 'true' && inputs.phase == 'start' && inputs.prior_rounds > 0 }}
continue-on-error: true
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: prior-axes
path: ${{ runner.temp }}/prior/prior-axes.json
if-no-files-found: ignore
overwrite: true
retention-days: 7
- name: Download the axis verdicts
if: steps.token.outputs.present == 'true' && inputs.phase == 'decide'
# A failed axis uploaded nothing; that is "no result", not an error here.
# No merge-multiple: each artifact lands in its own axis-<name>/ directory
# and decide reads <axis>.json ONLY from there (--artifact-dirs), so an
# artifact under any other name cannot supply or overwrite a verdict.
continue-on-error: true
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
pattern: axis-*
path: ${{ runner.temp }}/axes
- name: Decide
if: steps.token.outputs.present == 'true' && inputs.phase == 'decide'
env:
AXES: ${{ inputs.axes }}
MAX_YELLOW: ${{ inputs.max_yellow_axes }}
COMMIT_SHA: ${{ inputs.commit_sha }}
run: |
mkdir -p "$RUNNER_TEMP/axes"
python3 "$CURSOR_APPROVE_ASSETS/aggregate.py" decide --outputs-dir "$RUNNER_TEMP/axes" \
--artifact-dirs --commit-sha "$COMMIT_SHA" \
--axes "$AXES" --max-yellow-axes "$MAX_YELLOW" > "$RUNNER_TEMP/decision.json"
cat "$RUNNER_TEMP/decision.json"
- name: Find the card
id: card
if: steps.token.outputs.present == 'true' && inputs.phase == 'decide'
env:
GH_TOKEN: ${{ secrets.APPROVER_TOKEN }}
LOGIN: ${{ steps.login.outputs.login }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
COMMIT_SHA: ${{ inputs.commit_sha }}
AXES: ${{ inputs.axes }}
ROUND: ${{ inputs.round }}
MAX_ROUNDS: ${{ inputs.max_rounds }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
python3 "$CURSOR_APPROVE_ASSETS/card.py" ensure --repo "$REPO" --pr-number "$PR_NUMBER" \
--login "$LOGIN" --commit-sha "$COMMIT_SHA" --axes "$AXES" --round "$ROUND" \
--max-rounds "$MAX_ROUNDS" --run-url "$RUN_URL"
- name: Approve or withhold
id: approve
# always(): a failed Decide leaves no readable decision, which
# approve-external treats as "not approved" — and that withdraws any
# earlier approval instead of leaving it standing.
if: always() && steps.token.outputs.present == 'true' && inputs.phase == 'decide' && steps.login.outcome == 'success'
env:
GH_TOKEN: ${{ secrets.APPROVER_TOKEN }}
LOGIN: ${{ steps.login.outputs.login }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
COMMIT_SHA: ${{ inputs.commit_sha }}
AXES: ${{ inputs.axes }}
CARD_URL: ${{ steps.card.outputs.card_url }}
# The base the axes diffed: the same event payload they rendered from.
BASE_REF: ${{ github.event.pull_request.base.ref }}
# Both empty by default: no thread is auto-resolved.
THRESHOLD: ${{ inputs.approve_max_severity }}
POSTER: ${{ inputs.poster_login }}
# cursor-review's `approve_scope_effective`; `full` (the default) is
# the fail-closed reading of the non-gating marker.
APPROVE_SCOPE: ${{ inputs.approve_scope }}
run: |
python3 "$CURSOR_REVIEW_ASSETS/auto-approve.py" approve-external --repo "$REPO" \
--pr-number "$PR_NUMBER" --commit-sha "$COMMIT_SHA" --axes "$AXES" \
--decision "$RUNNER_TEMP/decision.json" --approver-login "$LOGIN" \
--base-ref "$BASE_REF" --card-url "$CARD_URL" \
--threshold "$THRESHOLD" --poster-login "$POSTER" --approve-scope "$APPROVE_SCOPE"
- name: Write the decide card
if: always() && steps.token.outputs.present == 'true' && inputs.phase == 'decide' && steps.login.outcome == 'success'
env:
GH_TOKEN: ${{ secrets.APPROVER_TOKEN }}
LOGIN: ${{ steps.login.outputs.login }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
COMMIT_SHA: ${{ inputs.commit_sha }}
AXES: ${{ inputs.axes }}
ROUND: ${{ inputs.round }}
MAX_ROUNDS: ${{ inputs.max_rounds }}
MAX_YELLOW: ${{ inputs.max_yellow_axes }}
PRIOR_ROUNDS: ${{ inputs.prior_rounds }}
OUTCOME: ${{ steps.approve.outputs.outcome || 'error' }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
python3 "$CURSOR_APPROVE_ASSETS/card.py" decide --repo "$REPO" --pr-number "$PR_NUMBER" \
--login "$LOGIN" --commit-sha "$COMMIT_SHA" --axes "$AXES" --round "$ROUND" \
--max-rounds "$MAX_ROUNDS" --decision "$RUNNER_TEMP/decision.json" \
--outcome "$OUTCOME" --max-yellow-axes "$MAX_YELLOW" --prior-rounds "$PRIOR_ROUNDS" \
--run-url "$RUN_URL"