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
29 changes: 19 additions & 10 deletions .github/workflows/publish-m0-research-ledger.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,17 +41,26 @@ jobs:
with:
python-version: "3.12"

# The App must be installed only on QuantAdvisorResearch with Actions:read.
# The action limits its short-lived token to that one repository and revokes
# it after this job. It is never available to the publishing step below.
- name: Create scoped QAR artifact-reader token
id: qar-artifact-reader
uses: actions/create-github-app-token@v3
with:
app-id: ${{ vars.QAR_ARTIFACT_READER_APP_ID }}
private-key: ${{ secrets.QAR_ARTIFACT_READER_APP_PRIVATE_KEY }}
owner: QuantStrategyLab
repositories: QuantAdvisorResearch
permission-actions: read

- name: Verify immutable successful QAR weekly run
env:
QAR_ARTIFACT_READ_TOKEN: ${{ secrets.QAR_ARTIFACT_READ_TOKEN }}
GH_TOKEN: ${{ steps.qar-artifact-reader.outputs.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 \
gh api \
"repos/${QAR_REPOSITORY}/actions/runs/${QAR_RUN_ID}" > "${run_metadata}"

python3 - "${run_metadata}" <<'PY'
Expand Down Expand Up @@ -92,11 +101,11 @@ jobs:

- name: Resolve exactly one fixed QAR artifact
env:
QAR_ARTIFACT_READ_TOKEN: ${{ secrets.QAR_ARTIFACT_READ_TOKEN }}
GH_TOKEN: ${{ steps.qar-artifact-reader.outputs.token }}
run: |
set -euo pipefail
artifacts_metadata="${RUNNER_TEMP}/qar-weekly-artifacts.json"
GH_TOKEN="${QAR_ARTIFACT_READ_TOKEN}" gh api \
gh api \
"repos/${QAR_REPOSITORY}/actions/runs/${QAR_RUN_ID}/artifacts?per_page=100" > "${artifacts_metadata}"

python3 - "${artifacts_metadata}" <<'PY'
Expand Down Expand Up @@ -130,12 +139,12 @@ jobs:

- name: Download and verify one M0 source snapshot
env:
QAR_ARTIFACT_READ_TOKEN: ${{ secrets.QAR_ARTIFACT_READ_TOKEN }}
GH_TOKEN: ${{ steps.qar-artifact-reader.outputs.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 \
gh api --method GET \
"repos/${QAR_REPOSITORY}/actions/artifacts/${QAR_ARTIFACT_ID}/zip" \
> "${artifact_zip}"

Expand Down
34 changes: 28 additions & 6 deletions docs/m0_research_publisher_envelope_contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,8 @@ deployment branch 也必须只允许 `main`;从其他 ref 手动 dispatch 时
- 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(仅
在下载前,workflow 用 GitHub App 临时安装令牌验证 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、
Expand All @@ -120,10 +120,32 @@ run ID 和 artifact ID 都作为 `source_artifact` metadata 显式传给构建
| 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 接收端发布封套;不得复用、互相授予或写入运行时/平台配置。
`m0-research-publisher` Environment 还必须保存 GitHub App 配置,而不是任何长期 QAR PAT:

| GitHub 配置 | 用途 |
| --- | --- |
| variable `QAR_ARTIFACT_READER_APP_ID` | 只安装于 QAR 的 GitHub App ID;不是 secret |
| secret `QAR_ARTIFACT_READER_APP_PRIVATE_KEY` | 该 App 的 PEM private key |

App 只能安装到 `QuantStrategyLab/QuantAdvisorResearch`,repository permission 只能是
`Actions: Read-only`。工作流明确把生成的安装令牌进一步缩小到该 owner/repository 和
`actions:read`,并只注入三个 QAR API 读取步骤;`actions/create-github-app-token` 会在 job
结束时撤销令牌。它从不进入构建/发布步骤、封套、输出或日志。

`M0_RESEARCH_SYNC_TOKEN`、`M0_RESEARCH_SYNC_URL`、上述 App ID 和 private key 都必须配置
在 `m0-research-publisher` Environment 中,而不是 repository-level 默认作用域。App 的读取权限
与 M0 发布 token 完全分离;后者只能向 M0 接收端发布封套,二者不得复用、互相授予或写入运行时、
平台配置。

### 一次性创建 GitHub App

组织管理员可在 [QuantStrategyLab GitHub Apps](https://github.com/organizations/QuantStrategyLab/settings/apps/new)
创建私有 App:名称可用 `QSL M0 Research Artifact Reader`,Homepage URL 用
`https://github.com/QuantStrategyLab/QuantRuntimeSettings`,关闭 webhook,唯一 repository permission
选择 `Actions: Read-only`。创建后生成一把 private key,并把**完整 PEM 文件内容**保存为
`QAR_ARTIFACT_READER_APP_PRIVATE_KEY`,把 App 的 App ID 保存为
`QAR_ARTIFACT_READER_APP_ID`。最后通过 App 的 Install 页面选择组织 `QuantStrategyLab`,并且
只选择 `QuantAdvisorResearch`。不要把 PEM 或任何 access token 粘贴到 issue、PR、日志或聊天中。

同一个 `M0_RESEARCH_SYNC_TOKEN` 还必须以**同名、同值的独立 secret**配置到已有的
`runtime-strategy-switch` Environment。它只会在控制台 Worker 的部署 workflow 中被写入
Expand Down
23 changes: 16 additions & 7 deletions python/tests/test_manual_m0_research_publisher_workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,21 @@ def test_workflow_is_manual_and_binds_one_explicit_successful_qar_artifact(self)
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):
def test_workflow_uses_only_scoped_app_reader_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("uses: actions/create-github-app-token@v3", workflow)
self.assertIn("id: qar-artifact-reader", workflow)
self.assertIn("app-id: ${{ vars.QAR_ARTIFACT_READER_APP_ID }}", workflow)
self.assertIn("private-key: ${{ secrets.QAR_ARTIFACT_READER_APP_PRIVATE_KEY }}", workflow)
self.assertIn("owner: QuantStrategyLab", workflow)
self.assertIn("repositories: QuantAdvisorResearch", workflow)
self.assertIn("permission-actions: read", workflow)
self.assertNotIn("QAR_ARTIFACT_READ_TOKEN", workflow)
self.assertEqual(
workflow.count("GH_TOKEN: ${{ steps.qar-artifact-reader.outputs.token }}"),
3,
)
self.assertIn(
"QSL_M0_RESEARCH_LEDGER_PUBLISH_URL: ${{ vars.M0_RESEARCH_SYNC_URL }}",
workflow,
Expand All @@ -72,20 +82,19 @@ def test_workflow_uses_only_dedicated_read_and_publish_credentials(self):
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)
self.assertNotIn("qar-artifact-reader.outputs.token", build_step)
self.assertNotIn("QAR_ARTIFACT_READER_APP_PRIVATE_KEY", 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))",
r"\b(?:echo|printf)\b.*\$\{?(?:QAR_ARTIFACT_READER_APP_PRIVATE_KEY|QSL_M0_RESEARCH_LEDGER_PUBLISH_(?:URL|TOKEN))",
line,
),
line,
Expand Down