From 7c4d8abe2e6d74996e5e8b69555cbdead4f459d0 Mon Sep 17 00:00:00 2001 From: Delicious233 <101502465+DeliciousBuding@users.noreply.github.com> Date: Sat, 5 Sep 2026 23:12:49 +0800 Subject: [PATCH] =?UTF-8?q?ci(web):=20=E8=A1=A5=E9=BD=90=E9=95=9C=E5=83=8F?= =?UTF-8?q?=E6=9E=84=E5=BB=BA=E8=BE=93=E5=85=A5=E8=A7=A6=E5=8F=91=E4=B8=8E?= =?UTF-8?q?=E5=9B=9E=E5=BD=92=E4=BF=9D=E6=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Codex --- .github/workflows/cd-web.yml | 2 + .../github-actions-ci-cd-policy.md | 8 +++- scripts/verify/tests/verify-ci-gates.Tests.py | 46 ++++++++++++++++++- scripts/verify/verify-ci-gates.py | 22 ++++++++- 4 files changed, 74 insertions(+), 4 deletions(-) diff --git a/.github/workflows/cd-web.yml b/.github/workflows/cd-web.yml index 908125517..9ef4bbde9 100644 --- a/.github/workflows/cd-web.yml +++ b/.github/workflows/cd-web.yml @@ -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: diff --git a/docs/architecture/github-actions-ci-cd-policy.md b/docs/architecture/github-actions-ci-cd-policy.md index a53aa380d..7a5b9836f 100644 --- a/docs/architecture/github-actions-ci-cd-policy.md +++ b/docs/architecture/github-actions-ci-cd-policy.md @@ -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 为准。 @@ -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`。 diff --git a/scripts/verify/tests/verify-ci-gates.Tests.py b/scripts/verify/tests/verify-ci-gates.Tests.py index e8a4abaab..0abf88c98 100644 --- a/scripts/verify/tests/verify-ci-gates.Tests.py +++ b/scripts/verify/tests/verify-ci-gates.Tests.py @@ -43,6 +43,7 @@ actual policy violations (fail-closed, no false green). """ +import fnmatch import os import re import subprocess @@ -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" @@ -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", @@ -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(?: - .+\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(?: - .+\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) diff --git a/scripts/verify/verify-ci-gates.py b/scripts/verify/verify-ci-gates.py index e4e39b27d..4a9f1d780 100644 --- a/scripts/verify/verify-ci-gates.py +++ b/scripts/verify/verify-ci-gates.py @@ -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。 """ @@ -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 @@ -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