Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions .github/workflows/qar_vnext_d3_daily_preview_artifact.yml
Original file line number Diff line number Diff line change
@@ -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"
Comment on lines +9 to +10

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Add transitive modules to the path filter

In the pull_request workflow path filter I checked, changes to transitive validation code such as src/quant_advisor_research/contracts.py do not trigger this job, even though preview_bundle.py imports validate_advisory_report from that module for build_preview_bundle()/read_preview_bundle() validation. A PR that only changes the contract/schema validation can therefore bypass the upload/download artifact acceptance; please include the transitive modules or broaden the src/quant_advisor_research/** filter.

Useful? React with 👍 / 👎.

- "tests/test_d3_daily_preview_artifact.py"
Comment on lines +7 to +11

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include fixture CSVs in the PR path filter

When a PR changes examples/political_events.example.csv or examples/political_watchlist.example.csv, this workflow is skipped because the paths filter only lists workflow/script/src/test files, even though the build step consumes those two fixture files. That lets changes to the representative artifact inputs bypass the artifact upload/download/readback acceptance check.

Useful? React with 👍 / 👎.

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}"
12 changes: 12 additions & 0 deletions docs/qar_vnext_d3_daily_action_artifact.md
Original file line number Diff line number Diff line change
@@ -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.
169 changes: 169 additions & 0 deletions scripts/d3_build_daily_preview_artifact.py
Original file line number Diff line number Diff line change
@@ -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()
122 changes: 122 additions & 0 deletions scripts/d3_verify_daily_preview_artifact.py
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading