forgedock 1.16.1 → 1.16.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/engine/phases.mjs +22 -2
- package/bin/engine.mjs +10 -3
- package/commands/orchestrate/phase-1-resolve.md +2 -2
- package/commands/orchestrate/phase-4-execution.md +8 -8
- package/commands/quality-gate.md +4 -4
- package/commands/review-pr-staging.md +32 -32
- package/commands/review-pr.md +16 -16
- package/commands/work-on/build/validate.md +2 -2
- package/commands/work-on/build.md +51 -19
- package/commands/work-on/close.md +2 -2
- package/commands/work-on/decompose.md +5 -4
- package/commands/work-on/investigate.md +10 -7
- package/commands/work-on/remediate.md +20 -10
- package/commands/work-on/review.md +20 -3
- package/commands/work-on.md +6 -5
- package/docs/CONFIG.md +4 -2
- package/docs/WORK-ON-RUNTIME.md +1 -1
- package/docs/spec/forge-protocol-v1.md +1 -1
- package/package.json +1 -1
- package/scripts/check-spec-markers.sh +2 -0
- package/scripts/diff-size.sh +36 -3
- package/scripts/diff-size.test.sh +47 -0
- package/scripts/forge-root.test.sh +10 -2
- package/scripts/lib/forge-resolve.sh +13 -4
- package/scripts/remediate-liveness.sh +96 -0
- package/scripts/review-acceptance-gate.sh +132 -0
- package/scripts/review-acceptance-gate.test.sh +90 -0
|
@@ -8,16 +8,22 @@
|
|
|
8
8
|
# forge_resolve <name> prints the absolute path of scripts/<name> from the first trusted root that has it,
|
|
9
9
|
# or prints nothing (status 0). Callers fail closed on empty output.
|
|
10
10
|
#
|
|
11
|
-
# Trust boundary: roots come only from the engine (FORGEDOCK_HOME, authoritative when set), the plugin
|
|
11
|
+
# Trust boundary: roots come only from the engine (FORGEDOCK_HOME, authoritative when set), the running plugin root the
|
|
12
|
+
# stub locator handed over (FORGE_SOURCED_ROOT, the loader-substituted plugin root), the plugin runtime
|
|
12
13
|
# (CLAUDE_PLUGIN_ROOT), the legacy FORGE_ROOT/FORGE_HOME variables, the install.sh symlink, the Codex
|
|
13
14
|
# $CODEX_HOME/forge-home file, the newest pinned plugin cache and the marketplace checkout under CLAUDE_CONFIG_DIR
|
|
14
15
|
# then ~/.claude, and last the directory this file was sourced from. The consumer's working directory and the repo
|
|
15
16
|
# under review are author-controlled and are never consulted, and every root must be an absolute path.
|
|
16
17
|
#
|
|
17
18
|
# Order (matches docs/CONFIG.md "Install root resolution"): FORGEDOCK_HOME (when set, nothing else is consulted) >
|
|
18
|
-
# CLAUDE_PLUGIN_ROOT > FORGE_ROOT > FORGE_HOME > install symlink >
|
|
19
|
+
# running plugin root (FORGE_SOURCED_ROOT) > CLAUDE_PLUGIN_ROOT > FORGE_ROOT > FORGE_HOME > install symlink >
|
|
20
|
+
# Codex forge-home > newest cache > marketplace > self.
|
|
21
|
+
# The running plugin root wins so a session keeps the scripts that match its own spec text even when a newer version is
|
|
22
|
+
# cached or a stale FORGE_HOME is exported. The stub assigns it from the loader-substituted plugin root (a shell-local
|
|
23
|
+
# assignment, never exported); non-Claude runtimes leave the literal placeholder, which the absolute-path check rejects.
|
|
19
24
|
# The self root is last on purpose: the stub locator that sourced this file picks a cache directory by glob order
|
|
20
|
-
# (lexical), so the helper re-resolves by the numeric-newest tier instead of trusting the copy
|
|
25
|
+
# (lexical), so absent a running root the helper re-resolves by the numeric-newest tier instead of trusting the copy
|
|
26
|
+
# that happened to load.
|
|
21
27
|
|
|
22
28
|
# Root this file was sourced from (scripts/lib/.. /..), captured now because the cwd changes later. Under zsh
|
|
23
29
|
# BASH_SOURCE is empty, so the sourced path comes from ${(%):-%x} (eval'd: bash cannot parse that expansion). An empty
|
|
@@ -40,12 +46,15 @@ _forge_posix() {
|
|
|
40
46
|
esac
|
|
41
47
|
}
|
|
42
48
|
|
|
49
|
+
# Running plugin root handed over by the stub locator (shell-local, consumed once, never exported).
|
|
50
|
+
_FORGE_RUNNING_ROOT="$(_forge_posix "${FORGE_SOURCED_ROOT:-}")"; unset FORGE_SOURCED_ROOT
|
|
51
|
+
|
|
43
52
|
# One absolute candidate root per line, highest priority first.
|
|
44
53
|
_forge_candidates() {
|
|
45
54
|
local _l _cfg _mk _x _cx
|
|
46
55
|
if [ -n "${FORGEDOCK_HOME:-}" ]; then _forge_posix "$FORGEDOCK_HOME"; return 0; fi
|
|
47
56
|
_mk="${FORGEDOCK_MARKETPLACE:-forgedock}"; case "$_mk" in ""|.|..|*[!A-Za-z0-9._-]*) _mk="forgedock" ;; esac
|
|
48
|
-
printf '%s\n' "${CLAUDE_PLUGIN_ROOT:-}" "${FORGE_ROOT:-}" "${FORGE_HOME:-}"
|
|
57
|
+
printf '%s\n' "$_FORGE_RUNNING_ROOT" "${CLAUDE_PLUGIN_ROOT:-}" "${FORGE_ROOT:-}" "${FORGE_HOME:-}"
|
|
49
58
|
for _cfg in "${CLAUDE_CONFIG_DIR:-}" "$HOME/.claude"; do
|
|
50
59
|
[ -n "$_cfg" ] || continue
|
|
51
60
|
_l="$(readlink -f "$_cfg/commands/work-on.md" 2>/dev/null || true)"
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# remediate-liveness.sh — is an earlier remediation of this PR still alive? (remediate.md Phase M0, forge#3792)
|
|
3
|
+
# SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
4
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
#
|
|
6
|
+
# Usage: remediate-liveness.sh <pr> <issue> <owner/repo> [lease-holder]
|
|
7
|
+
# Env: DRY_RUN non-empty: print the deferral record instead of posting it
|
|
8
|
+
# FORGE_LIVENESS_NOW_MS override the clock (tests)
|
|
9
|
+
#
|
|
10
|
+
# The decision is deterministic and comes from GitHub state only, never from how partial trail comments look:
|
|
11
|
+
# (a) lease: the FORGE:STATE block in the issue body carries lease.until in (now, now + MAX_LEASE_TTL] for a
|
|
12
|
+
# lease.by that is not the dispatching holder and not a provably dead same-host engine pid. An until past
|
|
13
|
+
# the TTL cap is not a lease an engine could have written (the issue body is author-editable), so it is dead.
|
|
14
|
+
# (b) review: the newest trusted FORGE:REVIEW_ROUTE comment is younger than REVIEW_PENDING_MAX_S and no trusted
|
|
15
|
+
# FORGE:REVIEW-AGENT:* comment carries a Reviewed-SHA that prefix-matches the routed sha (either direction).
|
|
16
|
+
# A deferral is bounded: after MAX_DEFERRALS trusted FORGE:REMEDIATION_DEFERRED records for this PR the answer is
|
|
17
|
+
# EXHAUSTED (a human is needed), so a flapping lease or a dead review can never park a PR forever.
|
|
18
|
+
#
|
|
19
|
+
# The last stdout line is the verdict; the exit code mirrors it:
|
|
20
|
+
# LIVENESS: DEAD exit 0 proceed (adopt the interrupted attempt)
|
|
21
|
+
# LIVENESS: DEFER <kind> <detail> exit 10 a live run owns the PR; a FORGE:REMEDIATION_DEFERRED record was posted
|
|
22
|
+
# LIVENESS: EXHAUSTED <n> exit 11 deferral budget used up; escalate
|
|
23
|
+
# LIVENESS: ERROR <why> exit 2 unreadable state; fail closed (never "no lease", never delete)
|
|
24
|
+
# Trust predicate for comments: Bot, or OWNER/MEMBER/COLLABORATOR (same default as scripts/trusted-comments.sh).
|
|
25
|
+
set -uo pipefail
|
|
26
|
+
|
|
27
|
+
PR="${1:-}"; ISSUE="${2:-}"; REPO="${3:-}"; HOLDER="${4:-}"
|
|
28
|
+
MAX_LEASE_TTL_MS=1800000 # 3x the engine's 10 min lease TTL
|
|
29
|
+
REVIEW_PENDING_MAX_S=1800 # a review with no verdict after 30 min is dead, not in flight
|
|
30
|
+
MAX_DEFERRALS=3
|
|
31
|
+
|
|
32
|
+
err() { printf 'LIVENESS: ERROR %s\n' "$1"; exit 2; }
|
|
33
|
+
case "$PR" in ''|*[!0-9]*) err "bad pr" ;; esac
|
|
34
|
+
case "$ISSUE" in ''|*[!0-9]*) err "bad issue" ;; esac
|
|
35
|
+
[ -n "$REPO" ] || err "no repo"
|
|
36
|
+
printf '%s' "$HOLDER" | grep -qE '^[A-Za-z0-9._-]{1,128}$' || HOLDER=""
|
|
37
|
+
NOW_MS="${FORGE_LIVENESS_NOW_MS:-$(( $(date +%s) * 1000 ))}"
|
|
38
|
+
NOW_S=$(( NOW_MS / 1000 ))
|
|
39
|
+
|
|
40
|
+
TRUST='((.user.type // "") == "Bot" or ((.author_association // "") | IN("OWNER","MEMBER","COLLABORATOR")))'
|
|
41
|
+
|
|
42
|
+
ISSUE_JSON=$(gh issue view "$ISSUE" -R "$REPO" --json body 2>/dev/null) || err "issue unreadable"
|
|
43
|
+
ISSUE_COMMENTS=$(gh api --paginate "repos/$REPO/issues/$ISSUE/comments" 2>/dev/null) || err "issue comments unreadable"
|
|
44
|
+
PR_COMMENTS=$(gh api --paginate "repos/$REPO/issues/$PR/comments" 2>/dev/null) || err "PR comments unreadable"
|
|
45
|
+
[ -n "$PR_COMMENTS" ] || err "PR comments unreadable"
|
|
46
|
+
|
|
47
|
+
# (a) lease. No FORGE:STATE block = no lease; a block that is present but unparseable fails closed.
|
|
48
|
+
STATE_JSON=$(printf '%s' "$ISSUE_JSON" | jq -r '.body // ""' | awk '/^<!-- FORGE:STATE[[:space:]]*$/{f=1;next} f&&/^-->/{exit} f')
|
|
49
|
+
LEASE_BY=""; LEASE_UNTIL=0; LEASE_LIVE=false
|
|
50
|
+
if [ -n "$STATE_JSON" ]; then
|
|
51
|
+
ROW=$(printf '%s' "$STATE_JSON" | jq -r '(.lease // {}) as $l | [($l.by // "" | tostring), (if ($l.until|type)=="number" then ($l.until|floor) else 0 end)] | @tsv' 2>/dev/null) \
|
|
52
|
+
|| err "FORGE:STATE block unparseable"
|
|
53
|
+
LEASE_BY=$(printf '%s' "$ROW" | cut -f1); LEASE_UNTIL=$(printf '%s' "$ROW" | cut -f2)
|
|
54
|
+
case "$LEASE_UNTIL" in ''|*[!0-9]*) LEASE_UNTIL=0 ;; esac
|
|
55
|
+
if [ -n "$LEASE_BY" ] && [ "$LEASE_BY" != "$HOLDER" ] && [ "$LEASE_UNTIL" -gt "$NOW_MS" ] && [ "$LEASE_UNTIL" -le $(( NOW_MS + MAX_LEASE_TTL_MS )) ]; then
|
|
56
|
+
LEASE_LIVE=true
|
|
57
|
+
# Provably dead same-host engine (id cli_<host>_<pid>, pid gone): not live, as the engine's own I3 guard decides.
|
|
58
|
+
if [[ "$LEASE_BY" =~ ^cli_(.+)_([0-9]+)$ ]]; then
|
|
59
|
+
_h="${BASH_REMATCH[1]}"; _p="${BASH_REMATCH[2]}"
|
|
60
|
+
_me=$(uname -n 2>/dev/null | sed 's/[^A-Za-z0-9.-]/-/g')
|
|
61
|
+
if [ "$_h" = "${_me:-unknown}" ] && ! kill -0 "$_p" 2>/dev/null && ! ps -p "$_p" >/dev/null 2>&1; then LEASE_LIVE=false; fi
|
|
62
|
+
fi
|
|
63
|
+
fi
|
|
64
|
+
fi
|
|
65
|
+
|
|
66
|
+
# (b) a routed review that is still inside its window and has produced no verdict for the routed sha.
|
|
67
|
+
ROUTE=$(printf '%s' "$PR_COMMENTS" | jq -rs "[.[][] | select($TRUST) | select((.body // \"\") | test(\"^<!-- FORGE:REVIEW_ROUTE mode=[^ ]+ spec=review-pr.md sha=[0-9a-f]{7,40} -->\")) | select((.body | test(\"mode=spec-evolution-blocked\")) | not)] | last | if . == null then \"\" else ((.body | capture(\"sha=(?<s>[0-9a-f]{7,40})\").s) + \" \" + ((.created_at // \"\") | fromdateiso8601? // 0 | tostring)) end" 2>/dev/null) \
|
|
68
|
+
|| err "review comments unparseable"
|
|
69
|
+
ROUTE_SHA="${ROUTE%% *}"; ROUTE_AT="${ROUTE##* }"; REVIEW_PENDING=false
|
|
70
|
+
if [ -n "$ROUTE_SHA" ]; then
|
|
71
|
+
case "$ROUTE_AT" in ''|*[!0-9]*) ROUTE_AT=0 ;; esac
|
|
72
|
+
VERDICTS=$(printf '%s' "$PR_COMMENTS" | jq -rs --arg r "$ROUTE_SHA" "[.[][] | select($TRUST) | select((.body // \"\") | test(\"^<!-- FORGE:REVIEW-AGENT:[a-z-]+ -->\")) | ((.body | capture(\"(^|\\\\n)Reviewed-SHA: (?<s>[0-9a-f]{7,40})\")? // {s: \"\"}).s) as \$s | select(\$s != \"\" and ((\$r | startswith(\$s)) or (\$s | startswith(\$r))))] | length" 2>/dev/null) || err "reviewer comments unreadable"
|
|
73
|
+
[ "${VERDICTS:-0}" -eq 0 ] && [ "$ROUTE_AT" -gt 0 ] && [ $(( NOW_S - ROUTE_AT )) -lt "$REVIEW_PENDING_MAX_S" ] && REVIEW_PENDING=true
|
|
74
|
+
fi
|
|
75
|
+
|
|
76
|
+
if [ "$LEASE_LIVE" != "true" ] && [ "$REVIEW_PENDING" != "true" ]; then
|
|
77
|
+
echo "LIVENESS: DEAD"; exit 0
|
|
78
|
+
fi
|
|
79
|
+
|
|
80
|
+
PRIOR=$(printf '%s' "$ISSUE_COMMENTS" | jq -rs --arg pr "pr=$PR" "[.[][] | select($TRUST) | select((.body // \"\") | startswith(\"<!-- FORGE:REMEDIATION_DEFERRED -->\")) | select((.body | split(\"\\n\") | index(\$pr)) != null)] | length" 2>/dev/null) || err "deferral records unreadable"
|
|
81
|
+
if [ "${PRIOR:-0}" -ge "$MAX_DEFERRALS" ]; then echo "LIVENESS: EXHAUSTED ${PRIOR}"; exit 11; fi
|
|
82
|
+
|
|
83
|
+
if [ "$LEASE_LIVE" = "true" ]; then KIND=lease; DETAIL="lease held by ${LEASE_BY} until ${LEASE_UNTIL}"
|
|
84
|
+
else KIND=review; DETAIL="re-review of ${ROUTE_SHA} has no reviewer verdicts yet"; fi
|
|
85
|
+
BODY="<!-- FORGE:REMEDIATION_DEFERRED -->
|
|
86
|
+
## Remediation Deferred
|
|
87
|
+
|
|
88
|
+
PR #${PR}: an earlier remediation run is still live. This run changed nothing (deferral $(( ${PRIOR:-0} + 1 )) of ${MAX_DEFERRALS}).
|
|
89
|
+
|
|
90
|
+
pr=${PR}
|
|
91
|
+
kind=${KIND}
|
|
92
|
+
lease_by=${LEASE_BY:-none}
|
|
93
|
+
lease_until=${LEASE_UNTIL}"
|
|
94
|
+
if [ -n "${DRY_RUN:-}" ]; then printf 'DRY_RUN: would post:\n%s\n' "$BODY"
|
|
95
|
+
else gh issue comment "$ISSUE" -R "$REPO" --body "$BODY" >/dev/null 2>&1 || err "could not post deferral record"; fi
|
|
96
|
+
echo "LIVENESS: DEFER ${KIND} ${DETAIL}"; exit 10
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# review-acceptance-gate.sh — review-phase acceptance gate (work-on/review.md Phase R2.5, forge#3793).
|
|
3
|
+
# SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
4
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
#
|
|
6
|
+
# Usage: review-acceptance-gate.sh <issue> <gh_repo> "<gh_flag>" <worktree_path> (DRY_RUN=1 skips gh writes)
|
|
7
|
+
# Evaluates the ACCEPTANCE_CHECK lines the build gate (B6.5) deferred: `phase=review` checks, plus untagged legacy
|
|
8
|
+
# command/behavior checks on a `gh pr` target. Uses its own FORGE:ACCEPTANCE_GATE_REVIEW marker, never the build
|
|
9
|
+
# gate's FORGE:ACCEPTANCE_GATE header or :PASSED/:FAILED/:BLOCKED sentinels (the engine reads those).
|
|
10
|
+
# Malformed checks (forge#3694 parity with B6.5: 4+ backslash run, `bash -n` parse failure, grep usage error) are skipped and listed,
|
|
11
|
+
# never a FAIL and never needs-human. With zero verified checks and some malformed, a non-blocking notice is posted (no PASSED marker).
|
|
12
|
+
# Exit: 0 passed / no-op / notice / already passed for this head, 1 a deferred check failed (issue gets needs-human).
|
|
13
|
+
set -uo pipefail
|
|
14
|
+
|
|
15
|
+
NUMBER="${1:?issue number}"; GH_REPO="${2:?repo}"; GH_FLAG="${3:-}"; WT="${4:-.}"
|
|
16
|
+
cd "$WT" || exit 2
|
|
17
|
+
run() { if [ -n "${DRY_RUN:-}" ]; then echo "DRY_RUN: $*"; else "$@"; fi; }
|
|
18
|
+
# shellcheck disable=SC2086 # GH_FLAG is a pre-split "-R owner/repo" flag string
|
|
19
|
+
gh_i() { gh "$@" $GH_FLAG; }
|
|
20
|
+
|
|
21
|
+
HEAD_SHA=$(git rev-parse HEAD 2>/dev/null)
|
|
22
|
+
# Trust (forge#3813): the spec lines are executed as check targets and the PASSED marker skips the gate, so both are read only
|
|
23
|
+
# from comments scripts/trusted-comments.sh accepts. The helper is resolved from ForgeDock's own install (forge_resolve), never
|
|
24
|
+
# the working directory; an unresolvable helper or unreadable comments fail closed (nothing executed, gate does not pass).
|
|
25
|
+
block() {
|
|
26
|
+
echo "Review acceptance gate: $1 (fail closed)" >&2
|
|
27
|
+
printf 'REVIEW_RESULT:\n status: BLOCKED\n pr_number:\n pr_url:\n merged_to:\n blocker: review acceptance gate cannot trust issue comments (%s)\n' "$1"
|
|
28
|
+
exit 1
|
|
29
|
+
}
|
|
30
|
+
_RESOLVE_LIB="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/forge-resolve.sh"
|
|
31
|
+
type forge_resolve >/dev/null 2>&1 || { [ -f "$_RESOLVE_LIB" ] && . "$_RESOLVE_LIB"; }
|
|
32
|
+
type forge_resolve >/dev/null 2>&1 || block "forge_resolve unavailable"
|
|
33
|
+
TRUSTED_SCRIPT=$(forge_resolve trusted-comments.sh)
|
|
34
|
+
[ -n "$TRUSTED_SCRIPT" ] || block "trusted-comments.sh unresolvable"
|
|
35
|
+
COMMENTS=$(gh api "repos/$GH_REPO/issues/$NUMBER/comments" --paginate 2>/dev/null) || block "comments unreadable"
|
|
36
|
+
[ -n "$COMMENTS" ] || block "comments unreadable"
|
|
37
|
+
TRUSTED_GATE=$(printf '%s' "$COMMENTS" | bash "$TRUSTED_SCRIPT" bodies '^<!-- FORGE:ACCEPTANCE_GATE_REVIEW -->') || block "trust filter failed"
|
|
38
|
+
TRUSTED_INV=$(printf '%s' "$COMMENTS" | bash "$TRUSTED_SCRIPT" bodies '^<!-- FORGE:INVESTIGATOR -->') || block "trust filter failed"
|
|
39
|
+
LAST=$(printf '%s\n' "$TRUSTED_GATE" | tail -n 1 | jq -r '. // ""' 2>/dev/null) || LAST=""
|
|
40
|
+
if printf '%s' "$LAST" | grep -qF "<!-- FORGE:ACCEPTANCE_GATE_REVIEW:PASSED:${HEAD_SHA} -->"; then
|
|
41
|
+
echo "Review acceptance gate already PASSED for ${HEAD_SHA}; skipping."; exit 0
|
|
42
|
+
fi
|
|
43
|
+
|
|
44
|
+
CHECKS=$(printf '%s\n' "$TRUSTED_INV" | jq -r '.' 2>/dev/null | grep "^ACCEPTANCE_CHECK:")
|
|
45
|
+
GATE_PASS=true; FAILED=""; VERIFIED_COUNT=0; MALFORMED_CHECKS=""; RESULTS=""
|
|
46
|
+
while IFS= read -r check_line; do
|
|
47
|
+
[ -z "$check_line" ] && continue
|
|
48
|
+
ID=$(echo "$check_line" | sed -n 's/^ACCEPTANCE_CHECK: id=\([^ ]*\) type=.*/\1/p')
|
|
49
|
+
TYPE=$(echo "$check_line" | sed -n 's/^ACCEPTANCE_CHECK: id=[^ ]* type=\([^ ]*\) target=.*/\1/p')
|
|
50
|
+
TARGET=$(printf '%s\n' "$check_line" | sed -nE 's/^ACCEPTANCE_CHECK: id=[^ ]* type=[^ ]* target="(([^"\\]|\\.)*)".*/\1/p' | sed 's/\\"/"/g')
|
|
51
|
+
[ -z "$TARGET" ] && TARGET=$(echo "$check_line" | sed -n 's/^ACCEPTANCE_CHECK: id=[^ ]* type=[^ ]* target=\([^ ]*\).*/\1/p')
|
|
52
|
+
MATCHER=$(printf '%s\n' "$check_line" | sed -nE 's/^ACCEPTANCE_CHECK: id=[^ ]* type=[^ ]* target=("([^"\\]|\\.)*"|[^ ]*) matcher="(([^"\\]|\\.)*)".*/\3/p' | sed 's/\\"/"/g')
|
|
53
|
+
[ -z "$MATCHER" ] && MATCHER=$(echo "$check_line" | sed -n 's/^ACCEPTANCE_CHECK: id=[^ ]* type=[^ ]* target=[^ ]* matcher=\([^ ]*\).*/\1/p')
|
|
54
|
+
DESC=$(echo "$check_line" | sed -n 's/.*description=\(.*\)/\1/p')
|
|
55
|
+
# phase= is anchored after matcher=, so text inside target/matcher/description cannot set it; anything else is build.
|
|
56
|
+
PHASE=$(printf '%s\n' "$check_line" | sed -nE 's/^ACCEPTANCE_CHECK: id=[^ ]* type=[^ ]* target=("(([^"\\]|\\.)*)"|[^ ]*) matcher=("(([^"\\]|\\.)*)"|[^ ]*) phase=(build|review)( .*)?$/\7/p')
|
|
57
|
+
[ "$PHASE" = "review" ] || PHASE=build
|
|
58
|
+
case "$TYPE" in command|behavior) ;; *) [ "$PHASE" = "review" ] || continue ;; esac
|
|
59
|
+
if [ "$PHASE" != "review" ]; then
|
|
60
|
+
printf '%s\n' "$check_line" | grep -qE ' phase=(build|review)( |$)' && continue
|
|
61
|
+
case "$TARGET" in *'gh pr'*) ;; *) continue ;; esac
|
|
62
|
+
fi
|
|
63
|
+
# malformed-check (forge#3694): static screen only parses (bash -n), never executes.
|
|
64
|
+
MALFORMED=""
|
|
65
|
+
{ [ "$TYPE" = "command" ] || [ "$TYPE" = "behavior" ]; } && { case "$TARGET" in *'\\\\'*) MALFORMED="run of 4+ backslashes (over-escaped target)";; esac; [ -n "$MALFORMED" ] || bash -n -c "$TARGET" 2>/dev/null || MALFORMED="shell syntax error (unbalanced quotes)"; }
|
|
66
|
+
if [ -n "$MALFORMED" ]; then MALFORMED_CHECKS="${MALFORMED_CHECKS}\n- **$ID** ($DESC): $MALFORMED"; continue; fi
|
|
67
|
+
RESULT="PASS"; DETAIL=""
|
|
68
|
+
case "$TYPE" in
|
|
69
|
+
exists) [ -e "$TARGET" ] || { RESULT="FAIL"; DETAIL="path not found: $TARGET"; } ;;
|
|
70
|
+
contains)
|
|
71
|
+
grep -qE "$MATCHER" "$TARGET" 2>/dev/null || grep -qF -- "$MATCHER" "$TARGET" 2>/dev/null || { RESULT="FAIL"; DETAIL="'$MATCHER' not found in $TARGET"; } ;;
|
|
72
|
+
command|behavior)
|
|
73
|
+
if [ "$MATCHER" = "exit_0" ]; then
|
|
74
|
+
ERR=$(eval "$TARGET" 2>&1 >/dev/null); RC=$?
|
|
75
|
+
# grep exit >= 2 is a usage error, not a verdict; a missing/unreadable operand also exits 2 but stays a real FAIL.
|
|
76
|
+
[ "$RC" -ge 2 ] && case "$TARGET" in grep\ *|egrep\ *|fgrep\ *) printf '%s' "$ERR" | grep -qE 'No such file or directory|Permission denied|Is a directory' || MALFORMED="grep usage error (exit $RC)";; esac
|
|
77
|
+
[ "$RC" -eq 0 ] || [ -n "$MALFORMED" ] || { RESULT="FAIL"; DETAIL="command exited non-zero: $TARGET"; }
|
|
78
|
+
else
|
|
79
|
+
OUTPUT=$(eval "$TARGET" 2>&1); RC=$?; ERR="$OUTPUT"
|
|
80
|
+
[ "$RC" -ge 2 ] && case "$TARGET" in grep\ *|egrep\ *|fgrep\ *) printf '%s' "$ERR" | grep -qE 'No such file or directory|Permission denied|Is a directory' || MALFORMED="grep usage error (exit $RC)";; esac
|
|
81
|
+
if [ -n "$MALFORMED" ]; then :
|
|
82
|
+
else
|
|
83
|
+
printf '%s\n' "$OUTPUT" | grep -qE "$MATCHER" || printf '%s\n' "$OUTPUT" | grep -qF -- "$MATCHER" \
|
|
84
|
+
|| { RESULT="FAIL"; DETAIL="output did not match '$MATCHER'. Got: $(printf '%s\n' "$OUTPUT" | head -3)"; }
|
|
85
|
+
fi
|
|
86
|
+
fi ;;
|
|
87
|
+
*) RESULT="FAIL"; DETAIL="unknown check type: $TYPE" ;;
|
|
88
|
+
esac
|
|
89
|
+
if [ -n "$MALFORMED" ]; then MALFORMED_CHECKS="${MALFORMED_CHECKS}\n- **$ID** ($DESC): $MALFORMED"; continue; fi
|
|
90
|
+
VERIFIED_COUNT=$((VERIFIED_COUNT+1))
|
|
91
|
+
RESULTS="${RESULTS}\n- **$ID** ($DESC): $RESULT"
|
|
92
|
+
if [ "$RESULT" = "FAIL" ]; then GATE_PASS=false; FAILED="${FAILED}\n- **$ID** ($DESC): $DETAIL"; fi
|
|
93
|
+
done <<< "$CHECKS"
|
|
94
|
+
|
|
95
|
+
MALFORMED_SECTION=""
|
|
96
|
+
[ -n "$MALFORMED_CHECKS" ] && MALFORMED_SECTION=$(printf '\n**malformed-check (not counted as failures; regenerate the acceptance spec by re-running investigate)**:%b\n' "$MALFORMED_CHECKS")
|
|
97
|
+
|
|
98
|
+
if [ "$VERIFIED_COUNT" -eq 0 ] && [ -z "$MALFORMED_CHECKS" ]; then
|
|
99
|
+
echo "No deferred acceptance checks; R2.5 is a no-op."
|
|
100
|
+
elif [ "$VERIFIED_COUNT" -eq 0 ]; then
|
|
101
|
+
# Review has no repair path, so unlike B6.5 this does not fail: notice only, no PASSED marker (a repaired spec is re-evaluated).
|
|
102
|
+
run gh_i issue comment "$NUMBER" --body "<!-- FORGE:ACCEPTANCE_GATE_REVIEW -->
|
|
103
|
+
## Review Acceptance Gate: SKIPPED (malformed checks)
|
|
104
|
+
|
|
105
|
+
Every deferred (phase=review) acceptance check was malformed, so none could be verified. This is not a failure and does not block merge.
|
|
106
|
+
$MALFORMED_SECTION"
|
|
107
|
+
echo "Review acceptance gate: all deferred checks malformed; non-blocking notice posted."
|
|
108
|
+
elif [ "$GATE_PASS" = "true" ]; then
|
|
109
|
+
run gh_i issue comment "$NUMBER" --body "<!-- FORGE:ACCEPTANCE_GATE_REVIEW -->
|
|
110
|
+
## Review Acceptance Gate: PASSED
|
|
111
|
+
|
|
112
|
+
Deferred (phase=review) acceptance checks verified against the PR:
|
|
113
|
+
$(echo -e "$RESULTS")
|
|
114
|
+
$MALFORMED_SECTION
|
|
115
|
+
|
|
116
|
+
<!-- FORGE:ACCEPTANCE_GATE_REVIEW:PASSED:${HEAD_SHA} -->"
|
|
117
|
+
else
|
|
118
|
+
run gh_i issue comment "$NUMBER" --body "<!-- FORGE:ACCEPTANCE_GATE_REVIEW -->
|
|
119
|
+
## Review Acceptance Gate: FAILED
|
|
120
|
+
|
|
121
|
+
The following deferred (phase=review) acceptance checks did not pass against the PR:
|
|
122
|
+
|
|
123
|
+
$(echo -e "$FAILED")
|
|
124
|
+
$MALFORMED_SECTION
|
|
125
|
+
|
|
126
|
+
Merge is blocked. Fix the PR (for example its body) and re-run the review phase.
|
|
127
|
+
|
|
128
|
+
<!-- FORGE:ACCEPTANCE_GATE_REVIEW:FAILED -->"
|
|
129
|
+
run gh_i issue edit "$NUMBER" --add-label "needs-human"
|
|
130
|
+
printf 'REVIEW_RESULT:\n status: BLOCKED\n pr_number:\n pr_url:\n merged_to:\n blocker: review acceptance gate failed (see FORGE:ACCEPTANCE_GATE_REVIEW comment)\n'
|
|
131
|
+
exit 1
|
|
132
|
+
fi
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# review-acceptance-gate.test.sh — trust filtering (forge#3813) and behavior (pass / fail / malformed-skip, forge#3814) of scripts/review-acceptance-gate.sh. No network (gh is stubbed, DRY_RUN=1).
|
|
3
|
+
# SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
4
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
set -uo pipefail
|
|
6
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
7
|
+
GATE="$SCRIPT_DIR/review-acceptance-gate.sh"
|
|
8
|
+
PASS=0; FAILN=0
|
|
9
|
+
ok() { PASS=$((PASS+1)); echo "ok - $1"; }
|
|
10
|
+
bad() { FAILN=$((FAILN+1)); echo "FAIL - $1"; }
|
|
11
|
+
eq() { if [ "$2" = "$3" ]; then ok "$1"; else bad "$1 (got '$2', want '$3')"; fi; }
|
|
12
|
+
|
|
13
|
+
TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT
|
|
14
|
+
mkdir -p "$TMP/bin" "$TMP/wt"
|
|
15
|
+
git -C "$TMP/wt" init -q . && git -C "$TMP/wt" -c user.email=t@t -c user.name=t commit -q --allow-empty -m init
|
|
16
|
+
HEAD_SHA=$(git -C "$TMP/wt" rev-parse HEAD)
|
|
17
|
+
cat > "$TMP/bin/gh" <<'STUB'
|
|
18
|
+
#!/usr/bin/env bash
|
|
19
|
+
[ "${GH_FAIL:-}" = 1 ] && exit 1
|
|
20
|
+
[ "$1" = api ] && { cat "$COMMENTS_FILE"; exit 0; }
|
|
21
|
+
echo "gh $*" >> "$GH_LOG"
|
|
22
|
+
STUB
|
|
23
|
+
chmod +x "$TMP/bin/gh"
|
|
24
|
+
export PATH="$TMP/bin:$PATH" GH_LOG="$TMP/gh.log" COMMENTS_FILE="$TMP/comments.json" FORGEDOCK_HOME="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
25
|
+
unset FORGE_TRAIL_TRUSTED_ASSOCIATIONS FORGE_TRAIL_TRUSTED_LOGINS
|
|
26
|
+
|
|
27
|
+
c() { jq -nc --arg a "$1" --arg t "$2" --arg b "$3" '{author_association:$a,user:{type:$t,login:"u"},body:$b}'; }
|
|
28
|
+
SENT="$TMP/sentinel"
|
|
29
|
+
INV_BAD="<!-- FORGE:INVESTIGATOR -->
|
|
30
|
+
ACCEPTANCE_CHECK: id=ac-1 type=command target=\"touch $SENT\" matcher=\"exit_0\" phase=review description=pwn"
|
|
31
|
+
INV_OK="<!-- FORGE:INVESTIGATOR -->
|
|
32
|
+
ACCEPTANCE_CHECK: id=ac-1 type=command target=\"touch $SENT\" matcher=\"exit_0\" phase=review description=ok"
|
|
33
|
+
PASSED="<!-- FORGE:ACCEPTANCE_GATE_REVIEW -->
|
|
34
|
+
<!-- FORGE:ACCEPTANCE_GATE_REVIEW:PASSED:${HEAD_SHA} -->"
|
|
35
|
+
|
|
36
|
+
run() { : > "$GH_LOG"; rm -f "$SENT"; DRY_RUN=1 bash "$GATE" 1 o/r "" "$TMP/wt" >"$TMP/out" 2>&1; echo $?; }
|
|
37
|
+
|
|
38
|
+
echo "[$(c NONE User "$INV_BAD")]" > "$COMMENTS_FILE"
|
|
39
|
+
rc=$(run); eq "untrusted spec ignored (rc 0, no-op)" "$rc" 0
|
|
40
|
+
[ ! -e "$SENT" ] && ok "untrusted spec line not executed" || bad "untrusted spec line executed"
|
|
41
|
+
|
|
42
|
+
echo "[$(c OWNER User "$INV_OK")]" > "$COMMENTS_FILE"
|
|
43
|
+
rc=$(run); eq "trusted spec runs" "$rc" 0
|
|
44
|
+
[ -e "$SENT" ] && ok "trusted check executed" || bad "trusted check not executed"
|
|
45
|
+
|
|
46
|
+
echo "[$(c NONE Bot "$INV_OK")]" > "$COMMENTS_FILE"
|
|
47
|
+
run >/dev/null; [ -e "$SENT" ] && ok "Bot author accepted" || bad "Bot author rejected"
|
|
48
|
+
|
|
49
|
+
echo "[$(c OWNER User "$INV_OK"),$(c NONE User "$PASSED")]" > "$COMMENTS_FILE"
|
|
50
|
+
run >/dev/null; [ -e "$SENT" ] && ok "untrusted PASSED marker does not skip" || bad "untrusted PASSED marker skipped the gate"
|
|
51
|
+
|
|
52
|
+
echo "[$(c OWNER User "$INV_OK"),$(c MEMBER User "$PASSED")]" > "$COMMENTS_FILE"
|
|
53
|
+
run >/dev/null; [ ! -e "$SENT" ] && grep -q "already PASSED" "$TMP/out" && ok "trusted PASSED marker skips" || bad "trusted PASSED marker ignored"
|
|
54
|
+
|
|
55
|
+
echo "[$(c OWNER User "$INV_OK")]" > "$COMMENTS_FILE"
|
|
56
|
+
GH_FAIL=1 run >/dev/null; rc=$(GH_FAIL=1 run)
|
|
57
|
+
eq "unreadable comments fail closed" "$rc" 1
|
|
58
|
+
[ ! -e "$SENT" ] && ok "nothing executed when unreadable" || bad "executed with unreadable comments"
|
|
59
|
+
|
|
60
|
+
rc=$(FORGEDOCK_HOME="$TMP/nowhere" run)
|
|
61
|
+
eq "unresolvable trust helper fails closed" "$rc" 1
|
|
62
|
+
[ ! -e "$SENT" ] && ok "nothing executed without helper" || bad "executed without helper"
|
|
63
|
+
|
|
64
|
+
# Behavioral cases (forge#3814). DRY_RUN=1 echoes gh writes as `DRY_RUN:` lines into $TMP/out.
|
|
65
|
+
printf 'x\n' > "$TMP/wt/f.txt"
|
|
66
|
+
inv() { local b="<!-- FORGE:INVESTIGATOR -->"; local l; for l in "$@"; do b="$b
|
|
67
|
+
$l"; done; echo "[$(c OWNER User "$b")]" > "$COMMENTS_FILE"; }
|
|
68
|
+
chk() { printf 'ACCEPTANCE_CHECK: id=%s type=command target="%s" matcher="exit_0" phase=review description=d' "$1" "$2"; }
|
|
69
|
+
nolabel() { ! grep -q 'needs-human' "$TMP/out"; }
|
|
70
|
+
|
|
71
|
+
inv "$(chk ac-1 true)"; rc=$(run); eq "pass: rc 0" "$rc" 0
|
|
72
|
+
grep -q ':PASSED:' "$TMP/out" && ok "pass: PASSED sentinel posted" || bad "pass: no PASSED sentinel"
|
|
73
|
+
|
|
74
|
+
inv "$(chk ac-1 false)"; rc=$(run); eq "real fail: rc 1" "$rc" 1
|
|
75
|
+
grep -q 'needs-human' "$TMP/out" && ok "real fail: needs-human added" || bad "real fail: no needs-human"
|
|
76
|
+
inv "$(chk ac-1 'grep -q nomatch f.txt')"; rc=$(run); eq "grep exit 1 is a real fail" "$rc" 1
|
|
77
|
+
inv "$(chk ac-1 'grep -q x nofile.txt')"; rc=$(run); eq "grep missing operand file stays a real fail" "$rc" 1
|
|
78
|
+
|
|
79
|
+
for t in "echo 'unbalanced" 'echo a\\\\\\\\b' "grep -E '[' f.txt"; do
|
|
80
|
+
inv "$(chk ac-7 "$t")"; rc=$(run); eq "malformed skipped, rc 0: $t" "$rc" 0
|
|
81
|
+
nolabel && ok "malformed: no needs-human" || bad "malformed: needs-human added: $t"
|
|
82
|
+
grep -qi 'malformed' "$TMP/out" && ok "malformed reported" || bad "malformed not reported: $t"
|
|
83
|
+
grep -q ':PASSED:' "$TMP/out" && bad "all-malformed wrote PASSED marker: $t" || ok "all-malformed: no PASSED marker"
|
|
84
|
+
done
|
|
85
|
+
|
|
86
|
+
inv "$(chk ac-1 true)" "$(chk ac-7 "echo 'unbalanced")"; rc=$(run); eq "mixed pass+malformed: rc 0" "$rc" 0
|
|
87
|
+
grep -q ':PASSED:' "$TMP/out" && grep -qi 'malformed' "$TMP/out" && ok "mixed: PASSED with malformed listed" || bad "mixed: wrong output"
|
|
88
|
+
inv "$(chk ac-1 false)" "$(chk ac-7 "echo 'unbalanced")"; rc=$(run); eq "mixed fail+malformed: fails closed" "$rc" 1
|
|
89
|
+
|
|
90
|
+
echo "$PASS passed, $FAILN failed"; [ "$FAILN" -eq 0 ]
|