Skip to content
Merged
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
214 changes: 214 additions & 0 deletions .github/workflows/publish-m0-research-ledger.yml
Original file line number Diff line number Diff line change
@@ -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
43 changes: 43 additions & 0 deletions docs/m0_research_publisher_envelope_contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 封套。
96 changes: 96 additions & 0 deletions python/tests/test_manual_m0_research_publisher_workflow.py
Original file line number Diff line number Diff line change
@@ -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()