forge-orkes 0.56.0 → 0.58.3
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/settings.json +1 -1
- package/template/.claude/skills/upgrading/SKILL.md +18 -0
- 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.53.0-render-on-read-registries.md +103 -0
- package/template/.forge/migrations/0.58.2-merge-floor-checks-relocation.md +61 -0
- package/template/.forge/migrations/0.58.3-render-on-read-registries-catchup.md +42 -0
- package/template/.forge/templates/hold-config.yml +34 -0
|
@@ -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,103 @@
|
|
|
1
|
+
# Migration Guide: Render-on-read state registries (Forge 0.53.0)
|
|
2
|
+
|
|
3
|
+
Applies to projects whose derived registries — `.forge/state/index.yml` and/or
|
|
4
|
+
`.forge/streams/active.yml` — are still **git-tracked**. Both files were already
|
|
5
|
+
`DERIVED / DO NOT HAND-EDIT` projections; 0.53.0 makes them **git-ignored
|
|
6
|
+
render-on-read caches**: regenerated at every `forge` boot from their sources and
|
|
7
|
+
**never committed**. Because the value is computed on read and never stored in
|
|
8
|
+
git, the two-registry drift class — a committed projection going stale against
|
|
9
|
+
its source (e.g. a committed `active.yml` phase reading `researching` while the
|
|
10
|
+
milestone had moved to `discussing`) — can no longer occur.
|
|
11
|
+
|
|
12
|
+
Sources are unchanged: `index.yml` ← `.forge/state/milestone-*.yml` (Rollup 1.0);
|
|
13
|
+
`active.yml` ← `.forge/streams/*.yml` + active milestones (Stream Rollup). Only
|
|
14
|
+
the *storage* changes — tracked file → local cache.
|
|
15
|
+
|
|
16
|
+
Projects that never committed these files (fresh 0.53.0+ installs) need nothing.
|
|
17
|
+
|
|
18
|
+
## Prerequisites
|
|
19
|
+
|
|
20
|
+
1. On the new version's framework files (`npx forge-orkes upgrade`, or the
|
|
21
|
+
`upgrading` skill) — so the updated `.gitignore` entries and the render-then-read
|
|
22
|
+
`deferred`/`planning` skills are present.
|
|
23
|
+
2. Working tree clean or changes committed — the migration stops tracking two
|
|
24
|
+
files and edits `.gitignore`.
|
|
25
|
+
|
|
26
|
+
## Detection
|
|
27
|
+
|
|
28
|
+
Keyed on **git-tracking state**, not a content grep: the `DERIVED / DO NOT
|
|
29
|
+
HAND-EDIT` header is identical before and after, so only `git ls-files` can tell
|
|
30
|
+
a committed-pre-0.53.0 registry from a git-ignored-post-0.53.0 one. Fires while
|
|
31
|
+
either registry is still tracked; silent once both are untracked (already
|
|
32
|
+
migrated), and silent in a non-Forge / non-git tree:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
git rev-parse --is-inside-work-tree >/dev/null 2>&1 || exit 0
|
|
36
|
+
migrate=0
|
|
37
|
+
for f in .forge/state/index.yml .forge/streams/active.yml; do
|
|
38
|
+
if git ls-files --error-unmatch "$f" >/dev/null 2>&1; then
|
|
39
|
+
echo "MIGRATE — $f is still git-tracked (pre-0.53.0); render-on-read makes it a git-ignored cache regenerated at boot"
|
|
40
|
+
migrate=1
|
|
41
|
+
fi
|
|
42
|
+
done
|
|
43
|
+
[ "$migrate" -eq 0 ] && exit 0
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Migration steps
|
|
47
|
+
|
|
48
|
+
### 1. Stop tracking the two registries (keep them on disk)
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
git rm --cached .forge/state/index.yml .forge/streams/active.yml 2>/dev/null
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`--cached` removes them from the index only — the working-copy files stay as a
|
|
55
|
+
local cache and are re-rendered on the next boot. (If either was never tracked,
|
|
56
|
+
the command is a harmless no-op for that path.)
|
|
57
|
+
|
|
58
|
+
### 2. Git-ignore both derived registries
|
|
59
|
+
|
|
60
|
+
Add to the project's root `.gitignore` (paths relative to the repo root):
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
.forge/state/index.yml
|
|
64
|
+
.forge/streams/active.yml
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Consumer projects created from `create-forge` instead gain these under
|
|
68
|
+
`.forge/.gitignore` (paths relative to `.forge/`):
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
state/index.yml
|
|
72
|
+
streams/active.yml
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Without this, the next `git add .forge/` would re-commit the caches and reopen
|
|
76
|
+
the drift class.
|
|
77
|
+
|
|
78
|
+
### 3. Confirm the readers render-then-read
|
|
79
|
+
|
|
80
|
+
`forge` boot and `chief-of-staff` already render before reading. In 0.53.0 the
|
|
81
|
+
two direct readers are converted too: `deferred` and `planning` no longer treat a
|
|
82
|
+
missing `index.yml` as "not a Forge project" — the project sentinel moved to the
|
|
83
|
+
committed `.forge/project.yml`, and they derive milestone state from
|
|
84
|
+
`.forge/state/milestone-*.yml` (rendering the cache first) when `index.yml` is
|
|
85
|
+
absent. These ship with the framework upgrade; no per-project edit is needed.
|
|
86
|
+
|
|
87
|
+
### 4. Regenerate the caches
|
|
88
|
+
|
|
89
|
+
Run `/forge` (boot) — it runs Rollup 1.0 and the Stream Rollup and rewrites both
|
|
90
|
+
files from their sources. From here they are regenerated on read, never committed.
|
|
91
|
+
|
|
92
|
+
## Validation
|
|
93
|
+
|
|
94
|
+
- `git ls-files --error-unmatch .forge/state/index.yml` exits **non-zero**
|
|
95
|
+
(untracked); same for `.forge/streams/active.yml`.
|
|
96
|
+
- `git check-ignore .forge/state/index.yml` and `git check-ignore
|
|
97
|
+
.forge/streams/active.yml` each exit **0** (ignored).
|
|
98
|
+
- Both files still exist on disk after a `/forge` boot (rendered cache).
|
|
99
|
+
- A fresh clone with no prior boot: `deferred` and `planning` do **not** print
|
|
100
|
+
"No forge state found" (they key the sentinel on `project.yml`).
|
|
101
|
+
- Editing a milestone's phase and booting yields an `active.yml` whose row phase
|
|
102
|
+
matches the milestone — the projection can no longer be stale, because it is
|
|
103
|
+
never stored.
|
|
@@ -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,42 @@
|
|
|
1
|
+
# Migration Guide: Render-on-read registries catch-up (Forge 0.58.3)
|
|
2
|
+
|
|
3
|
+
Applies to projects that upgraded **across 0.53.0 while the 0.53.0 migration
|
|
4
|
+
guide was missing from the shipped template** (missing 0.53.0–0.58.2; restored
|
|
5
|
+
in 0.58.3). For those projects, `/upgrading` Step 7's version-range detection
|
|
6
|
+
(`installed < v <= source`) fired for the crossed range but had no
|
|
7
|
+
`0.53.0-*.md` guide on disk to surface — so the render-on-read registry
|
|
8
|
+
migration (untrack `.forge/state/index.yml` + `.forge/streams/active.yml`) was
|
|
9
|
+
silently skipped, and range detection never looks backward at an
|
|
10
|
+
already-crossed version. Host-verified on ptnrkit: stamped 0.58.2 with both
|
|
11
|
+
derived registries still git-tracked and actively committed.
|
|
12
|
+
|
|
13
|
+
This guide exists solely to re-surface that skipped migration. It re-runs the
|
|
14
|
+
same detection; the actual steps live in the (now-delivered)
|
|
15
|
+
`0.53.0-render-on-read-registries.md` alongside this file.
|
|
16
|
+
|
|
17
|
+
## Detection
|
|
18
|
+
|
|
19
|
+
Identical to the 0.53.0 guide — keyed on git-tracking state. Silent once both
|
|
20
|
+
registries are untracked (migrated, or a fresh ≥0.53.0 install):
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
git rev-parse --is-inside-work-tree >/dev/null 2>&1 || exit 0
|
|
24
|
+
migrate=0
|
|
25
|
+
for f in .forge/state/index.yml .forge/streams/active.yml; do
|
|
26
|
+
if git ls-files --error-unmatch "$f" >/dev/null 2>&1; then
|
|
27
|
+
echo "MIGRATE — $f is still git-tracked; the 0.53.0 render-on-read migration was skipped"
|
|
28
|
+
migrate=1
|
|
29
|
+
fi
|
|
30
|
+
done
|
|
31
|
+
[ "$migrate" -eq 0 ] && echo "OK — already migrated (or never tracked the registries)"
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Steps
|
|
35
|
+
|
|
36
|
+
Detection fired → follow `0.53.0-render-on-read-registries.md` (same
|
|
37
|
+
directory) end to end: `git rm --cached` the two registries, gitignore them
|
|
38
|
+
(root `.gitignore`, or `state/index.yml` + `streams/active.yml` in
|
|
39
|
+
`.forge/.gitignore` for create-forge installs), then boot `/forge` to
|
|
40
|
+
regenerate the caches. Its Validation section applies unchanged.
|
|
41
|
+
|
|
42
|
+
Detection silent → nothing to do; this guide is a no-op.
|
|
@@ -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/*"
|