From 2aecb3aee30919ab6a3f322445cb7bf2c1d16cd2 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Wed, 15 Jul 2026 10:00:03 +0800 Subject: [PATCH 1/2] test: add tool-agnostic D3 preview artifact workflow Co-Authored-By: Codex --- .../qar_d3_daily_preview_artifact.yml | 104 ++++++++++++++++++ scripts/d3_build_daily_preview.py | 89 +++++++++++++++ scripts/d3_evidence.py | 61 ++++++++++ scripts/d3_verify_daily_preview.py | 53 +++++++++ tests/test_d3_tool_agnostic.py | 69 ++++++++++++ 5 files changed, 376 insertions(+) create mode 100644 .github/workflows/qar_d3_daily_preview_artifact.yml create mode 100644 scripts/d3_build_daily_preview.py create mode 100644 scripts/d3_evidence.py create mode 100644 scripts/d3_verify_daily_preview.py create mode 100644 tests/test_d3_tool_agnostic.py diff --git a/.github/workflows/qar_d3_daily_preview_artifact.yml b/.github/workflows/qar_d3_daily_preview_artifact.yml new file mode 100644 index 0000000..9a45413 --- /dev/null +++ b/.github/workflows/qar_d3_daily_preview_artifact.yml @@ -0,0 +1,104 @@ +name: QAR D3 daily preview artifact + +on: + pull_request: + types: [opened, synchronize, reopened] + paths: + - ".github/workflows/qar_d3_daily_preview_artifact.yml" + - "pyproject.toml" + - "uv.lock" + - "scripts/d3_*.py" + - "src/quant_advisor_research/**" + - "tests/test_d3_tool_agnostic.py" + - "examples/political_events.example.csv" + - "examples/political_watchlist.example.csv" + workflow_dispatch: + inputs: + as_of: + description: "Daily representative fixture as_of" + required: false + default: "2026-06-20" + frozen_generated_at: + description: "Harness-only deterministic generated_at" + required: false + default: "2026-07-15T00:00:00Z" + +permissions: + contents: read + +jobs: + build-and-verify: + runs-on: ubuntu-latest + env: + INPUT_AS_OF: ${{ inputs.as_of || '2026-06-20' }} + FROZEN_GENERATED_AT: ${{ inputs.frozen_generated_at || '2026-07-15T00:00:00Z' }} + steps: + - name: Checkout repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: "3.11" + - name: Set up uv + uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6 + with: + version: "0.11.19" + - name: Sync locked test environment + run: uv sync --locked --extra test + - name: Build representative previews + id: build + env: + BASE_SHA: ${{ github.sha }} + BUILD_ROOT: ${{ runner.temp }}/qar-d3 + run: | + mkdir -p "$BUILD_ROOT" + uv run --no-sync python scripts/d3_build_daily_preview.py \ + --as-of "$INPUT_AS_OF" \ + --political-events examples/political_events.example.csv \ + --political-watchlist examples/political_watchlist.example.csv \ + --frozen-generated-at "$FROZEN_GENERATED_AT" \ + --base-sha "$BASE_SHA" \ + --uv-version "$(uv --version)" \ + --lock-path uv.lock \ + --temp-root "$BUILD_ROOT" \ + --evidence-path "$BUILD_ROOT/build-evidence.json" \ + --workspace-path-file "$BUILD_ROOT/workspace-path.txt" + echo "workspace=$(cat "$BUILD_ROOT/workspace-path.txt")" >> "$GITHUB_OUTPUT" + echo "evidence=$BUILD_ROOT/build-evidence.json" >> "$GITHUB_OUTPUT" + - name: Upload exact preview members + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: qar-d3-preview-bundle + path: ${{ steps.build.outputs.workspace }}/* + if-no-files-found: error + - name: Upload build evidence + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: qar-d3-build-evidence + path: ${{ steps.build.outputs.evidence }} + if-no-files-found: error + - name: Download preview members + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + with: + name: qar-d3-preview-bundle + path: ${{ runner.temp }}/qar-d3-download/bundle + - name: Download build evidence + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + with: + name: qar-d3-build-evidence + path: ${{ runner.temp }}/qar-d3-download/evidence + - name: Verify downloaded artifact and full evidence binding + run: | + uv run --no-sync python scripts/d3_verify_daily_preview.py \ + --workspace "${{ runner.temp }}/qar-d3-download/bundle" \ + --evidence-path "${{ runner.temp }}/qar-d3-download/evidence/build-evidence.json" \ + --base-sha "${{ github.sha }}" \ + --uv-version "$(uv --version)" \ + --lock-path uv.lock \ + --as-of "$INPUT_AS_OF" \ + --political-events examples/political_events.example.csv \ + --political-watchlist examples/political_watchlist.example.csv \ + --frozen-generated-at "$FROZEN_GENERATED_AT" + - name: Cleanup private preview workspaces + if: always() + run: rm -rf "${{ runner.temp }}/qar-d3" diff --git a/scripts/d3_build_daily_preview.py b/scripts/d3_build_daily_preview.py new file mode 100644 index 0000000..79e759b --- /dev/null +++ b/scripts/d3_build_daily_preview.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +"""Workflow-only D3 builder; uv discovery is never imported by ordinary tests.""" +from __future__ import annotations + +import argparse +import hashlib +import importlib.metadata +import json +import os +import platform +import re +import subprocess +import tempfile +from pathlib import Path +from unittest.mock import patch + +from d3_evidence import DEPENDENCY_INVENTORY, locked_environment_evidence +from quant_advisor_research.advisory_report import build_advisory_report +from quant_advisor_research.preview_bundle import read_preview_bundle +from quant_advisor_research.preview_workspace import build_preview_workspace + +EVIDENCE_VERSION = "qar.d3.build_evidence.v2" +FIXED_FILES = ("manifest.json", "report.html", "report.json") + + +def _file_hashes(workspace: Path) -> dict[str, dict[str, object]]: + return {name: {"sha256": hashlib.sha256((workspace / name).read_bytes()).hexdigest(), "size": (workspace / name).stat().st_size} for name in FIXED_FILES} + + +def _distributions() -> list[str]: + return sorted({f"{name}=={dist.version}" for dist in importlib.metadata.distributions() if (name := dist.metadata.get("Name"))}) + + +def _report(as_of: str, events: Path, watchlist: Path, generated_at: str) -> dict[str, object]: + with patch("quant_advisor_research.advisory_report.utc_now_iso", return_value=generated_at): + return build_advisory_report(as_of=as_of, cadence="daily", political_events_path=events, political_watchlist_path=watchlist) + + +def build_evidence(args: argparse.Namespace) -> Path: + if any(not Path(item).is_file() for item in DEPENDENCY_INVENTORY): + raise RuntimeError("dependency_inventory_invalid") + lock_path = Path(args.lock_path) + environment = locked_environment_evidence( + lock_sha256=hashlib.sha256(lock_path.read_bytes()).hexdigest(), + uv_version=args.uv_version, + python_version=platform.python_version(), + distributions=_distributions(), + ) + parent = Path(tempfile.mkdtemp(prefix="qar-d3-parent-", dir=args.temp_root)) + succeeded = False + try: + first = build_preview_workspace(_report(args.as_of, Path(args.political_events), Path(args.political_watchlist), args.frozen_generated_at), parent) + second = build_preview_workspace(_report(args.as_of, Path(args.political_events), Path(args.political_watchlist), args.frozen_generated_at), parent) + read_preview_bundle(first); read_preview_bundle(second) + first_files, second_files = _file_hashes(first), _file_hashes(second) + if first_files != second_files or any((first / n).read_bytes() != (second / n).read_bytes() for n in FIXED_FILES): + raise RuntimeError("repeat_build_not_equal") + manifest = json.loads((first / "manifest.json").read_text(encoding="utf-8")) + source = {"schema_version": "5", "contract_version": "model_recommendations.v5", "cadence": "daily", "as_of": args.as_of, "generated_at": args.frozen_generated_at} + if manifest.get("source") != source or manifest.get("bundle_contract") != "qar.preview_bundle.v1": + raise RuntimeError("source_contract_mismatch") + if not re.fullmatch(r"[0-9a-f]{40}", args.base_sha): + raise RuntimeError("base_sha_invalid") + evidence = { + "evidence_version": EVIDENCE_VERSION, "base_sha": args.base_sha, + "source": {"fixture_paths": [Path(args.political_events).as_posix(), Path(args.political_watchlist).as_posix()], "provenance": "repository_representative_fixture"}, + "deterministic_clock": {"frozen_generated_at": args.frozen_generated_at, "producer_invocations": 2}, + "workflow_dependency_inventory": DEPENDENCY_INVENTORY, + "locked_environment": environment, + "bundle": {"contract": manifest["bundle_contract"], "source": source, "files": first_files}, + "repeat_build": {"independent_invocations": 2, "bytes_equal": True, "files": second_files}, + } + evidence_path = Path(args.evidence_path); evidence_path.parent.mkdir(parents=True, exist_ok=True) + evidence_path.write_text(json.dumps(evidence, sort_keys=True, separators=(",", ":")) + "\n", encoding="utf-8") + Path(args.workspace_path_file).write_text(str(first) + "\n", encoding="utf-8") + succeeded = True + print(json.dumps({"workspace": str(first), "evidence": str(evidence_path), "base_sha": args.base_sha}, sort_keys=True)) + return first + finally: + if not succeeded: + try: parent.rmdir() + except OSError: pass + + +def parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser(); p.add_argument("--as-of", required=True); p.add_argument("--political-events", required=True); p.add_argument("--political-watchlist", required=True); p.add_argument("--frozen-generated-at", required=True); p.add_argument("--base-sha", required=True); p.add_argument("--uv-version", required=True); p.add_argument("--lock-path", required=True); p.add_argument("--temp-root", required=True); p.add_argument("--evidence-path", required=True); p.add_argument("--workspace-path-file", required=True); return p.parse_args() + + +if __name__ == "__main__": build_evidence(parse_args()) diff --git a/scripts/d3_evidence.py b/scripts/d3_evidence.py new file mode 100644 index 0000000..3a69ac7 --- /dev/null +++ b/scripts/d3_evidence.py @@ -0,0 +1,61 @@ +"""Tool-agnostic D3 evidence contracts; no uv/process/filesystem discovery.""" +from __future__ import annotations + +import hashlib +import re +from collections.abc import Iterable, Mapping + +DIST_RE = re.compile(r"^[A-Za-z0-9_.-]+==[^\s=]+$") + +DEPENDENCY_INVENTORY = [ + ".github/workflows/qar_d3_daily_preview_artifact.yml", "pyproject.toml", "uv.lock", + "scripts/d3_build_daily_preview.py", "scripts/d3_verify_daily_preview.py", "scripts/d3_evidence.py", + "src/quant_advisor_research/advisory_report.py", "src/quant_advisor_research/artifact_integrity.py", + "src/quant_advisor_research/artifacts.py", "src/quant_advisor_research/contracts.py", + "src/quant_advisor_research/csv_utils.py", "src/quant_advisor_research/period_contract.py", + "src/quant_advisor_research/preview_bundle.py", "src/quant_advisor_research/preview_workspace.py", + "src/quant_advisor_research/time_contract.py", "tests/test_d3_tool_agnostic.py", + "examples/political_events.example.csv", "examples/political_watchlist.example.csv", +] + + +class EvidenceContractError(ValueError): + def __init__(self, code: str) -> None: + self.code = code + super().__init__(code) + + +def canonical_distribution_snapshot(values: Iterable[str]) -> tuple[list[str], str]: + try: + snapshot = list(values) + except (TypeError, ValueError): + raise EvidenceContractError("distribution_snapshot_invalid") from None + if any(type(value) is not str or not DIST_RE.fullmatch(value) for value in snapshot): + raise EvidenceContractError("distribution_snapshot_invalid") + snapshot.sort(key=str.casefold) + if len(snapshot) != len(set(snapshot)): + raise EvidenceContractError("distribution_snapshot_invalid") + digest = hashlib.sha256("\n".join(snapshot).encode("utf-8")).hexdigest() + return snapshot, digest + + +def locked_environment_evidence(*, lock_sha256: str, uv_version: str, python_version: str, distributions: Iterable[str]) -> dict[str, object]: + if type(lock_sha256) is not str or not re.fullmatch(r"[0-9a-f]{64}", lock_sha256): + raise EvidenceContractError("lock_digest_invalid") + if type(uv_version) is not str or not uv_version.startswith("uv "): + raise EvidenceContractError("uv_version_invalid") + if type(python_version) is not str or not re.fullmatch(r"3\.11(?:\.\d+)?", python_version): + raise EvidenceContractError("python_version_invalid") + snapshot, snapshot_sha256 = canonical_distribution_snapshot(distributions) + return { + "lock_sha256": lock_sha256, + "uv_version": uv_version, + "python_version": python_version, + "installed_distributions": snapshot, + "installed_distributions_sha256": snapshot_sha256, + } + + +def require_exact_locked_environment(value: object, expected: Mapping[str, object]) -> None: + if not isinstance(value, Mapping) or dict(value) != dict(expected): + raise EvidenceContractError("locked_environment_mismatch") diff --git a/scripts/d3_verify_daily_preview.py b/scripts/d3_verify_daily_preview.py new file mode 100644 index 0000000..2a073cc --- /dev/null +++ b/scripts/d3_verify_daily_preview.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +"""Workflow-only downloaded artifact verifier with exact environment binding.""" +from __future__ import annotations + +import argparse +import hashlib +import importlib.metadata +import json +import platform +from pathlib import Path + +from d3_evidence import DEPENDENCY_INVENTORY, locked_environment_evidence +from quant_advisor_research.preview_bundle import read_preview_bundle + +EVIDENCE_VERSION = "qar.d3.build_evidence.v2" +FIXED_FILES = ("manifest.json", "report.html", "report.json") +EXPECTED_EVIDENCE_KEYS = frozenset({ + "evidence_version", "base_sha", "source", "deterministic_clock", "workflow_dependency_inventory", + "locked_environment", "bundle", "repeat_build", +}) + + +def _file_hashes(workspace: Path) -> dict[str, dict[str, object]]: + return {name: {"sha256": hashlib.sha256((workspace / name).read_bytes()).hexdigest(), "size": (workspace / name).stat().st_size} for name in FIXED_FILES} + + +def _distributions() -> list[str]: + return sorted({f"{name}=={dist.version}" for dist in importlib.metadata.distributions() if (name := dist.metadata.get("Name"))}) + + +def verify(args: argparse.Namespace) -> None: + evidence = json.loads(Path(args.evidence_path).read_text(encoding="utf-8")) + if not isinstance(evidence, dict) or set(evidence) != EXPECTED_EVIDENCE_KEYS: + raise ValueError("build_evidence_shape_invalid") + if evidence.get("workflow_dependency_inventory") != DEPENDENCY_INVENTORY: + raise ValueError("dependency_inventory_mismatch") + expected_environment = locked_environment_evidence(lock_sha256=hashlib.sha256(Path(args.lock_path).read_bytes()).hexdigest(), uv_version=args.uv_version, python_version=platform.python_version(), distributions=_distributions()) + expected_source = {"schema_version": "5", "contract_version": "model_recommendations.v5", "cadence": "daily", "as_of": args.as_of, "generated_at": args.frozen_generated_at} + workspace = Path(args.workspace); read_preview_bundle(workspace) + if {p.name for p in workspace.iterdir()} != set(FIXED_FILES): raise ValueError("file_set_invalid") + manifest = json.loads((workspace / "manifest.json").read_text(encoding="utf-8")) + if manifest.get("source") != expected_source or manifest.get("bundle_contract") != "qar.preview_bundle.v1": raise ValueError("source_contract_mismatch") + files = _file_hashes(workspace) + expected = {"evidence_version": EVIDENCE_VERSION, "base_sha": args.base_sha, "source": {"fixture_paths": [Path(args.political_events).as_posix(), Path(args.political_watchlist).as_posix()], "provenance": "repository_representative_fixture"}, "deterministic_clock": {"frozen_generated_at": args.frozen_generated_at, "producer_invocations": 2}, "workflow_dependency_inventory": DEPENDENCY_INVENTORY, "locked_environment": expected_environment, "bundle": {"contract": "qar.preview_bundle.v1", "source": expected_source, "files": files}, "repeat_build": {"independent_invocations": 2, "bytes_equal": True, "files": files}} + if evidence != expected: raise ValueError("build_evidence_mismatch") + print(json.dumps({"status": "passed", "evidence_binding": "exact_full_payload", "base_sha": args.base_sha, "files": files}, sort_keys=True)) + + +def parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser(); p.add_argument("--workspace", required=True); p.add_argument("--evidence-path", required=True); p.add_argument("--base-sha", required=True); p.add_argument("--uv-version", required=True); p.add_argument("--lock-path", required=True); p.add_argument("--as-of", required=True); p.add_argument("--political-events", required=True); p.add_argument("--political-watchlist", required=True); p.add_argument("--frozen-generated-at", required=True); return p.parse_args() + + +if __name__ == "__main__": verify(parse_args()) diff --git a/tests/test_d3_tool_agnostic.py b/tests/test_d3_tool_agnostic.py new file mode 100644 index 0000000..3658285 --- /dev/null +++ b/tests/test_d3_tool_agnostic.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parents[1] / "scripts")) +from d3_evidence import EvidenceContractError, canonical_distribution_snapshot, locked_environment_evidence, require_exact_locked_environment + + +def test_environment_contract_is_pure_and_deterministic(): + values = ["pytest==9.1.1", "quant-advisor-research==0.1.3"] + evidence = locked_environment_evidence( + lock_sha256="a" * 64, + uv_version="uv 0.11.19", + python_version="3.11.9", + distributions=reversed(values), + ) + assert evidence["installed_distributions"] == values + require_exact_locked_environment(dict(evidence), evidence) + + +@pytest.mark.parametrize("mutation", [ + lambda value: {**value, "unknown": True}, + lambda value: {**value, "lock_sha256": "b" * 64}, + lambda value: {**value, "installed_distributions": ["pytest==9.1.1", "pytest==9.1.1"]}, +]) +def test_environment_tamper_fails_closed(mutation): + evidence = locked_environment_evidence( + lock_sha256="a" * 64, + uv_version="uv 0.11.19", + python_version="3.11.9", + distributions=["pytest==9.1.1"], + ) + with pytest.raises(EvidenceContractError, match="locked_environment_mismatch"): + require_exact_locked_environment(mutation(evidence), evidence) + + +def test_invalid_toolchain_values_are_rejected_without_uv_lookup(): + with pytest.raises(EvidenceContractError, match="uv_version_invalid"): + locked_environment_evidence(lock_sha256="a" * 64, uv_version="latest", python_version="3.11.9", distributions=[]) + with pytest.raises(EvidenceContractError, match="python_version_invalid"): + locked_environment_evidence(lock_sha256="a" * 64, uv_version="uv 0.11.19", python_version="3.12.0", distributions=[]) + + +def test_distribution_snapshot_is_sorted_and_hashed(): + snapshot, digest = canonical_distribution_snapshot(["Zed==1.0", "alpha==2.0"]) + assert snapshot == ["alpha==2.0", "Zed==1.0"] + assert len(digest) == 64 + + +def test_d3_workflow_is_triggered_and_uses_locked_tooling(): + workflow = (Path(__file__).parents[1] / ".github/workflows/qar_d3_daily_preview_artifact.yml").read_text() + assert "pull_request:" in workflow + assert "workflow_dispatch:" in workflow + assert "uv sync --locked --extra test" in workflow + assert "uv run --no-sync python" in workflow + assert "pip install" not in workflow + assert "${{ inputs.as_of || '2026-06-20' }}" in workflow + assert "${{ inputs.frozen_generated_at || '2026-07-15T00:00:00Z' }}" in workflow + for action in ( + "actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10", + "actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1", + "astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e", + "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a", + "actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131", + ): + assert action in workflow From 01a6e55fe97e40f6b89ade756e471ae62d2d9f9c Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Wed, 15 Jul 2026 10:16:28 +0800 Subject: [PATCH 2/2] fix: bind D3 artifact evidence and explicit uploads Co-Authored-By: Codex --- .../qar_d3_daily_preview_artifact.yml | 16 +++++-- scripts/d3_build_daily_preview.py | 31 ++++++++---- scripts/d3_evidence.py | 47 +++++++++++++++++-- scripts/d3_verify_daily_preview.py | 15 ++++-- tests/test_d3_tool_agnostic.py | 24 ++++++++-- 5 files changed, 107 insertions(+), 26 deletions(-) diff --git a/.github/workflows/qar_d3_daily_preview_artifact.yml b/.github/workflows/qar_d3_daily_preview_artifact.yml index 9a45413..417456c 100644 --- a/.github/workflows/qar_d3_daily_preview_artifact.yml +++ b/.github/workflows/qar_d3_daily_preview_artifact.yml @@ -48,7 +48,9 @@ jobs: - name: Build representative previews id: build env: - BASE_SHA: ${{ github.sha }} + # PRs bind the real target/source commits; dispatch binds both to the documented workflow SHA. + BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.sha }} + HEAD_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} BUILD_ROOT: ${{ runner.temp }}/qar-d3 run: | mkdir -p "$BUILD_ROOT" @@ -58,8 +60,10 @@ jobs: --political-watchlist examples/political_watchlist.example.csv \ --frozen-generated-at "$FROZEN_GENERATED_AT" \ --base-sha "$BASE_SHA" \ + --head-sha "$HEAD_SHA" \ --uv-version "$(uv --version)" \ --lock-path uv.lock \ + --repo-root "$GITHUB_WORKSPACE" \ --temp-root "$BUILD_ROOT" \ --evidence-path "$BUILD_ROOT/build-evidence.json" \ --workspace-path-file "$BUILD_ROOT/workspace-path.txt" @@ -69,7 +73,11 @@ jobs: uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: qar-d3-preview-bundle - path: ${{ steps.build.outputs.workspace }}/* + path: | + ${{ steps.build.outputs.workspace }}/report.json + ${{ steps.build.outputs.workspace }}/report.html + ${{ steps.build.outputs.workspace }}/manifest.json + include-hidden-files: true if-no-files-found: error - name: Upload build evidence uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 @@ -92,9 +100,11 @@ jobs: uv run --no-sync python scripts/d3_verify_daily_preview.py \ --workspace "${{ runner.temp }}/qar-d3-download/bundle" \ --evidence-path "${{ runner.temp }}/qar-d3-download/evidence/build-evidence.json" \ - --base-sha "${{ github.sha }}" \ + --base-sha "${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.sha }}" \ + --head-sha "${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}" \ --uv-version "$(uv --version)" \ --lock-path uv.lock \ + --repo-root "$GITHUB_WORKSPACE" \ --as-of "$INPUT_AS_OF" \ --political-events examples/political_events.example.csv \ --political-watchlist examples/political_watchlist.example.csv \ diff --git a/scripts/d3_build_daily_preview.py b/scripts/d3_build_daily_preview.py index 79e759b..c3fd5d6 100644 --- a/scripts/d3_build_daily_preview.py +++ b/scripts/d3_build_daily_preview.py @@ -9,22 +9,30 @@ import os import platform import re -import subprocess +import stat import tempfile from pathlib import Path from unittest.mock import patch -from d3_evidence import DEPENDENCY_INVENTORY, locked_environment_evidence +from d3_evidence import DEPENDENCY_INVENTORY, EvidenceContractError, locked_environment_evidence, repository_file_hashes from quant_advisor_research.advisory_report import build_advisory_report from quant_advisor_research.preview_bundle import read_preview_bundle from quant_advisor_research.preview_workspace import build_preview_workspace -EVIDENCE_VERSION = "qar.d3.build_evidence.v2" +EVIDENCE_VERSION = "qar.d3.build_evidence.v3" FIXED_FILES = ("manifest.json", "report.html", "report.json") def _file_hashes(workspace: Path) -> dict[str, dict[str, object]]: - return {name: {"sha256": hashlib.sha256((workspace / name).read_bytes()).hexdigest(), "size": (workspace / name).stat().st_size} for name in FIXED_FILES} + result: dict[str, dict[str, object]] = {} + for name in FIXED_FILES: + path = workspace / name + info = path.lstat() + if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1: + raise RuntimeError("bundle_member_invalid") + content = path.read_bytes() + result[name] = {"sha256": hashlib.sha256(content).hexdigest(), "size": len(content)} + return result def _distributions() -> list[str]: @@ -37,8 +45,10 @@ def _report(as_of: str, events: Path, watchlist: Path, generated_at: str) -> dic def build_evidence(args: argparse.Namespace) -> Path: - if any(not Path(item).is_file() for item in DEPENDENCY_INVENTORY): - raise RuntimeError("dependency_inventory_invalid") + try: + dependency_files = repository_file_hashes(args.repo_root, DEPENDENCY_INVENTORY) + except EvidenceContractError as exc: + raise RuntimeError(exc.code) from None lock_path = Path(args.lock_path) environment = locked_environment_evidence( lock_sha256=hashlib.sha256(lock_path.read_bytes()).hexdigest(), @@ -59,13 +69,14 @@ def build_evidence(args: argparse.Namespace) -> Path: source = {"schema_version": "5", "contract_version": "model_recommendations.v5", "cadence": "daily", "as_of": args.as_of, "generated_at": args.frozen_generated_at} if manifest.get("source") != source or manifest.get("bundle_contract") != "qar.preview_bundle.v1": raise RuntimeError("source_contract_mismatch") - if not re.fullmatch(r"[0-9a-f]{40}", args.base_sha): - raise RuntimeError("base_sha_invalid") + if not re.fullmatch(r"[0-9a-f]{40}", args.base_sha) or not re.fullmatch(r"[0-9a-f]{40}", args.head_sha): + raise RuntimeError("provenance_sha_invalid") evidence = { - "evidence_version": EVIDENCE_VERSION, "base_sha": args.base_sha, + "evidence_version": EVIDENCE_VERSION, "base_sha": args.base_sha, "head_sha": args.head_sha, "source": {"fixture_paths": [Path(args.political_events).as_posix(), Path(args.political_watchlist).as_posix()], "provenance": "repository_representative_fixture"}, "deterministic_clock": {"frozen_generated_at": args.frozen_generated_at, "producer_invocations": 2}, "workflow_dependency_inventory": DEPENDENCY_INVENTORY, + "dependency_files": dependency_files, "locked_environment": environment, "bundle": {"contract": manifest["bundle_contract"], "source": source, "files": first_files}, "repeat_build": {"independent_invocations": 2, "bytes_equal": True, "files": second_files}, @@ -83,7 +94,7 @@ def build_evidence(args: argparse.Namespace) -> Path: def parse_args() -> argparse.Namespace: - p = argparse.ArgumentParser(); p.add_argument("--as-of", required=True); p.add_argument("--political-events", required=True); p.add_argument("--political-watchlist", required=True); p.add_argument("--frozen-generated-at", required=True); p.add_argument("--base-sha", required=True); p.add_argument("--uv-version", required=True); p.add_argument("--lock-path", required=True); p.add_argument("--temp-root", required=True); p.add_argument("--evidence-path", required=True); p.add_argument("--workspace-path-file", required=True); return p.parse_args() + p = argparse.ArgumentParser(); p.add_argument("--as-of", required=True); p.add_argument("--political-events", required=True); p.add_argument("--political-watchlist", required=True); p.add_argument("--frozen-generated-at", required=True); p.add_argument("--base-sha", required=True); p.add_argument("--head-sha", required=True); p.add_argument("--uv-version", required=True); p.add_argument("--lock-path", required=True); p.add_argument("--repo-root", required=True); p.add_argument("--temp-root", required=True); p.add_argument("--evidence-path", required=True); p.add_argument("--workspace-path-file", required=True); return p.parse_args() if __name__ == "__main__": build_evidence(parse_args()) diff --git a/scripts/d3_evidence.py b/scripts/d3_evidence.py index 3a69ac7..d34c0c6 100644 --- a/scripts/d3_evidence.py +++ b/scripts/d3_evidence.py @@ -4,8 +4,10 @@ import hashlib import re from collections.abc import Iterable, Mapping +from pathlib import Path +import stat -DIST_RE = re.compile(r"^[A-Za-z0-9_.-]+==[^\s=]+$") +DIST_RE = re.compile(r"^([A-Za-z0-9_.-]+)==([^\s=]+)$") DEPENDENCY_INVENTORY = [ ".github/workflows/qar_d3_daily_preview_artifact.yml", "pyproject.toml", "uv.lock", @@ -30,10 +32,17 @@ def canonical_distribution_snapshot(values: Iterable[str]) -> tuple[list[str], s snapshot = list(values) except (TypeError, ValueError): raise EvidenceContractError("distribution_snapshot_invalid") from None - if any(type(value) is not str or not DIST_RE.fullmatch(value) for value in snapshot): - raise EvidenceContractError("distribution_snapshot_invalid") - snapshot.sort(key=str.casefold) - if len(snapshot) != len(set(snapshot)): + normalized: dict[str, str] = {} + for value in snapshot: + if type(value) is not str or not (match := DIST_RE.fullmatch(value)): + raise EvidenceContractError("distribution_snapshot_invalid") + name = re.sub(r"[-_.]+", "-", match.group(1)).lower() + normalized_value = f"{name}=={match.group(2)}" + if name in normalized and normalized[name] != normalized_value: + raise EvidenceContractError("distribution_snapshot_conflict") + normalized[name] = normalized_value + snapshot = sorted(normalized.values()) + if len(snapshot) != len(normalized): raise EvidenceContractError("distribution_snapshot_invalid") digest = hashlib.sha256("\n".join(snapshot).encode("utf-8")).hexdigest() return snapshot, digest @@ -59,3 +68,31 @@ def locked_environment_evidence(*, lock_sha256: str, uv_version: str, python_ver def require_exact_locked_environment(value: object, expected: Mapping[str, object]) -> None: if not isinstance(value, Mapping) or dict(value) != dict(expected): raise EvidenceContractError("locked_environment_mismatch") + + +def repository_file_hashes(repo_root: str | Path, relative_paths: Iterable[str]) -> dict[str, str]: + root = Path(repo_root) + try: + root_info = root.stat() + if not stat.S_ISDIR(root_info.st_mode): + raise EvidenceContractError("dependency_root_invalid") + result: dict[str, str] = {} + for raw_path in relative_paths: + if type(raw_path) is not str or not raw_path or Path(raw_path).is_absolute(): + raise EvidenceContractError("dependency_path_invalid") + relative = Path(raw_path) + if ".." in relative.parts or raw_path in result: + raise EvidenceContractError("dependency_path_invalid") + target = root / relative + info = target.lstat() + if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1: + raise EvidenceContractError("dependency_file_invalid") + resolved = target.resolve(strict=True) + if resolved != target.absolute(): + raise EvidenceContractError("dependency_file_invalid") + result[raw_path] = hashlib.sha256(target.read_bytes()).hexdigest() + return dict(sorted(result.items())) + except EvidenceContractError: + raise + except (OSError, TypeError, ValueError, UnicodeError): + raise EvidenceContractError("dependency_file_invalid") from None diff --git a/scripts/d3_verify_daily_preview.py b/scripts/d3_verify_daily_preview.py index 2a073cc..0739b0a 100644 --- a/scripts/d3_verify_daily_preview.py +++ b/scripts/d3_verify_daily_preview.py @@ -9,13 +9,14 @@ import platform from pathlib import Path -from d3_evidence import DEPENDENCY_INVENTORY, locked_environment_evidence +from d3_evidence import DEPENDENCY_INVENTORY, EvidenceContractError, locked_environment_evidence, repository_file_hashes from quant_advisor_research.preview_bundle import read_preview_bundle -EVIDENCE_VERSION = "qar.d3.build_evidence.v2" +EVIDENCE_VERSION = "qar.d3.build_evidence.v3" FIXED_FILES = ("manifest.json", "report.html", "report.json") EXPECTED_EVIDENCE_KEYS = frozenset({ - "evidence_version", "base_sha", "source", "deterministic_clock", "workflow_dependency_inventory", + "evidence_version", "base_sha", "head_sha", "source", "deterministic_clock", "workflow_dependency_inventory", + "dependency_files", "locked_environment", "bundle", "repeat_build", }) @@ -34,6 +35,10 @@ def verify(args: argparse.Namespace) -> None: raise ValueError("build_evidence_shape_invalid") if evidence.get("workflow_dependency_inventory") != DEPENDENCY_INVENTORY: raise ValueError("dependency_inventory_mismatch") + try: + dependency_files = repository_file_hashes(args.repo_root, DEPENDENCY_INVENTORY) + except EvidenceContractError as exc: + raise ValueError(exc.code) from None expected_environment = locked_environment_evidence(lock_sha256=hashlib.sha256(Path(args.lock_path).read_bytes()).hexdigest(), uv_version=args.uv_version, python_version=platform.python_version(), distributions=_distributions()) expected_source = {"schema_version": "5", "contract_version": "model_recommendations.v5", "cadence": "daily", "as_of": args.as_of, "generated_at": args.frozen_generated_at} workspace = Path(args.workspace); read_preview_bundle(workspace) @@ -41,13 +46,13 @@ def verify(args: argparse.Namespace) -> None: manifest = json.loads((workspace / "manifest.json").read_text(encoding="utf-8")) if manifest.get("source") != expected_source or manifest.get("bundle_contract") != "qar.preview_bundle.v1": raise ValueError("source_contract_mismatch") files = _file_hashes(workspace) - expected = {"evidence_version": EVIDENCE_VERSION, "base_sha": args.base_sha, "source": {"fixture_paths": [Path(args.political_events).as_posix(), Path(args.political_watchlist).as_posix()], "provenance": "repository_representative_fixture"}, "deterministic_clock": {"frozen_generated_at": args.frozen_generated_at, "producer_invocations": 2}, "workflow_dependency_inventory": DEPENDENCY_INVENTORY, "locked_environment": expected_environment, "bundle": {"contract": "qar.preview_bundle.v1", "source": expected_source, "files": files}, "repeat_build": {"independent_invocations": 2, "bytes_equal": True, "files": files}} + expected = {"evidence_version": EVIDENCE_VERSION, "base_sha": args.base_sha, "head_sha": args.head_sha, "source": {"fixture_paths": [Path(args.political_events).as_posix(), Path(args.political_watchlist).as_posix()], "provenance": "repository_representative_fixture"}, "deterministic_clock": {"frozen_generated_at": args.frozen_generated_at, "producer_invocations": 2}, "workflow_dependency_inventory": DEPENDENCY_INVENTORY, "dependency_files": dependency_files, "locked_environment": expected_environment, "bundle": {"contract": "qar.preview_bundle.v1", "source": expected_source, "files": files}, "repeat_build": {"independent_invocations": 2, "bytes_equal": True, "files": files}} if evidence != expected: raise ValueError("build_evidence_mismatch") print(json.dumps({"status": "passed", "evidence_binding": "exact_full_payload", "base_sha": args.base_sha, "files": files}, sort_keys=True)) def parse_args() -> argparse.Namespace: - p = argparse.ArgumentParser(); p.add_argument("--workspace", required=True); p.add_argument("--evidence-path", required=True); p.add_argument("--base-sha", required=True); p.add_argument("--uv-version", required=True); p.add_argument("--lock-path", required=True); p.add_argument("--as-of", required=True); p.add_argument("--political-events", required=True); p.add_argument("--political-watchlist", required=True); p.add_argument("--frozen-generated-at", required=True); return p.parse_args() + p = argparse.ArgumentParser(); p.add_argument("--workspace", required=True); p.add_argument("--evidence-path", required=True); p.add_argument("--base-sha", required=True); p.add_argument("--head-sha", required=True); p.add_argument("--uv-version", required=True); p.add_argument("--lock-path", required=True); p.add_argument("--repo-root", required=True); p.add_argument("--as-of", required=True); p.add_argument("--political-events", required=True); p.add_argument("--political-watchlist", required=True); p.add_argument("--frozen-generated-at", required=True); return p.parse_args() if __name__ == "__main__": verify(parse_args()) diff --git a/tests/test_d3_tool_agnostic.py b/tests/test_d3_tool_agnostic.py index 3658285..6ee7183 100644 --- a/tests/test_d3_tool_agnostic.py +++ b/tests/test_d3_tool_agnostic.py @@ -6,7 +6,7 @@ import pytest sys.path.insert(0, str(Path(__file__).parents[1] / "scripts")) -from d3_evidence import EvidenceContractError, canonical_distribution_snapshot, locked_environment_evidence, require_exact_locked_environment +from d3_evidence import EvidenceContractError, canonical_distribution_snapshot, locked_environment_evidence, require_exact_locked_environment, repository_file_hashes def test_environment_contract_is_pure_and_deterministic(): @@ -45,11 +45,26 @@ def test_invalid_toolchain_values_are_rejected_without_uv_lookup(): def test_distribution_snapshot_is_sorted_and_hashed(): - snapshot, digest = canonical_distribution_snapshot(["Zed==1.0", "alpha==2.0"]) - assert snapshot == ["alpha==2.0", "Zed==1.0"] + snapshot, digest = canonical_distribution_snapshot(["Zed==1.0", "alpha==2.0", "ALPHA==2.0"]) + assert snapshot == ["alpha==2.0", "zed==1.0"] assert len(digest) == 64 +def test_distribution_snapshot_rejects_pep503_name_version_conflict(): + with pytest.raises(EvidenceContractError, match="distribution_snapshot_conflict"): + canonical_distribution_snapshot(["alpha_.==1.0", "ALPHA-==2.0"]) + + +def test_repository_file_hashes_is_canonical_and_rejects_unsafe_members(tmp_path): + (tmp_path / "a.txt").write_text("a") + assert repository_file_hashes(tmp_path, ["a.txt"]) == repository_file_hashes(tmp_path, ["a.txt"]) + with pytest.raises(EvidenceContractError, match="dependency_path_invalid"): + repository_file_hashes(tmp_path, ["../a.txt"]) + (tmp_path / "link.txt").symlink_to(tmp_path / "a.txt") + with pytest.raises(EvidenceContractError, match="dependency_file_invalid"): + repository_file_hashes(tmp_path, ["link.txt"]) + + def test_d3_workflow_is_triggered_and_uses_locked_tooling(): workflow = (Path(__file__).parents[1] / ".github/workflows/qar_d3_daily_preview_artifact.yml").read_text() assert "pull_request:" in workflow @@ -57,6 +72,9 @@ def test_d3_workflow_is_triggered_and_uses_locked_tooling(): assert "uv sync --locked --extra test" in workflow assert "uv run --no-sync python" in workflow assert "pip install" not in workflow + assert "path: ${{ steps.build.outputs.workspace }}/*" not in workflow + assert "--base-sha" in workflow and "--head-sha" in workflow + assert "--repo-root \"$GITHUB_WORKSPACE\"" in workflow assert "${{ inputs.as_of || '2026-06-20' }}" in workflow assert "${{ inputs.frozen_generated_at || '2026-07-15T00:00:00Z' }}" in workflow for action in (