Skip to content

Commit c255a80

Browse files
Pigbibicodex
andcommitted
feat: add daily preview artifact acceptance slice
Co-Authored-By: Codex <noreply@openai.com>
1 parent c5ba801 commit c255a80

5 files changed

Lines changed: 392 additions & 0 deletions
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
name: QAR vNext D3 Daily Preview Artifact
2+
3+
on:
4+
pull_request:
5+
paths:
6+
- ".github/workflows/qar_vnext_d3_daily_preview_artifact.yml"
7+
- "scripts/d3_build_daily_preview_artifact.py"
8+
- "scripts/d3_verify_daily_preview_artifact.py"
9+
- "src/quant_advisor_research/preview_bundle.py"
10+
- "src/quant_advisor_research/advisory_report.py"
11+
- "tests/test_d3_daily_preview_artifact.py"
12+
workflow_dispatch:
13+
inputs:
14+
as_of:
15+
description: "Representative daily report date"
16+
required: true
17+
default: "2026-06-20"
18+
type: string
19+
20+
permissions:
21+
contents: read
22+
23+
jobs:
24+
build-and-verify:
25+
runs-on: ubuntu-latest
26+
timeout-minutes: 15
27+
steps:
28+
- name: Checkout repository
29+
uses: actions/checkout@v6
30+
with:
31+
fetch-depth: 0
32+
- name: Setup Python
33+
uses: actions/setup-python@v6
34+
with:
35+
python-version: "3.11"
36+
- name: Install advisor package
37+
run: python -m pip install -e .
38+
- name: Build representative daily preview
39+
env:
40+
AS_OF: ${{ inputs.as_of || '2026-06-20' }}
41+
BASE_SHA: ${{ github.event.pull_request.base.sha || github.sha }}
42+
run: |
43+
set -euo pipefail
44+
python scripts/d3_build_daily_preview_artifact.py \
45+
--as-of "${AS_OF}" \
46+
--political-events examples/political_events.example.csv \
47+
--political-watchlist examples/political_watchlist.example.csv \
48+
--artifact-dir "${RUNNER_TEMP}/qar-daily-preview" \
49+
--evidence-path "${RUNNER_TEMP}/qar-daily-preview-build-evidence.json" \
50+
--base-sha "${BASE_SHA}" \
51+
> "${RUNNER_TEMP}/qar-daily-preview-build-evidence.log"
52+
cat "${RUNNER_TEMP}/qar-daily-preview-build-evidence.log"
53+
- name: Upload daily preview artifact
54+
uses: actions/upload-artifact@v7
55+
with:
56+
name: qar-daily-preview
57+
path: ${{ runner.temp }}/qar-daily-preview
58+
if-no-files-found: error
59+
- name: Download daily preview artifact
60+
uses: actions/download-artifact@v7
61+
with:
62+
name: qar-daily-preview
63+
path: ${{ runner.temp }}/qar-daily-preview-downloaded
64+
- name: Read back uploaded artifact
65+
env:
66+
BASE_SHA: ${{ github.event.pull_request.base.sha || github.sha }}
67+
run: |
68+
set -euo pipefail
69+
python scripts/d3_verify_daily_preview_artifact.py \
70+
--artifact-dir "${RUNNER_TEMP}/qar-daily-preview-downloaded" \
71+
--evidence-path "${RUNNER_TEMP}/qar-daily-preview-download-evidence.json" \
72+
--base-sha "${BASE_SHA}"
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# QAR vNext D3 daily action artifact
2+
3+
This workflow is an isolated, repository-representative fixture acceptance slice:
4+
5+
1. `build_advisory_report(..., cadence="daily")` reads the fixed examples CSVs.
6+
2. Existing `qar.preview_bundle.v1` writes exactly `report.json`, `report.html`, and `manifest.json` under a unique `$RUNNER_TEMP` destination.
7+
3. The bundle is read back before `actions/upload-artifact@v7`.
8+
4. The uploaded artifact is downloaded to a separate temporary directory and read back again.
9+
10+
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.
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
#!/usr/bin/env python3
2+
"""Build and locally verify the D3 representative daily preview artifact."""
3+
from __future__ import annotations
4+
5+
import argparse
6+
import hashlib
7+
import json
8+
import re
9+
import shutil
10+
import sys
11+
import tempfile
12+
from pathlib import Path
13+
14+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
15+
16+
from quant_advisor_research.advisory_report import build_advisory_report
17+
from quant_advisor_research.preview_bundle import PreviewBundleError, build_preview_bundle, read_preview_bundle
18+
19+
20+
BASE_SHA_PATTERN = re.compile(r"[0-9a-f]{40}")
21+
SOURCE_KIND = "repository_representative_fixture"
22+
CHECKS = [
23+
"exact_three_files",
24+
"canonical_json_readback",
25+
"manifest_hashes",
26+
"manifest_source_pair",
27+
"relative_html_links",
28+
"repeat_build_bytes",
29+
]
30+
31+
32+
def _require_base_sha(value: str) -> str:
33+
if BASE_SHA_PATTERN.fullmatch(value) is None:
34+
raise PreviewBundleError("base_sha_invalid")
35+
return value
36+
37+
38+
def _sha256(path: Path) -> str:
39+
return hashlib.sha256(path.read_bytes()).hexdigest()
40+
41+
42+
def _evidence(
43+
*, output: Path, report: dict[str, object], manifest: dict[str, object], base_sha: str, repeat_equal: bool
44+
) -> dict[str, object]:
45+
return {
46+
"source_kind": SOURCE_KIND,
47+
"base_sha": base_sha,
48+
"bundle_contract": "qar.preview_bundle.v1",
49+
"source": {
50+
"cadence": report["cadence"],
51+
"as_of": report["as_of"],
52+
"generated_at": report["generated_at"],
53+
"schema_version": report["schema_version"],
54+
},
55+
"provenance": {
56+
"political_events": "examples/political_events.example.csv",
57+
"political_watchlist": "examples/political_watchlist.example.csv",
58+
},
59+
"files": sorted(path.name for path in output.iterdir()),
60+
"sha256": {name: _sha256(output / name) for name in ("manifest.json", "report.html", "report.json")},
61+
"manifest_source": manifest["source"],
62+
"html_links": ["report.json", "manifest.json"],
63+
"checks": CHECKS,
64+
"repeat_build_bytes": repeat_equal,
65+
}
66+
67+
68+
def build(args: argparse.Namespace) -> None:
69+
base_sha = _require_base_sha(args.base_sha)
70+
output = Path(args.artifact_dir)
71+
events = Path(args.political_events)
72+
watchlist = Path(args.political_watchlist)
73+
if not events.is_file() or not watchlist.is_file():
74+
raise PreviewBundleError("fixture_missing")
75+
report = build_advisory_report(
76+
as_of=args.as_of,
77+
cadence=args.cadence,
78+
political_events_path=events,
79+
political_watchlist_path=watchlist,
80+
)
81+
build_preview_bundle(report, output)
82+
evidence = read_preview_bundle(output)
83+
repeat_parent = Path(tempfile.mkdtemp(prefix=f".{output.name}.repeat-", dir=output.parent))
84+
try:
85+
repeat_output = repeat_parent / "preview"
86+
build_preview_bundle(report, repeat_output)
87+
repeat_equal = all(
88+
(output / name).read_bytes() == (repeat_output / name).read_bytes()
89+
for name in ("manifest.json", "report.html", "report.json")
90+
)
91+
finally:
92+
shutil.rmtree(repeat_parent, ignore_errors=True)
93+
if not repeat_equal:
94+
raise PreviewBundleError("repeat_build_non_deterministic")
95+
payload = _evidence(
96+
output=output,
97+
report=dict(evidence.report),
98+
manifest=dict(evidence.manifest),
99+
base_sha=base_sha,
100+
repeat_equal=repeat_equal,
101+
)
102+
evidence_path = Path(args.evidence_path)
103+
if evidence_path.exists():
104+
raise PreviewBundleError("evidence_exists")
105+
evidence_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
106+
print(json.dumps(payload, ensure_ascii=False, sort_keys=True))
107+
108+
109+
def parser() -> argparse.ArgumentParser:
110+
result = argparse.ArgumentParser()
111+
result.add_argument("--as-of", required=True)
112+
result.add_argument("--cadence", choices=("daily", "weekly", "monthly"), default="daily")
113+
result.add_argument("--political-events", required=True)
114+
result.add_argument("--political-watchlist", required=True)
115+
result.add_argument("--artifact-dir", required=True)
116+
result.add_argument("--evidence-path", required=True)
117+
result.add_argument("--base-sha", required=True)
118+
return result
119+
120+
121+
def main() -> None:
122+
try:
123+
build(parser().parse_args())
124+
except PreviewBundleError as exc:
125+
print(f"daily_preview_build_failed:{exc.code}", file=sys.stderr)
126+
raise SystemExit(1) from None
127+
except (OSError, TypeError, ValueError, UnicodeError):
128+
print("daily_preview_build_failed", file=sys.stderr)
129+
raise SystemExit(1) from None
130+
131+
132+
if __name__ == "__main__":
133+
main()
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
#!/usr/bin/env python3
2+
"""Read back a downloaded D3 preview artifact without production integration."""
3+
from __future__ import annotations
4+
5+
import argparse
6+
import hashlib
7+
import json
8+
import re
9+
import sys
10+
from pathlib import Path
11+
12+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
13+
14+
from quant_advisor_research.preview_bundle import PreviewBundleError, read_preview_bundle
15+
16+
17+
BASE_SHA_PATTERN = re.compile(r"[0-9a-f]{40}")
18+
SOURCE_KIND = "downloaded_repository_representative_fixture"
19+
20+
21+
def verify(args: argparse.Namespace) -> None:
22+
if BASE_SHA_PATTERN.fullmatch(args.base_sha) is None:
23+
raise PreviewBundleError("base_sha_invalid")
24+
output = Path(args.artifact_dir)
25+
evidence = read_preview_bundle(output)
26+
report = dict(evidence.report)
27+
manifest = dict(evidence.manifest)
28+
if report.get("cadence") != "daily" or manifest.get("bundle_contract") != "qar.preview_bundle.v1":
29+
raise PreviewBundleError("downloaded_bundle_contract_invalid")
30+
payload = {
31+
"source_kind": SOURCE_KIND,
32+
"base_sha": args.base_sha,
33+
"bundle_contract": manifest["bundle_contract"],
34+
"source": manifest["source"],
35+
"files": sorted(path.name for path in output.iterdir()),
36+
"sha256": {
37+
name: hashlib.sha256((output / name).read_bytes()).hexdigest()
38+
for name in ("manifest.json", "report.html", "report.json")
39+
},
40+
"checks": [
41+
"exact_three_files",
42+
"canonical_json_readback",
43+
"manifest_hashes",
44+
"manifest_source_pair",
45+
"relative_html_links",
46+
],
47+
}
48+
evidence_path = Path(args.evidence_path)
49+
if evidence_path.exists():
50+
raise PreviewBundleError("evidence_exists")
51+
evidence_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
52+
print(json.dumps(payload, ensure_ascii=False, sort_keys=True))
53+
54+
55+
def parser() -> argparse.ArgumentParser:
56+
result = argparse.ArgumentParser()
57+
result.add_argument("--artifact-dir", required=True)
58+
result.add_argument("--evidence-path", required=True)
59+
result.add_argument("--base-sha", required=True)
60+
return result
61+
62+
63+
def main() -> None:
64+
try:
65+
verify(parser().parse_args())
66+
except (PreviewBundleError, OSError, TypeError, ValueError, UnicodeError):
67+
print("daily_preview_readback_failed", file=sys.stderr)
68+
raise SystemExit(1) from None
69+
70+
71+
if __name__ == "__main__":
72+
main()
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
from __future__ import annotations
2+
3+
import json
4+
import subprocess
5+
import sys
6+
from pathlib import Path
7+
8+
9+
ROOT = Path(__file__).resolve().parents[1]
10+
BUILD = ROOT / "scripts/d3_build_daily_preview_artifact.py"
11+
VERIFY = ROOT / "scripts/d3_verify_daily_preview_artifact.py"
12+
BASE_SHA = "c5ba801a8696eec63c7ba348f3f125cb52cd06ff"
13+
14+
15+
def run_script(script: Path, *args: str) -> subprocess.CompletedProcess[str]:
16+
return subprocess.run(
17+
[sys.executable, str(script), *args],
18+
cwd=ROOT,
19+
text=True,
20+
capture_output=True,
21+
check=False,
22+
)
23+
24+
25+
def build_args(output: Path, evidence: Path) -> tuple[str, ...]:
26+
return (
27+
"--as-of",
28+
"2026-06-20",
29+
"--political-events",
30+
str(ROOT / "examples/political_events.example.csv"),
31+
"--political-watchlist",
32+
str(ROOT / "examples/political_watchlist.example.csv"),
33+
"--artifact-dir",
34+
str(output),
35+
"--evidence-path",
36+
str(evidence),
37+
"--base-sha",
38+
BASE_SHA,
39+
)
40+
41+
42+
def test_representative_daily_build_emits_deterministic_evidence(tmp_path):
43+
output = tmp_path / "preview"
44+
evidence = tmp_path / "evidence.json"
45+
result = run_script(BUILD, *build_args(output, evidence))
46+
47+
assert result.returncode == 0, result.stderr
48+
assert {item.name for item in output.iterdir()} == {"report.json", "report.html", "manifest.json"}
49+
payload = json.loads(evidence.read_text(encoding="utf-8"))
50+
assert payload["source_kind"] == "repository_representative_fixture"
51+
assert payload["base_sha"] == BASE_SHA
52+
assert payload["bundle_contract"] == "qar.preview_bundle.v1"
53+
assert payload["checks"] == [
54+
"exact_three_files",
55+
"canonical_json_readback",
56+
"manifest_hashes",
57+
"manifest_source_pair",
58+
"relative_html_links",
59+
"repeat_build_bytes",
60+
]
61+
assert payload["repeat_build_bytes"] is True
62+
63+
64+
def test_verify_rejects_tampered_artifact(tmp_path):
65+
output = tmp_path / "preview"
66+
evidence = tmp_path / "evidence.json"
67+
assert run_script(BUILD, *build_args(output, evidence)).returncode == 0
68+
(output / "report.html").write_text("tampered", encoding="utf-8")
69+
70+
result = run_script(
71+
VERIFY,
72+
"--artifact-dir",
73+
str(output),
74+
"--evidence-path",
75+
str(tmp_path / "downloaded-evidence.json"),
76+
"--base-sha",
77+
BASE_SHA,
78+
)
79+
80+
assert result.returncode != 0
81+
assert "readback_failed" in result.stderr
82+
83+
84+
def test_build_rejects_non_daily_source_before_output(tmp_path):
85+
result = run_script(
86+
BUILD,
87+
"--as-of",
88+
"2026-06-20",
89+
"--cadence",
90+
"weekly",
91+
"--political-events",
92+
str(ROOT / "examples/political_events.example.csv"),
93+
"--political-watchlist",
94+
str(ROOT / "examples/political_watchlist.example.csv"),
95+
"--artifact-dir",
96+
str(tmp_path / "preview"),
97+
"--evidence-path",
98+
str(tmp_path / "evidence.json"),
99+
"--base-sha",
100+
BASE_SHA,
101+
)
102+
103+
assert result.returncode != 0
104+
assert not (tmp_path / "preview").exists()
105+
assert "daily_only" in result.stderr

0 commit comments

Comments
 (0)