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,404 @@
|
|
|
1
|
+
#!/usr/bin/env sh
|
|
2
|
+
# Fixture-repo test suite for .forge/checks/forge-hold-check.sh (m-31, plan 53-01).
|
|
3
|
+
#
|
|
4
|
+
# Contract under test (plan-01 "Locked interface" — do not re-derive):
|
|
5
|
+
# forge-hold-check.sh <base>...<head> [--approvals <file>]
|
|
6
|
+
# stdout: hold=clear (no facts)
|
|
7
|
+
# hold=blocked + fact=<class>:<path> lines (facts, no operator act)
|
|
8
|
+
# hold=waived waived_by=<login>
|
|
9
|
+
# + "fact=<class>:<path> waived" (facts + operator approval)
|
|
10
|
+
# class ∈ irreversible|structural|protected
|
|
11
|
+
# exit: 0 = clear or waived; 1 = blocked; 2 = bad invocation (missing arg,
|
|
12
|
+
# non-three-dot range, unresolvable refs, missing --approvals file).
|
|
13
|
+
# Range MUST be three-dot BASE...HEAD; the fact base is merge-base(BASE, HEAD).
|
|
14
|
+
# Config .forge/hold-config.yml is read from the BASE side only
|
|
15
|
+
# (git show <merge-base>:...) — a PR cannot rewrite the rules it is judged by.
|
|
16
|
+
# Built-ins are hard-coded and config is ADDITIVE only (raise-only):
|
|
17
|
+
# irreversible: */migrations/*, */migrate/*, db/schema*, *.sql (+config irreversible_paths)
|
|
18
|
+
# structural: NEW dependency key in package.json, composer.json, pyproject.toml,
|
|
19
|
+
# requirements*.txt, Gemfile, go.mod, Cargo.toml — version-value
|
|
20
|
+
# changes to an existing key NEVER fire; lockfiles (package-lock.json,
|
|
21
|
+
# yarn.lock, pnpm-lock.yaml, composer.lock, Gemfile.lock, go.sum,
|
|
22
|
+
# Cargo.lock, uv.lock, poetry.lock) ignored entirely (pain 6)
|
|
23
|
+
# protected: .github/workflows/*, .gitlab-ci.yml, .forge/project.yml,
|
|
24
|
+
# .forge/hold-config.yml, .forge/checks/** (scripts + fixture
|
|
25
|
+
# suites) (+config deploy_paths, gating_tests)
|
|
26
|
+
# Waiver: any --approvals login ∈ base-side config operators: → waived. Absent
|
|
27
|
+
# config / empty operators → NO waiver possible (raise-only safe default).
|
|
28
|
+
#
|
|
29
|
+
# Builds throwaway git repos in mktemp dirs with NO remote configured anywhere —
|
|
30
|
+
# offline by construction (NFR-032); any network dependency in the script breaks here.
|
|
31
|
+
#
|
|
32
|
+
# Usage:
|
|
33
|
+
# ./.forge/checks/tests/forge-hold-check.test.sh # all cases
|
|
34
|
+
# ./.forge/checks/tests/forge-hold-check.test.sh pain6 # one tag
|
|
35
|
+
# Filter tags: irreversible structural pain6 waiver self-protect docs-only invocation multi
|
|
36
|
+
#
|
|
37
|
+
# RED-phase friendly (TDD): a missing/non-executable helper does NOT abort the
|
|
38
|
+
# suite — every selected case FAILS with "(helper missing ...)" as the got-value
|
|
39
|
+
# and the suite exits non-zero. Exits 0 iff every selected case passes. Self-cleans.
|
|
40
|
+
set -u
|
|
41
|
+
|
|
42
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
43
|
+
HELPER="$SCRIPT_DIR/../forge-hold-check.sh"
|
|
44
|
+
|
|
45
|
+
[ -x "$HELPER" ] || printf 'NOTE: %s missing or not executable — RED phase, every selected case below should FAIL\n' "$HELPER" >&2
|
|
46
|
+
|
|
47
|
+
ROOT="$(mktemp -d)"
|
|
48
|
+
trap 'rm -rf "$ROOT"' EXIT INT TERM
|
|
49
|
+
|
|
50
|
+
# Isolate from the developer's global/system git config.
|
|
51
|
+
GIT_CONFIG_GLOBAL=/dev/null
|
|
52
|
+
GIT_CONFIG_SYSTEM=/dev/null
|
|
53
|
+
export GIT_CONFIG_GLOBAL GIT_CONFIG_SYSTEM
|
|
54
|
+
|
|
55
|
+
PASSED=0
|
|
56
|
+
FAILED=0
|
|
57
|
+
SELECTED=0
|
|
58
|
+
FILTER="${1:-}"
|
|
59
|
+
|
|
60
|
+
pass() { PASSED=$((PASSED + 1)); printf 'PASS: %s\n' "$1"; }
|
|
61
|
+
fail() { FAILED=$((FAILED + 1)); printf 'FAIL: %s\n' "$1" >&2; }
|
|
62
|
+
check() { # desc, actual, expected (exact match)
|
|
63
|
+
if [ "$2" = "$3" ]; then pass "$1"; else fail "$1 (got '$2', want '$3')"; fi
|
|
64
|
+
}
|
|
65
|
+
check_contains() { # desc, actual, needle
|
|
66
|
+
case "$2" in
|
|
67
|
+
*"$3"*) pass "$1" ;;
|
|
68
|
+
*) fail "$1 (got '$2', must contain '$3')" ;;
|
|
69
|
+
esac
|
|
70
|
+
}
|
|
71
|
+
check_absent() { # desc, actual, forbidden-substring
|
|
72
|
+
case "$2" in
|
|
73
|
+
*"$3"*) fail "$1 (got '$2', must NOT contain '$3')" ;;
|
|
74
|
+
*) pass "$1" ;;
|
|
75
|
+
esac
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
running() { # tag... — 0 if this case block is selected by $FILTER (empty = all)
|
|
79
|
+
if [ -z "$FILTER" ]; then SELECTED=$((SELECTED + 1)); return 0; fi
|
|
80
|
+
for _t in "$@"; do
|
|
81
|
+
if [ "$_t" = "$FILTER" ]; then SELECTED=$((SELECTED + 1)); return 0; fi
|
|
82
|
+
done
|
|
83
|
+
return 1
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
# Run the helper from a repo cwd; sets OUT (stdout) and RC. RED-friendly.
|
|
87
|
+
run_hold() { # cwd, helper-args...
|
|
88
|
+
rh_cwd="$1"; shift
|
|
89
|
+
if [ -x "$HELPER" ]; then
|
|
90
|
+
if OUT="$(cd "$rh_cwd" && "$HELPER" "$@" 2>/dev/null)"; then RC=0; else RC=$?; fi
|
|
91
|
+
else
|
|
92
|
+
OUT="(helper missing or not executable: $HELPER)"
|
|
93
|
+
RC=127
|
|
94
|
+
fi
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
# NO `git remote add` anywhere: fixtures are remote-less by construction (NFR-032).
|
|
98
|
+
new_repo() { # name → prints repo path (main branch, one root commit)
|
|
99
|
+
d="$ROOT/repo.$1"
|
|
100
|
+
mkdir -p "$d"
|
|
101
|
+
git -C "$d" init -q
|
|
102
|
+
git -C "$d" symbolic-ref HEAD refs/heads/main
|
|
103
|
+
git -C "$d" config user.email t@example.com
|
|
104
|
+
git -C "$d" config user.name tester
|
|
105
|
+
git -C "$d" commit --allow-empty -qm init
|
|
106
|
+
printf '%s\n' "$d"
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
put() { # repo, path, content — write + stage + commit on current branch
|
|
110
|
+
p_dir=$(dirname "$1/$2")
|
|
111
|
+
mkdir -p "$p_dir"
|
|
112
|
+
printf '%s\n' "$3" > "$1/$2"
|
|
113
|
+
git -C "$1" add "$2"
|
|
114
|
+
git -C "$1" commit -qm "edit $2"
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
pr() { # repo — create + switch to branch 'pr' off main
|
|
118
|
+
git -C "$1" checkout -qb pr main
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
approvals() { # name, login... → prints file path with one login per line
|
|
122
|
+
a_f="$ROOT/approvals.$1"
|
|
123
|
+
: > "$a_f"
|
|
124
|
+
for _l in "$@"; do [ "$_l" = "$1" ] && continue; printf '%s\n' "$_l" >> "$a_f"; done
|
|
125
|
+
printf '%s\n' "$a_f"
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
CONFIG_OPS='version: 1
|
|
129
|
+
operators:
|
|
130
|
+
- zayne
|
|
131
|
+
irreversible_paths: []
|
|
132
|
+
deploy_paths: []
|
|
133
|
+
gating_tests: []'
|
|
134
|
+
|
|
135
|
+
# ---------------------------------------------------------------------------
|
|
136
|
+
# irreversible — acceptance 1: migration fires naming the fact; neg control clean
|
|
137
|
+
# ---------------------------------------------------------------------------
|
|
138
|
+
if running irreversible; then
|
|
139
|
+
R=$(new_repo irr)
|
|
140
|
+
put "$R" src/app.js 'console.log(1)'
|
|
141
|
+
pr "$R"
|
|
142
|
+
put "$R" app/migrations/001_add_users.py 'create table users'
|
|
143
|
+
put "$R" src/app.js 'console.log(2)'
|
|
144
|
+
run_hold "$R" 'main...pr'
|
|
145
|
+
check "irreversible: migration file blocks (exit 1)" "$RC" "1"
|
|
146
|
+
check_contains "irreversible: verdict line" "$OUT" "hold=blocked"
|
|
147
|
+
check_contains "irreversible: fact names class+path" "$OUT" "fact=irreversible:app/migrations/001_add_users.py"
|
|
148
|
+
|
|
149
|
+
R=$(new_repo irr-neg)
|
|
150
|
+
put "$R" src/app.js 'console.log(1)'
|
|
151
|
+
pr "$R"
|
|
152
|
+
put "$R" src/app.js 'console.log(2)'
|
|
153
|
+
run_hold "$R" 'main...pr'
|
|
154
|
+
check "irreversible neg: same diff minus migration is clean (exit 0)" "$RC" "0"
|
|
155
|
+
check "irreversible neg: silent verdict only" "$OUT" "hold=clear"
|
|
156
|
+
|
|
157
|
+
R=$(new_repo irr-schema)
|
|
158
|
+
pr "$R"
|
|
159
|
+
put "$R" db/schema.rb 'ActiveRecord::Schema.define'
|
|
160
|
+
run_hold "$R" 'main...pr'
|
|
161
|
+
check "irreversible: db/schema* fires" "$RC" "1"
|
|
162
|
+
check_contains "irreversible: schema fact" "$OUT" "fact=irreversible:db/schema.rb"
|
|
163
|
+
|
|
164
|
+
R=$(new_repo irr-sql)
|
|
165
|
+
pr "$R"
|
|
166
|
+
put "$R" scripts/backfill.sql 'UPDATE users SET x=1;'
|
|
167
|
+
run_hold "$R" 'main...pr'
|
|
168
|
+
check "irreversible: *.sql data-mutation script fires" "$RC" "1"
|
|
169
|
+
check_contains "irreversible: sql fact" "$OUT" "fact=irreversible:scripts/backfill.sql"
|
|
170
|
+
|
|
171
|
+
# Config ADDS a glob (additive union with built-ins)
|
|
172
|
+
R=$(new_repo irr-conf)
|
|
173
|
+
put "$R" .forge/hold-config.yml 'version: 1
|
|
174
|
+
operators: []
|
|
175
|
+
irreversible_paths:
|
|
176
|
+
- "dangerzone/*"
|
|
177
|
+
deploy_paths: []
|
|
178
|
+
gating_tests: []'
|
|
179
|
+
pr "$R"
|
|
180
|
+
put "$R" dangerzone/wipe.txt 'boom'
|
|
181
|
+
run_hold "$R" 'main...pr'
|
|
182
|
+
check "irreversible: config-added glob fires" "$RC" "1"
|
|
183
|
+
check_contains "irreversible: config-glob fact" "$OUT" "fact=irreversible:dangerzone/wipe.txt"
|
|
184
|
+
fi
|
|
185
|
+
|
|
186
|
+
# ---------------------------------------------------------------------------
|
|
187
|
+
# pain6 / structural — acceptance 2: bump+lockfile NEVER fire; NEW dep entry fires
|
|
188
|
+
# ---------------------------------------------------------------------------
|
|
189
|
+
if running pain6 structural; then
|
|
190
|
+
R=$(new_repo bump)
|
|
191
|
+
put "$R" package.json '{
|
|
192
|
+
"name": "app",
|
|
193
|
+
"dependencies": {
|
|
194
|
+
"left-pad": "1.0.0",
|
|
195
|
+
"express": "4.17.0"
|
|
196
|
+
}
|
|
197
|
+
}'
|
|
198
|
+
put "$R" package-lock.json '{"lockfileVersion": 2, "v": 1}'
|
|
199
|
+
pr "$R"
|
|
200
|
+
put "$R" package.json '{
|
|
201
|
+
"name": "app",
|
|
202
|
+
"dependencies": {
|
|
203
|
+
"left-pad": "1.3.0",
|
|
204
|
+
"express": "4.18.2"
|
|
205
|
+
}
|
|
206
|
+
}'
|
|
207
|
+
put "$R" package-lock.json '{"lockfileVersion": 2, "v": 2}'
|
|
208
|
+
run_hold "$R" 'main...pr'
|
|
209
|
+
check "pain6: version bump + lockfile churn is clean (exit 0)" "$RC" "0"
|
|
210
|
+
check "pain6: verdict" "$OUT" "hold=clear"
|
|
211
|
+
|
|
212
|
+
R=$(new_repo newdep)
|
|
213
|
+
put "$R" package.json '{
|
|
214
|
+
"name": "app",
|
|
215
|
+
"dependencies": {
|
|
216
|
+
"express": "4.17.0"
|
|
217
|
+
}
|
|
218
|
+
}'
|
|
219
|
+
pr "$R"
|
|
220
|
+
put "$R" package.json '{
|
|
221
|
+
"name": "app",
|
|
222
|
+
"dependencies": {
|
|
223
|
+
"express": "4.17.0",
|
|
224
|
+
"evil-pkg": "0.0.1"
|
|
225
|
+
}
|
|
226
|
+
}'
|
|
227
|
+
run_hold "$R" 'main...pr'
|
|
228
|
+
check "pain6 neg: NEW dependency entry fires structural (exit 1)" "$RC" "1"
|
|
229
|
+
check_contains "pain6 neg: structural fact names manifest" "$OUT" "fact=structural:package.json"
|
|
230
|
+
|
|
231
|
+
R=$(new_repo lockonly)
|
|
232
|
+
put "$R" package-lock.json '{"v": 1}'
|
|
233
|
+
pr "$R"
|
|
234
|
+
put "$R" package-lock.json '{"v": 2}'
|
|
235
|
+
put "$R" yarn.lock 'lock churn'
|
|
236
|
+
run_hold "$R" 'main...pr'
|
|
237
|
+
check "pain6: lockfile-only churn is clean (exit 0)" "$RC" "0"
|
|
238
|
+
|
|
239
|
+
# Second manifest format: requirements.txt
|
|
240
|
+
R=$(new_repo reqs)
|
|
241
|
+
put "$R" requirements.txt 'flask==2.0.0
|
|
242
|
+
requests==2.28.0'
|
|
243
|
+
pr "$R"
|
|
244
|
+
put "$R" requirements.txt 'flask==2.3.0
|
|
245
|
+
requests==2.28.0'
|
|
246
|
+
run_hold "$R" 'main...pr'
|
|
247
|
+
check "pain6: requirements.txt version bump clean" "$RC" "0"
|
|
248
|
+
|
|
249
|
+
R=$(new_repo reqs-new)
|
|
250
|
+
put "$R" requirements.txt 'flask==2.0.0'
|
|
251
|
+
pr "$R"
|
|
252
|
+
put "$R" requirements.txt 'flask==2.0.0
|
|
253
|
+
sketchy-lib==0.1.0'
|
|
254
|
+
run_hold "$R" 'main...pr'
|
|
255
|
+
check "pain6 neg: requirements.txt NEW entry fires" "$RC" "1"
|
|
256
|
+
check_contains "pain6 neg: requirements fact" "$OUT" "fact=structural:requirements.txt"
|
|
257
|
+
|
|
258
|
+
# go.mod: bump clean / new require fires
|
|
259
|
+
R=$(new_repo gomod)
|
|
260
|
+
put "$R" go.mod 'module example.com/app
|
|
261
|
+
|
|
262
|
+
require (
|
|
263
|
+
github.com/pkg/errors v0.8.0
|
|
264
|
+
)'
|
|
265
|
+
pr "$R"
|
|
266
|
+
put "$R" go.mod 'module example.com/app
|
|
267
|
+
|
|
268
|
+
require (
|
|
269
|
+
github.com/pkg/errors v0.9.1
|
|
270
|
+
)'
|
|
271
|
+
put "$R" go.sum 'churn'
|
|
272
|
+
run_hold "$R" 'main...pr'
|
|
273
|
+
check "pain6: go.mod version bump + go.sum churn clean" "$RC" "0"
|
|
274
|
+
fi
|
|
275
|
+
|
|
276
|
+
# ---------------------------------------------------------------------------
|
|
277
|
+
# waiver — acceptance 3: operator approval waives + lists; non-operator does not
|
|
278
|
+
# ---------------------------------------------------------------------------
|
|
279
|
+
if running waiver; then
|
|
280
|
+
R=$(new_repo waive)
|
|
281
|
+
put "$R" .forge/hold-config.yml "$CONFIG_OPS"
|
|
282
|
+
pr "$R"
|
|
283
|
+
put "$R" app/migrations/002_drop_col.py 'drop column'
|
|
284
|
+
AF=$(approvals w1 zayne)
|
|
285
|
+
run_hold "$R" 'main...pr' --approvals "$AF"
|
|
286
|
+
check "waiver: operator approval waives (exit 0)" "$RC" "0"
|
|
287
|
+
check_contains "waiver: verdict names login" "$OUT" "hold=waived waived_by=zayne"
|
|
288
|
+
check_contains "waiver: lists what it waived" "$OUT" "fact=irreversible:app/migrations/002_drop_col.py waived"
|
|
289
|
+
|
|
290
|
+
AF=$(approvals w2 bot-agent)
|
|
291
|
+
run_hold "$R" 'main...pr' --approvals "$AF"
|
|
292
|
+
check "waiver neg: non-operator approval stays blocked (exit 1)" "$RC" "1"
|
|
293
|
+
check_contains "waiver neg: still names the fact" "$OUT" "fact=irreversible:app/migrations/002_drop_col.py"
|
|
294
|
+
check_absent "waiver neg: no waived verdict" "$OUT" "hold=waived"
|
|
295
|
+
|
|
296
|
+
run_hold "$R" 'main...pr'
|
|
297
|
+
check "waiver neg: no approvals input stays blocked (exit 1)" "$RC" "1"
|
|
298
|
+
|
|
299
|
+
# Operators are read from BASE: a PR that adds its approver to operators
|
|
300
|
+
# cannot self-waive — and the config edit itself raises protected.
|
|
301
|
+
R=$(new_repo selfadd)
|
|
302
|
+
put "$R" .forge/hold-config.yml "$CONFIG_OPS"
|
|
303
|
+
pr "$R"
|
|
304
|
+
put "$R" app/migrations/003_evil.py 'drop table'
|
|
305
|
+
put "$R" .forge/hold-config.yml 'version: 1
|
|
306
|
+
operators:
|
|
307
|
+
- zayne
|
|
308
|
+
- mallory
|
|
309
|
+
irreversible_paths: []
|
|
310
|
+
deploy_paths: []
|
|
311
|
+
gating_tests: []'
|
|
312
|
+
AF=$(approvals w3 mallory)
|
|
313
|
+
run_hold "$R" 'main...pr' --approvals "$AF"
|
|
314
|
+
check "waiver F4: head-side operator self-add cannot waive (exit 1)" "$RC" "1"
|
|
315
|
+
check_contains "waiver F4: config edit raises protected fact" "$OUT" "fact=protected:.forge/hold-config.yml"
|
|
316
|
+
|
|
317
|
+
# No config at base → empty operators → approval cannot waive (raise-only default)
|
|
318
|
+
R=$(new_repo noconf)
|
|
319
|
+
pr "$R"
|
|
320
|
+
put "$R" app/migrations/004_x.py 'alter'
|
|
321
|
+
AF=$(approvals w4 zayne)
|
|
322
|
+
run_hold "$R" 'main...pr' --approvals "$AF"
|
|
323
|
+
check "waiver: absent config means NO waiver possible (exit 1)" "$RC" "1"
|
|
324
|
+
fi
|
|
325
|
+
|
|
326
|
+
# ---------------------------------------------------------------------------
|
|
327
|
+
# self-protect — acceptance 4: config + scripts + project.yml + CI defs hard-coded
|
|
328
|
+
# ---------------------------------------------------------------------------
|
|
329
|
+
if running self-protect protected; then
|
|
330
|
+
for f in .forge/hold-config.yml .forge/checks/forge-hold-check.sh .forge/checks/forge-check-lint.sh .forge/checks/tests/forge-hold-check.test.sh .forge/project.yml .github/workflows/ci.yml .gitlab-ci.yml; do
|
|
331
|
+
R=$(new_repo "sp-$(printf '%s' "$f" | tr '/.' '--')")
|
|
332
|
+
pr "$R"
|
|
333
|
+
put "$R" "$f" 'edited'
|
|
334
|
+
run_hold "$R" 'main...pr'
|
|
335
|
+
check "self-protect: $f edit blocks (exit 1)" "$RC" "1"
|
|
336
|
+
check_contains "self-protect: $f named as protected fact" "$OUT" "fact=protected:$f"
|
|
337
|
+
done
|
|
338
|
+
|
|
339
|
+
# Config ADDS deploy path (additive; base side)
|
|
340
|
+
R=$(new_repo sp-deploy)
|
|
341
|
+
put "$R" .forge/hold-config.yml 'version: 1
|
|
342
|
+
operators: []
|
|
343
|
+
irreversible_paths: []
|
|
344
|
+
deploy_paths:
|
|
345
|
+
- "deploy/*"
|
|
346
|
+
gating_tests: []'
|
|
347
|
+
pr "$R"
|
|
348
|
+
put "$R" deploy/prod.tf 'resource {}'
|
|
349
|
+
run_hold "$R" 'main...pr'
|
|
350
|
+
check "self-protect: config-added deploy path fires" "$RC" "1"
|
|
351
|
+
check_contains "self-protect: deploy fact" "$OUT" "fact=protected:deploy/prod.tf"
|
|
352
|
+
fi
|
|
353
|
+
|
|
354
|
+
# ---------------------------------------------------------------------------
|
|
355
|
+
# docs-only — acceptance 6: a status on every PR; clean exits 0 silent
|
|
356
|
+
# ---------------------------------------------------------------------------
|
|
357
|
+
if running docs-only; then
|
|
358
|
+
R=$(new_repo docs)
|
|
359
|
+
put "$R" README.md 'hello'
|
|
360
|
+
pr "$R"
|
|
361
|
+
put "$R" README.md 'hello world'
|
|
362
|
+
put "$R" docs/guide.md 'guide'
|
|
363
|
+
run_hold "$R" 'main...pr'
|
|
364
|
+
check "docs-only: exit 0" "$RC" "0"
|
|
365
|
+
check "docs-only: exactly hold=clear, nothing else" "$OUT" "hold=clear"
|
|
366
|
+
fi
|
|
367
|
+
|
|
368
|
+
# ---------------------------------------------------------------------------
|
|
369
|
+
# multi — a risky PR names EVERY fact, one line each
|
|
370
|
+
# ---------------------------------------------------------------------------
|
|
371
|
+
if running multi; then
|
|
372
|
+
R=$(new_repo multi)
|
|
373
|
+
put "$R" package.json '{"dependencies": {"a": "1.0.0"}}'
|
|
374
|
+
pr "$R"
|
|
375
|
+
put "$R" package.json '{"dependencies": {"a": "1.0.0", "b": "2.0.0"}}'
|
|
376
|
+
put "$R" db/migrations/005.sql 'ALTER TABLE'
|
|
377
|
+
put "$R" .github/workflows/deploy.yml 'on: push'
|
|
378
|
+
run_hold "$R" 'main...pr'
|
|
379
|
+
check "multi: blocked (exit 1)" "$RC" "1"
|
|
380
|
+
check_contains "multi: irreversible named" "$OUT" "fact=irreversible:db/migrations/005.sql"
|
|
381
|
+
check_contains "multi: structural named" "$OUT" "fact=structural:package.json"
|
|
382
|
+
check_contains "multi: protected named" "$OUT" "fact=protected:.github/workflows/deploy.yml"
|
|
383
|
+
fi
|
|
384
|
+
|
|
385
|
+
# ---------------------------------------------------------------------------
|
|
386
|
+
# invocation — exit 2 on bad usage, never a false verdict
|
|
387
|
+
# ---------------------------------------------------------------------------
|
|
388
|
+
if running invocation; then
|
|
389
|
+
R=$(new_repo inv)
|
|
390
|
+
run_hold "$R"
|
|
391
|
+
check "invocation: missing range exits 2" "$RC" "2"
|
|
392
|
+
run_hold "$R" 'main..pr'
|
|
393
|
+
check "invocation: two-dot range rejected (exit 2)" "$RC" "2"
|
|
394
|
+
run_hold "$R" 'nosuch...branches'
|
|
395
|
+
check "invocation: unresolvable refs exit 2" "$RC" "2"
|
|
396
|
+
pr "$R"
|
|
397
|
+
put "$R" src/x.js 'x'
|
|
398
|
+
run_hold "$R" 'main...pr' --approvals "$ROOT/definitely-missing-file"
|
|
399
|
+
check "invocation: missing --approvals file exits 2 (derivation failed = loud)" "$RC" "2"
|
|
400
|
+
fi
|
|
401
|
+
|
|
402
|
+
# ---------------------------------------------------------------------------
|
|
403
|
+
printf '\n%d selected case block(s): %d passed, %d failed\n' "$SELECTED" "$PASSED" "$FAILED"
|
|
404
|
+
[ "$FAILED" -eq 0 ] && [ "$SELECTED" -gt 0 ]
|
|
@@ -4,7 +4,12 @@
|
|
|
4
4
|
# project state survives machine loss and can be pulled from any clone:
|
|
5
5
|
# FORGE.md project.yml constitution.md design-system.md context.md roadmap.yml
|
|
6
6
|
# requirements/ phases/ research/ decisions/ contracts/ audits/
|
|
7
|
-
# refactor-backlog.yml state/ templates/ migrations/
|
|
7
|
+
# refactor-backlog.yml state/ templates/ migrations/ checks/
|
|
8
|
+
#
|
|
9
|
+
# checks/ (the merge-floor CI check scripts + fixture suites) is deliberately
|
|
10
|
+
# HERE and not in .claude/hooks/: many projects gitignore .claude/hooks/ as
|
|
11
|
+
# per-developer files, and an ignored script can neither run in CI nor be
|
|
12
|
+
# guarded by the hold check's protected-path self-protection.
|
|
8
13
|
#
|
|
9
14
|
# The entries below are the ONLY ephemeral exceptions — session- or
|
|
10
15
|
# machine-local artifacts that must never be committed.
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Migration Guide: Merged→validated fold (Forge 0.56.0)
|
|
2
|
+
|
|
3
|
+
0.56.0 adds a **derived `release_state`** dimension — each unit of work is now
|
|
4
|
+
computed (at every `forge` boot, from git truth) as `unmerged`, `merged`, or
|
|
5
|
+
`validated` and rendered into the git-ignored `index.yml` cache. Nothing is
|
|
6
|
+
stored: the fold-of-record `.claude/hooks/forge-release-fold.sh` recomputes it
|
|
7
|
+
on read, so a value can never go stale. See
|
|
8
|
+
[ADR-024](../decisions/ADR-024-merged-validated-fold.md).
|
|
9
|
+
|
|
10
|
+
**This change is inert by default.** With no `forge.validation_event` configured
|
|
11
|
+
— which is every existing repo, including forge itself — every gate (milestone
|
|
12
|
+
close, worktree teardown, boot) behaves byte-for-byte as before. The only
|
|
13
|
+
difference is an additional derived column in the git-ignored cache. You do not
|
|
14
|
+
have to do anything.
|
|
15
|
+
|
|
16
|
+
## What arrives via upgrading
|
|
17
|
+
|
|
18
|
+
The `upgrading` skill (or `npx forge-orkes upgrade`) syncs the framework files:
|
|
19
|
+
|
|
20
|
+
- `.claude/hooks/forge-release-fold.sh` (+ its test suite) — the fold.
|
|
21
|
+
- Skill edits to `forge` (boot rollup renders `release_state`), `verifying`,
|
|
22
|
+
`reviewing` (the human-gate now dispatches on the binding), and
|
|
23
|
+
`orchestrating` (worktree teardown splits on merged-ness).
|
|
24
|
+
- `.forge/templates/project.yml` — a commented `forge.validation_event` block
|
|
25
|
+
documenting the opt-in.
|
|
26
|
+
|
|
27
|
+
**Not touched:** your `project.yml`. The binding is opt-in — the upgrade never
|
|
28
|
+
writes it. `human_verified` / M0 sign-off / the legacy-complete grandfather all
|
|
29
|
+
keep working exactly as today.
|
|
30
|
+
|
|
31
|
+
## Opting in (deploy-surface repos only)
|
|
32
|
+
|
|
33
|
+
A repo that gates release on a real deployment can bind validation to a
|
|
34
|
+
host-recorded promotion. In `project.yml`:
|
|
35
|
+
|
|
36
|
+
```yaml
|
|
37
|
+
forge:
|
|
38
|
+
validation_event: promotion_approval
|
|
39
|
+
promotion:
|
|
40
|
+
tag_pattern: "promotion/{sha}" # FROZEN — your promotion job must emit it byte-exactly
|
|
41
|
+
allowed_signers: ".forge/promotion_signers" # committed, PUBLIC keys only
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Under `promotion_approval`, `complete` becomes the machine/code-verified state
|
|
45
|
+
(no in-session sign-off required) and the human gate relocates to the deploy
|
|
46
|
+
boundary: a unit renders `validated` only once a **CI-signed** `promotion/{sha}`
|
|
47
|
+
tag covers its landed SHA. The fold verifies the tag **offline** (`git tag -v`
|
|
48
|
+
against the allowed-signers file) — no network.
|
|
49
|
+
|
|
50
|
+
### Prerequisite (Track B — not shipped by this change)
|
|
51
|
+
|
|
52
|
+
`promotion_approval` is only meaningful once your deploy pipeline emits the
|
|
53
|
+
signed tag. That job must:
|
|
54
|
+
|
|
55
|
+
1. Run in a protected/Environment-approved context holding a **CI-held SSH key**
|
|
56
|
+
no agent context can read.
|
|
57
|
+
2. SSH-sign a tag named `promotion/<full-deploy-sha>` on the approved deploy
|
|
58
|
+
commit.
|
|
59
|
+
3. Commit the signing key's **public** half to the `allowed_signers` path.
|
|
60
|
+
|
|
61
|
+
Until that job exists, leave `validation_event` unset (the default) — the fold
|
|
62
|
+
stays inert.
|
|
63
|
+
|
|
64
|
+
## Detection
|
|
65
|
+
|
|
66
|
+
Data-driven by filename: `upgrading` globs `.forge/migrations/{v}-*.md` and
|
|
67
|
+
surfaces any guide where `installed < v <= source`. No action needed beyond the
|
|
68
|
+
framework-file sync — this guide fires automatically on upgrade to 0.56.0.
|
|
69
|
+
|
|
70
|
+
## Nothing-to-do default
|
|
71
|
+
|
|
72
|
+
If you do not run a deploy-gated release flow, ignore all of the above. The
|
|
73
|
+
default binding is your current behavior; 0.56.0 changes nothing you can see
|
|
74
|
+
except a new derived column in a cache you never commit.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Migration Guide: Merge-floor checks relocate to `.forge/checks/` (Forge 0.58.2)
|
|
2
|
+
|
|
3
|
+
Applies to projects that received the merge-floor check scripts —
|
|
4
|
+
`forge-hold-check.sh`, `forge-check-lint.sh` + their fixture suites — under
|
|
5
|
+
`.claude/hooks/` (shipped there in 0.57.0–0.58.1), and/or wired CI at that path.
|
|
6
|
+
|
|
7
|
+
0.58.2 moves them to **`.forge/checks/`** (scripts at the top, suites under
|
|
8
|
+
`tests/`). Why: many projects gitignore `.claude/hooks/` as per-developer files
|
|
9
|
+
(host-verified on ptnrkit). A gitignored script never reaches the repo CI
|
|
10
|
+
clones — the *required check can't run* — and, invisible to the PR diff, the
|
|
11
|
+
hold check's self-protection can't guard it either. `.forge/**` is committed in
|
|
12
|
+
installed projects, so `.forge/checks/` is CI-runnable and sits inside the hold
|
|
13
|
+
check's hard-coded protected set (`.forge/checks/**`, replacing the two old
|
|
14
|
+
`.claude/hooks/` entries).
|
|
15
|
+
|
|
16
|
+
The upgrade (npm bin or `/upgrading` Step 4a) **adds** `.forge/checks/` but —
|
|
17
|
+
additive by design — never deletes the old copies. This guide is the cleanup.
|
|
18
|
+
|
|
19
|
+
## Detection
|
|
20
|
+
|
|
21
|
+
Fires when an old-path copy or an old-path CI reference is still present:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
migrate=0
|
|
25
|
+
for f in .claude/hooks/forge-hold-check.sh .claude/hooks/forge-check-lint.sh \
|
|
26
|
+
.claude/hooks/tests/forge-hold-check.test.sh .claude/hooks/tests/forge-check-lint.test.sh; do
|
|
27
|
+
[ -f "$f" ] && { echo "MIGRATE — stale relocation leftover: $f"; migrate=1; }
|
|
28
|
+
done
|
|
29
|
+
if grep -rl '\.claude/hooks/forge-\(hold-check\|check-lint\)\.sh' \
|
|
30
|
+
.github/workflows .gitlab-ci.yml 2>/dev/null; then
|
|
31
|
+
echo "MIGRATE — CI still invokes the old path"; migrate=1
|
|
32
|
+
fi
|
|
33
|
+
[ "$migrate" -eq 0 ] && echo "OK — already migrated (or never had the checks)"
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Steps
|
|
37
|
+
|
|
38
|
+
1. **Upgrade first** (`npx forge-orkes upgrade`, or `/upgrading`) so
|
|
39
|
+
`.forge/checks/` exists with the 0.58.2 scripts.
|
|
40
|
+
2. **Commit `.forge/checks/`** — it is *meant to be tracked*, unlike
|
|
41
|
+
`.claude/hooks/`: `git add .forge/checks && git commit -m "chore(forge): track merge-floor checks at .forge/checks/"`.
|
|
42
|
+
3. **Delete the old copies** listed by the detection block (plus
|
|
43
|
+
`git rm --cached` them first if your project tracks `.claude/hooks/`).
|
|
44
|
+
4. **Re-point CI** — in `.github/workflows/merge-floor.yml` / `.gitlab-ci.yml`,
|
|
45
|
+
change `./.claude/hooks/forge-{hold-check,check-lint}.sh` →
|
|
46
|
+
`./.forge/checks/forge-{hold-check,check-lint}.sh` (full snippets:
|
|
47
|
+
`docs/merge-floor-checks.md` §5–6). Expect the PR carrying this CI edit to
|
|
48
|
+
raise a **protected-path hold on itself** — that is the self-protection
|
|
49
|
+
working; the operator's PR approval waives it.
|
|
50
|
+
5. If your `.forge/hold-config.yml` `gating_tests:`/`deploy_paths:` lists name
|
|
51
|
+
the old paths (unusual), update them too.
|
|
52
|
+
|
|
53
|
+
Projects that gitignored `.claude/hooks/` never had the scripts in CI at all —
|
|
54
|
+
for them steps 3–5 reduce to wiring CI at `.forge/checks/` for the first time.
|
|
55
|
+
|
|
56
|
+
## Verify
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
./.forge/checks/tests/forge-hold-check.test.sh && ./.forge/checks/tests/forge-check-lint.test.sh
|
|
60
|
+
git ls-files --error-unmatch .forge/checks/forge-hold-check.sh # tracked = ✓
|
|
61
|
+
```
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Forge hold-config — per-repo path lists for the merge-floor hold check
|
|
2
|
+
# (.forge/checks/forge-hold-check.sh; see docs/merge-floor-checks.md).
|
|
3
|
+
#
|
|
4
|
+
# ADDITIVE ONLY: every list below UNIONS with the check's hard-coded built-ins —
|
|
5
|
+
# nothing here can remove a hold class (raise-only by construction, D-9/A4d).
|
|
6
|
+
#
|
|
7
|
+
# SELF-PROTECTED: this file's path and the whole .forge/checks/** subtree (both
|
|
8
|
+
# check scripts + their fixture suites) are hard-coded into the check's
|
|
9
|
+
# protected set. A PR that edits any of them raises a protected-path hold on
|
|
10
|
+
# itself — editing the watchdog's rules wakes the watchdog (fold-pass F4/F5
|
|
11
|
+
# lesson). The check reads this file from the BASE side of the diff, so a PR
|
|
12
|
+
# cannot rewrite the rules it is judged by.
|
|
13
|
+
version: 1
|
|
14
|
+
|
|
15
|
+
# Logins whose PR review approval waives a hold (the recorded operator act, B2).
|
|
16
|
+
# Empty ⇒ no waiver is possible — holds block until this is configured.
|
|
17
|
+
operators: []
|
|
18
|
+
# - your-login
|
|
19
|
+
|
|
20
|
+
# Extra irreversible-class paths (migrations, schema, data-mutation scripts)
|
|
21
|
+
# beyond the built-ins (*/migrations/*, */migrate/*, db/schema*, *.sql).
|
|
22
|
+
irreversible_paths: []
|
|
23
|
+
# - "db/seeds/*"
|
|
24
|
+
|
|
25
|
+
# Deploy config paths (protected class) beyond the built-in CI-definition set
|
|
26
|
+
# (.github/workflows/*, .gitlab-ci.yml).
|
|
27
|
+
deploy_paths: []
|
|
28
|
+
# - "deploy/*"
|
|
29
|
+
# - "Dockerfile"
|
|
30
|
+
|
|
31
|
+
# Gating test files (protected class; also narrows the check-quality lint's
|
|
32
|
+
# deleted-assertion scope when set — default there is EVERY test file in the diff).
|
|
33
|
+
gating_tests: []
|
|
34
|
+
# - "tests/e2e/*"
|