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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ Coverage 页从源仓 GitHub Actions 产物中读取 `node-coverage-summary-*`

```bash
PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s tests -v
PYTHONDONTWRITEBYTECODE=1 python3 test/journey-rerun-collection.py
node test/sync-e2e.mjs
node .e2e/node_modules/playwright/cli.js test --config test/playwright.config.cjs
```
Expand Down
44 changes: 42 additions & 2 deletions collect_with_oidc.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from datetime import datetime, timezone
import json
import os
import re
from pathlib import Path
import subprocess
import sys
Expand All @@ -17,6 +18,8 @@
ROOT = Path(__file__).resolve().parent
# GitCode sync records fetched from the bot for publish.py; not uploaded anywhere by itself.
SYNC_RECORDS = ROOT / ".tmp" / "gitcode-sync.json"
RUN_CHANGES = ROOT / ".tmp" / "run-changes.json"
RUN_RECEIPT = ROOT / ".tmp" / "run-changes-receipt.json"


class CredentialError(RuntimeError):
Expand Down Expand Up @@ -79,6 +82,35 @@ def sync_records(url, identity):
return {"ok": False, "error": "bot unreachable"}


def changed_runs(url, identity):
try:
value = request_json(url, identity, {}, "POST")
return value if isinstance(value, dict) else {"ok": False}
except CredentialError:
print("::warning::Rerun hints unavailable; pending IDs remain at the bot.", flush=True)
return {"ok": False}


def acknowledge_runs(url, audience, receipt, requested, source):
# The file only exists after publish.py committed both progress and public data.
# Read a fresh GitHub identity: collection can outlive the original OIDC JWT.
try:
value = json.loads(receipt.read_text())
from gsb.run_changes import valid_change
rows = value.get("runs")
if (value.get("source") != source or not isinstance(value.get("commit"), str)
or not re.fullmatch('[0-9a-f]{40}', value['commit'])
or not isinstance(rows, list) or not rows or len(rows) > 20
or not all(valid_change(row) and row in requested.get("runs", []) for row in rows)):
raise ValueError("invalid rerun receipt")
reply = request_json(url, oidc_identity(audience), {"runs": rows, "commit": value["commit"]}, "POST")
if not isinstance(reply, dict) or reply.get("ok") is not True:
raise ValueError("rerun acknowledgement unavailable")
except Exception:
# Publication succeeded; lost acknowledgements are safe to retry next collection.
print("::warning::Rerun acknowledgement failed; pending IDs remain at the bot.", flush=True)


def main():
grants = []
stage = "configuration"
Expand Down Expand Up @@ -109,10 +141,18 @@ def main():
stage = "GitCode sync records"
SYNC_RECORDS.parent.mkdir(parents=True, exist_ok=True)
SYNC_RECORDS.write_text(json.dumps(sync_records(urllib.parse.urlunsplit(endpoint._replace(path="/actions/gitcode-sync")), identity)), encoding="utf-8")
stage = "rerun hints"
requested = changed_runs(urllib.parse.urlunsplit(endpoint._replace(path="/actions/reruns")), identity)
RUN_CHANGES.write_text(json.dumps(requested), encoding="utf-8")
RUN_RECEIPT.unlink(missing_ok=True)
# The collector receives neither OIDC credentials nor any App private key.
child_env = {k: v for k, v in os.environ.items() if k not in {"ACTIONS_ID_TOKEN_REQUEST_TOKEN", "ACTIONS_ID_TOKEN_REQUEST_URL", "GH_TOKEN", "GITHUB_TOKEN", "GSB_PUBLISH_TOKEN"} and "PRIVATE_KEY" not in k}
child_env.update(GITHUB_TOKEN=grants[0], GSB_PUBLISH_TOKEN=grants[1], GSB_GITCODE_SYNC_FILE=str(SYNC_RECORDS))
return subprocess.run([sys.executable, str(ROOT / "publish.py"), "--repo", context["source"], "--publish-repo", context["target"], "--output", ".tmp/collected-site", "--incremental"], env=child_env, cwd=ROOT, check=False).returncode
child_env.update(GITHUB_TOKEN=grants[0], GSB_PUBLISH_TOKEN=grants[1], GSB_GITCODE_SYNC_FILE=str(SYNC_RECORDS),
GSB_RUN_CHANGES_FILE=str(RUN_CHANGES), GSB_RUN_CHANGES_RECEIPT=str(RUN_RECEIPT))
code = subprocess.run([sys.executable, str(ROOT / "publish.py"), "--repo", context["source"], "--publish-repo", context["target"], "--output", ".tmp/collected-site", "--incremental"], env=child_env, cwd=ROOT, check=False).returncode
if code == 0 and RUN_RECEIPT.exists():
acknowledge_runs(urllib.parse.urlunsplit(endpoint._replace(path="/actions/reruns/ack")), audience, RUN_RECEIPT, requested, context["source"])
return code
except Exception as error:
status = getattr(error, "status", None)
detail = f" (HTTP {status})" if isinstance(status, int) else ""
Expand Down
3 changes: 3 additions & 0 deletions docs/actions-collection.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ App 的 Actions 写权限在 App 注册页 **Permissions & events → Repository

兑换令牌后,客户端用同一 OIDC 身份向同一 Worker 的 `/actions/gitcode-sync` 读取 GitCode 同步记录,经 `GSB_GITCODE_SYNC_FILE` 交给发布器;读取失败只把页面标为过期,不影响采集。见 [GitCode 同步记录](gitcode-sync.md)。

客户端还向同一 Worker `POST /actions/reruns`(正文 `{}`)领取最多 20 条完成重跑通知,经非秘密的 `GSB_RUN_CHANGES_FILE` 交给采集器。Worker 仅合并 run ID/attempt/revision,不为每个完成事件调度采集;仍随既有 Issue/PR 合并刷新、8 小时空闲刷新、每日兜底或手动采集处理。发布器按 ID 优先读取最新 attempt、状态与必要报告,不回扫全部 Actions 历史。成功原子提交后才生成 `GSB_RUN_CHANGES_RECEIPT`,父进程重新领取短期 OIDC 身份,向 `/actions/reruns/ack` 确认成功项;失败项和确认丢失均保留,旧确认不删除后来的新 attempt。两个接口沿用固定看板仓 ID/所有者 ID、`main`、`collect.yml` 和 `schedule`/`workflow_dispatch` 的校验,不新增信任或安装令牌权限。建议先发布 Bot 接口再更新采集器;旧 Bot 不提供接口时给出警告并继续普通采集。请求、下载和时间预算保持不变。见 [增量同步与历史](incremental-history.md)。

采集步骤从环境变量读取源仓 `GITHUB_TOKEN` 和目标仓 `GSB_PUBLISH_TOKEN`,运行 `publish.py --repo ... --publish-repo ... --output .tmp/collected-site --incremental`。临时目录不上传,不写回原始测试 artifact。发布器基于本轮 checkout 的准确 HEAD,将 `.sync/` 进度及变化的 `site/` 分片放入一个 Git 提交。保留源码和工作流;并发冲突直接失败,下轮从新 main 续跑。只有 site 变化才触发 Pages。详情见 [增量同步](incremental-history.md)。

E2E 执行记录的 HTML 报告不进入提交:采集成功后,`collect.yml` 把新读取的报告上传为本次运行的 `e2e-html` 产物,保留到对应源产物的 `expires_at`;`pages.yml` 以 `actions: read` 读取这些产物并在部署前挂载,取不到时页面改为 GitHub 产物链接。见 [E2E 执行记录](e2e-run-records.md)。
Expand All @@ -42,6 +44,7 @@ Bot 的“已触发采集”只表示 GitHub 接受调度。实际采集结果

```bash
PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s tests -v
PYTHONDONTWRITEBYTECODE=1 python3 test/journey-rerun-collection.py
```

测试覆盖正式/测试映射、拒绝跨站和恶意 inputs、读写凭据隔离、公开内容边界、非强制提交和测试报告统计。Bot 仓 `npm run test:worker-adapter` 覆盖 Worker 到模拟 Actions API 的签名凭据与触发链路。两者不代表真实 GitHub runner 或 Pages 已执行,上线前仍需验证。
Expand Down
1 change: 1 addition & 0 deletions docs/ci-history-lanes.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ CI 页的「CI 分层历史」按触发方式展示该分支线窗口内的全
- 只展示已采集的 Actions run,不触发、不执行测试,也不修改源仓工作流。
- 窗口天数与时区是代码常量(`WINDOW_DAYS`、`DISPLAY_ZONE`)。
- 位置按 run 创建时间;重跑后颜色更新,但不会移到重跑当天。
- 重跑完成通知随现有刷新节奏合并处理,每轮最多按 ID 补查 20 条最新 attempt 与必要报告,不为此扫描全部 Actions 历史或单独触发采集;发布成功后确认,失败保留到下次刷新。旧通知未送达时仍由原有对账兜底。
- 构建报告页的三张摘要卡与本图独立。

## 验证
Expand Down
2 changes: 1 addition & 1 deletion docs/dashboard-pages.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ CI、PR、Issue 数据不可读取时显示“状态未知”,不会显示为
E2E 页展示:

- 测试文件与最近一次 UT / E2E 用例数;
- Real E2E 质量评分:顶部是最近两天的 Nightly、最近一次 `real-e2e-results` 的出处和四个汇总数字;下面与 E2E 执行记录同一种排版,按评分体系(DeepResearchBench、BiomniBench、Research Team、PUCT Compression)分类,类别可收起,每个用例一排 ✓/×,按时间从左到右表示每次记录的交付结果,悬停或聚焦显示日期、交付、评分状态、各项分数、耗时和 run,点击打开该 run。每个用例下方直接画出每项分数和运行耗时的历史曲线,并标出最新值;交付失败的运行在分数曲线上画 ×,用虚线沿用上次实测(无历史按 0),缺失的分数留空不补零。历史为该分支线每个用例最近 30 个运行日(每天取一次运行)。质量分数仅供观察,不改变 CI 结论;点击用例名打开该用例的旅程报告页 `real-e2e.html`,列出场景、前置条件、评分表和执行历史。
- Real E2E 质量评分:顶部是最近两天的 Nightly、最近一次 `real-e2e-results` 的出处和四个汇总数字;下面与 E2E 执行记录同一种排版,按评分体系(DeepResearchBench、BiomniBench、Research Team、PUCT Compression)分类,类别可收起,每个用例一排 ✓/×,按时间从左到右表示每次记录的交付结果,悬停或聚焦显示日期、交付、评分状态、各项分数、耗时和 run,点击打开该 run。每个用例下方直接画出每项分数和运行耗时的历史曲线,并标出最新值;分数曲线从首次成功实测开始;此前的失败仍保留在状态轴上,不在曲线虚构零分。首次实测后的交付失败画红色 × 并用虚线沿用上次实测;后续缺失评分的 × 与上次有分数的运行平齐,虚线经过缺失运行连接到下次实测,提示注明缺失原因和它不是本次测量值,不补零。历史为该分支线每个用例最近 30 个运行日(每天取一次运行)。质量分数仅供观察,不改变 CI 结论;点击用例名打开该用例的旅程报告页 `real-e2e.html`,列出场景、前置条件、评分表和执行历史。
- 保留期内 E2E 运行的[执行记录](e2e-run-records.md):按类别和用例汇总的执行状态时间轴、各次步骤与错误摘要,及历史点直接跳转的 HTML 附件;
- [标签化测试](tagged-tests.md);
- 按层 / 包的测试文件分布,以及按包的测试结构。
Expand Down
10 changes: 7 additions & 3 deletions docs/incremental-history.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## 功能与数据范围

每个看板仓单独保存同步状态和公开数据。Bot 只提交刷新信号,Actions 使用 API 采集;漏 Webhook、重复触发或 Bot 停机都不改变数据事实来源。定时工作流从仓内断点继续,全部 Webhook 存档仍由 Bot 按原规则保存。
每个看板仓单独保存同步状态和公开数据。Bot 提交刷新信号及合并后的重跑 ID,Actions 使用 API 采集;漏 Webhook、重复触发或 Bot 停机都不改变数据事实来源。定时工作流从仓内断点继续,全部 Webhook 存档仍由 Bot 按原规则保存。

Issue / PR 的开放及关闭记录不设保留条数上限。Actions 保存 run / attempt 的状态、工作流、触发事件、门禁/每日/版本分类、分支、head 仓库、SHA、GitHub 链接与可核验测试指标。版本发布也逐页回填。历史尚未补齐时,页面显示“历史回填中”和已采集数量;列表回填完成不代表所有报告都有可读指标。

Expand All @@ -27,7 +27,11 @@ Issue / PR 的开放及关闭记录不设保留条数上限。Actions 保存 run

主采集的产物下载仍使用原有 160 MiB 预算。[E2E 执行记录](e2e-run-records.md)在其后使用独立的有界预算,一次列表、最多两份 ZIP、每份最多 80 MiB,避免历史详情耗尽额度后执行记录一直无法升级。升级失败保留原有步骤和 HTML bundle,不以空记录覆盖已发布内容。

Issue 按 updated 的 since 水位增量读取;PR 按 updated 倒序读到上一水位,均重叠一小时。历史按创建顺序逐页补齐。每周开始一轮小批量全表对账,修复翻页期间顺序变化、旧 PR 更新及旧构建重跑;对账也有持久游标。旧已完成 run 的重新运行最迟由对账发现;新建运行与已知在途运行优先处理。
Issue 按 updated 的 since 水位增量读取;PR 按 updated 倒序读到上一水位,均重叠一小时。历史按创建顺序逐页补齐。每周开始一轮小批量全表对账,修复翻页期间顺序变化、旧 PR 更新及漏通知的旧构建重跑;对账也有持久游标,新建运行与已知在途运行优先处理。

Worker 接收 `workflow_run.completed` 中 attempt 大于 1 的重跑完成通知,同一 run 只留最新 attempt 与 revision;普通第一次完成不入这条队列。不依赖 `requested`。下一次原有采集最多读取 20 条待办,优先调用 `/repos/{source}/actions/runs/{id}` 获取当前 attempt、状态,再按现有缓存读取对应 job 和必要报告;即使创建时间已在增量窗口之外也能更新,不为重跑新增 Actions 历史列表扫描。批次轮转,失败 ID 不挡住其他待办;刷新间隔、合并窗口、请求/时间和产物预算均不增加。

只有最新详情已读完、且 `.sync/` 与 `site/` 原子提交成功,采集器才发送对应 ID、attempt、revision 的确认。源端错误、预算用尽、报告暂不可读、提交冲突或确认失败均保留 Bot 待办,下一次原有刷新再试;旧确认不会删除更晚的新 attempt。本地预览不确认。未部署此接口的 Bot 不影响原有增量采集;历史对账仍作为漏通知的原有兜底。

Actions 历史按固定 created 时间窗遍历,遇到 GitHub 每次筛选查询 1,000 条的限制,将窗口递归切成不重叠秒区间。极端情况下同一秒仍超过 1,000 条会显式记录 `time_window_saturated` 并保持未完成,不静默丢弃;需再细分到工作流后重试。

Expand Down Expand Up @@ -59,4 +63,4 @@ Actions 产物上传后不再变化,看板已经读过的内容不再下载:

Pages 每次部署仍整站上传 site,并非文件级增量部署。GitHub Pages 有站点容量边界;本实现不承诺无限字节容量。数据增长后需观测仓库与站点体积,再决定外置历史存储,不能悄悄改回业务条数截断。

验证入口:`tests/test_incremental.py`。覆盖超旧上限回填、预算重启、权限失败、重开撤销计数、缓存和过期、attempt 隔离及原子提交冲突。
验证入口:`tests/test_incremental.py`、`tests/test_run_changes.py` 与 `tests/test_oidc_collection.py`。覆盖超旧上限回填、预算重启、权限失败、重开撤销计数、缓存和过期、attempt 隔离、按 ID 补采、详情失败保留待办及发布后确认。`PYTHONDONTWRITEBYTECODE=1 python3 test/journey-rerun-collection.py` 通过本地 HTTP 替身运行真实采集 CLI,验证窗口外的失败 run 重跑变绿、源端失败不覆盖旧结论、原子发布冲突不确认;不访问真实 GitHub,也不派发采集。
42 changes: 42 additions & 0 deletions gsb/run_changes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""Bounded, nonsecret rerun hints fetched from the collector's trusted bot."""
from __future__ import annotations

import json
from pathlib import Path
import re

BATCH_SIZE = 20


def valid_change(row):
return (isinstance(row, dict) and set(row) == {'id', 'attempt', 'revision'}
and type(row['id']) is int and 0 < row['id'] <= 2**53 - 1
and type(row['attempt']) is int and 1 < row['attempt'] <= 2**53 - 1
and isinstance(row['revision'], str) and re.fullmatch('[0-9a-f]{32}', row['revision']) is not None)


def load_changes(path, source):
if not path:
return []
value = json.loads(Path(path).read_text())
if not isinstance(value, dict) or value.get('ok') is not True:
return [] # An unavailable/older bot keeps its hints; ordinary collection can continue.
if not isinstance(value.get('source'), str) or value['source'].lower() != source.lower():
raise ValueError('rerun source mismatch')
rows = value.get('runs')
if not isinstance(rows, list) or len(rows) > BATCH_SIZE or not all(valid_change(row) for row in rows):
raise ValueError('invalid rerun hints')
if len({row['id'] for row in rows}) != len(rows):
raise ValueError('duplicate rerun hints')
return rows


def write_receipt(path, source, changes, commit):
"""Only publish.py calls this after its atomic Git update succeeded."""
if path and changes:
value = {'source': source, 'runs': changes, 'commit': commit}
target = Path(path)
target.parent.mkdir(parents=True, exist_ok=True)
temp = target.with_suffix('.tmp')
temp.write_text(json.dumps(value, separators=(',', ':')))
temp.replace(target)
Loading
Loading