forge-orkes 0.55.0 → 0.58.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.
- package/bin/create-forge.js +6 -0
- package/package.json +1 -1
- package/template/.claude/hooks/forge-release-fold.sh +458 -0
- package/template/.claude/hooks/tests/forge-release-fold.test.sh +773 -0
- package/template/.claude/settings.json +1 -1
- package/template/.claude/skills/forge/SKILL.md +4 -2
- package/template/.claude/skills/reviewing/SKILL.md +1 -1
- package/template/.claude/skills/upgrading/SKILL.md +18 -0
- package/template/.claude/skills/verifying/SKILL.md +9 -2
- package/template/.forge/FORGE.md +8 -3
- package/template/.forge/checks/forge-check-lint.sh +383 -0
- package/template/.forge/checks/forge-hold-check.sh +259 -0
- package/template/.forge/checks/tests/forge-check-lint.test.sh +597 -0
- package/template/.forge/checks/tests/forge-hold-check.test.sh +404 -0
- package/template/.forge/gitignore +6 -1
- package/template/.forge/migrations/0.56.0-merged-validated-fold.md +74 -0
- package/template/.forge/migrations/0.58.2-merge-floor-checks-relocation.md +61 -0
- package/template/.forge/templates/hold-config.yml +34 -0
- package/template/.forge/templates/project.yml +51 -21
- package/template/.forge/templates/state/index.yml +51 -24
|
@@ -0,0 +1,773 @@
|
|
|
1
|
+
#!/usr/bin/env sh
|
|
2
|
+
# Fixture-repo test suite for .claude/hooks/forge-release-fold.sh (m-30, plan 50-01).
|
|
3
|
+
#
|
|
4
|
+
# Contract under test (plan-01 "Locked interface" — do not re-derive):
|
|
5
|
+
# forge-release-fold.sh <unit-id> [--sha <sha>] [--main <ref>] [--project <path>]
|
|
6
|
+
# stdout: release_state=unmerged|merged|validated [reason=<code>]
|
|
7
|
+
# reason codes: not-on-main | no-approval | no-signoff | bad-signature |
|
|
8
|
+
# not-contained | legacy-complete | signed-off
|
|
9
|
+
# exit: 0 = computed (ANY of the three states — unmerged is an answer, not an
|
|
10
|
+
# error); 2 = bad invocation / unknown unit (never a false verdict).
|
|
11
|
+
# Landed-sha resolution when --sha absent: (1) live branch recorded for the unit
|
|
12
|
+
# (main-tree state/milestone-{id}.yml lifecycle.worktree_branch, else main-tree
|
|
13
|
+
# streams/m-{id}.yml runtime.branch) → branch tip; (2) tracked-only → the LAST
|
|
14
|
+
# COMMIT TOUCHING the state file on main, NEVER main's tip; (3) unknown → exit 2.
|
|
15
|
+
# All YAML field reads go through `git show main:...` — never the working tree.
|
|
16
|
+
#
|
|
17
|
+
# Builds throwaway git repos in mktemp dirs with NO remote configured anywhere —
|
|
18
|
+
# the whole suite is offline by construction (NFR-030). SSH-signed signoff tags
|
|
19
|
+
# reuse the key-gen pattern from verify-signoff.test.sh. Needs ssh-keygen + git
|
|
20
|
+
# with SSH signing support (git >= 2.34).
|
|
21
|
+
#
|
|
22
|
+
# Usage:
|
|
23
|
+
# ./.claude/hooks/tests/forge-release-fold.test.sh # all cases
|
|
24
|
+
# ./.claude/hooks/tests/forge-release-fold.test.sh ordering # ordering controls
|
|
25
|
+
# Filter tags (one arg, selects matching case blocks):
|
|
26
|
+
# unmerged merged grandfather human-verified signoff-tag stored-field
|
|
27
|
+
# exit-2 stable-sha ordering promotion forgery
|
|
28
|
+
# `promotion` + `forgery` (plan 51-01): validation_event=promotion_approval —
|
|
29
|
+
# a CI-signed tag on a deploy sha CONTAINING the unit's landed sha validates;
|
|
30
|
+
# tag deletion reverts; unsigned/wrong-key lookalikes and non-containing tags
|
|
31
|
+
# never validate. Tag pattern FROZEN 2026-07-16: promotion/{sha} (full deploy
|
|
32
|
+
# sha) — Track B must emit it byte-exactly.
|
|
33
|
+
# `ordering` maps to plan truth check 3: the unmerged/merged/validated ladder can
|
|
34
|
+
# never skip (unmerged never renders merged/validated; merged never validated).
|
|
35
|
+
#
|
|
36
|
+
# RED-phase friendly (TDD): a missing/non-executable helper does NOT abort the
|
|
37
|
+
# suite — every selected case FAILS with "(helper missing ...)" as the got-value
|
|
38
|
+
# and the suite exits non-zero. No FATAL short-circuit on the script under test.
|
|
39
|
+
# Exits 0 iff every selected case passes. Self-cleans on exit.
|
|
40
|
+
set -u
|
|
41
|
+
|
|
42
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
43
|
+
HELPER="$SCRIPT_DIR/../forge-release-fold.sh"
|
|
44
|
+
|
|
45
|
+
command -v ssh-keygen >/dev/null 2>&1 || { printf 'FATAL: ssh-keygen not found\n' >&2; exit 1; }
|
|
46
|
+
|
|
47
|
+
[ -x "$HELPER" ] || printf 'NOTE: %s missing or not executable — RED phase, every selected case below should FAIL\n' "$HELPER" >&2
|
|
48
|
+
|
|
49
|
+
ROOT="$(mktemp -d)"
|
|
50
|
+
trap 'rm -rf "$ROOT"' EXIT INT TERM
|
|
51
|
+
|
|
52
|
+
# Isolate from the developer's global/system git config — no $HOME leakage into
|
|
53
|
+
# fixture repos or into the helper's git invocations.
|
|
54
|
+
GIT_CONFIG_GLOBAL=/dev/null
|
|
55
|
+
GIT_CONFIG_SYSTEM=/dev/null
|
|
56
|
+
export GIT_CONFIG_GLOBAL GIT_CONFIG_SYSTEM
|
|
57
|
+
|
|
58
|
+
PASSED=0
|
|
59
|
+
FAILED=0
|
|
60
|
+
SELECTED=0
|
|
61
|
+
FILTER="${1:-}"
|
|
62
|
+
|
|
63
|
+
pass() { PASSED=$((PASSED + 1)); printf 'PASS: %s\n' "$1"; }
|
|
64
|
+
fail() { FAILED=$((FAILED + 1)); printf 'FAIL: %s\n' "$1" >&2; }
|
|
65
|
+
check() { # desc, actual, expected (exact match)
|
|
66
|
+
if [ "$2" = "$3" ]; then pass "$1"; else fail "$1 (got '$2', want '$3')"; fi
|
|
67
|
+
}
|
|
68
|
+
check_prefix() { # desc, actual, expected-prefix
|
|
69
|
+
case "$2" in
|
|
70
|
+
"$3"*) pass "$1" ;;
|
|
71
|
+
*) fail "$1 (got '$2', want prefix '$3')" ;;
|
|
72
|
+
esac
|
|
73
|
+
}
|
|
74
|
+
check_absent() { # desc, actual, forbidden-substring
|
|
75
|
+
case "$2" in
|
|
76
|
+
*"$3"*) fail "$1 (got '$2', must NOT contain '$3')" ;;
|
|
77
|
+
*) pass "$1" ;;
|
|
78
|
+
esac
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
running() { # tag... — 0 if this case block is selected by $FILTER (empty = all)
|
|
82
|
+
if [ -z "$FILTER" ]; then SELECTED=$((SELECTED + 1)); return 0; fi
|
|
83
|
+
for _t in "$@"; do
|
|
84
|
+
if [ "$_t" = "$FILTER" ]; then SELECTED=$((SELECTED + 1)); return 0; fi
|
|
85
|
+
done
|
|
86
|
+
return 1
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
# Run the helper from a given cwd; sets OUT (stdout) and RC (exit code). When the
|
|
90
|
+
# helper is absent (RED phase) each assertion fails with a clear got-value instead
|
|
91
|
+
# of the suite aborting.
|
|
92
|
+
run_fold() { # cwd, helper-args...
|
|
93
|
+
rf_cwd="$1"; shift
|
|
94
|
+
if [ -x "$HELPER" ]; then
|
|
95
|
+
if OUT="$(cd "$rf_cwd" && "$HELPER" "$@" 2>/dev/null)"; then RC=0; else RC=$?; fi
|
|
96
|
+
else
|
|
97
|
+
OUT="(helper missing or not executable: $HELPER)"
|
|
98
|
+
RC=127
|
|
99
|
+
fi
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
# One allowed-signer keypair (the "operator") + one non-allowed keypair (the
|
|
103
|
+
# "agent") shared across cases — the verify-signoff.test.sh pattern.
|
|
104
|
+
gen_key() { # path, comment
|
|
105
|
+
ssh-keygen -t ed25519 -N "" -C "$2" -f "$1" -q
|
|
106
|
+
}
|
|
107
|
+
gen_key "$ROOT/operator_key" "operator@example.com"
|
|
108
|
+
gen_key "$ROOT/agent_key" "agent@example.com"
|
|
109
|
+
gen_key "$ROOT/ci_key" "ci@example.com"
|
|
110
|
+
|
|
111
|
+
allowed_signers="$ROOT/allowed_signers"
|
|
112
|
+
printf 'operator@example.com namespaces="git" %s\n' "$(cat "$ROOT/operator_key.pub")" > "$allowed_signers"
|
|
113
|
+
|
|
114
|
+
# Promotion signers: the CI key ONLY (the host-side, Environment-scoped key).
|
|
115
|
+
# agent_key stays out — it is the rogue key for the forgery neg controls.
|
|
116
|
+
promotion_signers="$ROOT/promotion_signers"
|
|
117
|
+
printf 'ci@example.com namespaces="git" %s\n' "$(cat "$ROOT/ci_key.pub")" > "$promotion_signers"
|
|
118
|
+
|
|
119
|
+
evidence="cafef00d5511c0deba5eba11cafef00d5511c0deba5eba11cafef00d5511c0de"
|
|
120
|
+
|
|
121
|
+
# NO `git remote add` anywhere in this suite: fixtures are remote-less by
|
|
122
|
+
# construction, so any network dependency in the fold breaks here (NFR-030).
|
|
123
|
+
new_repo() { # name → prints repo path (main branch, one root commit)
|
|
124
|
+
d="$ROOT/repo.$1"
|
|
125
|
+
mkdir -p "$d"
|
|
126
|
+
git -C "$d" init -q
|
|
127
|
+
git -C "$d" symbolic-ref HEAD refs/heads/main
|
|
128
|
+
git -C "$d" config user.email t@example.com
|
|
129
|
+
git -C "$d" config user.name tester
|
|
130
|
+
git -C "$d" commit --allow-empty -qm init
|
|
131
|
+
printf '%s\n' "$d"
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
write_milestone() { # repo, N, yaml-body — writes + commits .forge/state/milestone-N.yml on the current branch
|
|
135
|
+
mkdir -p "$1/.forge/state"
|
|
136
|
+
printf '%s\n' "$3" > "$1/.forge/state/milestone-$2.yml"
|
|
137
|
+
git -C "$1" add ".forge/state/milestone-$2.yml"
|
|
138
|
+
git -C "$1" commit -qm "chore(forge): state milestone-$2"
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
write_stream() { # repo, unit-id (m-N), branch — commits .forge/streams/m-N.yml with runtime.branch
|
|
142
|
+
mkdir -p "$1/.forge/streams"
|
|
143
|
+
printf 'version: 1\n\nstream:\n id: %s\n status: active\n milestone: %s\n\nruntime:\n branch: %s\n worktree: ""\n' \
|
|
144
|
+
"$2" "$2" "$3" > "$1/.forge/streams/$2.yml"
|
|
145
|
+
git -C "$1" add ".forge/streams/$2.yml"
|
|
146
|
+
git -C "$1" commit -qm "chore(forge): stream $2"
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
mint_tag() { # repo, unit-id, signing-key, head, evidence — SSH-signed signoff/<unit-id>
|
|
150
|
+
git -C "$1" -c gpg.format=ssh -c "user.signingkey=$3" \
|
|
151
|
+
tag -s "signoff/$2" -m "wu: $2
|
|
152
|
+
head: $4
|
|
153
|
+
evidence: $5"
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
landed_sha() { # repo, N — last commit touching the unit's state file on main (the contract's stable identity)
|
|
157
|
+
git -C "$1" log -1 --format=%H main -- ".forge/state/milestone-$2.yml"
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
# --- promotion_approval fixture helpers (plan 51-01) ---------------------------
|
|
161
|
+
|
|
162
|
+
# write_promotion_binding <repo> <signers-path> — the locked B3 binding shape,
|
|
163
|
+
# working-tree project.yml (the mode file is a working-tree read by design;
|
|
164
|
+
# protected-path-held per the contract). Tag pattern FROZEN: promotion/{sha}.
|
|
165
|
+
write_promotion_binding() {
|
|
166
|
+
mkdir -p "$1/.forge"
|
|
167
|
+
cat > "$1/.forge/project.yml" <<EOF
|
|
168
|
+
forge:
|
|
169
|
+
validation_event: promotion_approval
|
|
170
|
+
promotion:
|
|
171
|
+
tag_pattern: "promotion/{sha}"
|
|
172
|
+
allowed_signers: "$2"
|
|
173
|
+
EOF
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
# mint_promotion_tag <repo> <deploy-sha> <signing-key> — SSH-signed
|
|
177
|
+
# promotion/<deploy-sha> pointing AT the deploy sha. The tag's target commit +
|
|
178
|
+
# containment IS the binding — no extra metadata (no head:/evidence: lines).
|
|
179
|
+
mint_promotion_tag() {
|
|
180
|
+
git -C "$1" -c gpg.format=ssh -c "user.signingkey=$3" \
|
|
181
|
+
tag -s "promotion/$2" -m "promotion: approved deploy $2" "$2"
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
# advance_main <repo> <msg> — one unrelated commit on main; prints the new tip
|
|
185
|
+
# (a deploy point containing everything landed so far).
|
|
186
|
+
advance_main() {
|
|
187
|
+
printf '%s\n' "$2" >> "$1/DEPLOY.txt"
|
|
188
|
+
git -C "$1" add DEPLOY.txt
|
|
189
|
+
git -C "$1" commit -qm "$2"
|
|
190
|
+
git -C "$1" rev-parse main
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
# --- case 1: unmerged — live branch not ancestor of main --------------------
|
|
194
|
+
if running unmerged ordering; then
|
|
195
|
+
printf '\n--- case 1: unmerged (live branch not ancestor of main) ---\n'
|
|
196
|
+
d="$(new_repo unmerged)"
|
|
197
|
+
write_milestone "$d" 91 'milestone:
|
|
198
|
+
id: 91
|
|
199
|
+
name: "fixture: unmerged unit"
|
|
200
|
+
|
|
201
|
+
current:
|
|
202
|
+
tier: standard
|
|
203
|
+
status: executing
|
|
204
|
+
last_updated: "2026-07-16"
|
|
205
|
+
human_verified: null
|
|
206
|
+
|
|
207
|
+
lifecycle:
|
|
208
|
+
worktree_mode: active
|
|
209
|
+
worktree_branch: "forge/m-91"'
|
|
210
|
+
git -C "$d" checkout -qb forge/m-91
|
|
211
|
+
git -C "$d" commit --allow-empty -qm "work on forge/m-91 (never merged)"
|
|
212
|
+
git -C "$d" checkout -q main
|
|
213
|
+
check "fixture repo has no remote (offline by construction)" "$(git -C "$d" remote)" ""
|
|
214
|
+
run_fold "$d" m-91
|
|
215
|
+
check "unmerged: exit 0 (a computed state, not an error)" "$RC" "0"
|
|
216
|
+
check "unmerged: stdout" "$OUT" "release_state=unmerged reason=not-on-main"
|
|
217
|
+
|
|
218
|
+
# Ordering negative control: the main-tree file LOOKS validated (complete +
|
|
219
|
+
# populated human_verified) but the unit's live branch never landed on main →
|
|
220
|
+
# still unmerged. validated REQUIRES merged — checked first, always. The live
|
|
221
|
+
# branch resolves via streams/m-92.yml runtime.branch (the `else` route of
|
|
222
|
+
# resolution rule 1: no lifecycle.worktree_branch in the state file).
|
|
223
|
+
d2="$(new_repo unmerged-control)"
|
|
224
|
+
write_milestone "$d2" 92 'milestone:
|
|
225
|
+
id: 92
|
|
226
|
+
name: "fixture: unmerged unit with validation-looking YAML"
|
|
227
|
+
|
|
228
|
+
current:
|
|
229
|
+
tier: standard
|
|
230
|
+
status: complete
|
|
231
|
+
completed_at: "2026-07-10"
|
|
232
|
+
last_updated: "2026-07-10"
|
|
233
|
+
human_verified:
|
|
234
|
+
at: "2026-07-10"
|
|
235
|
+
method: "smoke test"
|
|
236
|
+
notes: "looks signed off — but the branch never landed"
|
|
237
|
+
|
|
238
|
+
lifecycle:
|
|
239
|
+
worktree_mode: active'
|
|
240
|
+
write_stream "$d2" m-92 forge/m-92
|
|
241
|
+
git -C "$d2" checkout -qb forge/m-92
|
|
242
|
+
git -C "$d2" commit --allow-empty -qm "work on forge/m-92 (never merged)"
|
|
243
|
+
git -C "$d2" checkout -q main
|
|
244
|
+
run_fold "$d2" m-92
|
|
245
|
+
check "ordering control: exit 0" "$RC" "0"
|
|
246
|
+
check "ordering control: unmerged despite complete+human_verified in main tree" "$OUT" "release_state=unmerged reason=not-on-main"
|
|
247
|
+
fi
|
|
248
|
+
|
|
249
|
+
# --- case 2: merged but not validated ----------------------------------------
|
|
250
|
+
if running merged ordering; then
|
|
251
|
+
printf '\n--- case 2: merged-but-not-validated (no approval artifact) ---\n'
|
|
252
|
+
d="$(new_repo merged)"
|
|
253
|
+
write_milestone "$d" 93 'milestone:
|
|
254
|
+
id: 93
|
|
255
|
+
name: "fixture: merged unit, no approval"
|
|
256
|
+
|
|
257
|
+
current:
|
|
258
|
+
tier: standard
|
|
259
|
+
status: executing
|
|
260
|
+
last_updated: "2026-07-16"
|
|
261
|
+
human_verified: null
|
|
262
|
+
|
|
263
|
+
lifecycle:
|
|
264
|
+
worktree_mode: active
|
|
265
|
+
worktree_branch: "forge/m-93"'
|
|
266
|
+
git -C "$d" checkout -qb forge/m-93
|
|
267
|
+
git -C "$d" commit --allow-empty -qm "work on forge/m-93"
|
|
268
|
+
git -C "$d" checkout -q main
|
|
269
|
+
git -C "$d" merge -q --no-ff -m "merge forge/m-93" forge/m-93
|
|
270
|
+
run_fold "$d" m-93
|
|
271
|
+
check "merged: exit 0" "$RC" "0"
|
|
272
|
+
check "merged: stdout (never validated without approval)" "$OUT" "release_state=merged reason=no-signoff"
|
|
273
|
+
|
|
274
|
+
# --project <path>: same answer when invoked from outside the repo.
|
|
275
|
+
run_fold "$ROOT" m-93 --project "$d"
|
|
276
|
+
check "merged via --project from outside: exit 0" "$RC" "0"
|
|
277
|
+
check "merged via --project from outside: stdout" "$OUT" "release_state=merged reason=no-signoff"
|
|
278
|
+
fi
|
|
279
|
+
|
|
280
|
+
# --- case 3: legacy-complete grandfather --------------------------------------
|
|
281
|
+
if running grandfather ordering; then
|
|
282
|
+
printf '\n--- case 3: legacy-complete grandfather (complete, no tag, no human_verified) ---\n'
|
|
283
|
+
d="$(new_repo grandfather)"
|
|
284
|
+
write_milestone "$d" 1 'milestone:
|
|
285
|
+
id: 1
|
|
286
|
+
name: "fixture: pre-gate closed milestone"
|
|
287
|
+
|
|
288
|
+
current:
|
|
289
|
+
tier: standard
|
|
290
|
+
status: complete
|
|
291
|
+
completed_at: "2025-11-01"
|
|
292
|
+
last_updated: "2025-11-01"
|
|
293
|
+
|
|
294
|
+
lifecycle:
|
|
295
|
+
worktree_mode: retired'
|
|
296
|
+
run_fold "$d" m-1
|
|
297
|
+
check "grandfather: exit 0" "$RC" "0"
|
|
298
|
+
check "grandfather: stdout" "$OUT" "release_state=validated reason=legacy-complete"
|
|
299
|
+
|
|
300
|
+
# Explicit `human_verified: null` is NOT "set" — still the grandfather route.
|
|
301
|
+
write_milestone "$d" 2 'milestone:
|
|
302
|
+
id: 2
|
|
303
|
+
name: "fixture: closed milestone with explicit null human_verified"
|
|
304
|
+
|
|
305
|
+
current:
|
|
306
|
+
tier: standard
|
|
307
|
+
status: complete
|
|
308
|
+
completed_at: "2025-12-01"
|
|
309
|
+
last_updated: "2025-12-01"
|
|
310
|
+
human_verified: null
|
|
311
|
+
|
|
312
|
+
lifecycle:
|
|
313
|
+
worktree_mode: retired'
|
|
314
|
+
run_fold "$d" m-2
|
|
315
|
+
check "grandfather (human_verified: null): exit 0" "$RC" "0"
|
|
316
|
+
check "grandfather (human_verified: null): reason stays legacy-complete" "$OUT" "release_state=validated reason=legacy-complete"
|
|
317
|
+
fi
|
|
318
|
+
|
|
319
|
+
# --- case 4: human_verified set, not yet complete ------------------------------
|
|
320
|
+
if running human-verified; then
|
|
321
|
+
printf '\n--- case 4: human_verified populated, status not complete ---\n'
|
|
322
|
+
d="$(new_repo human-verified)"
|
|
323
|
+
write_milestone "$d" 4 'milestone:
|
|
324
|
+
id: 4
|
|
325
|
+
name: "fixture: human-verified unit still in reviewing"
|
|
326
|
+
|
|
327
|
+
current:
|
|
328
|
+
tier: standard
|
|
329
|
+
status: reviewing
|
|
330
|
+
last_updated: "2026-07-16"
|
|
331
|
+
human_verified:
|
|
332
|
+
at: "2026-07-16"
|
|
333
|
+
method: "device smoke test"
|
|
334
|
+
notes: "operator walked the flow"
|
|
335
|
+
|
|
336
|
+
lifecycle:
|
|
337
|
+
worktree_mode: active'
|
|
338
|
+
run_fold "$d" m-4
|
|
339
|
+
check "human_verified: exit 0" "$RC" "0"
|
|
340
|
+
check_prefix "human_verified: validated" "$OUT" "release_state=validated"
|
|
341
|
+
fi
|
|
342
|
+
|
|
343
|
+
# --- case 5: SSH-signed signoff tag, allowed vs non-allowed key ----------------
|
|
344
|
+
if running signoff-tag; then
|
|
345
|
+
printf '\n--- case 5: signoff tag (operator-signed vs wrong key) ---\n'
|
|
346
|
+
# 5a: operator-signed signoff/<id> at the landed sha → validated reason=signed-off
|
|
347
|
+
d="$(new_repo signoff)"
|
|
348
|
+
git -C "$d" config gpg.ssh.allowedSignersFile "$allowed_signers"
|
|
349
|
+
write_milestone "$d" 95 'milestone:
|
|
350
|
+
id: 95
|
|
351
|
+
name: "fixture: signed-off unit"
|
|
352
|
+
|
|
353
|
+
current:
|
|
354
|
+
tier: standard
|
|
355
|
+
status: executing
|
|
356
|
+
last_updated: "2026-07-16"
|
|
357
|
+
human_verified: null
|
|
358
|
+
|
|
359
|
+
lifecycle:
|
|
360
|
+
worktree_mode: active'
|
|
361
|
+
landed="$(landed_sha "$d" 95)"
|
|
362
|
+
mint_tag "$d" m-95 "$ROOT/operator_key" "$landed" "$evidence"
|
|
363
|
+
run_fold "$d" m-95
|
|
364
|
+
check "signed tag: exit 0" "$RC" "0"
|
|
365
|
+
check "signed tag: stdout" "$OUT" "release_state=validated reason=signed-off"
|
|
366
|
+
|
|
367
|
+
# 5b: same shape, tag signed by a NON-allowed key, no YAML fallback → the tag
|
|
368
|
+
# route must not validate; the unit stays merged.
|
|
369
|
+
d2="$(new_repo signoff-badkey)"
|
|
370
|
+
git -C "$d2" config gpg.ssh.allowedSignersFile "$allowed_signers"
|
|
371
|
+
write_milestone "$d2" 96 'milestone:
|
|
372
|
+
id: 96
|
|
373
|
+
name: "fixture: lookalike tag from a non-allowed key"
|
|
374
|
+
|
|
375
|
+
current:
|
|
376
|
+
tier: standard
|
|
377
|
+
status: executing
|
|
378
|
+
last_updated: "2026-07-16"
|
|
379
|
+
human_verified: null
|
|
380
|
+
|
|
381
|
+
lifecycle:
|
|
382
|
+
worktree_mode: active'
|
|
383
|
+
landed2="$(landed_sha "$d2" 96)"
|
|
384
|
+
mint_tag "$d2" m-96 "$ROOT/agent_key" "$landed2" "$evidence"
|
|
385
|
+
run_fold "$d2" m-96
|
|
386
|
+
check "wrong-key tag (no fallback): exit 0" "$RC" "0"
|
|
387
|
+
check_prefix "wrong-key tag (no fallback): merged, never validated" "$OUT" "release_state=merged"
|
|
388
|
+
|
|
389
|
+
# 5c: wrong-key tag but human_verified IS set in the main tree → validated via
|
|
390
|
+
# the YAML route — and never attributed to the tag (reason=signed-off forbidden).
|
|
391
|
+
d3="$(new_repo signoff-badkey-fallback)"
|
|
392
|
+
git -C "$d3" config gpg.ssh.allowedSignersFile "$allowed_signers"
|
|
393
|
+
write_milestone "$d3" 97 'milestone:
|
|
394
|
+
id: 97
|
|
395
|
+
name: "fixture: wrong-key tag with a real human_verified fallback"
|
|
396
|
+
|
|
397
|
+
current:
|
|
398
|
+
tier: standard
|
|
399
|
+
status: reviewing
|
|
400
|
+
last_updated: "2026-07-16"
|
|
401
|
+
human_verified:
|
|
402
|
+
at: "2026-07-16"
|
|
403
|
+
method: "device smoke test"
|
|
404
|
+
notes: "verified in session"
|
|
405
|
+
|
|
406
|
+
lifecycle:
|
|
407
|
+
worktree_mode: active'
|
|
408
|
+
landed3="$(landed_sha "$d3" 97)"
|
|
409
|
+
mint_tag "$d3" m-97 "$ROOT/agent_key" "$landed3" "$evidence"
|
|
410
|
+
run_fold "$d3" m-97
|
|
411
|
+
check "wrong-key tag (YAML fallback): exit 0" "$RC" "0"
|
|
412
|
+
check_prefix "wrong-key tag (YAML fallback): validated via YAML route" "$OUT" "release_state=validated"
|
|
413
|
+
check_absent "wrong-key tag (YAML fallback): never reason=signed-off" "$OUT" "reason=signed-off"
|
|
414
|
+
fi
|
|
415
|
+
|
|
416
|
+
# --- case 6: a stored release_state field cannot stick -------------------------
|
|
417
|
+
if running stored-field; then
|
|
418
|
+
printf '\n--- case 6: stored release_state ignored; main-tree reads only ---\n'
|
|
419
|
+
d="$(new_repo stored-field)"
|
|
420
|
+
write_milestone "$d" 61 'milestone:
|
|
421
|
+
id: 61
|
|
422
|
+
name: "fixture: adversarial stored release_state"
|
|
423
|
+
|
|
424
|
+
release_state: validated
|
|
425
|
+
|
|
426
|
+
current:
|
|
427
|
+
tier: standard
|
|
428
|
+
status: executing
|
|
429
|
+
release_state: validated
|
|
430
|
+
last_updated: "2026-07-16"
|
|
431
|
+
human_verified: null
|
|
432
|
+
|
|
433
|
+
lifecycle:
|
|
434
|
+
worktree_mode: active'
|
|
435
|
+
run_fold "$d" m-61
|
|
436
|
+
check "stored field: exit 0" "$RC" "0"
|
|
437
|
+
check "stored field: recomputed from git truth, stored value ignored" "$OUT" "release_state=merged reason=no-signoff"
|
|
438
|
+
|
|
439
|
+
# 6b: working-tree tamper — an uncommitted edit flips status + human_verified.
|
|
440
|
+
# The fold reads `git show main:...`, so the render must not change (an
|
|
441
|
+
# agent-writable working tree must not flip the result).
|
|
442
|
+
printf 'current:\n status: complete\n human_verified:\n at: "2026-07-16"\n method: "tampered"\n notes: "uncommitted working-tree edit"\n' \
|
|
443
|
+
> "$d/.forge/state/milestone-61.yml"
|
|
444
|
+
run_fold "$d" m-61
|
|
445
|
+
check "working-tree tamper: exit 0" "$RC" "0"
|
|
446
|
+
check "working-tree tamper: main-tree read unchanged" "$OUT" "release_state=merged reason=no-signoff"
|
|
447
|
+
fi
|
|
448
|
+
|
|
449
|
+
# --- case 7: exit 2 — never a false verdict ------------------------------------
|
|
450
|
+
if running exit-2; then
|
|
451
|
+
printf '\n--- case 7: exit 2 on unknown unit / bad invocation ---\n'
|
|
452
|
+
d="$(new_repo exit2)"
|
|
453
|
+
run_fold "$d" m-404
|
|
454
|
+
check "unknown unit: exit 2" "$RC" "2"
|
|
455
|
+
check_absent "unknown unit: no verdict on stdout" "$OUT" "release_state="
|
|
456
|
+
|
|
457
|
+
write_milestone "$d" 70 'milestone:
|
|
458
|
+
id: 70
|
|
459
|
+
name: "fixture: known unit for flag parsing"
|
|
460
|
+
|
|
461
|
+
current:
|
|
462
|
+
tier: standard
|
|
463
|
+
status: executing
|
|
464
|
+
last_updated: "2026-07-16"
|
|
465
|
+
human_verified: null'
|
|
466
|
+
run_fold "$d" m-70 --bogus
|
|
467
|
+
check "bad flag: exit 2" "$RC" "2"
|
|
468
|
+
check_absent "bad flag: no verdict on stdout" "$OUT" "release_state="
|
|
469
|
+
|
|
470
|
+
run_fold "$d"
|
|
471
|
+
check "no unit-id: exit 2" "$RC" "2"
|
|
472
|
+
check_absent "no unit-id: no verdict on stdout" "$OUT" "release_state="
|
|
473
|
+
fi
|
|
474
|
+
|
|
475
|
+
# --- case 8: stable landed sha (anti-flap, operator ruling 2026-07-16) ----------
|
|
476
|
+
if running stable-sha; then
|
|
477
|
+
printf '\n--- case 8: landed sha is stable — advancing main must not flap the state ---\n'
|
|
478
|
+
d="$(new_repo stable-sha)"
|
|
479
|
+
git -C "$d" config gpg.ssh.allowedSignersFile "$allowed_signers"
|
|
480
|
+
write_milestone "$d" 80 'milestone:
|
|
481
|
+
id: 80
|
|
482
|
+
name: "fixture: tracked unit, signed at its landed sha"
|
|
483
|
+
|
|
484
|
+
current:
|
|
485
|
+
tier: standard
|
|
486
|
+
status: executing
|
|
487
|
+
last_updated: "2026-07-16"
|
|
488
|
+
human_verified: null
|
|
489
|
+
|
|
490
|
+
lifecycle:
|
|
491
|
+
worktree_mode: active'
|
|
492
|
+
landed="$(landed_sha "$d" 80)"
|
|
493
|
+
mint_tag "$d" m-80 "$ROOT/operator_key" "$landed" "$evidence"
|
|
494
|
+
run_fold "$d" m-80
|
|
495
|
+
check "stable sha (before advance): exit 0" "$RC" "0"
|
|
496
|
+
check "stable sha (before advance): signed-off at the landed sha" "$OUT" "release_state=validated reason=signed-off"
|
|
497
|
+
|
|
498
|
+
# Advance main with an unrelated commit. The unit's landed sha must remain the
|
|
499
|
+
# last commit touching ITS state file — never main's moving tip. A tip-resolving
|
|
500
|
+
# fold flaps here: the tag's head: no longer matches → tag route dies → merged.
|
|
501
|
+
printf 'unrelated\n' > "$d/UNRELATED.txt"
|
|
502
|
+
git -C "$d" add UNRELATED.txt
|
|
503
|
+
git -C "$d" commit -qm "unrelated: advance main past the landed sha"
|
|
504
|
+
run_fold "$d" m-80
|
|
505
|
+
check "stable sha (after advance): exit 0" "$RC" "0"
|
|
506
|
+
check "stable sha (after advance): still signed-off — identity did not move" "$OUT" "release_state=validated reason=signed-off"
|
|
507
|
+
|
|
508
|
+
# Same anti-flap for a plain merged unit: state + reason unchanged across the advance.
|
|
509
|
+
write_milestone "$d" 81 'milestone:
|
|
510
|
+
id: 81
|
|
511
|
+
name: "fixture: tracked merged unit, no approval"
|
|
512
|
+
|
|
513
|
+
current:
|
|
514
|
+
tier: standard
|
|
515
|
+
status: executing
|
|
516
|
+
last_updated: "2026-07-16"
|
|
517
|
+
human_verified: null'
|
|
518
|
+
run_fold "$d" m-81
|
|
519
|
+
check "stable sha (merged unit, before advance): exit 0" "$RC" "0"
|
|
520
|
+
check "stable sha (merged unit, before advance): stdout" "$OUT" "release_state=merged reason=no-signoff"
|
|
521
|
+
printf 'more\n' >> "$d/UNRELATED.txt"
|
|
522
|
+
git -C "$d" add UNRELATED.txt
|
|
523
|
+
git -C "$d" commit -qm "unrelated: advance main again"
|
|
524
|
+
run_fold "$d" m-81
|
|
525
|
+
check "stable sha (merged unit, after advance): exit 0" "$RC" "0"
|
|
526
|
+
check "stable sha (merged unit, after advance): unchanged" "$OUT" "release_state=merged reason=no-signoff"
|
|
527
|
+
fi
|
|
528
|
+
|
|
529
|
+
# --- case 9: promotion_approval — CI-signed containing tag validates ------------
|
|
530
|
+
if running promotion; then
|
|
531
|
+
printf '\n--- case 9: promotion_approval (containment; delete reverts; isolation; anti-flap) ---\n'
|
|
532
|
+
# 9a: positive — unit landed sha CONTAINED IN a deploy sha carrying a genuine
|
|
533
|
+
# CI-signed promotion tag → validated. Containment, not tip-equality (operator
|
|
534
|
+
# ruling 2026-07-16): the tag approves a deploy; every unit inside it is covered.
|
|
535
|
+
d="$(new_repo promotion)"
|
|
536
|
+
write_milestone "$d" 101 'milestone:
|
|
537
|
+
id: 101
|
|
538
|
+
name: "fixture: promotion-approved unit"
|
|
539
|
+
|
|
540
|
+
current:
|
|
541
|
+
tier: standard
|
|
542
|
+
status: executing
|
|
543
|
+
last_updated: "2026-07-16"
|
|
544
|
+
human_verified: null
|
|
545
|
+
|
|
546
|
+
lifecycle:
|
|
547
|
+
worktree_mode: active'
|
|
548
|
+
landed="$(landed_sha "$d" 101)"
|
|
549
|
+
deploy="$(advance_main "$d" "deploy: ship it")"
|
|
550
|
+
mint_promotion_tag "$d" "$deploy" "$ROOT/ci_key"
|
|
551
|
+
write_promotion_binding "$d" "$promotion_signers"
|
|
552
|
+
check "promotion fixture has no remote (offline by construction)" "$(git -C "$d" remote)" ""
|
|
553
|
+
run_fold "$d" m-101
|
|
554
|
+
check "promotion positive: exit 0" "$RC" "0"
|
|
555
|
+
check_prefix "promotion positive: validated via containing CI-signed tag" "$OUT" "release_state=validated"
|
|
556
|
+
|
|
557
|
+
# 9b: delete-reverts (acceptance 2) — the operator deletes the tag, re-runs,
|
|
558
|
+
# and the same unit reverts to merged. Nothing stored anywhere to un-stick.
|
|
559
|
+
git -C "$d" tag -d "promotion/$deploy" >/dev/null 2>&1
|
|
560
|
+
run_fold "$d" m-101
|
|
561
|
+
check "delete-reverts: exit 0" "$RC" "0"
|
|
562
|
+
check "delete-reverts: merged reason=no-approval" "$OUT" "release_state=merged reason=no-approval"
|
|
563
|
+
|
|
564
|
+
# 9c: not-contained — a genuinely signed tag whose deploy commit does NOT
|
|
565
|
+
# contain the unit's landed sha (sibling history) must not validate it.
|
|
566
|
+
d2="$(new_repo promotion-notcontained)"
|
|
567
|
+
base="$(git -C "$d2" rev-parse main)"
|
|
568
|
+
write_milestone "$d2" 103 'milestone:
|
|
569
|
+
id: 103
|
|
570
|
+
name: "fixture: unit outside the approved deploy"
|
|
571
|
+
|
|
572
|
+
current:
|
|
573
|
+
tier: standard
|
|
574
|
+
status: executing
|
|
575
|
+
last_updated: "2026-07-16"
|
|
576
|
+
human_verified: null
|
|
577
|
+
|
|
578
|
+
lifecycle:
|
|
579
|
+
worktree_mode: active'
|
|
580
|
+
git -C "$d2" checkout -qb sibling "$base"
|
|
581
|
+
git -C "$d2" commit --allow-empty -qm "sibling deploy line (does not contain the unit)"
|
|
582
|
+
sibling_sha="$(git -C "$d2" rev-parse sibling)"
|
|
583
|
+
git -C "$d2" checkout -q main
|
|
584
|
+
mint_promotion_tag "$d2" "$sibling_sha" "$ROOT/ci_key"
|
|
585
|
+
write_promotion_binding "$d2" "$promotion_signers"
|
|
586
|
+
run_fold "$d2" m-103
|
|
587
|
+
check "not-contained: exit 0" "$RC" "0"
|
|
588
|
+
check "not-contained: merged reason=not-contained" "$OUT" "release_state=merged reason=not-contained"
|
|
589
|
+
check_absent "not-contained: never validated" "$OUT" "release_state=validated"
|
|
590
|
+
|
|
591
|
+
# 9d: binding-absent isolation — same repo shape, genuine containing signed
|
|
592
|
+
# tag, but NO promotion binding → in_session_signoff routes apply and the
|
|
593
|
+
# promotion tag alone must NOT validate a default-binding repo.
|
|
594
|
+
d3="$(new_repo promotion-nobinding)"
|
|
595
|
+
write_milestone "$d3" 104 'milestone:
|
|
596
|
+
id: 104
|
|
597
|
+
name: "fixture: promotion tag on a default-binding repo"
|
|
598
|
+
|
|
599
|
+
current:
|
|
600
|
+
tier: standard
|
|
601
|
+
status: executing
|
|
602
|
+
last_updated: "2026-07-16"
|
|
603
|
+
human_verified: null
|
|
604
|
+
|
|
605
|
+
lifecycle:
|
|
606
|
+
worktree_mode: active'
|
|
607
|
+
deploy3="$(advance_main "$d3" "deploy: ship it")"
|
|
608
|
+
mint_promotion_tag "$d3" "$deploy3" "$ROOT/ci_key"
|
|
609
|
+
# no project.yml written — validation_event absent ⇒ in_session_signoff
|
|
610
|
+
run_fold "$d3" m-104
|
|
611
|
+
check "binding-absent: exit 0" "$RC" "0"
|
|
612
|
+
check "binding-absent: promotion tag alone does not validate (no-signoff)" "$OUT" "release_state=merged reason=no-signoff"
|
|
613
|
+
check_absent "binding-absent: never validated" "$OUT" "release_state=validated"
|
|
614
|
+
|
|
615
|
+
# 9e: anti-flap (the ruling's motivating case) — validate via a containing
|
|
616
|
+
# signed tag, then advance main PAST the promotion with unrelated commits.
|
|
617
|
+
# Containment is stable forever; tip-equality would flap validated off here.
|
|
618
|
+
d4="$(new_repo promotion-antiflap)"
|
|
619
|
+
write_milestone "$d4" 105 'milestone:
|
|
620
|
+
id: 105
|
|
621
|
+
name: "fixture: promoted unit, main advances afterwards"
|
|
622
|
+
|
|
623
|
+
current:
|
|
624
|
+
tier: standard
|
|
625
|
+
status: executing
|
|
626
|
+
last_updated: "2026-07-16"
|
|
627
|
+
human_verified: null
|
|
628
|
+
|
|
629
|
+
lifecycle:
|
|
630
|
+
worktree_mode: active'
|
|
631
|
+
deploy4="$(advance_main "$d4" "deploy: ship it")"
|
|
632
|
+
mint_promotion_tag "$d4" "$deploy4" "$ROOT/ci_key"
|
|
633
|
+
write_promotion_binding "$d4" "$promotion_signers"
|
|
634
|
+
run_fold "$d4" m-105
|
|
635
|
+
check "anti-flap (at promotion): exit 0" "$RC" "0"
|
|
636
|
+
check_prefix "anti-flap (at promotion): validated" "$OUT" "release_state=validated"
|
|
637
|
+
first_out="$OUT"
|
|
638
|
+
advance_main "$d4" "unrelated: main moves past the promotion" >/dev/null
|
|
639
|
+
advance_main "$d4" "unrelated: and again" >/dev/null
|
|
640
|
+
run_fold "$d4" m-105
|
|
641
|
+
check "anti-flap (after advance): exit 0" "$RC" "0"
|
|
642
|
+
check_prefix "anti-flap (after advance): STILL validated — containment is stable" "$OUT" "release_state=validated"
|
|
643
|
+
check "anti-flap (after advance): render is identical, no flap" "$OUT" "$first_out"
|
|
644
|
+
fi
|
|
645
|
+
|
|
646
|
+
# --- case 10: forgery neg controls — lookalike tags compute nothing -------------
|
|
647
|
+
if running forgery; then
|
|
648
|
+
printf '\n--- case 10: forgery (unsigned / lightweight / wrong-key lookalikes) ---\n'
|
|
649
|
+
# 10a: annotated-but-UNSIGNED lookalike tag at a genuine deploy point →
|
|
650
|
+
# verification fails → merged reason=bad-signature; validated is impossible.
|
|
651
|
+
d="$(new_repo forgery-unsigned)"
|
|
652
|
+
write_milestone "$d" 111 'milestone:
|
|
653
|
+
id: 111
|
|
654
|
+
name: "fixture: unsigned lookalike promotion tag"
|
|
655
|
+
|
|
656
|
+
current:
|
|
657
|
+
tier: standard
|
|
658
|
+
status: executing
|
|
659
|
+
last_updated: "2026-07-16"
|
|
660
|
+
human_verified: null
|
|
661
|
+
|
|
662
|
+
lifecycle:
|
|
663
|
+
worktree_mode: active'
|
|
664
|
+
deploy="$(advance_main "$d" "deploy: ship it")"
|
|
665
|
+
git -C "$d" tag -a "promotion/$deploy" -m "promotion: approved deploy $deploy" "$deploy"
|
|
666
|
+
write_promotion_binding "$d" "$promotion_signers"
|
|
667
|
+
run_fold "$d" m-111
|
|
668
|
+
check "forgery-unsigned (annotated): exit 0" "$RC" "0"
|
|
669
|
+
check "forgery-unsigned (annotated): merged reason=bad-signature" "$OUT" "release_state=merged reason=bad-signature"
|
|
670
|
+
check_absent "forgery-unsigned (annotated): never validated" "$OUT" "release_state=validated"
|
|
671
|
+
|
|
672
|
+
# 10b: LIGHTWEIGHT lookalike (no tag object at all — nothing to verify).
|
|
673
|
+
d2="$(new_repo forgery-lightweight)"
|
|
674
|
+
write_milestone "$d2" 112 'milestone:
|
|
675
|
+
id: 112
|
|
676
|
+
name: "fixture: lightweight lookalike promotion tag"
|
|
677
|
+
|
|
678
|
+
current:
|
|
679
|
+
tier: standard
|
|
680
|
+
status: executing
|
|
681
|
+
last_updated: "2026-07-16"
|
|
682
|
+
human_verified: null
|
|
683
|
+
|
|
684
|
+
lifecycle:
|
|
685
|
+
worktree_mode: active'
|
|
686
|
+
deploy2="$(advance_main "$d2" "deploy: ship it")"
|
|
687
|
+
git -C "$d2" tag "promotion/$deploy2" "$deploy2"
|
|
688
|
+
write_promotion_binding "$d2" "$promotion_signers"
|
|
689
|
+
run_fold "$d2" m-112
|
|
690
|
+
check "forgery-lightweight: exit 0" "$RC" "0"
|
|
691
|
+
check "forgery-lightweight: merged reason=bad-signature" "$OUT" "release_state=merged reason=bad-signature"
|
|
692
|
+
check_absent "forgery-lightweight: never validated" "$OUT" "release_state=validated"
|
|
693
|
+
|
|
694
|
+
# 10c: tag SSH-signed by a key NOT in the promotion allowed-signers (the
|
|
695
|
+
# agent-minted lookalike, acceptance 5) → merged reason=bad-signature.
|
|
696
|
+
d3="$(new_repo forgery-wrongkey)"
|
|
697
|
+
write_milestone "$d3" 113 'milestone:
|
|
698
|
+
id: 113
|
|
699
|
+
name: "fixture: rogue-key promotion tag"
|
|
700
|
+
|
|
701
|
+
current:
|
|
702
|
+
tier: standard
|
|
703
|
+
status: executing
|
|
704
|
+
last_updated: "2026-07-16"
|
|
705
|
+
human_verified: null
|
|
706
|
+
|
|
707
|
+
lifecycle:
|
|
708
|
+
worktree_mode: active'
|
|
709
|
+
deploy3="$(advance_main "$d3" "deploy: ship it")"
|
|
710
|
+
mint_promotion_tag "$d3" "$deploy3" "$ROOT/agent_key"
|
|
711
|
+
write_promotion_binding "$d3" "$promotion_signers"
|
|
712
|
+
run_fold "$d3" m-113
|
|
713
|
+
check "forgery-wrong-key: exit 0" "$RC" "0"
|
|
714
|
+
check "forgery-wrong-key: merged reason=bad-signature" "$OUT" "release_state=merged reason=bad-signature"
|
|
715
|
+
check_absent "forgery-wrong-key: never validated" "$OUT" "release_state=validated"
|
|
716
|
+
|
|
717
|
+
# 10d: signers-file tamper (root-of-trust hardening) — the allowed-signers
|
|
718
|
+
# file is COMMITTED in-repo and points at the CI key only. An agent appends
|
|
719
|
+
# its own key to the WORKING-TREE copy and mints an own-key-signed tag. The
|
|
720
|
+
# fold reads the signers CONTENT from main-tree (like every other field), so
|
|
721
|
+
# the working-tree key is not trusted → merged reason=bad-signature. Proves
|
|
722
|
+
# an in-repo signers file cannot be forged from the working tree.
|
|
723
|
+
d4="$(new_repo forgery-signers-tamper)"
|
|
724
|
+
# committed signers file (CI key only), referenced by a repo-relative path.
|
|
725
|
+
mkdir -p "$d4/.forge"
|
|
726
|
+
printf 'ci@example.com namespaces="git" %s\n' "$(cat "$ROOT/ci_key.pub")" > "$d4/.forge/promotion_signers"
|
|
727
|
+
git -C "$d4" add ".forge/promotion_signers"
|
|
728
|
+
git -C "$d4" commit -qm "chore: commit CI-only promotion signers"
|
|
729
|
+
cat > "$d4/.forge/project.yml" <<'EOF'
|
|
730
|
+
forge:
|
|
731
|
+
validation_event: promotion_approval
|
|
732
|
+
promotion:
|
|
733
|
+
tag_pattern: "promotion/{sha}"
|
|
734
|
+
allowed_signers: ".forge/promotion_signers"
|
|
735
|
+
EOF
|
|
736
|
+
git -C "$d4" add ".forge/project.yml"
|
|
737
|
+
git -C "$d4" commit -qm "chore: promotion binding (in-repo signers)"
|
|
738
|
+
write_milestone "$d4" 114 'milestone:
|
|
739
|
+
id: 114
|
|
740
|
+
name: "fixture: in-repo signers, working-tree tampered"
|
|
741
|
+
|
|
742
|
+
current:
|
|
743
|
+
tier: standard
|
|
744
|
+
status: executing
|
|
745
|
+
last_updated: "2026-07-17"
|
|
746
|
+
human_verified: null
|
|
747
|
+
|
|
748
|
+
lifecycle:
|
|
749
|
+
worktree_mode: active'
|
|
750
|
+
deploy4="$(advance_main "$d4" "deploy: ship it")"
|
|
751
|
+
# positive control: genuine CI-signed tag validates via the MAIN-TREE signers.
|
|
752
|
+
mint_promotion_tag "$d4" "$deploy4" "$ROOT/ci_key"
|
|
753
|
+
run_fold "$d4" m-114
|
|
754
|
+
check "signers-tamper (control): CI tag validates via main-tree signers" "$OUT" "release_state=validated reason=promotion-approved"
|
|
755
|
+
# attack: append the rogue key to the WORKING-TREE signers (uncommitted),
|
|
756
|
+
# swap the tag for one signed by that rogue key.
|
|
757
|
+
git -C "$d4" tag -d "promotion/$deploy4" >/dev/null
|
|
758
|
+
printf 'agent@example.com namespaces="git" %s\n' "$(cat "$ROOT/agent_key.pub")" >> "$d4/.forge/promotion_signers"
|
|
759
|
+
mint_promotion_tag "$d4" "$deploy4" "$ROOT/agent_key"
|
|
760
|
+
run_fold "$d4" m-114
|
|
761
|
+
check "signers-tamper: exit 0" "$RC" "0"
|
|
762
|
+
check "signers-tamper: working-tree key NOT trusted → bad-signature" "$OUT" "release_state=merged reason=bad-signature"
|
|
763
|
+
check_absent "signers-tamper: never validated from a working-tree signers edit" "$OUT" "release_state=validated"
|
|
764
|
+
fi
|
|
765
|
+
|
|
766
|
+
# --- summary -------------------------------------------------------------------
|
|
767
|
+
if [ -n "$FILTER" ] && [ "$SELECTED" -eq 0 ]; then
|
|
768
|
+
printf 'ERROR: filter "%s" matched no cases (tags: unmerged merged grandfather human-verified signoff-tag stored-field exit-2 stable-sha ordering promotion forgery)\n' "$FILTER" >&2
|
|
769
|
+
exit 2
|
|
770
|
+
fi
|
|
771
|
+
|
|
772
|
+
printf '\n%d passed, %d failed (%d case blocks selected)\n' "$PASSED" "$FAILED" "$SELECTED"
|
|
773
|
+
[ "$FAILED" -eq 0 ]
|