forge-orkes 0.70.0 → 0.72.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,487 @@
1
+ #!/usr/bin/env sh
2
+ # Fixture suite for .forge/checks/forge-roadmap-check.sh (m-39, plan-01, phase 71).
3
+ #
4
+ # Contract under test (locked interface — do not re-derive):
5
+ # forge-roadmap-check.sh [<roadmap-file>] # default: <git-root>/.forge/roadmap.yml
6
+ # stdout: ok=clean # no findings
7
+ # finding=<rule>:<detail> # one line per problem
8
+ # rule ∈ missing-field | duplicate-phase-id | dangling-phase-ref | orphan-phase
9
+ # | bad-milestone-dir | dangling-wave-ref
10
+ # exit: 0 = ok=clean; 1 = findings; 2 = bad invocation (missing/unreadable file)
11
+ #
12
+ # Required fields — milestone entry: id, name, phases (non-empty: an empty
13
+ # list — `phases: []` — raises missing-field:milestone:<id>:phases).
14
+ # phase entry: id, name, goal, requirements, dependencies,
15
+ # success_criteria, estimated_hours, status.
16
+ # milestone_dir is DELIBERATELY not required (17/65 live phases predate it) —
17
+ # validated only when present (bad-milestone-dir rule); absence never a finding.
18
+ #
19
+ # Unlike forge-hold-check.sh/forge-check-lint.sh, this check reads ONE YAML file,
20
+ # not a diff — fixtures are plain files under mktemp -d (one throwaway git repo
21
+ # for the no-arg default-path case only).
22
+ #
23
+ # Usage:
24
+ # ./.forge/checks/tests/forge-roadmap-check.test.sh # all cases
25
+ # ./.forge/checks/tests/forge-roadmap-check.test.sh duplicate-id # one tag
26
+ # Filter tags: clean missing-field duplicate-id refs milestone-dir waves malformed invocation
27
+ #
28
+ # RED-phase friendly: missing helper → every selected case FAILS, suite exits
29
+ # non-zero, no abort. Exits 0 iff every selected case passes AND at least one
30
+ # case block was selected (a typo'd tag must not exit green). Self-cleans.
31
+ set -u
32
+
33
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
34
+ HELPER="$SCRIPT_DIR/../forge-roadmap-check.sh"
35
+ FILTER="${1:-}"
36
+
37
+ [ -x "$HELPER" ] || printf 'NOTE: %s missing or not executable — RED phase, every selected case below should FAIL\n' "$HELPER" >&2
38
+
39
+ ROOT="$(mktemp -d)"
40
+ trap 'rm -rf "$ROOT"' EXIT INT TERM
41
+
42
+ PASSED=0
43
+ FAILED=0
44
+ SELECTED=0
45
+
46
+ want_tag() {
47
+ if [ -z "$FILTER" ] || [ "$FILTER" = "$1" ]; then
48
+ SELECTED=$((SELECTED + 1))
49
+ return 0
50
+ fi
51
+ return 1
52
+ }
53
+
54
+ pass() { PASSED=$((PASSED + 1)); printf 'PASS: %s\n' "$1"; }
55
+ fail() { FAILED=$((FAILED + 1)); printf 'FAIL: %s\n' "$1" >&2; }
56
+
57
+ # run_check <fixture-path> -> sets OUT (stdout) and RC (exit status)
58
+ run_check() {
59
+ OUT="$("$HELPER" "$1" 2>"$ROOT/stderr")"
60
+ RC=$?
61
+ }
62
+
63
+ assert_exit() {
64
+ # $1=case-label $2=expected-code
65
+ if [ "$RC" = "$2" ]; then pass "$1 (exit $2)"; else fail "$1 (exit expected $2, got $RC)"; fi
66
+ }
67
+
68
+ assert_finding() {
69
+ # $1=case-label $2=exact-expected-line
70
+ if printf '%s\n' "$OUT" | grep -qF "$2"; then
71
+ pass "$1 (found: $2)"
72
+ else
73
+ fail "$1 (missing expected line: $2 -- got: $OUT)"
74
+ fi
75
+ }
76
+
77
+ assert_no_finding_matching() {
78
+ # $1=case-label $2=grep-pattern that must NOT appear
79
+ if printf '%s\n' "$OUT" | grep -qE "$2"; then
80
+ fail "$1 (unexpected finding matching: $2 -- got: $OUT)"
81
+ else
82
+ pass "$1 (no finding matching: $2)"
83
+ fi
84
+ }
85
+
86
+ assert_clean() {
87
+ # $1=case-label
88
+ if [ "$OUT" = "ok=clean" ]; then pass "$1 (ok=clean)"; else fail "$1 (expected ok=clean, got: $OUT)"; fi
89
+ }
90
+
91
+ # ---------------------------------------------------------------------------
92
+ # Fixture bodies
93
+ # ---------------------------------------------------------------------------
94
+
95
+ # A minimal, fully well-formed roadmap. Phase 2 deliberately carries NO
96
+ # milestone_dir -- doubles as the milestone-dir negative control (absence of
97
+ # the optional field must never raise a finding).
98
+ write_clean() {
99
+ cat >"$1" <<'EOF'
100
+ roadmap:
101
+ milestones:
102
+ - id: 1
103
+ name: "Alpha"
104
+ phases: [1, 2]
105
+ - id: 2
106
+ name: "Beta"
107
+ phases: [3]
108
+
109
+ phases:
110
+ - id: 1
111
+ name: "alpha-one"
112
+ milestone_dir: "milestone-1/1-alpha-one"
113
+ goal: "User can alpha"
114
+ requirements: [FR-001]
115
+ dependencies: []
116
+ integration_checkpoint: false
117
+ success_criteria:
118
+ - "does the thing"
119
+ estimated_hours: 1
120
+ status: pending
121
+ - id: 2
122
+ name: "alpha-two"
123
+ goal: "User can alpha two"
124
+ requirements: [FR-002]
125
+ dependencies: [1]
126
+ success_criteria:
127
+ - "does thing two"
128
+ estimated_hours: 1
129
+ status: pending
130
+ - id: 3
131
+ name: "beta-one"
132
+ milestone_dir: "milestone-2/3-beta-one"
133
+ goal: "User can beta"
134
+ requirements: [FR-003]
135
+ dependencies: []
136
+ success_criteria:
137
+ - "does beta"
138
+ estimated_hours: 2
139
+ status: pending
140
+
141
+ waves:
142
+ 1: [1, 3]
143
+ 2: [2]
144
+ EOF
145
+ }
146
+
147
+ # One well-formed phase entry (id 1, under milestone 1), field-complete --
148
+ # the SINGLE source fixture every single-mutation case derives from via sed
149
+ # (a required-field change edits this one body, not N copies).
150
+ write_phase_template() {
151
+ cat >"$1" <<'EOF'
152
+ roadmap:
153
+ milestones:
154
+ - id: 1
155
+ name: "Alpha"
156
+ phases: [1]
157
+
158
+ phases:
159
+ - id: 1
160
+ name: "alpha-one"
161
+ milestone_dir: "milestone-1/1-alpha-one"
162
+ goal: "User can alpha"
163
+ requirements: [FR-001]
164
+ dependencies: []
165
+ integration_checkpoint: false
166
+ success_criteria:
167
+ - "does the thing"
168
+ estimated_hours: 1
169
+ status: pending
170
+
171
+ waves:
172
+ 1: [1]
173
+ EOF
174
+ }
175
+
176
+ # ---------------------------------------------------------------------------
177
+ # clean
178
+ # ---------------------------------------------------------------------------
179
+ if want_tag clean; then
180
+ f="$ROOT/clean.yml"; write_clean "$f"
181
+ run_check "$f"
182
+ assert_exit "clean: well-formed roadmap" 0
183
+ assert_clean "clean: well-formed roadmap"
184
+ assert_no_finding_matching "clean: phase-2 has no milestone_dir" "bad-milestone-dir:2:"
185
+
186
+ # Quoted m-33-style milestone id + matching milestone_dir — 8 live milestones
187
+ # use this shape; an unquote/bare_num regression must not mass-false-positive.
188
+ f="$ROOT/clean-quoted-id.yml"
189
+ cat >"$f" <<'EOF'
190
+ roadmap:
191
+ milestones:
192
+ - id: "m-33"
193
+ name: "Quoted id"
194
+ phases: [4]
195
+
196
+ phases:
197
+ - id: 4
198
+ name: "quoted-one"
199
+ milestone_dir: "milestone-33/4-quoted-one"
200
+ goal: "Quoted milestone ids resolve"
201
+ requirements: [FR-001]
202
+ dependencies: []
203
+ success_criteria:
204
+ - "stays clean"
205
+ estimated_hours: 1
206
+ status: pending
207
+
208
+ waves:
209
+ 1: [4]
210
+ EOF
211
+ run_check "$f"
212
+ assert_exit "clean: quoted m-33-style milestone id" 0
213
+ assert_clean "clean: quoted m-33-style milestone id"
214
+
215
+ # Trailing "# comment" on id lines (both milestone and phase) must not corrupt
216
+ # the captured id — pre-fix this false-fired bad-milestone-dir for every phase
217
+ # of the commented milestone (reproduced at m-39 review).
218
+ f="$ROOT/clean-id-comment.yml"
219
+ write_phase_template "$f"
220
+ sed -i.bak 's/^ - id: 1$/ - id: 1 # renumbered at rebase/' "$f"
221
+ run_check "$f"
222
+ assert_exit "clean: trailing comment on id lines" 0
223
+ assert_clean "clean: trailing comment on id lines"
224
+
225
+ # Block-style and wrapped flow phases: lists are real claims — pre-fix both
226
+ # parsed as empty and false-fired orphan-phase per phase.
227
+ f="$ROOT/clean-block-phases.yml"
228
+ cat >"$f" <<'EOF'
229
+ roadmap:
230
+ milestones:
231
+ - id: 1
232
+ name: "Alpha"
233
+ phases:
234
+ - 1
235
+ - 2
236
+ - id: 2
237
+ name: "Beta"
238
+ phases: [2,
239
+ 3]
240
+
241
+ phases:
242
+ - id: 1
243
+ name: "alpha-one"
244
+ goal: "User can alpha"
245
+ requirements: [FR-001]
246
+ dependencies: []
247
+ success_criteria:
248
+ - "does the thing"
249
+ estimated_hours: 1
250
+ status: pending
251
+ - id: 2
252
+ name: "alpha-two"
253
+ goal: "User can alpha two"
254
+ requirements: [FR-002]
255
+ dependencies: [1]
256
+ success_criteria:
257
+ - "does thing two"
258
+ estimated_hours: 1
259
+ status: pending
260
+ - id: 3
261
+ name: "beta-one"
262
+ goal: "User can beta"
263
+ requirements: [FR-003]
264
+ dependencies: []
265
+ success_criteria:
266
+ - "does beta"
267
+ estimated_hours: 2
268
+ status: pending
269
+
270
+ waves:
271
+ 1: [1, 2, 3]
272
+ EOF
273
+ run_check "$f"
274
+ assert_exit "clean: block-style + wrapped flow phase lists" 0
275
+ assert_clean "clean: block-style + wrapped flow phase lists"
276
+ fi
277
+
278
+ # ---------------------------------------------------------------------------
279
+ # missing-field (phase side): delete one required field line at a time
280
+ # ---------------------------------------------------------------------------
281
+ if want_tag missing-field; then
282
+ for field in name goal requirements dependencies estimated_hours status; do
283
+ f="$ROOT/missing-phase-$field.yml"
284
+ write_phase_template "$f"
285
+ sed -i.bak "/^ $field:/d" "$f"
286
+ run_check "$f"
287
+ assert_exit "missing-field phase:$field" 1
288
+ assert_finding "missing-field phase:$field" "finding=missing-field:phase:1:$field"
289
+ done
290
+
291
+ # success_criteria is a two-line block field (key + one list item)
292
+ f="$ROOT/missing-phase-success_criteria.yml"
293
+ write_phase_template "$f"
294
+ sed -i.bak '/^ success_criteria:/,/^ - /d' "$f"
295
+ run_check "$f"
296
+ assert_exit "missing-field phase:success_criteria" 1
297
+ assert_finding "missing-field phase:success_criteria" "finding=missing-field:phase:1:success_criteria"
298
+
299
+ # milestone side: name, phases
300
+ f="$ROOT/missing-milestone-name.yml"
301
+ write_phase_template "$f"
302
+ sed -i.bak '/^ name: "Alpha"/d' "$f"
303
+ run_check "$f"
304
+ assert_exit "missing-field milestone:name" 1
305
+ assert_finding "missing-field milestone:name" "finding=missing-field:milestone:1:name"
306
+
307
+ f="$ROOT/missing-milestone-phases.yml"
308
+ write_phase_template "$f"
309
+ sed -i.bak '/^ phases: \[1\]/d' "$f"
310
+ run_check "$f"
311
+ assert_exit "missing-field milestone:phases" 1
312
+ assert_finding "missing-field milestone:phases" "finding=missing-field:milestone:1:phases"
313
+
314
+ # phases present but EMPTY — the "(non-empty)" half of the requirement:
315
+ # phases: [] must raise the same missing-field finding, not pass silently.
316
+ f="$ROOT/empty-milestone-phases.yml"
317
+ write_phase_template "$f"
318
+ sed -i.bak 's/^ phases: \[1\]$/ phases: []/' "$f"
319
+ run_check "$f"
320
+ assert_exit "missing-field milestone:phases empty list" 1
321
+ assert_finding "missing-field milestone:phases empty list" "finding=missing-field:milestone:1:phases"
322
+ fi
323
+
324
+ # ---------------------------------------------------------------------------
325
+ # duplicate-id: the REAL live m-34/m-36 shape (id 63 claimed by two phases).
326
+ # Kept literal — this fixture IS the incident, not a derived mutation.
327
+ # ---------------------------------------------------------------------------
328
+ if want_tag duplicate-id; then
329
+ f="$ROOT/duplicate-id.yml"
330
+ cat >"$f" <<'EOF'
331
+ roadmap:
332
+ milestones:
333
+ - id: 36
334
+ name: "FORGE.md context diet"
335
+ phases: [63]
336
+ - id: 34
337
+ name: "Origin-first upgrading"
338
+ phases: [63]
339
+
340
+ phases:
341
+ - id: 63
342
+ name: "context-diet"
343
+ milestone_dir: "milestone-36/63-context-diet"
344
+ goal: "FORGE.md slimmed"
345
+ requirements: [FR-191]
346
+ dependencies: []
347
+ success_criteria:
348
+ - "wc -c <= 35840"
349
+ estimated_hours: 3
350
+ status: pending
351
+ - id: 63
352
+ name: "origin-upgrade-bin"
353
+ milestone_dir: "milestone-34/63-origin-upgrade-bin"
354
+ goal: "Non-interactive upgrade bin"
355
+ requirements: [FR-184]
356
+ dependencies: []
357
+ success_criteria:
358
+ - "upgrade --yes --json exits 0"
359
+ estimated_hours: 4
360
+ status: pending
361
+
362
+ waves:
363
+ 1: [63]
364
+ EOF
365
+ run_check "$f"
366
+ assert_exit "duplicate-id: real m-34/m-36 id-63 shape" 1
367
+ assert_finding "duplicate-id: real m-34/m-36 id-63 shape" "finding=duplicate-phase-id:63:milestone-36/63-context-diet,milestone-34/63-origin-upgrade-bin"
368
+ fi
369
+
370
+ # ---------------------------------------------------------------------------
371
+ # refs: dangling-phase-ref (milestone claims a nonexistent phase) + orphan-phase
372
+ # — both derived from a source fixture by single-line sed mutation
373
+ # ---------------------------------------------------------------------------
374
+ if want_tag refs; then
375
+ f="$ROOT/dangling-ref.yml"
376
+ write_phase_template "$f"
377
+ sed -i.bak 's/^ phases: \[1\]$/ phases: [1, 99]/' "$f"
378
+ run_check "$f"
379
+ assert_exit "refs: dangling-phase-ref" 1
380
+ assert_finding "refs: dangling-phase-ref" "finding=dangling-phase-ref:milestone:1:phase:99"
381
+
382
+ # orphan: drop phase 2 from the clean fixture's claims — phase 2 still exists,
383
+ # no milestone claims it. Phase 2 carries no milestone_dir, so this also
384
+ # covers the "(none)" detail branch of the orphan finding.
385
+ f="$ROOT/orphan-phase.yml"
386
+ write_clean "$f"
387
+ sed -i.bak 's/^ phases: \[1, 2\]$/ phases: [1]/' "$f"
388
+ run_check "$f"
389
+ assert_exit "refs: orphan-phase" 1
390
+ assert_finding "refs: orphan-phase" "finding=orphan-phase:2:(none)"
391
+ fi
392
+
393
+ # ---------------------------------------------------------------------------
394
+ # milestone-dir: bad prefix fires; absent field never fires (negative control)
395
+ # — both derived from the template by single-line sed mutation
396
+ # ---------------------------------------------------------------------------
397
+ if want_tag milestone-dir; then
398
+ f="$ROOT/bad-milestone-dir.yml"
399
+ write_phase_template "$f"
400
+ sed -i.bak 's|milestone-1/1-alpha-one|milestone-99/1-alpha-one|' "$f"
401
+ run_check "$f"
402
+ assert_exit "milestone-dir: bad prefix" 1
403
+ assert_finding "milestone-dir: bad prefix" "finding=bad-milestone-dir:1:milestone-99/1-alpha-one"
404
+
405
+ f="$ROOT/no-milestone-dir.yml"
406
+ write_phase_template "$f"
407
+ sed -i.bak '/^ milestone_dir:/d' "$f"
408
+ run_check "$f"
409
+ assert_exit "milestone-dir: absent field stays clean" 0
410
+ assert_clean "milestone-dir: absent field stays clean"
411
+ fi
412
+
413
+ # ---------------------------------------------------------------------------
414
+ # waves: a wave referencing a phase id absent from phases:
415
+ # — derived from the template by single-line sed mutation
416
+ # ---------------------------------------------------------------------------
417
+ if want_tag waves; then
418
+ f="$ROOT/dangling-wave.yml"
419
+ write_phase_template "$f"
420
+ sed -i.bak 's/^ 1: \[1\]$/ 1: [1, 99]/' "$f"
421
+ run_check "$f"
422
+ assert_exit "waves: dangling-wave-ref" 1
423
+ assert_finding "waves: dangling-wave-ref" "finding=dangling-wave-ref:1:99"
424
+ fi
425
+
426
+ # ---------------------------------------------------------------------------
427
+ # malformed: a field mis-indented one level shallow -- proves the parser is
428
+ # structural (indentation-keyed), not a flat "key: value anywhere" grep. A
429
+ # flat grep would still find "success_criteria:" text in the file and wrongly
430
+ # pass; the structural walk must NOT associate it with phase 1 at the wrong depth.
431
+ # ---------------------------------------------------------------------------
432
+ if want_tag malformed; then
433
+ f="$ROOT/malformed-indent.yml"
434
+ cat >"$f" <<'EOF'
435
+ roadmap:
436
+ milestones:
437
+ - id: 1
438
+ name: "Alpha"
439
+ phases: [1]
440
+
441
+ phases:
442
+ - id: 1
443
+ name: "alpha-one"
444
+ goal: "User can alpha"
445
+ requirements: [FR-001]
446
+ dependencies: []
447
+ success_criteria:
448
+ - "does the thing"
449
+ estimated_hours: 1
450
+ status: pending
451
+
452
+ waves:
453
+ 1: [1]
454
+ EOF
455
+ run_check "$f"
456
+ assert_exit "malformed: mis-indented success_criteria" 1
457
+ assert_finding "malformed: mis-indented success_criteria" "finding=missing-field:phase:1:success_criteria"
458
+ fi
459
+
460
+ # ---------------------------------------------------------------------------
461
+ # invocation: missing/unreadable file -> exit 2, nothing on stdout;
462
+ # no-arg default resolves <git-root>/.forge/roadmap.yml
463
+ # ---------------------------------------------------------------------------
464
+ if want_tag invocation; then
465
+ run_check "$ROOT/does-not-exist.yml"
466
+ assert_exit "invocation: nonexistent file" 2
467
+ if [ -z "$OUT" ]; then pass "invocation: empty stdout on bad invocation"; else fail "invocation: expected empty stdout, got: $OUT"; fi
468
+ if [ -s "$ROOT/stderr" ]; then pass "invocation: diagnostic on stderr"; else fail "invocation: expected a diagnostic on stderr"; fi
469
+
470
+ # default no-arg path inside a throwaway git repo (the only git use in this suite)
471
+ if command -v git >/dev/null 2>&1; then
472
+ d="$ROOT/defrepo"
473
+ mkdir -p "$d/.forge"
474
+ git init -q "$d"
475
+ write_clean "$d/.forge/roadmap.yml"
476
+ OUT="$(cd "$d" && "$HELPER" 2>"$ROOT/stderr")"
477
+ RC=$?
478
+ assert_exit "invocation: no-arg resolves git-root roadmap" 0
479
+ assert_clean "invocation: no-arg resolves git-root roadmap"
480
+ else
481
+ printf 'NOTE: git unavailable — skipping no-arg default-path case\n' >&2
482
+ fi
483
+ fi
484
+
485
+ # ---------------------------------------------------------------------------
486
+ printf '\n%d selected case block(s): %d passed, %d failed\n' "$SELECTED" "$PASSED" "$FAILED"
487
+ [ "$FAILED" -eq 0 ] && [ "$SELECTED" -gt 0 ]
@@ -32,3 +32,18 @@ deploy_paths: []
32
32
  # deleted-assertion scope when set — default there is EVERY test file in the diff).
33
33
  gating_tests: []
34
34
  # - "tests/e2e/*"
35
+
36
+ # OPTIONAL run-waiver binding — the Environment-gated hold waiver (default OFF).
37
+ # When set and a hold has fired (and no operator PR approval waives it), the
38
+ # check queries the host for a SUCCESSFUL run of this workflow on the repo's
39
+ # DEFAULT branch whose run title carries the PR head SHA (the workflow's
40
+ # run-name templates the sha input in — activate .forge/templates/waive-hold.yml
41
+ # as .github/workflows/waive-hold.yml, gated by a `hold-waiver` Environment
42
+ # whose required reviewer is the operator). Match → the hold passes, annotated
43
+ # with the run URL. FAIL-CLOSED on every leg: unconfigured, API unreachable,
44
+ # no matching run, run/config ref != default branch, stale/superseded SHA → the
45
+ # hold stands. Applies to the HOLD CHECK ONLY — never lint or CI test checks.
46
+ # CI needs `actions: read` on the token; see docs/merge-floor-checks.md §5.
47
+ # waiver:
48
+ # workflow: waive-hold.yml # workflow file under .github/workflows/
49
+ # ref: main # MUST equal the repo's default branch
@@ -155,6 +155,16 @@ models:
155
155
  # # phone-spawned sessions act without prompts — opt in only where the
156
156
  # # phone lane carries the same trust as an attended terminal.
157
157
 
158
+ # attribution: # OPT-IN Driven-by attribution (hold-waiver patch) — default OFF.
159
+ # driven_by: true # When true, the merge-floor check-quality lint
160
+ # # (.forge/checks/forge-check-lint.sh) requires every non-merge PR
161
+ # # commit to carry a `Driven-by: <email>` trailer (optional trailing
162
+ # # session ref allowed) — the durable per-commit record of the driving
163
+ # # human, whatever identity authors the commit. Read from the BASE side
164
+ # # of the PR diff (a PR cannot disable it for itself; this file is on
165
+ # # the hold check's protected paths, so flipping it holds for operator
166
+ # # review). Absent/false → rule fully off, zero behavior change.
167
+
158
168
  risks: # What could go wrong?
159
169
  - risk: ""
160
170
  mitigation: ""
@@ -0,0 +1,63 @@
1
+ # waive-hold — Environment-gated hold waiver (TEMPLATE — hold-waiver patch,
2
+ # operator-ruled 2026-07-22; see docs/merge-floor-checks.md).
3
+ #
4
+ # This file is a TEMPLATE shipped in .forge/templates/ — it is NOT an active
5
+ # workflow here. To activate on a consuming repo:
6
+ # 1. Create the `hold-waiver` Environment (repo Settings → Environments) with
7
+ # the operator as required reviewer. No secrets, no branch restriction.
8
+ # 2. Copy this file to .github/workflows/waive-hold.yml on the DEFAULT branch.
9
+ # 3. Bind it in .forge/hold-config.yml:
10
+ # waiver:
11
+ # workflow: waive-hold.yml
12
+ # ref: <default-branch>
13
+ #
14
+ # How it waives: the operator dispatches this workflow (phone-friendly) with the
15
+ # held PR's full 40-char head SHA. The `environment: hold-waiver` gate parks the
16
+ # run until the operator approves it — the approval IS the recorded human act
17
+ # (D-9: lowering a hold is an explicit recorded act; Environment approvals have
18
+ # no self-approval ban). On approval the run executes and succeeds; run-name
19
+ # templates the SHA into the run's display title, which is exactly what
20
+ # forge-hold-check.sh's run-waiver channel matches on (workflow path + default-
21
+ # branch ref + success conclusion + head SHA in the title). A new push to the PR
22
+ # changes its head SHA, so an old waiver run stops matching — waive per SHA.
23
+ #
24
+ # The job's body IS the record: it validates the input and asserts the SHA is
25
+ # the head of an open PR (a run for a random SHA waives nothing anyway — the
26
+ # assert just keeps the record clean). It deploys NOTHING and needs NO secrets —
27
+ # only the default GITHUB_TOKEN, read-only.
28
+ name: waive-hold
29
+ run-name: "waive-hold sha=${{ inputs.sha }}"
30
+
31
+ on:
32
+ workflow_dispatch:
33
+ inputs:
34
+ sha:
35
+ description: "Full 40-char head SHA of the held PR this waiver approves"
36
+ required: true
37
+ type: string
38
+
39
+ permissions:
40
+ contents: read
41
+ pull-requests: read
42
+
43
+ jobs:
44
+ record-waiver:
45
+ runs-on: ubuntu-latest
46
+ environment: hold-waiver # the gate: required reviewer = the operator
47
+ env:
48
+ SHA: ${{ inputs.sha }} # env-var indirection — the input never lands in script text
49
+ GH_TOKEN: ${{ github.token }}
50
+ steps:
51
+ - name: Validate SHA format (full 40-char hex, nothing else)
52
+ run: |
53
+ printf '%s\n' "$SHA" | grep -qE '^[0-9a-f]{40}$' \
54
+ || { printf 'waive-hold: sha input must be a full 40-char lowercase hex SHA, got: %s\n' "$SHA"; exit 1; }
55
+ - name: Assert the SHA is the head of an open PR
56
+ run: |
57
+ pr_number="$(gh api "repos/$GITHUB_REPOSITORY/commits/$SHA/pulls" \
58
+ --jq "[.[] | select(.state == \"open\" and .head.sha == \"$SHA\")][0].number")"
59
+ if [ -z "$pr_number" ] || [ "$pr_number" = "null" ]; then
60
+ printf 'waive-hold: %s is not the head SHA of any OPEN pull request — refusing to record a waiver\n' "$SHA"
61
+ exit 1
62
+ fi
63
+ printf 'waive-hold: recorded — approved waiver for open PR #%s at head %s\n' "$pr_number" "$SHA"