forgedock 1.8.3 → 1.9.0
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/engine/admission.mjs +23 -4
- package/bin/engine/admission.test.mjs +37 -0
- package/commands/orchestrate/phase-1-resolve.md +40 -24
- package/commands/orchestrate/phase-3-dependency.md +56 -11
- package/commands/orchestrate/phase-4-execution.md +351 -111
- package/commands/orchestrate/phase-5-cleanup.md +17 -0
- package/commands/orchestrate/phase-6-report.md +1 -1
- package/commands/quality-gate.md +51 -0
- package/commands/recover-orphans.md +3 -3
- package/commands/review-pr.md +92 -19
- package/commands/work-on/build/context.md +25 -4
- package/commands/work-on/build/validate.md +1 -0
- package/commands/work-on/build.md +29 -9
- package/commands/work-on/close.md +107 -40
- package/commands/work-on/decompose.md +16 -7
- package/commands/work-on/investigate.md +16 -7
- package/commands/work-on/remediate.md +18 -4
- package/commands/work-on/review.md +51 -19
- package/commands/work-on.md +68 -9
- package/docs/CODEX.md +12 -0
- package/docs/CONFIG.md +49 -1
- package/docs/RELEASE-NOTES.md +52 -0
- package/package.json +1 -1
- package/scripts/amplification-breaker.sh +89 -0
- package/scripts/amplification-breaker.test.sh +39 -0
- package/scripts/check-admission-parity.mjs +74 -1
- package/scripts/check-spec-markers.sh +67 -3
- package/scripts/classify-finding.sh +120 -0
- package/scripts/classify-finding.test.sh +74 -0
- package/scripts/close-dossier-no-main-commit.test.sh +139 -0
- package/scripts/forge-root.test.sh +84 -27
- package/scripts/lint-build-validate-files.sh +29 -0
- package/scripts/lint-build-validate-files.test.sh +25 -0
- package/scripts/lint-dispatch-prompt.test.sh +2 -1
- package/scripts/recovery-claim-live.sh +73 -0
- package/scripts/recovery-claim-live.test.sh +66 -0
- package/scripts/verify-phase-trail.sh +183 -4
- package/scripts/verify-phase-trail.test.sh +211 -1
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
3
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
# Lint: every work-on:build:validate Skill( invocation in build.md must carry --files,
|
|
5
|
+
# and the B6.5 repair section must contain an explicit one (forge#3254).
|
|
6
|
+
# Usage: bash scripts/lint-build-validate-files.sh [path/to/build.md]
|
|
7
|
+
set -uo pipefail
|
|
8
|
+
HERE="$(cd "$(dirname "$0")" && pwd)"
|
|
9
|
+
SPEC="${1:-$HERE/../commands/work-on/build.md}"
|
|
10
|
+
[ -f "$SPEC" ] || { echo "lint-build-validate-files: spec not found: $SPEC" >&2; exit 2; }
|
|
11
|
+
rc=0
|
|
12
|
+
calls=$(grep -n 'Skill(skill=.*work-on:build:validate' "$SPEC" || true)
|
|
13
|
+
if [ -z "$calls" ]; then
|
|
14
|
+
echo "FAIL: no work-on:build:validate Skill( invocation found in $SPEC" >&2
|
|
15
|
+
exit 1
|
|
16
|
+
fi
|
|
17
|
+
bad=$(printf '%s\n' "$calls" | grep -vF -- '--files' || true)
|
|
18
|
+
if [ -n "$bad" ]; then
|
|
19
|
+
printf '%s\n' "$bad" | while IFS= read -r l; do
|
|
20
|
+
echo "FAIL: $SPEC:${l%%:*}: validate invocation lacks --files" >&2
|
|
21
|
+
done
|
|
22
|
+
rc=1
|
|
23
|
+
fi
|
|
24
|
+
if ! awk '/Phase B6\.5/{f=1} f' "$SPEC" | grep 'Skill(skill=.*work-on:build:validate' | grep -qF -- '--files'; then
|
|
25
|
+
echo "FAIL: $SPEC: B6.5 repair section has no explicit validate invocation carrying --files" >&2
|
|
26
|
+
rc=1
|
|
27
|
+
fi
|
|
28
|
+
[ "$rc" -eq 0 ] && echo "OK: all validate invocations carry --files"
|
|
29
|
+
exit "$rc"
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
3
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
# Fixtures for lint-build-validate-files.sh (forge#3254). Run: bash scripts/lint-build-validate-files.test.sh
|
|
5
|
+
set -uo pipefail
|
|
6
|
+
HERE="$(cd "$(dirname "$0")" && pwd)"
|
|
7
|
+
LINT="$HERE/lint-build-validate-files.sh"
|
|
8
|
+
T="$(mktemp -d)"; trap 'rm -rf "$T"' EXIT
|
|
9
|
+
PASS=0; FAILN=0
|
|
10
|
+
check() { # name want_rc file
|
|
11
|
+
bash "$LINT" "$3" >"$T/out" 2>&1; rc=$?
|
|
12
|
+
if [ "$rc" -eq "$2" ]; then PASS=$((PASS+1)); else FAILN=$((FAILN+1)); echo "FAIL: $1 (rc=$rc want $2)"; cat "$T/out"; fi
|
|
13
|
+
}
|
|
14
|
+
V='Skill(skill="{FORGE_SKILL_PREFIX}work-on:build:validate", args="{NUMBER} --worktree {W}'
|
|
15
|
+
printf '## Phase B6\n%s --files \\"{CHANGED_FILES}\\"")\n## Phase B6.5\n%s --files \\"{CHANGED_FILES}\\"")\n' "$V" "$V" > "$T/ok.md"
|
|
16
|
+
check "all carry --files" 0 "$T/ok.md"
|
|
17
|
+
printf '## Phase B6\n%s --files \\"x\\"")\n## Phase B6.5\n%s")\n' "$V" "$V" > "$T/bad1.md"
|
|
18
|
+
check "one invocation lacks --files" 1 "$T/bad1.md"
|
|
19
|
+
printf '## Phase B6\n%s --files \\"x\\"")\n## Phase B6.5\nfollowed by work-on:build:validate with the same args\n' "$V" > "$T/bad2.md"
|
|
20
|
+
check "B6.5 has no explicit invocation" 1 "$T/bad2.md"
|
|
21
|
+
printf 'nothing here\n' > "$T/bad3.md"
|
|
22
|
+
check "no invocations at all" 1 "$T/bad3.md"
|
|
23
|
+
check "real build.md" 0 "$HERE/../commands/work-on/build.md"
|
|
24
|
+
echo "passed=$PASS failed=$FAILN"
|
|
25
|
+
[ "$FAILN" -eq 0 ]
|
|
@@ -229,7 +229,8 @@ check "mid-line CR not stripped" 1 "$T/p25"
|
|
|
229
229
|
# 10. Spec snippet enforcement (forge#3070): extract the lint gate from the spec and run it in a loop.
|
|
230
230
|
# Gate = from the bootstrap marker to the end of its fenced code block (not a hard-coded count of
|
|
231
231
|
# top-level fi lines, which breaks whenever the bootstrap gains or loses a conditional).
|
|
232
|
-
|
|
232
|
+
# Several spec blocks carry the bootstrap marker (e.g. the recovery-claim gate, forge#3172); pick the one holding the lint gate.
|
|
233
|
+
awk '/^# FORGE_ROOT bootstrap/{f=1;buf=""} f&&/^```/{if(buf ~ /LINT_REFUSED_ISSUES/){printf "%s",buf;exit} f=0} f{buf=buf $0 "\n"}' "$SPEC" > "$T/gate"
|
|
233
234
|
[ -s "$T/gate" ] || { FAILN=$((FAILN+1)); echo "FAIL: could not extract spec lint gate"; }
|
|
234
235
|
gate_refused() { # prompt-file home -> prints refusal entries or LAUNCHED
|
|
235
236
|
( LINT_REFUSED_ISSUES=(); RENDERED_PROMPT="$(cat "$1")"; FORGEDOCK_HOME="$2"; unset FORGE_HOME; reset_vals
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
3
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
#
|
|
5
|
+
# recovery-claim-live.sh — is an issue currently claimed by a /recover-orphans sweep?
|
|
6
|
+
#
|
|
7
|
+
# Single source of truth for the dispatcher-side check of FORGE:RECOVERY_CLAIM, used by
|
|
8
|
+
# /work-on Phase 0B and /orchestrate Phase 4 so a dispatcher never re-enters an issue a sweep is
|
|
9
|
+
# resuming. Same semantics as `claim_orphan` in commands/recover-orphans.md: a claim is LIVE when it
|
|
10
|
+
# is an unreleased `<!-- FORGE:RECOVERY_CLAIM -->` comment, its `updated_at` is within
|
|
11
|
+
# RECOVERY_CLAIM_TTL_MIN (default 30) minutes, and no `FORGE:RECOVERY_CLAIM_RELEASED` comment names
|
|
12
|
+
# its sweep id. FORGE:HEARTBEAT is deliberately NOT counted: a dispatcher must not defer to another
|
|
13
|
+
# dispatcher's heartbeat (that is the lease/claims-board job).
|
|
14
|
+
#
|
|
15
|
+
# Usage:
|
|
16
|
+
# recovery-claim-live.sh <issue> -R <owner/repo> [--exempt-sweep <sweep-id>]
|
|
17
|
+
#
|
|
18
|
+
# --exempt-sweep Ignore the claim held by this sweep id (the calling sweep's own claim), so the
|
|
19
|
+
# holder's inline /work-on is not blocked by its own claim. A claim with no
|
|
20
|
+
# parsable sweep id is never exempt (and `unknown` is rejected as an exemption id).
|
|
21
|
+
#
|
|
22
|
+
# Output (stdout): CLAIM: LIVE <sweep-id> | CLAIM: FREE | CLAIM: ERROR
|
|
23
|
+
# Exit codes: 0 free, 1 live, 2 error (unreadable comments or usage; fails closed — callers must
|
|
24
|
+
# treat 2 like "live" and defer, never like "free").
|
|
25
|
+
#
|
|
26
|
+
# Trust note: any commenter's claim marker counts (same as claim_orphan). A spoofed claim can only
|
|
27
|
+
# defer an issue for RECOVERY_CLAIM_TTL_MIN unless it is refreshed.
|
|
28
|
+
# END-HELP (`-h` prints the header up to this line; keep it last)
|
|
29
|
+
|
|
30
|
+
set -uo pipefail
|
|
31
|
+
|
|
32
|
+
ISSUE=""; REPO=""; EXEMPT=""
|
|
33
|
+
err() { echo "CLAIM: ERROR"; echo "$1" >&2; exit 2; }
|
|
34
|
+
|
|
35
|
+
while [ $# -gt 0 ]; do
|
|
36
|
+
case "$1" in
|
|
37
|
+
-R|--repo)
|
|
38
|
+
[ $# -ge 2 ] && [ -n "${2:-}" ] || err "usage error: $1 needs a value"
|
|
39
|
+
REPO="$2"; shift 2 ;;
|
|
40
|
+
--exempt-sweep)
|
|
41
|
+
[ $# -ge 2 ] && [ -n "${2:-}" ] || err "usage error: $1 needs a value"
|
|
42
|
+
[ "$2" != "unknown" ] || err "usage error: --exempt-sweep 'unknown' is the no-sweep-id sentinel and cannot exempt a claim"
|
|
43
|
+
EXEMPT="$2"; shift 2 ;;
|
|
44
|
+
-h|--help) awk 'NR >= 5 { if (/^# END-HELP/) exit; print }' "$0"; exit 0 ;;
|
|
45
|
+
*)
|
|
46
|
+
if [ -z "$ISSUE" ] && [[ "$1" =~ ^[0-9]+$ ]]; then ISSUE="$1"; shift
|
|
47
|
+
else err "usage error: unexpected argument '$1'"; fi ;;
|
|
48
|
+
esac
|
|
49
|
+
done
|
|
50
|
+
[ -n "$ISSUE" ] && [ -n "$REPO" ] || err "usage: recovery-claim-live.sh <issue> -R <owner/repo> [--exempt-sweep <id>]"
|
|
51
|
+
|
|
52
|
+
TTL="${RECOVERY_CLAIM_TTL_MIN:-30}"
|
|
53
|
+
[[ "$TTL" =~ ^[0-9]+$ ]] || TTL=30
|
|
54
|
+
CUTOFF=$(( $(date -u +%s) - TTL * 60 ))
|
|
55
|
+
|
|
56
|
+
# pipefail: a gh failure (rate limit, 403) must not look like "no comments".
|
|
57
|
+
COMMENTS=$(gh api --paginate "repos/${REPO}/issues/${ISSUE}/comments" 2>/dev/null | jq -s 'add // []') \
|
|
58
|
+
|| err "could not read comments for #${ISSUE}"
|
|
59
|
+
|
|
60
|
+
LIVE=$(echo "$COMMENTS" | jq -r --argjson cutoff "$CUTOFF" --arg exempt "$EXEMPT" '
|
|
61
|
+
def sweepid: ((.body | capture("Sweep: (?<id>[^ \n*]+)")?) // {id: "unknown"}).id;
|
|
62
|
+
# "unknown" is the no-id sentinel, never a matchable id: an unparsable release frees nothing (fail closed).
|
|
63
|
+
([.[] | select(.body | contains("FORGE:RECOVERY_CLAIM_RELEASED")) | sweepid] | map(select(. != "unknown"))) as $rel
|
|
64
|
+
| [ .[] | select(.body | contains("<!-- FORGE:RECOVERY_CLAIM -->"))
|
|
65
|
+
| select((.body | contains("FORGE:RECOVERY_CLAIM_RELEASED")) | not)
|
|
66
|
+
| select(((.updated_at // "") | fromdateiso8601? // 9999999999) >= $cutoff)
|
|
67
|
+
| select(sweepid as $sid | ($rel | index($sid)) == null)
|
|
68
|
+
| select($exempt == "" or sweepid == "unknown" or sweepid != $exempt)
|
|
69
|
+
| sweepid ] | first // empty') \
|
|
70
|
+
|| err "could not evaluate recovery claims for #${ISSUE}"
|
|
71
|
+
|
|
72
|
+
if [ -n "$LIVE" ]; then echo "CLAIM: LIVE ${LIVE}"; exit 1; fi
|
|
73
|
+
echo "CLAIM: FREE"; exit 0
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# recovery-claim-live.test.sh — tests for scripts/recovery-claim-live.sh
|
|
3
|
+
# No network: `gh` is mocked on PATH; MOCK_GH_JSON names a fixture holding the JSON array
|
|
4
|
+
# `gh api .../comments` would return. MOCK_GH_FAIL=1 simulates an outage.
|
|
5
|
+
#
|
|
6
|
+
# Usage: bash scripts/recovery-claim-live.test.sh
|
|
7
|
+
# SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
8
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
9
|
+
|
|
10
|
+
set -uo pipefail
|
|
11
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
12
|
+
CHECK="$SCRIPT_DIR/recovery-claim-live.sh"
|
|
13
|
+
TMP_BIN=$(mktemp -d); TMP_FX=$(mktemp -d)
|
|
14
|
+
trap 'rm -rf "$TMP_BIN" "$TMP_FX"' EXIT
|
|
15
|
+
|
|
16
|
+
cat > "$TMP_BIN/gh" <<'MOCK'
|
|
17
|
+
#!/usr/bin/env bash
|
|
18
|
+
if [ "${MOCK_GH_FAIL:-}" = "1" ]; then echo "mock gh: outage" >&2; exit 1; fi
|
|
19
|
+
cat "$MOCK_GH_JSON"
|
|
20
|
+
MOCK
|
|
21
|
+
chmod +x "$TMP_BIN/gh"
|
|
22
|
+
export PATH="$TMP_BIN:$PATH"
|
|
23
|
+
|
|
24
|
+
PASS=0; FAILN=0
|
|
25
|
+
ok() { PASS=$((PASS+1)); echo "ok - $1"; }
|
|
26
|
+
bad() { FAILN=$((FAILN+1)); echo "FAIL - $1"; }
|
|
27
|
+
|
|
28
|
+
now_iso() { date -u +%Y-%m-%dT%H:%M:%SZ; }
|
|
29
|
+
old_iso() { date -u -d '2 hours ago' +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -v-2H +%Y-%m-%dT%H:%M:%SZ; }
|
|
30
|
+
|
|
31
|
+
# fx <name> <json-array>
|
|
32
|
+
fx() { echo "$2" > "$TMP_FX/$1.json"; echo "$TMP_FX/$1.json"; }
|
|
33
|
+
claim() { jq -cn --arg id "$1" --arg t "$2" '{body:("<!-- FORGE:RECOVERY_CLAIM -->\n**Sweep: "+$id+"**\nHolder: /recover-orphans"), updated_at:$t}'; }
|
|
34
|
+
release() { jq -cn --arg id "$1" --arg t "$2" '{body:("<!-- FORGE:RECOVERY_CLAIM_RELEASED -->\n**Sweep: "+$id+"**\nReleased"), updated_at:$t}'; }
|
|
35
|
+
|
|
36
|
+
run() { local f="$1"; shift; OUT=$(MOCK_GH_JSON="$f" bash "$CHECK" 7 -R o/r "$@" 2>/dev/null); RC=$?; }
|
|
37
|
+
expect() { # name rc needle
|
|
38
|
+
[ "$RC" -eq "$2" ] && echo "$OUT" | grep -q "$3" && ok "$1" || bad "$1 (rc=$RC out=$OUT)"
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
N=$(now_iso); O=$(old_iso)
|
|
42
|
+
|
|
43
|
+
run "$(fx live "[$(claim sweep-A "$N")]")"; expect "fresh claim is LIVE" 1 "CLAIM: LIVE sweep-A"
|
|
44
|
+
run "$(fx exp "[$(claim sweep-A "$O")]")"; expect "expired claim is FREE" 0 "CLAIM: FREE"
|
|
45
|
+
run "$(fx rel "[$(claim sweep-A "$N"), $(release sweep-A "$N")]")"; expect "released claim is FREE" 0 "CLAIM: FREE"
|
|
46
|
+
run "$(fx relother "[$(claim sweep-A "$N"), $(release sweep-B "$N")]")"; expect "release of another sweep does not free the claim" 1 "CLAIM: LIVE sweep-A"
|
|
47
|
+
run "$(fx unkrel "[{\"body\":\"<!-- FORGE:RECOVERY_CLAIM -->\\nno sweep line\",\"updated_at\":\"$N\"}, {\"body\":\"<!-- FORGE:RECOVERY_CLAIM_RELEASED -->\\nno sweep line\",\"updated_at\":\"$N\"}]")"; expect "release with no sweep id does not free an id-less claim" 1 "CLAIM: LIVE unknown"
|
|
48
|
+
run "$(fx own "[$(claim sweep-A "$N")]")" --exempt-sweep sweep-A; expect "own sweep id is exempt" 0 "CLAIM: FREE"
|
|
49
|
+
run "$(fx other "[$(claim sweep-A "$N")]")" --exempt-sweep sweep-B; expect "other sweep id is not exempt" 1 "CLAIM: LIVE sweep-A"
|
|
50
|
+
run "$(fx two "[$(claim sweep-A "$N"), $(claim sweep-B "$N")]")" --exempt-sweep sweep-A; expect "exempting A still sees live B" 1 "CLAIM: LIVE sweep-B"
|
|
51
|
+
run "$(fx unk "[{\"body\":\"<!-- FORGE:RECOVERY_CLAIM -->\\nno sweep line\",\"updated_at\":\"$N\"}]")" --exempt-sweep sweep-A; expect "claim with no sweep id is never exempt" 1 "CLAIM: LIVE"
|
|
52
|
+
run "$(fx unk2 "[{\"body\":\"<!-- FORGE:RECOVERY_CLAIM -->\\nno sweep line\",\"updated_at\":\"$N\"}]")" --exempt-sweep unknown; expect "--exempt-sweep unknown is rejected" 2 "CLAIM: ERROR"
|
|
53
|
+
run "$(fx none '[]')"; expect "no comments is FREE" 0 "CLAIM: FREE"
|
|
54
|
+
run "$(fx hb "[{\"body\":\"<!-- FORGE:HEARTBEAT -->\\nPhase 3\",\"updated_at\":\"$N\"}]")"; expect "heartbeat alone is not a recovery claim" 0 "CLAIM: FREE"
|
|
55
|
+
run "$(fx bad "[{\"body\":\"<!-- FORGE:RECOVERY_CLAIM -->\\nno sweep line\",\"updated_at\":\"$N\"}]")"; expect "malformed claim fails closed (LIVE)" 1 "CLAIM: LIVE"
|
|
56
|
+
run "$(fx nodate "[{\"body\":\"<!-- FORGE:RECOVERY_CLAIM -->\\n**Sweep: sweep-A**\",\"updated_at\":\"garbage\"}]")"; expect "unparsable updated_at fails closed (LIVE)" 1 "CLAIM: LIVE"
|
|
57
|
+
|
|
58
|
+
OUT=$(MOCK_GH_FAIL=1 MOCK_GH_JSON=/dev/null bash "$CHECK" 7 -R o/r 2>/dev/null); RC=$?
|
|
59
|
+
expect "gh outage is ERROR (exit 2)" 2 "CLAIM: ERROR"
|
|
60
|
+
OUT=$(MOCK_GH_JSON="$(fx junk 'not json')" bash "$CHECK" 7 -R o/r 2>/dev/null); RC=$?
|
|
61
|
+
expect "unparsable response is ERROR (exit 2)" 2 "CLAIM: ERROR"
|
|
62
|
+
OUT=$(bash "$CHECK" 2>/dev/null); RC=$?; expect "no args is a usage error" 2 "CLAIM: ERROR"
|
|
63
|
+
OUT=$(bash "$CHECK" 7 -R 2>/dev/null); RC=$?; expect "-R without value is a usage error" 2 "CLAIM: ERROR"
|
|
64
|
+
|
|
65
|
+
echo "passed=$PASS failed=$FAILN"
|
|
66
|
+
[ "$FAILN" -eq 0 ]
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
# verdict alone after phases were silently skipped.
|
|
10
10
|
#
|
|
11
11
|
# Usage:
|
|
12
|
-
# verify-phase-trail.sh <issue> -R <owner/repo> [--docs-only] [--code-diff] [--head-tree <sha>]
|
|
12
|
+
# verify-phase-trail.sh <issue> -R <owner/repo> [--docs-only] [--code-diff] [--head-tree <sha>] [--head-sha <sha>]
|
|
13
13
|
#
|
|
14
14
|
# --docs-only The diff is documentation-only; the FORGE:QUALITY_GATE marker
|
|
15
15
|
# is not required.
|
|
@@ -22,6 +22,9 @@
|
|
|
22
22
|
# An empty or non-hex value is a usage error (exit 2, fails closed).
|
|
23
23
|
# Scope: this binds the PASS to the built tree; it is not tamper-proof, because the
|
|
24
24
|
# pipeline agent posts under the same trusted identity and could compute the tree itself.
|
|
25
|
+
# --head-sha Commit id (40 or 64 hex) of the head being gated. Enables the human break-glass
|
|
26
|
+
# override (below); without it an override comment is never evaluated. An empty or
|
|
27
|
+
# non-hex value is a usage error (exit 2, fails closed).
|
|
25
28
|
#
|
|
26
29
|
# Requirements by COMPLEXITY_BAND (read from the FORGE:FAST_PATH comment).
|
|
27
30
|
# The authoritative table is the `case "$EFFECTIVE"` block below; this summary must be
|
|
@@ -38,9 +41,14 @@
|
|
|
38
41
|
# PHASE_TRAIL: PASS|FAIL|ERROR
|
|
39
42
|
# BAND: <band>
|
|
40
43
|
# MISSING: <marker> -> <phase to re-run> (one line per missing artifact)
|
|
44
|
+
# PHASE_TRAIL: OVERRIDDEN (exit 0 via an accepted break-glass override)
|
|
45
|
+
# WAIVED: <marker list> (the MISSING set the override waived)
|
|
46
|
+
# OVERRIDE: approver=<login> head=<sha> missing=<list> reason=<sanitised>
|
|
47
|
+
# (the ONLY source callers use to record the override)
|
|
41
48
|
#
|
|
42
|
-
# Exit codes: 0 pass, 1 one or more artifacts missing, 2 could not
|
|
49
|
+
# Exit codes: 0 pass (or accepted override), 1 one or more artifacts missing, 2 could not
|
|
43
50
|
# read the issue (fails closed — an unreadable trail is never treated as a pass).
|
|
51
|
+
# Exit 2 is NEVER overridable: override evaluation only runs on the exit-1 (MISSING) path.
|
|
44
52
|
# Callers route on the exit code: 1 -> re-run the MISSING phases; 2 (or 127 when the
|
|
45
53
|
# verifier itself cannot be resolved) -> infrastructure BLOCKED, never "missing phases".
|
|
46
54
|
#
|
|
@@ -62,11 +70,36 @@
|
|
|
62
70
|
# Identities outside that set -- e.g. a human with author_association CONTRIBUTOR/NONE/FIRST_TIME_CONTRIBUTOR
|
|
63
71
|
# (an external contributor running the pipeline under their own login) -- are NOT trusted, so their
|
|
64
72
|
# markers are ignored and the gate reports them MISSING. To accept such an identity add its login to
|
|
65
|
-
# FORGE_TRAIL_TRUSTED_LOGINS
|
|
73
|
+
# FORGE_TRAIL_TRUSTED_LOGINS (do NOT widen FORGE_TRAIL_TRUSTED_ASSOCIATIONS to CONTRIBUTOR: that lets any
|
|
74
|
+
# contributor forge FORGE:* trail markers). On FAIL the
|
|
66
75
|
# script prints a NOTE when untrusted-author FORGE markers were seen, so this is diagnosable (#3123).
|
|
67
76
|
# Limits: "Bot" trusts any GitHub App/bot that can comment on the repo (set
|
|
68
77
|
# FORGE_TRAIL_TRUSTED_ASSOCIATIONS and FORGE_TRAIL_TRUSTED_LOGINS to tighten);
|
|
69
78
|
# COLLABORATOR includes read-level collaborators; login matching is case-sensitive.
|
|
79
|
+
# The override prefilter uses FORGE_TRAIL_OVERRIDE_ASSOCIATIONS (default: the trail list). Widen THAT variable,
|
|
80
|
+
# not the trail list, for concealed-membership approvers; it never affects which FORGE markers count toward the
|
|
81
|
+
# gate. Blast radius of widening FORGE_TRAIL_TRUSTED_ASSOCIATIONS: every listed association can forge trail markers.
|
|
82
|
+
#
|
|
83
|
+
# Break-glass override (#3152): a misfiring gate can be cleared by a HUMAN, never by the pipeline.
|
|
84
|
+
# A comment whose body starts with `<!-- FORGE:PHASE_TRAIL_OVERRIDE -->` and carries the lines
|
|
85
|
+
# **Head**: <full head sha>
|
|
86
|
+
# **Missing**: <marker names, comma separated>
|
|
87
|
+
# **Reason**: <one line>
|
|
88
|
+
# is honoured only when ALL of these hold (any error, null field or API failure rejects it and the
|
|
89
|
+
# gate stays at exit 1; never a pass):
|
|
90
|
+
# - user.type == "User" (allowlist: Bots and null users are rejected) with a plain login;
|
|
91
|
+
# - updated_at == created_at (an edited comment is rejected: tamper evidence);
|
|
92
|
+
# - the author has repo permission admin or write (collaborators/<login>/permission), not mere
|
|
93
|
+
# org membership or read access;
|
|
94
|
+
# - the author is NOT a pipeline identity: FORGE_TRAIL_PIPELINE_LOGINS (comma-separated, case-insensitive)
|
|
95
|
+
# plus the author of every trusted FORGE marker comment (BUILDER, INVESTIGATOR, CONTRACT, ...), so a
|
|
96
|
+
# pipeline running under a human token cannot approve its own gate. A solo operator therefore needs a
|
|
97
|
+
# second human with write access to post the override;
|
|
98
|
+
# - **Head** equals --head-sha and **Missing** equals the current MISSING marker-name set exactly
|
|
99
|
+
# (a new commit or a different failure invalidates it);
|
|
100
|
+
# - created_at is later than the latest trusted FORGE:BUILDER:COMPLETE update time.
|
|
101
|
+
# The reason is sanitised (control chars stripped, comment markers neutralised, whitespace collapsed,
|
|
102
|
+
# capped at 200 chars); an empty reason rejects the override.
|
|
70
103
|
# END-HELP (`-h` prints the header up to this line; keep it last)
|
|
71
104
|
|
|
72
105
|
set -uo pipefail
|
|
@@ -77,6 +110,8 @@ DOCS_ONLY=0
|
|
|
77
110
|
CODE_DIFF=0
|
|
78
111
|
HEAD_TREE=""
|
|
79
112
|
HEAD_TREE_SET=0
|
|
113
|
+
HEAD_SHA=""
|
|
114
|
+
HEAD_SHA_SET=0
|
|
80
115
|
|
|
81
116
|
while [ $# -gt 0 ]; do
|
|
82
117
|
case "$1" in
|
|
@@ -88,6 +123,9 @@ while [ $# -gt 0 ]; do
|
|
|
88
123
|
--head-tree)
|
|
89
124
|
if [ $# -lt 2 ]; then echo "PHASE_TRAIL: ERROR"; echo "usage error: $1 needs a value" >&2; exit 2; fi
|
|
90
125
|
HEAD_TREE="$2"; HEAD_TREE_SET=1; shift 2 ;;
|
|
126
|
+
--head-sha)
|
|
127
|
+
if [ $# -lt 2 ]; then echo "PHASE_TRAIL: ERROR"; echo "usage error: $1 needs a value" >&2; exit 2; fi
|
|
128
|
+
HEAD_SHA="$2"; HEAD_SHA_SET=1; shift 2 ;;
|
|
91
129
|
-h|--help) awk 'NR >= 5 { if (/^# END-HELP/) exit; print }' "$0"; exit 0 ;;
|
|
92
130
|
*)
|
|
93
131
|
if [ -z "$ISSUE" ] && [[ "$1" =~ ^[0-9]+$ ]]; then ISSUE="$1"; shift
|
|
@@ -98,7 +136,7 @@ done
|
|
|
98
136
|
|
|
99
137
|
if [ -z "$ISSUE" ] || [ -z "$REPO" ]; then
|
|
100
138
|
echo "PHASE_TRAIL: ERROR"
|
|
101
|
-
echo "usage: verify-phase-trail.sh <issue> -R <owner/repo> [--docs-only] [--code-diff] [--head-tree <sha>]" >&2
|
|
139
|
+
echo "usage: verify-phase-trail.sh <issue> -R <owner/repo> [--docs-only] [--code-diff] [--head-tree <sha>] [--head-sha <sha>]" >&2
|
|
102
140
|
exit 2
|
|
103
141
|
fi
|
|
104
142
|
if [ "$HEAD_TREE_SET" = "1" ]; then
|
|
@@ -111,6 +149,16 @@ if [ "$HEAD_TREE_SET" = "1" ]; then
|
|
|
111
149
|
fi
|
|
112
150
|
fi
|
|
113
151
|
|
|
152
|
+
if [ "$HEAD_SHA_SET" = "1" ]; then
|
|
153
|
+
# Fail closed: a caller that could not resolve the head passes an empty value and must not silently skip the binding.
|
|
154
|
+
HEAD_SHA=$(printf '%s' "$HEAD_SHA" | tr 'A-F' 'a-f')
|
|
155
|
+
if ! [[ "$HEAD_SHA" =~ ^([0-9a-f]{40}|[0-9a-f]{64})$ ]]; then
|
|
156
|
+
echo "PHASE_TRAIL: ERROR"
|
|
157
|
+
echo "usage error: --head-sha needs a 40 or 64 hex commit id" >&2
|
|
158
|
+
exit 2
|
|
159
|
+
fi
|
|
160
|
+
fi
|
|
161
|
+
|
|
114
162
|
RAW=$(gh api "repos/${REPO}/issues/${ISSUE}/comments" --paginate 2>/dev/null) || {
|
|
115
163
|
echo "PHASE_TRAIL: ERROR"
|
|
116
164
|
echo "could not read comments for ${REPO}#${ISSUE}" >&2
|
|
@@ -130,6 +178,10 @@ RAW=$(printf '%s' "$RAW" | jq -s 'if all(.[]; type == "array" and all(.[]; type
|
|
|
130
178
|
# One line per comment, newlines folded to \x1f so a marker and its sentinel can
|
|
131
179
|
# be matched within the SAME comment.
|
|
132
180
|
TRUSTED_ASSOC="${FORGE_TRAIL_TRUSTED_ASSOCIATIONS-OWNER,MEMBER,COLLABORATOR}"
|
|
181
|
+
# Separate trust decision (forge#3350): the break-glass override prefilter has its own list so it can be widened
|
|
182
|
+
# (e.g. CONTRIBUTOR for concealed-membership approvers) without loosening the trail-marker gate. Defaults to the
|
|
183
|
+
# trail list; ${VAR-default} (no colon) so an explicitly empty value yields no override candidates (fail closed).
|
|
184
|
+
OVERRIDE_ASSOC="${FORGE_TRAIL_OVERRIDE_ASSOCIATIONS-$TRUSTED_ASSOC}"
|
|
133
185
|
TRUSTED_LOGINS="${FORGE_TRAIL_TRUSTED_LOGINS-}"
|
|
134
186
|
COMMENTS=$(printf '%s' "$RAW" | jq -r --arg assoc "$TRUSTED_ASSOC" --arg logins "$TRUSTED_LOGINS" '
|
|
135
187
|
($assoc | split(",") | map(gsub("^\\s+|\\s+$"; "")) | map(select(length > 0))) as $A
|
|
@@ -263,6 +315,133 @@ if [ "${#MISSING[@]}" -eq 0 ]; then
|
|
|
263
315
|
exit 0
|
|
264
316
|
fi
|
|
265
317
|
|
|
318
|
+
# --- Break-glass override (#3152) ---------------------------------------------------------------
|
|
319
|
+
# Reached ONLY with a non-empty MISSING set (exit-1 path); every `exit 2` above precedes it.
|
|
320
|
+
# Prints the OVERRIDE/WAIVED lines and returns 0 only when a comment passes every rule; any failure
|
|
321
|
+
# (jq error, permission API error, empty field) returns 1 and the caller falls through to FAIL.
|
|
322
|
+
# Name set normaliser shared by BOTH sides of the comparison: drops ` -> action` and `(qualifier)`,
|
|
323
|
+
# trims, de-duplicates, sorts, joins with commas. bash 3.2/BSD portable.
|
|
324
|
+
norm_set() {
|
|
325
|
+
sed -e 's/ -> .*$//' -e 's/([^)]*)//g' -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' \
|
|
326
|
+
| grep -v '^$' | LC_ALL=C sort -u | paste -sd, -
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
evaluate_override() {
|
|
330
|
+
[ "$HEAD_SHA_SET" = "1" ] || return 1
|
|
331
|
+
local cur_set cands pipeline_logins login created head missing reason perm n=0 total=0 seen_logins="" llogin
|
|
332
|
+
cur_set=$(printf '%s\n' "${MISSING[@]}" | norm_set) || return 1
|
|
333
|
+
[ -n "$cur_set" ] || return 1
|
|
334
|
+
# BUILDER exists but its completion time is unknown -> cannot prove the override is newer: reject.
|
|
335
|
+
if has '<!-- FORGE:BUILDER -->' && [ -z "$BUILD_AT" ]; then return 1; fi
|
|
336
|
+
# Pipeline identity set (configured + derived): authors of trusted FORGE marker comments other than overrides.
|
|
337
|
+
# The token this verifier runs under is the pipeline's identity too (forge#3269): a pipeline using a human
|
|
338
|
+
# token with no markers yet must not approve its own override. Lookup failure adds nothing (derived set still applies).
|
|
339
|
+
local self_login extra_logins marker_floor
|
|
340
|
+
self_login=$(gh api user --jq '.login' 2>/dev/null || true)
|
|
341
|
+
case "$self_login" in *[!A-Za-z0-9_-]*) self_login="" ;; esac
|
|
342
|
+
extra_logins="${FORGE_TRAIL_PIPELINE_LOGINS-}${self_login:+,$self_login}"
|
|
343
|
+
pipeline_logins=$(printf '%s' "$RAW" | jq -r --arg assoc "$TRUSTED_ASSOC" --arg logins "$TRUSTED_LOGINS" --arg extra "$extra_logins" '
|
|
344
|
+
($assoc | split(",") | map(gsub("^\\s+|\\s+$"; "")) | map(select(length > 0))) as $A
|
|
345
|
+
| ($logins | split(",") | map(gsub("^\\s+|\\s+$"; "")) | map(select(length > 0))) as $L
|
|
346
|
+
| ($extra | split(",") | map(gsub("^\\s+|\\s+$"; "") | ascii_downcase) | map(select(length > 0))) as $E
|
|
347
|
+
| ( [ .[]
|
|
348
|
+
| select(
|
|
349
|
+
((.author_association // "") as $x | $A | index($x) != null)
|
|
350
|
+
or ((.user.type // "") == "Bot")
|
|
351
|
+
or ((.user.login // "") as $x | $L | index($x) != null))
|
|
352
|
+
| select((.body // "") | startswith("<!-- FORGE:") and (startswith("<!-- FORGE:PHASE_TRAIL_OVERRIDE -->") | not))
|
|
353
|
+
| (.user.login // empty) | ascii_downcase ] + $E ) | unique | .[]' 2>/dev/null) || return 1
|
|
354
|
+
# Time floor (forge#3271): the latest trusted BUILDER:COMPLETE. With none, fall back to the newest trusted
|
|
355
|
+
# non-override FORGE marker so an override still has to post-date the pipeline's own activity.
|
|
356
|
+
marker_floor="$BUILD_AT"
|
|
357
|
+
if [ -z "$marker_floor" ]; then
|
|
358
|
+
marker_floor=$(printf '%s' "$RAW" | jq -r --arg assoc "$TRUSTED_ASSOC" --arg logins "$TRUSTED_LOGINS" '
|
|
359
|
+
($assoc | split(",") | map(gsub("^\\s+|\\s+$"; "")) | map(select(length > 0))) as $A
|
|
360
|
+
| ($logins | split(",") | map(gsub("^\\s+|\\s+$"; "")) | map(select(length > 0))) as $L
|
|
361
|
+
| [ .[]
|
|
362
|
+
| select(((.author_association // "") as $x | $A | index($x) != null)
|
|
363
|
+
or ((.user.type // "") == "Bot")
|
|
364
|
+
or ((.user.login // "") as $x | $L | index($x) != null))
|
|
365
|
+
| select((.body // "") | startswith("<!-- FORGE:") and (startswith("<!-- FORGE:PHASE_TRAIL_OVERRIDE -->") | not))
|
|
366
|
+
| .created_at // empty ] | sort | .[-1] // empty' 2>/dev/null) || return 1
|
|
367
|
+
fi
|
|
368
|
+
# Candidates, newest first, as TSV: login created_at head missing reason (reason last, others never empty).
|
|
369
|
+
# The author_association prefilter uses its own list ($OVERRIDE_ASSOC, default = trail list) and is only an
|
|
370
|
+
# optimization (forge#3279): the permission API stays authoritative. A real write/admin approver with concealed
|
|
371
|
+
# org membership can show CONTRIBUTOR/NONE and is filtered out here; widen FORGE_TRAIL_OVERRIDE_ASSOCIATIONS to
|
|
372
|
+
# accept them (never the trail list). An empty list yields no candidates (fail closed). Filtered candidates are counted in a NOTE.
|
|
373
|
+
# One jq pass (forge#3351): the eligibility predicate is defined once (`eligible`); the first output line is the
|
|
374
|
+
# count of eligible comments dropped by the association prefilter, the rest are the candidate TSV rows.
|
|
375
|
+
local assoc_dropped out
|
|
376
|
+
out=$(printf '%s' "$RAW" | jq -r --arg assoc "$OVERRIDE_ASSOC" --arg bt "$marker_floor" '
|
|
377
|
+
($assoc | split(",") | map(gsub("^\\s+|\\s+$"; "")) | map(select(length > 0))) as $A
|
|
378
|
+
| def clean: explode | map(select(. >= 32 and . != 127 and (. < 128 or . > 159) and (. < 8203 or . > 8207) and (. < 8232 or . > 8238) and (. < 8288 or . > 8297) and . != 65279)) | implode;
|
|
379
|
+
def eligible:
|
|
380
|
+
select((.body // "") | startswith("<!-- FORGE:PHASE_TRAIL_OVERRIDE -->"))
|
|
381
|
+
| select((.user.type // "") == "User")
|
|
382
|
+
| select(((.user.login // "") | test("^[A-Za-z0-9_-]+$")))
|
|
383
|
+
| select((.created_at // "") != "" and (.updated_at // "") != "" and .created_at == .updated_at)
|
|
384
|
+
| select($bt == "" or .created_at > $bt);
|
|
385
|
+
def assoc_ok: (.author_association // "") as $x | $A | index($x) != null;
|
|
386
|
+
( [ .[] | eligible | select(assoc_ok | not) ] | length ) as $dropped
|
|
387
|
+
| ( [ .[]
|
|
388
|
+
| eligible
|
|
389
|
+
| select(assoc_ok)
|
|
390
|
+
| (.body | split("\n") | map(sub("\r$"; ""))) as $ln
|
|
391
|
+
| { login: .user.login, at: .created_at,
|
|
392
|
+
head: ([$ln[] | capture("^\\*\\*Head\\*\\*: *(?<v>[0-9A-Fa-f]{40,64}) *$")? | .v] | .[0] // "-"),
|
|
393
|
+
missing: ([$ln[] | capture("^\\*\\*Missing\\*\\*: *(?<v>.+)$")? | .v] | .[0] // "-"),
|
|
394
|
+
reason: ([$ln[] | capture("^\\*\\*Reason\\*\\*: *(?<v>.*)$")? | .v] | .[0] // "")
|
|
395
|
+
| clean | gsub("<!--"; "<!-/-") | gsub("-->"; "-/->") | gsub("`"; "'"'"'") | gsub("@"; "(at)")
|
|
396
|
+
| gsub("\\s+"; " ") | sub("^ "; "") | sub(" $"; "") | .[0:200] } ]
|
|
397
|
+
| sort_by(.at) | reverse ) as $rows
|
|
398
|
+
| "#DROPPED\t\($dropped)",
|
|
399
|
+
($rows[] | [.login, .at, .head, .missing, (if .reason == "" then "-" else .reason end)] | @tsv)' 2>/dev/null) || return 1
|
|
400
|
+
assoc_dropped=$(printf '%s\n' "$out" | head -n 1 | cut -f2)
|
|
401
|
+
case "$assoc_dropped" in ''|*[!0-9]*) assoc_dropped=0 ;; esac
|
|
402
|
+
if [ "$assoc_dropped" -gt 0 ]; then echo "NOTE: ${assoc_dropped} override candidate(s) ignored: author_association not in override-approver set" >&2; fi
|
|
403
|
+
cands=$(printf '%s\n' "$out" | tail -n +2)
|
|
404
|
+
[ -n "$cands" ] || return 1
|
|
405
|
+
while IFS=$'\t' read -r login created head missing reason; do
|
|
406
|
+
[ -n "$login" ] || continue
|
|
407
|
+
[ "$reason" != "-" ] && [ -n "$reason" ] || continue
|
|
408
|
+
reason=$(printf '%s' "$reason" | tr -d '[:cntrl:]') # belt and braces: cntrl (incl. ESC) stripped again
|
|
409
|
+
[ -n "$reason" ] || continue
|
|
410
|
+
[ "$(printf '%s' "$head" | tr 'A-F' 'a-f')" = "$HEAD_SHA" ] || continue
|
|
411
|
+
[ "$(printf '%s' "$missing" | tr ',' '\n' | norm_set)" = "$cur_set" ] || continue
|
|
412
|
+
if printf '%s\n' "$pipeline_logins" | grep -Fx -- "$(printf '%s' "$login" | tr 'A-Z' 'a-z')" >/dev/null; then continue; fi
|
|
413
|
+
# forge#3279/#3306: dedupe distinct logins (bash 3.2-portable newline list, no associative arrays); a login seen
|
|
414
|
+
# before was already rejected (an accepted one returns), so repeat comments cost no slot and no API call.
|
|
415
|
+
# Cap accounting: a DEFINITIVE negative (read/triage/none) is free so read-level members cannot crowd out an older
|
|
416
|
+
# real approver; only UNREADABLE lookups spend the 5-slot error cap, and 25 distinct lookups is a hard ceiling.
|
|
417
|
+
llogin=$(printf '%s' "$login" | tr 'A-Z' 'a-z')
|
|
418
|
+
if printf '%s\n' "$seen_logins" | grep -Fx -- "$llogin" >/dev/null; then continue; fi
|
|
419
|
+
total=$((total+1)); [ "$total" -le 25 ] || { echo "NOTE: break-glass override: more than 25 distinct candidate logins; extra ones ignored" >&2; break; } # bound API calls
|
|
420
|
+
seen_logins="${seen_logins}${llogin}"$'\n'
|
|
421
|
+
# Login already matched ^[A-Za-z0-9_-]+$ in jq, so it is safe to interpolate into the API path.
|
|
422
|
+
perm=$(gh api "repos/${REPO}/collaborators/${login}/permission" 2>/dev/null | jq -r '.permission // empty' 2>/dev/null) || perm=""
|
|
423
|
+
case "$perm" in
|
|
424
|
+
admin|write) ;;
|
|
425
|
+
"")
|
|
426
|
+
echo "NOTE: break-glass override by ${login} rejected: permission='unreadable' (needs admin/write; the lookup needs a token with push access)" >&2
|
|
427
|
+
n=$((n+1)); [ "$n" -le 5 ] || { echo "NOTE: break-glass override: more than 5 unreadable permission lookups; extra candidates ignored" >&2; break; }
|
|
428
|
+
continue ;;
|
|
429
|
+
*) echo "NOTE: break-glass override by ${login} rejected: permission='${perm}' (needs admin/write)" >&2; continue ;;
|
|
430
|
+
esac
|
|
431
|
+
echo "PHASE_TRAIL: OVERRIDDEN"
|
|
432
|
+
echo "BAND: ${BAND:-UNKNOWN}"
|
|
433
|
+
echo "WAIVED: ${cur_set}"
|
|
434
|
+
echo "OVERRIDE: approver=${login} head=${HEAD_SHA} missing=${cur_set} reason=${reason}"
|
|
435
|
+
return 0
|
|
436
|
+
done <<< "$cands"
|
|
437
|
+
return 1
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if evaluate_override; then
|
|
441
|
+
[ -n "${BAND_NOTE:-}" ] && echo "NOTE: $BAND_NOTE"
|
|
442
|
+
exit 0
|
|
443
|
+
fi
|
|
444
|
+
|
|
266
445
|
echo "PHASE_TRAIL: FAIL"
|
|
267
446
|
echo "BAND: ${BAND:-UNKNOWN}"
|
|
268
447
|
for m in "${MISSING[@]}"; do echo "MISSING: $m"; done
|