Repository navigation
403 lines (399 loc) · 23.1 KB
/
Copy pathvinaya-review.yml
File metadata and controls
403 lines (399 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
# Managed by Vinaya — created by `vinaya init`. `vinaya upgrade` regenerates it; `vinaya eject` removes it.
#
# The required review gate, and the push-time run of `evidence-fresh`.
# `pull_request_target` is the trust boundary: GitHub loads this workflow
# and its default checkout from the repository's default branch, never from
# the pull request being judged. Neither job checks out, installs, builds, or
# executes pull-request content. The review job fetches none of it; the
# `evidence-fresh` job fetches the pull request head's commits as git
# objects only, so `git diff` and `git merge-base` can read them, and
# runs the CLI built from the default-branch checkout over them.
#
# The verdict-comment
# half lives in its own workflow (vinaya-review-verdict.yml): a new PR
# comment fires a different GitHub event that this
# workflow structurally cannot receive — and keeping the comment path in a
# separate FILE means this workflow's runs never list permanently-skipped
# comment jobs on the PR's checks panel. When a clean final verdict lands,
# that workflow re-runs this one, so the required check below goes green
# natively with no manual rerun.
#
# Nothing re-runs this workflow when CI completes, and nothing needs to:
# the review gate answers for the review verdicts, `evidence-fresh` for
# the body's `AEG:EVIDENCE` block against the pushed head, and no other
# check's result can change either answer, so a CI run finishing tells
# neither anything.
#
# `synchronize` is what runs `evidence-fresh` on every push, so a push
# with no following body edit turns its check-run red instead of producing
# no run at all; `edited` re-runs it on the body edit `vinaya pr report
# --push` makes after that push, so a body naming the pushed head turns the
# same check-run green with no manual rerun.
#
# The job's first steps never build: they read the PR, its comments and its
# labels through the REST API alone — the same read the forge snapshot the
# gate later evaluates is written from, so the run reads them once — and fail
# fast, without checkout or build, whenever
# neither a `VERDICT:` comment nor a (presence-only, unverified) waiver
# label exists yet — the ordinary state on `opened`/`synchronize`/
# `reopened`/`edited`/`labeled`/`unlabeled`. The build runs once that step finds
# either: a verdict, which in practice is the state a rerun finds it in (the
# verdict-comment workflow re-runs THIS run, and by the time it fires, the
# verdict this job is looking for already exists) — or a `labeled` event
# applying the waiver label itself, so a
# waived PR goes green on the SAME push that applies the label rather than
# waiting on a verdict that will never arrive (`#525`: found live minutes
# after this pre-check step was first added — PR #517's Version Packages
# PR, carrying the waiver label, went red at the gate in four seconds,
# never having learned the waiver the full gate downstream already
# honoured).
name: Vinaya Review Gate
run-name: "Vinaya Review Gate PR #${{ github.event.pull_request.number }} @ ${{ github.event.pull_request.head.sha }}"
on:
pull_request_target:
types: [opened, synchronize, reopened, edited, labeled, unlabeled]
# One run per pull request COMMIT, always. Several `types:` above can fire in the
# same instant — `vinaya pr create` opens the PR and applies its tranche
# label immediately after, so `opened` and `labeled` arrive together and
# GitHub starts TWO runs of this workflow. Both then report under the same
# check name, and the merge box counts both: one can go green while its twin
# holds a stale red, which no later verdict clears because each run only ever
# re-evaluates itself. Measured live on atta-labs/vinaya#18 — two runs created
# in the same second, one success, one failure, PR blocked with both reviews
# already approved.
#
# The key carries the head SHA as well as the PR number, and that second half
# is load-bearing. Keyed on the PR alone, every run for that PR shares one
# group — including a rerun of an EARLIER commit's run, which a retrigger
# performs. Measured on PR #22: re-running the old commit's run (attempt 4)
# cancelled the current commit's run after one second, so a push appeared to
# produce a cancelled gate. Runs for different commits must not be able to
# cancel each other; runs for the SAME commit still collapse, which is the
# duplicate this group exists to remove.
#
# `cancel-in-progress` is safe here and not merely tolerable: the job is a
# pure re-evaluation of forge state that takes seconds, so a cancelled run had
# nothing to lose and the survivor reads strictly fresher state.
concurrency:
group: vinaya-review-${{ github.event.pull_request.number || github.ref }}-${{ github.event.pull_request.head.sha || github.sha }}
cancel-in-progress: true
jobs:
vinaya-review:
name: vinaya review gate
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
checks: read
steps:
- name: Record the GraphQL budget at job start
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh api rate_limit --jq '"GraphQL budget at job start: \(.resources.graphql.used) used of \(.resources.graphql.limit), resets at \(.resources.graphql.reset | todate)"' >> "$GITHUB_STEP_SUMMARY" || echo "GraphQL budget at job start: unreadable" >> "$GITHUB_STEP_SUMMARY"
# No checkout, no build: read whether a verdict exists yet, or an
# actor-verified `vinaya/waiver:review` label is present, from the REST
# read just above. Absent both, THIS step fails — the job's own single
# check-run ("vinaya review gate") goes red with the reason below and
# every step after it is skipped, so an ordinary push (opened /
# synchronize / reopened / edited / labeled / unlabeled) never pays for
# a build it cannot use. Deliberately a step of this job, never a job of
# its own: a pre-check job would report a second review check-run name,
# and `REVIEW_GATE_CHECK_RUN_NAME`
# (this repo's own `check-review-gate.ts`/`dev-review-loop.ts`) is
# the one required name every other mechanism already keys on.
#
# The label check below is PRESENCE-ONLY, never actor verification
# (`#525`: "Do NOT verify the waiver's actor in the pre-check").
# Actor verification needs the trusted build's
# `principalAllowlist`/`gh api .../timeline` reads and stays exactly
# where it already lives, in `check-review-gate.ts`'s own `main()`.
# An unverified label only earns the PR a build — the SAME build job
# the full gate already runs on every ordinary push — so a
# self-applied label buys nothing on its own: `checkReviewGate` inside
# that build re-decides for real, actor and all, exactly as it did
# before this pre-check existed.
#
# Both string constants spliced into the `jq` calls below are read
# from `packages/aeg-core` at CLI-GENERATION time — `WAIVER_LABEL_REVIEW`
# (`waiver-label.ts`) and `VERDICT_MARKER_SOURCE` (`verdict-extraction.ts`)
# — the SAME constants `check-review-gate.ts` reads for the full gate,
# never a second hand-typed copy of either fact (`#525`).
#
# Line-anchored `VERDICT:` — same anchor discipline
# `packages/aeg-core/src/verdict-extraction.ts` standardizes on
# (security-review FAIL finding, PR #636/#639): a bare substring/word
# search matches ordinary prose that merely mentions "VERDICT" (an
# escalation, a reviewer report, this very step's own description) and
# would wrongly let the build run on a push with no real verdict cast.
# `VERDICT_MARKER_SOURCE` tolerates the same leading markdown emphasis
# run the real extractor does, and — because it is tested per split
# line rather than as one multiline string (this jq/oniguruma build's
# `^`/`$` do not cross embedded newlines even under the "m"/"s"
# flags) — rejects the same blockquote/list-item/heading prefixes the
# real extractor rejects. This is a presence-only approximation of
# that module's full extraction (no first-five-lines window, no
# value-side match, no most-recent-comment selection, no code-span
# exclusion) — deliberately so: this step never runs after a checkout,
# so it cannot import `packages/aeg-core` at RUN time (the constant is
# instead read once, at CLI-generation time, and spliced in below). A
# false positive here only wastes a trusted build; the downstream
# `vinayaRun(selfHost, 'check review-gate')` step, which DOES run the
# real extractor and the real actor-verified waiver check after
# checkout, is the authoritative read and still fails correctly on
# anything this coarser check let through.
- name: Read the pull request over REST
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
set -o pipefail
RAW="$RUNNER_TEMP/vinaya-forge-snapshot/raw"
mkdir -p "$RAW"
fail_read() {
echo "::error::Reading $1 over the REST API failed: $(cat "$RAW/read-error.txt" 2>/dev/null)" >&2
gh api rate_limit --jq '.resources as $r | "::error::GitHub REST (core) budget: \($r.core.remaining) of \($r.core.limit) left, resets at \($r.core.reset | todate)\(if $r.core.remaining == 0 then " - spent, so GitHub rate-limited this read" else "" end); GraphQL budget: \($r.graphql.remaining) of \($r.graphql.limit) left, resets at \($r.graphql.reset | todate)\(if $r.graphql.remaining == 0 then " - spent" else "" end)."' >&2 || echo "::error::The token budget could not be read either." >&2
exit 1
}
gh api "repos/{owner}/{repo}/pulls/$PR_NUMBER" > "$RAW/pull.json" 2> "$RAW/read-error.txt" || fail_read "pull request #$PR_NUMBER"
gh api --paginate --slurp "repos/{owner}/{repo}/issues/$PR_NUMBER/comments?per_page=100" > "$RAW/pull-comments.json" 2> "$RAW/read-error.txt" || fail_read "pull request #$PR_NUMBER's comments"
- name: Require a verdict or waiver before building
id: verdict-check
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
set -o pipefail
RAW="$RUNNER_TEMP/vinaya-forge-snapshot/raw"
HAS_VERDICT=$(jq '[.[][] | (.body // "") | select((. / "\n") | any(test("^[ \\t]*(?:\\*{1,3}|_{1,3})?VERDICT:")))] | length > 0' "$RAW/pull-comments.json")
HAS_WAIVER_LABEL=$(jq '[.labels[].name == "vinaya/waiver:review"] | any' "$RAW/pull.json")
if [ "$HAS_VERDICT" != "true" ] && [ "$HAS_WAIVER_LABEL" != "true" ]; then
echo "No VERDICT: comment and no vinaya/waiver:review label yet on PR #$PR_NUMBER - nothing to evaluate. Holding the gate red without building until a reviewer posts a verdict or a principal applies the waiver." >&2
exit 1
fi
- uses: actions/checkout@v4
with:
# Explicit trusted ref: never use the PR head/merge ref in this job.
ref: ${{ github.event.repository.default_branch }}
persist-credentials: false
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 22
# Pinned to a commit, not the mutable `v2` tag. A repoint of
# `v2` would execute new upstream code in every adopter on the next run,
# with no diff anywhere to review. Resolved from the `v2` tag on
# 2026-08-14. Bun's own version still comes from the repo's
# `packageManager` field, not from this pin.
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
- name: Restore Bun install cache
uses: actions/cache@v4
with:
path: ~/.bun/install/cache
key: bun-${{ runner.os }}-${{ hashFiles('bun.lock', 'bun.lockb') }}
restore-keys: |
bun-${{ runner.os }}-
# This repo declares the `@attalabs/vinaya` workspace package itself, so
# `npx @attalabs/vinaya` resolves to that local member instead of the
# registry and dies on its unbuilt `bin`. Build and run the trusted
# default-branch copy directly.
#
# This workflow executes only a trusted checkout selected by its event:
# never the pull request head or merge ref. The review-authority decision
# therefore comes from code the pull request cannot alter. `--ignore-scripts`
# remains defense in depth against lifecycle hooks in that trusted tree.
- name: Build the trusted Vinaya CLI
run: |
bun install --frozen-lockfile --ignore-scripts
bun run --cwd apps/cli build
- name: Write the forge snapshot
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
run: |
node apps/cli/dist/index.js pr snapshot --dir "$RUNNER_TEMP/vinaya-forge-snapshot"
- name: Review gate
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# PR_NUMBER is what makes the review-gate check EVALUATE: without
# it the adapter reads "no PR yet — local dev" and exits 0, and
# the gate is green regardless of review state.
PR_NUMBER: ${{ github.event.pull_request.number }}
# The run's forge snapshot (`lib/forge-snapshot.ts`): the checks read
# the pull request and its task Issue from it instead of reading the
# forge for them again. Set on this step only.
VINAYA_FORGE_SNAPSHOT_DIR: ${{ runner.temp }}/vinaya-forge-snapshot
PR_BODY_FILE: ${{ runner.temp }}/vinaya-forge-snapshot/body.md
run: node apps/cli/dist/index.js check review-gate
- name: Record the GraphQL budget at job end
if: ${{ !cancelled() }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh api rate_limit --jq '"GraphQL budget at job end: \(.resources.graphql.used) used of \(.resources.graphql.limit), resets at \(.resources.graphql.reset | todate)"' >> "$GITHUB_STEP_SUMMARY" || echo "GraphQL budget at job end: unreadable" >> "$GITHUB_STEP_SUMMARY"
# When this job failed, print its own token budgets on one line for
# the review loop (`apps/cli/specs/loop.md`). Reading them spends none.
- name: Report this job's GitHub token budget
if: failure()
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh api rate_limit --jq '.resources as $r | "vinaya-budget: exhausted=\(if $r.core.remaining == 0 then "core" elif $r.graphql.remaining == 0 then "graphql" else "none" end) core_remaining=\($r.core.remaining) core_reset=\($r.core.reset) graphql_remaining=\($r.graphql.remaining) graphql_reset=\($r.graphql.reset)"' || true
# `evidence-fresh` on every push, under its own check-run name. The
# checks workflow never triggers on `synchronize`, so without this job a
# push followed by no body edit produced no `evidence-fresh` run at all.
# It carries no `if:`: a skipped job reports success for the same head,
# which would replace a red result.
#
# Default-branch code only: the checkout is the default branch, the CLI is
# built from it, and the pull request head's commits are fetched as git
# objects for `git diff` and `git merge-base` to read — never checked
# out, installed, built or executed. The check reads the head from the same
# forge snapshot the fetch step read it from, so both name one commit.
vinaya-evidence-fresh:
name: vinaya check evidence-fresh
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
steps:
- name: Record the GraphQL budget at job start
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh api rate_limit --jq '"GraphQL budget at job start: \(.resources.graphql.used) used of \(.resources.graphql.limit), resets at \(.resources.graphql.reset | todate)"' >> "$GITHUB_STEP_SUMMARY" || echo "GraphQL budget at job start: unreadable" >> "$GITHUB_STEP_SUMMARY"
- uses: actions/checkout@v4
with:
# Explicit trusted ref: never use the PR head/merge ref in this job.
ref: ${{ github.event.repository.default_branch }}
persist-credentials: false
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 22
# Pinned to a commit, not the mutable `v2` tag. A repoint of
# `v2` would execute new upstream code in every adopter on the next run,
# with no diff anywhere to review. Resolved from the `v2` tag on
# 2026-08-14. Bun's own version still comes from the repo's
# `packageManager` field, not from this pin.
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
- name: Restore Bun install cache
uses: actions/cache@v4
with:
path: ~/.bun/install/cache
key: bun-${{ runner.os }}-${{ hashFiles('bun.lock', 'bun.lockb') }}
restore-keys: |
bun-${{ runner.os }}-
# This repo declares the `@attalabs/vinaya` workspace package itself, so
# `npx @attalabs/vinaya` resolves to that local member instead of the
# registry and dies on its unbuilt `bin`. Build and run the trusted
# default-branch copy directly.
#
# This workflow executes only a trusted checkout selected by its event:
# never the pull request head or merge ref. The review-authority decision
# therefore comes from code the pull request cannot alter. `--ignore-scripts`
# remains defense in depth against lifecycle hooks in that trusted tree.
- name: Build the trusted Vinaya CLI
run: |
bun install --frozen-lockfile --ignore-scripts
bun run --cwd apps/cli build
- name: Read the pull request over REST
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
BRANCH: ${{ github.head_ref }}
run: |
set -o pipefail
RAW="$RUNNER_TEMP/vinaya-forge-snapshot/raw"
mkdir -p "$RAW"
fail_read() {
echo "::error::Reading $1 over the REST API failed: $(cat "$RAW/read-error.txt" 2>/dev/null)" >&2
gh api rate_limit --jq '.resources as $r | "::error::GitHub REST (core) budget: \($r.core.remaining) of \($r.core.limit) left, resets at \($r.core.reset | todate)\(if $r.core.remaining == 0 then " - spent, so GitHub rate-limited this read" else "" end); GraphQL budget: \($r.graphql.remaining) of \($r.graphql.limit) left, resets at \($r.graphql.reset | todate)\(if $r.graphql.remaining == 0 then " - spent" else "" end)."' >&2 || echo "::error::The token budget could not be read either." >&2
exit 1
}
ATTEMPTS=0
MAX_ATTEMPTS=6
SLEEP_SECONDS=5
while :; do
gh api "repos/{owner}/{repo}/pulls/$PR_NUMBER" > "$RAW/pull.json" 2> "$RAW/read-error.txt" || fail_read "pull request #$PR_NUMBER"
BODY="$(jq -r '.body // ""' "$RAW/pull.json")"
WAIT_FOR=""
if [[ "$BRANCH" =~ ^task/[^/]+/[^/]+$ ]]; then
CLOSES_REGION="$(printf '%s\n' "$BODY" | sed -n '/<!-- AEG:CLOSES:START -->/,/<!-- AEG:CLOSES:END -->/p')"
if ! printf '%s\n' "$CLOSES_REGION" | grep -qE '#[0-9]+'; then
WAIT_FOR="a real Closes #N inside the AEG:CLOSES region for task branch $BRANCH"
fi
fi
if [ -z "$WAIT_FOR" ]; then
break
fi
ATTEMPTS=$((ATTEMPTS + 1))
if [ "$ATTEMPTS" -ge "$MAX_ATTEMPTS" ]; then
echo "::error::Gave up after $ATTEMPTS attempts waiting for $WAIT_FOR." >&2
exit 1
fi
sleep "$SLEEP_SECONDS"
done
gh api --paginate --slurp "repos/{owner}/{repo}/issues/$PR_NUMBER/comments?per_page=100" > "$RAW/pull-comments.json" 2> "$RAW/read-error.txt" || fail_read "pull request #$PR_NUMBER's comments"
- name: Write the forge snapshot
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
run: |
node apps/cli/dist/index.js pr snapshot --dir "$RUNNER_TEMP/vinaya-forge-snapshot"
DELIM="PR_BODY_$(openssl rand -hex 16)"
{
echo "PR_BODY<<$DELIM"
cat "$RUNNER_TEMP/vinaya-forge-snapshot/body.md"
echo
echo "$DELIM"
} >> "$GITHUB_ENV"
- name: Fetch the pull request head as git objects
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -o pipefail
HEAD_SHA="$(jq -r '.head.sha // ""' "$RUNNER_TEMP/vinaya-forge-snapshot/raw/pull.json")"
if ! [[ "$HEAD_SHA" =~ ^[0-9a-f]{40}$ ]]; then
echo "::error::The pull request read recorded no head commit sha." >&2
exit 1
fi
AUTH="$(printf 'x-access-token:%s' "$GH_TOKEN" | base64 -w0)"
echo "::add-mask::$AUTH"
GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.extraheader GIT_CONFIG_VALUE_0="AUTHORIZATION: basic $AUTH" \
git fetch --no-tags --quiet origin "$HEAD_SHA"
git cat-file -e "$HEAD_SHA^{commit}"
- name: Evidence freshness
env:
# `principals` is read from the default branch over the forge, so
# this step needs the token even with the snapshot in hand.
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# PR_NUMBER is what makes evidence-fresh EVALUATE: without it the
# check reads "no PR yet" and exits 0.
PR_NUMBER: ${{ github.event.pull_request.number }}
# The commit the pull request targets, for the merge-base the diff
# stat is recomputed from.
BASE_SHA: ${{ github.event.pull_request.base.sha }}
# The run's forge snapshot (`lib/forge-snapshot.ts`): the checks read
# the pull request and its task Issue from it instead of reading the
# forge for them again. Set on this step only.
VINAYA_FORGE_SNAPSHOT_DIR: ${{ runner.temp }}/vinaya-forge-snapshot
PR_BODY_FILE: ${{ runner.temp }}/vinaya-forge-snapshot/body.md
run: node apps/cli/dist/index.js check evidence-fresh
- name: Record the GraphQL budget at job end
if: ${{ !cancelled() }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh api rate_limit --jq '"GraphQL budget at job end: \(.resources.graphql.used) used of \(.resources.graphql.limit), resets at \(.resources.graphql.reset | todate)"' >> "$GITHUB_STEP_SUMMARY" || echo "GraphQL budget at job end: unreadable" >> "$GITHUB_STEP_SUMMARY"
# When this job failed, print its own token budgets on one line for
# the review loop (`apps/cli/specs/loop.md`). Reading them spends none.
- name: Report this job's GitHub token budget
if: failure()
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh api rate_limit --jq '.resources as $r | "vinaya-budget: exhausted=\(if $r.core.remaining == 0 then "core" elif $r.graphql.remaining == 0 then "graphql" else "none" end) core_remaining=\($r.core.remaining) core_reset=\($r.core.reset) graphql_remaining=\($r.graphql.remaining) graphql_reset=\($r.graphql.reset)"' || true