mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
ci: guards → central reusable workflows (comment/leak/identity/doc-links)
Thin callers to freemkv/.github@v1; delete the now-duplicated local ci/ scripts. Adds concurrency cancel-in-progress and drops the push+PR double-fire (fork PRs still run). doc-links now resolves toolchain from rust-version (was hardcoded 1.98).
This commit is contained in:
@@ -1,17 +1,12 @@
|
|||||||
name: comment-guard
|
name: comment-guard
|
||||||
|
|
||||||
# Caps explanatory prose by COMMENT INTENT (not visibility):
|
# Thin caller → the canonical reusable guard in freemkv/.github (one definition
|
||||||
# //! / /*! module docs — unbounded (module-scoped, can't hide per-item text)
|
# for every repo). Fork PRs still run it; same-repo pushes aren't double-run.
|
||||||
# /// / /** */ item docs — 8 prose lines (fenced ``` examples excluded)
|
|
||||||
# // / /* */ asides — 3 lines (single-blank-separated runs count as one)
|
|
||||||
# Overflow belongs in the module //! or docs/<topic>.md with a one-line // pointer.
|
|
||||||
|
|
||||||
on: [push, pull_request]
|
on: [push, pull_request]
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
jobs:
|
jobs:
|
||||||
comment-guard:
|
comment-guard:
|
||||||
runs-on: ubuntu-latest
|
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork
|
||||||
steps:
|
uses: freemkv/.github/.github/workflows/guard-comment.yml@v1
|
||||||
- uses: actions/checkout@v7
|
|
||||||
- name: Run comment-guard
|
|
||||||
run: python3 ci/comment-guard.py
|
|
||||||
|
|||||||
@@ -1,32 +1,17 @@
|
|||||||
name: doc-links
|
name: doc-links
|
||||||
|
|
||||||
# Fails on broken intra-doc links in `///`/`//!` doc comments (e.g. a `[Type]`
|
# Thin caller → the canonical reusable doc-links gate in freemkv/.github.
|
||||||
# link to a renamed/removed item). Catches doc drift that `cargo build` misses.
|
# libfreemkv path-deps freemkv-unlock, so the reusable checks it out as a sibling
|
||||||
#
|
# (no [patch] needed — the layout resolves the path dep) and resolves the
|
||||||
# libfreemkv path-deps ../freemkv-unlock on the branch tip, so — like ci.yml —
|
# toolchain from rust-version.
|
||||||
# both repos are checked out into sibling subdirectories for the path dep to
|
|
||||||
# resolve.
|
|
||||||
|
|
||||||
on: [push, pull_request]
|
on: [push, pull_request]
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
jobs:
|
jobs:
|
||||||
doc-links:
|
doc-links:
|
||||||
runs-on: ubuntu-latest
|
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork
|
||||||
steps:
|
uses: freemkv/.github/.github/workflows/guard-doclinks.yml@v1
|
||||||
- uses: actions/checkout@v7
|
with:
|
||||||
with:
|
self: libfreemkv
|
||||||
path: libfreemkv
|
checkout: freemkv-unlock
|
||||||
- uses: actions/checkout@v7
|
|
||||||
with:
|
|
||||||
repository: freemkv/freemkv-unlock
|
|
||||||
ref: "${{ github.ref_name == 'qa' && 'qa' || 'dev' }}"
|
|
||||||
path: freemkv-unlock
|
|
||||||
- uses: dtolnay/rust-toolchain@1.98.0
|
|
||||||
- uses: Swatinem/rust-cache@v2
|
|
||||||
with:
|
|
||||||
workspaces: libfreemkv
|
|
||||||
- name: cargo doc (deny broken intra-doc links)
|
|
||||||
working-directory: libfreemkv
|
|
||||||
env:
|
|
||||||
RUSTDOCFLAGS: "-D rustdoc::broken_intra_doc_links"
|
|
||||||
run: cargo doc --no-deps --document-private-items --lib --bins
|
|
||||||
|
|||||||
@@ -1,41 +1,14 @@
|
|||||||
name: identity-guard
|
name: identity-guard
|
||||||
# Prevention: no commit may carry an unrelated-business or non-canonical author/
|
|
||||||
# committer email. dev@example.com (a dead corp address) once slipped onto the
|
# Thin caller → the canonical reusable guard in freemkv/.github.
|
||||||
# release cascade + a batch of local commits; this fails the build if it recurs.
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [dev, qa, main]
|
branches: [dev, qa, main]
|
||||||
pull_request:
|
pull_request:
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
jobs:
|
jobs:
|
||||||
identity:
|
identity:
|
||||||
runs-on: ubuntu-latest
|
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork
|
||||||
steps:
|
uses: freemkv/.github/.github/workflows/guard-identity.yml@v1
|
||||||
- uses: actions/checkout@v7
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
- name: Reject banned author/committer emails
|
|
||||||
env:
|
|
||||||
BEFORE: ${{ github.event.before }}
|
|
||||||
AFTER: ${{ github.sha }}
|
|
||||||
EVENT: ${{ github.event_name }}
|
|
||||||
PR_BASE: ${{ github.event.pull_request.base.sha }}
|
|
||||||
PR_HEAD: ${{ github.event.pull_request.head.sha }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
# Unrelated business domains + a personal address normalized to the
|
|
||||||
# GitHub noreply. Real external contributors are unaffected.
|
|
||||||
BANNED='@example-a\.invalid|@example-b\.invalid|owner@example\.invalid'
|
|
||||||
if [ "$EVENT" = "pull_request" ]; then
|
|
||||||
range="$PR_BASE..$PR_HEAD"
|
|
||||||
elif [ -n "${BEFORE:-}" ] && [ "$BEFORE" != "0000000000000000000000000000000000000000" ] && git cat-file -e "$BEFORE" 2>/dev/null; then
|
|
||||||
range="$BEFORE..$AFTER"
|
|
||||||
else
|
|
||||||
range="$AFTER~1..$AFTER"
|
|
||||||
fi
|
|
||||||
bad=$(git log --format='%an <%ae>%n%cn <%ce>' "$range" 2>/dev/null | sort -u | grep -iE "$BANNED" || true)
|
|
||||||
if [ -n "$bad" ]; then
|
|
||||||
echo "::error::commit(s) carry a banned author/committer identity:"; echo "$bad" | sed 's/^/ /'
|
|
||||||
echo "::error::Commit as your GitHub noreply (1085847+MattJackson@users.noreply.github.com). Fix: git config user.email <noreply>; git commit --amend --reset-author."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "identity-guard: clean"
|
|
||||||
|
|||||||
@@ -1,35 +1,11 @@
|
|||||||
name: leak-guard
|
name: leak-guard
|
||||||
|
|
||||||
# Self-contained public-repo leak gate. Public CI cannot reach the private
|
# Thin caller → the canonical reusable guard in freemkv/.github.
|
||||||
# tooling, so this encodes only the generic net: internal-infra references,
|
|
||||||
# tracked CLAUDE.md/.claude paths, and AI-attribution in commit messages.
|
|
||||||
# No project-specific reverse-engineering vocabulary lives here.
|
|
||||||
|
|
||||||
on: [push, pull_request]
|
on: [push, pull_request]
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
jobs:
|
jobs:
|
||||||
leak-guard:
|
leak-guard:
|
||||||
runs-on: ubuntu-latest
|
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork
|
||||||
steps:
|
uses: freemkv/.github/.github/workflows/guard-leak.yml@v1
|
||||||
- uses: actions/checkout@v7
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
- name: Compute commit range
|
|
||||||
id: range
|
|
||||||
run: |
|
|
||||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
|
||||||
base="${{ github.event.pull_request.base.sha }}"
|
|
||||||
head="${{ github.event.pull_request.head.sha }}"
|
|
||||||
echo "range=$base..$head" >> "$GITHUB_OUTPUT"
|
|
||||||
else
|
|
||||||
before="${{ github.event.before }}"
|
|
||||||
after="${{ github.sha }}"
|
|
||||||
# New branch / first push: github.event.before is all-zeros.
|
|
||||||
if [ -z "$before" ] || [ "$before" = "0000000000000000000000000000000000000000" ]; then
|
|
||||||
echo "range=$after" >> "$GITHUB_OUTPUT"
|
|
||||||
else
|
|
||||||
echo "range=$before..$after" >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
- name: Run leak-guard
|
|
||||||
run: bash ci/leak-guard.sh "${{ steps.range.outputs.range }}"
|
|
||||||
|
|||||||
@@ -1,252 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""comment-guard: cap explanatory prose by COMMENT INTENT, not visibility.
|
|
||||||
|
|
||||||
Three tiers, keyed on the syntax the author reached for:
|
|
||||||
|
|
||||||
* `//!` / `/*!` — a module (or crate) design doc. UNBOUNDED. There is one per
|
|
||||||
file, at the top, documenting the module as a whole, so it can't be used to
|
|
||||||
smuggle per-item rationale the way item docs can. Long correctness / design
|
|
||||||
essays (e.g. a memory-ordering proof) legitimately live here.
|
|
||||||
* `///` / `/** */` — item documentation. Up to DOC_CAP *prose* lines. Fenced
|
|
||||||
``` code blocks do NOT count, so real examples aren't punished. This holds
|
|
||||||
whether or not the item is `pub`: a `pub(crate)` fn deserves a real doc
|
|
||||||
paragraph just like a `pub` one. Capping `///` here (rather than exempting
|
|
||||||
it, as the old guard did — issue #17) is what stops rationale being hidden
|
|
||||||
behind a doc sigil, while still leaving room for a genuine paragraph.
|
|
||||||
* `//` / `/* */` — a quick internal aside. Up to INLINE_CAP lines. This is the
|
|
||||||
shape that sprawls into walls, so it's kept tight: stanzas separated by a
|
|
||||||
single blank line count CUMULATIVELY, so one essay can't be split into runs.
|
|
||||||
|
|
||||||
Overflow belongs in the module `//!`, or in docs/<topic>.md with a one-line `//`
|
|
||||||
pointer — both show up in review.
|
|
||||||
|
|
||||||
Scans src/ and tests/ under the given root (default cwd). `--selftest` runs
|
|
||||||
built-in cases. Exit non-zero if any block exceeds its cap.
|
|
||||||
"""
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
INLINE_CAP = 3
|
|
||||||
DOC_CAP = 8
|
|
||||||
|
|
||||||
_RAW_OPEN = re.compile(r'r(#*)"') # raw string opener: r"…", r#"…"#, br##"…"## …
|
|
||||||
|
|
||||||
|
|
||||||
def _strip_line_comment(s):
|
|
||||||
"""Return (kind, body) for a whole-line comment, else (None, None).
|
|
||||||
|
|
||||||
kind is 'doc' for `///`/`//!`, 'plain' for other `//`. A trailing comment on
|
|
||||||
a code line is not a whole-line comment and is ignored (single-line, cheap).
|
|
||||||
"""
|
|
||||||
if s.startswith("//!"):
|
|
||||||
return "doc", s[3:]
|
|
||||||
if s.startswith("///") and not s.startswith("////"):
|
|
||||||
return "doc", s[3:]
|
|
||||||
if s.startswith("//"):
|
|
||||||
return "plain", s[2:]
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
|
|
||||||
def _fence_toggle(body):
|
|
||||||
"""True if this doc body line opens/closes a ``` fenced code block."""
|
|
||||||
return body.strip().startswith("```")
|
|
||||||
|
|
||||||
|
|
||||||
def _classify(lines):
|
|
||||||
"""Yield per-line dicts: comment kind ('doc'/'plain'/None), doc-ness of
|
|
||||||
block comments, and code info. Block comments are tracked across lines.
|
|
||||||
"""
|
|
||||||
info = []
|
|
||||||
in_block = False
|
|
||||||
block_is_doc = False
|
|
||||||
raw_hashes = None # None = not in a raw string; int N = open, needs '"' + N*'#'
|
|
||||||
for raw in lines:
|
|
||||||
s = raw.lstrip()
|
|
||||||
rec = {"raw": raw, "s": s, "comment": None, "is_doc": False, "code": False}
|
|
||||||
# Inside a raw string literal (e.g. an embedded HTML/CSS/JS asset): the
|
|
||||||
# bytes are string data, not Rust comments, so the guard is blind to them
|
|
||||||
# until the string closes. Prevents `/* */` or `//` lines in an embedded
|
|
||||||
# page from being counted as comment blocks.
|
|
||||||
if raw_hashes is not None:
|
|
||||||
if ('"' + "#" * raw_hashes) in raw:
|
|
||||||
raw_hashes = None
|
|
||||||
info.append(rec)
|
|
||||||
continue
|
|
||||||
if in_block:
|
|
||||||
rec["comment"] = "doc" if block_is_doc else "plain"
|
|
||||||
rec["is_doc"] = block_is_doc
|
|
||||||
if "*/" in raw:
|
|
||||||
in_block = False
|
|
||||||
info.append(rec)
|
|
||||||
continue
|
|
||||||
# A block comment only starts a comment-line if it's the first token.
|
|
||||||
if s.startswith("/*"):
|
|
||||||
rec["is_doc"] = s.startswith("/**") or s.startswith("/*!")
|
|
||||||
rec["comment"] = "doc" if rec["is_doc"] else "plain"
|
|
||||||
if "*/" not in s[2:]:
|
|
||||||
in_block = True
|
|
||||||
block_is_doc = rec["is_doc"]
|
|
||||||
info.append(rec)
|
|
||||||
continue
|
|
||||||
kind, _ = _strip_line_comment(s)
|
|
||||||
if kind is not None:
|
|
||||||
rec["comment"] = kind
|
|
||||||
rec["is_doc"] = kind == "doc"
|
|
||||||
info.append(rec)
|
|
||||||
continue
|
|
||||||
# Code (or blank). Blank = empty after strip.
|
|
||||||
rec["code"] = s != ""
|
|
||||||
# A code line may OPEN a raw string that spans following lines. Detected
|
|
||||||
# only on code (comments were handled above), so `// see r"x"` is safe.
|
|
||||||
m = _RAW_OPEN.search(raw)
|
|
||||||
if m and ('"' + "#" * len(m.group(1))) not in raw[m.end():]:
|
|
||||||
raw_hashes = len(m.group(1))
|
|
||||||
info.append(rec)
|
|
||||||
return info
|
|
||||||
|
|
||||||
|
|
||||||
def violations(path):
|
|
||||||
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
||||||
info = _classify(lines)
|
|
||||||
n = len(info)
|
|
||||||
out = []
|
|
||||||
|
|
||||||
i = 0
|
|
||||||
while i < n:
|
|
||||||
rec = info[i]
|
|
||||||
if rec["comment"] is None:
|
|
||||||
i += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
start = i
|
|
||||||
if rec["is_doc"]:
|
|
||||||
# Doc run: contiguous doc lines. `//!` / `/*!` module docs are
|
|
||||||
# UNBOUNDED; any other doc run caps PROSE (fenced ``` excluded) at
|
|
||||||
# DOC_CAP, regardless of the documented item's visibility.
|
|
||||||
j = i
|
|
||||||
prose = 0
|
|
||||||
in_fence = False
|
|
||||||
module_doc = False
|
|
||||||
while j < n and info[j]["comment"] == "doc":
|
|
||||||
s = info[j]["s"]
|
|
||||||
if s.startswith("//!") or s.startswith("/*!"):
|
|
||||||
module_doc = True
|
|
||||||
body = (
|
|
||||||
s[3:] if (s.startswith("///") or s.startswith("//!")) else s
|
|
||||||
)
|
|
||||||
if _fence_toggle(body):
|
|
||||||
in_fence = not in_fence
|
|
||||||
elif not in_fence and body.strip() != "":
|
|
||||||
prose += 1
|
|
||||||
j += 1
|
|
||||||
if not module_doc and prose > DOC_CAP:
|
|
||||||
out.append((start + 1, prose, DOC_CAP, "doc prose"))
|
|
||||||
i = j
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Internal (plain) run, with single-blank bridging (cumulative).
|
|
||||||
j = i
|
|
||||||
count = 0
|
|
||||||
while j < n:
|
|
||||||
r = info[j]
|
|
||||||
if r["comment"] == "plain":
|
|
||||||
count += 1
|
|
||||||
j += 1
|
|
||||||
continue
|
|
||||||
if r["comment"] == "doc":
|
|
||||||
break
|
|
||||||
if r["comment"] is None and not r["code"]:
|
|
||||||
# blank: bridge only a SINGLE blank, else stop
|
|
||||||
if j + 1 < n and info[j + 1]["comment"] == "plain":
|
|
||||||
j += 1
|
|
||||||
continue
|
|
||||||
break
|
|
||||||
break # code
|
|
||||||
if count > INLINE_CAP:
|
|
||||||
out.append((start + 1, count, INLINE_CAP, "inline comment"))
|
|
||||||
i = j
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv):
|
|
||||||
if "--selftest" in argv:
|
|
||||||
return _selftest()
|
|
||||||
root = Path(argv[1]) if len(argv) > 1 else Path.cwd()
|
|
||||||
files = sorted(p for d in ("src", "tests") for p in (root / d).rglob("*.rs"))
|
|
||||||
total = 0
|
|
||||||
for f in files:
|
|
||||||
for start, length, cap, kind in violations(f):
|
|
||||||
rel = f.relative_to(root)
|
|
||||||
print(f"{rel}:{start}: {kind} block is {length} lines (max {cap})")
|
|
||||||
total += 1
|
|
||||||
if total:
|
|
||||||
print(f"\ncomment-guard: {total} comment block(s) exceed their cap.")
|
|
||||||
print(
|
|
||||||
f"Inline // asides cap at {INLINE_CAP}; /// item-doc prose caps at "
|
|
||||||
f"{DOC_CAP} (fenced examples excluded); //! module docs are unbounded. "
|
|
||||||
"Trim, move the rationale into the module //!, or docs/<topic>.md "
|
|
||||||
"with a // pointer."
|
|
||||||
)
|
|
||||||
return 1
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def _selftest():
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
cases = [
|
|
||||||
# (rust source, expected number of violations)
|
|
||||||
# short /// doc -> ok
|
|
||||||
("/// short doc\npub fn a() {}\n", 0),
|
|
||||||
# /// on a PRIVATE item now gets the doc cap (8), so 4 lines is fine
|
|
||||||
("/// l1\n/// l2\n/// l3\n/// l4\nfn priv_a() {}\n", 0),
|
|
||||||
# ...but /// is NOT unlimited: 9 prose lines on a private item fails
|
|
||||||
("".join(f"/// l{k}\n" for k in range(9)) + "fn priv_b() {}\n", 1),
|
|
||||||
# 8 prose /// on a pub item -> ok (boundary)
|
|
||||||
("".join(f"/// l{k}\n" for k in range(8)) + "pub fn c() {}\n", 0),
|
|
||||||
# 9 prose /// on a pub item -> violation
|
|
||||||
("".join(f"/// l{k}\n" for k in range(9)) + "pub fn d() {}\n", 1),
|
|
||||||
# /// with a long fenced example -> example excluded, ok
|
|
||||||
(
|
|
||||||
"/// summary\n/// # Examples\n/// ```\n"
|
|
||||||
+ "".join("/// code\n" for _ in range(12))
|
|
||||||
+ "/// ```\npub fn e() {}\n",
|
|
||||||
0,
|
|
||||||
),
|
|
||||||
# pub(crate) gets the SAME doc cap as pub (no pub/private split anymore)
|
|
||||||
("".join(f"/// l{k}\n" for k in range(6)) + "pub(crate) fn f() {}\n", 0),
|
|
||||||
# bare inline 4 lines -> violation
|
|
||||||
("// a\n// b\n// c\n// d\nlet x = 1;\n", 1),
|
|
||||||
# blank-split dodge: 3 + blank + 3 inline -> cumulative -> violation
|
|
||||||
("// a\n// b\n// c\n\n// d\n// e\n// f\nfn z() {}\n", 1),
|
|
||||||
# plain /* */ block, 5 lines -> inline cap -> violation
|
|
||||||
("/* one\n two\n three\n four\n five */\nfn q() {}\n", 1),
|
|
||||||
# comment-like lines INSIDE a raw string are string data, not comments
|
|
||||||
(
|
|
||||||
'const H: &str = r##"<style>\n'
|
|
||||||
+ "".join("/* css note */\n" for _ in range(8))
|
|
||||||
+ '</style>"##;\nfn h() {}\n',
|
|
||||||
0,
|
|
||||||
),
|
|
||||||
# //! module doc may run long -> EXEMPT (no cap)
|
|
||||||
("".join(f"//! l{k}\n" for k in range(40)) + "\npub fn m() {}\n", 0),
|
|
||||||
# /*! block module doc may run long -> EXEMPT
|
|
||||||
("/*! l0\n" + "".join(f" l{k}\n" for k in range(30)) + "*/\nfn n() {}\n", 0),
|
|
||||||
]
|
|
||||||
ok = True
|
|
||||||
with tempfile.TemporaryDirectory() as d:
|
|
||||||
for idx, (src, want) in enumerate(cases):
|
|
||||||
p = Path(d) / f"case{idx}.rs"
|
|
||||||
p.write_text(src)
|
|
||||||
got = len(violations(p))
|
|
||||||
flag = "ok" if got == want else "FAIL"
|
|
||||||
if got != want:
|
|
||||||
ok = False
|
|
||||||
print(f" selftest {idx}: want {want} got {got} [{flag}]")
|
|
||||||
print("selftest PASSED" if ok else "selftest FAILED")
|
|
||||||
return 0 if ok else 1
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main(sys.argv))
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
#
|
|
||||||
# leak-guard.sh — self-contained public-repo leak gate.
|
|
||||||
#
|
|
||||||
# This is the LAST line of defense in CI. It is intentionally self-contained:
|
|
||||||
# public CI cannot reach the private tooling, so this script encodes ONLY the
|
|
||||||
# generic net — internal infrastructure references, agent-context files, and
|
|
||||||
# AI-attribution in commit messages. It deliberately contains NO project-
|
|
||||||
# specific reverse-engineering vocabulary (those words would themselves be a
|
|
||||||
# leak). The richer private scanner stays private.
|
|
||||||
#
|
|
||||||
# Fails (exit 1) if any of the following appear in the repo:
|
|
||||||
# 1. a tracked CLAUDE.md or .claude/ path (agent context — never public),
|
|
||||||
# 2. tracked file content matching the internal-infra net,
|
|
||||||
# 3. a commit message (in the given range) with AI attribution.
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# leak-guard.sh [<commit-range>]
|
|
||||||
# <commit-range> optional git rev-list range to scan commit messages
|
|
||||||
# (e.g. "abc..def"). If omitted, commit-message scan is
|
|
||||||
# skipped (path + content checks always run).
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# Absolute path to this script, resolved before any cd, so we can exclude it
|
|
||||||
# from the content scan (it necessarily contains the detection patterns).
|
|
||||||
SELF_ABS="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")"
|
|
||||||
|
|
||||||
REPO="$(git rev-parse --show-toplevel)"
|
|
||||||
cd "$REPO"
|
|
||||||
|
|
||||||
fail=0
|
|
||||||
note() { printf ' ✗ %s\n' "$1"; fail=1; }
|
|
||||||
|
|
||||||
# Internal-infra net — GENERIC ONLY. This script ships in the public repo, so
|
|
||||||
# the patterns themselves must not name any org-specific identifier (doing so
|
|
||||||
# would itself leak the infra they guard). We catch the leak *class*:
|
|
||||||
# - RFC1918 private IPv4 ranges (10/8, 172.16/12, 192.168/16),
|
|
||||||
# - private/internal/non-routable TLDs (.internal/.local/.lan/.corp/.invalid),
|
|
||||||
# - docker.internal.
|
|
||||||
# The full org-specific net (literal hostnames, service names, repo paths,
|
|
||||||
# vendor tooling, …) lives ONLY in the private scanner and never ships here.
|
|
||||||
INFRA_RE='\b10\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}|\b172\.(1[6-9]|2[0-9]|3[01])\.[0-9]{1,3}\.[0-9]{1,3}|\b192\.168\.[0-9]{1,3}\.[0-9]{1,3}|\.internal\b|\.local\b|\.lan\b|\.corp\b|\.invalid\b|docker\.internal'
|
|
||||||
# Home-path net — GENERIC ONLY. Catches an absolute developer home path
|
|
||||||
# committed into a tracked file (a macOS /Users/<user>/… or Linux /home/<user>/…
|
|
||||||
# path). This names NO specific user — it matches the leak *class* (any home
|
|
||||||
# path), so the pattern itself reveals nothing org- or person-specific. A real
|
|
||||||
# leak (e.g. /Users/alice/Developer/x slipping into a public RELEASE.md) trips
|
|
||||||
# this regardless of whose machine it came from. The username segment is a
|
|
||||||
# literal-username class ([A-Za-z0-9._-]) so dynamic/templated paths that build
|
|
||||||
# the user at runtime — shell `/home/$USER/`, doc `/home/<rip>/`, Rust
|
|
||||||
# `/home/{user}/` — do NOT false-positive; only a baked-in literal home leaks.
|
|
||||||
HOMEPATH_RE='/Users/[A-Za-z0-9._-]+/|/home/[A-Za-z0-9._-]+/'
|
|
||||||
# AI-attribution net (case-insensitive). "claude" matches only as a standalone
|
|
||||||
# word — NOT preceded by a dot/slash/alnum and NOT followed by .md — so legit
|
|
||||||
# mentions of CLAUDE.md / .claude/ in a commit message don't false-positive.
|
|
||||||
ATTR_RE='co-authored-by|generated with|🤖|(?<![.\/A-Za-z0-9])claude(?!\.md)'
|
|
||||||
|
|
||||||
echo "── leak-guard: tracked agent-context paths ──"
|
|
||||||
while IFS= read -r f; do
|
|
||||||
case "$f" in
|
|
||||||
CLAUDE.md|*/CLAUDE.md|.claude|.claude/*|*/.claude|*/.claude/*)
|
|
||||||
note "tracked agent-context file: $f (CLAUDE.md/.claude must never be tracked in a public repo)" ;;
|
|
||||||
esac
|
|
||||||
done < <(git ls-files)
|
|
||||||
|
|
||||||
# Match a PCRE against a file, emitting "LINE: MATCH". The pattern is passed as
|
|
||||||
# an argument (not interpolated into a //) so metacharacters like the "/" in a
|
|
||||||
# path-style token can't break the regex. Reads raw bytes so non-UTF-8 blobs
|
|
||||||
# don't abort the scan.
|
|
||||||
pcre_matches() {
|
|
||||||
perl -e '
|
|
||||||
my ($file, $re) = @ARGV;
|
|
||||||
open(my $fh, "<:raw", $file) or exit 0;
|
|
||||||
my $rx; eval { $rx = qr/$re/i }; exit 0 if $@;
|
|
||||||
while (my $l = <$fh>) { if ($l =~ /$rx/) { print "$.: $&\n"; } }
|
|
||||||
' "$1" "$2" 2>/dev/null
|
|
||||||
}
|
|
||||||
|
|
||||||
# This script's own source necessarily contains the detection patterns (e.g.
|
|
||||||
# the regex tokens in INFRA_RE), so scanning it would always self-flag. Skip it.
|
|
||||||
SELF="$(git ls-files --full-name -- "$SELF_ABS" 2>/dev/null | head -1)"
|
|
||||||
|
|
||||||
echo "── leak-guard: internal-infra references in tracked files ──"
|
|
||||||
while IFS= read -r f; do
|
|
||||||
case "$f" in *.png|*.jpg|*.jpeg|*.ico|*.gif|*.bin|*.crate|*.gz|*.zip|*.pdf) continue ;; esac
|
|
||||||
[ -n "$SELF" ] && [ "$f" = "$SELF" ] && continue
|
|
||||||
[ -f "$f" ] || continue
|
|
||||||
while IFS= read -r hit; do
|
|
||||||
[ -z "$hit" ] && continue
|
|
||||||
note "internal-infra reference: $f:$hit"
|
|
||||||
done < <(pcre_matches "$f" "$INFRA_RE")
|
|
||||||
while IFS= read -r hit; do
|
|
||||||
[ -z "$hit" ] && continue
|
|
||||||
note "[HOME-PATH] absolute home path: $f:$hit (no local home path may be committed to a public repo)"
|
|
||||||
done < <(pcre_matches "$f" "$HOMEPATH_RE")
|
|
||||||
done < <(git ls-files)
|
|
||||||
|
|
||||||
RANGE="${1:-}"
|
|
||||||
if [ -n "$RANGE" ]; then
|
|
||||||
echo "── leak-guard: AI-attribution in commit messages ($RANGE) ──"
|
|
||||||
while IFS= read -r sha; do
|
|
||||||
[ -z "$sha" ] && continue
|
|
||||||
msg="$(git log -1 --format='%B' "$sha" 2>/dev/null || true)"
|
|
||||||
# Pass the pattern as an argument (not interpolated into a //) so the
|
|
||||||
# lookbehind char class and "/" don't break the regex.
|
|
||||||
hit="$(printf '%s' "$msg" | perl -e '
|
|
||||||
my $re = $ARGV[0]; my $rx = qr/$re/i;
|
|
||||||
while (my $l = <STDIN>) { if ($l =~ /($rx)/) { print "$1\n"; last; } }
|
|
||||||
' "$ATTR_RE" | head -1 || true)"
|
|
||||||
[ -n "$hit" ] && note "commit ${sha:0:12}: message contains \"$hit\" (owner rule: zero AI attribution, ever)"
|
|
||||||
done < <(git rev-list "$RANGE" 2>/dev/null || true)
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo
|
|
||||||
if [ "$fail" -ne 0 ]; then
|
|
||||||
echo "✗ leak-guard: blocking finding(s) above — DO NOT MERGE/PUBLISH"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "✓ leak-guard: clean"
|
|
||||||
Reference in New Issue
Block a user