Skip to content

Commit 8c920cc

Browse files
Pigbibicodex
andauthored
ci: add manual verified M0 publisher (#314)
* ci: add manual verified M0 publisher Co-Authored-By: Codex <noreply@openai.com> * fix: harden M0 publisher source trust Co-Authored-By: Codex <noreply@openai.com> --------- Co-authored-by: Codex <noreply@openai.com>
1 parent 17e7f2a commit 8c920cc

3 files changed

Lines changed: 353 additions & 0 deletions

File tree

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
name: Publish verified M0 research ledger
2+
3+
# This deliberately has no push, schedule, or repository-dispatch trigger.
4+
# An operator must select one already-successful QAR weekly run by its immutable
5+
# run ID. This workflow only transfers a research-only ledger to the console;
6+
# it has no strategy, platform, runtime, or order access.
7+
on:
8+
workflow_dispatch:
9+
inputs:
10+
qar_run_id:
11+
description: "Required successful QAR Weekly Intelligent Advisory Review run ID; this workflow never selects the latest run."
12+
required: true
13+
type: string
14+
15+
permissions:
16+
contents: read
17+
18+
concurrency:
19+
group: publish-m0-research-ledger-${{ inputs.qar_run_id }}
20+
cancel-in-progress: false
21+
22+
jobs:
23+
publish-verified-research-ledger:
24+
# Environment secrets and the publisher endpoint are deployable only from
25+
# the protected main branch. A manual dispatch on another ref is skipped.
26+
if: github.ref == 'refs/heads/main'
27+
runs-on: ubuntu-latest
28+
environment: m0-research-publisher
29+
timeout-minutes: 10
30+
env:
31+
QAR_REPOSITORY: QuantStrategyLab/QuantAdvisorResearch
32+
QAR_WEEKLY_WORKFLOW_ID: "285971223"
33+
QAR_WEEKLY_ARTIFACT_NAME: weekly-model-recommendations
34+
QAR_RUN_ID: ${{ inputs.qar_run_id }}
35+
steps:
36+
- name: Checkout publisher revision
37+
uses: actions/checkout@v6
38+
39+
- name: Set up Python
40+
uses: actions/setup-python@v6
41+
with:
42+
python-version: "3.12"
43+
44+
- name: Verify immutable successful QAR weekly run
45+
env:
46+
QAR_ARTIFACT_READ_TOKEN: ${{ secrets.QAR_ARTIFACT_READ_TOKEN }}
47+
run: |
48+
set -euo pipefail
49+
if [ -z "${QAR_ARTIFACT_READ_TOKEN:-}" ]; then
50+
echo "QAR_ARTIFACT_READ_TOKEN is required to read the fixed QAR artifact." >&2
51+
exit 2
52+
fi
53+
run_metadata="${RUNNER_TEMP}/qar-weekly-run.json"
54+
GH_TOKEN="${QAR_ARTIFACT_READ_TOKEN}" gh api \
55+
"repos/${QAR_REPOSITORY}/actions/runs/${QAR_RUN_ID}" > "${run_metadata}"
56+
57+
python3 - "${run_metadata}" <<'PY'
58+
import json
59+
import os
60+
import re
61+
import sys
62+
from pathlib import Path
63+
64+
metadata = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
65+
run_id = os.environ["QAR_RUN_ID"]
66+
expected_repository = os.environ["QAR_REPOSITORY"]
67+
expected_workflow_id = int(os.environ["QAR_WEEKLY_WORKFLOW_ID"])
68+
if not re.fullmatch(r"[1-9][0-9]{0,19}", run_id):
69+
raise SystemExit("qar_run_id must be one explicit positive GitHub Actions run ID")
70+
if metadata.get("id") != int(run_id):
71+
raise SystemExit("QAR run metadata ID mismatch")
72+
if metadata.get("status") != "completed" or metadata.get("conclusion") != "success":
73+
raise SystemExit("QAR run must already be completed successfully")
74+
if metadata.get("workflow_id") != expected_workflow_id:
75+
raise SystemExit("QAR run is not the fixed Weekly Intelligent Advisory Review workflow")
76+
repository = metadata.get("repository")
77+
if not isinstance(repository, dict) or repository.get("full_name") != expected_repository:
78+
raise SystemExit("QAR run repository mismatch")
79+
head_repository = metadata.get("head_repository")
80+
if not isinstance(head_repository, dict) or head_repository.get("full_name") != expected_repository:
81+
raise SystemExit("QAR run head repository mismatch")
82+
if metadata.get("head_branch") != "main":
83+
raise SystemExit("QAR run must originate from the main branch")
84+
if metadata.get("event") not in {"schedule", "workflow_dispatch"}:
85+
raise SystemExit("QAR run event is not trusted for M0 publication")
86+
revision = metadata.get("head_sha")
87+
if not isinstance(revision, str) or not re.fullmatch(r"[0-9a-f]{40}", revision):
88+
raise SystemExit("QAR run head revision is invalid")
89+
with Path(os.environ["GITHUB_ENV"]).open("a", encoding="utf-8") as env_file:
90+
env_file.write(f"QAR_SOURCE_REVISION={revision}\n")
91+
PY
92+
93+
- name: Resolve exactly one fixed QAR artifact
94+
env:
95+
QAR_ARTIFACT_READ_TOKEN: ${{ secrets.QAR_ARTIFACT_READ_TOKEN }}
96+
run: |
97+
set -euo pipefail
98+
artifacts_metadata="${RUNNER_TEMP}/qar-weekly-artifacts.json"
99+
GH_TOKEN="${QAR_ARTIFACT_READ_TOKEN}" gh api \
100+
"repos/${QAR_REPOSITORY}/actions/runs/${QAR_RUN_ID}/artifacts?per_page=100" > "${artifacts_metadata}"
101+
102+
python3 - "${artifacts_metadata}" <<'PY'
103+
import json
104+
import os
105+
from pathlib import Path
106+
107+
payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
108+
artifacts = payload.get("artifacts")
109+
if not isinstance(artifacts, list):
110+
raise SystemExit("QAR artifact listing is invalid")
111+
expected_name = os.environ["QAR_WEEKLY_ARTIFACT_NAME"]
112+
candidates = [
113+
artifact for artifact in artifacts
114+
if isinstance(artifact, dict)
115+
and artifact.get("name") == expected_name
116+
and artifact.get("expired") is False
117+
]
118+
if len(candidates) != 1:
119+
raise SystemExit("QAR run must expose exactly one unexpired fixed weekly artifact")
120+
artifact = candidates[0]
121+
artifact_id = artifact.get("id")
122+
if not isinstance(artifact_id, int) or artifact_id <= 0:
123+
raise SystemExit("QAR artifact ID is invalid")
124+
workflow_run = artifact.get("workflow_run")
125+
if not isinstance(workflow_run, dict) or workflow_run.get("id") != int(os.environ["QAR_RUN_ID"]):
126+
raise SystemExit("QAR artifact workflow-run binding mismatch")
127+
with Path(os.environ["GITHUB_ENV"]).open("a", encoding="utf-8") as env_file:
128+
env_file.write(f"QAR_ARTIFACT_ID={artifact_id}\n")
129+
PY
130+
131+
- name: Download and verify one M0 source snapshot
132+
env:
133+
QAR_ARTIFACT_READ_TOKEN: ${{ secrets.QAR_ARTIFACT_READ_TOKEN }}
134+
run: |
135+
set -euo pipefail
136+
artifact_zip="${RUNNER_TEMP}/qar-weekly-artifact.zip"
137+
source_snapshot="${RUNNER_TEMP}/m0-research-source-snapshot.json"
138+
GH_TOKEN="${QAR_ARTIFACT_READ_TOKEN}" gh api --method GET \
139+
"repos/${QAR_REPOSITORY}/actions/artifacts/${QAR_ARTIFACT_ID}/zip" \
140+
> "${artifact_zip}"
141+
142+
python3 - "${artifact_zip}" "${source_snapshot}" <<'PY'
143+
import hashlib
144+
import json
145+
import os
146+
import re
147+
import sys
148+
import zipfile
149+
from pathlib import Path
150+
151+
archive_path = Path(sys.argv[1])
152+
output_path = Path(sys.argv[2])
153+
snapshot_pattern = re.compile(
154+
r"(?:[^/]+/)*m0_research_source_snapshot_[0-9]{4}-[0-9]{2}-[0-9]{2}\.json"
155+
)
156+
try:
157+
with zipfile.ZipFile(archive_path) as archive:
158+
matches = []
159+
for info in archive.infolist():
160+
name = info.filename
161+
if name.startswith(("/", "\\")) or "\\" in name or ".." in Path(name).parts:
162+
raise SystemExit("QAR artifact contains an unsafe archive member path")
163+
if not info.is_dir() and snapshot_pattern.fullmatch(name):
164+
matches.append(info)
165+
if len(matches) != 1:
166+
raise SystemExit("QAR artifact must contain exactly one dated M0 source snapshot")
167+
source_info = matches[0]
168+
if source_info.file_size <= 0 or source_info.file_size > 2 * 1024 * 1024:
169+
raise SystemExit("QAR M0 source snapshot size is invalid")
170+
raw = archive.read(source_info)
171+
except (OSError, zipfile.BadZipFile) as exc:
172+
raise SystemExit("QAR artifact ZIP is invalid") from exc
173+
174+
try:
175+
snapshot = json.loads(raw.decode("utf-8"))
176+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
177+
raise SystemExit("QAR M0 source snapshot is not JSON") from exc
178+
if not isinstance(snapshot, dict):
179+
raise SystemExit("QAR M0 source snapshot must be a JSON object")
180+
if snapshot.get("schema_version") != "qsl_m0_research_source_snapshot.v1":
181+
raise SystemExit("QAR M0 source snapshot schema mismatch")
182+
if snapshot.get("source_id") != "quant-advisor-research":
183+
raise SystemExit("QAR M0 source snapshot source ID mismatch")
184+
digest = snapshot.get("source_report_digest")
185+
if not isinstance(digest, str) or not re.fullmatch(r"[0-9a-f]{64}", digest):
186+
raise SystemExit("QAR M0 source report digest is invalid")
187+
output_path.write_bytes(raw)
188+
sha256 = hashlib.sha256(raw).hexdigest()
189+
with Path(os.environ["GITHUB_ENV"]).open("a", encoding="utf-8") as env_file:
190+
env_file.write(f"M0_SOURCE_SNAPSHOT_PATH={output_path}\n")
191+
env_file.write(f"M0_SOURCE_SNAPSHOT_SHA256={sha256}\n")
192+
PY
193+
194+
- name: Build and publish verified no-order M0 ledger
195+
env:
196+
QSL_M0_RESEARCH_LEDGER_PUBLISH_URL: ${{ vars.M0_RESEARCH_SYNC_URL }}
197+
QSL_M0_RESEARCH_LEDGER_PUBLISH_TOKEN: ${{ secrets.M0_RESEARCH_SYNC_TOKEN }}
198+
run: |
199+
set -euo pipefail
200+
if [ -z "${QSL_M0_RESEARCH_LEDGER_PUBLISH_URL:-}" ] || [ -z "${QSL_M0_RESEARCH_LEDGER_PUBLISH_TOKEN:-}" ]; then
201+
echo "The dedicated M0 publication URL variable and token secret are required." >&2
202+
exit 2
203+
fi
204+
python3 python/scripts/build_m0_research_publisher_envelope.py \
205+
--source-snapshot "${M0_SOURCE_SNAPSHOT_PATH}" \
206+
--output "${RUNNER_TEMP}/m0-research-publisher-envelope.json" \
207+
--source-artifact-repository "${QAR_REPOSITORY}" \
208+
--source-artifact-revision "${QAR_SOURCE_REVISION}" \
209+
--source-artifact-run-id "${QAR_RUN_ID}" \
210+
--source-artifact-id "${QAR_ARTIFACT_ID}" \
211+
--source-artifact-sha256 "${M0_SOURCE_SNAPSHOT_SHA256}" \
212+
--producer-repository "${GITHUB_REPOSITORY}" \
213+
--producer-revision "${GITHUB_SHA}" \
214+
--publish

docs/m0_research_publisher_envelope_contract.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,3 +85,46 @@ header;它从不写进封套、标准输出、错误信息或日志。该工
8585
发布 endpoint 只是研究资料接收端:接收者仍必须重验 schema、artifact metadata、
8686
`ledger_sha256``ledger.policy``research_only/no_order` 固定值。接收、展示或
8787
排队研究任务都不能构成 P4/P5/P6、Shadow、Paper 或 live 授权。
88+
89+
## 手动发布已验证的 QAR 周报
90+
91+
`.github/workflows/publish-m0-research-ledger.yml` 是唯一的跨仓 M0 发布入口。
92+
它只有 `workflow_dispatch`,不按 push、定时任务或其他 workflow 事件自动运行。操作员
93+
必须输入一个**已经成功完成**`QuantStrategyLab/QuantAdvisorResearch`「Weekly
94+
Intelligent Advisory Review」run ID;它不会检索、猜测或自动采用最新 run。
95+
96+
该 job 必须在 `QuantRuntimeSettings``main` 分支运行(`github.ref` 必须为
97+
`refs/heads/main`),并绑定专用 GitHub Environment `m0-research-publisher`。环境的
98+
deployment branch 也必须只允许 `main`;从其他 ref 手动 dispatch 时 job 会跳过,不能
99+
取得任何环境配置或发布研究台账。
100+
101+
该入口固定只读以下来源:
102+
103+
- repository:`QuantStrategyLab/QuantAdvisorResearch`
104+
- workflow:`Weekly Intelligent Advisory Review`(GitHub workflow ID `285971223`);
105+
- artifact:`weekly-model-recommendations`
106+
- artifact 内唯一命名为 `m0_research_source_snapshot_YYYY-MM-DD.json` 的文件。
107+
108+
在下载前,workflow 用专用的 `QAR_ARTIFACT_READ_TOKEN` 验证 run ID、成功状态、
109+
workflow 身份、来源仓库和 `head_repository``head_branch=main`、可信 event(仅
110+
`schedule``workflow_dispatch`)、immutable `head_sha`,以及 artifact 与该 run 的绑定。下载后,
111+
它拒绝不安全 ZIP 路径、多个或缺失 snapshot、超过 2 MiB 的 snapshot、错误 schema/source
112+
ID 或无效 report digest,并计算**原始 snapshot 字节**的 SHA-256。该 SHA、QAR revision、
113+
run ID 和 artifact ID 都作为 `source_artifact` metadata 显式传给构建器,构建器会再次验证
114+
字节 SHA 后才生成封套。
115+
116+
工作流只使用两个专用发布值,并映射到构建器固定读取的环境变量:
117+
118+
| GitHub 配置 | 构建器环境变量 | 用途 |
119+
| --- | --- | --- |
120+
| variable `M0_RESEARCH_SYNC_URL` | `QSL_M0_RESEARCH_LEDGER_PUBLISH_URL` | HTTPS 研究台账接收地址 |
121+
| secret `M0_RESEARCH_SYNC_TOKEN` | `QSL_M0_RESEARCH_LEDGER_PUBLISH_TOKEN` | 接收端专用 Bearer token |
122+
123+
`QAR_ARTIFACT_READ_TOKEN``M0_RESEARCH_SYNC_TOKEN``M0_RESEARCH_SYNC_URL` 都必须配置
124+
`m0-research-publisher` Environment 中,而不是 repository-level 默认作用域。两个 token
125+
必须是不同的值和不同的最小权限用途:前者只能读取固定 QAR repository 的 Actions run/artifact,
126+
后者只能向 M0 接收端发布封套;不得复用、互相授予或写入运行时/平台配置。
127+
128+
URL、发布 token 和 QAR 读取 token 不会写进封套、`GITHUB_STEP_SUMMARY` 或 workflow 输出。该
129+
workflow 不读取运行时、平台、selector、策略或券商配置;其唯一网络写入是构建器在
130+
`--publish` 明确指定时,对上述研究接收地址发送经过校验的 no-order 封套。
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
from __future__ import annotations
2+
3+
import re
4+
import unittest
5+
from pathlib import Path
6+
7+
8+
ROOT = Path(__file__).resolve().parents[2]
9+
WORKFLOW = ROOT / ".github" / "workflows" / "publish-m0-research-ledger.yml"
10+
11+
12+
class ManualM0ResearchPublisherWorkflowTest(unittest.TestCase):
13+
def test_workflow_is_manual_and_binds_one_explicit_successful_qar_artifact(self):
14+
workflow = WORKFLOW.read_text(encoding="utf-8")
15+
16+
self.assertIn("workflow_dispatch:", workflow)
17+
self.assertNotRegex(workflow, r"(?m)^ (?:push|pull_request|schedule|repository_dispatch):")
18+
self.assertIn("if: github.ref == 'refs/heads/main'", workflow)
19+
self.assertIn("environment: m0-research-publisher", workflow)
20+
self.assertRegex(
21+
workflow,
22+
r"(?s)qar_run_id:\n.*?required: true\n.*?type: string",
23+
)
24+
self.assertIn("never selects the latest run", workflow)
25+
self.assertNotIn("gh run list", workflow)
26+
self.assertNotIn("actions/runs?", workflow)
27+
self.assertIn("QAR_REPOSITORY: QuantStrategyLab/QuantAdvisorResearch", workflow)
28+
self.assertIn('QAR_WEEKLY_WORKFLOW_ID: "285971223"', workflow)
29+
self.assertIn("QAR_WEEKLY_ARTIFACT_NAME: weekly-model-recommendations", workflow)
30+
self.assertIn('"repos/${QAR_REPOSITORY}/actions/runs/${QAR_RUN_ID}"', workflow)
31+
self.assertIn("QAR run must already be completed successfully", workflow)
32+
self.assertIn("QAR run is not the fixed Weekly Intelligent Advisory Review workflow", workflow)
33+
self.assertIn("QAR run head repository mismatch", workflow)
34+
self.assertIn("QAR run must originate from the main branch", workflow)
35+
self.assertIn("QAR run event is not trusted for M0 publication", workflow)
36+
self.assertIn('head_repository = metadata.get("head_repository")', workflow)
37+
self.assertIn('head_repository.get("full_name") != expected_repository', workflow)
38+
self.assertIn('metadata.get("head_branch") != "main"', workflow)
39+
self.assertIn('metadata.get("event") not in {"schedule", "workflow_dispatch"}', workflow)
40+
self.assertIn("QAR artifact workflow-run binding mismatch", workflow)
41+
self.assertIn("QAR artifact must contain exactly one dated M0 source snapshot", workflow)
42+
self.assertIn("m0_research_source_snapshot_[0-9]{4}-[0-9]{2}-[0-9]{2}", workflow)
43+
self.assertIn("M0_SOURCE_SNAPSHOT_SHA256", workflow)
44+
self.assertIn('> "${artifact_zip}"', workflow)
45+
self.assertNotIn("--output \"${artifact_zip}\"", workflow)
46+
self.assertIn("--source-artifact-revision \"${QAR_SOURCE_REVISION}\"", workflow)
47+
self.assertIn("--source-artifact-run-id \"${QAR_RUN_ID}\"", workflow)
48+
self.assertIn("--source-artifact-id \"${QAR_ARTIFACT_ID}\"", workflow)
49+
self.assertIn("--source-artifact-sha256 \"${M0_SOURCE_SNAPSHOT_SHA256}\"", workflow)
50+
51+
def test_workflow_uses_only_dedicated_read_and_publish_credentials(self):
52+
workflow = WORKFLOW.read_text(encoding="utf-8")
53+
54+
self.assertIn("QAR_ARTIFACT_READ_TOKEN: ${{ secrets.QAR_ARTIFACT_READ_TOKEN }}", workflow)
55+
self.assertIn("GH_TOKEN=\"${QAR_ARTIFACT_READ_TOKEN}\" gh api", workflow)
56+
self.assertIn(
57+
"QSL_M0_RESEARCH_LEDGER_PUBLISH_URL: ${{ vars.M0_RESEARCH_SYNC_URL }}",
58+
workflow,
59+
)
60+
self.assertIn(
61+
"QSL_M0_RESEARCH_LEDGER_PUBLISH_TOKEN: ${{ secrets.M0_RESEARCH_SYNC_TOKEN }}",
62+
workflow,
63+
)
64+
self.assertIn("build_m0_research_publisher_envelope.py", workflow)
65+
self.assertIn("--publish", workflow)
66+
self.assertNotIn("gh workflow run", workflow)
67+
self.assertNotIn("runtime_settings.py", workflow)
68+
self.assertNotIn("platform-config", workflow)
69+
self.assertNotIn("manual-strategy-switch", workflow)
70+
self.assertNotIn("broker", workflow.lower())
71+
self.assertNotIn("selector", workflow.lower())
72+
self.assertNotIn("${QSL_M0_RESEARCH_LEDGER_PUBLISH_URL}", workflow)
73+
self.assertNotIn("${QSL_M0_RESEARCH_LEDGER_PUBLISH_TOKEN}", workflow)
74+
75+
read_token_steps = workflow.count("QAR_ARTIFACT_READ_TOKEN: ${{ secrets.QAR_ARTIFACT_READ_TOKEN }}")
76+
self.assertEqual(read_token_steps, 3)
77+
self.assertEqual(workflow.count("QSL_M0_RESEARCH_LEDGER_PUBLISH_URL: ${{ vars.M0_RESEARCH_SYNC_URL }}"), 1)
78+
self.assertEqual(workflow.count("QSL_M0_RESEARCH_LEDGER_PUBLISH_TOKEN: ${{ secrets.M0_RESEARCH_SYNC_TOKEN }}"), 1)
79+
build_step = workflow.split("- name: Build and publish verified no-order M0 ledger", maxsplit=1)[1]
80+
self.assertNotIn("QAR_ARTIFACT_READ_TOKEN", build_step)
81+
82+
def test_sensitive_values_are_not_emitted_by_workflow_commands(self):
83+
workflow = WORKFLOW.read_text(encoding="utf-8")
84+
85+
for line in workflow.splitlines():
86+
self.assertFalse(
87+
re.search(
88+
r"\b(?:echo|printf)\b.*\$\{?(?:QAR_ARTIFACT_READ_TOKEN|QSL_M0_RESEARCH_LEDGER_PUBLISH_(?:URL|TOKEN))",
89+
line,
90+
),
91+
line,
92+
)
93+
94+
95+
if __name__ == "__main__":
96+
unittest.main()

0 commit comments

Comments
 (0)