forgedock 1.17.0 → 1.18.0

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.
Files changed (60) hide show
  1. package/bin/engine/outcome-codes.mjs +24 -2
  2. package/bin/engine/phases.mjs +153 -102
  3. package/bin/engine/runlog.mjs +7 -0
  4. package/bin/engine-cli.mjs +5 -0
  5. package/bin/engine.mjs +84 -10
  6. package/bin/forgedock.mjs +1 -1
  7. package/bin/hooks/pre-tool-use.mjs +65 -32
  8. package/bin/runner.mjs +63 -4
  9. package/bin/stats/github-source.mjs +89 -18
  10. package/bin/stats/stats.test.mjs +89 -1
  11. package/commands/cleanup.md +10 -3
  12. package/commands/deploy-pr.md +64 -0
  13. package/commands/fix-ci.md +20 -6
  14. package/commands/milestone.md +34 -17
  15. package/commands/orchestrate/phase-4-execution.md +11 -4
  16. package/commands/review-pr-staging.md +142 -519
  17. package/commands/review-pr.md +490 -3128
  18. package/commands/test-gate.md +25 -10
  19. package/commands/work-on/build/architect.md +13 -1
  20. package/commands/work-on/build/implement.md +2 -1
  21. package/commands/work-on/build/validate.md +2 -0
  22. package/commands/work-on/build.md +107 -86
  23. package/commands/work-on/close.md +50 -15
  24. package/commands/work-on/decompose.md +72 -34
  25. package/commands/work-on/investigate.md +9 -12
  26. package/commands/work-on/remediate.md +27 -13
  27. package/commands/work-on/review.md +5 -3
  28. package/commands/work-on.md +1 -2
  29. package/docs/CONFIG.md +44 -25
  30. package/docs/OUTCOME-CODES.md +7 -2
  31. package/docs/RELEASE-NOTES.md +12 -0
  32. package/docs/STATS.md +2 -1
  33. package/docs/TELEMETRY.md +2 -2
  34. package/docs/WORK-ON-RUNTIME.md +6 -0
  35. package/docs/spec/forge-protocol-v1.md +2 -2
  36. package/package.json +1 -1
  37. package/packages/protocol/src/phases.js +5 -5
  38. package/scripts/cap-findings.sh +1 -1
  39. package/scripts/cap-findings.test.sh +1 -1
  40. package/scripts/check-spec-bash.sh +5 -1
  41. package/scripts/check-spec-bash.test.sh +20 -0
  42. package/scripts/close-parent-c3.test.sh +69 -0
  43. package/scripts/diff-size.sh +93 -4
  44. package/scripts/diff-size.test.sh +59 -2
  45. package/scripts/forge-root.test.sh +13 -11
  46. package/scripts/publish-staging-sync.test.sh +48 -0
  47. package/scripts/resolve-deploy-gate.sh +32 -6
  48. package/scripts/resolve-deploy-gate.test.sh +42 -1
  49. package/scripts/review-finalize.sh +1427 -0
  50. package/scripts/review-finalize.test.sh +833 -0
  51. package/scripts/review-pr-delta-wiring.test.sh +19 -72
  52. package/scripts/review-pr-domain-map.test.sh +18 -13
  53. package/scripts/review-pr-gate2.test.sh +9 -65
  54. package/scripts/review-pr-staging-scope.test.sh +14 -98
  55. package/scripts/review-prepare.sh +48 -12
  56. package/scripts/review-prepare.test.sh +19 -1
  57. package/scripts/review-roster.sh +16 -2
  58. package/scripts/review-roster.test.sh +22 -0
  59. package/scripts/trusted-comments.test.sh +9 -2
  60. package/scripts/wait-ci-green.test.sh +3 -3
@@ -137,10 +137,67 @@ check "test name patterns" "$OUT" test_lines 30
137
137
  reset; gen 20 > keep.py; gen 900 > package-lock.json; git add -A
138
138
  OUT="$(run --threshold 1000)"; check "excluded not in new_lines" "$OUT" new_lines 20
139
139
 
140
- # existing key order is unchanged: cohesion keys sit between over= and top=
140
+ # existing key order is unchanged: cohesion keys sit between over= and top=; review keys follow cohesive=
141
141
  reset; gen 3 > s.txt; git add -A
142
142
  KEYS="$(run | sed -n 's/^\([a-z_]*\)=.*/\1/p' | tr '\n' ' ')"
143
- [ "$KEYS" = "diff_lines excluded_lines threshold over new_lines test_lines cohesive_lines cohesive top " ] && ok || bad "key order ($KEYS)"
143
+ [ "$KEYS" = "diff_lines excluded_lines threshold over new_lines test_lines cohesive_lines cohesive ws_lines review_weight modules top " ] && ok || bad "key order ($KEYS)"
144
+
145
+ # review_weight (forge#3862): a pure re-indent counts about 0, whatever its raw size
146
+ reset; mkdir -p svc; awk 'BEGIN{for(i=0;i<120;i++) print "if x:\n y" i}' > svc/h.py; git add -A; git commit -qm h; git push -q origin feat:staging 2>/dev/null
147
+ awk 'BEGIN{for(i=0;i<120;i++) print "if x:\n y" i}' > svc/h.py; git add -A
148
+ OUT="$(run)"; check "re-indent raw diff_lines" "$OUT" diff_lines 240; check "re-indent ws_lines" "$OUT" ws_lines 0
149
+ check "re-indent review_weight" "$OUT" review_weight 0; check "re-indent modules" "$OUT" modules 0
150
+ git reset -q --hard origin/staging
151
+
152
+ # tests are weighted (default 50%, configurable); ws_lines is unweighted
153
+ reset; mkdir -p src tests; gen 100 > src/m.py; gen 200 > tests/test_m.py; git add -A
154
+ OUT="$(run)"; check "weight ws_lines" "$OUT" ws_lines 300; check "weight default 50" "$OUT" review_weight 200
155
+ OUT="$(run --test-weight 25)"; check "weight 25" "$OUT" review_weight 150
156
+ OUT="$(run --test-weight 0)"; check "weight 0" "$OUT" review_weight 100
157
+ OUT="$(run --test-weight 100)"; check "weight 100" "$OUT" review_weight 300
158
+ reset; mkdir -p src; gen 3 > src/o.py; mkdir -p tests; gen 1 > tests/test_o.py; git add -A
159
+ OUT="$(run)"; check "weight rounds up" "$OUT" review_weight 4
160
+
161
+ # generated and vendored code is excluded from review_weight and modules
162
+ reset; gen 10 > keep.py; mkdir -p vendor generated; gen 900 > vendor/v.js; gen 900 > generated/g.js; gen 900 > package-lock.json; git add -A
163
+ OUT="$(run)"; check "excluded ws_lines" "$OUT" ws_lines 10; check "excluded review_weight" "$OUT" review_weight 10; check "excluded modules" "$OUT" modules 1
164
+
165
+ # module count: distinct directories at --module-depth
166
+ reset; mkdir -p a/b/c a/b/d a/e f; gen 5 > a/b/c/1.py; gen 5 > a/b/d/2.py; gen 5 > a/e/3.py; gen 5 > f/4.py; gen 5 > root.py; git add -A
167
+ OUT="$(run)"; check "modules depth 2" "$OUT" modules 4
168
+ OUT="$(run --module-depth 1)"; check "modules depth 1" "$OUT" modules 3
169
+ OUT="$(run --module-depth 3)"; check "modules depth 3" "$OUT" modules 5
170
+
171
+ # scope diff: in-plan, out-of-plan, prefix entries, formatting noise, no baseline, overrun
172
+ reset; mkdir -p src tests docs; gen 10 > src/a.py; gen 10 > src/b.py; gen 10 > tests/test_a.py; gen 10 > docs/x.md; git add -A
173
+ printf '%s\n' '# plan' '- `src/a.py`' '* ./src/b.py' '' 'tests/' > "$TMP/plan"
174
+ OUT="$(run --plan-files "$TMP/plan")"
175
+ check "scope out_of_plan" "$OUT" scope out_of_plan; check "scope count" "$OUT" out_of_plan_count 1
176
+ check "scope plan_files" "$OUT" plan_files 3; check "scope path" "$OUT" out_of_plan docs/x.md
177
+ printf '%s\n' src/a.py src/b.py tests/test_a.py docs/x.md > "$TMP/plan"
178
+ OUT="$(run --plan-files "$TMP/plan")"; check "scope ok" "$OUT" scope ok; check "scope ok count" "$OUT" out_of_plan_count 0
179
+ printf '%s\n' 'src/a.py' > "$TMP/plan"
180
+ OUT="$(run --plan-files "$TMP/plan")"; check "scope 3 out" "$OUT" out_of_plan_count 3
181
+ : > "$TMP/plan"; OUT="$(run --plan-files "$TMP/plan")"; check "empty plan is no_baseline" "$OUT" scope no_baseline; check "no_baseline count" "$OUT" out_of_plan_count 0
182
+ OUT="$(run)"; [ -z "$(val "$OUT" scope)" ] && ok || bad "scope keys absent without plan args"
183
+ # excluded files are never out-of-plan; a path is data (glob chars, spaces) not a pattern
184
+ reset; mkdir -p vendor; gen 5 > vendor/z.js; gen 5 > 'we ird[1].txt'; git add -A
185
+ printf '%s\n' 'src/a.py' > "$TMP/plan"
186
+ OUT="$(run --plan-files "$TMP/plan")"; check "excluded not out-of-plan" "$OUT" out_of_plan_count 1; check "path with space and glob quoted" "$OUT" out_of_plan 'we ird[1].txt'
187
+ printf '%s\n' '*.txt' > "$TMP/plan"; OUT="$(run --plan-files "$TMP/plan")"; check "plan entry is not a glob" "$OUT" out_of_plan_count 1
188
+ # overrun against the plan estimate (ws-insensitive actual)
189
+ reset; gen 500 > big.py; git add -A
190
+ OUT="$(run --estimate 200)"; check "overrun 2.5x" "$OUT" overrun true; check "overrun estimate" "$OUT" estimate 200; check "overrun no plan file is no_baseline" "$OUT" scope no_baseline
191
+ OUT="$(run --estimate 250)"; check "at 2x not overrun" "$OUT" overrun false
192
+ OUT="$(run --estimate 200 --overrun-pct 300)"; check "custom overrun pct" "$OUT" overrun false
193
+ OUT="$(run --estimate 0)"; check "estimate 0 is no estimate" "$OUT" overrun false
194
+ # unreadable plan fails closed; bad numeric args are usage errors
195
+ OUT="$(bash "$S" --repo-path "$TMP/w" --base staging --plan-files "$TMP/none" 2>/dev/null)"; rc=$?
196
+ { [ "$rc" = 2 ] && [ -z "$OUT" ]; } && ok || bad "unreadable plan (rc=$rc)"
197
+ for bad_arg in "--test-weight x" "--module-depth 0" "--overrun-pct -1" "--estimate x"; do
198
+ OUT="$(bash "$S" --repo-path "$TMP/w" --base staging $bad_arg 2>/dev/null)"; rc=$?
199
+ { [ "$rc" = 2 ] && [ -z "$OUT" ]; } && ok || bad "bad arg $bad_arg (rc=$rc)"
200
+ done
144
201
 
145
202
  # fail closed: bad repo path, bad base, bad threshold, unknown arg, missing base
146
203
  OUT="$(bash "$S" --repo-path "$TMP/nope" --base staging 2>/dev/null)"; rc=$?
@@ -95,8 +95,8 @@ fi
95
95
  # forge#3758 (hgate is a fixture of its own: the loop runs once per shell and h14 is reused below with other versions): deploy-gate script resolves on a plugin-only install (cache only, no symlink, no FORGE_* env).
96
96
  # The gate-script line is taken verbatim from the spec so the test exercises exactly what ships.
97
97
  mkscripts "$T/hgate/.claude/plugins/cache/forgedock/forgedock/1.0.0"; : > "$T/hgate/.claude/plugins/cache/forgedock/forgedock/1.0.0/scripts/resolve-deploy-gate.sh"
98
- # review-pr.md resolves the gate script through the forge_resolve stub (#3780): the locator and the call line are taken verbatim from the spec.
99
- sed 's/^[[:space:]]*//' "$ROOT/commands/review-pr.md" | grep '^GATE_SCRIPT=\$(forge_resolve resolve-deploy-gate.sh)$' | sort -u > "$T/gs"
98
+ # review-finalize.sh (the review-pr.md Phase 8 gate) resolves the gate script through forge_resolve (#3780): the locator and the call line are taken verbatim from the script.
99
+ sed 's/^[[:space:]]*//' "$ROOT/scripts/review-finalize.sh" | grep '^GATE_SCRIPT=\$(forge_resolve resolve-deploy-gate.sh)$' | sort -u > "$T/gs"
100
100
  expect "gate-script resolver line is identical across sites" 1 "$(wc -l < "$T/gs" | tr -d ' ')"
101
101
  { sed 's/^[[:space:]]*//' "$ROOT/commands/review-pr.md" | grep -m1 '^type forge_resolve >/dev/null'; cat "$T/gs"; } > "$T/gs_full"
102
102
  gate_run() { ( cd "$T/consumer" && env -i PATH="$PATH" HOME="$1" "$SH" -c "$(cat "$T/gs_full"); printf %s \"\$GATE_SCRIPT\"" ); }
@@ -271,11 +271,13 @@ extract_helper_block() { # file var -> block on stdout
271
271
  extract_helper_fn() { # file -> function on stdout
272
272
  awk '/^resolve_extract_affected_files\(\) \{/ {inb=1} inb {print} inb && /^\}$/ {exit}' "$1"
273
273
  }
274
- # review-pr.md resolves its helpers through the forge_resolve stub (#3780): the pinned locator plus the call line, taken from the spec.
274
+ # review-pr.md resolves its helpers through the forge_resolve stub (#3780); since #3841 the classify, cap, milestone, priority and dedup helpers are resolved inside
275
+ # scripts/review-finalize.sh and the code-index helper inside scripts/review-prepare.sh: the pinned locator plus the call line, taken from those scripts.
275
276
  stub_block() { # file var -> locator + the first `var=$(forge_resolve ...)` line, indentation stripped
276
277
  sed 's/^[[:space:]]*//' "$1" | grep -m1 '^type forge_resolve >/dev/null'; sed 's/^[[:space:]]*//' "$1" | grep -m1 "^$2=\$(forge_resolve "
277
278
  }
278
- HR_CLASSIFY="$ROOT/commands/review-pr.md"
279
+ HR_CLASSIFY="$ROOT/scripts/review-finalize.sh"
280
+ HR_PREPARE="$ROOT/scripts/review-prepare.sh"
279
281
  HR_AMP="$ROOT/commands/orchestrate/phase-4-execution.md"
280
282
  HR_EX3="$ROOT/commands/orchestrate/phase-3-dependency.md"
281
283
  stub_block "$HR_CLASSIFY" CLASSIFY_SCRIPT > "$T/hr_classify.sh"
@@ -285,7 +287,7 @@ extract_helper_fn "$HR_AMP" > "$T/hr_ex4.sh"
285
287
  extract_helper_fn "$HR_EX3" > "$T/hr_ex3.sh"
286
288
  expect "CLASSIFY_SCRIPT resolver stub found" 1 "$(grep -c '^CLASSIFY_SCRIPT=\$(forge_resolve classify-finding.sh)$' "$T/hr_classify.sh")"
287
289
  expect "CAP_SCRIPT resolver stub found" 1 "$(grep -c '^CAP_SCRIPT=\$(forge_resolve cap-findings.sh)$' "$T/hr_cap.sh")"
288
- expect "CAP_SCRIPT resolver stub found in review-pr-staging.md" 1 "$(sed 's/^[[:space:]]*//' "$ROOT/commands/review-pr-staging.md" | grep -c '^CAP_SCRIPT=\$(forge_resolve cap-findings.sh)$')"
290
+ expect "review-pr-staging.md no longer carries a CAP_SCRIPT resolver stub (review-finalize.sh owns the cap helper, #3841)" 0 "$(sed 's/^[[:space:]]*//' "$ROOT/commands/review-pr-staging.md" | grep -c '^CAP_SCRIPT=\$(forge_resolve cap-findings.sh)$')"
289
291
  expect "AMP_SCRIPT resolver block found" 1 "$(grep -c '^AMP_SCRIPT=""' "$T/hr_amp.sh")"
290
292
  expect "extract-affected-files resolver found in phase-4" 1 "$(grep -c 'resolve_extract_affected_files() {' "$T/hr_ex4.sh")"
291
293
  expect "extract-affected-files resolver found in phase-3" 1 "$(grep -c 'resolve_extract_affected_files() {' "$T/hr_ex3.sh")"
@@ -337,7 +339,7 @@ hr_check_stub() { # label file var script: the spec calls the script through the
337
339
  }
338
340
  hr_check_stub review-pr "$HR_CLASSIFY" DERIVE_MILESTONE_SCRIPT derive-finding-milestone.sh
339
341
  hr_check_stub review-pr "$HR_CLASSIFY" PRIORITY_SCRIPT severity-to-priority.sh
340
- hr_check_stub review-pr "$HR_CLASSIFY" CODE_INDEX_SCRIPT code-index.sh
342
+ hr_check_stub review-prepare "$HR_PREPARE" CODE_INDEX_SCRIPT code-index.sh
341
343
  hr_check_stub review-pr "$HR_CLASSIFY" DEDUP_SCRIPT issue-dedup.sh
342
344
  hr_check_block issue "$ROOT/commands/issue.md" DEDUP_SCRIPT issue-dedup.sh
343
345
  stub_block "$HR_CLASSIFY" DERIVE_MILESTONE_SCRIPT > "$T/hr_dm.sh"
@@ -414,9 +416,9 @@ FR="$ROOT/scripts/lib/forge-resolve.sh"; FB="$ROOT/scripts/lib/fence-bootstrap.s
414
416
  [ -f "$FR" ] && [ -f "$FB" ] && ok || bad "scripts/lib/forge-resolve.sh and scripts/lib/fence-bootstrap.sh must exist"
415
417
  sed 's/^[[:space:]]*//' "$STG_SPEC" | grep '^type forge_resolve >/dev/null' | sort | uniq -c > "$T/loc_uniq"
416
418
  [ "$(wc -l < "$T/loc_uniq" | tr -d ' ')" = 1 ] && ok || bad "forge_resolve locator line must be byte-identical across every stub in review-pr-staging.md"
417
- [ "$(awk '{print $1}' "$T/loc_uniq")" -ge 30 ] && ok || bad "expected a locator on every bootstrap (21) and resolver (11) stub"
419
+ [ "$(awk '{print $1}' "$T/loc_uniq")" -ge 25 ] && ok || bad "expected a locator on every bootstrap (21) and resolver (5) stub"
418
420
  grep -qE 'PWD|\$\(pwd\)|REPO_PATH' "$T/locator.sh" && bad "stub locator must not consult the cwd or repo under review" || ok
419
- [ "$(grep -c '# FENCE_BOOTSTRAP$' "$STG_SPEC")" = 22 ] && ok || bad "expected 22 fence bootstrap stubs"
421
+ [ "$(grep -c '# FENCE_BOOTSTRAP$' "$STG_SPEC")" = 21 ] && ok || bad "expected 21 fence bootstrap stubs"
420
422
  [ "$(grep '# FENCE_BOOTSTRAP$' "$STG_SPEC" | sort -u | wc -l | tr -d ' ')" = 1 ] && ok || bad "fence bootstrap stubs must be byte-identical"
421
423
  grep '# FENCE_BOOTSTRAP$' "$STG_SPEC" | sed 1q | grep -qF 'exit 1' && ok || bad "fence bootstrap stub must fail closed (exit 1) when the helper is unresolved"
422
424
  # the spec carries none of the old inlined blocks
@@ -424,11 +426,11 @@ grep -qE '^[[:space:]]*(TRUSTED_SCRIPT|[A-Z_]+_SCRIPT)=""$|_tc="\$\(printf' "$ST
424
426
  # no $PWD / $(pwd) tier in the helper (comments aside)
425
427
  grep -nE '\$PWD|\$\(pwd\)|\$\{PWD' "$FR" | grep -vE '^[0-9]+:[[:space:]]*#' | grep -q . && bad "forge-resolve.sh must have no PWD tier" || ok
426
428
 
427
- # Guard (#3779): remediate.md carries the same byte-identical locator stub (7 fences: 5 gate sites, trusted-comments, CI gate) and none of the old inlined resolvers.
429
+ # Guard (#3779): remediate.md carries the same byte-identical locator stub (9 fences: M4 ancestry, merged-first transition-label (the merge-branch one reuses the CI-gate fence locator), 5 gate sites, trusted-comments, CI gate) and none of the old inlined resolvers.
428
430
  REM_SPEC="$ROOT/commands/work-on/remediate.md"
429
431
  sed 's/^[[:space:]]*//' "$REM_SPEC" | grep '^type forge_resolve >/dev/null' | sort | uniq -c > "$T/rem_uniq"
430
432
  [ "$(wc -l < "$T/rem_uniq" | tr -d ' ')" = 1 ] && ok || bad "remediate.md locator lines must be byte-identical"
431
- expect "remediate.md locator count (one per fence that resolves a script)" 7 "$(awk '{print $1}' "$T/rem_uniq")"
433
+ expect "remediate.md locator count (one per fence that resolves a script)" 9 "$(awk '{print $1}' "$T/rem_uniq")"
432
434
  [ "$(sed 's/^[[:space:]]*//' "$REM_SPEC" | grep '^type forge_resolve >/dev/null' | sed 1q)" = "$(cat "$T/locator.sh")" ] && ok || bad "remediate.md locator differs from the pinned review-pr-staging.md locator"
433
435
  expect "remediate.md inlined FORGE_ROOT bootstrap copies" 0 "$(grep -c '# FORGE_ROOT bootstrap' "$REM_SPEC" || true)"
434
436
  grep -qE '^[[:space:]]*(TRUSTED_SCRIPT|[A-Z_]+_SCRIPT)=""|_tc="\$\(printf|^[[:space:]]*GATE_SCRIPT=""' "$REM_SPEC" && bad "remediate.md still carries an inlined resolver block" || ok
@@ -441,7 +443,7 @@ expect "remediate.md: forge_resolve calls without a preceding locator in the sam
441
443
  RP_SPEC="$ROOT/commands/review-pr.md"
442
444
  sed 's/^[[:space:]]*//' "$RP_SPEC" | grep '^type forge_resolve >/dev/null' | sort | uniq -c > "$T/rp_uniq"
443
445
  [ "$(wc -l < "$T/rp_uniq" | tr -d ' ')" = 1 ] && ok || bad "review-pr.md locator lines must be byte-identical"
444
- [ "$(awk '{print $1}' "$T/rp_uniq")" -ge 15 ] && ok || bad "expected a locator in every review-pr.md fence that resolves a script (>=15)"
446
+ [ "$(awk '{print $1}' "$T/rp_uniq")" -ge 10 ] && ok || bad "expected a locator in every review-pr.md fence that resolves a script (>=10; the classify/file/prepare helpers resolve inside the review scripts since #3841)"
445
447
  [ "$(sed 's/^[[:space:]]*//' "$RP_SPEC" | grep '^type forge_resolve >/dev/null' | sed 1q)" = "$(cat "$T/locator.sh")" ] && ok || bad "review-pr.md locator differs from the pinned review-pr-staging.md locator"
446
448
  expect "review-pr.md inlined FORGE_ROOT bootstrap copies" 0 "$(grep -c 'FORGE_ROOT bootstrap' "$RP_SPEC" || true)"
447
449
  # (SCOPE_SCRIPT="" is a sibling path derived from the resolved trusted script's own directory, not a resolver accumulator)
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env bash
2
+ # publish-staging-sync.test.sh — guards the publish.yml "Sync version to staging" step (forge#3695)
3
+ # and the deploy-pr Phase 2A.5 version-sync pre-flight. Static assertions, no network.
4
+ # Usage: bash scripts/publish-staging-sync.test.sh
5
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
6
+ # SPDX-License-Identifier: AGPL-3.0-or-later
7
+
8
+ set -uo pipefail
9
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
10
+ ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
11
+ PUB="$ROOT/.github/workflows/publish.yml"
12
+ DEP="$ROOT/commands/deploy-pr.md"
13
+
14
+ PASS=0; FAILN=0
15
+ ok() { PASS=$((PASS+1)); echo "ok - $1"; }
16
+ bad() { FAILN=$((FAILN+1)); echo "FAIL - $1"; }
17
+ has() { if grep -qF -e "$2" "$3"; then ok "$1"; else bad "$1 (missing '$2')"; fi; }
18
+
19
+ # Extract the sync step block (from its name line to the next step's name line).
20
+ STEP=$(awk '/- name: Sync version to staging/{f=1;print;next} f&&/^ - name: /{exit} f{print}' "$PUB")
21
+ [ -n "$STEP" ] && ok "publish.yml has the Sync version to staging step" || bad "publish.yml has the Sync version to staging step"
22
+
23
+ L_PUSH=$(grep -n -- '- name: Push version bump' "$PUB" | head -1 | cut -d: -f1)
24
+ L_SYNC=$(grep -n -- '- name: Sync version to staging' "$PUB" | head -1 | cut -d: -f1)
25
+ L_REL=$(grep -n -- '- name: Create GitHub Release' "$PUB" | head -1 | cut -d: -f1)
26
+ if [ -n "$L_PUSH" ] && [ -n "$L_SYNC" ] && [ -n "$L_REL" ] && [ "$L_PUSH" -lt "$L_SYNC" ] && [ "$L_SYNC" -lt "$L_REL" ]; then
27
+ ok "sync step sits between Push version bump and Create GitHub Release"
28
+ else bad "sync step sits between Push version bump and Create GitHub Release"; fi
29
+
30
+ stepHas() { if printf '%s\n' "$STEP" | grep -qF -e "$2"; then ok "$1"; else bad "$1 (missing '$2')"; fi; }
31
+ stepHas "step fetches origin/staging" "origin/staging"
32
+ stepHas "step pushes HEAD to staging" "git push origin HEAD:staging"
33
+ stepHas "step is non-fatal" "continue-on-error: true"
34
+ stepHas "step is guarded by SKIP_PUBLISH" "env.SKIP_PUBLISH != 'true'"
35
+ stepHas "commit carries [skip ci]" "[skip ci]"
36
+ stepHas "only version files are staged" "git add package.json .claude-plugin/plugin.json"
37
+ stepHas "failure emits a warning" "::warning::"
38
+ if printf '%s\n' "$STEP" | grep -qE 'git (merge|pull)'; then bad "step must not merge main (version-only commit)"; else ok "step does not merge main"; fi
39
+
40
+ has "deploy-pr has the 2A.5 pre-flight" "### 2A.5: Version Sync Pre-flight" "$DEP"
41
+ has "deploy-pr pre-flight compares package.json" "package.json" "$DEP"
42
+
43
+ if git -C "$ROOT" rev-parse --verify -q origin/main >/dev/null 2>&1; then
44
+ if git -C "$ROOT" diff --quiet origin/main -- .github/workflows/version-guard.yml; then ok "version-guard.yml unchanged"; else bad "version-guard.yml unchanged"; fi
45
+ fi
46
+
47
+ echo "passed=$PASS failed=$FAILN"
48
+ [ "$FAILN" -eq 0 ]
@@ -11,7 +11,9 @@
11
11
  # A base is the deploy gate when it is empty/`null`, the literal `main` (hard
12
12
  # floor), the repository's GitHub default branch (`default_branch`), or the
13
13
  # `branches.default` of the default branch's COMMITTED forge.yaml
14
- # (`origin/<default>:forge.yaml`, falling back to the contents API) or of an
14
+ # (`refs/remotes/origin/<default>:forge.yaml`, falling back to the contents API;
15
+ # if neither source is readable the result is `true`, never `false`; a source that
16
+ # cleanly reports forge.yaml absent is "no config" and falls through) or of an
15
17
  # explicit trusted config (FORGE_TRUSTED_CONFIG, orchestrator-supplied).
16
18
  #
17
19
  # It NEVER reads forge.yaml from $PWD: the PR worktree is author-controlled, so
@@ -34,13 +36,37 @@ GH_DEFAULT=$(gh api "repos/${REPO}" --jq .default_branch 2>/dev/null || echo "")
34
36
 
35
37
  _cfg_default() { yq '.branches.default // ""' 2>/dev/null || true; }
36
38
 
39
+ # Parse stdin's branches.default. Returns non-zero when yq is missing or the
40
+ # parse fails, so "config unreadable" is distinguishable from "no branches.default".
41
+ _cfg_parse() { command -v yq >/dev/null 2>&1 && yq '.branches.default // ""' 2>/dev/null; }
42
+
37
43
  CFG_DEFAULT=""
38
- # Committed default-branch config only (a ref, never the working tree).
39
- CFG_DEFAULT=$(git show "origin/${GH_DEFAULT}:forge.yaml" 2>/dev/null | _cfg_default)
40
- if [ -z "$CFG_DEFAULT" ]; then
41
- CFG_DEFAULT=$(gh api "repos/${REPO}/contents/forge.yaml?ref=${GH_DEFAULT}" --jq .content 2>/dev/null \
42
- | base64 -d 2>/dev/null | _cfg_default)
44
+ READ_OK=""
45
+ # Committed default-branch config only (a ref, never the working tree). The
46
+ # fully-qualified ref cannot be shadowed by a local tag/branch named origin/<default>.
47
+ # A present ref with no forge.yaml at that path is a clean "no config" read (READ_OK,
48
+ # empty default); an unfetched ref, missing yq or parse error stays unreadable.
49
+ CFG_REF="refs/remotes/origin/${GH_DEFAULT}"
50
+ if git rev-parse -q --verify "${CFG_REF}^{commit}" >/dev/null 2>&1; then
51
+ if git cat-file -e "${CFG_REF}:forge.yaml" 2>/dev/null; then
52
+ CFG_RAW=$(git show "${CFG_REF}:forge.yaml" 2>/dev/null) && [ -n "$CFG_RAW" ] \
53
+ && CFG_DEFAULT=$(printf '%s\n' "$CFG_RAW" | _cfg_parse) && READ_OK=1
54
+ else
55
+ READ_OK=1
56
+ fi
57
+ fi
58
+ if [ -z "$READ_OK" ]; then
59
+ CFG_DEFAULT=""
60
+ API_OUT=$(gh api "repos/${REPO}/contents/forge.yaml?ref=${GH_DEFAULT}" --jq .content 2>&1); API_RC=$?
61
+ if [ "$API_RC" -eq 0 ]; then
62
+ CFG_RAW=$(printf '%s' "$API_OUT" | base64 -d 2>/dev/null) && [ -n "$CFG_RAW" ] \
63
+ && CFG_DEFAULT=$(printf '%s\n' "$CFG_RAW" | _cfg_parse) && READ_OK=1
64
+ elif printf '%s' "$API_OUT" | grep -q 'HTTP 404'; then
65
+ READ_OK=1 # API says the file does not exist: clean "no config", not a read failure
66
+ fi
43
67
  fi
68
+ # Config unreadable from every source (no yq, ref unfetched, API failing): fail closed.
69
+ [ -n "$READ_OK" ] || emit true
44
70
  [ -n "$CFG_DEFAULT" ] && [ "$CFG_DEFAULT" != "null" ] && [ "$BASE" = "$CFG_DEFAULT" ] && emit true
45
71
 
46
72
  if [ -n "${FORGE_TRUSTED_CONFIG:-}" ] && [ -f "$FORGE_TRUSTED_CONFIG" ]; then
@@ -9,7 +9,7 @@ TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
9
9
  ok() { PASS=$((PASS+1)); }
10
10
  bad() { FAILN=$((FAILN+1)); echo "FAIL: $1"; }
11
11
 
12
- command -v yq >/dev/null 2>&1 || { echo "SKIP: yq not installed"; exit 0; }
12
+ HAVE_YQ=1; command -v yq >/dev/null 2>&1 || HAVE_YQ=0
13
13
 
14
14
  # Stub gh: GH_DEFAULT_STUB drives `repos/<r> --jq .default_branch`; contents API unused (fails).
15
15
  mkdir -p "$TMP/bin"
@@ -17,6 +17,7 @@ cat > "$TMP/bin/gh" <<'EOF'
17
17
  #!/usr/bin/env bash
18
18
  case "$*" in
19
19
  *"--jq .default_branch"*) [ -n "${GH_FAIL:-}" ] && exit 1; printf '%s\n' "${GH_DEFAULT_STUB:-}" ;;
20
+ *"contents/forge.yaml"*) [ -n "${GH_CONTENTS_404:-}" ] && { echo "gh: Not Found (HTTP 404)" >&2; exit 1; }; exit 1 ;;
20
21
  *) exit 1 ;;
21
22
  esac
22
23
  EOF
@@ -40,6 +41,7 @@ run() { # base default_stub [env...]
40
41
  }
41
42
  expect() { [ "$1" = "$2" ] && ok || bad "$3: got '$1' want '$2'"; }
42
43
 
44
+ if [ "$HAVE_YQ" = 1 ]; then
43
45
  expect "$(run main trunk)" true "main with worktree release"
44
46
  expect "$(run release trunk)" false "worktree-declared branch is not a gate"
45
47
  expect "$(run staging trunk)" false "staging is fast lane"
@@ -55,5 +57,44 @@ printf 'branches:\n default: prod\n' > "$TMP/trusted.yaml"
55
57
  expect "$(run prod trunk FORGE_TRUSTED_CONFIG="$TMP/trusted.yaml")" true "trusted config default"
56
58
  expect "$(run prod trunk)" false "prod without trusted config"
57
59
 
60
+
61
+ # Shadowing: a local tag named origin/trunk carrying a config without `develop`
62
+ # must not displace the committed refs/remotes/origin/trunk config.
63
+ printf 'branches:\n default: other\n' > "$REPO/forge.yaml"
64
+ git -C "$REPO" add forge.yaml; git -C "$REPO" commit -qm shadow
65
+ git -C "$REPO" tag origin/trunk
66
+ expect "$(run develop trunk)" true "shadow tag origin/trunk does not displace committed config"
67
+ expect "$(run other trunk)" false "shadow tag config is not honoured"
68
+ git -C "$REPO" tag -d origin/trunk >/dev/null
69
+ fi
70
+
71
+ # Config read fail: no origin ref and failing contents API -> true (fail closed).
72
+ NOREF="$TMP/noref"; git init -q "$NOREF"
73
+ run_in() { # dir base [env...]
74
+ local dir="$1" base="$2"; shift 2
75
+ (cd "$dir" && env PATH="$TMP/bin:$PATH" GH_DEFAULT_STUB=trunk "$@" bash "$S" "$base" owner/repo)
76
+ }
77
+ expect "$(run_in "$NOREF" staging)" true "config read fail (no origin ref, API failing) fails closed"
78
+
79
+ # Missing forge.yaml is a clean "no config" read, not a read failure (falls through to false).
80
+ NOCFG="$TMP/nocfg.git"; NOCFGW="$TMP/nocfgw"
81
+ git init -q --bare "$NOCFG"; git init -q "$NOCFGW"
82
+ git -C "$NOCFGW" config user.email t@t; git -C "$NOCFGW" config user.name t
83
+ echo x > "$NOCFGW/README"; git -C "$NOCFGW" add README; git -C "$NOCFGW" commit -qm init
84
+ git -C "$NOCFGW" branch -M trunk; git -C "$NOCFGW" remote add origin "$NOCFG"
85
+ git -C "$NOCFGW" push -q origin trunk 2>/dev/null; git -C "$NOCFGW" fetch -q origin
86
+ expect "$(run_in "$NOCFGW" staging)" false "forge.yaml absent on default branch ref is no-config (fast lane)"
87
+ expect "$(run_in "$NOCFGW" main)" true "main stays strict with no config"
88
+ expect "$(run_in "$NOCFGW" trunk)" true "github default stays strict with no config"
89
+ # API 404 (ref not fetched, file absent) is also a clean no-config read; other API failures stay closed.
90
+ expect "$(run_in "$NOREF" staging GH_CONTENTS_404=1)" false "contents API 404 is no-config"
91
+ expect "$(run_in "$NOREF" staging)" true "non-404 contents API failure fails closed"
92
+
93
+ # Config unreadable: yq unusable (a failing stub shadows any real yq; portable to Git Bash, unlike a stripped PATH) even though the ref is readable.
94
+ NOYQ="$TMP/noyqbin"; mkdir -p "$NOYQ"
95
+ printf '#!/usr/bin/env bash\nexit 127\n' > "$NOYQ/yq"; chmod +x "$NOYQ/yq"
96
+ cp "$TMP/bin/gh" "$NOYQ/gh"
97
+ expect "$(cd "$REPO" && env PATH="$NOYQ:$PATH" GH_DEFAULT_STUB=trunk bash "$S" staging owner/repo)" true "config unreadable (yq failing) fails closed"
98
+
58
99
  echo "resolve-deploy-gate.test.sh: $PASS passed, $FAILN failed"
59
100
  [ "$FAILN" -eq 0 ]