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
2 changes: 2 additions & 0 deletions .github/workflows/cd-web.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ on:
paths:
- "app/web/**"
- "app/shared/**"
- "app/workbench/**"
- "app/package.json"
- "app/pnpm-lock.yaml"
- "app/pnpm-workspace.yaml"
- "app/Dockerfile"
- ".github/workflows/cd-web.yml"
workflow_dispatch:
Expand Down
8 changes: 7 additions & 1 deletion docs/architecture/github-actions-ci-cd-policy.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# GitHub Actions CI/CD policy

最后更新:2026-09-03
最后更新:2026-09-05

本文档定义 AgentHub 的免费 GitHub-hosted runner 测试链路。它描述职责和触发边界;具体 job、版本和脚本以 `.github/workflows/checks.yml`、`release-readiness.yml`、`release.yml` 及仓库内 verifier 为准。

Expand All @@ -22,6 +22,12 @@ AgentHub 使用 Ubuntu 和 Windows 原生 runner 验证不同类别的问题:
| Release readiness | 相关发布/桌面文件变更或手动触发 | Ubuntu + Windows(macOS 仅显式手动) | `readiness-policy`、`windows-installer-smoke-preflight`;`windows-package-dry`、`macos-unsigned-dry-policy`、`macos-package-dry` 仅显式 opt-in | 发布前验证,不替代 PR 快速门禁;不存在名为 `release-readiness` 的 job |
| Release | semver tag | Ubuntu + Windows | release gate、跨平台 Go artifacts、Tauri 发布产物 | 只从 tag 进入发布 |

## 镜像构建与发布

`cd-web.yml` 按 `app/Dockerfile` 的实际输入筛选 master push:Web、Shared、Workbench 源码,以及 workspace manifest、lockfile、根 package manifest 和构建工作流。Workbench-only 改动同样必须产出新 Web 镜像;无关 Desktop、Mobile、Hub 或文档改动不触发 Web 构建。该边界由现有 `verify-ci-gates.py` 及其路径删除/选择自测保护。

`cd-web.yml` / `cd-hub-server.yml` 构建并推送多架构 GHCR 镜像,支持手动 dispatch;它们不部署运行环境。构建成功只证明镜像产出,部署仍须核对所选 revision、运行镜像与实际 API/UI。

## 分支保护与稳定 required checks

`master` 分支保护使用 `strict=true`,PR 必须先与目标分支保持 up-to-date。仓库要求的稳定 required-check 契约是 `validate`、`go-hub`、`go-edge`、`windows-go`、`windows-frontend`、`backend-required`、`frontend-required`。
Expand Down
46 changes: 44 additions & 2 deletions scripts/verify/tests/verify-ci-gates.Tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
actual policy violations (fail-closed, no false green).
"""

import fnmatch
import os
import re
import subprocess
Expand All @@ -52,6 +53,7 @@

REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
WORKFLOW_PATH = os.path.join(REPO_ROOT, ".github", "workflows", "checks.yml")
WEB_WORKFLOW_PATH = os.path.join(REPO_ROOT, ".github", "workflows", "cd-web.yml")
VERIFIER_PATH = os.path.join(REPO_ROOT, "scripts", "verify", "verify-ci-gates.py")

DESIGN_CSS_STEP_VERIFY = " - name: Verify design CSS syntax\n run: pnpm test:css-syntax\n"
Expand Down Expand Up @@ -164,13 +166,21 @@ def read_workflow() -> str:
return handle.read()


def run_verifier(workflow_text: str) -> tuple:
def read_web_workflow() -> str:
with open(WEB_WORKFLOW_PATH, encoding="utf-8") as handle:
return handle.read()


def run_verifier(workflow_text: str, web_workflow_text: str | None = None) -> tuple:
with tempfile.TemporaryDirectory(prefix="agenthub-ci-gates-") as tmp_dir:
workflow_copy = os.path.join(tmp_dir, "checks.yml")
with open(workflow_copy, "w", encoding="utf-8", newline="\n") as handle:
handle.write(workflow_text)
web_copy = os.path.join(tmp_dir, "cd-web.yml")
with open(web_copy, "w", encoding="utf-8", newline="\n") as handle:
handle.write(read_web_workflow() if web_workflow_text is None else web_workflow_text)
result = subprocess.run(
[sys.executable, VERIFIER_PATH, "--WorkflowPath", workflow_copy],
[sys.executable, VERIFIER_PATH, "--WorkflowPath", workflow_copy, "--WebWorkflowPath", web_copy],
capture_output=True,
text=True,
encoding="utf-8",
Expand Down Expand Up @@ -583,6 +593,38 @@ def test_delete_fixture_pinning_self_test_step_fails(self):
"deleted the fixture connection pinning negative self-test step",
)

def test_web_cd_missing_build_input_fails(self):
web = read_web_workflow()
# Remove every declared input in turn; this also exercises Workbench
# and the workspace manifest rather than merely checking constants.
path_block = re.search(r"(?m)^ paths:\n(?P<paths>(?: - .+\n)+)", web)
self.assertIsNotNone(path_block)
for entry in path_block.group("paths").splitlines(keepends=True):
with self.subTest(entry=entry.strip()):
code, output = run_verifier(read_workflow(), web.replace(entry, "", 1))
self.assertEqual(code, 1, output)

def test_web_cd_path_selection(self):
web = read_web_workflow()
path_block = re.search(r"(?m)^ paths:\n(?P<paths>(?: - .+\n)+)", web)
self.assertIsNotNone(path_block)
patterns = [line.strip()[2:].strip("\"'") for line in path_block.group("paths").splitlines()]
# These positive literal / ** globs share fnmatch's matching behavior.
# Keep unrelated packages/docs out of image builds.
for path, selected in (
("app/workbench/src/shell/Workbench.tsx", True),
("app/workbench/package.json", True),
("app/pnpm-workspace.yaml", True),
("app/shared/src/ui/Button.tsx", True),
("app/web/src/main.tsx", True),
("app/desktop/src/main.tsx", False),
("app/mobile-rn/src/App.tsx", False),
("hub-server/main.go", False),
("docs/architecture.md", False),
):
with self.subTest(path=path):
self.assertEqual(any(fnmatch.fnmatchcase(path, pattern) for pattern in patterns), selected)

def test_unmutated_workflow_passes(self):
exit_code, output = run_verifier(read_workflow())
self.assertEqual(exit_code, 0, "unmutated checks.yml must pass the CI policy verifier:\n%s" % output)
Expand Down
22 changes: 21 additions & 1 deletion scripts/verify/verify-ci-gates.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@
用正则解析 .github/workflows/checks.yml 的 job/step 结构并断言 CI 政策:
覆盖门禁、gosec/vuln 扫描、backend fixture/focused 边界、前端 pnpm 缓存、
coverage include、commit-message/quality-debt/doc-ssot 自测、mobile light、
visual-qa-shell(web+desktop 双半边)、changes 路径筛选等。断言引用脚本名与本批迁移后 checks.yml
visual-qa-shell(web+desktop 双半边)、changes 路径筛选、Web CD 构建输入等。断言引用脚本名与本批迁移后 checks.yml
实际内容一致(本批脚本 .py,其余保持 .ps1)。

CLI 兼容:--WorkflowPath 默认 ".github/workflows/checks.yml"(相对 cwd);
--WebWorkflowPath 默认 ".github/workflows/cd-web.yml"。
通过输出 "ci gate policy ok" 且退出码 0;违例抛异常 → stderr + 退出码 1。
"""

Expand Down Expand Up @@ -78,6 +79,7 @@ def main() -> int:
"""解析 checks.yml 并断言全部 CI 门禁政策;违例即抛错退出 1(fail-closed,防回退)。"""
parser = argparse.ArgumentParser(description="CI gate policy verifier")
parser.add_argument("--WorkflowPath", default=".github/workflows/checks.yml")
parser.add_argument("--WebWorkflowPath", default=".github/workflows/cd-web.yml")
args = parser.parse_args()

workflow_path = args.WorkflowPath
Expand Down Expand Up @@ -610,6 +612,24 @@ def main() -> int:
assert_not_contains(desktop_visual, r"toHaveScreenshot", "visual-qa-desktop must not use Playwright pixel golden matchers")
assert_not_contains(desktop_visual, r"windows-latest", "visual-qa-desktop must stay on ubuntu for cost control")

# Web CD must cover the build inputs copied by app/Dockerfile, not just
# app/web. Otherwise a Workbench-only merge can leave the image stale.
with open(args.WebWorkflowPath, encoding="utf-8-sig") as handle:
web_workflow = handle.read()
web_push = re.search(r"(?ms)^ push:\r?\n.*?(?=^ \S|\Z)", web_workflow)
if not web_push:
fail("Web CD must have a push trigger")
web_paths = re.search(r"(?m)^ paths:\r?\n(?: - [^\r\n]+\r?\n)+", web_push.group())
if not web_paths:
fail("Web CD must filter push paths")
for path in (
"app/web/**", "app/shared/**", "app/workbench/**",
"app/package.json", "app/pnpm-lock.yaml", "app/pnpm-workspace.yaml",
"app/Dockerfile", ".github/workflows/cd-web.yml",
):
assert_contains(web_paths.group(), r"(?m)^ - [\"']?" + re.escape(path) + r"[\"']?\s*$",
f"Web CD must watch build input {path}")

print("ci gate policy ok")
return 0

Expand Down
Loading