publish: the public projection begins here
This is a projection, not a development branch. The tree above was constructed from the internal source named below under a manifest that decides which paths may leave, then scanned as a whole tree rather than as a series of patches, and only then published. Public history starts here because the history before it was not admissible, and neither was the tree. What used to stand in this repository included a rescue copy of another machine, a directory of phone handoffs, deployment wired to one house, and a submodule pointing at a forge no stranger can reach. None of that was ever the product. It stays in the private forge, which is allowed to hold the whole working organism, and this is what was deliberately sent out instead. Three mechanisms produced this tree, in decreasing order of trust. A top-level path the manifest does not name never arrives at all, which is the one that catches directories nobody has thought of yet. Named internal files inside admitted roots are dropped. A short, reviewed table replaces deployment defaults that a public build must not carry -- an endpoint aimed at one LAN, a VPN profile belonging to one phone, packaging built from one checkout path. Everything after this commit is an ordinary publication with the same three trailers, so a force push stops being routine and starts meaning that something deliberate happened. The trailers bind the projection to its source without pretending the public SHA is the private one: same lineage, different tree, and the record says so. Source-Sha: 8f27b1e76a8fef560a336aba18e6990713ff1047 Policy-Sha: 6b261d2f3e6e1fb19874846ba4bb1dfe15565d25b8618c1c1afba0419c101d27 Tree-Digest: 18ec3563c5e5ef9a414993a9f6734b251ff9ed3cd56eebdd6cac01e45c6e3067
This commit is contained in:
commit
8f42fc953d
1476 changed files with 238455 additions and 0 deletions
13
.publication/EPOCH
Normal file
13
.publication/EPOCH
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# Break glass.
|
||||
#
|
||||
# Ordinary publication is fast-forward only. This file authorises the
|
||||
# publication job to replace the public branch exactly once, and only when the
|
||||
# public ref it finds is the one named below. After the epoch lands, the named
|
||||
# SHA is no longer what any remote holds, so this authorisation is spent and
|
||||
# force push goes dark again without anyone remembering to close it.
|
||||
#
|
||||
# Development history is never rewritten. It is preserved internally at
|
||||
# archive/public-pre-epoch-2026-09-04.
|
||||
|
||||
replaces b68bcebf6c390ec6e2a361aff3f17ea0736c7da2
|
||||
reason sanitized projection epoch; public history begins at the cut
|
||||
478
.publication/surface_gate.py
Normal file
478
.publication/surface_gate.py
Normal file
|
|
@ -0,0 +1,478 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Editorial authority over a public tree.
|
||||
|
||||
The existing `.publication` mechanism answers a transport question: was this
|
||||
SHA produced by the right gates, on the right repository, under a policy that
|
||||
authorizes projection to this destination. It is a good answer to that
|
||||
question and this file does not replace it.
|
||||
|
||||
This file answers the question nobody was asking:
|
||||
|
||||
Does this tree belong outside at all?
|
||||
|
||||
Six gates, in the order a reviewer would actually apply them:
|
||||
|
||||
1. tree-shape every top-level entry is classified by the manifest
|
||||
2. new-surface what the public diff P1 -> P2 newly admits
|
||||
3. file-class forbidden classes anywhere in the tree
|
||||
4. content the whole candidate tree, not the changed lines
|
||||
5. history subjects, bodies, and author identities
|
||||
6. presentation what a stranger sees in the root listing
|
||||
|
||||
Gate 4 is the one that matters most and is the one an incremental scanner
|
||||
structurally cannot provide. A patch scanner sees a file the day it lands. It
|
||||
never sees it again. A tree that was clean when every one of its commits was
|
||||
scanned can still be a tree that should not be public, because publication is
|
||||
a property of the tree, not of the diffs that built it.
|
||||
|
||||
The severity model is deliberately harsher than the incremental scanner's.
|
||||
There, a home path or a LAN address is a WARN: one line in one patch, and a
|
||||
human is reading the patch anyway. Here the same finding is a DENY, because
|
||||
nobody reads a whole tree, and because the finding means the path is standing
|
||||
in the public product right now, not that it passed through once.
|
||||
|
||||
Absence of known-secret content is not authorization to publish.
|
||||
|
||||
Exceptions are narrow and auditable: path, rule, reason, review date. A rule
|
||||
turned off globally is not an exception, it is a retreat, so this file has no
|
||||
syntax for one.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import fnmatch
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import date
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
|
||||
DENY = "deny"
|
||||
REVIEW = "review"
|
||||
NOTE = "note"
|
||||
|
||||
SEVERITY_ORDER = {DENY: 0, REVIEW: 1, NOTE: 2}
|
||||
|
||||
# Anything a forge will not render as source. Reviewed by eye, not by rule.
|
||||
BINARY_HINT = re.compile(
|
||||
r"\.(png|jpe?g|gif|webp|ico|pdf|zip|gz|xz|zst|bz2|tar|so|a|o|dll|dylib"
|
||||
r"|exe|bin|wav|mp3|mp4|ogg|woff2?|ttf|otf|jar|whl|deb|rpm|img|iso)$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Host suffixes a stranger can actually resolve. A submodule pointed anywhere
|
||||
# else is both a leak and a repository that does not clone.
|
||||
PUBLIC_FORGE_HOSTS = (
|
||||
"forge.caseytunturi.com",
|
||||
"codeberg.org",
|
||||
"github.com",
|
||||
"gitlab.com",
|
||||
"git.sr.ht",
|
||||
)
|
||||
|
||||
|
||||
class Rule:
|
||||
__slots__ = ("rule_id", "description", "pattern", "severity")
|
||||
|
||||
def __init__(self, rule_id, description, pattern, severity):
|
||||
self.rule_id = rule_id
|
||||
self.description = description
|
||||
self.pattern = re.compile(pattern, re.IGNORECASE)
|
||||
self.severity = severity
|
||||
|
||||
|
||||
# Capabilities deny because publishing one hands control to whoever reads it
|
||||
# and no later cleanup takes it back. Topology denies here — see the module
|
||||
# docstring — because in a tree it is a standing disclosure, not a transit.
|
||||
CONTENT_RULES = (
|
||||
Rule("private-key", "private key material",
|
||||
r"-----BEGIN (?:RSA|DSA|EC|OPENSSH|PGP) PRIVATE KEY", DENY),
|
||||
# The value must be quoted. An unquoted run of letters after `authorization:`
|
||||
# is a type name in every language that has types, and matching it made the
|
||||
# gate cry wolf over `authorization: MutationAuthorization` on first run.
|
||||
Rule("bearer-token", "embedded token or bearer credential",
|
||||
r"(?:ghp|gho|ghs|ghu|github_pat)_[A-Za-z0-9_]{20,}"
|
||||
r"|(?:authorization|private[-_]?token|api[-_]?key|client[-_]?secret)"
|
||||
r"\s*[:=]\s*['\"][A-Za-z0-9._~+/=-]{16,}['\"]", DENY),
|
||||
Rule("rfc1918", "private LAN address",
|
||||
r"\b(?:10\.\d{1,3}\.\d{1,3}\.\d{1,3}"
|
||||
r"|192\.168\.\d{1,3}\.\d{1,3}"
|
||||
r"|172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3})\b", DENY),
|
||||
# Case-sensitive on purpose. Every rule here is otherwise IGNORECASE, and
|
||||
# a case-blind `/Users/` matched the phrase "sessions/users/seats" in a
|
||||
# comment about loginctl. A macOS home is capitalised; a POSIX path segment
|
||||
# spelled `users` is not a home directory.
|
||||
Rule("home-path", "developer home directory",
|
||||
r"/home/[a-z][a-z0-9_-]*|(?-i:/Users/[A-Za-z])|(?-i:C:\\\\Users\\\\)", DENY),
|
||||
Rule("internal-host", "internal hostname or forge",
|
||||
r"\bwiuf|\barchdev\b|\bwiufph\b", DENY),
|
||||
# The internal domain only. An author's own public contact address is not a
|
||||
# leak — it is on the security page on purpose, and matching it made the
|
||||
# gate refuse commits for being signed by the person who wrote them.
|
||||
Rule("internal-email", "internal service identity or internal domain",
|
||||
r"@wiuf\.net", DENY),
|
||||
Rule("ai-attribution", "model attribution",
|
||||
r"co-authored-by:\s*(?:claude|gpt|copilot|codex)"
|
||||
r"|generated with \[?claude", REVIEW),
|
||||
)
|
||||
|
||||
MESSAGE_RULES = CONTENT_RULES
|
||||
|
||||
|
||||
def run(repo, args, ok=(0,)):
|
||||
p = subprocess.run(["git", "-C", repo] + args,
|
||||
capture_output=True, text=True, errors="replace")
|
||||
if p.returncode not in ok:
|
||||
raise RuntimeError(f"git {' '.join(args)}: {p.stderr.strip()}")
|
||||
return p.stdout
|
||||
|
||||
|
||||
class Finding:
|
||||
__slots__ = ("gate", "severity", "rule", "path", "detail", "excerpt")
|
||||
|
||||
def __init__(self, gate, severity, rule, path, detail, excerpt=""):
|
||||
self.gate = gate
|
||||
self.severity = severity
|
||||
self.rule = rule
|
||||
self.path = path
|
||||
self.detail = detail
|
||||
self.excerpt = excerpt
|
||||
|
||||
|
||||
def redact(text, match):
|
||||
"""Keep the shape, drop the value. A report is itself a publishable object."""
|
||||
s, e = match.span()
|
||||
line = text[max(0, s - 40):e + 40].replace("\n", " ").strip()
|
||||
hit = match.group(0)
|
||||
keep = 2 if len(hit) > 6 else 1
|
||||
masked = hit[:keep] + "*" * max(1, len(hit) - keep * 2) + (hit[-keep:] if len(hit) > 6 else "")
|
||||
return line.replace(hit, masked)[:150]
|
||||
|
||||
|
||||
class Manifest:
|
||||
"""The authority for what may cross. Changing it is the consequential act."""
|
||||
|
||||
def __init__(self, raw, path):
|
||||
self.path = path
|
||||
self.schema_version = raw.get("schema_version")
|
||||
if self.schema_version != SCHEMA_VERSION:
|
||||
raise ValueError(f"{path}: schema_version must be {SCHEMA_VERSION}")
|
||||
self.repository = raw.get("repository", "?")
|
||||
self.public_roots = list(raw.get("public_roots", []))
|
||||
self.public_root_files = list(raw.get("public_root_files", []))
|
||||
self.review_required = list(raw.get("review_required", []))
|
||||
self.forbidden = list(raw.get("forbidden", []))
|
||||
self.forbidden_classes = list(raw.get("forbidden_classes", []))
|
||||
self.max_blob_bytes = int(raw.get("max_blob_bytes", 2 * 1024 * 1024))
|
||||
self.exceptions = list(raw.get("exceptions", []))
|
||||
|
||||
def classify_root(self, name):
|
||||
if name in self.forbidden:
|
||||
return "forbidden"
|
||||
if name in self.public_roots or name in self.public_root_files:
|
||||
return "allowed"
|
||||
if name in self.review_required:
|
||||
return "review"
|
||||
return "unclassified"
|
||||
|
||||
def excepted(self, path, rule_id):
|
||||
"""An exception names one path and one rule, and says why, and when."""
|
||||
for exc in self.exceptions:
|
||||
if exc.get("rule") != rule_id:
|
||||
continue
|
||||
if not fnmatch.fnmatch(path, exc.get("path", "")):
|
||||
continue
|
||||
if not exc.get("reason") or not exc.get("reviewed"):
|
||||
continue
|
||||
return exc
|
||||
return None
|
||||
|
||||
|
||||
def tree_entries(repo, sha):
|
||||
"""(mode, type, oid, path) for every entry, recursively."""
|
||||
out = run(repo, ["ls-tree", "-r", "-l", sha])
|
||||
entries = []
|
||||
for line in out.splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
meta, path = line.split("\t", 1)
|
||||
parts = meta.split()
|
||||
mode, otype, oid = parts[0], parts[1], parts[2]
|
||||
size = parts[3] if len(parts) > 3 else "-"
|
||||
entries.append((mode, otype, oid, size, path))
|
||||
return entries
|
||||
|
||||
|
||||
def top_level(repo, sha):
|
||||
return [l for l in run(repo, ["ls-tree", "--name-only", sha]).splitlines() if l.strip()]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- gate 1
|
||||
|
||||
def gate_tree_shape(repo, sha, manifest, findings):
|
||||
for name in top_level(repo, sha):
|
||||
verdict = manifest.classify_root(name)
|
||||
if verdict == "forbidden":
|
||||
findings.append(Finding(
|
||||
"tree-shape", DENY, "forbidden-root", name,
|
||||
"top-level path is forbidden by the public-surface manifest"))
|
||||
elif verdict == "unclassified":
|
||||
findings.append(Finding(
|
||||
"tree-shape", DENY, "unclassified-root", name,
|
||||
"no manifest entry admits this top-level path to the public product"))
|
||||
elif verdict == "review":
|
||||
findings.append(Finding(
|
||||
"tree-shape", REVIEW, "review-root", name,
|
||||
"manifest marks this root review-required"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- gate 2
|
||||
|
||||
def gate_new_surface(repo, sha, previous, manifest, findings):
|
||||
if not previous:
|
||||
findings.append(Finding(
|
||||
"new-surface", NOTE, "no-baseline", "-",
|
||||
"no previous public SHA given; the diff gate did not run"))
|
||||
return
|
||||
|
||||
out = run(repo, ["diff", "--name-status", "--diff-filter=ACR", previous, sha])
|
||||
added = []
|
||||
for line in out.splitlines():
|
||||
parts = line.split("\t")
|
||||
if len(parts) >= 2:
|
||||
added.append(parts[-1])
|
||||
|
||||
old_roots = set(top_level(repo, previous))
|
||||
# One finding per newly admitted root, not one per file underneath it: the
|
||||
# decision being reviewed is the root, and a hundred rows for one directory
|
||||
# buries the other gates' output.
|
||||
reported = set()
|
||||
for path in added:
|
||||
root = path.split("/", 1)[0]
|
||||
if root not in old_roots and root not in reported:
|
||||
reported.add(root)
|
||||
sev = REVIEW if manifest.classify_root(root) == "allowed" else DENY
|
||||
findings.append(Finding(
|
||||
"new-surface", sev, "new-top-level", root,
|
||||
"publication newly admits a top-level path; "
|
||||
"this is a public-surface change, not a code change"))
|
||||
if os.path.basename(path).startswith("."):
|
||||
findings.append(Finding(
|
||||
"new-surface", REVIEW, "new-dotfile", path,
|
||||
"new dotfile or dotdirectory entering the public tree"))
|
||||
|
||||
# Symlinks and submodules are surface changes disguised as files.
|
||||
for mode, otype, _oid, _size, path in tree_entries(repo, sha):
|
||||
if mode == "120000":
|
||||
target = run(repo, ["show", f"{sha}:{path}"]).strip()
|
||||
escapes = target.startswith("/") or ".." in target.split("/")
|
||||
findings.append(Finding(
|
||||
"new-surface", DENY if escapes else NOTE, "symlink", path,
|
||||
"symlink target leaves the public tree" if escapes
|
||||
else "symlink stays inside the public tree"))
|
||||
if otype == "commit":
|
||||
findings.append(Finding(
|
||||
"new-surface", REVIEW, "submodule", path,
|
||||
"submodule gitlink; its URL must resolve publicly"))
|
||||
|
||||
|
||||
def gate_submodule_urls(repo, sha, findings):
|
||||
try:
|
||||
text = run(repo, ["show", f"{sha}:.gitmodules"])
|
||||
except RuntimeError:
|
||||
return
|
||||
name = None
|
||||
for line in text.splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("[submodule"):
|
||||
name = line
|
||||
if line.startswith("url"):
|
||||
url = line.split("=", 1)[1].strip()
|
||||
host = re.sub(r"^[a-z+]+://", "", url).split("/")[0].split("@")[-1].split(":")[0]
|
||||
if not any(host == h or host.endswith("." + h) for h in PUBLIC_FORGE_HOSTS):
|
||||
findings.append(Finding(
|
||||
"new-surface", DENY, "submodule-private-url", ".gitmodules",
|
||||
f"submodule url host is not publicly resolvable ({host}); "
|
||||
"the public repository cannot clone and the host leaks",
|
||||
excerpt=name or ""))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- gate 3
|
||||
|
||||
def gate_file_class(repo, sha, manifest, findings):
|
||||
for _mode, _otype, _oid, size, path in tree_entries(repo, sha):
|
||||
for pat in manifest.forbidden_classes:
|
||||
if fnmatch.fnmatch(path, pat) or fnmatch.fnmatch(os.path.basename(path), pat):
|
||||
findings.append(Finding(
|
||||
"file-class", DENY, "forbidden-class", path,
|
||||
f"matches forbidden class {pat!r}"))
|
||||
break
|
||||
if size not in ("-", None) and size.isdigit() and int(size) > manifest.max_blob_bytes:
|
||||
findings.append(Finding(
|
||||
"file-class", REVIEW, "oversized-blob", path,
|
||||
f"{int(size):,} bytes exceeds the reviewed maximum "
|
||||
f"({manifest.max_blob_bytes:,})"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- gate 4
|
||||
|
||||
def gate_content(repo, sha, manifest, findings, limit_per_rule=40):
|
||||
counts = {}
|
||||
for _mode, otype, _oid, _size, path in tree_entries(repo, sha):
|
||||
if otype != "blob" or BINARY_HINT.search(path):
|
||||
continue
|
||||
try:
|
||||
text = run(repo, ["show", f"{sha}:{path}"])
|
||||
except RuntimeError:
|
||||
continue
|
||||
if "\0" in text[:8000]:
|
||||
continue
|
||||
for rule in CONTENT_RULES:
|
||||
m = rule.pattern.search(text)
|
||||
if not m:
|
||||
continue
|
||||
exc = manifest.excepted(path, rule.rule_id)
|
||||
if exc:
|
||||
findings.append(Finding(
|
||||
"content", NOTE, rule.rule_id, path,
|
||||
f"excepted: {exc['reason']} (reviewed {exc['reviewed']})"))
|
||||
continue
|
||||
counts[rule.rule_id] = counts.get(rule.rule_id, 0) + 1
|
||||
if counts[rule.rule_id] > limit_per_rule:
|
||||
continue
|
||||
findings.append(Finding(
|
||||
"content", rule.severity, rule.rule_id, path,
|
||||
rule.description, excerpt=redact(text, m)))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- gate 5
|
||||
|
||||
def gate_history(repo, sha, manifest, findings, limit_per_rule=25):
|
||||
sep = "\x1e"
|
||||
out = run(repo, ["log", f"--format=%H{sep}%an <%ae>{sep}%s{sep}%b\x1d", sha])
|
||||
counts = {}
|
||||
total = 0
|
||||
for record in out.split("\x1d"):
|
||||
record = record.strip("\n")
|
||||
if not record.strip():
|
||||
continue
|
||||
parts = record.split(sep)
|
||||
if len(parts) < 4:
|
||||
continue
|
||||
h, ident, subject, body = parts[0], parts[1], parts[2], parts[3]
|
||||
total += 1
|
||||
blob = f"{ident}\n{subject}\n{body}"
|
||||
for rule in MESSAGE_RULES:
|
||||
m = rule.pattern.search(blob)
|
||||
if not m:
|
||||
continue
|
||||
counts[rule.rule_id] = counts.get(rule.rule_id, 0) + 1
|
||||
if counts[rule.rule_id] > limit_per_rule:
|
||||
continue
|
||||
findings.append(Finding(
|
||||
"history", rule.severity, rule.rule_id, h[:12],
|
||||
f"{rule.description} in commit metadata: {subject[:60]}",
|
||||
excerpt=redact(blob, m)))
|
||||
findings.append(Finding("history", NOTE, "reachable", "-",
|
||||
f"{total} commits reachable from {sha[:12]}"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- gate 6
|
||||
|
||||
def gate_presentation(repo, sha, manifest, findings):
|
||||
roots = top_level(repo, sha)
|
||||
product = [r for r in roots
|
||||
if manifest.classify_root(r) == "allowed" and not r.startswith(".")]
|
||||
other = [r for r in roots if manifest.classify_root(r) != "allowed"]
|
||||
findings.append(Finding(
|
||||
"presentation", NOTE, "root-listing", "-",
|
||||
f"{len(roots)} top-level entries; {len(product)} read as product, "
|
||||
f"{len(other)} do not"))
|
||||
if other:
|
||||
findings.append(Finding(
|
||||
"presentation", REVIEW, "mixed-listing", "-",
|
||||
"a stranger cannot distinguish product from working material: "
|
||||
+ ", ".join(sorted(other))))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- report
|
||||
|
||||
GATES = ("tree-shape", "new-surface", "file-class", "content", "history", "presentation")
|
||||
|
||||
|
||||
def report(findings, repo, sha, previous, manifest):
|
||||
lines = []
|
||||
w = lines.append
|
||||
w(f"# Public-surface audit — {manifest.repository}")
|
||||
w("")
|
||||
w(f"- candidate SHA: `{sha[:12]}`")
|
||||
w(f"- previous public SHA: `{previous[:12] if previous else '(none given)'}`")
|
||||
w(f"- manifest: `{os.path.basename(manifest.path)}`")
|
||||
w(f"- generated: {date.today().isoformat()}")
|
||||
w("")
|
||||
deny = [f for f in findings if f.severity == DENY]
|
||||
review = [f for f in findings if f.severity == REVIEW]
|
||||
verdict = "DENY" if deny else ("REVIEW" if review else "PASS")
|
||||
w(f"**Verdict: {verdict}** — {len(deny)} deny, {len(review)} review, "
|
||||
f"{len([f for f in findings if f.severity == NOTE])} note.")
|
||||
w("")
|
||||
for gate in GATES:
|
||||
rows = [f for f in findings if f.gate == gate]
|
||||
if not rows:
|
||||
continue
|
||||
w(f"## {gate}")
|
||||
w("")
|
||||
rows.sort(key=lambda f: (SEVERITY_ORDER[f.severity], f.rule, f.path))
|
||||
w("| sev | rule | path | detail |")
|
||||
w("| --- | --- | --- | --- |")
|
||||
for f in rows:
|
||||
detail = f.detail
|
||||
if f.excerpt:
|
||||
detail += f" — `{f.excerpt}`"
|
||||
detail = detail.replace("|", "\\|")
|
||||
w(f"| {f.severity} | {f.rule} | `{f.path}` | {detail} |")
|
||||
w("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
ap.add_argument("--repo", required=True)
|
||||
ap.add_argument("--sha", required=True)
|
||||
ap.add_argument("--manifest", required=True)
|
||||
ap.add_argument("--previous", default="")
|
||||
ap.add_argument("--out", default="")
|
||||
ap.add_argument("--skip-history", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
with open(args.manifest) as fh:
|
||||
manifest = Manifest(json.load(fh), args.manifest)
|
||||
|
||||
sha = run(args.repo, ["rev-parse", args.sha]).strip()
|
||||
previous = run(args.repo, ["rev-parse", args.previous]).strip() if args.previous else ""
|
||||
|
||||
findings = []
|
||||
gate_tree_shape(args.repo, sha, manifest, findings)
|
||||
gate_new_surface(args.repo, sha, previous, manifest, findings)
|
||||
gate_submodule_urls(args.repo, sha, findings)
|
||||
gate_file_class(args.repo, sha, manifest, findings)
|
||||
gate_content(args.repo, sha, manifest, findings)
|
||||
if not args.skip_history:
|
||||
gate_history(args.repo, sha, manifest, findings)
|
||||
gate_presentation(args.repo, sha, manifest, findings)
|
||||
|
||||
text = report(findings, args.repo, sha, previous, manifest)
|
||||
if args.out:
|
||||
with open(args.out, "w") as fh:
|
||||
fh.write(text + "\n")
|
||||
print(f"wrote {args.out}")
|
||||
else:
|
||||
print(text)
|
||||
|
||||
return 1 if any(f.severity == DENY for f in findings) else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Loading…
Reference in a new issue