Gatekeep evals / eval-floor (push) Successful in 8s
Add .github/workflows/gatekeep-pr.yml with uses: ./ and OPENROUTER_API_KEY from secrets. Forge-agnostic API helper for Gitea.
179 lines
6.2 KiB
Bash
179 lines
6.2 KiB
Bash
#!/usr/bin/env bash
|
|
# Deterministic fail-closed policy floor + lightweight security scan.
|
|
set -euo pipefail
|
|
|
|
ROOT="${GATEKEEP_ROOT:-.}"
|
|
ACTION="${GATEKEEP_ACTION:-.}"
|
|
OUT="$ROOT/.gatekeep/out"
|
|
mkdir -p "$OUT"
|
|
|
|
# shellcheck source=lib-config.sh
|
|
source "$ACTION/scripts/lib-config.sh"
|
|
POLICY="$(gatekeep_config_file policy.json)"
|
|
|
|
EVENT="${GITHUB_EVENT_PATH:-}"
|
|
if [[ -z "$EVENT" || ! -f "$EVENT" ]]; then
|
|
echo "GITHUB_EVENT_PATH missing" >&2
|
|
exit 1
|
|
fi
|
|
|
|
PR_NUMBER=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["pull_request"]["number"])' "$EVENT")
|
|
REPO="${GITHUB_REPOSITORY:?}"
|
|
OWNER="${REPO%%/*}"
|
|
NAME="${REPO##*/}"
|
|
FORGE="$ACTION/scripts/forge_api.py"
|
|
|
|
python3 - "$FORGE" "$OWNER" "$NAME" "$PR_NUMBER" "$OUT" <<'PY'
|
|
import json, pathlib, sys, subprocess
|
|
|
|
forge, owner, name, pr, out = sys.argv[1:6]
|
|
out = pathlib.Path(out)
|
|
|
|
def api(method, path):
|
|
p = subprocess.run(
|
|
[sys.executable, forge, method, path],
|
|
capture_output=True, text=True,
|
|
)
|
|
if p.returncode != 0:
|
|
return None
|
|
try:
|
|
return json.loads(p.stdout) if p.stdout.strip() else None
|
|
except Exception:
|
|
return None
|
|
|
|
files_data = api("GET", f"/repos/{owner}/{name}/pulls/{pr}/files") or []
|
|
# Gitea may paginate poorly; accept list
|
|
if isinstance(files_data, dict):
|
|
files_data = files_data.get("files") or []
|
|
filenames = []
|
|
add = delete = 0
|
|
for f in files_data:
|
|
filenames.append(f.get("filename") or f.get("name") or "")
|
|
add += int(f.get("additions") or 0)
|
|
delete += int(f.get("deletions") or 0)
|
|
filenames = [f for f in filenames if f]
|
|
(out / "changed-files.txt").write_text("\n".join(filenames) + ("\n" if filenames else ""))
|
|
|
|
pr_data = api("GET", f"/repos/{owner}/{name}/pulls/{pr}") or {}
|
|
if not add and not delete:
|
|
add = int(pr_data.get("additions") or 0)
|
|
delete = int(pr_data.get("deletions") or 0)
|
|
labels = []
|
|
for l in pr_data.get("labels") or []:
|
|
if isinstance(l, dict):
|
|
labels.append(l.get("name") or "")
|
|
else:
|
|
labels.append(str(l))
|
|
head_sha = ((pr_data.get("head") or {}).get("sha")) or ""
|
|
ci = "unknown"
|
|
if head_sha:
|
|
st = api("GET", f"/repos/{owner}/{name}/commits/{head_sha}/statuses") or []
|
|
# Gitea returns list; derive combined
|
|
if isinstance(st, list) and st:
|
|
states = [s.get("state") for s in st]
|
|
if any(s == "failure" or s == "error" for s in states):
|
|
ci = "failure"
|
|
elif any(s == "pending" for s in states):
|
|
ci = "pending"
|
|
elif all(s == "success" for s in states):
|
|
ci = "success"
|
|
else:
|
|
ci = states[0] or "unknown"
|
|
elif isinstance(st, dict):
|
|
ci = st.get("state") or "unknown"
|
|
|
|
meta = {"additions": add, "deletions": delete, "labels": labels, "ci": ci, "head_sha": head_sha}
|
|
(out / "pr-meta.json").write_text(json.dumps(meta, indent=2))
|
|
print(json.dumps({"files": len(filenames), "add": add, "del": delete, "ci": ci}))
|
|
PY
|
|
|
|
ADD=$(python3 -c 'import json; print(json.load(open("'"$OUT"'/pr-meta.json")).get("additions",0))')
|
|
DEL=$(python3 -c 'import json; print(json.load(open("'"$OUT"'/pr-meta.json")).get("deletions",0))')
|
|
LABELS=$(python3 -c 'import json; print(",".join(json.load(open("'"$OUT"'/pr-meta.json")).get("labels") or []))')
|
|
CI_STATE=$(python3 -c 'import json; print(json.load(open("'"$OUT"'/pr-meta.json")).get("ci","unknown"))')
|
|
|
|
python3 - "$POLICY" "$OUT/changed-files.txt" "$ADD" "$DEL" "$LABELS" "$CI_STATE" "$OUT" <<'PY'
|
|
import json, fnmatch, sys, pathlib
|
|
|
|
policy = json.load(open(sys.argv[1]))
|
|
files = [l.strip() for l in open(sys.argv[2]) if l.strip()]
|
|
add, delete = int(sys.argv[3] or 0), int(sys.argv[4] or 0)
|
|
labels = [x.strip().lower() for x in (sys.argv[5] or "").split(",") if x.strip()]
|
|
ci = sys.argv[6]
|
|
out = pathlib.Path(sys.argv[7])
|
|
|
|
rank = {"low": 0, "medium": 1, "high": 2}
|
|
floor = "low"
|
|
reasons = []
|
|
|
|
def bump(t, why):
|
|
global floor
|
|
if rank[t] > rank[floor]:
|
|
floor = t
|
|
reasons.append(why)
|
|
|
|
for lab in labels:
|
|
if lab in [x.lower() for x in policy.get("force_high_labels", [])]:
|
|
bump("high", f"label forces high: {lab}")
|
|
if lab in [x.lower() for x in policy.get("force_medium_labels", [])]:
|
|
bump("medium", f"label forces medium: {lab}")
|
|
|
|
def match(path, glob):
|
|
g = glob.replace("**/", "*").replace("**", "*")
|
|
return fnmatch.fnmatch(path, glob) or fnmatch.fnmatch(path, g) or fnmatch.fnmatch(path.split("/")[-1], glob)
|
|
|
|
for f in files:
|
|
for g in policy.get("high_path_globs", []):
|
|
if match(f, g):
|
|
bump("high", f"high-path {f} ~ {g}")
|
|
for g in policy.get("medium_path_globs", []):
|
|
if match(f, g):
|
|
bump("medium", f"medium-path {f} ~ {g}")
|
|
|
|
if len(files) > int(policy.get("low_max_files", 8)):
|
|
bump("medium", f"file count {len(files)} > low cap")
|
|
if add + delete > int(policy.get("low_max_diff_lines", 200)):
|
|
bump("medium", f"diff lines {add+delete} > low cap")
|
|
if ci in ("failure", "error"):
|
|
bump("high", f"CI status {ci}")
|
|
|
|
seen=set(); uniq=[]
|
|
for r in reasons:
|
|
if r not in seen:
|
|
seen.add(r); uniq.append(r)
|
|
|
|
result = {"floor": floor, "reasons": uniq, "files": files, "additions": add, "deletions": delete, "labels": labels, "ci": ci}
|
|
(out / "floor.json").write_text(json.dumps(result, indent=2))
|
|
print(floor)
|
|
print("---")
|
|
print("\n".join(uniq))
|
|
PY
|
|
|
|
FLOOR_JSON="$OUT/floor.json"
|
|
FLOOR=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["floor"])' "$FLOOR_JSON")
|
|
REASONS=$(python3 -c 'import json,sys; print(" | ".join(json.load(open(sys.argv[1]))["reasons"]))' "$FLOOR_JSON")
|
|
|
|
SCAN_OUT="$OUT/security-scan.json"
|
|
bash "$ACTION/scripts/security-scan.sh" "$OUT/changed-files.txt" "$SCAN_OUT" || true
|
|
SCAN_HARD=$(python3 -c 'import json,sys; print("true" if json.load(open(sys.argv[1])).get("hard_fail") else "false")' "$SCAN_OUT")
|
|
|
|
if [[ "$SCAN_HARD" == "true" && "$FLOOR" != "high" ]]; then
|
|
FLOOR=high
|
|
REASONS="${REASONS} | deterministic security scan hard-fail"
|
|
python3 - "$FLOOR_JSON" <<'PY'
|
|
import json,sys
|
|
p=json.load(open(sys.argv[1]))
|
|
p["floor"]="high"
|
|
p["reasons"].append("deterministic security scan hard-fail")
|
|
json.dump(p, open(sys.argv[1],"w"), indent=2)
|
|
PY
|
|
fi
|
|
|
|
if [[ -n "${GITHUB_OUTPUT:-}" ]]; then
|
|
echo "floor=$FLOOR" >> "$GITHUB_OUTPUT"
|
|
echo "reasons=$REASONS" >> "$GITHUB_OUTPUT"
|
|
echo "scan_hard_fail=$SCAN_HARD" >> "$GITHUB_OUTPUT"
|
|
fi
|
|
|
|
echo "Policy floor: $FLOOR (scan_hard_fail=$SCAN_HARD)" >&2
|