diff --git a/.gitea/scripts/ci_checkout.sh b/.gitea/scripts/ci_checkout.sh index 948139d..1bcccc3 100755 --- a/.gitea/scripts/ci_checkout.sh +++ b/.gitea/scripts/ci_checkout.sh @@ -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}" \ No newline at end of file diff --git a/.gitea/scripts/ci_gate.py b/.gitea/scripts/ci_gate.py index 0a7c830..ed916b0 100755 --- a/.gitea/scripts/ci_gate.py +++ b/.gitea/scripts/ci_gate.py @@ -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 diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 73d35f0..5d4c183 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -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: | diff --git a/CHANGELOG.md b/CHANGELOG.md index f3ed635..8a498e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/..`; + 取不到基准 ⇒ 明确失败,绝不用整部历史或空集糊过去。 +- **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 ### 改了什么 diff --git a/docs/agents/traps.md b/docs/agents/traps.md index d26aa8c..e9b1a99 100644 --- a/docs/agents/traps.md +++ b/docs/agents/traps.md @@ -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 ` 取任意提交(服务端默认不允许) + +**症状**:分支 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/..`;取不到基准 ⇒ **明确失败**,不许退化成整部历史或空集。 + +**修法**:`ci_gate.resolve_scope()`;`ci_checkout.sh --post-checkout` 负责把默认分支引用取下来。 +`--self-test` 里有专门的回归用例(`origin/<本分支> == after` 时必须仍算出那几条提交)。 + diff --git a/scripts/ci_chain_fixture.py b/scripts/ci_chain_fixture.py new file mode 100644 index 0000000..d00605d --- /dev/null +++ b/scripts/ci_chain_fixture.py @@ -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/.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:,仓库路径 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()) \ No newline at end of file diff --git a/scripts/ci_negative_fixtures.sh b/scripts/ci_negative_fixtures.sh index 65a5724..d67cbe2 100644 --- a/scripts/ci_negative_fixtures.sh +++ b/scripts/ci_negative_fixtures.sh @@ -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 \ No newline at end of file