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.
@@ -20,12 +20,18 @@
20
20
  # exit: 0 = ok=clean; 1 = one or more findings; 2 = bad invocation (missing/unreadable file)
21
21
  #
22
22
  # Required fields (confirmed against the live file — zero false positives today):
23
- # milestone entry: id, name, phases (non-empty)
23
+ # milestone entry: id, name, phases (non-empty — an empty list, `phases: []` or a
24
+ # block-style key with no items, raises missing-field:milestone:<id>:phases)
24
25
  # phase entry: id, name, goal, requirements, dependencies, success_criteria,
25
26
  # estimated_hours, status
26
27
  # milestone_dir is DELIBERATELY not required — 17/65 live phase entries predate
27
28
  # the nested-layout convention. Validated only when present (bad-milestone-dir).
28
29
  #
30
+ # A milestone's phases: list is parsed in all three shapes YAML allows here:
31
+ # one-line flow ([1, 2]), wrapped flow ([1,\n 2]), and block style (- 1 items).
32
+ # Trailing "# comment" text on id/milestone_dir/phases lines is stripped before
33
+ # capture (mirrors forge-check-lint.sh strip_comment).
34
+ #
29
35
  # Pure POSIX sh + awk. No git-repo dependency (reads the file directly, unlike the
30
36
  # diff-scoped forge-hold-check.sh/forge-check-lint.sh). Collects every finding in
31
37
  # one pass before deciding exit code — a single run surfaces everything wrong.
@@ -51,6 +57,10 @@ function unquote(s) {
51
57
  gsub(/"/, "", s)
52
58
  return s
53
59
  }
60
+ function strip_comment(s) {
61
+ sub(/[ \t]#.*$/, "", s)
62
+ return s
63
+ }
54
64
  function bare_num(s, n) {
55
65
  n = unquote(trim(s))
56
66
  if (n ~ /^m-/) n = substr(n, 3)
@@ -70,6 +80,7 @@ function finish_entry() {
70
80
  ph_mdir[ph_count] = cur_mdir
71
81
  }
72
82
  entry_open = 0
83
+ ph_collect = 0
73
84
  cur_id = ""; cur_fields = ""; cur_mdir = ""; cur_phaselist = ""
74
85
  }
75
86
 
@@ -107,13 +118,14 @@ block == "waves" && $0 ~ /^ [0-9]+:/ {
107
118
  entry_type = (block == "milestones" ? "milestone" : "phase")
108
119
  val = $0
109
120
  sub(/^ - id:[ \t]*/, "", val)
110
- cur_id = unquote(trim(val))
121
+ cur_id = unquote(trim(strip_comment(val)))
111
122
  cur_fields = " id "
112
123
  next
113
124
  }
114
125
 
115
126
  # 6-space field line inside an open entry
116
127
  (block == "milestones" || block == "phases") && entry_open && $0 ~ /^ [A-Za-z_]+:/ {
128
+ ph_collect = 0
117
129
  line = $0
118
130
  sub(/^ /, "", line)
119
131
  match(line, /^[A-Za-z_]+/)
@@ -122,17 +134,45 @@ block == "waves" && $0 ~ /^ [0-9]+:/ {
122
134
  if (field == "milestone_dir") {
123
135
  val = line
124
136
  sub(/^milestone_dir:[ \t]*/, "", val)
125
- cur_mdir = unquote(trim(val))
137
+ cur_mdir = unquote(trim(strip_comment(val)))
126
138
  }
127
139
  if (field == "phases" && entry_type == "milestone") {
128
- rest = line
140
+ rest = strip_comment(line)
141
+ sub(/^phases:[ \t]*/, "", rest)
129
142
  if (match(rest, /\[[^]]*\]/)) {
130
143
  cur_phaselist = substr(rest, RSTART + 1, RLENGTH - 2)
144
+ } else if (rest ~ /^\[/) {
145
+ cur_phaselist = substr(rest, 2)
146
+ ph_collect = 1
147
+ } else if (rest == "") {
148
+ ph_collect = 2
131
149
  }
132
150
  }
133
151
  next
134
152
  }
135
153
 
154
+ # wrapped flow list continuation: collect until the ] that closes phases: [...
155
+ # (a new 6-space field line ends collection via the rule above — failsafe for
156
+ # a bracket that never closes)
157
+ block == "milestones" && entry_open && ph_collect == 1 && $0 ~ /^ / {
158
+ line = strip_comment($0)
159
+ if (match(line, /\]/)) {
160
+ cur_phaselist = cur_phaselist " " substr(line, 1, RSTART - 1)
161
+ ph_collect = 0
162
+ } else {
163
+ cur_phaselist = cur_phaselist " " line
164
+ }
165
+ next
166
+ }
167
+
168
+ # block-style list item under a bare "phases:" key: " - 4"
169
+ block == "milestones" && entry_open && ph_collect == 2 && $0 ~ /^ - / {
170
+ line = strip_comment($0)
171
+ sub(/^ - [ \t]*/, "", line)
172
+ cur_phaselist = cur_phaselist " " unquote(trim(line))
173
+ next
174
+ }
175
+
136
176
  # any other 4-space line inside milestones:/phases: (not "- id:") -> malformed
137
177
  # dedent: closes the current entry without opening a new one.
138
178
  (block == "milestones" || block == "phases") && $0 ~ /^ [A-Za-z_-]/ {
@@ -140,13 +180,8 @@ block == "waves" && $0 ~ /^ [0-9]+:/ {
140
180
  next
141
181
  }
142
182
 
143
- END {
144
- finish_entry()
145
-
146
- required_ms = "name phases"
147
- required_ph = "name goal requirements dependencies success_criteria estimated_hours status"
148
-
149
- # 1. required fields
183
+ # 1. required fields (+ non-empty phases list on milestones)
184
+ function check_fields( i, j, n, reqs, cnt, toks) {
150
185
  for (i = 1; i <= ms_count; i++) {
151
186
  n = split(required_ms, reqs, " ")
152
187
  for (j = 1; j <= n; j++) {
@@ -155,6 +190,15 @@ END {
155
190
  found++
156
191
  }
157
192
  }
193
+ if (index(ms_fields[i], " phases ") != 0) {
194
+ cnt = 0
195
+ n = split(ms_phaselist[i], toks, /[ ,]+/)
196
+ for (j = 1; j <= n; j++) if (trim(toks[j]) != "") cnt++
197
+ if (cnt == 0) {
198
+ print "finding=missing-field:milestone:" ms_id[i] ":phases"
199
+ found++
200
+ }
201
+ }
158
202
  }
159
203
  for (i = 1; i <= ph_count; i++) {
160
204
  n = split(required_ph, reqs, " ")
@@ -165,39 +209,60 @@ END {
165
209
  }
166
210
  }
167
211
  }
212
+ }
168
213
 
169
- # phase-id set + first-seen order tracking for duplicate detection
214
+ # phase-id bookkeeping consumed by check_dups/check_refs/check_orphans:
215
+ # dup_count (occurrences), dup_list (mdir detail per occurrence), phase_exists (set)
216
+ function collect_phase_index( i, id) {
170
217
  for (i = 1; i <= ph_count; i++) {
171
218
  id = ph_id[i]
172
- phase_seen[id] = phase_seen[id] "" (phase_seen[id] == "" ? "" : ",")
173
219
  dup_list[id] = (dup_list[id] == "" ? "" : dup_list[id] ",") (ph_mdir[i] == "" ? "(none)" : ph_mdir[i])
174
220
  dup_count[id]++
175
221
  phase_exists[id] = 1
176
222
  }
223
+ }
224
+
225
+ # pre-pass: claimed_by[] must be fully populated before check_orphans reads it
226
+ function collect_claimed( i, j, m, claimed, pid) {
227
+ for (i = 1; i <= ms_count; i++) {
228
+ m = split(ms_phaselist[i], claimed, /[ ,]+/)
229
+ for (j = 1; j <= m; j++) {
230
+ pid = trim(claimed[j])
231
+ if (pid != "") claimed_by[pid] = 1
232
+ }
233
+ }
234
+ }
177
235
 
178
- # 2. duplicate phase ids
179
- for (id in dup_count) {
180
- if (dup_count[id] > 1) {
236
+ # 2. duplicate phase ids — first-seen order over ph_id[] (stable across awk
237
+ # impls, unlike for-in), one report per id via dup_reported[]
238
+ function check_dups( i, id) {
239
+ for (i = 1; i <= ph_count; i++) {
240
+ id = ph_id[i]
241
+ if (dup_count[id] > 1 && !(id in dup_reported)) {
242
+ dup_reported[id] = 1
181
243
  print "finding=duplicate-phase-id:" id ":" dup_list[id]
182
244
  found++
183
245
  }
184
246
  }
247
+ }
185
248
 
186
- # 3. dangling-phase-ref (milestone claims a phase id with no phase entry)
249
+ # 3. dangling-phase-ref (milestone claims a phase id with no phase entry)
250
+ function check_refs( i, j, m, claimed, pid) {
187
251
  for (i = 1; i <= ms_count; i++) {
188
252
  m = split(ms_phaselist[i], claimed, /[ ,]+/)
189
253
  for (j = 1; j <= m; j++) {
190
254
  pid = trim(claimed[j])
191
255
  if (pid == "") continue
192
- claimed_by[pid] = 1
193
256
  if (!(pid in phase_exists)) {
194
257
  print "finding=dangling-phase-ref:milestone:" ms_id[i] ":phase:" pid
195
258
  found++
196
259
  }
197
260
  }
198
261
  }
262
+ }
199
263
 
200
- # orphan-phase (phase entry claimed by zero milestones)
264
+ # orphan-phase (phase entry claimed by zero milestones; reads claimed_by[])
265
+ function check_orphans( i, id) {
201
266
  for (i = 1; i <= ph_count; i++) {
202
267
  id = ph_id[i]
203
268
  if (!(id in claimed_by)) {
@@ -205,8 +270,10 @@ END {
205
270
  found++
206
271
  }
207
272
  }
273
+ }
208
274
 
209
- # 4. bad-milestone-dir (milestone_dir prefix must resolve to a real milestone id)
275
+ # 4. bad-milestone-dir (milestone_dir prefix must resolve to a real milestone id)
276
+ function check_mdirs( i, prefix, numeral) {
210
277
  for (i = 1; i <= ms_count; i++) {
211
278
  ms_numeral[bare_num(ms_id[i])] = 1
212
279
  }
@@ -222,8 +289,10 @@ END {
222
289
  }
223
290
  }
224
291
  }
292
+ }
225
293
 
226
- # 5. dangling-wave-ref
294
+ # 5. dangling-wave-ref
295
+ function check_waves( i, j, m, wids, pid) {
227
296
  for (i = 1; i <= wave_count; i++) {
228
297
  m = split(wave_ids[i], wids, /[ ,]+/)
229
298
  for (j = 1; j <= m; j++) {
@@ -235,6 +304,22 @@ END {
235
304
  }
236
305
  }
237
306
  }
307
+ }
308
+
309
+ END {
310
+ finish_entry()
311
+
312
+ required_ms = "name phases"
313
+ required_ph = "name goal requirements dependencies success_criteria estimated_hours status"
314
+
315
+ check_fields()
316
+ collect_phase_index()
317
+ collect_claimed()
318
+ check_dups()
319
+ check_refs()
320
+ check_orphans()
321
+ check_mdirs()
322
+ check_waves()
238
323
 
239
324
  if (found + 0 == 0) {
240
325
  print "ok=clean"
@@ -243,4 +328,3 @@ END {
243
328
  exit 1
244
329
  }
245
330
  ' "$FILE"
246
- exit $?
@@ -7,7 +7,7 @@
7
7
  # lint=fail + finding=<rule>:<file>[:<line>] lines
8
8
  # lint=justified justify="<trailer line>"
9
9
  # + "finding=<rule>:<file>[:<line>] justified" lines
10
- # rule ∈ cannot-fail|silent-fail|existence-only|deleted-assertion|feature-removal|skip-marker
10
+ # rule ∈ cannot-fail|silent-fail|existence-only|deleted-assertion|feature-removal|skip-marker|driven-by-missing
11
11
  # exit: 0 = clean or justified; 1 = fail; 2 = bad invocation.
12
12
  # Range MUST be three-dot BASE...HEAD; scanned against merge-base(BASE, HEAD).
13
13
  #
@@ -28,17 +28,24 @@
28
28
  # allowlist: still fires, still needs the operator act, only the label
29
29
  # changes (partial source shrink keeps the stricter deleted-assertion);
30
30
  # skip-marker — added .only(/.skip(/it.skip/describe.only/xit(/xdescribe(/
31
- # @pytest.mark.skip/t.Skip() line.
31
+ # @pytest.mark.skip/t.Skip() line;
32
+ # driven-by-missing — CONFIG-GATED, default OFF: when the BASE-side
33
+ # .forge/project.yml carries attribution.driven_by: true, every
34
+ # non-merge commit of merge-base..head must carry a
35
+ # Driven-by: <email> trailer (optional trailing session ref);
36
+ # finding's file slot = the offending commit's short SHA. Absent/
37
+ # false config = rule fully off. Base-side read: a PR cannot
38
+ # disable it for itself.
32
39
  # Justification: a "Lint-Justify: <one line>" trailer in ANY commit message of
33
40
  # merge-base..head (pure git). Lifts b/c/deleted-assertion/feature-removal/
34
- # skip-marker → exit 0 lint=justified. NEVER lifts (a).
41
+ # skip-marker/driven-by-missing → exit 0 lint=justified. NEVER lifts (a).
35
42
  #
36
43
  # Offline by construction: remote-less mktemp fixture repos (NFR-032).
37
44
  #
38
45
  # Usage:
39
46
  # ./.forge/checks/tests/forge-check-lint.test.sh # all cases
40
47
  # ./.forge/checks/tests/forge-check-lint.test.sh cannot-fail # one tag
41
- # Filter tags: cannot-fail silent-fail existence-only deleted-assertion feature-removal skip-marker justify docs-only invocation
48
+ # Filter tags: cannot-fail silent-fail existence-only deleted-assertion feature-removal skip-marker driven-by waiver-boundary justify docs-only invocation
42
49
  #
43
50
  # RED-phase friendly: missing helper → every selected case FAILS, suite exits
44
51
  # non-zero, no abort. Exits 0 iff every selected case passes. Self-cleans.
@@ -566,6 +573,121 @@ if running justify; then
566
573
  check "justify: trailer never saves a PR carrying rule a (exit 1)" "$RC" "1"
567
574
  fi
568
575
 
576
+ # ---------------------------------------------------------------------------
577
+ # driven-by — the config-gated Driven-by trailer rule (hold-waiver patch W2).
578
+ # Default OFF; base-side project.yml attribution.driven_by: true turns it on.
579
+ # ---------------------------------------------------------------------------
580
+ if running driven-by; then
581
+ PROJ_ON='attribution:
582
+ driven_by: true'
583
+
584
+ # OFF (default): no config → a trailer-less commit is clean (zero behavior change)
585
+ R=$(new_repo db-off)
586
+ put "$R" src/app.js 'x'
587
+ pr "$R"
588
+ put "$R" src/app.js 'y'
589
+ run_lint "$R" 'main...pr'
590
+ check "driven-by OFF: no config, trailer-less commit is clean (exit 0)" "$RC" "0"
591
+ check_absent "driven-by OFF: no driven-by finding" "$OUT" "driven-by-missing"
592
+
593
+ # OFF (explicit false): rule stays off
594
+ R=$(new_repo db-false)
595
+ put "$R" .forge/project.yml 'attribution:
596
+ driven_by: false'
597
+ pr "$R"
598
+ put "$R" src/app.js 'y'
599
+ run_lint "$R" 'main...pr'
600
+ check "driven-by OFF: driven_by false stays off (exit 0)" "$RC" "0"
601
+
602
+ # ON: a commit missing the trailer fails, finding names its short SHA
603
+ R=$(new_repo db-on)
604
+ put "$R" .forge/project.yml "$PROJ_ON"
605
+ pr "$R"
606
+ put "$R" src/app.js 'y'
607
+ DB_SHA="$(git -C "$R" rev-parse --short pr)"
608
+ run_lint "$R" 'main...pr'
609
+ check "driven-by ON: missing trailer fails (exit 1)" "$RC" "1"
610
+ check_contains "driven-by ON: verdict" "$OUT" "lint=fail"
611
+ check_contains "driven-by ON: finding names the commit" "$OUT" "finding=driven-by-missing:$DB_SHA"
612
+
613
+ # ON: every commit carrying the trailer is clean (optional session ref allowed)
614
+ R=$(new_repo db-ok)
615
+ put "$R" .forge/project.yml "$PROJ_ON"
616
+ pr "$R"
617
+ put "$R" src/app.js 'y' 'Driven-by: zayne@zayne.co.za (session cse_01X)'
618
+ put "$R" src/app.js 'z' 'Driven-by: zayne@zayne.co.za'
619
+ run_lint "$R" 'main...pr'
620
+ check "driven-by ON: trailered commits are clean (exit 0)" "$RC" "0"
621
+ check "driven-by ON: verdict clean" "$OUT" "lint=clean"
622
+
623
+ # ON: a malformed trailer (no email shape) still fails
624
+ R=$(new_repo db-malformed)
625
+ put "$R" .forge/project.yml "$PROJ_ON"
626
+ pr "$R"
627
+ put "$R" src/app.js 'y' 'Driven-by: somebody'
628
+ run_lint "$R" 'main...pr'
629
+ check "driven-by ON: trailer without an email fails (exit 1)" "$RC" "1"
630
+ check_contains "driven-by ON: malformed finding" "$OUT" "finding=driven-by-missing:"
631
+
632
+ # ON, mixed: one trailered + one bare commit → only the bare one is named
633
+ R=$(new_repo db-mixed)
634
+ put "$R" .forge/project.yml "$PROJ_ON"
635
+ pr "$R"
636
+ put "$R" src/app.js 'y' 'Driven-by: zayne@zayne.co.za'
637
+ put "$R" src/other.js 'w'
638
+ DB_BARE="$(git -C "$R" rev-parse --short pr)"
639
+ DB_GOOD="$(git -C "$R" rev-parse --short pr~1)"
640
+ run_lint "$R" 'main...pr'
641
+ check "driven-by ON mixed: bare commit fails (exit 1)" "$RC" "1"
642
+ check_contains "driven-by ON mixed: bare commit named" "$OUT" "finding=driven-by-missing:$DB_BARE"
643
+ check_absent "driven-by ON mixed: trailered commit not named" "$OUT" "finding=driven-by-missing:$DB_GOOD"
644
+
645
+ # Base-side gate: a PR that deletes the config cannot switch the rule off for itself
646
+ R=$(new_repo db-evade)
647
+ put "$R" .forge/project.yml "$PROJ_ON"
648
+ pr "$R"
649
+ git -C "$R" rm -q .forge/project.yml
650
+ git -C "$R" commit -qm "drop attribution config"
651
+ run_lint "$R" 'main...pr'
652
+ check "driven-by ON: head-side config removal cannot evade (exit 1)" "$RC" "1"
653
+ check_contains "driven-by ON: evasion finding" "$OUT" "finding=driven-by-missing:"
654
+
655
+ # Lint-Justify lifts it (same operator-act channel as the other liftable rules)
656
+ R=$(new_repo db-justify)
657
+ put "$R" .forge/project.yml "$PROJ_ON"
658
+ pr "$R"
659
+ put "$R" src/app.js 'y' 'Lint-Justify: host-authored dependabot commit, attribution n/a'
660
+ run_lint "$R" 'main...pr'
661
+ check "driven-by ON: Lint-Justify lifts (exit 0)" "$RC" "0"
662
+ check_contains "driven-by ON: justified verdict" "$OUT" "lint=justified"
663
+ check_contains "driven-by ON: finding listed as justified" "$OUT" "finding=driven-by-missing:"
664
+ fi
665
+
666
+ # ---------------------------------------------------------------------------
667
+ # waiver-boundary — the hold check's run-waiver binding must have ZERO effect
668
+ # here: the waiver applies to the hold check only, never the lint.
669
+ # ---------------------------------------------------------------------------
670
+ if running waiver-boundary; then
671
+ R=$(new_repo wb)
672
+ put "$R" .forge/hold-config.yml 'version: 1
673
+ operators: []
674
+ irreversible_paths: []
675
+ deploy_paths: []
676
+ gating_tests: []
677
+ waiver:
678
+ workflow: waive-hold.yml
679
+ ref: main'
680
+ put "$R" "$WF" 'jobs: {}'
681
+ pr "$R"
682
+ put "$R" "$WF" 'jobs:
683
+ gate:
684
+ steps:
685
+ - run: exit 0'
686
+ run_lint "$R" 'main...pr'
687
+ check "waiver-boundary: hold waiver binding never lifts a lint finding (exit 1)" "$RC" "1"
688
+ check_contains "waiver-boundary: finding intact" "$OUT" "finding=cannot-fail:$WF"
689
+ fi
690
+
569
691
  # ---------------------------------------------------------------------------
570
692
  # docs-only — acceptance 6: a status on every PR
571
693
  # ---------------------------------------------------------------------------
@@ -25,6 +25,15 @@
25
25
  # suites) (+config deploy_paths, gating_tests)
26
26
  # Waiver: any --approvals login ∈ base-side config operators: → waived. Absent
27
27
  # config / empty operators → NO waiver possible (raise-only safe default).
28
+ # Run-waiver (hold-waiver patch): OPTIONAL base-side `waiver: {workflow, ref}`
29
+ # binding — when a hold fired and no operator approval waives it, a host-queried
30
+ # run of that workflow waives iff ALL of: workflow path matches; run ref ==
31
+ # repo default branch (== configured ref); conclusion == success; display_title
32
+ # carries the PR head SHA. Output: hold=waived waiver_run=<url>. FAIL-CLOSED on
33
+ # every leg (unconfigured / gh missing / API unreachable / no match / wrong ref
34
+ # / config-ref drift / wrong path / non-success / superseded SHA → hold stands);
35
+ # unconfigured behavior is byte-identical to the pre-waiver check (no gh call).
36
+ # gh is env-overridable (FORGE_HOLD_GH) — fixtures stub it and stay offline.
28
37
  #
29
38
  # Builds throwaway git repos in mktemp dirs with NO remote configured anywhere —
30
39
  # offline by construction (NFR-032); any network dependency in the script breaks here.
@@ -32,7 +41,7 @@
32
41
  # Usage:
33
42
  # ./.forge/checks/tests/forge-hold-check.test.sh # all cases
34
43
  # ./.forge/checks/tests/forge-hold-check.test.sh pain6 # one tag
35
- # Filter tags: irreversible structural pain6 waiver self-protect docs-only invocation multi
44
+ # Filter tags: irreversible structural pain6 waiver run-waiver self-protect docs-only invocation multi
36
45
  #
37
46
  # RED-phase friendly (TDD): a missing/non-executable helper does NOT abort the
38
47
  # suite — every selected case FAILS with "(helper missing ...)" as the got-value
@@ -382,6 +391,162 @@ if running multi; then
382
391
  check_contains "multi: protected named" "$OUT" "fact=protected:.github/workflows/deploy.yml"
383
392
  fi
384
393
 
394
+ # ---------------------------------------------------------------------------
395
+ # run-waiver — the Environment-gated waiver run channel: pass-on-match plus a
396
+ # neg-control per fail-closed leg. gh is stubbed offline via FORGE_HOLD_GH.
397
+ # ---------------------------------------------------------------------------
398
+ if running run-waiver; then
399
+ GH_STUB="$ROOT/gh-stub"
400
+ cat > "$GH_STUB" <<'STUB'
401
+ #!/bin/sh
402
+ # Offline gh stand-in for the run-waiver channel. Argv shapes the check uses:
403
+ # gh api repos/{owner}/{repo} --jq .default_branch
404
+ # gh api "repos/{owner}/{repo}/actions/workflows/<wf>/runs?..." --jq <projection>
405
+ # Driven by env: STUB_DEFAULT_BRANCH (empty/unset => API failure, exit 1),
406
+ # STUB_RUNS_FILE (TSV: conclusion, head_branch, path, html_url, display_title;
407
+ # unset/missing => API failure), STUB_LOG (append argv per call when set).
408
+ [ -n "${STUB_LOG:-}" ] && printf '%s\n' "$*" >> "$STUB_LOG"
409
+ case "${2:-}" in
410
+ 'repos/{owner}/{repo}')
411
+ [ -n "${STUB_DEFAULT_BRANCH:-}" ] || exit 1
412
+ printf '%s\n' "$STUB_DEFAULT_BRANCH" ;;
413
+ 'repos/{owner}/{repo}/actions/workflows/'*)
414
+ [ -n "${STUB_RUNS_FILE:-}" ] && [ -f "$STUB_RUNS_FILE" ] || exit 1
415
+ cat "$STUB_RUNS_FILE" ;;
416
+ *) exit 1 ;;
417
+ esac
418
+ STUB
419
+ chmod +x "$GH_STUB"
420
+
421
+ CONFIG_WAIVER='version: 1
422
+ operators: []
423
+ irreversible_paths: []
424
+ deploy_paths: []
425
+ gating_tests: []
426
+ waiver:
427
+ workflow: waive-hold.yml
428
+ ref: main'
429
+
430
+ WURL='https://host.example/actions/runs/101'
431
+ WPATH='.github/workflows/waive-hold.yml'
432
+
433
+ new_waiver_repo() { # name, config → repo whose pr branch carries a migration (hold fires)
434
+ nw_d=$(new_repo "$1")
435
+ put "$nw_d" .forge/hold-config.yml "$2"
436
+ pr "$nw_d"
437
+ put "$nw_d" app/migrations/010_waiver.py 'alter table'
438
+ printf '%s\n' "$nw_d"
439
+ }
440
+
441
+ # pass-on-match: approved success run on the default branch, title carries head SHA
442
+ R=$(new_waiver_repo rw-pass "$CONFIG_WAIVER")
443
+ SHA=$(git -C "$R" rev-parse pr)
444
+ printf 'success\tmain\t%s\t%s\twaive-hold sha=%s\n' "$WPATH" "$WURL" "$SHA" > "$ROOT/runs.pass"
445
+ export FORGE_HOLD_GH="$GH_STUB" STUB_DEFAULT_BRANCH=main STUB_RUNS_FILE="$ROOT/runs.pass"
446
+ run_hold "$R" 'main...pr'
447
+ check "run-waiver: approved matching run waives (exit 0)" "$RC" "0"
448
+ check_contains "run-waiver: verdict annotates the run URL" "$OUT" "hold=waived waiver_run=$WURL"
449
+ check_contains "run-waiver: lists what it waived" "$OUT" "fact=irreversible:app/migrations/010_waiver.py waived"
450
+
451
+ # neg fail-closed leg: UNCONFIGURED — no waiver block: no gh call, and the
452
+ # blocked output is byte-identical to a run with no stub env at all.
453
+ R=$(new_waiver_repo rw-unconf "$CONFIG_OPS")
454
+ export STUB_LOG="$ROOT/gh.unconf.log"
455
+ : > "$STUB_LOG"
456
+ run_hold "$R" 'main...pr'
457
+ UNCONF_OUT="$OUT"
458
+ check "run-waiver neg (unconfigured): hold stands (exit 1)" "$RC" "1"
459
+ check "run-waiver neg (unconfigured): gh never invoked" "$(cat "$STUB_LOG")" ""
460
+ unset FORGE_HOLD_GH STUB_DEFAULT_BRANCH STUB_RUNS_FILE STUB_LOG
461
+ run_hold "$R" 'main...pr'
462
+ check "run-waiver neg (unconfigured): output byte-identical to pre-waiver check" "$OUT" "$UNCONF_OUT"
463
+
464
+ # neg fail-closed leg: gh MISSING (no host CLI at all)
465
+ R=$(new_waiver_repo rw-nogh "$CONFIG_WAIVER")
466
+ export FORGE_HOLD_GH="$ROOT/definitely-no-such-gh"
467
+ run_hold "$R" 'main...pr'
468
+ check "run-waiver neg (gh missing): hold stands (exit 1)" "$RC" "1"
469
+ check_contains "run-waiver neg (gh missing): blocked verdict" "$OUT" "hold=blocked"
470
+
471
+ # neg fail-closed leg: API UNREACHABLE (default-branch query fails)
472
+ export FORGE_HOLD_GH="$GH_STUB" STUB_DEFAULT_BRANCH="" STUB_RUNS_FILE="$ROOT/runs.pass"
473
+ run_hold "$R" 'main...pr'
474
+ check "run-waiver neg (API unreachable): hold stands (exit 1)" "$RC" "1"
475
+
476
+ # neg fail-closed leg: NO MATCHING RUN (API reachable, zero runs)
477
+ : > "$ROOT/runs.empty"
478
+ export STUB_DEFAULT_BRANCH=main STUB_RUNS_FILE="$ROOT/runs.empty"
479
+ run_hold "$R" 'main...pr'
480
+ check "run-waiver neg (no matching run): hold stands (exit 1)" "$RC" "1"
481
+
482
+ # neg fail-closed leg: WRONG REF — the only success run rode a PR-branch copy
483
+ # of the workflow (gate strippable), not the default branch.
484
+ SHA=$(git -C "$R" rev-parse pr)
485
+ printf 'success\tpr\t%s\t%s\twaive-hold sha=%s\n' "$WPATH" "$WURL" "$SHA" > "$ROOT/runs.wrongref"
486
+ export STUB_RUNS_FILE="$ROOT/runs.wrongref"
487
+ run_hold "$R" 'main...pr'
488
+ check "run-waiver neg (wrong ref): PR-branch run cannot waive (exit 1)" "$RC" "1"
489
+
490
+ # neg fail-closed leg: CONFIG-REF DRIFT — configured ref != repo default branch
491
+ R=$(new_waiver_repo rw-drift 'version: 1
492
+ operators: []
493
+ irreversible_paths: []
494
+ deploy_paths: []
495
+ gating_tests: []
496
+ waiver:
497
+ workflow: waive-hold.yml
498
+ ref: develop')
499
+ SHA=$(git -C "$R" rev-parse pr)
500
+ printf 'success\tmain\t%s\t%s\twaive-hold sha=%s\n' "$WPATH" "$WURL" "$SHA" > "$ROOT/runs.drift"
501
+ export STUB_RUNS_FILE="$ROOT/runs.drift"
502
+ run_hold "$R" 'main...pr'
503
+ check "run-waiver neg (config-ref drift): waiver.ref != default branch (exit 1)" "$RC" "1"
504
+
505
+ # neg fail-closed leg: SUPERSEDED SHA — a new push after the approved run;
506
+ # the run's title carries the OLD head SHA, so it no longer matches.
507
+ R=$(new_waiver_repo rw-stale "$CONFIG_WAIVER")
508
+ OLD_SHA=$(git -C "$R" rev-parse pr)
509
+ printf 'success\tmain\t%s\t%s\twaive-hold sha=%s\n' "$WPATH" "$WURL" "$OLD_SHA" > "$ROOT/runs.stale"
510
+ put "$R" src/followup.js 'pushed after the waiver run'
511
+ export STUB_RUNS_FILE="$ROOT/runs.stale"
512
+ run_hold "$R" 'main...pr'
513
+ check "run-waiver neg (superseded SHA): new push invalidates the waiver (exit 1)" "$RC" "1"
514
+ check_contains "run-waiver neg (superseded SHA): blocked verdict" "$OUT" "hold=blocked"
515
+
516
+ # neg fail-closed leg: WRONG WORKFLOW PATH — a same-shaped run of another workflow
517
+ R=$(new_waiver_repo rw-path "$CONFIG_WAIVER")
518
+ SHA=$(git -C "$R" rev-parse pr)
519
+ printf 'success\tmain\t.github/workflows/other.yml\t%s\twaive-hold sha=%s\n' "$WURL" "$SHA" > "$ROOT/runs.path"
520
+ export STUB_RUNS_FILE="$ROOT/runs.path"
521
+ run_hold "$R" 'main...pr'
522
+ check "run-waiver neg (wrong workflow path): hold stands (exit 1)" "$RC" "1"
523
+
524
+ # neg fail-closed leg: NON-SUCCESS CONCLUSION — pending/rejected run never waives
525
+ printf 'failure\tmain\t%s\t%s\twaive-hold sha=%s\n' "$WPATH" "$WURL" "$SHA" > "$ROOT/runs.fail"
526
+ export STUB_RUNS_FILE="$ROOT/runs.fail"
527
+ run_hold "$R" 'main...pr'
528
+ check "run-waiver neg (non-success run): hold stands (exit 1)" "$RC" "1"
529
+
530
+ # channel precedence: an operator PR approval still waives on its own — the
531
+ # run-waiver channel is additive, not a replacement.
532
+ R=$(new_waiver_repo rw-op 'version: 1
533
+ operators:
534
+ - zayne
535
+ irreversible_paths: []
536
+ deploy_paths: []
537
+ gating_tests: []
538
+ waiver:
539
+ workflow: waive-hold.yml
540
+ ref: main')
541
+ AF=$(approvals rwop zayne)
542
+ export STUB_DEFAULT_BRANCH="" STUB_RUNS_FILE=""
543
+ run_hold "$R" 'main...pr' --approvals "$AF"
544
+ check "run-waiver: operator approval still waives independently (exit 0)" "$RC" "0"
545
+ check_contains "run-waiver: operator verdict unchanged" "$OUT" "hold=waived waived_by=zayne"
546
+
547
+ unset FORGE_HOLD_GH STUB_DEFAULT_BRANCH STUB_RUNS_FILE STUB_LOG
548
+ fi
549
+
385
550
  # ---------------------------------------------------------------------------
386
551
  # invocation — exit 2 on bad usage, never a false verdict
387
552
  # ---------------------------------------------------------------------------