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..d4b86b2 --- /dev/null +++ b/.github/workflows/qar_vnext_d3_daily_preview_artifact.yml @@ -0,0 +1,74 @@ +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}" \ + --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 + 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" \ + --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 new file mode 100644 index 0000000..44d902d --- /dev/null +++ b/docs/qar_vnext_d3_daily_action_artifact.md @@ -0,0 +1,12 @@ +# QAR vNext D3 daily action artifact + +This workflow is an isolated, repository-representative fixture acceptance slice: + +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, 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 new file mode 100644 index 0000000..32cda63 --- /dev/null +++ b/scripts/d3_build_daily_preview_artifact.py @@ -0,0 +1,169 @@ +#!/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 +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +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", + "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 _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": BUNDLE_CONTRACT, + "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, + "deterministic_clock": {"mode": "frozen_harness", "generated_at": report["generated_at"]}, + } + + +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") + 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" + 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") + ) + 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) + result.add_argument("--frozen-generated-at", 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..bae45a2 --- /dev/null +++ b/scripts/d3_verify_daily_preview_artifact.py @@ -0,0 +1,122 @@ +#!/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" +SOURCE_SCHEMA_VERSION = "5" +SOURCE_CONTRACT_VERSION = "model_recommendations.v5" +BUNDLE_CONTRACT = "qar.preview_bundle.v1" + + +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) + 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": BUNDLE_CONTRACT, + "source": 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", + ], + "build_evidence_bound": True, + } + 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("--build-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..9f8d935 --- /dev/null +++ b/tests/test_d3_daily_preview_artifact.py @@ -0,0 +1,141 @@ +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, + "--frozen-generated-at", + "2026-07-15T00:00:00Z", + ) + + +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["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", + "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"), + "--build-evidence-path", + str(evidence), + "--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, + "--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