forge-orkes 0.56.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.
@@ -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