fix(ci): 第一步改最小 bootstrap(空工作区可启动),新分支范围以默认分支为基准

空工作区启动:workflow 第一步不再调用仓内脚本(此时它还不存在,实测 rc=127),改成只用 runner 环境变量的最小 bootstrap:校验协议/凭据 → git init+remote add → 取事件完整 SHA → checkout → 断言 HEAD==事件 SHA;检出后才调用 ci_checkout.sh --post-checkout 做再断言与补取(默认分支引用、事件 before)。按任意 SHA fetch 在服务端默认不可用(服务器不允许请求未公开的对象),改成取分支历史 + --deepen 再核事件 SHA。新分支范围:after --not --remotes 会把刚 fetch 的同名远端分支(=after)当排除集、排除自身得到 0 条;改为以远端默认分支为基准 refs/remotes/origin/<default>..<after>,取不到基准就明确失败。新增 scripts/ci_chain_fixture.py:本地 HTTP git 服务 + 把 workflow 的 bootstrap run 块原样抽出来在空目录执行,正/负例 12 项断言。
This commit is contained in:
姚章浩
2026-10-12 16:45:10 +08:00
parent 75a6085844
commit d167a2b937
7 changed files with 615 additions and 58 deletions
+63 -40
View File
@@ -1,17 +1,21 @@
#!/usr/bin/env bash
# 检出:固定到**事件的完整 SHA**,协议按服务端给的来(不许静默降级成 http),URL 里不放凭据。
# 检出后的**仓内校验与补取**(`--post-checkout`);以及只打印 remote 的 `--print-remote`。
#
# 为什么要单独成一个脚本(而不是写在 workflow 的 `run:` 里):
# workflow 里的 shell 没法在本地跑,也就没法给它补负例 —— 而"协议被降级""remote 里带了令牌"
# 这两件事恰恰是**只有真跑一遍才看得出来**的。
# ⚠️ 为什么本脚本**不能**是 workflow 的第一步:
# Runner 的工作区初始是空的(trap-6),此时 `.gitea/scripts/ci_checkout.sh` **根本不存在** ——
# 直接 `bash .gitea/scripts/ci_checkout.sh` 会返回 127(实测,第三轮审查)。所以顺序必须是:
# ① workflow 里一段**最小 bootstrap**(只依赖 runner 给的环境变量,不依赖仓内任何文件):
# 校验协议/凭据 → git init + remote add → 取**事件完整 SHA** → checkout → 断言 HEAD == 事件 SHA;
# ② 检出完成、脚本就位之后,才调用本脚本 `--post-checkout` 做剩下的校验与补取。
# 本脚本仍然保留协议/凭据/身份的断言 —— 它们要能被本地负例打到(`--print-remote` 等)。
#
# 用法:
# .gitea/scripts/ci_checkout.sh # CI 里跑(读下面的环境变量)
# .gitea/scripts/ci_checkout.sh --print-remote # 只打印将要使用的 remote(本地测协议用,不联网)
# .gitea/scripts/ci_checkout.sh --post-checkout # workflow 检出后调用(读下面的环境变量)
# .gitea/scripts/ci_checkout.sh --print-remote # 只打印将要使用的 remote(本地测协议,不联网)
#
# 环境变量(CI 提供):
# GITHUB_SERVER_URL / GITHUB_REPOSITORY / GITHUB_REF_NAME / GITHUB_SHA
# GITHUB_TOKEN(步骤级注入)/ GITHUB_WORKSPACE / GITHUB_EVENT_PATH(用来取 before)
# GITHUB_TOKEN(步骤级注入)/ GITHUB_WORKSPACE / GITHUB_EVENT_PATH(取 before 与默认分支)
set -uo pipefail
SRV="${GITHUB_SERVER_URL:-http://192.168.50.223:3000}"
@@ -19,15 +23,15 @@ WS="${GITHUB_WORKSPACE:-$(pwd)}"
BR="${GITHUB_REF_NAME:-${GITHUB_REF:-}}"
BR="${BR#refs/heads/}"
EVENT_SHA="${GITHUB_SHA:-}"
ZERO="0000000000000000000000000000000000000000"
MODE="${1:-}"
# ── ① 协议:**保留服务端给的协议**,并显式禁止 https → http 的降级 ──────────────
# ── 协议:保留 runner 给的服务端协议,并显式禁止 https → http 的降级 ──────────
case "$SRV" in
https://*|http://*) ;;
*) echo "❌ GITHUB_SERVER_URL 的协议不认识:${SRV:0:40}"; exit 1 ;;
esac
case "$SRV" in
*[[:space:]]*) echo "❌ GITHUB_SERVER_URL 里有空白字符"; exit 1 ;;
*) echo "❌ GITHUB_SERVER_URL 的协议不认识(只接受 http/https):${SRV:0:40}"; exit 1 ;;
esac
case "$SRV" in *[[:space:]]*) echo "❌ GITHUB_SERVER_URL 里有空白字符"; exit 1 ;; esac
case "$SRV" in https://*) WANT_SCHEME="https" ;; *) WANT_SCHEME="http" ;; esac
REMOTE="${SRV%/}/${GITHUB_REPOSITORY}.git"
case "$REMOTE" in https://*) GOT_SCHEME="https" ;; http://*) GOT_SCHEME="http" ;; *) GOT_SCHEME="?" ;; esac
@@ -38,63 +42,82 @@ fi
# 凭据一律走 credential helper(URL 内嵌凭据会进 ps / 配置 / 日志)
case "$REMOTE" in *@*) echo "❌ remote URL 里带上了凭据"; exit 1 ;; esac
if [ "${1:-}" = "--print-remote" ]; then
if [ "$MODE" = "--print-remote" ]; then
echo "$REMOTE"
exit 0
fi
if [ "$MODE" != "--post-checkout" ]; then
echo "用法:ci_checkout.sh --post-checkout(检出后校验+补取)|--print-remote(只打印 remote)"
echo "⚠️ 本脚本**不是**检出本身:空工作区里它还不存在,第一步必须是最小 bootstrap。"
exit 2
fi
[ -n "$EVENT_SHA" ] || { echo "❌ 没有 GITHUB_SHA:检出必须固定到事件的完整 SHA"; exit 1; }
cd "$WS" || exit 1
rm -rf ./* .git 2>/dev/null || true
git init -q .
git remote add origin "$REMOTE"
[ -n "$EVENT_SHA" ] || { echo "❌ 没有 GITHUB_SHA:检出必须固定到事件的完整 SHA"; exit 1; }
[ -n "${GITHUB_TOKEN:-}" ] || { echo "❌ 没有拿到读仓库用的令牌(GITHUB_TOKEN 为空)"; exit 1; }
gitauth() { git -c credential.helper='!f() { printf "username=x-access-token\npassword=%s\n" "$GITHUB_TOKEN"; }; f' "$@"; }
gitauth fetch -q --depth 50 origin "$BR"
git checkout -q -B "$BR" FETCH_HEAD
if [ "$(git rev-parse HEAD)" != "$EVENT_SHA" ]; then
echo "⚠️ 分支 $BR 的 HEAD 已经不等于事件 SHA(有人在之后又推了一次)—— 改按精确 SHA 取"
gitauth fetch -q --depth 50 origin "$EVENT_SHA" && git checkout -q --detach FETCH_HEAD \
|| { echo "❌ 取不到事件 SHA $EVENT_SHA"; exit 1; }
fi
GOT="$(git rev-parse HEAD)"
# ① 检出必须就是事件那个完整 SHA(bootstrap 已经断言过,这里再断言一次 —— 这一层能被本地负例打到)
GOT="$(git rev-parse HEAD 2>/dev/null || echo "")"
if [ "$GOT" != "$EVENT_SHA" ]; then
echo "❌ 检出 SHA 与事件 SHA 不一致:实际 $GOT / 事件 $EVENT_SHA"
echo " (门禁的 identity 项会再拦一次;这里先失败,避免拿错误的代码下结论)"
echo "❌ 检出 SHA 与事件 SHA 不一致:实际 ${GOT:-空} / 事件 $EVENT_SHA"
exit 1
fi
# remote 里不许有凭据(防有人改回内嵌凭据的写法)
if git remote get-url origin 2>/dev/null | grep -q '@'; then
echo "❌ remote URL 里带上了凭据:$(git remote get-url origin | sed 's#//.*@#//***@#')"
exit 1
fi
# ── ② 把 before 也取下来:否则"本次推送范围 before..after"在浅克隆里算不出来 ──
# 不取的话,范围校验要么报错要么静默收缩 —— 两种都不是"检查过本次改动"。
# ② 补取:远端追踪引用(分支 + 默认分支)与事件 before
# —— 没有这些引用,范围校验要么算不出、要么退化成"整部历史",两种都不是"检查过本次改动"。
if [ -n "$BR" ]; then
gitauth fetch -q --depth 50 origin "refs/heads/${BR}:refs/remotes/origin/${BR}" 2>/dev/null \
&& echo "✅ 已取远端分支引用 refs/remotes/origin/${BR}"
fi
DEFAULT=""
if [ -n "${GITHUB_EVENT_PATH:-}" ] && [ -f "${GITHUB_EVENT_PATH}" ]; then
DEFAULT="$(python3 -c 'import json,os
try:
d = json.load(open(os.environ["GITHUB_EVENT_PATH"], encoding="utf-8"))
print((d.get("repository") or {}).get("default_branch") or "")
except Exception:
print("")' 2>/dev/null || echo "")"
fi
[ -n "$DEFAULT" ] || DEFAULT="$(git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null | sed 's#^origin/##' || true)"
[ -n "$DEFAULT" ] || DEFAULT="$BR" # 推 main 时,被推的分支就是默认分支
if [ -n "$DEFAULT" ]; then
gitauth fetch -q --depth 50 origin "refs/heads/${DEFAULT}:refs/remotes/origin/${DEFAULT}" 2>/dev/null \
&& echo "✅ 已取默认分支引用 refs/remotes/origin/${DEFAULT}(新分支推送的范围基准)" \
|| echo "⚠️ 取不到默认分支 ${DEFAULT}:新分支推送会因此明确失败,不会静默算成空范围"
else
echo "⚠️ 事件里没有默认分支名,也推断不出:新分支推送会因此明确失败"
fi
BEFORE=""
if [ -n "${GITHUB_EVENT_PATH:-}" ] && [ -f "${GITHUB_EVENT_PATH}" ]; then
BEFORE="$(python3 -c 'import json,os,sys
BEFORE="$(python3 -c 'import json,os
try:
d = json.load(open(os.environ["GITHUB_EVENT_PATH"], encoding="utf-8"))
print(d.get("before") or "")
except Exception:
print("")' 2>/dev/null || echo "")"
fi
if [ -n "$BEFORE" ] && [ "$BEFORE" != "0000000000000000000000000000000000000000" ]; then
if [ -n "$BEFORE" ] && [ "$BEFORE" != "$ZERO" ]; then
if git cat-file -e "${BEFORE}^{commit}" 2>/dev/null; then
echo "✅ 事件 before ${BEFORE:0:12} 已在克隆里(本次推送范围可直接算)"
else
gitauth fetch -q --depth 50 origin "$BEFORE" 2>/dev/null \
&& echo "✅ 已补取事件 before ${BEFORE:0:12}(本次推送范围可算)" \
&& echo "✅ 已补取事件 before ${BEFORE:0:12}" \
|| echo "⚠️ 取不到事件 before ${BEFORE:0:12}:范围校验会明确失败,不会静默收缩"
fi
else
echo "(事件里没有 before / before 为全 0:新分支推送或手工运行,范围按 after 判)"
echo "(事件里没有 before / before 为全 0:新分支推送,范围按默认分支为基准判)"
fi
# 令牌不该出现在 remote 配置里:顺手断言一次(防有人改回去)
if git remote get-url origin | grep -q '@'; then
echo "❌ remote URL 里带上了凭据:$(git remote get-url origin | sed 's#//.*@#//***@#')"
exit 1
fi
git rev-list --count FETCH_HEAD > "${WS}/.ci-commits-count" 2>/dev/null || echo 1 > "${WS}/.ci-commits-count"
git rev-list --count HEAD > "${WS}/.ci-commits-count" 2>/dev/null || echo 1 > "${WS}/.ci-commits-count"
: > "${WS}/.ci-preview-url"
echo "✅ 已检出 ${GITHUB_REPOSITORY} 分支 $BR @ ${GOT:0:12}(== 事件完整 SHA,协议 $GOT_SCHEME)"
echo "✅ 检出就绪:${GITHUB_REPOSITORY} 分支 ${BR:-(游离)} @ ${GOT:0:12}(== 事件完整 SHA,协议 $GOT_SCHEME)"
git log -1 --format=' %h %an %s'
echo "CI_COMMITS_COUNT_FILE=${WS}/.ci-commits-count" >> "${GITHUB_ENV:-/dev/null}"
+48 -9
View File
@@ -6,7 +6,9 @@
**校验范围必须是"本次这一批"**,按优先级取,取不到就报错 —— 不回退成"最近 N 条":
1. `CI_COMMIT_RANGE=A..B`(显式指定,调试/重放用);
2. Gitea 事件负载:`commits[]`(推送事件自带本次推送的每一条);
2. Gitea 事件负载:`before..after` 是权威范围(**不是**负载里那串 commits —— 它可能被截断);
新分支推送(before 为全 0)以**远端默认分支**为基准,绝不用 `after --not --remotes`:
检出后 origin/<本分支> 往往已经等于 after,那个写法会把自身排除成空集(实测会漏检);
并且核对负载里的 `after` 与当前 HEAD 一致 —— 不一致说明事件与检出不是同一批;
3. 本地:`git log --not --remotes`(还没推到任何远端的提交 = 本次要推的范围);
本地**一个远端都没有**时,明确只核 HEAD(`--not --remotes` 在那种情况下会退化成整部历史,
@@ -152,8 +154,25 @@ def resolve_scope(explicit="", env=None, cwd=None):
# ── ① 推送事件:before..after 是权威范围 ─────────────────────────────
if before and after and after != zero:
if before == zero:
spec2 = f"{after} --not --remotes" # 新分支:远端没有这个分支的历史
src = "新分支推送(before 为全 0)⇒ 范围 = after 之后未被远端覆盖的提交"
# 新分支(before 为全 0):**不能**用 `after --not --remotes` 来推范围 ——
# 检出后 refs/remotes/origin/<本分支> 往往已经等于 after,那个写法会把自身排除掉,
# 得到空集合(第三轮审查实测:0 条提交并失败)。基准要用**远端默认分支**:
# 这批提交就是"从默认分支分出来之后新增的那些"。
base = ""
repo_obj = d.get("repository")
if isinstance(repo_obj, dict):
base = str(repo_obj.get("default_branch") or "").strip()
if not base:
return [], "", ("新分支推送,但事件里没有默认分支名,定不了基准 —— "
"范围无法确定,不许放行(用 --range A..B 显式指定)")
rc, tip, _ = git("rev-parse", "--verify", f"refs/remotes/origin/{base}", cwd=cwd)
if rc != 0 or not tip.strip():
return [], "", (f"新分支推送,但检出的克隆里取不到基准 refs/remotes/origin/{base} —— "
f"不知道这批提交从哪分出来的,不许放行"
f"(补取该分支,或用 --range A..B 显式指定)")
spec2 = f"refs/remotes/origin/{base}..{after}"
src = (f"新分支推送(before 为全 0)⇒ 范围 = origin/{base}"
f"({tip.strip()[:12]})..{after[:12]}")
else:
spec2 = f"{before}..{after}"
src = f"事件 Git 范围 {before[:12]}..{after[:12]}"
@@ -385,12 +404,32 @@ def self_test() -> int:
if not err:
problems.append("空范围(before == after)竟然没有报错")
# ⑨ 新分支推送(before 全 0)⇒ 用 after --not --remotes 取
# ⑨ 新分支推送(before 全 0):基准是**远端默认分支**,不是"刚检出的同名分支"
newb = {"before": "0" * 40, "after": good2, "total_commits": 2, "commits": [],
"repository": {"default_branch": "main"}}
# ⑨a 取不到基准 ⇒ 必须报错(不许拿"整部历史"或"空集合"糊过去)
with open(evf, "w", encoding="utf-8") as fh:
json.dump({"before": "0" * 40, "after": good2, "total_commits": 3, "commits": []}, fh)
json.dump(newb, fh)
commits, src, err = resolve_scope(env={"GITHUB_EVENT_PATH": evf, "GITHUB_ACTIONS": "true"}, cwd=d)
if err or len(commits) != 3 or "新分支" not in src:
problems.append(f"新分支推送没有按 after --not --remotes 取:{err or (len(commits), src)}")
if not err:
problems.append("新分支推送但取不到 origin/main 时竟然没有报错")
# ⑨b 有基准 ⇒ 范围 = origin/main(good1)..good2 的那 2 条
git("update-ref", "refs/remotes/origin/main", good1, cwd=d)
commits, src, err = resolve_scope(env={"GITHUB_EVENT_PATH": evf, "GITHUB_ACTIONS": "true"}, cwd=d)
if err or len(commits) != 2 or "新分支" not in src:
problems.append(f"新分支推送没有以 origin/main 为基准算范围:{err or (len(commits), src)}")
# ⑨c **回归用例(第三轮审查的复现)**:检出后 origin/<本分支> 已经等于 after ——
# 旧写法 `after --not --remotes` 会把自身排除掉 ⇒ 0 条;这里必须仍是那 2 条。
git("update-ref", "refs/remotes/origin/ci-trial", good2, cwd=d)
commits, src, err = resolve_scope(env={"GITHUB_EVENT_PATH": evf, "GITHUB_ACTIONS": "true"}, cwd=d)
if err or len(commits) != 2:
problems.append(f"同名远端分支把范围排除成空集(回归):{err or len(commits)}")
# 收尾:把夹具造的远端引用清掉,后面的用例要继续在"无远端"状态下跑
git("update-ref", "-d", "refs/remotes/origin/ci-trial", cwd=d)
git("update-ref", "-d", "refs/remotes/origin/main", cwd=d)
# ⑩ 手工运行(没有事件负载、也没有远端可比)⇒ 只核 HEAD,并且**写明**这一点
commits, src, err = resolve_scope(env={"GITHUB_ACTIONS": "true"}, cwd=d)
@@ -409,10 +448,10 @@ def self_test() -> int:
for p in problems:
print(f" · {p}", file=sys.stderr)
return 1
print("✅ 提交闸门自检通过:范围取法 12 个分支(显式范围 / Git 范围 before..after / "
print("✅ 提交闸门自检通过:范围取法 14 个分支(显式范围 / Git 范围 before..after / "
"负载声明与列表不一致 / 列表截断仍以 Git 为准 / 空范围拒绝 / 新分支 / 无 before 的列表 / "
"列表提交不在检出里 / after 与 HEAD 核对 / 手工运行只核 HEAD 并写明 / "
"无远端只核 HEAD / 未推送提交)")
"无远端只核 HEAD / 新分支以默认分支为基准(含同名分支回归)/ 未推送提交)")
return 0
+55 -9
View File
@@ -45,19 +45,65 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 40
steps:
- name: 检出代码(固定到事件完整 SHA;协议不降级;URL 不放凭据)
- name: 检出代码(最小 bootstrap:不依赖仓内文件,固定事件完整 SHA)
env:
GIT_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -uo pipefail
# 检出逻辑单独成脚本(.gitea/scripts/ci_checkout.sh),这样它能被本地负例打到:
# ① 协议按 GITHUB_SERVER_URL 原样保留,**禁止 https 降级成 http**;
# ② remote URL 里不放凭据(凭据只走 credential helper);
# ③ 检出必须等于事件的**完整 SHA**;
# ④ 顺带把事件的 before 也取下来 —— 否则"本次推送范围 before..after"
# 在浅克隆里算不出来,范围校验只能要么报错要么静默收缩。
GITHUB_TOKEN="${GIT_TOKEN:-}" GITHUB_ENV="${GITHUB_ENV:-/dev/null}" \
bash .gitea/scripts/ci_checkout.sh
# ⚠️ 工作区初始是空的(本 Runner 不自动检出,trap-6)⇒ 这一步**只能用 runner 给的环境变量**,
# 绝不能调用仓内脚本:此时 .gitea/scripts/ 还不存在,实测 rc=127(第三轮审查的启动阻断)。
# 检出完成、仓内脚本就位之后,才交给 ci_checkout.sh --post-checkout 做再断言与补取。
SRV="${GITHUB_SERVER_URL:-http://192.168.50.223:3000}"
case "$SRV" in
https://*|http://*) ;;
*) echo "❌ GITHUB_SERVER_URL 的协议不认识(只接受 http/https):${SRV:0:40}"; exit 1 ;;
esac
case "$SRV" in *[[:space:]]*) echo "❌ GITHUB_SERVER_URL 里有空白字符"; exit 1 ;; esac
REMOTE="${SRV%/}/${GITHUB_REPOSITORY}.git"
case "$REMOTE" in
https://*) SCHEME="https" ;;
http://*) SCHEME="http" ;;
*) echo "❌ remote 的协议不对:${REMOTE:0:30}"; exit 1 ;;
esac
WANT="${SRV%%://*}"
[ "$SCHEME" = "$WANT" ] || { echo "❌ 协议被降级:$SCHEME(服务端给的是 $WANT)"; exit 1; }
case "$REMOTE" in *@*) echo "❌ remote URL 里带上了凭据"; exit 1 ;; esac
[ -n "${GITHUB_SHA:-}" ] || { echo "❌ 没有 GITHUB_SHA:检出必须固定到事件的完整 SHA"; exit 1; }
[ -n "${GIT_TOKEN:-}" ] || { echo "❌ 没拿到读仓库用的令牌(secrets.GITHUB_TOKEN 为空)"; exit 1; }
BR="${GITHUB_REF_NAME:-${GITHUB_REF:-}}"; BR="${BR#refs/heads/}"
cd "${GITHUB_WORKSPACE:-$(pwd)}"
rm -rf ./* .git 2>/dev/null || true
git init -q .
git remote add origin "$REMOTE"
ga() { git -c credential.helper='!f() { printf "username=x-access-token\npassword=%s\n" "$GIT_TOKEN"; }; f' "$@"; }
if [ -n "$BR" ]; then
ga fetch -q --depth 50 origin "refs/heads/$BR" || { echo "❌ 取不到分支 $BR"; exit 1; }
else
ga fetch -q --depth 50 origin "$GITHUB_SHA" || { echo "❌ 取不到事件 SHA $GITHUB_SHA"; exit 1; }
fi
git checkout -q --detach FETCH_HEAD
if [ "$(git rev-parse HEAD)" != "$GITHUB_SHA" ]; then
echo "⚠️ 分支 $BR 的 HEAD != 事件 SHA(有人在之后又推了一次)—— 在已取到的历史里找事件 SHA"
# ⚠️ 不要 `fetch origin $SHA`:默认 Gitea/upload-pack **不允许请求未公开的对象**
# (实测 "服务器不允许请求未公开的对象")。做法是取深一点再核。
if ! git cat-file -e "${GITHUB_SHA}^{commit}" 2>/dev/null; then
ga fetch -q --deepen 200 origin "refs/heads/$BR" 2>/dev/null \
|| ga fetch -q --depth 200 origin "refs/heads/$BR" 2>/dev/null || true
fi
if git cat-file -e "${GITHUB_SHA}^{commit}" 2>/dev/null; then
git checkout -q --detach "$GITHUB_SHA"
else
echo "❌ 在已取到的历史里找不到事件 SHA $GITHUB_SHA"
echo " (浅克隆窗口不够:提高 fetch 深度;或等这次推送的 run 重跑)"
exit 1
fi
fi
GOT_SHA="$(git rev-parse HEAD)"
[ "$GOT_SHA" = "$GITHUB_SHA" ] || { echo "❌ 检出 $GOT_SHA != 事件 SHA $GITHUB_SHA"; exit 1; }
echo "✅ bootstrap 检出 ${GOT_SHA:0:12}(== 事件完整 SHA,协议 $SCHEME,remote 不含凭据)"
# 检出完成 ⇒ 仓内脚本就位:交给它做协议/身份再断言,并补取 before 与默认分支引用
GITHUB_TOKEN="$GIT_TOKEN" GITHUB_ENV="${GITHUB_ENV:-/dev/null}" \
bash .gitea/scripts/ci_checkout.sh --post-checkout
- name: 环境与工具链(缺什么一次性报全)
run: |
+26
View File
@@ -103,6 +103,32 @@ python3 -c "import json;d=json.load(open('ci-report.json'));print(d['overall_sta
仍未做/未验证(见文末"未部署"):本地检查通过 ≠ 远端 CI 已跑通;两仓**均未推送**。
### 改了什么(第三轮审查修正:启动与范围)
第三轮审查(GPT)实际复现了两个会**卡住真实 CI** 的问题,逐条修完并补了完整调用链夹具:
- **P0 空工作区起不来** → 旧写法把"检出"写成仓内脚本(`bash .gitea/scripts/ci_checkout.sh`),
而这个 Runner 不自动检出 ⇒ 工作区为空时脚本不存在,实测 **rc=127**。现在 workflow 第一步是
**最小 bootstrap**(只用 runner 的环境变量:校验协议/凭据 → `git init` + `remote add` →
取**事件完整 SHA** → checkout → 断言 HEAD==事件 SHA),检出完成、脚本就位后才调用
`ci_checkout.sh --post-checkout` 做再断言与补取(默认分支引用、事件 `before`)。
顺带修掉"按任意 SHA `fetch`"——服务端默认不允许(`服务器不允许请求未公开的对象`),改为
"取分支历史 + `--deepen` 再核事件 SHA"。
- **P1 新分支范围恒为空** → `after --not --remotes` 用的是**刚 fetch 的同名远端分支**,
它等于 `after`,于是把自身排除、范围恒为 0 条。现在新分支的基准是**远端默认分支**
(`repository.default_branch`):范围 = `refs/remotes/origin/<default>..<after>`;
取不到基准 ⇒ 明确失败,绝不用整部历史或空集糊过去。
- **P1 通用模板 Python 未锁定** → `templates/ci-gate.py` 不再有"缺锁就 `uv sync`"和
`uv pip install --system`:必须有 `uv.lock` 才装(`uv sync --frozen`),所有检查走
`uv run --frozen`(项目内 `.venv` 隔离),并断言 `sys.prefix != sys.base_prefix`;
只有 `requirements.txt` ⇒ 明确失败并要求先生成锁文件。
- **新增完整调用链夹具** `scripts/ci_chain_fixture.py`:本地 HTTP git 服务(真 `git http-backend`)+
**把 workflow 里的 bootstrap `run:` 块原样抽出来在空目录执行** → 再调用仓内脚本 →
再跑提交范围闸门。正例(检出到事件 SHA、协议保留、合规新分支通过)与负例
(旧写法 127、取不到 SHA 失败、含违规提交的新分支判红且**红在那条提交上**)共 12 项断言。
未推送、未合并、未部署;仍未在真实 Runner 上跑过(需要短命试验分支授权)。
## [0.1.0] - 2026-10-10
### 改了什么
+44
View File
@@ -276,3 +276,47 @@ YAML 里只调用它,不再内联这套拼接。
**修法**:`.gitea/scripts/ci_gate.py` 的 `resolve_scope()`(`--self-test` 11 个分支)+
检出脚本顺带把事件 `before` 取下来。
---
### trap-19|第一步就调用仓内脚本 = 整条流水线起不来
**症状**:workflow 第一步写 `bash .gitea/scripts/ci_checkout.sh`。本地"已经有代码的目录"里跑得好好的,
真到 Runner 上返回 **127**(`bash: .gitea/scripts/ci_checkout.sh: 没有那个文件或目录`)——
因为这个 Runner **不自动检出**,工作区初始是空的(trap-6),此时仓内脚本还不存在。
**根因**:把"检出"写成了仓内脚本,而"检出"恰恰是**让仓内文件出现**的那一步 —— 循环依赖。
本地验证只在有代码的目录里做,就永远看不到这个形状。
**判据**:第一步必须是**最小 bootstrap**:只依赖 runner 给的环境变量(`GITHUB_SERVER_URL` /
`GITHUB_REPOSITORY` / `GITHUB_SHA` / `GITHUB_TOKEN`),自己 `git init` + `remote add` + 取**事件完整 SHA**
+ checkout + 断言 HEAD==事件 SHA。检出完成、脚本就位之后,**才**调用仓内脚本做后续校验与补取。
**修法**:`build.yml` 第一步内联 bootstrap → 然后 `bash .gitea/scripts/ci_checkout.sh --post-checkout`。
`scripts/ci_chain_fixture.py` 会把那段 `run:` **原样抽出来**在空目录里执行(YAML 一改坏就红)。
### trap-20|`fetch origin <SHA>` 取任意提交(服务端默认不允许)
**症状**:分支 HEAD 已经前进时,bootstrap 想"按精确 SHA 取",执行 `git fetch origin $GITHUB_SHA`
→ `服务器不允许请求未公开的对象`(upload-pack 默认不开放按任意 SHA 取)。
**根因**:把 `--depth` 取分支的写法套到按 SHA 取上。这个能力在默认配置下**不存在**。
**判据**:事件 SHA 必须能从**已取到的分支历史**里找到;不够深就 `--deepen` 再找;
仍找不到才是失败(提示提高深度或等重跑)。失败要明确,不许"取不到就换个分支糊过去"。
**修法**:见 bootstrap 里的 `git cat-file -e "${GITHUB_SHA}^{commit}"` + `fetch --deepen 200` 分支。
### trap-21|`after --not --remotes` 在检出后把范围排成空集
**症状**:新分支推送(`before` 为全 0)用 `after --not --remotes` 算范围。CI 检出时
`refs/remotes/origin/<本分支>` 往往已经等于 `after` —— 那个引用把自身排除了,范围恒为 **0 条**,
闸门直接失败(或更糟:被当成"没有要检查的提交"放过去)。
**根因**:拿"刚 fetch 下来的同名远端分支"当排除集。它是**被检出的那个分支**,不是基线。
**判据**:新分支的基准只能是**远端默认分支**(事件里的 `repository.default_branch`),
范围 = `refs/remotes/origin/<default>..<after>`;取不到基准 ⇒ **明确失败**,不许退化成整部历史或空集。
**修法**:`ci_gate.resolve_scope()`;`ci_checkout.sh --post-checkout` 负责把默认分支引用取下来。
`--self-test` 里有专门的回归用例(`origin/<本分支> == after` 时必须仍算出那几条提交)。
+326
View File
@@ -0,0 +1,326 @@
#!/usr/bin/env python3
"""完整调用链夹具:**空工作区 → 最小 bootstrap → 仓内脚本 → 提交范围闸门**。
为什么必须单独有它(第三轮审查的两个阻断就是这里漏掉的):
· 前几轮的"检出脚本负例"是在**已经有代码的目录**里跑的 —— 那验证的是脚本本身,
**不覆盖 Runner 从空工作区启动**:工作区为空时 `.gitea/scripts/ci_checkout.sh` 还不存在,
直接调用它返回 127。而 workflow 的第一步如果就是调用它,整条流水线根本起不来。
· 新分支推送的范围用 `after --not --remotes` 算 —— 检出后 origin/<本分支> 通常已经等于 after,
那个写法会把自身排除掉,得到空集合(实测 0 条并失败)。这个形状只有
**fetch → checkout → gate** 走完整一遍才会出现。
做法:起一个本地 HTTP git 服务(真 `git http-backend`,不是 mock),把 workflow 里
**最小 bootstrap 那个 run 块原样抽出来执行**(所以 YAML 一旦改坏,这里立刻红),
然后调用仓内脚本、再跑提交范围闸门。
python3 scripts/ci_chain_fixture.py # 在项目仓里跑(需要 git 与 python3)
python3 scripts/ci_chain_fixture.py --workflow /path/to/build.yml
全程只在本机临时目录 + 127.0.0.1 上:不连真实 Gitea、不碰远端状态、不发任何通知。
"""
from __future__ import annotations
import argparse
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import threading
import urllib.parse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
FAILED: list[str] = []
PASSED: list[str] = []
def ok(msg: str) -> None:
PASSED.append(msg)
print(f" ✅ {msg}")
def no(msg: str) -> None:
FAILED.append(msg)
print(f" ❌ {msg}")
def git(*args, cwd=None, env=None, check=False):
p = subprocess.run(["git", *args], cwd=cwd, env={**os.environ, **(env or {})},
capture_output=True, text=True)
if check and p.returncode != 0:
raise RuntimeError(f"git {' '.join(args)} 失败:{p.stderr[:300]}")
return p
# ── 本地 HTTP git 服务(真 git http-backend)────────────────────────────────
class GitHTTPHandler(BaseHTTPRequestHandler):
root = "/tmp"
seen: list[str] = []
def log_message(self, *a):
pass
def _serve(self, method: str) -> None:
parsed = urllib.parse.urlparse(self.path)
body = self.rfile.read(int(self.headers.get("Content-Length") or 0)) if method == "POST" else b""
self.seen.append(f"{method} {parsed.path}")
env = {
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
"GIT_PROJECT_ROOT": self.root,
"GIT_HTTP_EXPORT_ALL": "1",
"PATH_INFO": urllib.parse.unquote(parsed.path),
"QUERY_STRING": parsed.query,
"REQUEST_METHOD": method,
"CONTENT_TYPE": self.headers.get("Content-Type") or "",
"CONTENT_LENGTH": str(len(body)),
"REMOTE_ADDR": "127.0.0.1",
"REMOTE_USER": "ci-fixture",
}
p = subprocess.run(["git", "http-backend"], input=body, env=env, capture_output=True)
raw = p.stdout
head, _, payload = raw.partition(b"\r\n\r\n")
if not payload:
head, _, payload = raw.partition(b"\n\n")
if p.returncode != 0 and not head:
self.send_response(500)
self.end_headers()
self.wfile.write(p.stderr[:300])
return
code = 200
headers = []
for line in head.decode("utf-8", "replace").splitlines():
if line.lower().startswith("status:"):
code = int(line.split(":", 1)[1].strip().split()[0])
elif ":" in line:
k, v = line.split(":", 1)
headers.append((k.strip(), v.strip()))
self.send_response(code)
for k, v in headers:
self.send_header(k, v)
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
def do_GET(self):
self._serve("GET")
def do_POST(self):
self._serve("POST")
# ── 从 workflow 里抽出 bootstrap 的 run 块(原样执行 ⇒ YAML 漂移会立刻暴露)──
def extract_bootstrap(workflow: Path) -> str:
text = workflow.read_text(encoding="utf-8")
lines = text.splitlines()
start = None
for i, line in enumerate(lines):
if line.strip().startswith("- name: 检出代码"):
start = i
break
if start is None:
raise RuntimeError(f"{workflow} 里找不到「检出代码」这一步")
run_at = None
for j in range(start, min(start + 12, len(lines))):
if lines[j].strip() == "run: |":
run_at = j
break
if run_at is None:
raise RuntimeError("「检出代码」这一步里没有 `run: |` 块")
indent = len(lines[run_at]) - len(lines[run_at].lstrip())
body: list[str] = []
for k in range(run_at + 1, len(lines)):
line = lines[k]
if line.strip() and (len(line) - len(line.lstrip())) <= indent:
break
body.append(line[indent + 2:] if line.strip() else "")
return "\n".join(body) + "\n"
def seed_origin(host: Path, repo: str, work: Path) -> dict:
"""在 $host/<repo>.git 造一个 bare 源:main 基线 + 一个**新分支**(含违规提交)。"""
bare = host / f"{repo}.git"
bare.parent.mkdir(parents=True, exist_ok=True)
git("init", "-q", "--bare", "-b", "main", str(bare), check=True)
git("init", "-q", "-b", "main", str(work), check=True)
empty_hooks = work.parent / "no-hooks"
empty_hooks.mkdir(exist_ok=True)
env = {"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t",
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t",
# 夹具要造一条**故意违规**的提交来验证闸门;本机的 commit-msg 钩子会先把它拦掉。
# 这不是绕过门禁:沙箱仓本来就没有钩子,那条提交只存在于临时目录里。
"GIT_CONFIG_COUNT": "1", "GIT_CONFIG_KEY_0": "core.hooksPath",
"GIT_CONFIG_VALUE_0": str(empty_hooks)}
(work / "README.md").write_text("fixture\n", encoding="utf-8")
# 种子仓里要有**本仓真实的 .gitea/scripts/** —— 否则检出后调用仓内脚本会 127,
# 而那不是我们要验的东西(我们要验的是"空工作区能不能启动 + 检出后脚本能不能被调用")。
shutil.copytree(ROOT / ".gitea" / "scripts", work / ".gitea" / "scripts",
ignore=shutil.ignore_patterns("__pycache__"))
(work / "scripts").mkdir(exist_ok=True)
shutil.copy2(ROOT / "scripts" / "ci_checks.py", work / "scripts" / "ci_checks.py")
git("add", "-A", cwd=work, env=env, check=True)
git("commit", "-q", "-m", "chore(fixture): 建立基线提交", cwd=work, env=env, check=True)
git("remote", "add", "origin", str(bare), cwd=work, check=True)
git("push", "-q", "origin", "main", cwd=work, env=env, check=True)
base = git("rev-parse", "HEAD", cwd=work, env=env).stdout.strip()
# 新分支:一条合规 + 一条**违规**提交(用来证明新分支的提交真的被检查了)
git("checkout", "-q", "-b", "ci-trial", cwd=work, env=env, check=True)
(work / "good.txt").write_text("1\n", encoding="utf-8")
git("add", "-A", cwd=work, env=env, check=True)
git("commit", "-q", "-m", "feat(trial): 新分支上的合规提交", cwd=work, env=env, check=True)
good_tip = git("rev-parse", "HEAD", cwd=work, env=env).stdout.strip()
(work / "bad.txt").write_text("2\n", encoding="utf-8")
git("add", "-A", cwd=work, env=env, check=True)
git("commit", "-q", "-m", "update stuff", cwd=work, env=env, check=True) # 违规
bad_tip = git("rev-parse", "HEAD", cwd=work, env=env).stdout.strip()
git("push", "-q", "origin", "ci-trial", cwd=work, env=env, check=True)
return {"base": base, "good_tip": good_tip, "bad_tip": bad_tip, "good_msg_tip": good_tip}
def run_bootstrap(script: Path, ws: Path, env_extra: dict) -> subprocess.CompletedProcess:
ws.mkdir(parents=True, exist_ok=True)
env = {**os.environ, "GITHUB_WORKSPACE": str(ws), "GITHUB_ENV": str(ws.parent / "gh_env"),
"GITHUB_REPOSITORY": env_extra.pop("_repo"), **env_extra}
env.pop("CI_COMMIT_RANGE", None)
return subprocess.run(["bash", str(script)], env=env, capture_output=True, text=True, timeout=300)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--workflow", default=str(ROOT / ".gitea" / "workflows" / "build.yml"))
args = ap.parse_args()
workflow = Path(args.workflow)
if not workflow.is_file():
print(f"❌ 找不到 workflow:{workflow}", file=sys.stderr)
return 2
if not (ROOT / ".gitea" / "scripts" / "ci_checkout.sh").is_file():
print("❌ 本仓里没有 .gitea/scripts/ci_checkout.sh(请在项目仓里跑)", file=sys.stderr)
return 2
tmp = Path(tempfile.mkdtemp(prefix="ci-chain-"))
try:
host = tmp / "host"
work = tmp / "seed"
info = seed_origin(host, "owner/repo", work)
# 本地 HTTP git 服务:GITHUB_SERVER_URL → http://127.0.0.1:<port>,仓库路径 owner/repo.git
GitHTTPHandler.root = str(host)
srv = ThreadingHTTPServer(("127.0.0.1", 0), GitHTTPHandler)
port = srv.server_address[1]
threading.Thread(target=srv.serve_forever, daemon=True).start()
server_url = f"http://127.0.0.1:{port}"
print(f"── 本地 git 服务 {server_url}(root={host},repo=owner/repo.git)──")
boot = tmp / "bootstrap.sh"
boot.write_text(extract_bootstrap(workflow), encoding="utf-8")
print(f"── bootstrap 来自 {workflow.name}(原样执行,不镜像、不复制)──")
# ── 正例 1:空工作区启动,取 main 基线 ──
print("\n== A. 空工作区 → bootstrap 检出(main 基线)==")
ws = tmp / "wsA"
p = run_bootstrap(boot, ws, {"GITHUB_SERVER_URL": server_url, "GITHUB_REF_NAME": "main",
"GITHUB_SHA": info["base"], "GIT_TOKEN": "fixture-token",
"_repo": "owner/repo"})
if p.returncode == 0:
ok("空工作区里 bootstrap 退出 0")
else:
no(f"空工作区里 bootstrap 退出 {p.returncode}:{(p.stderr or p.stdout).strip()[-200:]}")
head = git("rev-parse", "HEAD", cwd=ws).stdout.strip()
if head == info["base"]:
ok(f"检出 == 事件完整 SHA({head[:12]})")
else:
no(f"检出 {head[:12]} != 事件 SHA {info['base'][:12]}")
url = git("remote", "get-url", "origin", cwd=ws).stdout.strip()
if url == f"{server_url}/owner/repo.git" and "@" not in url:
ok(f"remote 保留了服务端协议且不含凭据:{url}")
else:
no(f"remote 不对:{url}")
if (ws / ".gitea" / "scripts" / "ci_checkout.sh").is_file():
ok("检出之后仓内脚本才就位(bootstrap 之前确实不存在)")
else:
no("检出后仓内脚本仍然不存在")
print(f" bootstrap 输出末行:{(p.stdout or '').strip().splitlines()[-1:]}")
# ── 负例:bootstrap 之前就先调用仓内脚本 ⇒ 必须 127(这就是第三轮阻断的复现)──
print("\n== B. 空工作区里**先调用仓内脚本**(旧写法的复现)==")
wsB = tmp / "wsB"
wsB.mkdir()
q = subprocess.run(["bash", ".gitea/scripts/ci_checkout.sh", "--post-checkout"],
cwd=wsB, capture_output=True, text=True)
if q.returncode == 127:
ok("空工作区里调用仓内脚本返回 127(证明第一步不能是它)")
else:
no(f"期望 127,实际 {q.returncode}")
# ── 负例:事件 SHA 不存在 ⇒ 必须失败,不许继续 ──
print("\n== C. 取不到事件 SHA ⇒ bootstrap 必须失败 ==")
wsC = tmp / "wsC"
r = run_bootstrap(boot, wsC, {"GITHUB_SERVER_URL": server_url, "GITHUB_REF_NAME": "main",
"GITHUB_SHA": "f" * 40, "GIT_TOKEN": "fixture-token",
"_repo": "owner/repo"})
if r.returncode != 0:
ok(f"取不到 SHA 时 bootstrap 非 0({r.returncode})")
else:
no("取不到事件 SHA 时 bootstrap 竟然返回 0")
# ── 正/负例:完整 fetch → checkout → gate(新分支推送)──
print("\n== D. 新分支推送:fetch → checkout → gate ==")
# 新分支上有 2 条提交(1 合规 + 1 违规):good 只到第一条(声明 1 条),
# bad 到第二条(声明 2 条)——这样"违规提交真的被判到"才有意义。
for label, tip, declared, want_rc in (("合规提交结尾", info["good_msg_tip"], 1, 0),
("违规提交结尾", info["bad_tip"], 2, 1)):
wsD = tmp / f"wsD-{want_rc}"
env_file = tmp / f"event-{want_rc}.json"
env_file.write_text(json.dumps({
"event_name": "push", "before": "0" * 40, "after": tip,
"total_commits": declared,
"commits": [{"id": info["good_tip"], "message": "feat(trial): 新分支上的合规提交"}],
"repository": {"default_branch": "main", "full_name": "owner/repo"},
}), encoding="utf-8")
p = run_bootstrap(boot, wsD, {"GITHUB_SERVER_URL": server_url, "GITHUB_REF_NAME": "ci-trial",
"GITHUB_SHA": tip, "GIT_TOKEN": "fixture-token",
"GITHUB_EVENT_PATH": str(env_file), "_repo": "owner/repo"})
if p.returncode != 0:
no(f"{label}:bootstrap 失败 {(p.stderr or p.stdout).strip()[-200:]}")
continue
g = subprocess.run([sys.executable, ".gitea/scripts/ci_gate.py"],
cwd=wsD, env={**os.environ, "GITHUB_EVENT_PATH": str(env_file),
"GITHUB_ACTIONS": "true", "GITHUB_SHA": tip},
capture_output=True, text=True, timeout=120)
out = g.stdout + g.stderr
m = re.search(r"校验范围:(.+?)|(\d+) 条提交", out)
scope = m.group(1) if m else "(没解析到范围)"
count = int(m.group(2)) if m else -1
if count <= 0:
no(f"{label}:范围算成 {count} 条 —— 空范围(第三轮阻断的复现)|{scope[:80]}")
else:
ok(f"{label}:范围 {count} 条|{scope[:70]}")
if g.returncode == want_rc:
ok(f"{label}:闸门退出码 {g.returncode}(符合预期)")
else:
no(f"{label}:闸门期望退出码 {want_rc},实际 {g.returncode}|{out.strip()[-200:]}")
if want_rc == 1:
if "update stuff" in out:
ok("违规提交『update stuff』真的被判到(不是范围为空偶然红)")
else:
no(f"红的原因不是那条违规提交:{out.strip()[-200:]}")
elif "全部合规" in out:
ok("合规新分支上的提交全部被判为合规")
else:
no(f"合规新分支没有被判成合规:{out.strip()[-200:]}")
srv.shutdown()
print(f"\n── 收到的 git HTTP 请求 {len(GitHTTPHandler.seen)} 次:"
f"{sorted(set(GitHTTPHandler.seen))[:4]} ──")
print(f"\n════ 完整调用链夹具:✅ {len(PASSED)} | ❌ {len(FAILED)} ════")
return 1 if FAILED else 0
finally:
shutil.rmtree(tmp, ignore_errors=True)
if __name__ == "__main__":
raise SystemExit(main())
+53
View File
@@ -339,5 +339,58 @@ if [ -x "$STD/scripts/new-project.sh" ]; then
rm -rf "$GEN2"
fi
say "20. 完整调用链:空工作区 → bootstrap → 仓内脚本 → 提交范围闸门"
if python3 scripts/ci_chain_fixture.py >/tmp/kg-chain.log 2>&1; then
ok "空工作区能启动:bootstrap 检出到事件完整 SHA、协议保留、脚本检出后才就位(12 项断言)"
else
no "完整调用链夹具失败,详见 /tmp/kg-chain.log"; tail -20 /tmp/kg-chain.log | sed 's/^/ /'
fi
if grep -q '没有那个文件或目录\|127' /tmp/kg-chain.log && grep -q '空工作区里调用仓内脚本返回 127' /tmp/kg-chain.log; then
ok "旧写法(第一步直接调用仓内脚本)的 127 已被复现并钉住"
fi
say "21. 通用模板:Python 必须锁定依赖 + 隔离环境"
# ① 自检必须覆盖"锁定/隔离"这一族判据(不是只看文本里有没有 --system —— 那会被说明文字误伤)
if python3 "$STD/templates/ci-gate.py" --self-test >/tmp/kg-pylock-self.log 2>&1 \
&& grep -q "Python 锁定/隔离" /tmp/kg-pylock-self.log; then
ok "通用门禁自检覆盖了「Python 锁定/隔离(缺锁失败、从不用 --system)」"
else
no "通用门禁自检没有覆盖 Python 锁定/隔离"; tail -3 /tmp/kg-pylock-self.log | sed 's/^/ /'
fi
# ② 真跑:只有 requirements.txt、没有 uv.lock ⇒ deps-install 红,并要求先生成锁文件
PYF="$(mktemp -d)"; mkdir -p "$PYF/scripts"
cp "$STD/templates/ci-gate.py" "$PYF/scripts/ci_checks.py"
cp "$STD/templates/check-secrets.py" "$PYF/scripts/check_secrets.py"
printf '[project]\nname = "fixture"\nversion = "0.1.0"\n' > "$PYF/pyproject.toml"
printf 'pytest==8.0.0\n' > "$PYF/requirements.txt"
printf '{"optional": []}' > "$PYF/scripts/test-scope.json"
( cd "$PYF" && git init -q -b main && env -u GITHUB_SHA python3 scripts/ci_checks.py --only deps-install ) >/tmp/kg-py-lock.log 2>&1
if [ $? -ne 0 ] && grep -q 'uv lock' /tmp/kg-py-lock.log; then
ok "只有 requirements.txt、没有 uv.lock ⇒ deps-install 红,并要求先生成锁文件"
else
no "缺锁时没有明确失败(或提示里没有'先生成锁文件')"; tail -5 /tmp/kg-py-lock.log | sed 's/^/ /'
fi
# ③ 真跑:生成 uv.lock 之后 ⇒ 绿,且环境是**项目内 .venv**(隔离,不是系统解释器)
if command -v uv >/dev/null 2>&1; then
( cd "$PYF" && uv lock >/dev/null 2>&1 )
( cd "$PYF" && env -u GITHUB_SHA python3 scripts/ci_checks.py --only deps-install ) >/tmp/kg-py-lock2.log 2>&1
# 断言看的是**这一项本身**(报告里 deps-install=success),不是进程总退出码 ——
# 夹具仓故意没有提交,总退出码会因为"commit_sha 不是完整 40 位"而红,那是另一回事。
if grep -q '按锁文件安装完成' /tmp/kg-py-lock2.log && grep -q 'deps-install=success' /tmp/kg-py-lock2.log; then
ok "有 uv.lock ⇒ 按锁文件安装成功(uv sync --frozen),deps-install=success"
else
no "有 uv.lock 时 deps-install 没通过"; tail -6 /tmp/kg-py-lock2.log | sed 's/^/ /'
fi
ISO="$( cd "$PYF" && uv run --frozen python -c "import sys; print(sys.prefix != sys.base_prefix)" 2>/dev/null )"
if [ "$ISO" = "True" ]; then
ok "依赖装在项目内 .venv(sys.prefix != sys.base_prefix ⇒ 没装进系统环境)"
else
no "Python 环境没有隔离(得到 $ISO)"
fi
else
no "本机没有 uv:无法验证锁定/隔离(不许当通过)"
fi
rm -rf "$PYF"
printf '\n\033[1m════ 反向验收汇总 ════\033[0m\n ✅ 通过 %d | ❌ 失败 %d\n' "$pass" "$fail"
[ "$fail" -eq 0 ] || exit 1