从零搭一套轻量 CI/CD,并让它托管自己

本文由 AI 读取 D:\hym 各仓库的 git commit 记录自动生成(覆盖 2026-10-10 ~ 2026-10-13)

署名说明:e-commerce-frontend 里 Heyiming、xlagent 里 hym / Heyiming 均为本人提交;同事 wenhai03(why)的提交不并入本文统计。本文只讲我这两天从零搭起来的这套 xlagent-ci,以及它最后怎么把「自己」也接进了流水线。


0. 一句话概括

用零第三方依赖的一个 Node 脚本(server.mjs)做 Gitee WebHook 接收器,把「校验 → 构建 → 人工批准 → 部署」拆成可审计的阶段;一个 receiver 服务托管五个项目;最后让这套 CI 引擎托管它自己(自举),并用 Go-Zero 的蓝绿方案把后端发布做成了「一翻 nginx 标记就切流」。

两天里前后端一共落了几十个提交,主线其实是三件事:

  1. 把发版从”人肉 scp”收编成流水线——先在推送时把「环境变量/产物口径」这类低级错误卡死;
  2. 多项目化——一个 9090 服务,前端、两个后端仓库、再加上 CI 引擎自身、GPU 编排器,五条流水线各管各;
  3. 自举与治理——CI 管自己(自举部署最危险的坑就是”重启会杀掉正在部署的进程”),并把整套只读/批准能力收敛进平台管理页。

1. 缘起:一次「裸端口」事故

导火索很朴素:apps/pc/.env.local 里

1
VITE_AGENT_API_BASE_URL=47.76.197.179:9012   # ← 丢了 http://

构建产物里全站 URL 被拼成 http://...:9012/47.76.197.179:9012/api/...,大面积 404。这类「环境变量 / 产物口径」错误,在构建阶段完全能自动发现——只要有人把它写成一条门禁。

这就是第一次提交的动机(97a29bde):

ci: 新增轻量 CI/CD(Gitee WebHook→校验/构建→人工批准部署)+ 修 env 裸端口

顺带把 .env.local 里 VITE_AGENT_API_BASE_URL、PUBLIC_BASE_URL、AGG_CHAT_URL 三处裸 host:port 补回 http:// 前缀。

为什么不用现成的 CI(Jenkins/GitLab CI/Drone)?

  • 目标机只有一台 14G 内存的云主机,还要和生产容器挤着跑;
  • 需求很窄:一个 push 钩子、串行构建、人工点一下部署,不需要 runner 集群 / 秘钥管理 / 缓存矩阵那一整套;
  • 自建 = 全部逻辑都在自己眼皮底下,每条门禁都能讲清楚为什么存在,出问题能当场改。

于是就有了 e-commerce-frontend/scripts/ci/ 这一个目录——它的全部内容就是这 12 个文件(外加服务端 config.json,含密钥、不入库)。


2. 整体设计:一条 push 的旅程

1
2
3
4
5
6
7
8
9
Gitee push ──WebHook(密码)──▶ POST http://47.76.197.179:9090/hook/<proj>
│ 立即 200,入队(全局串行,避免构建抢内存)
▼
run-pipeline-*.sh <sha> <ref>
install → env → typecheck → lint → unit → build → artifact
│
├─ 回写 Gitee 提交状态(绿/红)
└─ 通过则存 artifacts/<sha>.tgz
人工批准 ──POST /deploy/<proj>/<sha>(密码)──▶ deploy*.sh:备份 → 安装 → 切流 → 探针

两条铁律:

  • CI 红了不能 CD;
  • CI 绿了也不会自动 CD——部署永远要人工发一次 POST /deploy/<proj>/<sha>。

第二条是刻意的:服务器上跑着真实生产,自动部署带来的收益远小于一次误触发的代价。所以这套系统的定位是「把错误挡在推送时,把部署变成一次有回滚锚的点击」。


3. 触发面:WebHook → 队列 → 单 sha 流水线

3.1 接收器

server.mjs 只用 Node 内置模块(http/child_process/fs),监听 0.0.0.0:9090。它不解析、不执行 payload 里的任何命令,sha / ref / project 都要过严格字符白名单才允许传给子进程:

1
2
3
const PROJECT_RE = /^[a-z0-9][a-z0-9_-]{0,31}$/i;
const SHA_RE = /^[0-9a-f]{7,40}$/i;
const REF_RE = /^refs\/[A-Za-z0-9._\/-]{1,200}$/;

密码校验走 X-Gitee-Token 头或表单 password。校验通过立即 200 {queued:true},把真正的构建丢进队列。

3.2 为什么全局串行

前端 pnpm build 和后端 go build platform(链接期吃 >4G)都不能并行——一台 14G 的机器,两个构建一起上就 OOM 了。所以整个 receiver 只有一个 running 标志位,队列串行:

1
2
3
4
5
6
7
function pump() {
if (running || queue.length === 0) return;
const job = queue.shift();
running = true;
currentJob = { project: job.project.name, sha: job.sha, ref: job.ref, startedAt: new Date().toISOString() };
runPipeline(job).finally(() => { running = false; currentJob = null; setImmediate(pump); });
}

3.3 阶段与门禁(前端)

阶段 命令 默认门禁 说明
install pnpm install --frozen-lockfile 硬 依赖没装,后面全挂
env check-env.mjs --strict-prod apps/pc/.env.local 硬 裸 host:port / 双重 URL / 必填缺失 → 直接红
typecheck pnpm --filter pc type-check 报告 存量类型错误多,先只报不挡
lint npx eslint … --quiet 报告 只报不挡
unit npx vitest run --exclude **/tests/e2e/** 报告 存量失败用例待清;e2e 要浏览器,不在 CI 跑
build pnpm build:pc(NODE_OPTIONS=--max-old-space-size=3072) 硬 防 terser OOM
artifact check-build.mjs dist 硬 产物完整性 + bundle 内坏 URL / 旧主机

「报告 / 硬」这个区分很关键:存量问题先只报不挡,等清理得差不多了再逐个转硬门禁(hardStages 在 config.json 里可调)。否则一上来就全门禁,团队会发现「推什么都红」,于是集体绕过 CI——那还不如不做。


4. 多项目化:一个 receiver 服务五个项目

到第二天,需求从”一个前端仓库”扩到了五个:

项目 仓库 分支 产物 → CD
frontend e-commerce-frontend ai-assistant dist → deploy.sh(增量 staged)
ec-backend e-commerce-backend xlagent 四枚 Go 二进制 → deploy-bg.sh(蓝绿)
xlagent-backend xlagent main xlagent-linux-amd64 → deploy-xlagent.sh(单容器)
cicd e-commerce-frontend ai-assistant 仅 scripts/ci/** → deploy-cicd.sh(自举重启)
orchestrator scripts-related-to-autodl main orchestrator/*.py → deploy-orchestrator.sh

改造(b56dad33)的要点:

  • 配置从「单项目平铺」改成 projects.<name>(repoUrl/workDir/pipeline/deployScript/hardStages…),顶层只留 port/deployPassword/stateDir/nodeBinDir/feishuWebhook;
  • 路由统一带可选项目段,省略时落到 defaultProject(向后兼容旧单项目 webhook):POST /hook/<proj>、GET /reports/<proj>、/report/<proj>/<sha>、新增 /log/<proj>/<sha>、POST /deploy/<proj>/<sha>;
  • 每个项目的运行态目录 = <stateDir>/<project>/{logs,reports,artifacts},构建队列仍全局串行;
  • 旧单项目配置自动合成为 project=frontend——这样新 server.mjs 可以安全地先于 config.json 转换上线。

4.1 一个当时被 review 抓出来的鉴权洞

如果 pickProject 对未知项目名也回落到默认项目,那 GET /reports/unknownproj 就会读到默认项目的数据。修法是「只认已登记的项目名」:

1
2
3
4
5
6
7
8
9
10
11
function pickProject(segs) {
if (segs.length > 0 && PROJECT_RE.test(segs[0])) {
const p = resolveProject(segs[0]);
if (p) return { project: p, rest: segs.slice(1) };
}
if (CONFIG.defaultProject) { // 只有"不带项目段"才回落默认
const p = resolveProject(CONFIG.defaultProject);
if (p) return { project: p, rest: segs };
}
return { project: null, rest: segs }; // 未知项目名 → 404
}

4.2 watchPaths:让 CI 引擎只对自己目录的改动触发

cicd 项目和 frontend 项目是同一个仓库(里面放着 scripts/ci/)。如果不做过滤,前端页面每次推送都会连带重跑一遍 CI 引擎自己的流水线,纯浪费。

所以给项目加了 watchPaths(024c9d71):

1
2
3
4
5
6
7
8
9
10
11
12
function shouldTrigger(project, payload) {
const watch = Array.isArray(project.watchPaths) ? project.watchPaths.filter(Boolean) : [];
if (watch.length === 0) return { trigger: true }; // 空 = 不过滤
const paths = collectChangedPaths(payload); // 并集 commits[].added/modified/removed
if (!paths) return { trigger: true, reason: 'changed-paths-unknown' }; // 拿不到路径 → fail-open
const prefixes = watch.map((w) => w.replace(/^\.?\//, '').replace(/\/+$/, '') + '/');
for (const p of paths) {
const np = p.replace(/^\.?\//, '');
if (prefixes.some((pre) => np === pre.slice(0, -1) || np.startsWith(pre))) return { trigger: true };
}
return { trigger: false, files: [...paths].slice(0, 20) };
}

两处刻意的保守选择:前缀匹配足够(不必精确到文件),路径信息缺失时 fail-open(宁多跑一次,也别漏掉一次该跑的)。

注意 cicd 项目用了独立 clone 目录(repo-cicd),和 frontend 的 repo 分开——否则两个项目共用一个工作树,会互相 checkout -f 打架。


5. 后端流水线:容器内 protoc 重生成 + 串行交叉编译

后端(ec-backend)的流水线 run-pipeline-backend.sh(857e1034)有几个 Go-Zero 专属的门道:

  • 构建全在容器里(build-go:latest = Go1.26 + protoc + goctl + protoc-gen-{go,go-grpc,validate}),宿主不装 Go;
  • generate 是硬门禁:仓库 .gitignore 了 gateway/pb 和 platform/rpc_server,所以 CI 必须现场重跑 go run ./proto,否则网关 proto 解析 400 / crash-loop(这是 2026-09-10 血泪教训);
  • build 硬门禁、串行编译四枚 linux/amd64 二进制:
1
2
3
4
5
6
7
8
9
dkr() { docker run --rm --cpus=4 --memory=6g -e GOMEMLIMIT=4GiB \
-v "$WORK_DIR:/build" -v xlagent-ci-gomod:/go/pkg/mod -v xlagent-ci-gocache:/root/.cache/go-build \
-w /build "$BUILD_IMAGE" bash -c "export PATH=/usr/local/go/bin:/go/bin:\$PATH; $1"; }

export GOOS=linux GOARCH=amd64 CGO_ENABLED=0
export GOMAXPROCS=2 GOFLAGS="-p=1"
for svc in gateway platform easyclick aggregation; do
go build -p 1 -trimpath -ldflags "-w -s -buildid=" -o "/build/.ci-out/$svc" "./$svc"
done

内存参数是调出来的(26d8901e):--memory=4g 会被 OOM killed,因为 platform 链接期吃 >4G;最终定 --memory=6g + GOMEMLIMIT=4GiB(宿主 14G,留余量给生产)。

5.1 两个不太显眼的修复

  • | tail 会吞掉退出码(73e92bed):go vet ./... | tail -50 里,管道的退出码是 tail 的(恒 0),于是 vet 有错也判 success。改成先落文件再取 rc;
  • detached HEAD 判不出分支(c06c7148):检出指定 commit 后 git rev-parse --abbrev-ref HEAD 恒返回 HEAD。改为优先用 webhook 的 ref(refs/heads/<branch>),缺失时用 git branch -r --contains <sha> 反查。
1
2
3
4
5
6
7
8
BRANCH=""
case "$REF" in refs/heads/*) BRANCH="${REF#refs/heads/}";; esac
if [ -z "$BRANCH" ]; then
BRANCH="$(git branch -r --contains "$SHA" | sed 's#^[[:space:]]*##; s#^origin/##' | grep -v '^HEAD$' | head -1 || true)"
fi
if [ -n "$ALLOWED_BRANCH" ] && [ "$BRANCH" != "$ALLOWED_BRANCH" ]; then
log "⏭ 分支 '$BRANCH' 非允许分支($ALLOWED_BRANCH),跳过构建。"; exit 0
fi

6. 人工批准的 CD

6.1 前端:增量 staged 部署

deploy.sh 的核心顺序是「资源先进、最后切 index.html」——先备份,再把新资源落到线上目录,最后一步才覆盖 index.html,然后探针。这样即使中途挂掉,线上仍是旧的 index.html 指向旧资源,不会出现”新页面引用还没传上去的资源” 的中间态。

6.2 后端:蓝绿(deploy-bg.sh)

Go-Zero 的四个服务用 pro1 / pro2 两套色,deploy-bg.sh 的流程是:

1
2
判 idle 色 → 备份 idle 二进制/compose → 解包安装 + 同步 gateway/pb
→ 起 idle 色 → 健康粗筛(gateway 日志,不用 curl 404)→ 翻 nginx down 标记切流 → 停旧色

「唯一状态来源」就是 nginx 配置里的 down; 标记:

1
2
idle_color()   { grep -oE 'server[[:space:]]+127\.0\.0\.1:[0-9]+[[:space:]]+down;[[:space:]]*#[A-Za-z0-9_-]+' "$NGINX_CONF" | grep -oE '#[A-Za-z0-9_-]+' | tr -d '#\r' | head -1; }
active_color() { grep -oE 'server[[:space:]]+127\.0\.0\.1:[0-9]+;[[:space:]]*#[A-Za-z0-9_-]+' "$NGINX_CONF" | grep -oE '#[A-Za-z0-9_-]+' | tr -d '#\r' | head -1; }

健康粗筛刻意不用 curl(nginx 会剥 /v1,curl 得到的 404 说明不了问题),而是看 docker inspect 的 RestartCount/Running + docker logs 里有没有 panic|fatal|auth upstream unavailable。粗筛过了仍要人工确认稳定 1~2 分钟再切流。

6.3 一个被推翻又修正的设计:怎么做到「色隔离」

两套色如果共用一套容器网络,platform:8001 这种裸服务名会解析到哪一色是歧义的。第一次尝试(26d8901e)想用「颜色专属容器名 Target」——给 pro2 的配置把上游目标改成 platform2:8001 之类。

后来发现更干净的解法(73e92bed):每色挂各自的网络——

  • pro1 → xlagent 网络,pro2 → xlagent2 网络;
  • redis 同时接入两张网(别名 redis);
  • 于是每色内部的裸服务名 platform:8001 色内唯一,互不歧义;
  • pro2 的配置就是 pro1 的直接拷贝,一行 Target 都不用改,#pro1/#pro2 down 标记成为唯一的切换开关。

教训:一个「看似要改一堆配置」的问题,往往是因为网络拓扑没分层;把隔离放到网络层,配置层就自然干净了。


7. 自举:让 CI 引擎托管它自己

这是这两天最有意思的一块(024c9d71 + run-pipeline-cicd.sh / deploy-cicd.sh)。

动机:CI 引擎(server.mjs 和一堆脚本)之前是手工 scp 到新机 ci/bin 再 systemctl restart 的——绕过 CD、无版本校验、无日志。把 scripts/ci/** 纳入流水线后,改引擎也能走「推送 → 校验 → 人工批准 → 发布」。

run-pipeline-cicd.sh 的门禁很朴素但有效:

  • syntax(硬):对每个 .mjs 跑 node --check,每个 .sh 跑 bash -n——引擎正确性的最低门槛;
  • artifact(硬):把 scripts/ci 整目录打包,并断言产物里含 server.mjs。

7.1 自举的死结:重启会杀掉正在部署的进程

xlagent-ci 由 systemd 管理,默认 KillMode=control-group。而 deploy-cicd.sh 是 server.mjs 的子进程,和它同一个 cgroup。于是:

systemctl restart xlagent-ci 会连同 deploy 脚本自己一起杀掉——重启之后的「健康验证 / 失败回滚」永远跑不到。

解法(写在脚本顶部注释里):

  1. 正常模式下 deploy-cicd.sh 只做「备份 + 安装」,绝不在这条路径里重启本服务;
  2. 把「重启 + 验证 + 回滚」交给一个 systemd-run 派生的独立瞬态单元(独立 cgroup,不受本服务重启影响),靠环境变量 DEPLOY_CICD_MODE=verify 二次进入:
1
2
3
4
5
UNIT="xlagent-ci-selfdeploy-$TS"
systemd-run --unit="$UNIT" --collect \
--setenv=DEPLOY_CICD_MODE=verify --setenv=CI_ROOT="$ROOT" --setenv=CI_STATE_DIR="${CI_STATE_DIR:-}" \
--description="xlagent-ci self-deploy $shorts" \
bash "$BIN_DIR/deploy-cicd.sh" "$SHA" "$BACKUP" # 不带 --wait:本脚本立即返回,免被随后的重启杀掉
  1. verify 模式里再 systemctl restart + 探针 + 失败还原:
    重启后等 /healthz 起来(最多 15 次、每次 1s),200 则保留新脚本,否则 rm -rf bin && cp -a 备份 再重启。

7.2 重启后状态怎么还能看见?

自举重启会清空 server.mjs 的内存态,管理页的 /deploy-status 只能回退读落盘日志。而 deploy 脚本的成功路径里会打印一行「回滚:…」的提示语(不是失败),如果只看 ❌/✅ 会误判。

所以 verify 阶段往 deploy-<sha>.log 追加一行权威结论:

1
finish() { echo "$1 self-deploy=$( [ "$1" = "✅" ] && echo success || echo failed ) by deploy-cicd.sh(verify)" >> "$DEPLOY_LOG"; }

解析时优先采信这个 self-deploy=(success|failed)(829fa22a):

1
2
3
4
5
let state = 'success';
const verdict = text.match(/self-deploy=(success|failed)/);
if (verdict) state = verdict[1] === 'failed' ? 'failed' : 'success';
else if (/❌|✗/.test(text) || /未通过|失败|还原/.test(text)) state = 'failed';
else if (!/✅/.test(text)) state = 'interrupted';

7.3 又一个坑:脚本被常驻子进程「挂住」

deploy-orchestrator.sh 要重启一个常驻 daemon(python3 orchestrator/daemon.py)。最初的写法 ( … & ) 被 bash 优化成了直接子进程,于是本 shell 会一直 wait 这个永不退出的 daemon → 部署脚本永不返回 → server.mjs 收不到 close 事件 → 部署进度卡在 running。

修法(193d8d97):用 setsid 起新会话,再 disown 把后台作业移出本 shell 的作业表,并把 stdout/stderr 全部重定向到日志文件,避免 daemon 持有 server.mjs 的 stdout 管道:

1
2
setsid python3 "$APP_DIR/daemon.py" >> "$LOG_DIR/error.log" 2>&1 < /dev/null &
disown 2>/dev/null || true

自举这一块,本质上是分布式系统的经典自伤问题:一个进程试图重启它所属的那个服务。所有修法(独立 cgroup / 新会话 / disown / 权威结论行)都在绕开同一个约束——重启者不能被被重启者带走。


8. orchestrator 接入:py_compile 门禁 + 常驻 daemon 重启

GPU 编排器(autodl-orchestrator)的流水线 run-pipeline-orchestrator.sh / deploy-orchestrator.sh:

  • syntax 门禁 = python3 -m py_compile orchestrator/*.py;
  • CD:备份 .py → 解包 → 就地安装 .py → 重启 daemon(pkill → setsid 起新实例)→ 探针 http://127.0.0.1:8787/health = 200 → 失败回滚。

一个关键约束:.env(密钥)和 data/(队列 db)绝不随产物覆盖——部署脚本只搬 .py 源码。这也是”部署”和”初始化”要分开的典型场景:现场数据不属于制品。


9. 暴露到平台:反代 + 管理页

9090 只有一个裸 HTTP API,且只带「共享密码」鉴权,不适合直接对外。所以把它收敛进 xlagent 的登录态之后(c6251ea / 465d9bb / fa0e42a):

  • 浏览器只访问 /api/v1/admin/cicd/<project>/*;
  • 由 xlagent 后端反向代理到 [cicd].projects.<project>.base_url(线上 = 宿主 host.docker.internal:9090)——于是 9090 不必对外放行;
  • 角色门严格限定 ADMIN / SUPER_ADMIN / DEVELOPER / DEV(即”只 admin/dev 可见”),不带 knowledge:* 之类的权限兜底。

前端页面在 apps/pc/src/pages/platform/developer/cicd(6f7459a9 / b56dad33 / 024c9d71)。

9.1 一个路径形状不匹配的坑

平台对外路径是 REST 语义的「项目段在前」,而 CI 侧 server.mjs 是「动词在前、项目段在后」:

1
2
平台对外:  /admin/cicd/<project>/reports/<sha>/log
上游 CI: /log/<project>/<sha> ← 动词在前

第一次直拼就成了 /<project>/reports,而 server.mjs 认的是 /reports/<project> → 上游 404 → 管理页显示「未找到对应构建记录」、报告列表空。

修法是在反代里按下表显式重排(cicd_routes.go 顶部注释把它固化成契约):

1
2
3
4
5
GET  /admin/cicd/<project>/reports             → GET  /reports/<project>
GET /admin/cicd/<project>/reports/<sha> → GET /report/<project>/<sha>
GET /admin/cicd/<project>/reports/<sha>/log → GET /log/<project>/<sha>
GET /admin/cicd/<project>/deploy-status/<sha> → GET /deploy-status/<project>/<sha>
POST /admin/cicd/<project>/reports/<sha>/deploy → POST /deploy/<project>/<sha>

sha 白名单和 CI 侧 SHA_RE 同口径(7~40 位十六进制),避免把任意路径透传上游。

9.2 让”进行中的构建”可见

构建报告要等流水线结束才落盘,所以运行期间管理页看不到任何行。两个补丁:

  • /healthz 暴露 current(正在跑的 sha/ref/项目)——内存态,只有它知道”现在在跑什么”;
  • /reports 在返回列表时,若有当前作业没落盘,就补一条临时行 {ok: null, running: true} 置顶(同 sha 去重,避免和旧报告重复)。
1
2
3
if (currentJob && currentJob.project === project.name && !list.some((r) => r.sha === currentJob.sha)) {
list.unshift({ ...currentJob-ish, ok: null, running: true, finishedAt: null });
}

再配前端 5s 轮询 +「进行中」Tag + 详情禁用 + 健康卡显示在跑 sha。

9.3 部署进度

部署不产 report,进度只体现在 deploy 脚本的 stdout。所以 server.mjs 在流水线侧解析各 deploy 脚本已知的步骤前缀(1/6 备份 →、1/4 解包 →、5/6 切流…),落成有序的 steps,避免前端硬编码任何项目语义:

1
2
3
4
5
6
7
8
const DEPLOY_STAGES = [
{ re: /(\d+)\/\d+\s*备份/, stage: 'backup', title: '备份' },
{ re: /(\d+)\/\d+\s*解包/, stage: 'unpack', title: '解包' },
{ re: /(\d+)\/\d+\s*(资源进线上|安装|同步)/, stage: 'install', title: '安装/切换' },
{ re: /(\d+)\/\d+\s*(起 idle 色|重启容器|覆盖 index)/, stage: 'activate', title: '启用新版本' },
{ re: /(\d+)\/\d+\s*(健康粗筛|探针验证|冒烟)/, stage: 'verify', title: '健康验证' },
{ re: /(\d+)\/\d+\s*(切流|验证)/, stage: 'switch', title: '切流' },
];

step 只前进不回退,避免同一 step 重跑时把完整态改回 running。管理页还有个细节:报告列表按开始时间倒序、时间统一东八区(上游 /reports 按 sha 文件名字典序返回,是乱序的)——e5cbfed5。


10. 踩坑清单(速查)

# 坑 现象 修法
1 env 裸 host:port 构建产物全站 404 check-env.mjs --strict-prod 硬门禁
2 go vet | tail 吞退出码 vet 有错也判 success 先落文件再取 rc
3 detached HEAD 判分支 abbrev-ref 恒返回 HEAD 用 webhook ref / branch -r --contains 反查
4 容器内存不够 platform 链接期 OOM killed --memory=6g + GOMEMLIMIT=4GiB
5 proto 未重生成 网关 proto 解析 400 / crash-loop generate 设为硬门禁
6 未知项目回落默认 /reports/unknownproj 读到默认数据 只认已登记项目名,未知 → 404
7 共用工作树 两项目互相 checkout -f 打架 每项目独立 clone 目录
8 自举重启自杀 部署脚本被 systemctl restart 带走 systemd-run 独立 cgroup 瞬态单元
9 常驻 daemon 挂住脚本 部署进度卡 running setsid + disown + 输出重定向
10 成功路径打印”回滚:” 解析误判为 failed verify 阶段追加 self-deploy= 权威结论行
11 上游路径形状 管理页「未找到构建记录」 动词/项目段显式重排
12 色隔离靠改 Target 配置面到处改 每色独立网络,服务名色内唯一

11. 现状与后续

已上生产(新机 47.76.197.179,与 9012 同机):

  • Gitee WebHook 已配、ci/bin 已发、config.json 含五项目;
  • server.mjs 已就地换挂载二进制并重启(含 /deploy-status 与报告列表倒序);
  • 五项目冒烟全绿。

已知缺口 / 后续:

  • Gitee 状态回写不可用(API 实测 405)——目前静默降级,提交状态绿/红点看不到;
  • 后端 test 阶段(go vet)仍是报告态——存量问题清完再转硬门禁;
  • CD 不同步 config.json(含密钥,不入库)——改了配置得手工上机;
  • 前端 typecheck / unit 同理,是「待清理的存量」。

写在最后

两天时间,把这套东西从「一次裸端口事故的补救」做成了一个能托管自己、能管五个仓库、一次点击带回滚锚的轻量发布系统。回头看,最有价值的不是那些脚本本身,而是三个判断:

  1. 门禁要分「硬 / 报告」——一次性全硬会把团队推到”绕过 CI”的一侧,反而更糟;
  2. 自动化的边界要保守——CI 必须自动且严格,CD 必须人工且可回滚;
  3. 自举类系统的一切复杂度都来自一个约束——重启者不能被被重启者带走。想清楚这一点,systemd-run / 新会话 / 权威结论行就都是它的自然推论了。

一点反省:这两天为了赶进度,「蓝绿色隔离」的方案改了两轮(先”颜色专属 Target”、后”每色独立网络”),说明我在动手前对网络拓扑的分层设计想得不够深。下次遇到”看起来要改一堆配置”的问题,应该先问:「是不是某一层没隔离干净?」

下一步想把 Gitee 状态回写换成别的方式补上(哪怕发飞书也行),再把这套流水线的失败用例沉淀成回归清单。