diff --git a/.github/workflows/publish-m0-research-ledger.yml b/.github/workflows/publish-m0-research-ledger.yml new file mode 100644 index 0000000..a9959cf --- /dev/null +++ b/.github/workflows/publish-m0-research-ledger.yml @@ -0,0 +1,214 @@ +name: Publish verified M0 research ledger + +# This deliberately has no push, schedule, or repository-dispatch trigger. +# An operator must select one already-successful QAR weekly run by its immutable +# run ID. This workflow only transfers a research-only ledger to the console; +# it has no strategy, platform, runtime, or order access. +on: + workflow_dispatch: + inputs: + qar_run_id: + description: "Required successful QAR Weekly Intelligent Advisory Review run ID; this workflow never selects the latest run." + required: true + type: string + +permissions: + contents: read + +concurrency: + group: publish-m0-research-ledger-${{ inputs.qar_run_id }} + cancel-in-progress: false + +jobs: + publish-verified-research-ledger: + # Environment secrets and the publisher endpoint are deployable only from + # the protected main branch. A manual dispatch on another ref is skipped. + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + environment: m0-research-publisher + timeout-minutes: 10 + env: + QAR_REPOSITORY: QuantStrategyLab/QuantAdvisorResearch + QAR_WEEKLY_WORKFLOW_ID: "285971223" + QAR_WEEKLY_ARTIFACT_NAME: weekly-model-recommendations + QAR_RUN_ID: ${{ inputs.qar_run_id }} + steps: + - name: Checkout publisher revision + uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Verify immutable successful QAR weekly run + env: + QAR_ARTIFACT_READ_TOKEN: ${{ secrets.QAR_ARTIFACT_READ_TOKEN }} + run: | + set -euo pipefail + if [ -z "${QAR_ARTIFACT_READ_TOKEN:-}" ]; then + echo "QAR_ARTIFACT_READ_TOKEN is required to read the fixed QAR artifact." >&2 + exit 2 + fi + run_metadata="${RUNNER_TEMP}/qar-weekly-run.json" + GH_TOKEN="${QAR_ARTIFACT_READ_TOKEN}" gh api \ + "repos/${QAR_REPOSITORY}/actions/runs/${QAR_RUN_ID}" > "${run_metadata}" + + python3 - "${run_metadata}" <<'PY' + import json + import os + import re + import sys + from pathlib import Path + + metadata = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) + run_id = os.environ["QAR_RUN_ID"] + expected_repository = os.environ["QAR_REPOSITORY"] + expected_workflow_id = int(os.environ["QAR_WEEKLY_WORKFLOW_ID"]) + if not re.fullmatch(r"[1-9][0-9]{0,19}", run_id): + raise SystemExit("qar_run_id must be one explicit positive GitHub Actions run ID") + if metadata.get("id") != int(run_id): + raise SystemExit("QAR run metadata ID mismatch") + if metadata.get("status") != "completed" or metadata.get("conclusion") != "success": + raise SystemExit("QAR run must already be completed successfully") + if metadata.get("workflow_id") != expected_workflow_id: + raise SystemExit("QAR run is not the fixed Weekly Intelligent Advisory Review workflow") + repository = metadata.get("repository") + if not isinstance(repository, dict) or repository.get("full_name") != expected_repository: + raise SystemExit("QAR run repository mismatch") + head_repository = metadata.get("head_repository") + if not isinstance(head_repository, dict) or head_repository.get("full_name") != expected_repository: + raise SystemExit("QAR run head repository mismatch") + if metadata.get("head_branch") != "main": + raise SystemExit("QAR run must originate from the main branch") + if metadata.get("event") not in {"schedule", "workflow_dispatch"}: + raise SystemExit("QAR run event is not trusted for M0 publication") + revision = metadata.get("head_sha") + if not isinstance(revision, str) or not re.fullmatch(r"[0-9a-f]{40}", revision): + raise SystemExit("QAR run head revision is invalid") + with Path(os.environ["GITHUB_ENV"]).open("a", encoding="utf-8") as env_file: + env_file.write(f"QAR_SOURCE_REVISION={revision}\n") + PY + + - name: Resolve exactly one fixed QAR artifact + env: + QAR_ARTIFACT_READ_TOKEN: ${{ secrets.QAR_ARTIFACT_READ_TOKEN }} + run: | + set -euo pipefail + artifacts_metadata="${RUNNER_TEMP}/qar-weekly-artifacts.json" + GH_TOKEN="${QAR_ARTIFACT_READ_TOKEN}" gh api \ + "repos/${QAR_REPOSITORY}/actions/runs/${QAR_RUN_ID}/artifacts?per_page=100" > "${artifacts_metadata}" + + python3 - "${artifacts_metadata}" <<'PY' + import json + import os + from pathlib import Path + + payload = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) + artifacts = payload.get("artifacts") + if not isinstance(artifacts, list): + raise SystemExit("QAR artifact listing is invalid") + expected_name = os.environ["QAR_WEEKLY_ARTIFACT_NAME"] + candidates = [ + artifact for artifact in artifacts + if isinstance(artifact, dict) + and artifact.get("name") == expected_name + and artifact.get("expired") is False + ] + if len(candidates) != 1: + raise SystemExit("QAR run must expose exactly one unexpired fixed weekly artifact") + artifact = candidates[0] + artifact_id = artifact.get("id") + if not isinstance(artifact_id, int) or artifact_id <= 0: + raise SystemExit("QAR artifact ID is invalid") + workflow_run = artifact.get("workflow_run") + if not isinstance(workflow_run, dict) or workflow_run.get("id") != int(os.environ["QAR_RUN_ID"]): + raise SystemExit("QAR artifact workflow-run binding mismatch") + with Path(os.environ["GITHUB_ENV"]).open("a", encoding="utf-8") as env_file: + env_file.write(f"QAR_ARTIFACT_ID={artifact_id}\n") + PY + + - name: Download and verify one M0 source snapshot + env: + QAR_ARTIFACT_READ_TOKEN: ${{ secrets.QAR_ARTIFACT_READ_TOKEN }} + run: | + set -euo pipefail + artifact_zip="${RUNNER_TEMP}/qar-weekly-artifact.zip" + source_snapshot="${RUNNER_TEMP}/m0-research-source-snapshot.json" + GH_TOKEN="${QAR_ARTIFACT_READ_TOKEN}" gh api --method GET \ + "repos/${QAR_REPOSITORY}/actions/artifacts/${QAR_ARTIFACT_ID}/zip" \ + > "${artifact_zip}" + + python3 - "${artifact_zip}" "${source_snapshot}" <<'PY' + import hashlib + import json + import os + import re + import sys + import zipfile + from pathlib import Path + + archive_path = Path(sys.argv[1]) + output_path = Path(sys.argv[2]) + snapshot_pattern = re.compile( + r"(?:[^/]+/)*m0_research_source_snapshot_[0-9]{4}-[0-9]{2}-[0-9]{2}\.json" + ) + try: + with zipfile.ZipFile(archive_path) as archive: + matches = [] + for info in archive.infolist(): + name = info.filename + if name.startswith(("/", "\\")) or "\\" in name or ".." in Path(name).parts: + raise SystemExit("QAR artifact contains an unsafe archive member path") + if not info.is_dir() and snapshot_pattern.fullmatch(name): + matches.append(info) + if len(matches) != 1: + raise SystemExit("QAR artifact must contain exactly one dated M0 source snapshot") + source_info = matches[0] + if source_info.file_size <= 0 or source_info.file_size > 2 * 1024 * 1024: + raise SystemExit("QAR M0 source snapshot size is invalid") + raw = archive.read(source_info) + except (OSError, zipfile.BadZipFile) as exc: + raise SystemExit("QAR artifact ZIP is invalid") from exc + + try: + snapshot = json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise SystemExit("QAR M0 source snapshot is not JSON") from exc + if not isinstance(snapshot, dict): + raise SystemExit("QAR M0 source snapshot must be a JSON object") + if snapshot.get("schema_version") != "qsl_m0_research_source_snapshot.v1": + raise SystemExit("QAR M0 source snapshot schema mismatch") + if snapshot.get("source_id") != "quant-advisor-research": + raise SystemExit("QAR M0 source snapshot source ID mismatch") + digest = snapshot.get("source_report_digest") + if not isinstance(digest, str) or not re.fullmatch(r"[0-9a-f]{64}", digest): + raise SystemExit("QAR M0 source report digest is invalid") + output_path.write_bytes(raw) + sha256 = hashlib.sha256(raw).hexdigest() + with Path(os.environ["GITHUB_ENV"]).open("a", encoding="utf-8") as env_file: + env_file.write(f"M0_SOURCE_SNAPSHOT_PATH={output_path}\n") + env_file.write(f"M0_SOURCE_SNAPSHOT_SHA256={sha256}\n") + PY + + - name: Build and publish verified no-order M0 ledger + env: + QSL_M0_RESEARCH_LEDGER_PUBLISH_URL: ${{ vars.M0_RESEARCH_SYNC_URL }} + QSL_M0_RESEARCH_LEDGER_PUBLISH_TOKEN: ${{ secrets.M0_RESEARCH_SYNC_TOKEN }} + run: | + set -euo pipefail + if [ -z "${QSL_M0_RESEARCH_LEDGER_PUBLISH_URL:-}" ] || [ -z "${QSL_M0_RESEARCH_LEDGER_PUBLISH_TOKEN:-}" ]; then + echo "The dedicated M0 publication URL variable and token secret are required." >&2 + exit 2 + fi + python3 python/scripts/build_m0_research_publisher_envelope.py \ + --source-snapshot "${M0_SOURCE_SNAPSHOT_PATH}" \ + --output "${RUNNER_TEMP}/m0-research-publisher-envelope.json" \ + --source-artifact-repository "${QAR_REPOSITORY}" \ + --source-artifact-revision "${QAR_SOURCE_REVISION}" \ + --source-artifact-run-id "${QAR_RUN_ID}" \ + --source-artifact-id "${QAR_ARTIFACT_ID}" \ + --source-artifact-sha256 "${M0_SOURCE_SNAPSHOT_SHA256}" \ + --producer-repository "${GITHUB_REPOSITORY}" \ + --producer-revision "${GITHUB_SHA}" \ + --publish diff --git a/docs/m0_research_publisher_envelope_contract.md b/docs/m0_research_publisher_envelope_contract.md index 0aad06f..cf0fddc 100644 --- a/docs/m0_research_publisher_envelope_contract.md +++ b/docs/m0_research_publisher_envelope_contract.md @@ -85,3 +85,46 @@ header;它从不写进封套、标准输出、错误信息或日志。该工 发布 endpoint 只是研究资料接收端:接收者仍必须重验 schema、artifact metadata、 `ledger_sha256` 和 `ledger.policy` 的 `research_only/no_order` 固定值。接收、展示或 排队研究任务都不能构成 P4/P5/P6、Shadow、Paper 或 live 授权。 + +## 手动发布已验证的 QAR 周报 + +`.github/workflows/publish-m0-research-ledger.yml` 是唯一的跨仓 M0 发布入口。 +它只有 `workflow_dispatch`,不按 push、定时任务或其他 workflow 事件自动运行。操作员 +必须输入一个**已经成功完成**的 `QuantStrategyLab/QuantAdvisorResearch`「Weekly +Intelligent Advisory Review」run ID;它不会检索、猜测或自动采用最新 run。 + +该 job 必须在 `QuantRuntimeSettings` 的 `main` 分支运行(`github.ref` 必须为 +`refs/heads/main`),并绑定专用 GitHub Environment `m0-research-publisher`。环境的 +deployment branch 也必须只允许 `main`;从其他 ref 手动 dispatch 时 job 会跳过,不能 +取得任何环境配置或发布研究台账。 + +该入口固定只读以下来源: + +- repository:`QuantStrategyLab/QuantAdvisorResearch`; +- workflow:`Weekly Intelligent Advisory Review`(GitHub workflow ID `285971223`); +- artifact:`weekly-model-recommendations`; +- artifact 内唯一命名为 `m0_research_source_snapshot_YYYY-MM-DD.json` 的文件。 + +在下载前,workflow 用专用的 `QAR_ARTIFACT_READ_TOKEN` 验证 run ID、成功状态、 +workflow 身份、来源仓库和 `head_repository`、`head_branch=main`、可信 event(仅 +`schedule` 或 `workflow_dispatch`)、immutable `head_sha`,以及 artifact 与该 run 的绑定。下载后, +它拒绝不安全 ZIP 路径、多个或缺失 snapshot、超过 2 MiB 的 snapshot、错误 schema/source +ID 或无效 report digest,并计算**原始 snapshot 字节**的 SHA-256。该 SHA、QAR revision、 +run ID 和 artifact ID 都作为 `source_artifact` metadata 显式传给构建器,构建器会再次验证 +字节 SHA 后才生成封套。 + +工作流只使用两个专用发布值,并映射到构建器固定读取的环境变量: + +| GitHub 配置 | 构建器环境变量 | 用途 | +| --- | --- | --- | +| variable `M0_RESEARCH_SYNC_URL` | `QSL_M0_RESEARCH_LEDGER_PUBLISH_URL` | HTTPS 研究台账接收地址 | +| secret `M0_RESEARCH_SYNC_TOKEN` | `QSL_M0_RESEARCH_LEDGER_PUBLISH_TOKEN` | 接收端专用 Bearer token | + +`QAR_ARTIFACT_READ_TOKEN`、`M0_RESEARCH_SYNC_TOKEN` 和 `M0_RESEARCH_SYNC_URL` 都必须配置 +在 `m0-research-publisher` Environment 中,而不是 repository-level 默认作用域。两个 token +必须是不同的值和不同的最小权限用途:前者只能读取固定 QAR repository 的 Actions run/artifact, +后者只能向 M0 接收端发布封套;不得复用、互相授予或写入运行时/平台配置。 + +URL、发布 token 和 QAR 读取 token 不会写进封套、`GITHUB_STEP_SUMMARY` 或 workflow 输出。该 +workflow 不读取运行时、平台、selector、策略或券商配置;其唯一网络写入是构建器在 +`--publish` 明确指定时,对上述研究接收地址发送经过校验的 no-order 封套。 diff --git a/python/tests/test_manual_m0_research_publisher_workflow.py b/python/tests/test_manual_m0_research_publisher_workflow.py new file mode 100644 index 0000000..3e7bc4c --- /dev/null +++ b/python/tests/test_manual_m0_research_publisher_workflow.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import re +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +WORKFLOW = ROOT / ".github" / "workflows" / "publish-m0-research-ledger.yml" + + +class ManualM0ResearchPublisherWorkflowTest(unittest.TestCase): + def test_workflow_is_manual_and_binds_one_explicit_successful_qar_artifact(self): + workflow = WORKFLOW.read_text(encoding="utf-8") + + self.assertIn("workflow_dispatch:", workflow) + self.assertNotRegex(workflow, r"(?m)^ (?:push|pull_request|schedule|repository_dispatch):") + self.assertIn("if: github.ref == 'refs/heads/main'", workflow) + self.assertIn("environment: m0-research-publisher", workflow) + self.assertRegex( + workflow, + r"(?s)qar_run_id:\n.*?required: true\n.*?type: string", + ) + self.assertIn("never selects the latest run", workflow) + self.assertNotIn("gh run list", workflow) + self.assertNotIn("actions/runs?", workflow) + self.assertIn("QAR_REPOSITORY: QuantStrategyLab/QuantAdvisorResearch", workflow) + self.assertIn('QAR_WEEKLY_WORKFLOW_ID: "285971223"', workflow) + self.assertIn("QAR_WEEKLY_ARTIFACT_NAME: weekly-model-recommendations", workflow) + self.assertIn('"repos/${QAR_REPOSITORY}/actions/runs/${QAR_RUN_ID}"', workflow) + self.assertIn("QAR run must already be completed successfully", workflow) + self.assertIn("QAR run is not the fixed Weekly Intelligent Advisory Review workflow", workflow) + self.assertIn("QAR run head repository mismatch", workflow) + self.assertIn("QAR run must originate from the main branch", workflow) + self.assertIn("QAR run event is not trusted for M0 publication", workflow) + self.assertIn('head_repository = metadata.get("head_repository")', workflow) + self.assertIn('head_repository.get("full_name") != expected_repository', workflow) + self.assertIn('metadata.get("head_branch") != "main"', workflow) + self.assertIn('metadata.get("event") not in {"schedule", "workflow_dispatch"}', workflow) + self.assertIn("QAR artifact workflow-run binding mismatch", workflow) + self.assertIn("QAR artifact must contain exactly one dated M0 source snapshot", workflow) + self.assertIn("m0_research_source_snapshot_[0-9]{4}-[0-9]{2}-[0-9]{2}", workflow) + self.assertIn("M0_SOURCE_SNAPSHOT_SHA256", workflow) + self.assertIn('> "${artifact_zip}"', workflow) + self.assertNotIn("--output \"${artifact_zip}\"", workflow) + self.assertIn("--source-artifact-revision \"${QAR_SOURCE_REVISION}\"", workflow) + self.assertIn("--source-artifact-run-id \"${QAR_RUN_ID}\"", workflow) + self.assertIn("--source-artifact-id \"${QAR_ARTIFACT_ID}\"", workflow) + self.assertIn("--source-artifact-sha256 \"${M0_SOURCE_SNAPSHOT_SHA256}\"", workflow) + + def test_workflow_uses_only_dedicated_read_and_publish_credentials(self): + workflow = WORKFLOW.read_text(encoding="utf-8") + + self.assertIn("QAR_ARTIFACT_READ_TOKEN: ${{ secrets.QAR_ARTIFACT_READ_TOKEN }}", workflow) + self.assertIn("GH_TOKEN=\"${QAR_ARTIFACT_READ_TOKEN}\" gh api", workflow) + self.assertIn( + "QSL_M0_RESEARCH_LEDGER_PUBLISH_URL: ${{ vars.M0_RESEARCH_SYNC_URL }}", + workflow, + ) + self.assertIn( + "QSL_M0_RESEARCH_LEDGER_PUBLISH_TOKEN: ${{ secrets.M0_RESEARCH_SYNC_TOKEN }}", + workflow, + ) + self.assertIn("build_m0_research_publisher_envelope.py", workflow) + self.assertIn("--publish", workflow) + self.assertNotIn("gh workflow run", workflow) + self.assertNotIn("runtime_settings.py", workflow) + self.assertNotIn("platform-config", workflow) + self.assertNotIn("manual-strategy-switch", workflow) + self.assertNotIn("broker", workflow.lower()) + self.assertNotIn("selector", workflow.lower()) + self.assertNotIn("${QSL_M0_RESEARCH_LEDGER_PUBLISH_URL}", workflow) + self.assertNotIn("${QSL_M0_RESEARCH_LEDGER_PUBLISH_TOKEN}", workflow) + + read_token_steps = workflow.count("QAR_ARTIFACT_READ_TOKEN: ${{ secrets.QAR_ARTIFACT_READ_TOKEN }}") + self.assertEqual(read_token_steps, 3) + self.assertEqual(workflow.count("QSL_M0_RESEARCH_LEDGER_PUBLISH_URL: ${{ vars.M0_RESEARCH_SYNC_URL }}"), 1) + self.assertEqual(workflow.count("QSL_M0_RESEARCH_LEDGER_PUBLISH_TOKEN: ${{ secrets.M0_RESEARCH_SYNC_TOKEN }}"), 1) + build_step = workflow.split("- name: Build and publish verified no-order M0 ledger", maxsplit=1)[1] + self.assertNotIn("QAR_ARTIFACT_READ_TOKEN", build_step) + + def test_sensitive_values_are_not_emitted_by_workflow_commands(self): + workflow = WORKFLOW.read_text(encoding="utf-8") + + for line in workflow.splitlines(): + self.assertFalse( + re.search( + r"\b(?:echo|printf)\b.*\$\{?(?:QAR_ARTIFACT_READ_TOKEN|QSL_M0_RESEARCH_LEDGER_PUBLISH_(?:URL|TOKEN))", + line, + ), + line, + ) + + +if __name__ == "__main__": + unittest.main()