From da6715a0fdcf5f59ddc96f0669461c68a8a2f224 Mon Sep 17 00:00:00 2001 From: Sam-24-dev Date: Wed, 7 Oct 2026 01:04:41 +0100 Subject: [PATCH 1/3] fix(reddit): admit committed source packages in CI --- .../workflows/reddit_package_admission.yml | 31 +++ backend/reddit_source_package.py | 14 +- scripts/check_reddit_package_admission.py | 133 +++++++++++ tests/test_reddit_package_admission.py | 222 ++++++++++++++++++ 4 files changed, 397 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/reddit_package_admission.yml create mode 100644 scripts/check_reddit_package_admission.py create mode 100644 tests/test_reddit_package_admission.py diff --git a/.github/workflows/reddit_package_admission.yml b/.github/workflows/reddit_package_admission.yml new file mode 100644 index 0000000..f15c3ea --- /dev/null +++ b/.github/workflows/reddit_package_admission.yml @@ -0,0 +1,31 @@ +name: Reddit Package Admission + +on: + pull_request_target: + branches: [main] + types: [opened, synchronize, reopened] + +permissions: + contents: read + checks: write + +jobs: + admission: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout trusted base + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.event.pull_request.base.sha }} + persist-credentials: false + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: '3.11' + # Candidate commits are fetched as objects only, never checked out or executed. + # This workflow first becomes available after its implementation PR merges. + - name: Admit and publish exact-head check + env: + GITHUB_TOKEN: ${{ github.token }} + run: python -I -B scripts/check_reddit_package_admission.py diff --git a/backend/reddit_source_package.py b/backend/reddit_source_package.py index 830f999..017fbab 100644 --- a/backend/reddit_source_package.py +++ b/backend/reddit_source_package.py @@ -27,12 +27,20 @@ def _unique_json_keys(pairs): return values -def validate_reddit_source_package(project_root, aggregate_date_utc, *, now=None): +def producer_minimum_mentions(baseline): + """Preserve the producer floor and exact integer 85-percent ceiling.""" + if type(baseline) is not int or baseline <= 0: + raise ValueError("Invalid producer baseline") + return max(400, (baseline * 85 + 99) // 100) + + +def validate_reddit_source_package(project_root, aggregate_date_utc, *, now=None, read_bytes=None): """Return verified provenance; never copy, select, or publish package files.""" root = Path(project_root) / "datos" receipt_path = root / "source_packages" / "reddit" / "receipt.json" + read = read_bytes if read_bytes is not None else lambda path: path.read_bytes() try: - receipt = json.loads(receipt_path.read_text(encoding="utf-8"), object_pairs_hook=_unique_json_keys) + receipt = json.loads(read(receipt_path).decode("utf-8"), object_pairs_hook=_unique_json_keys) if not isinstance(receipt, dict) or set(receipt) != { "source", "reference_date_utc", "source_date_utc", "extraction_started_at_utc", "extraction_finished_at_utc", "scope", "posts_count", "outputs", @@ -59,7 +67,7 @@ def validate_reddit_source_package(project_root, aggregate_date_utc, *, now=None raise ValueError("Reddit source package provenance is ineligible") for name, schema in SCHEMAS.items(): - data = (root / name).read_bytes() + data = read(root / name) rows_reader = csv.DictReader(io.StringIO(data.decode("utf-8"), newline=""), strict=True) rows = list(rows_reader) if not rows or rows_reader.fieldnames != list(schema) or any( diff --git a/scripts/check_reddit_package_admission.py b/scripts/check_reddit_package_admission.py new file mode 100644 index 0000000..c01633e --- /dev/null +++ b/scripts/check_reddit_package_admission.py @@ -0,0 +1,133 @@ +"""Admit committed Reddit packages as data using trusted code and a real UTC clock.""" + +import csv +from datetime import datetime, timezone +import io +import json +import os +from pathlib import Path +import re +import subprocess +import sys +from urllib.request import Request, urlopen + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "backend")) +from reddit_source_package import producer_minimum_mentions, validate_reddit_source_package # noqa: E402 + +PATHS = ("datos/reddit_sentimiento_frameworks.csv", "datos/reddit_temas_emergentes.csv", + "datos/source_packages/reddit/receipt.json") +REPOSITORY = "Sam-24-dev/Technology-trend-analysis-platform" +SELECTORS = (r"^GIT_(DIR|WORK_TREE|COMMON_DIR|INDEX_FILE|OBJECT_DIRECTORY|ALTERNATE_OBJECT_DIRECTORIES|" + r"NAMESPACE|.*PREFIX|SHALLOW_FILE|GRAFT_FILE|CONFIG.*|CEILING_DIRECTORIES|" + r"DISCOVERY_ACROSS_FILESYSTEM|IMPLICIT_WORK_TREE|REPLACE_REF_BASE)$") + + +def git(root, *args): + rejected = sorted(name for name in os.environ if re.match(SELECTORS, name, re.I)) + if rejected: + raise ValueError("Rejected Git environment names: " + ", ".join(rejected)) + result = subprocess.run(["git", "--no-optional-locks", "-C", str(root), *args], + capture_output=True, timeout=60, check=False, + env=dict(os.environ, GIT_TERMINAL_PROMPT="0", GCM_INTERACTIVE="Never")) + if result.returncode: + raise ValueError("Git operation failed: " + args[0]) + return result.stdout + + +def commit(root, sha): + if not re.fullmatch(r"[0-9a-f]{40}", sha) or git(root, "rev-parse", sha + "^{commit}").decode().strip() != sha: + raise ValueError("Expected an exact commit SHA") + return sha + + +def admit(root, base, head, *, now=None): + """Read immutable blobs only; now injection is internal to fixture tests.""" + root = Path(root) + base, head = commit(root, base), commit(root, head) + diff = git(root, "diff", "--no-ext-diff", "--no-renames", "--name-status", "-z", base, head).split(b"\0")[:-1] + changes = [(diff[i].decode(), diff[i + 1].decode()) for i in range(0, len(diff), 2)] + if not any(path in PATHS for _, path in changes): + return {"classification": "code-only", "eligibility": "not-applicable"} + if any(status not in {"A", "M"} or path not in PATHS for status, path in changes): + raise ValueError("Package diff contains prohibited paths or changes") + blobs = {} + for path in PATHS: + entry = git(root, "ls-tree", head, "--", path).decode().strip().split() + if len(entry) != 4 or entry[:2] != ["100644", "blob"] or entry[3] != path: + raise ValueError("Package requires three regular 100644 files") + blobs[path] = git(root, "cat-file", "blob", entry[2]) + current = now if now is not None else datetime.now(timezone.utc) + if current.tzinfo is None: + raise ValueError("Admission clock must be UTC-aware") + current = current.astimezone(timezone.utc) + package = validate_reddit_source_package( + root, current.date().isoformat(), now=current, + read_bytes=lambda path: blobs[path.relative_to(root).as_posix()], + ) + baseline_bytes = git(root, "cat-file", "blob", base + ":" + PATHS[1]) + rows = list(csv.DictReader(io.StringIO(baseline_bytes.decode("utf-8"), newline=""), strict=True)) + counts = [int(row["menciones"]) for row in rows] + if not counts or any(count <= 0 for count in counts): + raise ValueError("Invalid verified-base topic snapshot") + baseline = sum(counts) + minimum = producer_minimum_mentions(baseline) + if package["mentions_total"] < minimum: + raise ValueError("Package below producer coverage minimum") + return {"classification": "source-package", "identity": "pass", "coverage": "pass", + "freshness": "pass", "eligibility": "pass", "aggregate_date_utc": current.date().isoformat(), + "baseline_sha": base, "baseline_mentions": baseline, "minimum_mentions": minimum, + "posts_count_claim": package["posts_count"], "mentions_total": package["mentions_total"]} + + +def event_binding(event, repository): + pr = event["pull_request"] + if repository != REPOSITORY or event["repository"]["full_name"] != repository or pr["base"]["ref"] != "main": + raise ValueError("Unexpected admission repository or base") + if type(event["number"]) is not int or event["number"] <= 0: + raise ValueError("Invalid PR number") + base, head = pr["base"]["sha"], pr["head"]["sha"] + if not all(re.fullmatch(r"[0-9a-f]{40}", sha) for sha in (base, head)): + raise ValueError("Invalid event commit binding") + return base, head, event["number"] + + +def publish_check(head, conclusion, summary, token): + """One request, no retry; publish only a completed exact-head result.""" + payload = {"name": "Reddit package admission", "head_sha": head, "status": "completed", + "conclusion": conclusion, "output": {"title": "Committed package admission", "summary": summary}} + request = Request("https://api.github.com/repos/" + REPOSITORY + "/check-runs", + data=json.dumps(payload).encode(), method="POST", + headers={"Authorization": "Bearer " + token, "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28"}) + with urlopen(request, timeout=30) as response: # nosec B310: fixed HTTPS GitHub endpoint + result = json.load(response) + if result.get("head_sha") != head or result.get("conclusion") != conclusion or not result.get("id"): + raise ValueError("Check response did not confirm exact-head result") + return result["id"] + + +def main(): + event = json.loads(Path(os.environ["GITHUB_EVENT_PATH"]).read_text(encoding="utf-8")) + base, head, number = event_binding(event, os.environ["GITHUB_REPOSITORY"]) + conclusion, summary = "failure", "Admission did not complete" + try: + if git(ROOT, "rev-parse", "HEAD").decode().strip() != base: + raise ValueError("Trusted checkout differs from event base") + if git(ROOT, "remote", "get-url", "origin").decode().strip().removesuffix(".git") != "https://github.com/" + REPOSITORY: + raise ValueError("Unexpected fetch origin") + capture = "refs/ttap/admission/" + os.environ["GITHUB_RUN_ID"] + "-" + os.environ["GITHUB_RUN_ATTEMPT"] + git(ROOT, "fetch", "--no-tags", "--no-recurse-submodules", "origin", f"refs/pull/{number}/head:{capture}") + if git(ROOT, "rev-parse", capture + "^{commit}").decode().strip() != head: + raise ValueError("Fetched PR head differs from event") + summary = json.dumps(admit(ROOT, base, head), sort_keys=True) + conclusion = "success" + except (ValueError, OSError, KeyError, UnicodeError, subprocess.TimeoutExpired) as exc: + summary = "Admission failed; no package accepted: " + str(exc).split(":", 1)[0] + print(summary) + publish_check(head, conclusion, summary, os.environ["GITHUB_TOKEN"]) + return 0 if conclusion == "success" else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_reddit_package_admission.py b/tests/test_reddit_package_admission.py new file mode 100644 index 0000000..5a5cd2f --- /dev/null +++ b/tests/test_reddit_package_admission.py @@ -0,0 +1,222 @@ +"""Committed admission uses real disposable Git objects, never live producer data.""" + +from datetime import datetime, timedelta, timezone +import hashlib +import io +import json +from pathlib import Path + +import pytest + +from scripts import check_reddit_package_admission as gate +from reddit_source_package import REDDIT_SCOPE, producer_minimum_mentions + +NOW = datetime(2026, 10, 6, 12, tzinfo=timezone.utc) + + +def write_package(repo, total=800, finish=NOW): + contents = ( + b"framework,total_menciones,positivos,neutros,negativos,% positivo,% neutro,% negativo\r\nPython,2,1,1,0,50,50,0\r\n", + f"tema,menciones\r\nPython,{total}\r\n".encode(), + ) + outputs = {} + for path, data in zip(gate.PATHS, contents): + target = repo / path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(data) + outputs[Path(path).name] = {"rows": 1, "sha256": hashlib.sha256(data).hexdigest()} + outputs[Path(gate.PATHS[1]).name]["mentions_total"] = total + receipt = {"source": "reddit", "reference_date_utc": finish.date().isoformat(), + "source_date_utc": finish.date().isoformat(), "scope": list(REDDIT_SCOPE), + "posts_count": 3000, "outputs": outputs, + "extraction_started_at_utc": finish.strftime("%Y-%m-%dT%H:%M:%SZ"), + "extraction_finished_at_utc": finish.strftime("%Y-%m-%dT%H:%M:%SZ")} + target = repo / gate.PATHS[2] + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(json.dumps(receipt) + "\n", encoding="utf-8") + return receipt + + +def save(repo): + gate.git(repo, "add", "--all") + gate.git(repo, "commit", "-qm", "test: package fixture") + return gate.git(repo, "rev-parse", "HEAD").decode().strip() + + +@pytest.fixture(params=[False, True]) +def candidate(tmp_path, request): + repo = tmp_path / "repo" + repo.mkdir() + gate.git(repo, "init", "-q") + for key, value in (("user.name", "Fixture"), ("user.email", "fixture@example.invalid"), + ("core.autocrlf", str(request.param).lower())): + gate.git(repo, "config", key, value) + (repo / ".gitattributes").write_bytes((gate.ROOT / ".gitattributes").read_bytes()) + write_package(repo, 900, NOW - timedelta(days=9)) + base = save(repo) + receipt = write_package(repo) + return repo, base, receipt + + +def test_exact_blobs_ignore_worktree_and_allow_unchanged_csv(candidate): + repo, base, _ = candidate + head = save(repo) + (repo / gate.PATHS[2]).unlink() + gate.git(repo, "restore", "--worktree", "--", gate.PATHS[2]) + assert json.loads((repo / gate.PATHS[2]).read_bytes()) == json.loads(gate.git(repo, "cat-file", "blob", head + ":" + gate.PATHS[2])) + assert gate.git(repo, "rev-parse", base + ":" + gate.PATHS[0]) == gate.git(repo, "rev-parse", head + ":" + gate.PATHS[0]) + (repo / gate.PATHS[1]).write_bytes(b"untrusted worktree bytes") + before = (gate.git(repo, "show-ref"), gate.git(repo, "status", "--porcelain")) + result = gate.admit(repo, base, head, now=NOW) + assert result["minimum_mentions"] == 765 and result["mentions_total"] == 800 + assert result["posts_count_claim"] == 3000 + assert result["identity"] == result["freshness"] == result["eligibility"] == "pass" + assert before == (gate.git(repo, "show-ref"), gate.git(repo, "status", "--porcelain")) + assert (repo / gate.PATHS[1]).read_bytes() == b"untrusted worktree bytes" + + +@pytest.mark.parametrize("damage", ["tamper", "top_duplicate", "nested_duplicate", "scope", "counts", "csv_counts", "schema", + "window", "stale", "future", "cross_date", "low", "extra", "delete", "rename", "mode", "symlink"]) +def test_bad_package_never_accepts_or_changes_files_refs(candidate, damage): + repo, base, receipt = candidate + path = repo / gate.PATHS[2] + if damage == "tamper": + with (repo / gate.PATHS[1]).open("ab") as stream: + stream.write(b"x") + elif damage in {"top_duplicate", "nested_duplicate"}: + key = "source" if damage == "top_duplicate" else "sha256" + path.write_text(path.read_text().replace('"' + key + '":', '"' + key + '": "duplicate", "' + key + '":', 1)) + elif damage in {"scope", "counts", "window", "schema", "csv_counts"}: + if damage == "scope": + receipt["scope"][0] = "unexpected" + elif damage == "counts": + receipt["outputs"][Path(gate.PATHS[1]).name]["mentions_total"] += 1 + elif damage == "schema": + receipt["outputs"][Path(gate.PATHS[1]).name]["rows"] = "1" + elif damage == "csv_counts": + data = b"tema,menciones\r\nPython,invalid\r\n" + (repo / gate.PATHS[1]).write_bytes(data) + receipt["outputs"][Path(gate.PATHS[1]).name]["sha256"] = hashlib.sha256(data).hexdigest() + else: + receipt["extraction_started_at_utc"] = "2026-10-06T13:00:00Z" + path.write_text(json.dumps(receipt)) + elif damage in {"stale", "future", "cross_date", "low"}: + finish = NOW + timedelta(days={"stale": -9, "future": 1, "cross_date": -1, "low": 0}[damage]) + write_package(repo, 764 if damage == "low" else 800, finish) + elif damage == "extra": + (repo / "unrelated.py").write_text("pass\n") + elif damage == "delete": + (repo / gate.PATHS[0]).unlink() + elif damage == "rename": + (repo / gate.PATHS[0]).rename(repo / "renamed.csv") + head = save(repo) + if damage in {"mode", "symlink"}: + oid = gate.git(repo, "rev-parse", head + ":" + gate.PATHS[0]).decode().strip() + gate.git(repo, "update-index", "--cacheinfo", "100755" if damage == "mode" else "120000", oid, gate.PATHS[0]) + gate.git(repo, "commit", "-qm", "test: prohibited mode") + head = gate.git(repo, "rev-parse", "HEAD").decode().strip() + before = (gate.git(repo, "show-ref"), gate.git(repo, "status", "--porcelain"), path.read_bytes()) + with pytest.raises(ValueError): + gate.admit(repo, base, head, now=NOW) + assert before == (gate.git(repo, "show-ref"), gate.git(repo, "status", "--porcelain"), path.read_bytes()) + + +def test_code_only_skips_historical_package(candidate): + repo, base, _ = candidate + gate.git(repo, "restore", "--worktree", "--", *gate.PATHS) + (repo / "code.py").write_text("pass\n") + assert gate.admit(repo, base, save(repo), now=NOW)["eligibility"] == "not-applicable" + + +def test_floor_ceiling_and_verified_baseline(candidate): + assert (301 * 85 + 99) // 100 == 256 and (300 * 85 + 99) // 100 == 255 + assert producer_minimum_mentions(301) == producer_minimum_mentions(300) == 400 + repo, base, _ = candidate + write_package(repo, 765) + assert gate.admit(repo, base, save(repo), now=NOW)["coverage"] == "pass" + write_package(repo, 764) + with pytest.raises(ValueError, match="coverage"): + gate.admit(repo, base, save(repo), now=NOW) + + +@pytest.mark.parametrize("total", [399, 400]) +def test_full_admission_preserves_absolute_floor(candidate, total): + repo, _, _ = candidate + write_package(repo, 300, NOW - timedelta(days=9)) + base = save(repo) + write_package(repo, total) + if total == 399: + with pytest.raises(ValueError, match="coverage"): + gate.admit(repo, base, save(repo), now=NOW) + else: + assert gate.admit(repo, base, save(repo), now=NOW)["minimum_mentions"] == 400 + + +def test_event_binding_never_uses_merge_sha(): + event = {"number": 12, "repository": {"full_name": gate.REPOSITORY}, "pull_request": { + "base": {"ref": "main", "sha": "a" * 40}, "head": {"sha": "b" * 40}}} + assert gate.event_binding(event, gate.REPOSITORY) == ("a" * 40, "b" * 40, 12) + event["pull_request"]["base"]["ref"] = "other" + with pytest.raises(ValueError): + gate.event_binding(event, gate.REPOSITORY) + + +@pytest.mark.parametrize("wrong_ref", [False, True]) +def test_cli_real_fetch_binding_and_failed_gate_preserve_data(candidate, monkeypatch, tmp_path, wrong_ref): + repo, base, _ = candidate + (repo / gate.PATHS[1]).write_bytes(b"tampered committed CSV") + head = save(repo) + remote = tmp_path / "remote.git" + gate.git(tmp_path, "init", "--bare", "-q", str(remote)) + gate.git(repo, "push", str(remote), (base if wrong_ref else head) + ":refs/pull/12/head") + gate.git(repo, "remote", "add", "origin", "https://github.com/" + gate.REPOSITORY) + gate.git(repo, "checkout", "--detach", base) + event = {"number": 12, "repository": {"full_name": gate.REPOSITORY}, "pull_request": { + "base": {"ref": "main", "sha": base}, "head": {"sha": head}}} + path = tmp_path / "event.json" + path.write_text(json.dumps(event)) + for key, value in {"GITHUB_EVENT_PATH": str(path), "GITHUB_REPOSITORY": gate.REPOSITORY, + "GITHUB_RUN_ID": "1", "GITHUB_RUN_ATTEMPT": "1", "GITHUB_TOKEN": "fixture-token"}.items(): + monkeypatch.setenv(key, value) + monkeypatch.setattr(gate, "ROOT", repo) + original = gate.git + def service(root, *args): + if args[0] == "fetch": + return original(root, *args[:3], str(remote), *args[4:]) + return original(root, *args) + monkeypatch.setattr(gate, "git", service) + checks = [] + monkeypatch.setattr(gate, "publish_check", lambda *args: checks.append(args)) + before = (original(repo, "show-ref").decode().splitlines(), original(repo, "status", "--porcelain"), tuple((repo / p).read_bytes() for p in gate.PATHS)) + assert gate.main() == 1 and checks[0][:2] == (head, "failure") + capture = "refs/ttap/admission/1-1" + assert original(repo, "rev-parse", capture).decode().strip() == (base if wrong_ref else head) + assert original(repo, "rev-parse", "HEAD").decode().strip() == base + assert before[0] == [line for line in original(repo, "show-ref").decode().splitlines() if not line.endswith(" " + capture)] + assert before[1:] == (original(repo, "status", "--porcelain"), tuple((repo / p).read_bytes() for p in gate.PATHS)) + + +def test_check_publication_exact_head_and_api_failure(monkeypatch): + seen = [] + def api(request, timeout): + payload = json.loads(request.data) + seen.append(payload) + assert timeout == 30 and payload["head_sha"] == "b" * 40 + return io.BytesIO(json.dumps({"id": 1, "head_sha": payload["head_sha"], "conclusion": payload["conclusion"]}).encode()) + monkeypatch.setattr(gate, "urlopen", api) + assert gate.publish_check("b" * 40, "failure", "Admission failed", "fixture-token") == 1 + assert seen[0]["conclusion"] == "failure" + def unavailable(*args, **kwargs): + raise OSError("fixture API unavailable") + monkeypatch.setattr(gate, "urlopen", unavailable) + with pytest.raises(OSError): + gate.publish_check("b" * 40, "success", "validated", "fixture-token") + + +def test_workflow_is_trusted_and_narrow(): + workflow = (gate.ROOT / ".github/workflows/reddit_package_admission.yml").read_text() + assert "pull_request_target:" in workflow and "checks: write" in workflow + assert "ref: ${{ github.event.pull_request.base.sha }}" in workflow + assert "persist-credentials: false" in workflow and "pip install" not in workflow + assert "cache:" not in workflow and "secrets." not in workflow + assert "--now" not in (gate.ROOT / "scripts/check_reddit_package_admission.py").read_text() From ef3b56ab05ccbdcdff9a633b6b4de7ccea695f5f Mon Sep 17 00:00:00 2001 From: Sam-24-dev Date: Wed, 7 Oct 2026 19:33:07 +0100 Subject: [PATCH 2/3] fix(ci): use trusted trigger checkout for package admission --- .github/workflows/reddit_package_admission.yml | 1 - tests/test_reddit_package_admission.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/reddit_package_admission.yml b/.github/workflows/reddit_package_admission.yml index f15c3ea..f643fbe 100644 --- a/.github/workflows/reddit_package_admission.yml +++ b/.github/workflows/reddit_package_admission.yml @@ -17,7 +17,6 @@ jobs: - name: Checkout trusted base uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: - ref: ${{ github.event.pull_request.base.sha }} persist-credentials: false - name: Set up Python uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 diff --git a/tests/test_reddit_package_admission.py b/tests/test_reddit_package_admission.py index 5a5cd2f..4baf916 100644 --- a/tests/test_reddit_package_admission.py +++ b/tests/test_reddit_package_admission.py @@ -216,7 +216,7 @@ def unavailable(*args, **kwargs): def test_workflow_is_trusted_and_narrow(): workflow = (gate.ROOT / ".github/workflows/reddit_package_admission.yml").read_text() assert "pull_request_target:" in workflow and "checks: write" in workflow - assert "ref: ${{ github.event.pull_request.base.sha }}" in workflow + assert "ref:" not in workflow and 'if git(ROOT, "rev-parse", "HEAD").decode().strip() != base:' in (gate.ROOT / "scripts/check_reddit_package_admission.py").read_text() assert "persist-credentials: false" in workflow and "pip install" not in workflow assert "cache:" not in workflow and "secrets." not in workflow assert "--now" not in (gate.ROOT / "scripts/check_reddit_package_admission.py").read_text() From ed89fd9ef639df05c50736414224e55b59a48034 Mon Sep 17 00:00:00 2001 From: Sam-24-dev Date: Wed, 7 Oct 2026 22:51:12 +0100 Subject: [PATCH 3/3] test(reddit): cover real CLI admission and fractional coverage --- tests/test_reddit_package_admission.py | 129 ++++++++++++++++++++++--- 1 file changed, 114 insertions(+), 15 deletions(-) diff --git a/tests/test_reddit_package_admission.py b/tests/test_reddit_package_admission.py index 4baf916..f9f3233 100644 --- a/tests/test_reddit_package_admission.py +++ b/tests/test_reddit_package_admission.py @@ -139,17 +139,21 @@ def test_floor_ceiling_and_verified_baseline(candidate): gate.admit(repo, base, save(repo), now=NOW) -@pytest.mark.parametrize("total", [399, 400]) -def test_full_admission_preserves_absolute_floor(candidate, total): +@pytest.mark.parametrize("baseline,total,minimum", [(300, 399, 400), (300, 400, 400), (501, 425, 426), (501, 426, 426)]) +def test_full_admission_preserves_absolute_floor(candidate, baseline, total, minimum): repo, _, _ = candidate - write_package(repo, 300, NOW - timedelta(days=9)) + write_package(repo, baseline, NOW - timedelta(days=9)) base = save(repo) write_package(repo, total) - if total == 399: - with pytest.raises(ValueError, match="coverage"): - gate.admit(repo, base, save(repo), now=NOW) + head = save(repo) + assert producer_minimum_mentions(baseline) == minimum + if total < minimum: + with pytest.raises(ValueError, match="^Package below producer coverage minimum$"): + gate.admit(repo, base, head, now=NOW) else: - assert gate.admit(repo, base, save(repo), now=NOW)["minimum_mentions"] == 400 + result = gate.admit(repo, base, head, now=NOW) + assert result["baseline_mentions"] == baseline and result["minimum_mentions"] == minimum + assert result["mentions_total"] == total and result["coverage"] == "pass" def test_event_binding_never_uses_merge_sha(): @@ -161,6 +165,18 @@ def test_event_binding_never_uses_merge_sha(): gate.event_binding(event, gate.REPOSITORY) + +def bind_cli_event(repo, base, head, monkeypatch, tmp_path, run_id="1"): + event = {"number": 12, "repository": {"full_name": gate.REPOSITORY}, "pull_request": { + "base": {"ref": "main", "sha": base}, "head": {"sha": head}}} + path = tmp_path / "event.json" + path.write_text(json.dumps(event)) + for key, value in {"GITHUB_EVENT_PATH": str(path), "GITHUB_REPOSITORY": gate.REPOSITORY, + "GITHUB_RUN_ID": run_id, "GITHUB_RUN_ATTEMPT": "1", "GITHUB_TOKEN": "fixture-token"}.items(): + monkeypatch.setenv(key, value) + monkeypatch.setattr(gate, "ROOT", repo) + + @pytest.mark.parametrize("wrong_ref", [False, True]) def test_cli_real_fetch_binding_and_failed_gate_preserve_data(candidate, monkeypatch, tmp_path, wrong_ref): repo, base, _ = candidate @@ -171,14 +187,7 @@ def test_cli_real_fetch_binding_and_failed_gate_preserve_data(candidate, monkeyp gate.git(repo, "push", str(remote), (base if wrong_ref else head) + ":refs/pull/12/head") gate.git(repo, "remote", "add", "origin", "https://github.com/" + gate.REPOSITORY) gate.git(repo, "checkout", "--detach", base) - event = {"number": 12, "repository": {"full_name": gate.REPOSITORY}, "pull_request": { - "base": {"ref": "main", "sha": base}, "head": {"sha": head}}} - path = tmp_path / "event.json" - path.write_text(json.dumps(event)) - for key, value in {"GITHUB_EVENT_PATH": str(path), "GITHUB_REPOSITORY": gate.REPOSITORY, - "GITHUB_RUN_ID": "1", "GITHUB_RUN_ATTEMPT": "1", "GITHUB_TOKEN": "fixture-token"}.items(): - monkeypatch.setenv(key, value) - monkeypatch.setattr(gate, "ROOT", repo) + bind_cli_event(repo, base, head, monkeypatch, tmp_path) original = gate.git def service(root, *args): if args[0] == "fetch": @@ -220,3 +229,93 @@ def test_workflow_is_trusted_and_narrow(): assert "persist-credentials: false" in workflow and "pip install" not in workflow assert "cache:" not in workflow and "secrets." not in workflow assert "--now" not in (gate.ROOT / "scripts/check_reddit_package_admission.py").read_text() + + +def test_cli_success_real_clock_fetch_and_exact_head_publication(candidate, monkeypatch, tmp_path, capsys): + import os + import shutil + import sys + + repo, base, _ = candidate + remote = tmp_path / "remote.git" + gate.git(tmp_path, "init", "--bare", "-q", str(remote)) + origin = "https://github.com/" + gate.REPOSITORY + gate.git(repo, "remote", "add", "origin", origin) + finish = datetime.now(timezone.utc).replace(microsecond=0) + write_package(repo, finish=finish) + head = save(repo) + gate.git(repo, "push", str(remote), head + ":refs/pull/12/head") + gate.git(repo, "checkout", "--detach", base) + bind_cli_event(repo, base, head, monkeypatch, tmp_path, "success") + monkeypatch.setenv("GIT_ALLOW_PROTOCOL", "file") + real_git = shutil.which("git") + assert real_git + shim = tmp_path / "git.py" + routing = "url." + remote.as_posix() + ".insteadOf=" + origin + shim.write_text("import subprocess,sys\nargs=sys.argv[1:]\n" + "if 'fetch' in args: args=['-c'," + repr(routing) + ",*args]\n" + "sys.exit(subprocess.call([" + repr(real_git) + ",*args]))\n", encoding="utf-8") + launcher = tmp_path / ("git.exe" if os.name == "nt" else "git") + if os.name == "nt": + source = tmp_path / "git.cs" + source.write_text(""" +using System; +using System.Diagnostics; +public class GitShim +{ + public static int Main() + { + string command = Environment.CommandLine; + int end = command[0] == '"' ? command.IndexOf('"', 1) + 1 : command.IndexOf(' '); + string arguments = command.Substring(end); + if (arguments.Contains(" fetch ")) + arguments = "-c \\"" + ROUTING + "\\" " + arguments; + var start = new ProcessStartInfo(NATIVE_GIT, arguments); + start.UseShellExecute = false; + using (var child = Process.Start(start)) + { + child.WaitForExit(); + return child.ExitCode; + } + } +} +""".replace("ROUTING", json.dumps(routing)).replace("NATIVE_GIT", json.dumps(real_git)), encoding="utf-8") + powershell = shutil.which("powershell.exe") + assert powershell + command = "Add-Type -Path $env:SHIM_SOURCE -OutputAssembly $env:SHIM_EXE -OutputType ConsoleApplication" + gate.subprocess.run([powershell, "-NoProfile", "-NonInteractive", "-Command", command], check=True, + env=dict(os.environ, TEMP=str(tmp_path), TMP=str(tmp_path), SHIM_SOURCE=str(source), SHIM_EXE=str(launcher))) + else: + launcher.write_text("#!" + sys.executable + "\n" + shim.read_text(), encoding="utf-8") + launcher.chmod(0o755) + before = (gate.git(repo, "show-ref").decode().splitlines(), gate.git(repo, "rev-parse", "HEAD"), + (repo / ".git/index").read_bytes(), tuple((repo / p).read_bytes() for p in gate.PATHS)) + trace = tmp_path / "git.trace" + monkeypatch.setenv("GIT_TRACE", str(trace)) + monkeypatch.setenv("PATH", str(tmp_path) + os.pathsep + os.environ["PATH"]) + seen = [] + def api(request, timeout): + payload = json.loads(request.data) + seen.append(payload) + assert request.full_url == "https://api.github.com/repos/" + gate.REPOSITORY + "/check-runs" + assert request.method == "POST" and timeout == 30 + return io.BytesIO(json.dumps({"id": 1, "head_sha": payload["head_sha"], + "conclusion": payload["conclusion"]}).encode()) + monkeypatch.setattr(gate, "urlopen", api) + start = datetime.now(timezone.utc) + assert gate.main() == 0 + end = datetime.now(timezone.utc) + summary = json.loads(capsys.readouterr().out.strip()) + assert summary["baseline_sha"] == base and summary["aggregate_date_utc"] == finish.date().isoformat() + assert finish <= start <= end and start.date() == end.date() == finish.date() + assert summary["identity"] == summary["coverage"] == summary["freshness"] == summary["eligibility"] == "pass" + assert len(seen) == 1 and seen[0]["head_sha"] == head and seen[0]["conclusion"] == "success" + assert json.loads(seen[0]["output"]["summary"]) == summary + capture = "refs/ttap/admission/success-1" + assert gate.git(repo, "rev-parse", capture).decode().strip() == head + assert before[0] == [line for line in gate.git(repo, "show-ref").decode().splitlines() if not line.endswith(" " + capture)] + assert before[1:] == (gate.git(repo, "rev-parse", "HEAD"), (repo / ".git/index").read_bytes(), + tuple((repo / p).read_bytes() for p in gate.PATHS)) + commands = trace.read_text() + assert "fetch --no-tags --no-recurse-submodules origin refs/pull/12/head:" + capture in commands + assert str(remote).replace("\\", "/") in commands.replace("\\", "/") and "checkout" not in commands