fix(template): 通用 CI 模板改成 CI-only;去掉假成功占位;修四处会造成假绿的写法

模板是"默认就合规"的起点,所以它的每一处错误都会被复制到每个新仓。

① 删掉 deploy:test 作业:它打印 TODO 然后返回成功 —— 网页上看起来部署过了,实际什么都没做。
   那是假报成功,不是"还没做"。模板从此只做 CI,部署由项目另写独立 workflow。
② gitea-prod-deploy.yml 里的两个 TODO 步骤同理,改成 exit 1 并在文件头写明"红的占位是设计"。
③ 假绿四处:
   - gofmt -l 列出文件时退出码仍是 0 ⇒ 改成判输出是否为空;
   - 缺 package-lock.json 时回落 npm install ⇒ 改成直接失败(npm install 会重写锁文件);
   - GOFLAGS=-mod=mod ⇒ 改成 -mod=readonly,并加"编译前后比对依赖清单摘要";
   - 识别不到任何构建入口时打印"跳过编译"然后成功 ⇒ 改成失败。
④ 检出改成核对到**事件完整 SHA**(原来取的是"当时的分支 HEAD",事件之后又有人推一次就对不上了)。
⑤ 预览加了三条断言:包里真的有 index.html、报告里每个地址都 200、不再产生 `.` 根成员
   (原来会:通知发了、预览 404 而流水线显示成功)。

验证:模板 YAML 用 pyyaml 解析通过(build 模板 jobs=gate,build,无 deploy 作业;
prod-deploy jobs=guard,publish 且只有 workflow_dispatch);
new-project.sh 生成物里 grep '^  deploy' 计数为 0。
This commit is contained in:
姚章浩
2026-10-12 00:11:40 +08:00
parent 89d029108a
commit 9d7f09ff4b
3 changed files with 136 additions and 62 deletions
+10 -4
View File
@@ -6,8 +6,8 @@
| 文件 | 落仓位置 | 说明 |
|---|---|---|
| `gitea-build.yml` | `.gitea/workflows/build.yml` | 提交触发的校验+编译+预览+上报;`deploy:test` 是待填骨架 |
| `gitea-prod-deploy.yml` | `.gitea/workflows/deploy-production.yml` | **生产发布唯一入口**,只 `workflow_dispatch`,不构建 |
| `gitea-build.yml` | `.gitea/workflows/build.yml` | 提交触发的校验+编译+预览+上报。**只做 CI**:里面**没有**任何部署作业(CD 由项目另写独立 workflow,见正本 §8) |
| `gitea-prod-deploy.yml` | `.gitea/workflows/deploy-production.yml` | **生产发布唯一入口**,只 `workflow_dispatch`,不构建。⚠️ 未实现的步骤**故意 `exit 1`**:占位不许返回成功 |
| `gitignore` | `.gitignore` | 去掉原稿自相矛盾的 `!src/secrets/` 例外 |
| `commit-msg` | `~/.githooks/commit-msg` | 本机提交闸门;规则必须与 `ci_gate.py` 一致 |
| `AGENTS.md` 骨架 | `AGENTS.md` | 见下(agent 指令文件有写入保护,故内联在此) |
@@ -21,6 +21,9 @@ git commit -F - <<'EOF'
update stuff
EOF
# 判据:本地 hook 报 ❌;push 后 CI 的 gate 红、build 未执行
# ⚠️ 口径别说过头:CI 的 gate 是**推完再跑**的,它拦不住那次推送本身。
# 要拦推送只有两条路:本机 hook,或开分支保护(禁直推)。gate 的价值是
# "把违规提交标红、挡住编译与预览",不是"没进 main"。
# 2) 合规提交必须过(正例对照:证明不是"永远红")
git commit -F - <<'EOF'
@@ -63,10 +66,13 @@ curl -m 5 -o /dev/null -w '%{http_code}\n' http://100.92.49.78:8899/healthz
## 2. 本地验证闭环(提交前的最低要求)
```bash
make check # 或 npm run verify —— 一条命令跑完本地门禁;跳过项会显式警告"不算全绿"
make verify # 本仓唯一门禁入口(本地与 CI 同一条命令);跳过项会显式警告「不算全绿」
```
- 门禁**串行**、失败即停:构建会清空产物目录,而端到端测的是构建产物 —— 并行会制造假失败。
- 门禁**串行**、失败即停;**汇总按必需清单逐项核对**:失败即停后剩下的记 `not_run`,
而 `not_run` / `skipped` 都不算通过(别把「没跑到」当成「没问题」)。
- 只有「跑产物」的套件(端到端 / 契约 / 产物自检)才必须排在构建之后;
单测(`go test` / `pytest` / `vitest`)各自跑源码,不需要先构建。
- 改到部署脚本或产物形状时,必须另外跑一次真实的装配/启动自检。
## 3. CI 闭环
+103 -51
View File
@@ -1,16 +1,26 @@
# <PROJECT> —— 提交触发的 CI(Gitea Actions)
# 来自 zheromiku/eng-standards-main 的 templates/gitea-build.yml;正本见 01-规范-Git与CI-CD(Gitea版).md §4
#
# ⚠️ 本模板**只做 CI**:不含任何部署作业,也不启动 compose / 不改线上。
# CD(部署测试/生产)是**每个项目自己设计**的独立 workflow(正本 §8)。
# 这里**不再**放 `deploy:test` 占位骨架 —— 占位作业会打印 TODO 然后返回成功,
# 那不是"还没做",那是**假报成功**(规范里明确禁止)。
#
# ⚠️ 四处形状不许动(动了会被规范判为不合规):
# · 触发只有三类入口:推任意分支(校验+编译,不部署)、推 main(+自动部署测试环境)、workflow_dispatch
# · 提交信息闸门(gate)必须在编译之前
# · 生产部署**不在这里** —— 它是独立的 prod-deploy.yml,只由 workflow_dispatch 触发(§5.2)
# · 触发只有:推任意分支(校验+编译,不部署)、workflow_dispatch
# · 提交信息闸门(gate)必须在编译之前,且**独立作业**(失败不编译,也便于一眼看出是信息问题不是代码问题)
# · 生产部署**不在这里**(独立 workflow,只由 workflow_dispatch 触发,见 §5.2)
# · 不用第三方 action(`uses:`)—— 本 runner 取不到就随机红;全部逻辑写在 run: 里
#
# ⚠️ 本 runner 实测特性(act 语义,见正本 §4.4):
# · act_runner 不会自动检出代码 ⇒ 必须自己 git init + fetch + checkout
# · 每步以 `bash -e -o pipefail` 执行 ⇒ 禁止 `cmd | head`(SIGPIPE 假红)
# · 工具链来自主机只读挂载:容器内 /opt/citools;可写缓存 /opt/citools-cache
#
# ⚠️ 三条"不许假绿"的写法(都踩过):
# · 判"输出即问题"的工具(gofmt -l / prettier --list-different)**要看输出**,不能看退出码
# · 缺锁文件 ⇒ 失败,**不回落** `npm install`(那会静默重写锁文件)
# · 识别不到任何构建入口 ⇒ 失败,**不**打印"跳过编译"然后成功
name: build
@@ -37,19 +47,27 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: 检出代码
- name: 检出代码(固定到事件完整 SHA)
run: |
set -uo pipefail
SERVER="${GITHUB_SERVER_URL:-http://192.168.50.223:3000}"
HOSTPORT="${SERVER#*://}"
BRANCH="${GITHUB_REF_NAME:-${GITHUB_REF#refs/heads/}}"
EVENT_SHA="${GITHUB_SHA}"
cd "${GITHUB_WORKSPACE:-$(pwd)}"
rm -rf ./* .git 2>/dev/null || true
git init -q .
git remote add origin "http://x-access-token:${GIT_TOKEN}@${HOSTPORT}/${GITHUB_REPOSITORY}.git"
git fetch -q --depth 50 origin "$BRANCH"
git checkout -q -B "$BRANCH" FETCH_HEAD
echo "已检出 ${GITHUB_REPOSITORY} 分支 ${BRANCH}"
if [ "$(git rev-parse HEAD)" != "$EVENT_SHA" ]; then
git fetch -q --depth 50 origin "$EVENT_SHA" && git checkout -q --detach FETCH_HEAD \
|| { echo "❌ 取不到事件 SHA $EVENT_SHA"; exit 1; }
fi
# ⚠️ 必须比对**完整** SHA:分支 HEAD 会随时前进,"构建成功"必须对应到某个确定的提交
[ "$(git rev-parse HEAD)" = "$EVENT_SHA" ] \
|| { echo "❌ 检出 SHA 与事件 SHA 不一致:$(git rev-parse HEAD) / $EVENT_SHA"; exit 1; }
echo "已检出 ${GITHUB_REPOSITORY} 分支 ${BRANCH} @ ${EVENT_SHA:0:12}(== 事件完整 SHA)"
git log -1 --format=' %h %an %s'
- name: 提交信息规范校验(权威闸门,失败不编译)
@@ -60,18 +78,25 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 40
steps:
- name: 检出代码
- name: 检出代码(固定到事件完整 SHA)
run: |
set -uo pipefail
SERVER="${GITHUB_SERVER_URL:-http://192.168.50.223:3000}"
HOSTPORT="${SERVER#*://}"
BRANCH="${GITHUB_REF_NAME:-${GITHUB_REF#refs/heads/}}"
EVENT_SHA="${GITHUB_SHA}"
cd "${GITHUB_WORKSPACE:-$(pwd)}"
rm -rf ./* .git 2>/dev/null || true
git init -q .
git remote add origin "http://x-access-token:${GIT_TOKEN}@${HOSTPORT}/${GITHUB_REPOSITORY}.git"
git fetch -q --depth 50 origin "$BRANCH"
git checkout -q -B "$BRANCH" FETCH_HEAD
if [ "$(git rev-parse HEAD)" != "$EVENT_SHA" ]; then
git fetch -q --depth 50 origin "$EVENT_SHA" && git checkout -q --detach FETCH_HEAD \
|| { echo "❌ 取不到事件 SHA $EVENT_SHA"; exit 1; }
fi
[ "$(git rev-parse HEAD)" = "$EVENT_SHA" ] \
|| { echo "❌ 检出 SHA 与事件 SHA 不一致"; exit 1; }
git rev-parse HEAD > /tmp/ci-sha
git rev-parse --short=12 HEAD > /tmp/ci-sha12
date +%s > /tmp/ci-start
@@ -79,6 +104,10 @@ jobs:
echo skipped > /tmp/ci-build-status
echo skipped > /tmp/ci-gate-status
git rev-list --count FETCH_HEAD > /tmp/ci-commits-count 2>/dev/null || echo 1 > /tmp/ci-commits-count
# 依赖清单摘要:编译完再比一次,抓"某一步顺手改了 go.mod / 锁文件"
for f in go.mod go.sum package.json package-lock.json pnpm-lock.yaml yarn.lock requirements.txt; do
[ -f "$f" ] && printf '%s %s\n' "$(sha256sum "$f" | cut -c1-16)" "$f"
done > /tmp/ci-manifests-before
- name: 编译与自检
run: |
@@ -90,7 +119,9 @@ jobs:
export PATH=/opt/citools/go/bin:/opt/citools/bin:$PATH
export GOCACHE=/opt/citools-cache/gocache GOMODCACHE=/opt/citools-cache/gomod
export UV_CACHE_DIR=/opt/citools-cache/uv GOPATH=/opt/citools-cache/gopath
export GOPROXY=https://goproxy.cn,direct GOTOOLCHAIN=local GOFLAGS=-mod=mod
export npm_config_cache=/opt/citools-cache/npm
# ⚠️ GOFLAGS 必须是 readonly:`-mod=mod` 会在构建时**静默改写** go.mod/go.sum
export GOPROXY=https://goproxy.cn,direct GOTOOLCHAIN=local GOFLAGS=-mod=readonly
mkdir -p "$GOCACHE" "$GOMODCACHE" "$UV_CACHE_DIR" "$GOPATH" 2>/dev/null
echo "工具链: $(go version 2>&1) | $(uv --version 2>&1) | $(node -v 2>&1) | $(python3 -V 2>&1)"
@@ -98,65 +129,108 @@ jobs:
if [ -f go.mod ]; then
ran="$ran go"
run go build ./... || fail=1
# ⚠️ `gofmt -l` 列出文件时**退出码仍是 0** —— 只看退出码就是永远的假绿
UGLY="$(gofmt -l $(git ls-files '*.go') 2>/dev/null)"
if [ -n "$UGLY" ]; then echo "❌ 这些 Go 文件没格式化:"; printf '%s\n' "$UGLY"; fail=1; fi
run go vet ./... || fail=1
run go build ./... || fail=1
run go test ./... || fail=1
fi
if [ -f pyproject.toml ] || [ -f requirements.txt ] || [ -f setup.py ]; then
ran="$ran python"
if grep -qE '^check:' Makefile 2>/dev/null; then
run make UV=uv check || fail=1
else
run uv sync || fail=1
run uv run pytest -q || fail=1
fi
run ruff format --check . || fail=1
run ruff check . || fail=1
if [ -f requirements.txt ]; then run uv pip install -r requirements.txt || fail=1; fi
run uv run pytest -q || fail=1
fi
WEBPKG=""
for p in package.json web/package.json frontend/package.json ui/package.json; do
if [ -f "$p" ]; then WEBPKG="$p"; break; fi
done
if [ -n "$WEBPKG" ]; then
ran="$ran node"; D="$(dirname "$WEBPKG")"
if [ -f "$D/package-lock.json" ]; then runin "$D" npm ci || fail=1; else runin "$D" npm install || fail=1; fi
if grep -q '"build"[[:space:]]*:' "$WEBPKG" 2>/dev/null; then runin "$D" npm run build || fail=1; fi
# ⚠️ 缺锁文件就失败:`npm install` 会按 package.json 重算依赖树并**重写锁文件**
if [ -f "$D/package-lock.json" ]; then
runin "$D" npm ci || fail=1
else
echo "❌ $D 下没有 package-lock.json:不接受无约束安装(先提交锁文件)"; fail=1
fi
if grep -q '"format:check"[[:space:]]*:' "$WEBPKG" 2>/dev/null; then
runin "$D" npm run format:check || fail=1
fi
if grep -q '"build"[[:space:]]*:' "$WEBPKG" 2>/dev/null; then
runin "$D" npm run build || fail=1
fi
if grep -q '"test"[[:space:]]*:' "$WEBPKG" 2>/dev/null; then
runin "$D" npm test || fail=1
fi
fi
if [ -z "$ran" ]; then
# ⚠️ 这里必须失败:识别不到入口 = 什么都没验 = 不能报成功
echo "❌ 未识别到任何构建入口(go.mod / pyproject.toml / package.json 都没有)"
echo " 要么把入口补上,要么在项目清单里声明'不适用'并写明理由 —— 不允许静默跳过。"
fail=1
fi
# 依赖清单不许被编译过程改写
for f in go.mod go.sum package.json package-lock.json pnpm-lock.yaml yarn.lock requirements.txt; do
[ -f "$f" ] && printf '%s %s\n' "$(sha256sum "$f" | cut -c1-16)" "$f"
done > /tmp/ci-manifests-after
if ! diff -q /tmp/ci-manifests-before /tmp/ci-manifests-after >/dev/null; then
echo "❌ 依赖清单在编译过程中被改写了(下面是对比):"; diff /tmp/ci-manifests-before /tmp/ci-manifests-after
echo " 这通常是 GOFLAGS=-mod=mod 或缺锁回落 npm install 造成的;改写必须由人显式提交。"
fail=1
fi
if [ -z "$ran" ] && [ -f Makefile ]; then ran="$ran make"; run make || fail=1; fi
if [ -z "$ran" ]; then echo "未识别到构建入口(go.mod / pyproject.toml / package.json / Makefile 都没有),跳过编译"; fi
if [ "$fail" -eq 0 ]; then echo success > /tmp/ci-build-status; else echo failure > /tmp/ci-build-status; fi
tail -c 4000 "$LOG" > /tmp/ci-build-tail
echo "构建结果: $(cat /tmp/ci-build-status)(识别:$ran)"
[ "$fail" -eq 0 ] || exit 1
- name: 发布网页预览
- name: 发布网页预览(只在全部必需检查通过后)
if: always()
run: |
set +e; set +o pipefail
: > /tmp/ci-preview-url
if [ "$(cat /tmp/ci-build-status 2>/dev/null)" != "success" ]; then
echo "构建未成功,跳过预览发布"; exit 0
echo "编译未成功,跳过预览发布"; exit 0
fi
WEB=""
for d in web/dist dist build out public web/build frontend/dist; do
if [ -f "$d/index.html" ]; then WEB="$d"; break; fi
done
if [ -z "$WEB" ]; then echo "未发现静态网页产物目录,跳过预览发布"; exit 0; fi
# ⚠️ 不要在打包时产生 `.` 根成员:先 cd 再只打目录内容(正本 §4.7)
tar -czf /tmp/ci-web.tgz -C "$WEB" . || { echo "打包失败"; exit 0; }
# ⚠️ 打包不要产生 `.` 根成员:先 cd 再让通配展开成员名(否则预览 404 而流水线显示成功)
( cd "$WEB" && { shopt -s dotglob; tar -czf /tmp/ci-web.tgz *; } ) || { echo "打包失败"; exit 0; }
tar -tzf /tmp/ci-web.tgz | grep -qx 'index.html' \
|| { echo "❌ 包里没有 index.html ⇒ 发出去也是 404 的预览,不发"; exit 0; }
B="${BRIDGE_URL:-}"
for cand in "$B" "http://172.17.0.1:8899" "http://192.168.50.134:8899" "http://100.92.49.78:8899"; do
if [ -n "$cand" ]; then curl -s -m 5 -o /dev/null "$cand/healthz" && { B="$cand"; break; }; fi
done
if [ -z "$B" ]; then echo "❌ 没有可达的 ci-bridge ⇒ 不发预览"; exit 0; fi
SHA="$(cat /tmp/ci-sha)"
echo "发布预览到 $B(产物目录 $WEB)"
curl -sS -m 90 -X POST "$B/api/ci/publish?repo=${GITHUB_REPOSITORY}&sha=${SHA}" \
CODE="$(curl -sS -m 90 -o /tmp/ci-pub.json -w '%{http_code}' -X POST \
"$B/api/ci/publish?repo=${GITHUB_REPOSITORY}&sha=${SHA}" \
-H "X-CI-Token: ${BRIDGE_TOKEN}" -H 'Content-Type: application/gzip' \
--data-binary @/tmp/ci-web.tgz -o /tmp/ci-pub.json
cat /tmp/ci-pub.json; echo
URL="$(python3 -c "import json;print(json.load(open('/tmp/ci-pub.json')).get('url',''))" 2>/dev/null)"
if [ -n "$URL" ]; then echo "$URL" > /tmp/ci-preview-url; echo "网页预览: $URL"; fi
--data-binary @/tmp/ci-web.tgz)"
echo "publish HTTP $CODE $(cat /tmp/ci-pub.json)"
[ "$CODE" = "200" ] || { echo "❌ 发布接口返回 $CODE"; exit 0; }
for key in url url_tailscale; do
U="$(python3 -c "import json,sys;print(json.load(open('/tmp/ci-pub.json')).get(sys.argv[1],''))" "$key")"
[ -n "$U" ] || { echo "⚠️ 报告里 $key 为空"; continue; }
C="$(curl -s -o /dev/null -w '%{http_code}' -m 10 "$U")"
echo "预览探测 $key: $U → $C"
[ "$C" = "200" ] || { echo "❌ $U 不是 200(通知里给出打不开的链接等于没给)"; exit 0; }
[ "$key" = "url" ] && echo "$U" > /tmp/ci-preview-url
done
echo "✅ 预览已发布,且每个地址都 200"
- name: 上报 ci-bridge(通知 + 报告)
- name: 上报 ci-bridge(通知失败不改变质量结果)
if: always()
run: |
set +e; set +o pipefail
@@ -167,26 +241,4 @@ jobs:
echo "使用 ci-bridge: $B"
S=$(date +%s); T=$(cat /tmp/ci-start 2>/dev/null); [ -n "$T" ] || T=$S
echo $((S - T)) > /tmp/ci-duration
CI_BRIDGE_RESOLVED="$B" python3 .gitea/scripts/ci_report.py
# ─────────────────────────────────────────────────────────────────────────
# 测试环境部署:只在推 main 时自动跑(正本 §4.6)
# TODO:本作业只是骨架。填 <TEST_HOST> 与下面的读回断言后再启用。
# 发布**必须**是"构建一次、两个环境部署同一份产物"(正本 §5.1)—— 见 prod-deploy.yml 的注释。
# ─────────────────────────────────────────────────────────────────────────
deploy:test:
needs: [gate, build]
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
timeout-minutes: 30
concurrency:
group: deploy-test
cancel-in-progress: false
steps:
- name: 占位(未启用)
run: |
echo "TODO: ① 取本次构建产物(按 sha12 寻址)② 校验 tar_sha256 ③ scp 到测试机"
echo " ④ docker load → 断言 image_id == release-<sha12>.json"
echo " ⑤ 切镜像 → 读回 healthz / version / env"
echo " ⑥ /version.commit_sha 必须 == $(cat /tmp/ci-sha12 2>/dev/null || echo '<sha12>')"
echo " 见正本 §5.1 证据链表与 02-流程说明与Runbook.md §C"
CI_BRIDGE_RESOLVED="$B" python3 .gitea/scripts/ci_report.py
+23 -7
View File
@@ -7,6 +7,11 @@
# 3. concurrency.cancel-in-progress **必须** false —— 取消不是回滚,会留下换了一半的镜像(§5.4)
# 4. **不构建**。只搬运"测试验收过的那一份产物"(同 tar_sha256 / 同 image_id,§5.1)
#
# ⚠️ **占位步骤一律 `exit 1`,不许返回成功**(正本 §8)。
# 一个"打印 TODO 然后返回 0"的占位作业,会在网页上显示成一次成功的生产发布 ——
# 那比没有这个 workflow 更危险。所以本模板里未实现的步骤**故意是红的**:
# 你填完真实逻辑它才会绿。第一次手动发起时看到红,是设计,不是故障。
#
# 人工确认三层(§5.2):
# 第一层 平台:本 workflow 只能手动发起
# 第二层 参数:confirm 必须等于字面量 deploy-production
@@ -60,17 +65,28 @@ jobs:
- name: 取产物并校验(不构建!)
run: |
set -eu
echo "TODO: 从产物托管点取 release-<${{ github.event.inputs.sha }}>.json 与 <repo>-<sha12>.tar"
echo " ① sha256sum <tar> 必须 == 清单 tar_sha256"
echo " ② 清单 sha12 必须 == 输入 sha"
echo " 见 02-流程说明与Runbook.md §D"
echo "❌ 本步骤尚未实现:占位不得返回成功(正本 §8)"
cat <<'EOF'
要填的真实逻辑(见 02-流程说明与Runbook.md §D):
① 从产物托管点取 release-<sha12>.json 与 <repo>-<sha12>.tar
② sha256sum <tar> 必须 == 清单 tar_sha256
③ 清单 sha12 必须 == 输入 sha
在填完之前,这条 workflow 会一直红 —— 这是**故意的**。
EOF
exit 1
- name: 交付到生产机(只传文件,不切镜像)
run: |
set -eu
echo "TODO: scp 到 120.76.218.137:/opt/dobotai/container/incoming/"
echo " ⚠️ 凭据只走 stdin / 文件,绝不进命令行参数(会进 ps)"
echo " ⚠️ ssh 是非交互 shell,不读 /etc/profile:远端命令要显式补 PATH"
echo "❌ 本步骤尚未实现:占位不得返回成功(正本 §8)"
cat <<'EOF'
要填的真实逻辑:
① scp 到 <PROD_HOST>:<INCOMING_DIR>/
② ⚠️ 凭据只走 stdin / 文件,绝不进命令行参数(会进 ps)
③ ⚠️ ssh 是非交互 shell,不读 /etc/profile:远端命令要显式补 PATH
在填完之前,这条 workflow 会一直红 —— 这是**故意的**。
EOF
exit 1
- name: 输出给本人的"下一步"(第三层确认在这里)
run: |