#!/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