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,259 @@
|
|
|
1
|
+
#!/usr/bin/env sh
|
|
2
|
+
# forge-hold-check — the merge-floor hold-computation check (m-31, Brief-R2 step 2 Track A).
|
|
3
|
+
#
|
|
4
|
+
# Reads a PR diff and blocks auto-merge when it carries the ratified v1 hold facts —
|
|
5
|
+
# until the operator's own PR approval is on record. Raise-only by construction
|
|
6
|
+
# (D-9/A4d): facts come from the diff alone, built-in classes are HARD-CODED, and
|
|
7
|
+
# per-repo config can only ADD paths, never remove a class. Everything else sails
|
|
8
|
+
# through silent (Gate 6: a docs fix pays one fast script run and nothing else).
|
|
9
|
+
#
|
|
10
|
+
# forge-hold-check.sh <base>...<head> [--approvals <file>]
|
|
11
|
+
#
|
|
12
|
+
# stdout (machine-parseable):
|
|
13
|
+
# hold=clear no facts
|
|
14
|
+
# hold=blocked facts, no recorded operator act
|
|
15
|
+
# fact=<class>:<path> one line per fact
|
|
16
|
+
# hold=waived waived_by=<login> facts + operator approval
|
|
17
|
+
# fact=<class>:<path> waived one line per waived fact
|
|
18
|
+
# class ∈ irreversible|structural|protected
|
|
19
|
+
# exit: 0 = clear or waived; 1 = blocked; 2 = bad invocation. All logs → stderr.
|
|
20
|
+
#
|
|
21
|
+
# Range MUST be three-dot BASE...HEAD (PR semantics); facts are computed against
|
|
22
|
+
# merge-base(BASE, HEAD), exactly what the PR would merge.
|
|
23
|
+
#
|
|
24
|
+
# Fact classes (ratified v1 hold-fact list, operator 2026-07-15):
|
|
25
|
+
# irreversible — migration files, schema paths, data-mutation scripts:
|
|
26
|
+
# */migrations/*, */migrate/*, db/schema*, *.sql (+ config irreversible_paths)
|
|
27
|
+
# structural — a NEW dependency entry (a key absent from the manifest at base)
|
|
28
|
+
# in: package.json, composer.json, pyproject.toml, requirements*.txt,
|
|
29
|
+
# Gemfile, go.mod, Cargo.toml. Version bumps of an EXISTING key and
|
|
30
|
+
# lockfile churn NEVER trigger (pain 6 verbatim). Lockfiles ignored:
|
|
31
|
+
# package-lock.json, yarn.lock, pnpm-lock.yaml, composer.lock,
|
|
32
|
+
# Gemfile.lock, go.sum, Cargo.lock, uv.lock, poetry.lock.
|
|
33
|
+
# protected — CI workflow definitions, deploy config, gating test files, and the
|
|
34
|
+
# self-protected set (F4/F5 lesson: editing the watchdog's rules wakes
|
|
35
|
+
# the watchdog — hard-coded, a config edit cannot silence it):
|
|
36
|
+
# .github/workflows/*, .gitlab-ci.yml, .forge/project.yml,
|
|
37
|
+
# .forge/hold-config.yml, .forge/checks/** (both check scripts +
|
|
38
|
+
# their fixture suites) (+ config deploy_paths, gating_tests)
|
|
39
|
+
#
|
|
40
|
+
# Config (.forge/hold-config.yml) is read from the BASE side ONLY (git show
|
|
41
|
+
# <merge-base>:...) — a PR cannot rewrite the rules it is judged by. ADDITIVE only.
|
|
42
|
+
#
|
|
43
|
+
# Waiver (B2, ratified): the recorded operator act is the operator's PR review
|
|
44
|
+
# approval. The CI job derives the current approving logins NATIVELY (gh api /
|
|
45
|
+
# GitLab approvals — the host API lives in the WIRING, never here) and passes them
|
|
46
|
+
# via --approvals <file>, one login per line. Waives iff some login is in the
|
|
47
|
+
# base-side config operators: list. Absent config / empty operators → no waiver
|
|
48
|
+
# possible (raise-only safe default until the repo configures operators).
|
|
49
|
+
#
|
|
50
|
+
# Host-agnostic + offline (NFR-032): pure POSIX sh over git plumbing. No GitHub/
|
|
51
|
+
# GitLab API, no network, no node/python/jq. Heuristic manifest parsing is
|
|
52
|
+
# documented inline; what it can't catch, UAT stands behind.
|
|
53
|
+
set -u
|
|
54
|
+
|
|
55
|
+
usage() { printf 'usage: forge-hold-check.sh <base>...<head> [--approvals <file>]\n' >&2; }
|
|
56
|
+
|
|
57
|
+
RANGE=""
|
|
58
|
+
APPROVALS=""
|
|
59
|
+
while [ $# -gt 0 ]; do
|
|
60
|
+
case "$1" in
|
|
61
|
+
--approvals) shift; APPROVALS="${1:-}"; [ $# -gt 0 ] && shift || true ;;
|
|
62
|
+
--approvals=*) APPROVALS="${1#*=}"; shift ;;
|
|
63
|
+
-*) printf 'forge-hold-check: unknown option: %s\n' "$1" >&2; usage; exit 2 ;;
|
|
64
|
+
*)
|
|
65
|
+
if [ -z "$RANGE" ]; then RANGE="$1"; shift
|
|
66
|
+
else printf 'forge-hold-check: unexpected argument: %s\n' "$1" >&2; usage; exit 2; fi
|
|
67
|
+
;;
|
|
68
|
+
esac
|
|
69
|
+
done
|
|
70
|
+
|
|
71
|
+
[ -n "$RANGE" ] || { usage; exit 2; }
|
|
72
|
+
case "$RANGE" in
|
|
73
|
+
*...*) : ;;
|
|
74
|
+
*) printf 'forge-hold-check: range must be three-dot BASE...HEAD (got: %s)\n' "$RANGE" >&2; exit 2 ;;
|
|
75
|
+
esac
|
|
76
|
+
BASE_REF="${RANGE%%...*}"
|
|
77
|
+
HEAD_REF="${RANGE#*...}"
|
|
78
|
+
[ -n "$BASE_REF" ] && [ -n "$HEAD_REF" ] || { usage; exit 2; }
|
|
79
|
+
|
|
80
|
+
git rev-parse --verify --quiet "$BASE_REF^{commit}" >/dev/null 2>&1 \
|
|
81
|
+
|| { printf 'forge-hold-check: cannot resolve base ref: %s\n' "$BASE_REF" >&2; exit 2; }
|
|
82
|
+
git rev-parse --verify --quiet "$HEAD_REF^{commit}" >/dev/null 2>&1 \
|
|
83
|
+
|| { printf 'forge-hold-check: cannot resolve head ref: %s\n' "$HEAD_REF" >&2; exit 2; }
|
|
84
|
+
MB="$(git merge-base "$BASE_REF" "$HEAD_REF" 2>/dev/null)" \
|
|
85
|
+
|| { printf 'forge-hold-check: no merge-base for %s\n' "$RANGE" >&2; exit 2; }
|
|
86
|
+
|
|
87
|
+
# A supplied-but-missing approvals file is a failed derivation in the CI job —
|
|
88
|
+
# loud beats a silently unwaivable hold the operator can't diagnose.
|
|
89
|
+
if [ -n "$APPROVALS" ] && [ ! -f "$APPROVALS" ]; then
|
|
90
|
+
printf 'forge-hold-check: --approvals file not found: %s\n' "$APPROVALS" >&2; exit 2
|
|
91
|
+
fi
|
|
92
|
+
|
|
93
|
+
# --- base-side config (additive; the PR cannot rewrite the rules it is judged by)
|
|
94
|
+
CONF="$(git show "$MB:.forge/hold-config.yml" 2>/dev/null || true)"
|
|
95
|
+
|
|
96
|
+
conf_list() { # key → one value per line from the base-side config
|
|
97
|
+
[ -n "$CONF" ] || return 0
|
|
98
|
+
printf '%s\n' "$CONF" | awk -v k="$1" '
|
|
99
|
+
/^[A-Za-z_]+:/ { insec = (index($0, k ":") == 1) ? 1 : 0; next }
|
|
100
|
+
insec && /^[[:space:]]*-[[:space:]]*/ {
|
|
101
|
+
v = $0
|
|
102
|
+
sub(/^[[:space:]]*-[[:space:]]*/, "", v)
|
|
103
|
+
gsub(/["\047]/, "", v)
|
|
104
|
+
sub(/[[:space:]]*(#.*)?$/, "", v)
|
|
105
|
+
if (v != "") print v
|
|
106
|
+
}'
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
CONF_IRREVERSIBLE="$(conf_list irreversible_paths)"
|
|
110
|
+
CONF_DEPLOY="$(conf_list deploy_paths)"
|
|
111
|
+
CONF_GATING="$(conf_list gating_tests)"
|
|
112
|
+
CONF_OPERATORS="$(conf_list operators)"
|
|
113
|
+
|
|
114
|
+
glob_match() { # path, glob-list (newline) → 0 if any glob matches
|
|
115
|
+
[ -n "$2" ] || return 1
|
|
116
|
+
gm_path="$1"
|
|
117
|
+
# shellcheck disable=SC2086 — unquoted expansion inside case IS the pattern semantics
|
|
118
|
+
while IFS= read -r gm_g; do
|
|
119
|
+
[ -n "$gm_g" ] || continue
|
|
120
|
+
case "$gm_path" in
|
|
121
|
+
$gm_g) return 0 ;;
|
|
122
|
+
esac
|
|
123
|
+
done <<EOF
|
|
124
|
+
$2
|
|
125
|
+
EOF
|
|
126
|
+
return 1
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
# --- per-format dependency-key extraction (heuristic, documented) -------------
|
|
130
|
+
# JSON dep maps (package.json, composer.json): keys whose string value looks like a
|
|
131
|
+
# version/range ("1.2.3", "^1.0", ">=2", "*"). Top-level fields with non-version
|
|
132
|
+
# values ("name": "app", "test": "jest") are excluded; keys present on both sides
|
|
133
|
+
# cancel in the set-diff, so a bump never fires.
|
|
134
|
+
dep_keys() { # rev, path → one dependency key per line (empty if file absent)
|
|
135
|
+
dk_body="$(git show "$1:$2" 2>/dev/null || true)"
|
|
136
|
+
[ -n "$dk_body" ] || return 0
|
|
137
|
+
case "$(basename "$2")" in
|
|
138
|
+
package.json|composer.json)
|
|
139
|
+
# tr splits single-line JSON into one "key": "value" pair per line first.
|
|
140
|
+
printf '%s\n' "$dk_body" | tr ',{}' '\n\n\n' \
|
|
141
|
+
| sed -n 's/^[[:space:]]*"\([^"]*\)"[[:space:]]*:[[:space:]]*"[~^><=]*[0-9*][^"]*".*/\1/p'
|
|
142
|
+
;;
|
|
143
|
+
requirements*.txt)
|
|
144
|
+
printf '%s\n' "$dk_body" \
|
|
145
|
+
| grep -v '^[[:space:]]*#' | grep -v '^[[:space:]]*$' \
|
|
146
|
+
| sed 's/[[:space:]]*$//; s/[=<>!~;\[ ].*//' | grep -v '^-'
|
|
147
|
+
;;
|
|
148
|
+
Gemfile)
|
|
149
|
+
printf '%s\n' "$dk_body" \
|
|
150
|
+
| sed -n "s/^[[:space:]]*gem[[:space:]]*['\"]\([^'\"]*\)['\"].*/\1/p"
|
|
151
|
+
;;
|
|
152
|
+
go.mod)
|
|
153
|
+
printf '%s\n' "$dk_body" | awk '
|
|
154
|
+
/^require[[:space:]]*\(/ { inblk = 1; next }
|
|
155
|
+
inblk && /^\)/ { inblk = 0; next }
|
|
156
|
+
inblk && NF >= 2 && $2 ~ /^v[0-9]/ { print $1; next }
|
|
157
|
+
/^require[[:space:]]+[^ ]+[[:space:]]+v[0-9]/ { print $2 }'
|
|
158
|
+
;;
|
|
159
|
+
pyproject.toml|Cargo.toml)
|
|
160
|
+
printf '%s\n' "$dk_body" | awk '
|
|
161
|
+
/^\[/ { insec = ($0 ~ /dependencies/) ? 1 : 0; next }
|
|
162
|
+
insec && /^[A-Za-z0-9_."-]+[[:space:]]*=/ { k = $1; gsub(/"/, "", k); print k; next }
|
|
163
|
+
insec && /^[[:space:]]*"[A-Za-z0-9_.-]+[><=~!^ ]/ {
|
|
164
|
+
v = $0; sub(/^[[:space:]]*"/, "", v); sub(/[><=~!^ ].*/, "", v); print v }'
|
|
165
|
+
;;
|
|
166
|
+
esac
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
# --- fact computation ---------------------------------------------------------
|
|
170
|
+
CHANGED="$(git diff --name-only "$MB" "$HEAD_REF" 2>/dev/null)" \
|
|
171
|
+
|| { printf 'forge-hold-check: git diff failed for %s\n' "$RANGE" >&2; exit 2; }
|
|
172
|
+
|
|
173
|
+
FACTS=""
|
|
174
|
+
add_fact() { # class, path
|
|
175
|
+
FACTS="${FACTS}${1}:${2}
|
|
176
|
+
"
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
while IFS= read -r path; do
|
|
180
|
+
[ -n "$path" ] || continue
|
|
181
|
+
base_name="$(basename "$path")"
|
|
182
|
+
|
|
183
|
+
# Lockfiles never participate in any class (pain 6).
|
|
184
|
+
case "$base_name" in
|
|
185
|
+
package-lock.json|yarn.lock|pnpm-lock.yaml|composer.lock|Gemfile.lock|go.sum|Cargo.lock|uv.lock|poetry.lock)
|
|
186
|
+
continue ;;
|
|
187
|
+
esac
|
|
188
|
+
|
|
189
|
+
# irreversible — built-ins ∪ config irreversible_paths
|
|
190
|
+
case "$path" in
|
|
191
|
+
*/migrations/*|migrations/*|*/migrate/*|migrate/*|db/schema*|*.sql)
|
|
192
|
+
add_fact irreversible "$path"; continue ;;
|
|
193
|
+
esac
|
|
194
|
+
if glob_match "$path" "$CONF_IRREVERSIBLE"; then
|
|
195
|
+
add_fact irreversible "$path"; continue
|
|
196
|
+
fi
|
|
197
|
+
|
|
198
|
+
# protected — hard-coded self-protecting set ∪ config deploy_paths/gating_tests
|
|
199
|
+
case "$path" in
|
|
200
|
+
.github/workflows/*|.gitlab-ci.yml|.forge/project.yml|.forge/hold-config.yml|.forge/checks/*)
|
|
201
|
+
add_fact protected "$path"; continue ;;
|
|
202
|
+
esac
|
|
203
|
+
if glob_match "$path" "$CONF_DEPLOY" || glob_match "$path" "$CONF_GATING"; then
|
|
204
|
+
add_fact protected "$path"; continue
|
|
205
|
+
fi
|
|
206
|
+
|
|
207
|
+
# structural — NEW dependency key in a manifest (base vs head key-set diff)
|
|
208
|
+
case "$base_name" in
|
|
209
|
+
package.json|composer.json|pyproject.toml|Gemfile|go.mod|Cargo.toml|requirements*.txt)
|
|
210
|
+
base_keys="$(dep_keys "$MB" "$path" | sort -u)"
|
|
211
|
+
head_keys="$(dep_keys "$HEAD_REF" "$path" | sort -u)"
|
|
212
|
+
new_key=""
|
|
213
|
+
while IFS= read -r k; do
|
|
214
|
+
[ -n "$k" ] || continue
|
|
215
|
+
if ! printf '%s\n' "$base_keys" | grep -Fxq "$k"; then new_key="$k"; break; fi
|
|
216
|
+
done <<EOF
|
|
217
|
+
$head_keys
|
|
218
|
+
EOF
|
|
219
|
+
[ -n "$new_key" ] && add_fact structural "$path"
|
|
220
|
+
;;
|
|
221
|
+
esac
|
|
222
|
+
done <<EOF
|
|
223
|
+
$CHANGED
|
|
224
|
+
EOF
|
|
225
|
+
|
|
226
|
+
FACTS="$(printf '%s' "$FACTS" | sort -u)"
|
|
227
|
+
|
|
228
|
+
# --- verdict ------------------------------------------------------------------
|
|
229
|
+
if [ -z "$FACTS" ]; then
|
|
230
|
+
printf 'hold=clear\n'
|
|
231
|
+
exit 0
|
|
232
|
+
fi
|
|
233
|
+
|
|
234
|
+
WAIVED_BY=""
|
|
235
|
+
if [ -n "$APPROVALS" ] && [ -n "$CONF_OPERATORS" ]; then
|
|
236
|
+
while IFS= read -r login; do
|
|
237
|
+
login="$(printf '%s' "$login" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')"
|
|
238
|
+
[ -n "$login" ] || continue
|
|
239
|
+
if printf '%s\n' "$CONF_OPERATORS" | grep -Fxq "$login"; then
|
|
240
|
+
WAIVED_BY="$login"; break
|
|
241
|
+
else
|
|
242
|
+
printf 'forge-hold-check: approval from %s ignored (not in operators)\n' "$login" >&2
|
|
243
|
+
fi
|
|
244
|
+
done < "$APPROVALS"
|
|
245
|
+
fi
|
|
246
|
+
|
|
247
|
+
if [ -n "$WAIVED_BY" ]; then
|
|
248
|
+
printf 'hold=waived waived_by=%s\n' "$WAIVED_BY"
|
|
249
|
+
printf '%s\n' "$FACTS" | while IFS= read -r f; do
|
|
250
|
+
[ -n "$f" ] && printf 'fact=%s waived\n' "$f"
|
|
251
|
+
done
|
|
252
|
+
exit 0
|
|
253
|
+
fi
|
|
254
|
+
|
|
255
|
+
printf 'hold=blocked\n'
|
|
256
|
+
printf '%s\n' "$FACTS" | while IFS= read -r f; do
|
|
257
|
+
[ -n "$f" ] && printf 'fact=%s\n' "$f"
|
|
258
|
+
done
|
|
259
|
+
exit 1
|