fix(template): 同批次同步 bootstrap 启动、新分支基准与 Python 锁定隔离
模板 workflow 第一步改成最小 bootstrap(空工作区里仓内脚本还不存在);ci_checkout.sh 拆成 --post-checkout(检出后校验与补取:默认分支引用、事件 before)+ --print-remote;ci_gate.py 新分支范围以远端默认分支为基准;ci-gate.py 的 Python 依赖必须按 uv.lock 安装(uv sync --frozen)、所有检查走 uv run --frozen(项目内 .venv),并断言 sys.prefix != sys.base_prefix,缺锁/只有 requirements.txt 明确失败(不再 uv pip install --system)。文档补第三轮修正记录、trap 与自检命令。
This commit is contained in:
@@ -287,4 +287,7 @@ tail -n 50 /srv/aiball/ci/logs/bridge.log
|
||||
| 合并判定纯函数 | `python3 ~/eng-standards/scripts/adopt-existing.py --self-test` | 11 分支全拒绝 |
|
||||
|
||||
⚠️ 这些都是**本地**证据:本地绿不等于远端 CI 已跑通。远端证据只有一条 —— 真实 run 的链接 + 完整 SHA。
|
||||
| 完整调用链(空工作区→检出→闸门) | `python3 scripts/ci_chain_fixture.py`(项目仓里) | 12 项断言:空工作区能启动、协议保留、新分支范围非空、违规提交红 |
|
||||
| 模板 workflow 的启动顺序 | `grep -n "name: 检出代码" -A3 templates/gitea-build.yml` | 第一步是最小 bootstrap,不是仓内脚本 |
|
||||
| 通用门禁的 Python 锁定 | `python3 templates/ci-gate.py --self-test` | 缺 `uv.lock` 必须失败、从不用 `--system` |
|
||||
|
||||
|
||||
@@ -159,3 +159,17 @@
|
||||
**口径修正(文档)**:模板不再声称"CI 能拦住直推";"所有单测都依赖预构建产物"改成"只有跑产物的套件才排在构建之后";
|
||||
通用模板默认不携带任何部署步骤,占位一律 `exit 1`(不许假报成功)。
|
||||
|
||||
---
|
||||
|
||||
## H. 第三轮审查修正(启动阻断与范围,2026-10-12)
|
||||
|
||||
| 编号 | 问题(实际复现) | 修法 | 负例在哪 |
|
||||
|---|---|---|---|
|
||||
| U-1 | **空工作区起不来**:模板 workflow 第一步 `bash .gitea/scripts/ci_checkout.sh` —— Runner 不自动检出,工作区为空时脚本不存在,实测 **127** | 模板 workflow 第一步改成**最小 bootstrap**(只用 runner 环境变量:协议/凭据校验 → `git init`+`remote add` → 取**事件完整 SHA** → checkout → 断言 HEAD==事件 SHA);检出后才调 `ci_checkout.sh --post-checkout` | `kg` 的 `scripts/ci_chain_fixture.py`:**把模板/YAML 里那段 `run:` 原样抽出来**在空目录执行;并复现旧写法的 127 |
|
||||
| U-2 | **按任意 SHA `fetch` 不可用**:分支前进时 `git fetch origin $SHA` → `服务器不允许请求未公开的对象` | 改为"取分支历史 + `--deepen` 再核事件 SHA",仍找不到才失败(提示提高深度) | 同上(C 例:取不到 SHA 必须非 0) |
|
||||
| U-3 | **新分支范围恒为空**:`after --not --remotes` 把 `origin/<本分支>`(= after)当排除集,排除自身 ⇒ 0 条并失败 | 新分支基准 = **远端默认分支**:`refs/remotes/origin/<default>..<after>`;取不到基准 ⇒ 明确失败 | `ci_gate.py --self-test` 加了回归用例(`origin/<本分支> == after` 时仍须算出那几条)+ 调用链夹具 D 例(合规 0 / 违规 1,且红在那条违规提交上) |
|
||||
| U-4 | **Python 未锁定/未隔离**:缺 `uv.lock` 回退 `uv sync`;requirements 路径用 `uv pip install --system`;`uv run` 未冻结 | 必须 `uv.lock` ⇒ `uv sync --frozen`;所有检查 `uv run --frozen`;断言 `sys.prefix != sys.base_prefix`(项目内 `.venv`);只有 `requirements.txt` ⇒ 明确失败并要求先生成锁 | 夹具 `gate-py-neg`(无锁红 / 有锁绿 + 隔离打印)+ 夹具脚本第 21 节(静态查 `--system` 必须不存在) |
|
||||
|
||||
**顺序口径(模板注释里也钉住了)**:`最小 bootstrap → 仓内脚本 → 环境 → 门禁入口 → 权威门禁 → 核对报告 → 预览 → 上报`。
|
||||
**不能在检出前依赖仓内文件**;预览仍然排在"报告核对"之后。
|
||||
|
||||
|
||||
@@ -101,3 +101,17 @@ bash scripts/new-project.sh /tmp/probe Weknora/probe r1 && ls /tmp/probe/.gitea/
|
||||
⚠️ `templates/ci_report.py --self-test` 在**规范仓里**会因为找不到 `scripts/ci_checks.py`
|
||||
(实现随项目走)而明确报"自检跑不了"—— 那是**故意**的:不许拿一份空清单去跑出莫名其妙的结论。
|
||||
|
||||
|
||||
## 完整调用链(在**项目仓**里跑:空工作区 → 最小 bootstrap → 仓内脚本 → 提交范围闸门)
|
||||
|
||||
```bash
|
||||
python3 scripts/ci_chain_fixture.py
|
||||
```
|
||||
|
||||
它起一个本地 HTTP git 服务,并把 workflow 里 **bootstrap 那段 `run:` 原样抽出来**在**空目录**里执行
|
||||
(所以 YAML 一旦改坏,这里立刻红):正例验证"空工作区能启动、检出到事件完整 SHA、协议保留、
|
||||
新分支范围非空、合规提交通过",负例复现"第一步调用仓内脚本 = 127"、"取不到事件 SHA 必须失败"、
|
||||
"含违规提交的新分支判红且**红在那条提交上**"。
|
||||
|
||||
⚠️ **模板 workflow 的第一步必须是最小 bootstrap**(只依赖 runner 给的环境变量):
|
||||
空工作区里仓内脚本还不存在 —— `grep -n "name: 检出代码" -A3 templates/gitea-build.yml` 可核对。
|
||||
|
||||
+59
-15
@@ -99,6 +99,25 @@ def go_env() -> dict:
|
||||
return env
|
||||
|
||||
|
||||
def python_lock_plan() -> tuple[bool, list[str], str]:
|
||||
"""Python 依赖怎么装:**必须按锁文件、装进项目内隔离环境**。
|
||||
|
||||
· 有 `uv.lock` ⇒ `uv sync --frozen`:冻结锁文件 + 项目内 `.venv`(不碰系统解释器);
|
||||
· 只有 `requirements.txt` ⇒ **失败**并让人先建锁文件 —— 不接受无约束解析,
|
||||
也不用 `uv pip install --system` 装进系统环境(那会让"本地与 CI 一致"变成空话);
|
||||
· 两个都没有 ⇒ 失败(不许"没有依赖入口所以跳过")。
|
||||
"""
|
||||
if (ROOT / "uv.lock").is_file():
|
||||
note = "uv sync --frozen(锁文件锁定 + 项目内 .venv 隔离)"
|
||||
if (ROOT / "requirements.txt").is_file():
|
||||
note += ";⚠️ 同时存在 requirements.txt,但**不参与安装**(以 uv.lock 为准)"
|
||||
return True, ["sync", "--frozen"], note
|
||||
if (ROOT / "requirements.txt").is_file():
|
||||
return False, [], ("只有 requirements.txt、没有 uv.lock:不接受无约束安装,也不会用 --system "
|
||||
"装进系统环境。请先 `uv lock`(或 `uv add -r requirements.txt`)生成锁文件后重跑")
|
||||
return False, [], "既没有 uv.lock 也没有 requirements.txt:Python 依赖装不了,不许空跑成功"
|
||||
|
||||
|
||||
def digest(p: Path) -> str:
|
||||
return hashlib.sha256(p.read_bytes()).hexdigest() if p.is_file() else "missing"
|
||||
|
||||
@@ -288,6 +307,26 @@ def self_test() -> int:
|
||||
if (status == SUCCESS) != want:
|
||||
problems.append(f"pytest {name}:实际 {status}")
|
||||
|
||||
# Python 锁定/隔离:缺锁必须失败,且**从不**出现 --system
|
||||
import tempfile as _tf
|
||||
with _tf.TemporaryDirectory() as _d:
|
||||
_root = ROOT
|
||||
try:
|
||||
globals()["ROOT"] = Path(_d)
|
||||
ok, argv, _why = python_lock_plan()
|
||||
if ok:
|
||||
problems.append("既没有 uv.lock 也没有 requirements.txt 时竟然返回可安装")
|
||||
(Path(_d) / "requirements.txt").write_text("pytest\n", encoding="utf-8")
|
||||
ok, argv, _why = python_lock_plan()
|
||||
if ok or "--system" in " ".join(argv):
|
||||
problems.append("只有 requirements.txt 时没有明确失败(或用了 --system)")
|
||||
(Path(_d) / "uv.lock").write_text("version = 1\n", encoding="utf-8")
|
||||
ok, argv, _why = python_lock_plan()
|
||||
if not ok or argv != ["sync", "--frozen"]:
|
||||
problems.append(f"有 uv.lock 时没有走 --frozen:{ok} {argv}")
|
||||
finally:
|
||||
globals()["ROOT"] = _root
|
||||
|
||||
if not FULL_SHA.match("a" * 40) or FULL_SHA.match("a" * 12):
|
||||
problems.append("完整 SHA 形状判定不对")
|
||||
if not manifest_mismatch({"a": ("local",)}, [("a", ("local",)), ("b", ("ci",))]):
|
||||
@@ -298,7 +337,8 @@ def self_test() -> int:
|
||||
print(f" · {p}", file=sys.stderr)
|
||||
return 1
|
||||
print(f"✅ 门禁自检通过:汇总 {len(cases)} 分支 + 前端 {len(fe)} 判据"
|
||||
f"(含「进程非零必红」「不写死 vitest」)+ pytest 退出码语义 + 完整 SHA + 清单比对")
|
||||
f"(含「进程非零必红」「不写死 vitest」)+ pytest 退出码语义 + 完整 SHA + 清单比对"
|
||||
f" + Python 锁定/隔离(缺锁失败、从不用 --system)")
|
||||
return 0
|
||||
|
||||
|
||||
@@ -335,15 +375,19 @@ def c_deps() -> tuple[str, str]:
|
||||
if rc:
|
||||
return FAILURE, f"go mod download 失败:{(err or out).strip()[-200:]}"
|
||||
if "python" in langs:
|
||||
uv = need("uv", "Python 依赖按锁文件安装需要 uv;没有就明确失败,不空跑")
|
||||
if (ROOT / "uv.lock").is_file():
|
||||
rc, out, err = run([uv, "sync", "--frozen"])
|
||||
elif (ROOT / "requirements.txt").is_file():
|
||||
rc, out, err = run([uv, "pip", "install", "--system", "-r", "requirements.txt"])
|
||||
else:
|
||||
rc, out, err = run([uv, "sync"])
|
||||
ok, argv, why = python_lock_plan()
|
||||
if not ok:
|
||||
return FAILURE, why
|
||||
uv = need("uv", "Python 依赖按锁文件装需要 uv;没有就明确失败,不空跑")
|
||||
rc, out, err = run([uv, *argv])
|
||||
if rc:
|
||||
return FAILURE, f"Python 依赖安装失败:{(err or out).strip()[-200:]}"
|
||||
return FAILURE, f"Python 依赖安装失败({why}):{(err or out).strip()[-200:]}"
|
||||
# 隔离断言:环境必须是项目内 .venv,**不是**系统解释器(--system 那种)
|
||||
rc2, out2, err2 = run([uv, "run", "--frozen", "python", "-c",
|
||||
"import sys; assert sys.prefix != sys.base_prefix, 'not isolated'"])
|
||||
if rc2:
|
||||
return FAILURE, (f"Python 环境没有隔离(uv run 用的是系统解释器,"
|
||||
f"不是项目内 .venv):{(out2 or err2).strip()[-200:]}")
|
||||
if not langs:
|
||||
return FAILURE, "识别不到任何依赖入口(go.mod / pyproject.toml / package.json 都没有)"
|
||||
return SUCCESS, f"依赖按锁文件安装完成({'/'.join(langs)})"
|
||||
@@ -365,8 +409,8 @@ def c_format() -> tuple[str, str]:
|
||||
if "python" in langs:
|
||||
# 工具要**从项目环境里跑**(uv run):锁文件装了哪个版本就用哪个版本,
|
||||
# 不依赖 PATH 上碰巧存在的 ruff(那会让本地与 CI 跑出不同结果)。
|
||||
uv = need("uv", "Python 格式检查要经 uv 在项目环境里跑;没有就明确失败,不空跑")
|
||||
rc, out, err = run([uv, "run", "ruff", "format", "--check", ".", *["--exclude", "scripts/ci_checks.py", "--exclude", "scripts/check_secrets.py"]])
|
||||
uv = need("uv", "Python 格式检查要经 uv 在项目环境里跑(--frozen);没有就明确失败")
|
||||
rc, out, err = run([uv, "run", "--frozen", "ruff", "format", "--check", ".", *["--exclude", "scripts/ci_checks.py", "--exclude", "scripts/check_secrets.py"]])
|
||||
if rc:
|
||||
problems.append(f"ruff format --check 非零:{(out or err).strip()[-160:]}")
|
||||
if "node" in langs and web is not None:
|
||||
@@ -390,8 +434,8 @@ def c_static() -> tuple[str, str]:
|
||||
if rc:
|
||||
problems.append(f"go vet 非零:{(err or out).strip()[-200:]}")
|
||||
if "python" in langs:
|
||||
uv = need("uv", "Python 静态检查要经 uv 在项目环境里跑")
|
||||
rc, out, err = run([uv, "run", "ruff", "check", ".", *["--exclude", "scripts/ci_checks.py", "--exclude", "scripts/check_secrets.py"]])
|
||||
uv = need("uv", "Python 静态检查要经 uv 在项目环境里跑(--frozen)")
|
||||
rc, out, err = run([uv, "run", "--frozen", "ruff", "check", ".", *["--exclude", "scripts/ci_checks.py", "--exclude", "scripts/check_secrets.py"]])
|
||||
if rc:
|
||||
problems.append(f"ruff check 非零:{(out or err).strip()[-160:]}")
|
||||
if "node" in langs and web is not None:
|
||||
@@ -496,8 +540,8 @@ def c_tests() -> tuple[str, str]:
|
||||
notes.append(f"go 用例 通过 {passed} / 失败 {failed} / 已声明可选未运行 {sum(opt_counts.values())}")
|
||||
|
||||
if "python" in langs:
|
||||
uv = need("uv", "Python 测试要按锁文件跑(uv run pytest);没有 uv 就明确失败")
|
||||
rc, out, err = run([uv, "run", "pytest", "-q"])
|
||||
uv = need("uv", "Python 测试要按锁文件跑(uv run --frozen pytest);没有 uv 就明确失败")
|
||||
rc, out, err = run([uv, "run", "--frozen", "pytest", "-q"])
|
||||
status, why = judge_python_tests(rc, out or err)
|
||||
notes.append(f"pytest 退出码 {rc}")
|
||||
if status != SUCCESS:
|
||||
|
||||
+63
-40
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -78,19 +78,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: |
|
||||
|
||||
Reference in New Issue
Block a user