From 361b370cab8ac3d921149453d08410cdbd2ca339 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Wed, 15 Jul 2026 09:42:02 +0800 Subject: [PATCH 1/2] feat: add daily preview artifact consumer Co-Authored-By: Codex --- .../qar_d3_daily_preview_artifact.yml | 105 ++++++++++++ scripts/d3_build_daily_preview.py | 149 ++++++++++++++++++ scripts/d3_verify_daily_preview.py | 112 +++++++++++++ tests/test_d3_preview_artifact.py | 95 +++++++++++ 4 files changed, 461 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_verify_daily_preview.py create mode 100644 tests/test_d3_preview_artifact.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..08ff522 --- /dev/null +++ b/.github/workflows/qar_d3_daily_preview_artifact.yml @@ -0,0 +1,105 @@ +name: QAR D3 Daily Preview Artifact + +on: + workflow_dispatch: + inputs: + as_of: + description: "Representative daily report date" + required: true + default: "2026-06-20" + type: string + frozen_generated_at: + description: "Harness-only deterministic producer timestamp" + required: true + default: "2026-07-15T00:00:00Z" + type: string + +permissions: + contents: read + +jobs: + build-and-verify: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout repository # v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 + + - name: Set up Python # v6 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 + with: + python-version: "3.11" + + - name: Install package + run: python -m pip install -e ".[test]" + + - name: Build two frozen-clock workspaces + id: build + env: + INPUT_AS_OF: ${{ inputs.as_of }} + FROZEN_GENERATED_AT: ${{ inputs.frozen_generated_at }} + run: | + set -euo pipefail + EVIDENCE_DIR="$RUNNER_TEMP/qar-d3-evidence" + mkdir -p "$EVIDENCE_DIR" + 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 "$GITHUB_SHA" \ + --temp-root "$RUNNER_TEMP" \ + --evidence-path "$EVIDENCE_DIR/build_evidence.json" \ + --workspace-path-file "$RUNNER_TEMP/qar-d3-workspace.txt" + echo "workspace=$(cat "$RUNNER_TEMP/qar-d3-workspace.txt")" >> "$GITHUB_OUTPUT" + echo "evidence=$EVIDENCE_DIR/build_evidence.json" >> "$GITHUB_OUTPUT" + + - name: Upload exact three-file preview artifact # v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: qar-daily-preview + path: ${{ steps.build.outputs.workspace }}/* + if-no-files-found: error + + - name: Upload build evidence # v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: qar-daily-preview-build-evidence + path: ${{ steps.build.outputs.evidence }} + if-no-files-found: error + + - name: Download preview artifact # v7 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 + with: + name: qar-daily-preview + path: ${{ runner.temp }}/qar-d3-download + + - name: Download build evidence # v7 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 + with: + name: qar-daily-preview-build-evidence + path: ${{ runner.temp }}/qar-d3-evidence-download + + - name: Verify downloaded artifact against full build evidence + env: + INPUT_AS_OF: ${{ inputs.as_of }} + FROZEN_GENERATED_AT: ${{ inputs.frozen_generated_at }} + run: | + set -euo pipefail + python scripts/d3_verify_daily_preview.py \ + --workspace "$RUNNER_TEMP/qar-d3-download" \ + --evidence-path "$RUNNER_TEMP/qar-d3-evidence-download/build_evidence.json" \ + --base-sha "$GITHUB_SHA" \ + --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" | tee -a "$GITHUB_STEP_SUMMARY" + + - name: Cleanup private workspace after evidence + if: ${{ always() }} + run: | + set -euo pipefail + if [ -f "$RUNNER_TEMP/qar-d3-workspace.txt" ]; then + workspace="$(cat "$RUNNER_TEMP/qar-d3-workspace.txt")" + rm -rf -- "$(dirname "$workspace")" + fi diff --git a/scripts/d3_build_daily_preview.py b/scripts/d3_build_daily_preview.py new file mode 100644 index 0000000..a178655 --- /dev/null +++ b/scripts/d3_build_daily_preview.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +"""Build two frozen-clock representative daily preview workspaces and evidence.""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import shutil +import subprocess +import tempfile +from pathlib import Path +from unittest.mock import patch + +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.v1" +FIXED_FILES = ("manifest.json", "report.html", "report.json") +DEPENDENCY_INVENTORY = [ + ".github/workflows/qar_d3_daily_preview_artifact.yml", + "scripts/d3_build_daily_preview.py", + "scripts/d3_verify_daily_preview.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_preview_artifact.py", + "examples/political_events.example.csv", + "examples/political_watchlist.example.csv", + "pyproject.toml", +] + + +def _base_sha() -> str: + return subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip() + + +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 _build_report(*, as_of: str, events: Path, watchlist: Path, frozen_generated_at: str) -> dict[str, object]: + with patch("quant_advisor_research.advisory_report.utc_now_iso", return_value=frozen_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) -> tuple[Path, dict[str, object]]: + if any(not Path(path).is_file() for path in DEPENDENCY_INVENTORY): + raise RuntimeError("dependency_inventory_invalid") + temp_root = Path(args.temp_root) + temp_root.mkdir(parents=True, exist_ok=True) + parent = Path(tempfile.mkdtemp(prefix="qar-d3-parent-", dir=temp_root)) + succeeded = False + try: + first_report = _build_report( + as_of=args.as_of, + events=Path(args.political_events), + watchlist=Path(args.political_watchlist), + frozen_generated_at=args.frozen_generated_at, + ) + second_report = _build_report( + as_of=args.as_of, + events=Path(args.political_events), + watchlist=Path(args.political_watchlist), + frozen_generated_at=args.frozen_generated_at, + ) + first = build_preview_workspace(first_report, parent) + second = build_preview_workspace(second_report, parent) + read_preview_bundle(first) + read_preview_bundle(second) + first_files = _file_hashes(first) + second_files = _file_hashes(second) + if first_files != second_files or any((first / name).read_bytes() != (second / name).read_bytes() for name in FIXED_FILES): + raise RuntimeError("repeat_build_not_equal") + manifest = json.loads((first / "manifest.json").read_text(encoding="utf-8")) + source = manifest["source"] + base_sha = args.base_sha or _base_sha() + if not re.fullmatch(r"[0-9a-f]{40}", base_sha): + raise RuntimeError("base_sha_invalid") + if source != { + "schema_version": "5", + "contract_version": "model_recommendations.v5", + "cadence": "daily", + "as_of": args.as_of, + "generated_at": args.frozen_generated_at, + }: + raise RuntimeError("source_contract_mismatch") + evidence = { + "evidence_version": EVIDENCE_VERSION, + "base_sha": 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, + "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, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n", encoding="utf-8") + Path(args.workspace_path_file).write_text(str(first) + "\n", encoding="utf-8") + print(json.dumps({"workspace": str(first), "evidence": str(evidence_path), "base_sha": evidence["base_sha"]}, sort_keys=True)) + succeeded = True + return first, evidence + finally: + if not succeeded: + try: + parent.rmdir() + except OSError: + shutil.rmtree(parent, ignore_errors=True) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--as-of", required=True) + parser.add_argument("--political-events", required=True) + parser.add_argument("--political-watchlist", required=True) + parser.add_argument("--frozen-generated-at", required=True) + parser.add_argument("--temp-root", default=os.environ.get("RUNNER_TEMP", tempfile.gettempdir())) + parser.add_argument("--evidence-path", required=True) + parser.add_argument("--workspace-path-file", required=True) + parser.add_argument("--base-sha") + return parser.parse_args() + + +if __name__ == "__main__": + build_evidence(parse_args()) diff --git a/scripts/d3_verify_daily_preview.py b/scripts/d3_verify_daily_preview.py new file mode 100644 index 0000000..dc6e06e --- /dev/null +++ b/scripts/d3_verify_daily_preview.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""Verify a downloaded D3 bundle against the complete build evidence payload.""" +from __future__ import annotations + +import argparse +import hashlib +import json +import re +from pathlib import Path + +from quant_advisor_research.preview_bundle import read_preview_bundle + +EVIDENCE_VERSION = "qar.d3.build_evidence.v1" +FIXED_FILES = ("manifest.json", "report.html", "report.json") +EVIDENCE_KEYS = {"evidence_version", "base_sha", "source", "deterministic_clock", "bundle", "repeat_build"} +DEPENDENCY_INVENTORY = [ + ".github/workflows/qar_d3_daily_preview_artifact.yml", + "scripts/d3_build_daily_preview.py", + "scripts/d3_verify_daily_preview.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_preview_artifact.py", + "examples/political_events.example.csv", + "examples/political_watchlist.example.csv", + "pyproject.toml", +] + + +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 _canonical_json(value: object) -> bytes: + return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8") + + +def verify(*, workspace: Path, evidence_path: Path, expected_base_sha: str, expected_as_of: str, expected_events: str, expected_watchlist: str, frozen_generated_at: str) -> dict[str, object]: + if any(not Path(path).is_file() for path in DEPENDENCY_INVENTORY): + raise ValueError("dependency_inventory_invalid") + evidence = json.loads(evidence_path.read_text(encoding="utf-8")) + if not isinstance(evidence, dict) or set(evidence) != EVIDENCE_KEYS | {"workflow_dependency_inventory"}: + raise ValueError("evidence_shape_invalid") + if not re.fullmatch(r"[0-9a-f]{40}", expected_base_sha): + raise ValueError("base_sha_invalid") + read_preview_bundle(workspace) + if {path.name for path in workspace.iterdir()} != set(FIXED_FILES): + raise ValueError("file_set_invalid") + manifest = json.loads((workspace / "manifest.json").read_text(encoding="utf-8")) + if (workspace / "manifest.json").read_bytes() != _canonical_json(manifest): + raise ValueError("manifest_noncanonical") + files = _file_hashes(workspace) + expected = { + "evidence_version": EVIDENCE_VERSION, + "base_sha": expected_base_sha, + "source": { + "fixture_paths": [Path(expected_events).as_posix(), Path(expected_watchlist).as_posix()], + "provenance": "repository_representative_fixture", + }, + "deterministic_clock": {"frozen_generated_at": frozen_generated_at, "producer_invocations": 2}, + "workflow_dependency_inventory": DEPENDENCY_INVENTORY, + "bundle": {"contract": "qar.preview_bundle.v1", "source": manifest["source"], "files": files}, + "repeat_build": {"independent_invocations": 2, "bytes_equal": True, "files": files}, + } + if evidence != expected: + raise ValueError("build_evidence_mismatch") + source = manifest.get("source", {}) + if source != { + "schema_version": "5", + "contract_version": "model_recommendations.v5", + "cadence": "daily", + "as_of": expected_as_of, + "generated_at": frozen_generated_at, + }: + raise ValueError("source_contract_mismatch") + result = {"status": "passed", "base_sha": expected_base_sha, "files": files, "bundle_contract": manifest["bundle_contract"], "readback": "passed", "evidence_binding": "exact_full_payload"} + print(json.dumps(result, sort_keys=True)) + return result + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--workspace", required=True, type=Path) + parser.add_argument("--evidence-path", required=True, type=Path) + parser.add_argument("--base-sha", required=True) + parser.add_argument("--as-of", required=True) + parser.add_argument("--political-events", required=True) + parser.add_argument("--political-watchlist", required=True) + parser.add_argument("--frozen-generated-at", required=True) + return parser.parse_args() + + +if __name__ == "__main__": + args = parse_args() + verify( + workspace=args.workspace, + evidence_path=args.evidence_path, + expected_base_sha=args.base_sha, + expected_as_of=args.as_of, + expected_events=args.political_events, + expected_watchlist=args.political_watchlist, + frozen_generated_at=args.frozen_generated_at, + ) diff --git a/tests/test_d3_preview_artifact.py b/tests/test_d3_preview_artifact.py new file mode 100644 index 0000000..0a95395 --- /dev/null +++ b/tests/test_d3_preview_artifact.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +EVENTS = ROOT / "examples/political_events.example.csv" +WATCHLIST = ROOT / "examples/political_watchlist.example.csv" + + +def test_d3_harness_builds_two_equal_workspaces_and_verifies_full_evidence(tmp_path): + evidence = tmp_path / "evidence" / "build_evidence.json" + workspace_file = tmp_path / "workspace.txt" + env = {"PYTHONPATH": str(ROOT / "src")} + build = subprocess.run( + [ + sys.executable, + "scripts/d3_build_daily_preview.py", + "--as-of", + "2026-06-20", + "--political-events", + str(EVENTS), + "--political-watchlist", + str(WATCHLIST), + "--frozen-generated-at", + "2026-07-15T00:00:00Z", + "--base-sha", + "a" * 40, + "--temp-root", + str(tmp_path), + "--evidence-path", + str(evidence), + "--workspace-path-file", + str(workspace_file), + ], + cwd=ROOT, + env={**__import__("os").environ, **env}, + check=True, + capture_output=True, + text=True, + ) + workspace = Path(workspace_file.read_text().strip()) + assert {path.name for path in workspace.iterdir()} == {"report.json", "report.html", "manifest.json"} + assert json.loads(evidence.read_text())["repeat_build"]["bytes_equal"] is True + + downloaded = tmp_path / "downloaded" + downloaded.mkdir() + for source in workspace.iterdir(): + (downloaded / source.name).write_bytes(source.read_bytes()) + subprocess.run( + [ + sys.executable, + "scripts/d3_verify_daily_preview.py", + "--workspace", + str(downloaded), + "--evidence-path", + str(evidence), + "--base-sha", + "a" * 40, + "--as-of", + "2026-06-20", + "--political-events", + str(EVENTS), + "--political-watchlist", + str(WATCHLIST), + "--frozen-generated-at", + "2026-07-15T00:00:00Z", + ], + cwd=ROOT, + env={**__import__("os").environ, **env}, + check=True, + capture_output=True, + text=True, + ) + + tampered = json.loads(evidence.read_text()) + tampered["unexpected"] = "reject" + evidence.write_text(json.dumps(tampered), encoding="utf-8") + sys.path.insert(0, str(ROOT / "scripts")) + import d3_verify_daily_preview as verifier + + with pytest.raises(ValueError, match="evidence_shape_invalid"): + verifier.verify( + workspace=downloaded, + evidence_path=evidence, + expected_base_sha="a" * 40, + expected_as_of="2026-06-20", + expected_events=str(EVENTS), + expected_watchlist=str(WATCHLIST), + frozen_generated_at="2026-07-15T00:00:00Z", + ) From f9058a397de4e255c40487c456fa4224d2785311 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Wed, 15 Jul 2026 09:50:14 +0800 Subject: [PATCH 2/2] fix: bind D3 artifact to locked toolchain Co-Authored-By: Codex --- .../qar_d3_daily_preview_artifact.yml | 29 ++++++++++++++++--- scripts/d3_build_daily_preview.py | 23 +++++++++++++++ scripts/d3_verify_daily_preview.py | 29 +++++++++++++++++-- tests/test_d3_preview_artifact.py | 29 +++++++++++++++++++ 4 files changed, 104 insertions(+), 6 deletions(-) diff --git a/.github/workflows/qar_d3_daily_preview_artifact.yml b/.github/workflows/qar_d3_daily_preview_artifact.yml index 08ff522..f5f5b52 100644 --- a/.github/workflows/qar_d3_daily_preview_artifact.yml +++ b/.github/workflows/qar_d3_daily_preview_artifact.yml @@ -1,6 +1,18 @@ 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_build_daily_preview.py" + - "scripts/d3_verify_daily_preview.py" + - "src/quant_advisor_research/**" + - "tests/test_d3_preview_artifact.py" + - "examples/political_events.example.csv" + - "examples/political_watchlist.example.csv" workflow_dispatch: inputs: as_of: @@ -30,8 +42,13 @@ jobs: with: python-version: "3.11" - - name: Install package - run: python -m pip install -e ".[test]" + - name: Set up uv # v6 + uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e + with: + version: "0.11.19" + + - name: Sync locked environment + run: uv sync --locked --extra test - name: Build two frozen-clock workspaces id: build @@ -42,12 +59,14 @@ jobs: set -euo pipefail EVIDENCE_DIR="$RUNNER_TEMP/qar-d3-evidence" mkdir -p "$EVIDENCE_DIR" - python scripts/d3_build_daily_preview.py \ + 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 "$GITHUB_SHA" \ + --uv-version "$(uv --version)" \ + --lock-path uv.lock \ --temp-root "$RUNNER_TEMP" \ --evidence-path "$EVIDENCE_DIR/build_evidence.json" \ --workspace-path-file "$RUNNER_TEMP/qar-d3-workspace.txt" @@ -86,10 +105,12 @@ jobs: FROZEN_GENERATED_AT: ${{ inputs.frozen_generated_at }} run: | set -euo pipefail - python scripts/d3_verify_daily_preview.py \ + uv run --no-sync python scripts/d3_verify_daily_preview.py \ --workspace "$RUNNER_TEMP/qar-d3-download" \ --evidence-path "$RUNNER_TEMP/qar-d3-evidence-download/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 \ diff --git a/scripts/d3_build_daily_preview.py b/scripts/d3_build_daily_preview.py index a178655..8e7cd54 100644 --- a/scripts/d3_build_daily_preview.py +++ b/scripts/d3_build_daily_preview.py @@ -4,6 +4,7 @@ import argparse import hashlib +import importlib.metadata import json import os import re @@ -50,6 +51,15 @@ def _file_hashes(workspace: Path) -> dict[str, dict[str, object]]: } +def _installed_distributions() -> tuple[list[str], str]: + lines = sorted( + f"{name}=={distribution.version}" + for distribution in importlib.metadata.distributions() + if (name := distribution.metadata.get("Name")) + ) + return lines, hashlib.sha256("\n".join(lines).encode("utf-8")).hexdigest() + + def _build_report(*, as_of: str, events: Path, watchlist: Path, frozen_generated_at: str) -> dict[str, object]: with patch("quant_advisor_research.advisory_report.utc_now_iso", return_value=frozen_generated_at): return build_advisory_report( @@ -63,6 +73,9 @@ def _build_report(*, as_of: str, events: Path, watchlist: Path, frozen_generated def build_evidence(args: argparse.Namespace) -> tuple[Path, dict[str, object]]: if any(not Path(path).is_file() for path in DEPENDENCY_INVENTORY): raise RuntimeError("dependency_inventory_invalid") + lock_path = Path(args.lock_path) + lock_sha256 = hashlib.sha256(lock_path.read_bytes()).hexdigest() + distributions, distributions_sha256 = _installed_distributions() temp_root = Path(args.temp_root) temp_root.mkdir(parents=True, exist_ok=True) parent = Path(tempfile.mkdtemp(prefix="qar-d3-parent-", dir=temp_root)) @@ -109,6 +122,14 @@ def build_evidence(args: argparse.Namespace) -> tuple[Path, dict[str, object]]: "provenance": "repository_representative_fixture", }, "deterministic_clock": {"frozen_generated_at": args.frozen_generated_at, "producer_invocations": 2}, + "locked_environment": { + "lockfile": lock_path.as_posix(), + "lock_sha256": lock_sha256, + "uv_version": args.uv_version, + "python_version": __import__("platform").python_version(), + "installed_distributions": distributions, + "installed_distributions_sha256": distributions_sha256, + }, "workflow_dependency_inventory": DEPENDENCY_INVENTORY, "bundle": { "contract": manifest["bundle_contract"], @@ -142,6 +163,8 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--evidence-path", required=True) parser.add_argument("--workspace-path-file", required=True) parser.add_argument("--base-sha") + parser.add_argument("--uv-version", required=True) + parser.add_argument("--lock-path", default="uv.lock") return parser.parse_args() diff --git a/scripts/d3_verify_daily_preview.py b/scripts/d3_verify_daily_preview.py index dc6e06e..43b1e0a 100644 --- a/scripts/d3_verify_daily_preview.py +++ b/scripts/d3_verify_daily_preview.py @@ -4,7 +4,9 @@ import argparse import hashlib +import importlib.metadata import json +import platform import re from pathlib import Path @@ -12,7 +14,7 @@ EVIDENCE_VERSION = "qar.d3.build_evidence.v1" FIXED_FILES = ("manifest.json", "report.html", "report.json") -EVIDENCE_KEYS = {"evidence_version", "base_sha", "source", "deterministic_clock", "bundle", "repeat_build"} +EVIDENCE_KEYS = {"evidence_version", "base_sha", "source", "deterministic_clock", "locked_environment", "bundle", "repeat_build"} DEPENDENCY_INVENTORY = [ ".github/workflows/qar_d3_daily_preview_artifact.yml", "scripts/d3_build_daily_preview.py", @@ -44,7 +46,16 @@ def _canonical_json(value: object) -> bytes: return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8") -def verify(*, workspace: Path, evidence_path: Path, expected_base_sha: str, expected_as_of: str, expected_events: str, expected_watchlist: str, frozen_generated_at: str) -> dict[str, object]: +def _installed_distributions() -> tuple[list[str], str]: + lines = sorted( + f"{name}=={distribution.version}" + for distribution in importlib.metadata.distributions() + if (name := distribution.metadata.get("Name")) + ) + return lines, hashlib.sha256("\n".join(lines).encode("utf-8")).hexdigest() + + +def verify(*, workspace: Path, evidence_path: Path, expected_base_sha: str, expected_as_of: str, expected_events: str, expected_watchlist: str, frozen_generated_at: str, uv_version: str, lock_path: Path) -> dict[str, object]: if any(not Path(path).is_file() for path in DEPENDENCY_INVENTORY): raise ValueError("dependency_inventory_invalid") evidence = json.loads(evidence_path.read_text(encoding="utf-8")) @@ -52,6 +63,8 @@ def verify(*, workspace: Path, evidence_path: Path, expected_base_sha: str, expe raise ValueError("evidence_shape_invalid") if not re.fullmatch(r"[0-9a-f]{40}", expected_base_sha): raise ValueError("base_sha_invalid") + distributions, distributions_sha256 = _installed_distributions() + lock_sha256 = hashlib.sha256(lock_path.read_bytes()).hexdigest() read_preview_bundle(workspace) if {path.name for path in workspace.iterdir()} != set(FIXED_FILES): raise ValueError("file_set_invalid") @@ -67,6 +80,14 @@ def verify(*, workspace: Path, evidence_path: Path, expected_base_sha: str, expe "provenance": "repository_representative_fixture", }, "deterministic_clock": {"frozen_generated_at": frozen_generated_at, "producer_invocations": 2}, + "locked_environment": { + "lockfile": lock_path.as_posix(), + "lock_sha256": lock_sha256, + "uv_version": uv_version, + "python_version": platform.python_version(), + "installed_distributions": distributions, + "installed_distributions_sha256": distributions_sha256, + }, "workflow_dependency_inventory": DEPENDENCY_INVENTORY, "bundle": {"contract": "qar.preview_bundle.v1", "source": manifest["source"], "files": files}, "repeat_build": {"independent_invocations": 2, "bytes_equal": True, "files": files}, @@ -96,6 +117,8 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--political-events", required=True) parser.add_argument("--political-watchlist", required=True) parser.add_argument("--frozen-generated-at", required=True) + parser.add_argument("--uv-version", required=True) + parser.add_argument("--lock-path", type=Path, default=Path("uv.lock")) return parser.parse_args() @@ -109,4 +132,6 @@ def parse_args() -> argparse.Namespace: expected_events=args.political_events, expected_watchlist=args.political_watchlist, frozen_generated_at=args.frozen_generated_at, + uv_version=args.uv_version, + lock_path=args.lock_path, ) diff --git a/tests/test_d3_preview_artifact.py b/tests/test_d3_preview_artifact.py index 0a95395..d90d3f7 100644 --- a/tests/test_d3_preview_artifact.py +++ b/tests/test_d3_preview_artifact.py @@ -16,6 +16,7 @@ def test_d3_harness_builds_two_equal_workspaces_and_verifies_full_evidence(tmp_p evidence = tmp_path / "evidence" / "build_evidence.json" workspace_file = tmp_path / "workspace.txt" env = {"PYTHONPATH": str(ROOT / "src")} + uv_version = subprocess.check_output(["uv", "--version"], text=True).strip() build = subprocess.run( [ sys.executable, @@ -30,6 +31,10 @@ def test_d3_harness_builds_two_equal_workspaces_and_verifies_full_evidence(tmp_p "2026-07-15T00:00:00Z", "--base-sha", "a" * 40, + "--uv-version", + uv_version, + "--lock-path", + str(ROOT / "uv.lock"), "--temp-root", str(tmp_path), "--evidence-path", @@ -61,6 +66,10 @@ def test_d3_harness_builds_two_equal_workspaces_and_verifies_full_evidence(tmp_p str(evidence), "--base-sha", "a" * 40, + "--uv-version", + uv_version, + "--lock-path", + str(ROOT / "uv.lock"), "--as-of", "2026-06-20", "--political-events", @@ -92,4 +101,24 @@ def test_d3_harness_builds_two_equal_workspaces_and_verifies_full_evidence(tmp_p expected_events=str(EVENTS), expected_watchlist=str(WATCHLIST), frozen_generated_at="2026-07-15T00:00:00Z", + uv_version=uv_version, + lock_path=ROOT / "uv.lock", ) + + +def test_d3_workflow_requires_pull_request_paths_and_locked_pinned_toolchain(): + workflow = (ROOT / ".github/workflows/qar_d3_daily_preview_artifact.yml").read_text() + assert "pull_request:" in workflow + for path in ("pyproject.toml", "uv.lock", "scripts/d3_build_daily_preview.py", "scripts/d3_verify_daily_preview.py", "src/quant_advisor_research/**", "tests/test_d3_preview_artifact.py"): + assert f' - "{path}"' in workflow + assert "uv sync --locked --extra test" in workflow + assert "uv run --no-sync" in workflow + assert "pip install" not 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