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:
@@ -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}"
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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
|
||||
|
||||
### 改了什么
|
||||
|
||||
@@ -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` 时必须仍算出那几条提交)。
|
||||
|
||||
|
||||
@@ -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())
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user