From 1b0cce283a78915718f0c37cbdc2455c2c54ab57 Mon Sep 17 00:00:00 2001 From: hermes Date: Tue, 13 Oct 2026 10:27:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(templates):=20=E5=90=8C=E6=AD=A5=E9=80=9A?= =?UTF-8?q?=E7=9F=A5=E9=93=BE=E6=8E=A5=E5=9F=BA=E5=9D=80=EF=BC=88CI=5FPUBL?= =?UTF-8?q?IC=5FBASE=5FURL=EF=BC=89=E4=B8=8E=E5=8F=AF=E4=BF=A1=E6=9D=A5?= =?UTF-8?q?=E6=BA=90=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit templates/ci_report.py 同步项目版(链接基址/提交链接/回退来源 + 通知字段);templates/gitea-build.yml 上报步骤加非秘密 CI_PUBLIC_BASE_URL;templates/README.md 加'上报脚本的非秘密配置'一节;01 正本 §4.7 增链接基址与'禁止全局 URL 替换';02 Runbook 增排障条目;04 加 T-9(通知链接指向内网地址)。 --- 01-规范-Git与CI-CD(Gitea版).md | 6 + 02-流程说明与Runbook.md | 2 + 04-模板评估与修正.md | 1 + templates/README.md | 11 ++ templates/ci_report.py | 210 +++++++++++++++++++++++++++++-- templates/gitea-build.yml | 5 + 6 files changed, 228 insertions(+), 7 deletions(-) diff --git a/01-规范-Git与CI-CD(Gitea版).md b/01-规范-Git与CI-CD(Gitea版).md index 9814d48..97969b1 100644 --- a/01-规范-Git与CI-CD(Gitea版).md +++ b/01-规范-Git与CI-CD(Gitea版).md @@ -272,6 +272,12 @@ Gitea 没有 `workflow: rules`,触发写在 `on:`。本环境只允许三类 - 上报步骤用 `if: always()`(失败的构建也要报),通知失败**不能**让作业失败。 - ⚠️ **兜底地址不要只写一个**:作业里按 `CI_BRIDGE_URL` → `http://172.17.0.1:8899` → `http://192.168.50.134:8899` → `http://100.92.49.78:8899` 顺序探测 `/healthz`,取第一个可达(现有模板已这么做,保留)。 - ⚠️ 报告里给出的每个 URL(报告页、预览页)**都要探测到 200** 才算发出去了,否则会收到一条没有预览链接的通知。 +- ⚠️ **通知里的链接基址**用非秘密配置 `CI_PUBLIC_BASE_URL`(由 workflow 传给上报脚本):CI 运行 / + 提交 / 变更 三类链接**统一**用它拼,链接里的仓库、SHA、run_id 仍是真实值。**不覆盖 + `GITHUB_SERVER_URL`**(检出与所有内部调用继续用它)。配置缺失或非法 ⇒ 上报脚本**明确回退** + `GITHUB_SERVER_URL`,并把基址来源写进 `link_base_source`(便于事后核对)。 + bridge 侧只渲染**允许来源**(`ci_base_url`,支持多个)的链接;**禁止**对消息文本做全局 URL 替换 —— + 提交标题、日志摘要里的 URL 是事实,一个字符都不许改。 - ⚠️ 静态产物打包**不要产生 `.` 根成员**:`tar -czf x.tgz -C dir .` 会写入 `.` 和 `./index.html`;严格解包器可能拒绝或报错,而流水线**仍显示成功** —— 通知到了、预览没有。bridge 侧应**规范化**(剥掉 `./`,丢弃归一化为 `.`/空的成员),而不是直接拒绝整个包。 ### 4.8 必需检查清单与结果口径(汇总不许自欺) diff --git a/02-流程说明与Runbook.md b/02-流程说明与Runbook.md index dcb6e27..89ebd81 100644 --- a/02-流程说明与Runbook.md +++ b/02-流程说明与Runbook.md @@ -248,6 +248,7 @@ ls /srv/aiball/ci/reports/pending/ /srv/aiball/ci/reports/undelivered/ tail -n 50 /srv/aiball/ci/logs/bridge.log ``` +- 通知里的链接基址:看上报里的 `link_base_source`(`CI_PUBLIC_BASE_URL` 还是回退到 `GITHUB_SERVER_URL`);bridge 只渲染允许来源的链接,非允许来源会被丢掉(消息里就没有该链接)。 - 通知发不出去时:先看 `undelivered/`。它是**入队即落盘**的设计,进程重启不丢;`pending/` 是重试中。 - ⚠️ 微信侧只能在"用户最近发过消息"的**会话窗口内**主动推送;窗口外发送会 `ret=-2 prepare failed`(**伪限流**,不是本机故障)。恢复办法是**本人给 bot 发一句**;队列会由 20 分钟一次的补投自己清空。**不要**为此重启任何服务。 - ⚠️ 通知正文要**空行分隔**:单换行会被客户端的 Markdown 折叠成一段。 @@ -266,6 +267,7 @@ tail -n 50 /srv/aiball/ci/logs/bridge.log | 明明匹配却报失败 | `pipefail` + `grep -q` 的 SIGPIPE | 去掉 `-q` | | 合并报错但 PR 其实合并了 | Gitea 成功返回空 body | **不要重试**,`GET /pulls/{n}` 读回 | | 通知到了但预览 404 | 产物包的 `.` 根成员被拒绝 | 规范化成员名(§4.7) | +| 通知里的链接指向内网地址/打不开 | 没配 `CI_PUBLIC_BASE_URL`(或配成了非法值 ⇒ 已明确回退),或 bridge 的可信来源没登记公网域名 | 在 workflow 的上报步骤配 `CI_PUBLIC_BASE_URL`;bridge `ci_base_url` 里把公网域名登记上(可多个);核对上报里的 `link_base_source` | | 构建随机报原生模块加载失败 | 缓存里混了平台相关依赖 | 不缓存 `node_modules` | | "登录成功却又要重新登录" | 两个环境共用「父域 + Cookie 名」 | 改其中一个 DOMAIN 或换 COOKIE_NAME | | 境外请求全 000 | mihomo 节点被钉死 | `PUT /proxies/三毛机场 {"name":"<活节点>"}` | diff --git a/04-模板评估与修正.md b/04-模板评估与修正.md index 9feb793..7de0959 100644 --- a/04-模板评估与修正.md +++ b/04-模板评估与修正.md @@ -151,6 +151,7 @@ | T-6 | **协议被拼成 http** | 检出抽成 `templates/ci_checkout.sh`:remote 用 `GITHUB_SERVER_URL` 原样拼 + 断言不降级 + URL 不带凭据 + 顺带取事件 `before` | `ci_checkout.sh --print-remote`:https/http 保留、`ftp://` 拒绝;https 真跑一次检出验证 | | T-7 | **合并必需 context 可为空** | `adopt-existing.py`:必需 context 只认显式策略(`--require-context` / `.gitea/required-contexts.txt`),空/缺失 ⇒ 拒绝;并核对 base/state/head 仓库 | `--self-test-chain`(假 Gitea + 真实 `merge_pr()`,13 分支) | | T-8 | **提交范围可能漏查** | `.gitea/scripts/ci_gate.py` 以 Git 范围为准(`before..after` / 新分支 `after --not --remotes`);声明与范围不一致、空范围、浅克隆缺 before、列表提交不在检出 ⇒ 拒绝;手工运行只核 HEAD 并写明 | `--self-test` 11 分支(含"声明 6 条 / 列表 1 条 / Git 2 条 ⇒ 报错") | +| T-9 | **通知里的链接指向内网地址**(群里点不开/换网就失效) | 上报脚本新增链接基址 `CI_PUBLIC_BASE_URL`(非秘密,workflow 传给上报脚本):run/提交/变更链接统一用它拼;缺失/非法**明确回退** `GITHUB_SERVER_URL` 并写 `link_base_source`;bridge 可信来源(`ci_base_url`,支持多个)同步登记;**不做消息文本的全局 URL 替换** | 项目侧真实链路用例:配了公网域名 ⇒ 三类链接都用它且保留真实 repo/SHA/run_id、链接目标只有可信来源;未配/非法(`ftp://`、带 userinfo、带空白)⇒ 回退且消息里不出现非法域名;提交标题里的 URL 仍是文本;真实对象的链接可达(匿名与带 token 各测) | **生成器同步**:`new-project.sh` 现在会落 `ci_preview.py`、`ci_checkout.sh`、`check_secrets.py`、 `test-scope.json`(合法但空的骨架)、`.gitea/required-contexts.txt`(**故意留空**并要求你填 —— 不预填猜出来的 context, diff --git a/templates/README.md b/templates/README.md index d11093c..7a53cd2 100644 --- a/templates/README.md +++ b/templates/README.md @@ -39,6 +39,17 @@ curl -m 5 -o /dev/null -w '%{http_code}\n' http://192.168.50.134:8899/healthz curl -m 5 -o /dev/null -w '%{http_code}\n' http://100.92.49.78:8899/healthz ``` +## 上报脚本的非秘密配置(写在 workflow 里,便于审查) + +| 变量 | 作用 | 缺失时 | +|---|---|---| +| `CI_PUBLIC_BASE_URL` | 通知里 **CI 运行 / 提交 / 变更** 三类链接的基址(对外域名,如 `https://gitea.example.com`) | 上报脚本**明确回退** `GITHUB_SERVER_URL`(内网地址),并把来源写进上报的 `link_base_source` | + +- **不覆盖 `GITHUB_SERVER_URL`**:检出、gate、内部调用继续用它;只影响通知里的三类链接。 +- 非法值(非 http(s)、带 userinfo、带空白)⇒ 明确回退并在 CI 日志里写明原因,**不生成垃圾链接**。 +- bridge 侧 `ci_base_url` 是链接的**可信来源**(字符串或数组): + 公网域名与(回退时的)内网地址都应登记,否则会被当成"非可信链接"丢掉。 + ## `AGENTS.md` 骨架(复制到仓库根,按本工程改写 TODO) ````markdown diff --git a/templates/ci_report.py b/templates/ci_report.py index 8d49ef7..64745da 100755 --- a/templates/ci_report.py +++ b/templates/ci_report.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""核对门禁报告并上报 ci-bridge(bridge 负责落报告页 + 推微信)。 +"""核对门禁报告并上报 ci-bridge(bridge 负责落报告页 + 走当前通知出口:微信/钉钉)。 **不只看 `overall_status` 一个字段** —— 那是"信一句没有任何依据的结论"。上报前先独立核对: @@ -182,6 +182,37 @@ def self_test() -> int: _, got, _ = verify(rep, expect, in_ci) if len(got) != want: problems.append(f"{name}:期望 {want} 个问题,实际 {len(got)} 个 {got}") + # 通知链接基址:优先 CI_PUBLIC_BASE_URL,缺/非法都必须**明确回退**(不猜、不静默) + env_backup = {k: os.environ.get(k) for k in ("CI_PUBLIC_BASE_URL", "GITHUB_SERVER_URL")} + try: + os.environ["GITHUB_SERVER_URL"] = "http://internal:3000" + os.environ.pop("CI_PUBLIC_BASE_URL", None) + b, src = _link_base() + if b != "http://internal:3000" or "GITHUB_SERVER_URL" not in src: + problems.append(f"没配 CI_PUBLIC_BASE_URL 时没回退到 GITHUB_SERVER_URL:{b!r}/{src!r}") + os.environ["CI_PUBLIC_BASE_URL"] = "https://gitea.zheromiku.top/" + b2, src2 = _link_base() + if b2 != "https://gitea.zheromiku.top" or src2 != "CI_PUBLIC_BASE_URL": + problems.append(f"配了 CI_PUBLIC_BASE_URL 没用它(或没去掉尾斜杠):{b2!r}/{src2!r}") + for bad in ("gitea.zheromiku.top", "ftp://gitea.zheromiku.top", + "https://u:p@gitea.zheromiku.top", "https://a b/"): + os.environ["CI_PUBLIC_BASE_URL"] = bad + b3, src3 = _link_base() + if b3 != "http://internal:3000" or "回退" not in src3: + problems.append(f"非法 CI_PUBLIC_BASE_URL({bad!r}) 没明确回退:{b3!r}/{src3!r}") + os.environ["CI_PUBLIC_BASE_URL"] = "https://gitea.zheromiku.top" + if _commit_url("a/b", "c" * 40, "https://gitea.zheromiku.top") != \ + f"https://gitea.zheromiku.top/a/b/commit/{'c' * 40}": + problems.append("提交链接拼错") + for bad_repo, bad_sha in (("onlyone", "c" * 40), ("a/b", "c" * 12), ("a/b", "")): + if _commit_url(bad_repo, bad_sha, "https://gitea.zheromiku.top"): + problems.append(f"仓库/短 SHA 不合法却仍拼出链接:{bad_repo!r}/{bad_sha[:12]!r}") + finally: + for k2, v2 in env_backup.items(): + if v2 is None: + os.environ.pop(k2, None) + else: + os.environ[k2] = v2 if problems: print("❌ 上报核对自检失败:", file=sys.stderr) for p in problems: @@ -192,6 +223,138 @@ def self_test() -> int: return 0 +# ── 通知消息规范(ci-notification-message-standard §5)需要的字段 ────────────── +# 全部**由程序从真实事实算**:job 名来自 workflow 文件、范围来自门禁的同一个解析器、 +# 统计来自权威清单。缺就缺(消息里显示"未取得"),绝不用写死的值凑格式。 +def _job_name() -> str: + """真实 job 名:从 workflow 文件里读 `jobs:` 下的第一个 job(不写死 build)。""" + for p in (ROOT / ".gitea" / "workflows").glob("*.y*ml"): + try: + txt = p.read_text(encoding="utf-8") + except Exception: + continue + m = re.search(r"^jobs:\s*$\n(.*)", txt, re.M | re.S) + if m: + m2 = re.search(r"^ ([A-Za-z0-9_.-]+):", m.group(1), re.M) + if m2: + return m2.group(1) + return os.environ.get("GITHUB_JOB", "") + + +def _scope_for_notify(): + """本次**准确范围**:复用门禁的同一个解析器(不另写一套范围逻辑)。 + + 返回 (commits:[{sha,subject}], source:str, err:str)。取不到就给空 —— 不猜。 + """ + try: + sys.path.insert(0, str(ROOT / ".gitea" / "scripts")) + import ci_gate # type: ignore + commits, source, err = ci_gate.resolve_scope() + out = [] + for c in (commits or [])[:10]: + msg = str(c.get("message") or "").strip().splitlines() + out.append({"sha": str(c.get("sha") or ""), "subject": (msg[0] if msg else "")}) + return out, source, err or "" + except Exception as e: + return [], "", f"{e.__class__.__name__}" + + +def _link_base() -> tuple: + """通知里的链接用哪个基址:**CI_PUBLIC_BASE_URL 优先**,没配/非法就退回 GITHUB_SERVER_URL。 + + 只接受 `http(s)://host[:port][/path]` 的绝对地址、不接受带凭据(userinfo)的写法。 + 返回 (base, source);source 会写进上报与 CI 日志,方便事后核对"这条链接是谁给的基址"。 + + ⚠️ 这是**唯一的**链接基址来源:不做"把消息文本里的 URL 全部替换一遍"那种事 —— + 那样会把提交标题、日志摘要里的 URL 也改掉(那些是事实,不许动)。 + """ + pub = (os.environ.get("CI_PUBLIC_BASE_URL") or "").strip() + fallback = (os.environ.get("GITHUB_SERVER_URL") or "").strip().rstrip("/") + if pub: + bad = None + if not re.match(r"^https?://", pub): + bad = "不是 http(s) 绝对地址" + elif " " in pub or " " in pub: + bad = "含空白字符" + else: + m = re.match(r"^[a-z]+://([^/]+)", pub, re.I) + if not m or "@" in m.group(1): + bad = "主机部分不合法或带凭据(userinfo)" + if bad: + return fallback, f"CI_PUBLIC_BASE_URL 非法({bad})⇒ 回退 GITHUB_SERVER_URL" + return pub.rstrip("/"), "CI_PUBLIC_BASE_URL" + return fallback, "GITHUB_SERVER_URL(未配 CI_PUBLIC_BASE_URL)" + + +def _repo_ok(repo: str) -> bool: + """链接里的仓库必须是真的 owner/repo(两段、都非空),否则宁可不给链接。""" + parts = [x for x in str(repo or "").split("/")] + return len(parts) == 2 and all(x.strip() for x in parts) + + +def _commit_url(repo: str, sha: str, base: str) -> str: + if not _repo_ok(repo) or not base or not re.match(r"^[0-9a-f]{40}$", str(sha or "")): + return "" + return f"{base}/{repo}/commit/{sha}" + + +def _compare_url(repo: str, base: str) -> str: + """变更列表链接:只有拿到真实的 before/after 才给(不然就是编一个打不开的地址)。""" + ev = (os.environ.get("GITHUB_EVENT_PATH") or "").strip() + if not base or not repo or not ev or not os.path.exists(ev): + return "" + try: + with open(ev, encoding="utf-8") as fh: + d = json.load(fh) + before, after = str(d.get("before") or ""), str(d.get("after") or "") + if (len(before) == 40 and len(after) == 40 and set(before) != {"0"} + and set(after) != {"0"} and before != after): + return f"{base}/{repo}/compare/{before}...{after}" + except Exception: + pass + return "" + + +def _first_failure(checks: list) -> dict: + """首个失败项(优先真失败,其次"未运行/缺项"—— 两者都不等于通过)。""" + for c in checks or []: + if str(c.get("status")) == "failure": + return {"id": c.get("id"), "reason": str(c.get("reason") or "").strip()[:400]} + for c in checks or []: + if str(c.get("status")) in ("not_run", "unknown", "skipped"): + return {"id": c.get("id"), + "reason": f"未运行/跳过({c.get('status')})—— 不算通过"} + return {} + + +def _next_step(checks: list) -> str: + """可复现入口:指向项目唯一门禁入口与该检查项(真实命令,不是建议口号)。""" + ff = _first_failure(checks) + if ff.get("id"): + return f"本地复现:make verify(或 python3 scripts/ci_checks.py --only {ff['id']})" + return "本地复现:make verify(唯一门禁入口)后查看报告" + + +def _limitations(checks: list, rep: dict) -> list: + """范围限制:**只写事实**(跳过了哪些检查、格式债基线还剩多少),不写"全部通过"。""" + out = [] + skipped = [str(c.get("id")) for c in (checks or []) if str(c.get("status")) == "skipped"] + if skipped: + out.append("已跳过的检查(不算通过):" + "、".join(skipped[:6])) + try: + base = json.loads((ROOT / "web" / ".prettier-baseline.json").read_text(encoding="utf-8")) + n = len(base.get("files") or base if isinstance(base, (list, dict)) else []) + if isinstance(base, dict): + n = len(base.get("files") or base.get("entries") or {}) + if n: + out.append(f"前端格式债基线:{n} 个文件(只减不增,到期前必须清零)") + except Exception: + pass + for x in (rep.get("limitations") or [])[:3]: + out.append(str(x)[:160]) + return out[:4] + + def main(argv: list[str]) -> int: ap = argparse.ArgumentParser() ap.add_argument("--report", default=os.environ.get("CI_REPORT", str(ROOT / "ci-report.json"))) @@ -257,6 +420,23 @@ def main(argv: list[str]) -> int: if problems: print("❌ 汇总不可信/不完整 ⇒ 不能报绿(作业这次不算通过)", file=sys.stderr) + _lb_base, _lb_source = _link_base() + repo_full = (rep.get("repository") or os.environ.get("GITHUB_REPOSITORY") or "").strip() + if _lb_source != "CI_PUBLIC_BASE_URL": + print(f"提示:通知链接基址 = {_lb_base or '(未取得)'}({_lb_source})", flush=True) + else: + print(f"通知链接基址 = {_lb_base}(来自 CI_PUBLIC_BASE_URL)", flush=True) + if not _repo_ok(repo_full): + print(f"⚠️ 仓库名不是 owner/repo({repo_full[:40]!r})⇒ 通知里不给 run/提交/变更 链接", + file=sys.stderr) + _scope_commits, _scope_source, _scope_err = _scope_for_notify() + _scope_payload = {"commits": _scope_commits, "source": _scope_source, + "count": len(_scope_commits)} + if _scope_err: + print(f"⚠️ 通知里不含本次范围摘要(范围解析:{_scope_err[:120]})", file=sys.stderr) + payload_count = rd(os.environ.get("CI_COMMITS_COUNT_FILE", ""), "") or git( + "rev-list", "--count", "HEAD~1..HEAD") + payload = { # —— bridge 既有字段(保持兼容,不改 bridge)—— "repo": rep.get("repository") or os.environ.get("GITHUB_REPOSITORY", "unknown/unknown"), @@ -266,8 +446,7 @@ def main(argv: list[str]) -> int: "workflow": os.environ.get("GITHUB_WORKFLOW", "build"), "status": BRIDGE_STATUS.get(overall, "failure"), "commit_subject": git("log", "-1", "--format=%s"), - "commit_count": rd(os.environ.get("CI_COMMITS_COUNT_FILE", ""), "") or git("rev-list", - "--count", "HEAD~1..HEAD"), + "commit_count": payload_count, "duration_s": rep.get("duration_s", "?"), "preview_url": rd(str(ROOT / ".ci-preview-url")), "log": rd(os.environ.get("CI_GATE_LOG", ""))[-3500:], @@ -283,9 +462,8 @@ def main(argv: list[str]) -> int: "event": rep.get("event"), "run_mode": rep.get("run_mode"), "run_id": os.environ.get("GITHUB_RUN_ID", ""), - "run_url": (f"{os.environ.get('GITHUB_SERVER_URL', '')}/" - f"{rep.get('repository', '')}/actions/runs/{os.environ.get('GITHUB_RUN_ID', '')}" - if os.environ.get("GITHUB_RUN_ID") else ""), + "run_url": (f"{_lb_base}/{repo_full}/actions/runs/{os.environ.get('GITHUB_RUN_ID', '')}" + if (os.environ.get("GITHUB_RUN_ID") and _lb_base and _repo_ok(repo_full)) else ""), "checks": checks, "manifest": rep.get("manifest"), "problems": (rep.get("problems") or []) + problems, @@ -293,6 +471,24 @@ def main(argv: list[str]) -> int: "expected_sha": expect_sha, "reported_sha": rep.get("commit_sha")}, "notification_status": "not_attempted", "deployed": False, # 本 workflow 只做 CI:没有部署,报告里也这么写 + + # —— 通知消息规范需要的字段(缺就缺,不许编)—— + "schema_version": 2, + "job": _job_name(), # 真实 job 名(来自 workflow 文件) + "run_number": os.environ.get("GITHUB_RUN_NUMBER", ""), + "run_attempt": os.environ.get("GITHUB_RUN_ATTEMPT", "1"), + "commits": _scope_payload["commits"], # 准确范围里的提交标题(≤10 条) + "range_source": _scope_payload["source"], # 范围是怎么算出来的(可复盘) + "commit_count": (_scope_payload["count"] or payload_count), # 条数由 Git 算 + "compare_url": _compare_url(repo_full, _lb_base), + "commit_url": _commit_url(repo_full, rep.get("commit_sha") or "", _lb_base), + "link_base": _lb_base, # 链接基址(可复盘) + "link_base_source": _lb_source, # 基址从哪来:CI_PUBLIC_BASE_URL / GITHUB_SERVER_URL 回退 + "run_duration_s": "", # 整次运行耗时:本 workflow 没留起点 ⇒ 不编 + "duration_kind": "gate", + "first_failure": _first_failure(checks), + "next_step": _next_step(checks), + "limitations": _limitations(checks, rep), } base = (os.environ.get("CI_BRIDGE_RESOLVED") or os.environ.get("BRIDGE_URL") or "").rstrip("/") @@ -322,4 +518,4 @@ def main(argv: list[str]) -> int: if __name__ == "__main__": - raise SystemExit(main(sys.argv[1:])) \ No newline at end of file + raise SystemExit(main(sys.argv[1:])) diff --git a/templates/gitea-build.yml b/templates/gitea-build.yml index dbe045f..5ec51dd 100644 --- a/templates/gitea-build.yml +++ b/templates/gitea-build.yml @@ -225,6 +225,11 @@ jobs: env: BRIDGE_URL: ${{ vars.CI_BRIDGE_URL }} BRIDGE_TOKEN: ${{ secrets.CI_BRIDGE_TOKEN }} + # 通知里的链接基址(**非秘密**,写在 workflow 里便于审查):CI 运行 / 提交 / 变更 + # 三类链接统一用它拼。**不覆盖 GITHUB_SERVER_URL**(检出与内部调用继续用它)。 + # 删掉这一行 ⇒ ci_report.py 明确回退 GITHUB_SERVER_URL,并在上报里写 link_base_source。 + # 换成你的实例对外域名。 + CI_PUBLIC_BASE_URL: https://gitea.example.com run: | set +e; set +o pipefail B="${BRIDGE_URL:-}"