install the publication mechanism
This commit is contained in:
parent
2f685facda
commit
e1eede9893
15 changed files with 1319 additions and 0 deletions
299
.publication/lib/gates.py
Normal file
299
.publication/lib/gates.py
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
"""Required-gate evaluator against the Gitea 1.25.5 Actions API shape.
|
||||
|
||||
Gate identity is (workflow_path, job_name). A gate passes only when exactly one
|
||||
completed, successful job matches it at the exact candidate SHA, on the
|
||||
permitted ref, under an allowed event, in the correct workflow.
|
||||
|
||||
Every other outcome denies. See docs/DESIGN.md for the attack list this shape
|
||||
answers and the one it cannot (continue-on-error).
|
||||
"""
|
||||
|
||||
RUN_FIELDS = ("id", "path", "head_sha", "head_branch", "event",
|
||||
"status", "conclusion", "run_number", "run_attempt")
|
||||
JOB_FIELDS = ("id", "run_id", "name", "head_sha", "head_branch",
|
||||
"status", "conclusion", "run_attempt")
|
||||
|
||||
CONTINUE_ON_ERROR_CAVEAT = (
|
||||
"a job failing under continue-on-error reports conclusion=success and is "
|
||||
"indistinguishable from a genuine pass when the API returns no steps")
|
||||
|
||||
|
||||
class Denied(Exception):
|
||||
def __init__(self, reasons):
|
||||
self.reasons = list(reasons)
|
||||
super().__init__("; ".join(self.reasons))
|
||||
|
||||
|
||||
class PublicationRequest:
|
||||
__slots__ = ("internal_repository", "destination_host",
|
||||
"destination_repository", "sha", "ref")
|
||||
|
||||
def __init__(self, internal_repository, destination_host,
|
||||
destination_repository, sha, ref):
|
||||
self.internal_repository = internal_repository
|
||||
self.destination_host = destination_host
|
||||
self.destination_repository = destination_repository
|
||||
self.sha = sha
|
||||
self.ref = ref
|
||||
|
||||
|
||||
class Report:
|
||||
def __init__(self):
|
||||
self.denials = []
|
||||
self.passed = []
|
||||
self.unproven = []
|
||||
|
||||
@property
|
||||
def allowed(self):
|
||||
return not self.denials
|
||||
|
||||
def deny(self, code, detail):
|
||||
self.denials.append(f"{code}: {detail}")
|
||||
|
||||
def note_unproven(self, detail):
|
||||
if detail not in self.unproven:
|
||||
self.unproven.append(detail)
|
||||
|
||||
def summary(self):
|
||||
verdict = "ALLOW" if self.allowed else "DENY"
|
||||
lines = [f"{verdict} ({len(self.passed)} gate(s) proven, "
|
||||
f"{len(self.denials)} denial(s))"]
|
||||
lines.extend(" proven: " + g for g in self.passed)
|
||||
lines.extend(" DENY " + d for d in self.denials)
|
||||
lines.extend(" unproven: " + u for u in self.unproven)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _require_fields(obj, fields, kind):
|
||||
if not isinstance(obj, dict):
|
||||
return [f"{kind} is not an object"]
|
||||
return [f"{kind} is missing field '{f}'" for f in fields if obj.get(f) is None]
|
||||
|
||||
|
||||
def _check_request(policy, request, report):
|
||||
if request.internal_repository != policy.internal_repository:
|
||||
report.deny("wrong-repository",
|
||||
f"request names {request.internal_repository}, policy authorizes "
|
||||
f"{policy.internal_repository}")
|
||||
if request.destination_repository != policy.destination_repository:
|
||||
report.deny("wrong-destination",
|
||||
f"request targets {request.destination_repository}, policy permits "
|
||||
f"{policy.destination_repository}")
|
||||
if request.destination_host != policy.destination_host:
|
||||
report.deny("wrong-destination-host",
|
||||
f"request targets {request.destination_host}, policy permits "
|
||||
f"{policy.destination_host}")
|
||||
if request.ref not in policy.permitted_refs:
|
||||
report.deny("wrong-ref",
|
||||
f"ref {request.ref!r} is not in permitted_refs "
|
||||
f"{list(policy.permitted_refs)}")
|
||||
sha = request.sha
|
||||
if not isinstance(sha, str) or len(sha) != 40 or any(
|
||||
c not in "0123456789abcdef" for c in sha):
|
||||
report.deny("malformed-sha", f"{sha!r} is not a full lowercase 40-hex SHA")
|
||||
|
||||
|
||||
def _candidate_runs(policy, request, client, report):
|
||||
try:
|
||||
raw_runs = client.list_runs(policy.internal_repository, head_sha=request.sha)
|
||||
except Exception as exc:
|
||||
report.deny("api-unreachable", f"could not list runs ({exc})")
|
||||
return []
|
||||
runs = []
|
||||
for r in raw_runs:
|
||||
missing = _require_fields(r, RUN_FIELDS, "run")
|
||||
if missing:
|
||||
report.deny("malformed-run", "; ".join(missing))
|
||||
continue
|
||||
if r["head_sha"] != request.sha:
|
||||
continue
|
||||
if r["head_branch"] != request.ref:
|
||||
continue
|
||||
if r["event"] not in policy.allowed_events:
|
||||
continue
|
||||
runs.append(r)
|
||||
return runs
|
||||
|
||||
|
||||
def _select_runs(policy, gate, runs, report):
|
||||
matching = [r for r in runs if r["path"] == gate.workflow_path]
|
||||
if not matching:
|
||||
report.deny("gate-missing",
|
||||
f"{gate.workflow_path}::{gate.job_name} has no run at this "
|
||||
f"SHA/ref/event")
|
||||
return []
|
||||
if policy.rerun_policy == "all-attempts-must-succeed":
|
||||
return matching
|
||||
top = max(r["run_number"] for r in matching)
|
||||
latest = [r for r in matching if r["run_number"] == top]
|
||||
if len(latest) > 1:
|
||||
report.deny("ambiguous-run",
|
||||
f"{gate.workflow_path} has {len(latest)} runs sharing run_number "
|
||||
f"{top}; cannot choose")
|
||||
return []
|
||||
return latest
|
||||
|
||||
|
||||
def _jobs_for(client, policy, run, report):
|
||||
try:
|
||||
raw = client.list_jobs(policy.internal_repository, run["id"])
|
||||
except Exception as exc:
|
||||
report.deny("api-unreachable", f"could not list jobs for run {run['id']} ({exc})")
|
||||
return None
|
||||
jobs = []
|
||||
for j in raw:
|
||||
missing = _require_fields(j, JOB_FIELDS, "job")
|
||||
if missing:
|
||||
report.deny("malformed-job", "; ".join(missing))
|
||||
return None
|
||||
jobs.append(j)
|
||||
return jobs
|
||||
|
||||
|
||||
def _judge_job(gate, job, report, label):
|
||||
ident = f"{gate.workflow_path}::{gate.job_name} ({label})"
|
||||
if job["status"] != "completed":
|
||||
report.deny("gate-incomplete", f"{ident} status={job['status']}")
|
||||
return False
|
||||
if job["conclusion"] != "success":
|
||||
report.deny(f"gate-{job['conclusion']}", f"{ident} conclusion={job['conclusion']}")
|
||||
return False
|
||||
steps = job.get("steps")
|
||||
if not steps:
|
||||
report.note_unproven(f"{ident}: no steps returned; {CONTINUE_ON_ERROR_CAVEAT}")
|
||||
return True
|
||||
failed = [s.get("name") for s in steps if s.get("conclusion") == "failure"]
|
||||
if failed:
|
||||
report.deny("gate-step-failed-under-success",
|
||||
f"{ident} reports success but step(s) failed: {failed}")
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _matrix_drift(gate, jobs, report):
|
||||
"""Tripwire, not a selector.
|
||||
|
||||
Selection is the exact list in matrix_jobs. This counts how many jobs in
|
||||
the run look like legs of the same declaration and denies on a mismatch, so
|
||||
a leg added upstream and never approved cannot pass unnoticed. It decides
|
||||
nothing about which jobs are trusted -- only that the policy is stale.
|
||||
"""
|
||||
shaped = [j for j in jobs if j["name"].startswith(gate.job_name + " (")]
|
||||
if len(shaped) != len(gate.matrix_jobs):
|
||||
report.deny("matrix-drift",
|
||||
f"{gate.workflow_path}::{gate.job_name} declares "
|
||||
f"{len(gate.matrix_jobs)} legs, the run carries {len(shaped)}; "
|
||||
"approve the change in policy rather than around it")
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _evaluate_matrix(policy, request, gate, runs, client, report):
|
||||
selected = _select_runs(policy, gate, runs, report)
|
||||
if not selected:
|
||||
return
|
||||
proven = True
|
||||
for run in selected:
|
||||
jobs = _jobs_for(client, policy, run, report)
|
||||
if jobs is None:
|
||||
return
|
||||
at_sha = [j for j in jobs
|
||||
if j["head_sha"] == request.sha and j["head_branch"] == request.ref]
|
||||
if not _matrix_drift(gate, at_sha, report):
|
||||
return
|
||||
for leg in gate.matrix_jobs:
|
||||
named = [j for j in at_sha if j["name"] == leg]
|
||||
if not named:
|
||||
report.deny("gate-missing",
|
||||
f"{gate.workflow_path}::{leg} absent from run {run['id']}")
|
||||
return
|
||||
if policy.rerun_policy == "latest-attempt-must-succeed":
|
||||
top = max(j["run_attempt"] for j in named)
|
||||
candidates = [j for j in named if j["run_attempt"] == top]
|
||||
label = f"run {run['id']} attempt {top}"
|
||||
if len(candidates) != 1:
|
||||
report.deny("gate-ambiguous",
|
||||
f"{gate.workflow_path}::{leg} matched "
|
||||
f"{len(candidates)} jobs at {label}; exactly one required")
|
||||
return
|
||||
else:
|
||||
candidates = named
|
||||
label = f"run {run['id']} all attempts"
|
||||
for job in candidates:
|
||||
if not _judge_job(gate, job, report, f"{leg}, {label}"):
|
||||
proven = False
|
||||
if proven:
|
||||
report.passed.append(f"{gate.workflow_path}::{gate.job_name} "
|
||||
f"({len(gate.matrix_jobs)} legs)")
|
||||
|
||||
|
||||
def _evaluate_gate(policy, request, gate, runs, client, report):
|
||||
if gate.cardinality == "matrix":
|
||||
return _evaluate_matrix(policy, request, gate, runs, client, report)
|
||||
if gate.cardinality != "exactly-one":
|
||||
report.deny("unsupported-cardinality",
|
||||
f"{gate.workflow_path}::{gate.job_name} declares "
|
||||
f"{gate.cardinality!r}; not supported in this iteration")
|
||||
return
|
||||
|
||||
selected = _select_runs(policy, gate, runs, report)
|
||||
if not selected:
|
||||
return
|
||||
|
||||
proven = True
|
||||
for run in selected:
|
||||
jobs = _jobs_for(client, policy, run, report)
|
||||
if jobs is None:
|
||||
return
|
||||
named = [j for j in jobs
|
||||
if j["name"] == gate.job_name
|
||||
and j["head_sha"] == request.sha
|
||||
and j["head_branch"] == request.ref]
|
||||
if not named:
|
||||
report.deny("gate-missing",
|
||||
f"{gate.workflow_path}::{gate.job_name} absent from run "
|
||||
f"{run['id']} (attempt {run['run_attempt']})")
|
||||
return
|
||||
if policy.rerun_policy == "latest-attempt-must-succeed":
|
||||
top_attempt = max(j["run_attempt"] for j in named)
|
||||
candidates = [j for j in named if j["run_attempt"] == top_attempt]
|
||||
label = f"run {run['id']} attempt {top_attempt}"
|
||||
else:
|
||||
candidates = named
|
||||
label = f"run {run['id']} all attempts"
|
||||
|
||||
if policy.rerun_policy == "latest-attempt-must-succeed" and len(candidates) != 1:
|
||||
report.deny("gate-ambiguous",
|
||||
f"{gate.workflow_path}::{gate.job_name} matched "
|
||||
f"{len(candidates)} jobs at {label}; exactly one required")
|
||||
return
|
||||
for job in candidates:
|
||||
if not _judge_job(gate, job, report, label):
|
||||
proven = False
|
||||
if proven:
|
||||
report.passed.append(f"{gate.workflow_path}::{gate.job_name}")
|
||||
|
||||
|
||||
def evaluate(policy, request, client):
|
||||
"""Return a Report. Report.allowed is True only if every gate is proven."""
|
||||
report = Report()
|
||||
_check_request(policy, request, report)
|
||||
if report.denials:
|
||||
return report
|
||||
|
||||
runs = _candidate_runs(policy, request, client, report)
|
||||
if report.denials:
|
||||
return report
|
||||
if not runs:
|
||||
report.deny("no-runs",
|
||||
f"no run at {request.sha} on {request.ref} under events "
|
||||
f"{sorted(policy.allowed_events)}")
|
||||
return report
|
||||
|
||||
for gate in policy.required_gates:
|
||||
_evaluate_gate(policy, request, gate, runs, client, report)
|
||||
|
||||
report.note_unproven(
|
||||
"gate proof shows a green run exists at this SHA on this ref; it does not "
|
||||
"prove which push produced it")
|
||||
return report
|
||||
Loading…
Reference in a new issue