From c255a80f769e49c62d1ca0875002c99c25ab859a Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Wed, 15 Jul 2026 07:37:13 +0800 Subject: [PATCH 1/2] feat: add daily preview artifact acceptance slice Co-Authored-By: Codex --- .../qar_vnext_d3_daily_preview_artifact.yml | 72 ++++++++++ docs/qar_vnext_d3_daily_action_artifact.md | 10 ++ scripts/d3_build_daily_preview_artifact.py | 133 ++++++++++++++++++ scripts/d3_verify_daily_preview_artifact.py | 72 ++++++++++ tests/test_d3_daily_preview_artifact.py | 105 ++++++++++++++ 5 files changed, 392 insertions(+) create mode 100644 .github/workflows/qar_vnext_d3_daily_preview_artifact.yml create mode 100644 docs/qar_vnext_d3_daily_action_artifact.md create mode 100644 scripts/d3_build_daily_preview_artifact.py create mode 100644 scripts/d3_verify_daily_preview_artifact.py create mode 100644 tests/test_d3_daily_preview_artifact.py diff --git a/.github/workflows/qar_vnext_d3_daily_preview_artifact.yml b/.github/workflows/qar_vnext_d3_daily_preview_artifact.yml new file mode 100644 index 0000000..b9dd537 --- /dev/null +++ b/.github/workflows/qar_vnext_d3_daily_preview_artifact.yml @@ -0,0 +1,72 @@ +name: QAR vNext D3 Daily Preview Artifact + +on: + pull_request: + paths: + - ".github/workflows/qar_vnext_d3_daily_preview_artifact.yml" + - "scripts/d3_build_daily_preview_artifact.py" + - "scripts/d3_verify_daily_preview_artifact.py" + - "src/quant_advisor_research/preview_bundle.py" + - "src/quant_advisor_research/advisory_report.py" + - "tests/test_d3_daily_preview_artifact.py" + workflow_dispatch: + inputs: + as_of: + description: "Representative daily report date" + required: true + default: "2026-06-20" + type: string + +permissions: + contents: read + +jobs: + build-and-verify: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: "3.11" + - name: Install advisor package + run: python -m pip install -e . + - name: Build representative daily preview + env: + AS_OF: ${{ inputs.as_of || '2026-06-20' }} + BASE_SHA: ${{ github.event.pull_request.base.sha || github.sha }} + run: | + set -euo pipefail + python scripts/d3_build_daily_preview_artifact.py \ + --as-of "${AS_OF}" \ + --political-events examples/political_events.example.csv \ + --political-watchlist examples/political_watchlist.example.csv \ + --artifact-dir "${RUNNER_TEMP}/qar-daily-preview" \ + --evidence-path "${RUNNER_TEMP}/qar-daily-preview-build-evidence.json" \ + --base-sha "${BASE_SHA}" \ + > "${RUNNER_TEMP}/qar-daily-preview-build-evidence.log" + cat "${RUNNER_TEMP}/qar-daily-preview-build-evidence.log" + - name: Upload daily preview artifact + uses: actions/upload-artifact@v7 + with: + name: qar-daily-preview + path: ${{ runner.temp }}/qar-daily-preview + if-no-files-found: error + - name: Download daily preview artifact + uses: actions/download-artifact@v7 + with: + name: qar-daily-preview + path: ${{ runner.temp }}/qar-daily-preview-downloaded + - name: Read back uploaded artifact + env: + BASE_SHA: ${{ github.event.pull_request.base.sha || github.sha }} + run: | + set -euo pipefail + python scripts/d3_verify_daily_preview_artifact.py \ + --artifact-dir "${RUNNER_TEMP}/qar-daily-preview-downloaded" \ + --evidence-path "${RUNNER_TEMP}/qar-daily-preview-download-evidence.json" \ + --base-sha "${BASE_SHA}" diff --git a/docs/qar_vnext_d3_daily_action_artifact.md b/docs/qar_vnext_d3_daily_action_artifact.md new file mode 100644 index 0000000..d75d8a7 --- /dev/null +++ b/docs/qar_vnext_d3_daily_action_artifact.md @@ -0,0 +1,10 @@ +# QAR vNext D3 daily action artifact + +This workflow is an isolated, repository-representative fixture acceptance slice: + +1. `build_advisory_report(..., cadence="daily")` reads the fixed examples CSVs. +2. Existing `qar.preview_bundle.v1` writes exactly `report.json`, `report.html`, and `manifest.json` under a unique `$RUNNER_TEMP` destination. +3. The bundle is read back before `actions/upload-artifact@v7`. +4. The uploaded artifact is downloaded to a separate temporary directory and read back again. + +Evidence explicitly identifies `source_kind=repository_representative_fixture`; it is not live producer or production-trusted evidence. The workflow does not modify weekly/monthly workflows, publisher, archive/feed, Pages, identity, or persistence. Issue #50 remains the production-trust hardening gate. diff --git a/scripts/d3_build_daily_preview_artifact.py b/scripts/d3_build_daily_preview_artifact.py new file mode 100644 index 0000000..00b17ac --- /dev/null +++ b/scripts/d3_build_daily_preview_artifact.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""Build and locally verify the D3 representative daily preview artifact.""" +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import shutil +import sys +import tempfile +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from quant_advisor_research.advisory_report import build_advisory_report +from quant_advisor_research.preview_bundle import PreviewBundleError, build_preview_bundle, read_preview_bundle + + +BASE_SHA_PATTERN = re.compile(r"[0-9a-f]{40}") +SOURCE_KIND = "repository_representative_fixture" +CHECKS = [ + "exact_three_files", + "canonical_json_readback", + "manifest_hashes", + "manifest_source_pair", + "relative_html_links", + "repeat_build_bytes", +] + + +def _require_base_sha(value: str) -> str: + if BASE_SHA_PATTERN.fullmatch(value) is None: + raise PreviewBundleError("base_sha_invalid") + return value + + +def _sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def _evidence( + *, output: Path, report: dict[str, object], manifest: dict[str, object], base_sha: str, repeat_equal: bool +) -> dict[str, object]: + return { + "source_kind": SOURCE_KIND, + "base_sha": base_sha, + "bundle_contract": "qar.preview_bundle.v1", + "source": { + "cadence": report["cadence"], + "as_of": report["as_of"], + "generated_at": report["generated_at"], + "schema_version": report["schema_version"], + }, + "provenance": { + "political_events": "examples/political_events.example.csv", + "political_watchlist": "examples/political_watchlist.example.csv", + }, + "files": sorted(path.name for path in output.iterdir()), + "sha256": {name: _sha256(output / name) for name in ("manifest.json", "report.html", "report.json")}, + "manifest_source": manifest["source"], + "html_links": ["report.json", "manifest.json"], + "checks": CHECKS, + "repeat_build_bytes": repeat_equal, + } + + +def build(args: argparse.Namespace) -> None: + base_sha = _require_base_sha(args.base_sha) + output = Path(args.artifact_dir) + events = Path(args.political_events) + watchlist = Path(args.political_watchlist) + if not events.is_file() or not watchlist.is_file(): + raise PreviewBundleError("fixture_missing") + report = build_advisory_report( + as_of=args.as_of, + cadence=args.cadence, + political_events_path=events, + political_watchlist_path=watchlist, + ) + build_preview_bundle(report, output) + evidence = read_preview_bundle(output) + repeat_parent = Path(tempfile.mkdtemp(prefix=f".{output.name}.repeat-", dir=output.parent)) + try: + repeat_output = repeat_parent / "preview" + build_preview_bundle(report, repeat_output) + repeat_equal = all( + (output / name).read_bytes() == (repeat_output / name).read_bytes() + for name in ("manifest.json", "report.html", "report.json") + ) + finally: + shutil.rmtree(repeat_parent, ignore_errors=True) + if not repeat_equal: + raise PreviewBundleError("repeat_build_non_deterministic") + payload = _evidence( + output=output, + report=dict(evidence.report), + manifest=dict(evidence.manifest), + base_sha=base_sha, + repeat_equal=repeat_equal, + ) + evidence_path = Path(args.evidence_path) + if evidence_path.exists(): + raise PreviewBundleError("evidence_exists") + evidence_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print(json.dumps(payload, ensure_ascii=False, sort_keys=True)) + + +def parser() -> argparse.ArgumentParser: + result = argparse.ArgumentParser() + result.add_argument("--as-of", required=True) + result.add_argument("--cadence", choices=("daily", "weekly", "monthly"), default="daily") + result.add_argument("--political-events", required=True) + result.add_argument("--political-watchlist", required=True) + result.add_argument("--artifact-dir", required=True) + result.add_argument("--evidence-path", required=True) + result.add_argument("--base-sha", required=True) + return result + + +def main() -> None: + try: + build(parser().parse_args()) + except PreviewBundleError as exc: + print(f"daily_preview_build_failed:{exc.code}", file=sys.stderr) + raise SystemExit(1) from None + except (OSError, TypeError, ValueError, UnicodeError): + print("daily_preview_build_failed", file=sys.stderr) + raise SystemExit(1) from None + + +if __name__ == "__main__": + main() diff --git a/scripts/d3_verify_daily_preview_artifact.py b/scripts/d3_verify_daily_preview_artifact.py new file mode 100644 index 0000000..d8d6345 --- /dev/null +++ b/scripts/d3_verify_daily_preview_artifact.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""Read back a downloaded D3 preview artifact without production integration.""" +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from quant_advisor_research.preview_bundle import PreviewBundleError, read_preview_bundle + + +BASE_SHA_PATTERN = re.compile(r"[0-9a-f]{40}") +SOURCE_KIND = "downloaded_repository_representative_fixture" + + +def verify(args: argparse.Namespace) -> None: + if BASE_SHA_PATTERN.fullmatch(args.base_sha) is None: + raise PreviewBundleError("base_sha_invalid") + output = Path(args.artifact_dir) + evidence = read_preview_bundle(output) + report = dict(evidence.report) + manifest = dict(evidence.manifest) + if report.get("cadence") != "daily" or manifest.get("bundle_contract") != "qar.preview_bundle.v1": + raise PreviewBundleError("downloaded_bundle_contract_invalid") + payload = { + "source_kind": SOURCE_KIND, + "base_sha": args.base_sha, + "bundle_contract": manifest["bundle_contract"], + "source": manifest["source"], + "files": sorted(path.name for path in output.iterdir()), + "sha256": { + name: hashlib.sha256((output / name).read_bytes()).hexdigest() + for name in ("manifest.json", "report.html", "report.json") + }, + "checks": [ + "exact_three_files", + "canonical_json_readback", + "manifest_hashes", + "manifest_source_pair", + "relative_html_links", + ], + } + evidence_path = Path(args.evidence_path) + if evidence_path.exists(): + raise PreviewBundleError("evidence_exists") + evidence_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print(json.dumps(payload, ensure_ascii=False, sort_keys=True)) + + +def parser() -> argparse.ArgumentParser: + result = argparse.ArgumentParser() + result.add_argument("--artifact-dir", required=True) + result.add_argument("--evidence-path", required=True) + result.add_argument("--base-sha", required=True) + return result + + +def main() -> None: + try: + verify(parser().parse_args()) + except (PreviewBundleError, OSError, TypeError, ValueError, UnicodeError): + print("daily_preview_readback_failed", file=sys.stderr) + raise SystemExit(1) from None + + +if __name__ == "__main__": + main() diff --git a/tests/test_d3_daily_preview_artifact.py b/tests/test_d3_daily_preview_artifact.py new file mode 100644 index 0000000..670a51c --- /dev/null +++ b/tests/test_d3_daily_preview_artifact.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +BUILD = ROOT / "scripts/d3_build_daily_preview_artifact.py" +VERIFY = ROOT / "scripts/d3_verify_daily_preview_artifact.py" +BASE_SHA = "c5ba801a8696eec63c7ba348f3f125cb52cd06ff" + + +def run_script(script: Path, *args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, str(script), *args], + cwd=ROOT, + text=True, + capture_output=True, + check=False, + ) + + +def build_args(output: Path, evidence: Path) -> tuple[str, ...]: + return ( + "--as-of", + "2026-06-20", + "--political-events", + str(ROOT / "examples/political_events.example.csv"), + "--political-watchlist", + str(ROOT / "examples/political_watchlist.example.csv"), + "--artifact-dir", + str(output), + "--evidence-path", + str(evidence), + "--base-sha", + BASE_SHA, + ) + + +def test_representative_daily_build_emits_deterministic_evidence(tmp_path): + output = tmp_path / "preview" + evidence = tmp_path / "evidence.json" + result = run_script(BUILD, *build_args(output, evidence)) + + assert result.returncode == 0, result.stderr + assert {item.name for item in output.iterdir()} == {"report.json", "report.html", "manifest.json"} + payload = json.loads(evidence.read_text(encoding="utf-8")) + assert payload["source_kind"] == "repository_representative_fixture" + assert payload["base_sha"] == BASE_SHA + assert payload["bundle_contract"] == "qar.preview_bundle.v1" + assert payload["checks"] == [ + "exact_three_files", + "canonical_json_readback", + "manifest_hashes", + "manifest_source_pair", + "relative_html_links", + "repeat_build_bytes", + ] + assert payload["repeat_build_bytes"] is True + + +def test_verify_rejects_tampered_artifact(tmp_path): + output = tmp_path / "preview" + evidence = tmp_path / "evidence.json" + assert run_script(BUILD, *build_args(output, evidence)).returncode == 0 + (output / "report.html").write_text("tampered", encoding="utf-8") + + result = run_script( + VERIFY, + "--artifact-dir", + str(output), + "--evidence-path", + str(tmp_path / "downloaded-evidence.json"), + "--base-sha", + BASE_SHA, + ) + + assert result.returncode != 0 + assert "readback_failed" in result.stderr + + +def test_build_rejects_non_daily_source_before_output(tmp_path): + result = run_script( + BUILD, + "--as-of", + "2026-06-20", + "--cadence", + "weekly", + "--political-events", + str(ROOT / "examples/political_events.example.csv"), + "--political-watchlist", + str(ROOT / "examples/political_watchlist.example.csv"), + "--artifact-dir", + str(tmp_path / "preview"), + "--evidence-path", + str(tmp_path / "evidence.json"), + "--base-sha", + BASE_SHA, + ) + + assert result.returncode != 0 + assert not (tmp_path / "preview").exists() + assert "daily_only" in result.stderr From 7bbf5c7772a874430c1eda143c89d73f3a3f585d Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Wed, 15 Jul 2026 07:45:07 +0800 Subject: [PATCH 2/2] fix: bind preview artifact evidence to frozen producer run Co-Authored-By: Codex --- .../qar_vnext_d3_daily_preview_artifact.yml | 2 + docs/qar_vnext_d3_daily_action_artifact.md | 6 +- scripts/d3_build_daily_preview_artifact.py | 58 +++++++++++++++---- scripts/d3_verify_daily_preview_artifact.py | 58 +++++++++++++++++-- tests/test_d3_daily_preview_artifact.py | 36 ++++++++++++ 5 files changed, 143 insertions(+), 17 deletions(-) diff --git a/.github/workflows/qar_vnext_d3_daily_preview_artifact.yml b/.github/workflows/qar_vnext_d3_daily_preview_artifact.yml index b9dd537..d4b86b2 100644 --- a/.github/workflows/qar_vnext_d3_daily_preview_artifact.yml +++ b/.github/workflows/qar_vnext_d3_daily_preview_artifact.yml @@ -48,6 +48,7 @@ jobs: --artifact-dir "${RUNNER_TEMP}/qar-daily-preview" \ --evidence-path "${RUNNER_TEMP}/qar-daily-preview-build-evidence.json" \ --base-sha "${BASE_SHA}" \ + --frozen-generated-at "2026-07-15T00:00:00Z" \ > "${RUNNER_TEMP}/qar-daily-preview-build-evidence.log" cat "${RUNNER_TEMP}/qar-daily-preview-build-evidence.log" - name: Upload daily preview artifact @@ -69,4 +70,5 @@ jobs: python scripts/d3_verify_daily_preview_artifact.py \ --artifact-dir "${RUNNER_TEMP}/qar-daily-preview-downloaded" \ --evidence-path "${RUNNER_TEMP}/qar-daily-preview-download-evidence.json" \ + --build-evidence-path "${RUNNER_TEMP}/qar-daily-preview-build-evidence.json" \ --base-sha "${BASE_SHA}" diff --git a/docs/qar_vnext_d3_daily_action_artifact.md b/docs/qar_vnext_d3_daily_action_artifact.md index d75d8a7..44d902d 100644 --- a/docs/qar_vnext_d3_daily_action_artifact.md +++ b/docs/qar_vnext_d3_daily_action_artifact.md @@ -2,9 +2,11 @@ This workflow is an isolated, repository-representative fixture acceptance slice: -1. `build_advisory_report(..., cadence="daily")` reads the fixed examples CSVs. +1. Two independent `build_advisory_report(..., cadence="daily")` invocations read the fixed examples CSVs under a harness-only frozen `generated_at` clock. This proves representative-fixture determinism; it is not a live wall-clock guarantee. 2. Existing `qar.preview_bundle.v1` writes exactly `report.json`, `report.html`, and `manifest.json` under a unique `$RUNNER_TEMP` destination. 3. The bundle is read back before `actions/upload-artifact@v7`. -4. The uploaded artifact is downloaded to a separate temporary directory and read back again. +4. The uploaded artifact is downloaded to a separate temporary directory and read back again, bound to the build evidence by full base SHA, source metadata, and all three file hashes. + +Both build and download harnesses fail closed unless the exact contract is schema `"5"`, manifest contract `model_recommendations.v5`, cadence `daily`, and bundle contract `qar.preview_bundle.v1`. Evidence explicitly identifies `source_kind=repository_representative_fixture`; it is not live producer or production-trusted evidence. The workflow does not modify weekly/monthly workflows, publisher, archive/feed, Pages, identity, or persistence. Issue #50 remains the production-trust hardening gate. diff --git a/scripts/d3_build_daily_preview_artifact.py b/scripts/d3_build_daily_preview_artifact.py index 00b17ac..32cda63 100644 --- a/scripts/d3_build_daily_preview_artifact.py +++ b/scripts/d3_build_daily_preview_artifact.py @@ -10,15 +10,19 @@ import sys import tempfile from pathlib import Path +from unittest.mock import patch sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) -from quant_advisor_research.advisory_report import build_advisory_report +from quant_advisor_research import advisory_report from quant_advisor_research.preview_bundle import PreviewBundleError, build_preview_bundle, read_preview_bundle BASE_SHA_PATTERN = re.compile(r"[0-9a-f]{40}") SOURCE_KIND = "repository_representative_fixture" +SOURCE_SCHEMA_VERSION = "5" +SOURCE_CONTRACT_VERSION = "model_recommendations.v5" +BUNDLE_CONTRACT = "qar.preview_bundle.v1" CHECKS = [ "exact_three_files", "canonical_json_readback", @@ -39,13 +43,32 @@ def _sha256(path: Path) -> str: return hashlib.sha256(path.read_bytes()).hexdigest() +def _require_contract(report: dict[str, object], manifest: dict[str, object]) -> None: + source = manifest.get("source") + if not isinstance(source, dict): + raise PreviewBundleError("contract_drift") + if report.get("schema_version") != SOURCE_SCHEMA_VERSION or report.get("cadence") != "daily": + raise PreviewBundleError("contract_drift") + if manifest.get("bundle_contract") != BUNDLE_CONTRACT: + raise PreviewBundleError("contract_drift") + expected_source = { + "schema_version": SOURCE_SCHEMA_VERSION, + "contract_version": SOURCE_CONTRACT_VERSION, + "cadence": "daily", + "as_of": report.get("as_of"), + "generated_at": report.get("generated_at"), + } + if source != expected_source: + raise PreviewBundleError("contract_drift") + + def _evidence( *, output: Path, report: dict[str, object], manifest: dict[str, object], base_sha: str, repeat_equal: bool ) -> dict[str, object]: return { "source_kind": SOURCE_KIND, "base_sha": base_sha, - "bundle_contract": "qar.preview_bundle.v1", + "bundle_contract": BUNDLE_CONTRACT, "source": { "cadence": report["cadence"], "as_of": report["as_of"], @@ -62,6 +85,7 @@ def _evidence( "html_links": ["report.json", "manifest.json"], "checks": CHECKS, "repeat_build_bytes": repeat_equal, + "deterministic_clock": {"mode": "frozen_harness", "generated_at": report["generated_at"]}, } @@ -72,18 +96,29 @@ def build(args: argparse.Namespace) -> None: watchlist = Path(args.political_watchlist) if not events.is_file() or not watchlist.is_file(): raise PreviewBundleError("fixture_missing") - report = build_advisory_report( - as_of=args.as_of, - cadence=args.cadence, - political_events_path=events, - political_watchlist_path=watchlist, - ) - build_preview_bundle(report, output) - evidence = read_preview_bundle(output) + if not args.frozen_generated_at: + raise PreviewBundleError("frozen_generated_at_required") repeat_parent = Path(tempfile.mkdtemp(prefix=f".{output.name}.repeat-", dir=output.parent)) try: repeat_output = repeat_parent / "preview" - build_preview_bundle(report, repeat_output) + with patch.object(advisory_report, "utc_now_iso", return_value=args.frozen_generated_at): + first_report = advisory_report.build_advisory_report( + as_of=args.as_of, + cadence=args.cadence, + political_events_path=events, + political_watchlist_path=watchlist, + ) + build_preview_bundle(first_report, output) + first_evidence = read_preview_bundle(output) + second_report = advisory_report.build_advisory_report( + as_of=args.as_of, + cadence=args.cadence, + political_events_path=events, + political_watchlist_path=watchlist, + ) + build_preview_bundle(second_report, repeat_output) + evidence = first_evidence + _require_contract(dict(evidence.report), dict(evidence.manifest)) repeat_equal = all( (output / name).read_bytes() == (repeat_output / name).read_bytes() for name in ("manifest.json", "report.html", "report.json") @@ -115,6 +150,7 @@ def parser() -> argparse.ArgumentParser: result.add_argument("--artifact-dir", required=True) result.add_argument("--evidence-path", required=True) result.add_argument("--base-sha", required=True) + result.add_argument("--frozen-generated-at", required=True) return result diff --git a/scripts/d3_verify_daily_preview_artifact.py b/scripts/d3_verify_daily_preview_artifact.py index d8d6345..bae45a2 100644 --- a/scripts/d3_verify_daily_preview_artifact.py +++ b/scripts/d3_verify_daily_preview_artifact.py @@ -16,6 +16,9 @@ BASE_SHA_PATTERN = re.compile(r"[0-9a-f]{40}") SOURCE_KIND = "downloaded_repository_representative_fixture" +SOURCE_SCHEMA_VERSION = "5" +SOURCE_CONTRACT_VERSION = "model_recommendations.v5" +BUNDLE_CONTRACT = "qar.preview_bundle.v1" def verify(args: argparse.Namespace) -> None: @@ -25,13 +28,58 @@ def verify(args: argparse.Namespace) -> None: evidence = read_preview_bundle(output) report = dict(evidence.report) manifest = dict(evidence.manifest) - if report.get("cadence") != "daily" or manifest.get("bundle_contract") != "qar.preview_bundle.v1": - raise PreviewBundleError("downloaded_bundle_contract_invalid") + source = manifest.get("source") + expected_source = { + "schema_version": SOURCE_SCHEMA_VERSION, + "contract_version": SOURCE_CONTRACT_VERSION, + "cadence": "daily", + "as_of": report.get("as_of"), + "generated_at": report.get("generated_at"), + } + if ( + not isinstance(source, dict) + or report.get("schema_version") != SOURCE_SCHEMA_VERSION + or report.get("cadence") != "daily" + or manifest.get("bundle_contract") != BUNDLE_CONTRACT + or source != expected_source + ): + raise PreviewBundleError("contract_drift") + try: + build_evidence = json.loads(Path(args.build_evidence_path).read_text(encoding="utf-8")) + except (OSError, TypeError, ValueError, UnicodeError): + raise PreviewBundleError("build_evidence_invalid") from None + if not isinstance(build_evidence, dict): + raise PreviewBundleError("build_evidence_invalid") + if ( + build_evidence.get("source_kind") != "repository_representative_fixture" + or build_evidence.get("base_sha") != args.base_sha + or build_evidence.get("bundle_contract") != BUNDLE_CONTRACT + or build_evidence.get("manifest_source") != source + ): + raise PreviewBundleError("build_evidence_mismatch") + expected_hashes = build_evidence.get("sha256") + if not isinstance(expected_hashes, dict): + raise PreviewBundleError("build_evidence_invalid") + actual_hashes = { + name: hashlib.sha256((output / name).read_bytes()).hexdigest() + for name in ("manifest.json", "report.html", "report.json") + } + if ( + expected_hashes != actual_hashes + or build_evidence.get("files") != ["manifest.json", "report.html", "report.json"] + or build_evidence.get("source") != { + "cadence": "daily", + "as_of": report.get("as_of"), + "generated_at": report.get("generated_at"), + "schema_version": SOURCE_SCHEMA_VERSION, + } + ): + raise PreviewBundleError("build_evidence_mismatch") payload = { "source_kind": SOURCE_KIND, "base_sha": args.base_sha, - "bundle_contract": manifest["bundle_contract"], - "source": manifest["source"], + "bundle_contract": BUNDLE_CONTRACT, + "source": source, "files": sorted(path.name for path in output.iterdir()), "sha256": { name: hashlib.sha256((output / name).read_bytes()).hexdigest() @@ -44,6 +92,7 @@ def verify(args: argparse.Namespace) -> None: "manifest_source_pair", "relative_html_links", ], + "build_evidence_bound": True, } evidence_path = Path(args.evidence_path) if evidence_path.exists(): @@ -56,6 +105,7 @@ def parser() -> argparse.ArgumentParser: result = argparse.ArgumentParser() result.add_argument("--artifact-dir", required=True) result.add_argument("--evidence-path", required=True) + result.add_argument("--build-evidence-path", required=True) result.add_argument("--base-sha", required=True) return result diff --git a/tests/test_d3_daily_preview_artifact.py b/tests/test_d3_daily_preview_artifact.py index 670a51c..9f8d935 100644 --- a/tests/test_d3_daily_preview_artifact.py +++ b/tests/test_d3_daily_preview_artifact.py @@ -36,6 +36,8 @@ def build_args(output: Path, evidence: Path) -> tuple[str, ...]: str(evidence), "--base-sha", BASE_SHA, + "--frozen-generated-at", + "2026-07-15T00:00:00Z", ) @@ -50,6 +52,12 @@ def test_representative_daily_build_emits_deterministic_evidence(tmp_path): assert payload["source_kind"] == "repository_representative_fixture" assert payload["base_sha"] == BASE_SHA assert payload["bundle_contract"] == "qar.preview_bundle.v1" + assert payload["source"] == { + "cadence": "daily", + "as_of": "2026-06-20", + "generated_at": "2026-07-15T00:00:00Z", + "schema_version": "5", + } assert payload["checks"] == [ "exact_three_files", "canonical_json_readback", @@ -73,6 +81,8 @@ def test_verify_rejects_tampered_artifact(tmp_path): str(output), "--evidence-path", str(tmp_path / "downloaded-evidence.json"), + "--build-evidence-path", + str(evidence), "--base-sha", BASE_SHA, ) @@ -98,8 +108,34 @@ def test_build_rejects_non_daily_source_before_output(tmp_path): str(tmp_path / "evidence.json"), "--base-sha", BASE_SHA, + "--frozen-generated-at", + "2026-07-15T00:00:00Z", ) assert result.returncode != 0 assert not (tmp_path / "preview").exists() assert "daily_only" in result.stderr + + +def test_verify_rejects_build_evidence_base_mismatch(tmp_path): + output = tmp_path / "preview" + evidence = tmp_path / "evidence.json" + assert run_script(BUILD, *build_args(output, evidence)).returncode == 0 + payload = json.loads(evidence.read_text(encoding="utf-8")) + payload["base_sha"] = "0" * 40 + evidence.write_text(json.dumps(payload), encoding="utf-8") + + result = run_script( + VERIFY, + "--artifact-dir", + str(output), + "--evidence-path", + str(tmp_path / "downloaded-evidence.json"), + "--build-evidence-path", + str(evidence), + "--base-sha", + BASE_SHA, + ) + + assert result.returncode != 0 + assert "readback_failed" in result.stderr