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
package/bin/create-forge.js
CHANGED
|
@@ -709,6 +709,12 @@ async function upgrade() {
|
|
|
709
709
|
// merged separately by upgradeSettings().
|
|
710
710
|
upgradeAdditiveDir('.claude/hooks', results);
|
|
711
711
|
upgradeAdditiveDir('.claude/rules', results);
|
|
712
|
+
// 2-checks. Sync the merge-floor CI check scripts + fixture suites (0.58.2).
|
|
713
|
+
// Tracked in installed projects (unlike .claude/hooks/, which many projects
|
|
714
|
+
// gitignore as per-developer files) so CI can run them and the hold check's
|
|
715
|
+
// protected set (.forge/checks/**) can guard them. Additive for the same
|
|
716
|
+
// reason as above: never delete a project-local extra check.
|
|
717
|
+
upgradeAdditiveDir('.forge/checks', results);
|
|
712
718
|
|
|
713
719
|
// 2a. Confirm stale framework-file removals before deleting (R066a). Defaults
|
|
714
720
|
// to yes (cleanup is the normal case); auto-confirms when non-interactive
|
package/package.json
CHANGED
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
#!/usr/bin/env sh
|
|
2
|
+
# forge-release-fold — the fold-of-record: unit-id → release_state, computed
|
|
3
|
+
# from git truth alone (m-30, plan 50-01). Generalizes the wu-done.sh stub:
|
|
4
|
+
# where wu-done answers the single `done` predicate, this fold answers the
|
|
5
|
+
# full release ladder for a unit — unmerged → merged → validated — and each
|
|
6
|
+
# rung REQUIRES the one below it (validated requires merged, always checked
|
|
7
|
+
# first). No committed file stores release_state; this script recomputes it
|
|
8
|
+
# on every call, so a stored copy can never stick.
|
|
9
|
+
#
|
|
10
|
+
# forge-release-fold.sh <unit-id> [--sha <sha>] [--main <ref>] [--project <path>]
|
|
11
|
+
#
|
|
12
|
+
# unit-id m-{N} (milestone unit).
|
|
13
|
+
# --sha <sha> landed commit to fold over (skips resolution below).
|
|
14
|
+
# --main <ref> the mainline ref. Default: main.
|
|
15
|
+
# --project <path> operate on the repo at <path> (any dir inside it).
|
|
16
|
+
# Default: the repo containing the CWD.
|
|
17
|
+
#
|
|
18
|
+
# Landed-sha resolution when --sha is absent (the unit's stable identity):
|
|
19
|
+
# (1) live branch recorded for the unit — MAIN-TREE
|
|
20
|
+
# .forge/state/milestone-{N}.yml lifecycle.worktree_branch, else
|
|
21
|
+
# MAIN-TREE .forge/streams/m-{N}.yml runtime.branch → that branch's tip
|
|
22
|
+
# (when the ref exists).
|
|
23
|
+
# (2) no live branch, state file tracked in main's tree → the LAST COMMIT
|
|
24
|
+
# TOUCHING that state file on main. NEVER main's moving tip (operator
|
|
25
|
+
# ruling 2026-07-16): a tip-resolving fold flaps off as soon as main
|
|
26
|
+
# advances past the sign-off. Containment (phase 51) checks against
|
|
27
|
+
# THIS stable sha.
|
|
28
|
+
# (3) unknown in both → exit 2 — the fold cannot ask the question, so it
|
|
29
|
+
# never emits a false verdict.
|
|
30
|
+
#
|
|
31
|
+
# merged = `git merge-base --is-ancestor <sha> <main-ref>`.
|
|
32
|
+
#
|
|
33
|
+
# Validation is dispatched on forge.validation_event from the project's
|
|
34
|
+
# .forge/project.yml (default: in_session_signoff; promotion_approval is the
|
|
35
|
+
# opt-in host-truth binding, plan 51-01). Under in_session_signoff, applied
|
|
36
|
+
# only when merged, in order:
|
|
37
|
+
# (a) signoff tag: refs/tags/signoff/<unit-id> verifies via verify-signoff.sh
|
|
38
|
+
# (allowed signers from git config gpg.ssh.allowedSignersFile) against
|
|
39
|
+
# the resolved landed sha → validated reason=signed-off. A tag that
|
|
40
|
+
# FAILS verification yields nothing — fall through to the YAML routes.
|
|
41
|
+
# verify-signoff exit 2 (unconfigured) → route unavailable, silent.
|
|
42
|
+
# (b) main-tree milestone current.human_verified set and non-null
|
|
43
|
+
# → validated (today's YAML mechanism, preserved honestly).
|
|
44
|
+
# (c) main-tree current.status == complete → validated reason=legacy-complete
|
|
45
|
+
# (the FORGE.md grandfather — pre-gate closed milestones never
|
|
46
|
+
# retro-invalidate).
|
|
47
|
+
# (d) none of the above → merged (reason=bad-signature when a signoff tag
|
|
48
|
+
# exists but its signature failed, else reason=no-signoff).
|
|
49
|
+
#
|
|
50
|
+
# Under promotion_approval (B4 rev 2 — CI-signed tag, verified OFFLINE), the
|
|
51
|
+
# promotion artifact is the ONLY validation route — the YAML/signoff routes
|
|
52
|
+
# above never apply (host truth, not repo-side files). Tag pattern FROZEN
|
|
53
|
+
# 2026-07-16: promotion/{sha}, {sha} = the full DEPLOY sha the host-side
|
|
54
|
+
# Environment-approved job signed off (not per-unit). Verification is
|
|
55
|
+
# CONTAINMENT, not tip-equality (operator ruling 2026-07-16): enumerate tags
|
|
56
|
+
# by the pattern's prefix; a candidate validates the unit iff its SSH
|
|
57
|
+
# signature verifies against the promotion allowed-signers AND the unit's
|
|
58
|
+
# landed sha is an ancestor of the tag's target commit. First verified
|
|
59
|
+
# containing tag → validated reason=promotion-approved. Otherwise merged with
|
|
60
|
+
# reason=not-contained (a verified tag exists, unit outside it) |
|
|
61
|
+
# bad-signature (candidates exist, none verify — unsigned/lightweight/
|
|
62
|
+
# wrong-key lookalikes land here) | no-approval (no candidate tags at all).
|
|
63
|
+
# Binding config (forge.promotion.*): tag_pattern (default promotion/{sha}),
|
|
64
|
+
# allowed_signers (path; empty ⇒ git config gpg.ssh.allowedSignersFile).
|
|
65
|
+
#
|
|
66
|
+
# ALL milestone/stream YAML reads go through `git show <main-ref>:<path>` —
|
|
67
|
+
# never the working tree. An agent-writable working-tree edit must not flip
|
|
68
|
+
# the render (contract F7-F9). The validation MODE (project.yml) is a
|
|
69
|
+
# working-tree read — that file is protected-path-held per the contract. The
|
|
70
|
+
# promotion allowed-signers FILE (the signature root-of-trust) is likewise
|
|
71
|
+
# read main-tree when it is tracked in main (see validate_promotion_approval)
|
|
72
|
+
# — an in-repo signers file must not be forgeable from the working tree.
|
|
73
|
+
#
|
|
74
|
+
# stdout (machine-parseable, nothing else):
|
|
75
|
+
# release_state=unmerged reason=not-on-main
|
|
76
|
+
# release_state=merged reason=<no-signoff|no-approval|bad-signature|not-contained>
|
|
77
|
+
# release_state=validated [reason=<signed-off|legacy-complete|promotion-approved>]
|
|
78
|
+
# Human-readable detail goes to stderr.
|
|
79
|
+
#
|
|
80
|
+
# Exit codes (mirrors wu-done.sh):
|
|
81
|
+
# 0 a state was computed — ANY of the three. unmerged is an answer, not
|
|
82
|
+
# an error.
|
|
83
|
+
# 2 bad invocation / unknown unit / unconfigured mode — no verdict emitted.
|
|
84
|
+
#
|
|
85
|
+
# Portable: POSIX sh + git + awk/sed. No network calls anywhere (NFR-030) —
|
|
86
|
+
# works in a repo with no remote at all.
|
|
87
|
+
set -eu
|
|
88
|
+
|
|
89
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
90
|
+
VERIFY="$SCRIPT_DIR/verify-signoff.sh"
|
|
91
|
+
|
|
92
|
+
usage() {
|
|
93
|
+
printf 'usage: forge-release-fold.sh <unit-id> [--sha <sha>] [--main <ref>] [--project <path>]\n' >&2
|
|
94
|
+
exit 2
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
# --- helpers -------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
# yaml_get <content> <block> <key> — scalar value of a 2-space-indented key
|
|
100
|
+
# inside a top-level block. Strips trailing `# comments`. Prints the raw
|
|
101
|
+
# value (possibly empty); never fails.
|
|
102
|
+
yaml_get() {
|
|
103
|
+
printf '%s\n' "$1" | awk -v blk="$2" -v key="$3" '
|
|
104
|
+
/^[^ \t]/ { inblk = (index($0, blk ":") == 1) }
|
|
105
|
+
inblk && index($0, " " key ":") == 1 {
|
|
106
|
+
val = substr($0, length(key) + 4)
|
|
107
|
+
sub(/[ \t]#.*$/, "", val)
|
|
108
|
+
if (val ~ /^[ \t]*#/) val = ""
|
|
109
|
+
gsub(/^[ \t]+/, "", val); gsub(/[ \t]+$/, "", val)
|
|
110
|
+
print val
|
|
111
|
+
exit
|
|
112
|
+
}
|
|
113
|
+
'
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
# unquote <value> — strip one layer of surrounding double or single quotes.
|
|
117
|
+
unquote() {
|
|
118
|
+
uq="$1"
|
|
119
|
+
uq="${uq#\"}"; uq="${uq%\"}"
|
|
120
|
+
uq="${uq#"'"}"; uq="${uq%"'"}"
|
|
121
|
+
printf '%s\n' "$uq"
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
yaml_scalar() { # <content> <block> <key> — yaml_get + unquote
|
|
125
|
+
unquote "$(yaml_get "$1" "$2" "$3")"
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
# yaml_sub_get <content> <block> <subblock> <key> — scalar value of a 4-space-
|
|
129
|
+
# indented key inside a 2-space-indented sub-block of a top-level block
|
|
130
|
+
# (e.g. forge: → promotion: → tag_pattern:). Same comment-stripping contract
|
|
131
|
+
# as yaml_get; never fails.
|
|
132
|
+
yaml_sub_get() {
|
|
133
|
+
printf '%s\n' "$1" | awk -v blk="$2" -v sb="$3" -v key="$4" '
|
|
134
|
+
/^[^ \t]/ { inblk = (index($0, blk ":") == 1); insb = 0 }
|
|
135
|
+
inblk && /^ [^ \t]/ { insb = (index($0, " " sb ":") == 1) }
|
|
136
|
+
insb && index($0, " " key ":") == 1 {
|
|
137
|
+
val = substr($0, length(key) + 6)
|
|
138
|
+
sub(/[ \t]#.*$/, "", val)
|
|
139
|
+
if (val ~ /^[ \t]*#/) val = ""
|
|
140
|
+
gsub(/^[ \t]+/, "", val); gsub(/[ \t]+$/, "", val)
|
|
141
|
+
print val
|
|
142
|
+
exit
|
|
143
|
+
}
|
|
144
|
+
'
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
yaml_sub_scalar() { # <content> <block> <subblock> <key> — yaml_sub_get + unquote
|
|
148
|
+
unquote "$(yaml_sub_get "$1" "$2" "$3" "$4")"
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
# hv_is_set <milestone-yaml> — prints "set" iff current.human_verified is
|
|
152
|
+
# populated: a non-empty non-null scalar, or an empty value followed by an
|
|
153
|
+
# indented child block (`at:` etc.). Literal null/~ or absence is NOT set.
|
|
154
|
+
hv_is_set() {
|
|
155
|
+
printf '%s\n' "$1" | awk '
|
|
156
|
+
/^[^ \t]/ { incur = (index($0, "current:") == 1); look = 0 }
|
|
157
|
+
incur && index($0, " human_verified:") == 1 {
|
|
158
|
+
val = substr($0, 19)
|
|
159
|
+
sub(/[ \t]#.*$/, "", val)
|
|
160
|
+
if (val ~ /^[ \t]*#/) val = ""
|
|
161
|
+
gsub(/[ \t"]/, "", val)
|
|
162
|
+
if (val == "") { look = 1; next }
|
|
163
|
+
if (val != "null" && val != "~") print "set"
|
|
164
|
+
exit
|
|
165
|
+
}
|
|
166
|
+
look {
|
|
167
|
+
if ($0 ~ /^[ \t]*$/) next
|
|
168
|
+
if ($0 ~ /^ /) print "set"
|
|
169
|
+
exit
|
|
170
|
+
}
|
|
171
|
+
'
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
# --- argument parsing ------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
unit=""
|
|
177
|
+
sha_arg=""
|
|
178
|
+
main_ref="main"
|
|
179
|
+
project="."
|
|
180
|
+
|
|
181
|
+
while [ $# -gt 0 ]; do
|
|
182
|
+
case "$1" in
|
|
183
|
+
--sha) shift; sha_arg="${1:-}"; [ $# -gt 0 ] && shift || usage ;;
|
|
184
|
+
--sha=*) sha_arg="${1#*=}"; shift ;;
|
|
185
|
+
--main) shift; main_ref="${1:-}"; [ $# -gt 0 ] && shift || usage ;;
|
|
186
|
+
--main=*) main_ref="${1#*=}"; shift ;;
|
|
187
|
+
--project) shift; project="${1:-}"; [ $# -gt 0 ] && shift || usage ;;
|
|
188
|
+
--project=*) project="${1#*=}"; shift ;;
|
|
189
|
+
-*) printf 'forge-release-fold: unknown option: %s\n' "$1" >&2; usage ;;
|
|
190
|
+
*)
|
|
191
|
+
if [ -z "$unit" ]; then unit="$1"; shift
|
|
192
|
+
else printf 'forge-release-fold: unexpected argument: %s\n' "$1" >&2; usage; fi
|
|
193
|
+
;;
|
|
194
|
+
esac
|
|
195
|
+
done
|
|
196
|
+
|
|
197
|
+
[ -n "$unit" ] || usage
|
|
198
|
+
case "$unit" in
|
|
199
|
+
m-*) ;;
|
|
200
|
+
*) printf 'forge-release-fold: unit-id must be m-<N>, got: %s\n' "$unit" >&2; usage ;;
|
|
201
|
+
esac
|
|
202
|
+
num="${unit#m-}"
|
|
203
|
+
case "$num" in
|
|
204
|
+
''|*[!0-9]*) printf 'forge-release-fold: unit-id must be m-<N>, got: %s\n' "$unit" >&2; usage ;;
|
|
205
|
+
esac
|
|
206
|
+
|
|
207
|
+
# --- repo + main ref -------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
if ! root="$(git -C "$project" rev-parse --show-toplevel 2>/dev/null)"; then
|
|
210
|
+
printf 'forge-release-fold: not a git repository: %s\n' "$project" >&2
|
|
211
|
+
exit 2
|
|
212
|
+
fi
|
|
213
|
+
|
|
214
|
+
if ! git -C "$root" rev-parse -q --verify "$main_ref^{commit}" >/dev/null 2>&1; then
|
|
215
|
+
printf 'forge-release-fold: cannot resolve main ref: %s\n' "$main_ref" >&2
|
|
216
|
+
exit 2
|
|
217
|
+
fi
|
|
218
|
+
|
|
219
|
+
state_path=".forge/state/milestone-$num.yml"
|
|
220
|
+
stream_path=".forge/streams/$unit.yml"
|
|
221
|
+
|
|
222
|
+
# MAIN-TREE state read — never the working tree. Empty when not tracked.
|
|
223
|
+
state_yaml=""
|
|
224
|
+
if git -C "$root" cat-file -e "$main_ref:$state_path" 2>/dev/null; then
|
|
225
|
+
state_yaml="$(git -C "$root" show "$main_ref:$state_path")"
|
|
226
|
+
fi
|
|
227
|
+
|
|
228
|
+
# --- landed-sha resolution ---------------------------------------------------
|
|
229
|
+
|
|
230
|
+
# (1) live branch recorded for the unit (main-tree reads only).
|
|
231
|
+
branch=""
|
|
232
|
+
if [ -n "$state_yaml" ]; then
|
|
233
|
+
branch="$(yaml_scalar "$state_yaml" lifecycle worktree_branch)"
|
|
234
|
+
case "$branch" in null|'~') branch="" ;; esac
|
|
235
|
+
fi
|
|
236
|
+
if [ -z "$branch" ]; then
|
|
237
|
+
if git -C "$root" cat-file -e "$main_ref:$stream_path" 2>/dev/null; then
|
|
238
|
+
stream_yaml="$(git -C "$root" show "$main_ref:$stream_path")"
|
|
239
|
+
branch="$(yaml_scalar "$stream_yaml" runtime branch)"
|
|
240
|
+
case "$branch" in null|'~') branch="" ;; esac
|
|
241
|
+
fi
|
|
242
|
+
fi
|
|
243
|
+
|
|
244
|
+
sha=""
|
|
245
|
+
if [ -n "$sha_arg" ]; then
|
|
246
|
+
if ! sha="$(git -C "$root" rev-parse -q --verify "$sha_arg^{commit}" 2>/dev/null)"; then
|
|
247
|
+
printf 'forge-release-fold: --sha does not resolve to a commit: %s\n' "$sha_arg" >&2
|
|
248
|
+
exit 2
|
|
249
|
+
fi
|
|
250
|
+
else
|
|
251
|
+
if [ -n "$branch" ]; then
|
|
252
|
+
if tip="$(git -C "$root" rev-parse -q --verify "refs/heads/$branch^{commit}" 2>/dev/null)"; then
|
|
253
|
+
sha="$tip"
|
|
254
|
+
fi
|
|
255
|
+
fi
|
|
256
|
+
# (2) tracked-only → last commit touching the state file on main — the
|
|
257
|
+
# stable identity. NEVER the moving tip of main (anti-flap ruling).
|
|
258
|
+
if [ -z "$sha" ] && [ -n "$state_yaml" ]; then
|
|
259
|
+
if landed="$(git -C "$root" log -1 --format=%H "$main_ref" -- "$state_path" 2>/dev/null)" \
|
|
260
|
+
&& [ -n "$landed" ]; then
|
|
261
|
+
sha="$landed"
|
|
262
|
+
fi
|
|
263
|
+
fi
|
|
264
|
+
# (3) unknown unit → exit 2, no verdict.
|
|
265
|
+
if [ -z "$sha" ]; then
|
|
266
|
+
printf 'forge-release-fold: unknown unit %s — no live branch recorded and %s is not tracked in %s\n' \
|
|
267
|
+
"$unit" "$state_path" "$main_ref" >&2
|
|
268
|
+
exit 2
|
|
269
|
+
fi
|
|
270
|
+
fi
|
|
271
|
+
|
|
272
|
+
# --- rung 1: merged? ---------------------------------------------------------
|
|
273
|
+
# validated REQUIRES merged — checked first, always. An unmerged unit is
|
|
274
|
+
# unmerged no matter what any approval artifact says.
|
|
275
|
+
|
|
276
|
+
if ! git -C "$root" merge-base --is-ancestor "$sha" "$main_ref" 2>/dev/null; then
|
|
277
|
+
printf 'release_state=unmerged reason=not-on-main\n'
|
|
278
|
+
exit 0
|
|
279
|
+
fi
|
|
280
|
+
|
|
281
|
+
# --- rung 2: validation, dispatched on the binding mode ----------------------
|
|
282
|
+
|
|
283
|
+
validate_in_session_signoff() {
|
|
284
|
+
tag_fail=""
|
|
285
|
+
tag_ref="refs/tags/signoff/$unit"
|
|
286
|
+
|
|
287
|
+
# (a) signoff-tag route: verify-signoff machinery, when usable. The fold
|
|
288
|
+
# constructs the invocation itself: --head is the landed sha it resolved;
|
|
289
|
+
# --evidence is extracted from the tag body (the signature + head checks
|
|
290
|
+
# are the real gate — a self-consistent evidence line cannot mismatch).
|
|
291
|
+
if git -C "$root" rev-parse -q --verify "$tag_ref" >/dev/null 2>&1 && [ -x "$VERIFY" ]; then
|
|
292
|
+
signers="$(git -C "$root" config --get gpg.ssh.allowedSignersFile 2>/dev/null || true)"
|
|
293
|
+
if [ -n "$signers" ]; then
|
|
294
|
+
# Tag message body: strip headers up to the first blank line, stop at
|
|
295
|
+
# the embedded signature block (same parse as verify-signoff.sh).
|
|
296
|
+
tag_msg="$(git -C "$root" cat-file -p "$tag_ref" \
|
|
297
|
+
| awk 'f{ if ($0 ~ /-----BEGIN SSH SIGNATURE-----/) exit; print } /^$/{ f=1 }')"
|
|
298
|
+
tag_evidence="$(printf '%s\n' "$tag_msg" | sed -n 's/^evidence: *//p' | head -1)"
|
|
299
|
+
if [ -n "$tag_evidence" ]; then
|
|
300
|
+
# if-guarded so errexit never aborts on a non-zero verify verdict.
|
|
301
|
+
if vout="$( (cd "$root" && "$VERIFY" "$unit" --head "$sha" \
|
|
302
|
+
--evidence "$tag_evidence" --allowed-signers "$signers") 2>/dev/null)"; then
|
|
303
|
+
vrc=0
|
|
304
|
+
else
|
|
305
|
+
vrc=$?
|
|
306
|
+
fi
|
|
307
|
+
if [ "$vrc" -eq 0 ]; then
|
|
308
|
+
printf 'release_state=validated reason=signed-off\n'
|
|
309
|
+
exit 0
|
|
310
|
+
elif [ "$vrc" -eq 1 ] && [ "$vout" = "reason=bad-signature" ]; then
|
|
311
|
+
tag_fail="bad-signature"
|
|
312
|
+
fi
|
|
313
|
+
# vrc 2 (unconfigured) or other exit-1 reasons (stale, mismatch):
|
|
314
|
+
# the tag route yields nothing — fall through to the YAML routes.
|
|
315
|
+
fi
|
|
316
|
+
fi
|
|
317
|
+
fi
|
|
318
|
+
|
|
319
|
+
# (b) human_verified route — main-tree read (git show), never working tree.
|
|
320
|
+
if [ -n "$state_yaml" ] && [ "$(hv_is_set "$state_yaml")" = "set" ]; then
|
|
321
|
+
printf 'release_state=validated\n'
|
|
322
|
+
exit 0
|
|
323
|
+
fi
|
|
324
|
+
|
|
325
|
+
# (c) legacy-complete grandfather — pre-gate closed milestones stay valid.
|
|
326
|
+
if [ -n "$state_yaml" ]; then
|
|
327
|
+
status="$(yaml_scalar "$state_yaml" current status)"
|
|
328
|
+
if [ "$status" = "complete" ]; then
|
|
329
|
+
printf 'release_state=validated reason=legacy-complete\n'
|
|
330
|
+
exit 0
|
|
331
|
+
fi
|
|
332
|
+
fi
|
|
333
|
+
|
|
334
|
+
# (d) merged, not validated.
|
|
335
|
+
if [ -n "$tag_fail" ]; then
|
|
336
|
+
printf 'release_state=merged reason=%s\n' "$tag_fail"
|
|
337
|
+
else
|
|
338
|
+
printf 'release_state=merged reason=no-signoff\n'
|
|
339
|
+
fi
|
|
340
|
+
exit 0
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
validate_promotion_approval() {
|
|
344
|
+
# Binding config — same working-tree project.yml read as the mode itself
|
|
345
|
+
# (protected-path-held per the contract). Pattern FROZEN: promotion/{sha}.
|
|
346
|
+
tag_pattern="$(yaml_sub_scalar "$project_yml" forge promotion tag_pattern)"
|
|
347
|
+
[ -n "$tag_pattern" ] || tag_pattern="promotion/{sha}"
|
|
348
|
+
case "$tag_pattern" in
|
|
349
|
+
*'{sha}'*) ;;
|
|
350
|
+
*)
|
|
351
|
+
printf 'forge-release-fold: forge.promotion.tag_pattern must contain {sha}, got: %s\n' "$tag_pattern" >&2
|
|
352
|
+
exit 2
|
|
353
|
+
;;
|
|
354
|
+
esac
|
|
355
|
+
prefix="${tag_pattern%%\{sha\}*}"
|
|
356
|
+
|
|
357
|
+
# Allowed signers: the CI key's public half (committed, public keys only).
|
|
358
|
+
# Empty ⇒ git config gpg.ssh.allowedSignersFile. Neither → unconfigured
|
|
359
|
+
# mode, exit 2 — never a false verdict.
|
|
360
|
+
signers="$(yaml_sub_scalar "$project_yml" forge promotion allowed_signers)"
|
|
361
|
+
case "$signers" in null|'~') signers="" ;; esac
|
|
362
|
+
if [ -z "$signers" ]; then
|
|
363
|
+
signers="$(git -C "$root" config --get gpg.ssh.allowedSignersFile 2>/dev/null || true)"
|
|
364
|
+
fi
|
|
365
|
+
if [ -z "$signers" ]; then
|
|
366
|
+
printf 'forge-release-fold: promotion_approval is unconfigured — set forge.promotion.allowed_signers or git config gpg.ssh.allowedSignersFile\n' >&2
|
|
367
|
+
exit 2
|
|
368
|
+
fi
|
|
369
|
+
case "$signers" in
|
|
370
|
+
/*) ;;
|
|
371
|
+
*) signers="$root/$signers" ;;
|
|
372
|
+
esac
|
|
373
|
+
|
|
374
|
+
# Root-of-trust hardening — read the signers CONTENT from main-tree when the
|
|
375
|
+
# file is tracked in main, exactly like every milestone/stream field above
|
|
376
|
+
# (header invariant: the working tree is never the fold's source of truth).
|
|
377
|
+
# Without this, an agent that appends its own public key to the WORKING-TREE
|
|
378
|
+
# copy of the committed signers file, then mints an own-key-signed promotion
|
|
379
|
+
# tag, forges `validated` — and because boot re-renders through this same
|
|
380
|
+
# fold, the forgery would PERSIST across boots (defeating the F7-F9
|
|
381
|
+
# self-correction the whole trust story leans on). A signers path OUTSIDE the
|
|
382
|
+
# repo (absolute host path, or the git-config fallback pointing outside) is
|
|
383
|
+
# host-managed — trusted as-is, not agent-writable in the threat model.
|
|
384
|
+
case "$signers" in
|
|
385
|
+
"$root"/*)
|
|
386
|
+
signers_rel="${signers#"$root"/}"
|
|
387
|
+
if git -C "$root" cat-file -e "$main_ref:$signers_rel" 2>/dev/null; then
|
|
388
|
+
signers_main="$(mktemp)"
|
|
389
|
+
trap 'rm -f "$signers_main"' EXIT INT TERM
|
|
390
|
+
git -C "$root" show "$main_ref:$signers_rel" > "$signers_main"
|
|
391
|
+
signers="$signers_main"
|
|
392
|
+
fi
|
|
393
|
+
# In-repo but NOT tracked in main → untracked/git-ignored host file:
|
|
394
|
+
# left as-is (same trust as before; no tracked copy to prefer).
|
|
395
|
+
;;
|
|
396
|
+
esac
|
|
397
|
+
|
|
398
|
+
# Enumerate candidate tags by the frozen pattern's prefix; first verified
|
|
399
|
+
# CONTAINING tag validates (a promotion approves a deploy sha; every unit
|
|
400
|
+
# whose landed sha is an ancestor of it is covered — never tip-equality).
|
|
401
|
+
saw_candidate=0
|
|
402
|
+
saw_verified=0
|
|
403
|
+
for tag in $(git -C "$root" tag -l "${prefix}*"); do
|
|
404
|
+
saw_candidate=1
|
|
405
|
+
# Offline SSH-signature verification: explicit -c gpg.format=ssh +
|
|
406
|
+
# allowedSignersFile so a scrubbed environment can still verify — the
|
|
407
|
+
# verify-signoff.sh:120-135 invocation, reused verbatim (no shared-lib
|
|
408
|
+
# extraction for 3 lines; Article III).
|
|
409
|
+
if git -C "$root" -c gpg.format=ssh -c "gpg.ssh.allowedSignersFile=$signers" \
|
|
410
|
+
tag -v "$tag" >/dev/null 2>&1; then
|
|
411
|
+
saw_verified=1
|
|
412
|
+
if git -C "$root" merge-base --is-ancestor "$sha" "$tag^{commit}" 2>/dev/null; then
|
|
413
|
+
printf 'release_state=validated reason=promotion-approved\n'
|
|
414
|
+
exit 0
|
|
415
|
+
fi
|
|
416
|
+
fi
|
|
417
|
+
done
|
|
418
|
+
|
|
419
|
+
# No verified containing tag. Most-specific reason wins: a genuine tag that
|
|
420
|
+
# simply doesn't cover this unit (not-contained) beats lookalike noise
|
|
421
|
+
# (bad-signature) beats silence (no-approval).
|
|
422
|
+
if [ "$saw_verified" -eq 1 ]; then
|
|
423
|
+
printf 'release_state=merged reason=not-contained\n'
|
|
424
|
+
elif [ "$saw_candidate" -eq 1 ]; then
|
|
425
|
+
printf 'release_state=merged reason=bad-signature\n'
|
|
426
|
+
else
|
|
427
|
+
printf 'release_state=merged reason=no-approval\n'
|
|
428
|
+
fi
|
|
429
|
+
exit 0
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
# Validation MODE comes from the project's working-tree config (the mode file
|
|
433
|
+
# is protected-path-held per the contract); every FIELD read above/below is
|
|
434
|
+
# main-tree only. Default when absent: in_session_signoff.
|
|
435
|
+
project_yml=""
|
|
436
|
+
if [ -f "$root/.forge/project.yml" ]; then
|
|
437
|
+
project_yml="$(cat "$root/.forge/project.yml")"
|
|
438
|
+
fi
|
|
439
|
+
validation_event="$(yaml_scalar "$project_yml" forge validation_event)"
|
|
440
|
+
[ -n "$validation_event" ] || validation_event="in_session_signoff"
|
|
441
|
+
|
|
442
|
+
# Single dispatch point — both arms of the B3 binding.
|
|
443
|
+
case "$validation_event" in
|
|
444
|
+
in_session_signoff)
|
|
445
|
+
validate_in_session_signoff
|
|
446
|
+
;;
|
|
447
|
+
promotion_approval)
|
|
448
|
+
validate_promotion_approval
|
|
449
|
+
;;
|
|
450
|
+
*)
|
|
451
|
+
printf 'forge-release-fold: unknown validation_event: %s\n' "$validation_event" >&2
|
|
452
|
+
exit 2
|
|
453
|
+
;;
|
|
454
|
+
esac
|
|
455
|
+
|
|
456
|
+
# Unreachable — every dispatch branch exits. Defensive: never fall off the
|
|
457
|
+
# end into an implicit success with no verdict printed.
|
|
458
|
+
exit 2
|