fix(mirror): 翻 public 的时机、快照强推、上游不可用时降级
四处修复,都来自本次重跑 openwork 时实际踩到的失败: 1. 翻 public 从「推送之前」移到「推送成功之后」 原实现先翻可见性再推,踩出「仓库已 public + 内容还是旧快照」的窗口期: --set-public 成功 → 推送被拒(source-only 历史不连续) → 公开了旧版本。 旧内容虽不含 ee/(合法),但公开了不该公开的版本,正是最该避免的局面。 正确顺序:先推对内容,再翻可见性;任一步失败都停在 private,无害。 收紧方向(private)仍留在同步前——锁起来永远安全,早做没代价。 2. source-only 快照改强推 每次快照都是 checkout → git init → 单次 commit,与远端上次快照无血缘, 永远不可能 fast-forward。改了排除项后重跑必被拒:"! [rejected] fetch first"。 强推在此语义正确:source-only 保的是当前源码状态,不是历史链。 3. 新增 --no-fetch GitHub 连不上(Empty reply from server)时脚本在 fetch 阶段就退出, 本地裸仓明明有 18763 条 commit 却什么都做不了。 上游不可用不该阻止用已有内容重做快照。 4. 补充 LICENSES/LicenseRef-OpenWork-EE.txt 到 openwork 的 excludes 详见上一提交,此处是让它真正生效后落库。 新增 scripts/validate.py:档案 schema 校验。 起因是我手写临时探针把 risk.claim_vs_reality 写成顶层字段, 得到「该字段 0 条」的错误结论——层级写错不报错,只会安静返回 0。 校验项含:核心字段完整性、claim_vs_reality 下限与 source 必填、 excludes 与 redistributable_after_excludes 的一致性、 以及自动抓「七个里」这类随样本增加即过时的计数表述。 当前 7 个项目全部通过。
This commit is contained in:
@@ -224,9 +224,9 @@ verdict:
|
||||
|
||||
mirror:
|
||||
gitea_repo: gitinbox/openwork
|
||||
visibility: private
|
||||
visibility: public
|
||||
upstream: "https://github.com/different-ai/openwork"
|
||||
last_sync: 2026-09-01
|
||||
last_sync: 2026-09-02
|
||||
mode: source-only
|
||||
# 目录切分许可:ee/ 之外是 MIT,ee/ 是 EE License。
|
||||
# 本档案的 visibility: public **完全建立在「这个排除真的生效」这个前提上**——
|
||||
|
||||
+47
-20
@@ -24,6 +24,8 @@ SOURCE_ONLY_FORCE="no"
|
||||
# private → public 的显式授权开关。默认 0:公开不可逆,绝不自动放宽。
|
||||
SET_PUBLIC="0"
|
||||
EXCLUDE_COUNT="0"
|
||||
# 跳过上游更新(本地裸仓已完整,只为重做快照时用)
|
||||
NO_FETCH="0"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
@@ -33,6 +35,7 @@ while [[ $# -gt 0 ]]; do
|
||||
--depth) DEPTH="$2"; shift 2;;
|
||||
--no-source-only) SOURCE_ONLY="no"; shift;;
|
||||
--set-public) SET_PUBLIC="1"; shift;;
|
||||
--no-fetch) NO_FETCH="1"; shift;;
|
||||
*) echo "未知参数: $1" >&2; exit 1;;
|
||||
esac
|
||||
done
|
||||
@@ -146,27 +149,16 @@ if [[ "${VISIBILITY}" == "private" ]]; then
|
||||
-H 'Content-Type: application/json' -d '{"private":true}' "${API}/repos/${ORG}/${REPO_NAME}" || true
|
||||
fi
|
||||
|
||||
# ---- 放宽方向(private → public)必须显式授权 ----
|
||||
# 公开是不可逆动作:一旦有人 fork/克隆,就再也收不回来。
|
||||
# 所以这里**不自动翻**——只报告,要求显式加 --set-public。
|
||||
# 2026-09-02 补:原先脚本只有"收紧"方向的修正,openwork 判定为 public 但仓库
|
||||
# 停在 private,重跑多少次都不会翻过来,档案与事实不一致却看不出原因。
|
||||
# ---- 放宽方向(private → public)推迟到推送成功之后做 ----
|
||||
# 2026-09-02 的教训:这段原先放在同步之前,结果 openwork 翻成了 public
|
||||
# 但推送失败(source-only 快照历史不连续被拒),
|
||||
# 于是出现「仓库已公开 + 内容仍是旧快照」的窗口期——
|
||||
# 虽然旧内容也不含 ee/(合法),但公开了不该公开的版本,正是最该避免的局面。
|
||||
# 正确顺序:先推成功、内容对了,再翻可见性。任一步失败都停在 private,无害。
|
||||
# 这里只做一次探测,真正的翻转在文件末尾 apply_visibility()。
|
||||
if [[ "${VISIBILITY}" == "public" ]]; then
|
||||
CUR_PRIVATE="$(curl -sS -u "${GITEA_USER}:${GITEA_TOKEN}" "${API}/repos/${ORG}/${REPO_NAME}" \
|
||||
| /Users/figmarlite/.workbuddy/binaries/python/envs/default/bin/python -c 'import sys,json;print("true" if json.load(sys.stdin).get("private") else "false")' 2>/dev/null || echo unknown)"
|
||||
if [[ "${CUR_PRIVATE}" == "true" ]]; then
|
||||
if [[ "${SET_PUBLIC}" == "1" ]]; then
|
||||
echo " ⚠ 按 --set-public 将 ${ORG}/${REPO_NAME} 从 private 翻为 public(不可逆)"
|
||||
curl -sS -o /dev/null -u "${GITEA_USER}:${GITEA_TOKEN}" -X PATCH \
|
||||
-H 'Content-Type: application/json' -d '{"private":false}' "${API}/repos/${ORG}/${REPO_NAME}" \
|
||||
&& echo " 已翻为 public"
|
||||
else
|
||||
echo ""
|
||||
echo " ⚠ ${ORG}/${REPO_NAME} 判定应为 public,但远端当前是 private。"
|
||||
echo " 公开不可逆,脚本默认不自动翻。确认后重跑并加 --set-public。"
|
||||
echo " 判定依据:许可证 ${LIC_LEVEL},排除 ${EXCLUDE_COUNT} 项后 redistributable=${VISIBILITY}"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---- 同步 ----
|
||||
@@ -202,7 +194,17 @@ if [[ -d "${MIRROR_DIR}" ]] && ! repo_ok; then
|
||||
rm -rf "${MIRROR_DIR}"
|
||||
fi
|
||||
|
||||
if repo_ok; then
|
||||
if [[ "${NO_FETCH}" == "1" ]]; then
|
||||
# 跳过上游更新,直接用本地裸仓生成快照。
|
||||
# 2026-09-02 需要这个:本地裸仓已是完整的,只想重跑一次快照(改了排除项),
|
||||
# 但 GitHub 恰好连不上("Empty reply from server"),脚本在 fetch 阶段就退出,
|
||||
# 明明有数据却什么都做不了——上游不可用时,不该阻止我们用已有内容重做快照。
|
||||
if repo_ok; then
|
||||
echo " 跳过上游更新(--no-fetch),直接用本地裸仓生成快照"
|
||||
else
|
||||
echo "✗ --no-fetch 但本地裸仓不可用:${MIRROR_DIR}" >&2; exit 1
|
||||
fi
|
||||
elif repo_ok; then
|
||||
if is_shallow && [[ -z "${DEPTH}" ]]; then
|
||||
# 慢链路续传:分批 --deepen。每批成功即落盘,中断后重跑本脚本可继续,
|
||||
# 不像 `git clone` 那样失败一次全部作废。
|
||||
@@ -402,7 +404,32 @@ echo " 推送到 ${ORG}/${REPO_NAME} ..."
|
||||
if [[ "${MIRROR_MODE}" == "full" ]]; then
|
||||
git -C "${PUSH_DIR}" push --mirror "${PROTO}://${U_ENC}:${P_ENC}@${HOST}/${ORG}/${REPO_NAME}.git"
|
||||
else
|
||||
git -C "${PUSH_DIR}" push "${PROTO}://${U_ENC}:${P_ENC}@${HOST}/${ORG}/${REPO_NAME}.git" HEAD:refs/heads/main
|
||||
# source-only 必须强推:每次快照都是「checkout → git init → 单次 commit」,
|
||||
# 与远端上一次的快照 commit 毫无血缘关系,永远不可能 fast-forward。
|
||||
# 2026-09-02 踩到:改了排除项后重跑,推送被拒
|
||||
# "! [rejected] HEAD -> main (fetch first)"
|
||||
# 但排除校验明明通过了——不是排除没生效,是历史不连续。
|
||||
# 强推在这里语义正确:source-only 镜像保的是**当前源码状态**,不是历史链。
|
||||
# 若哪天需要保留历史,该做的是增量快照而非回滚这个 force。
|
||||
echo " (source-only 快照,强推覆盖:历史不连续是设计使然)"
|
||||
git -C "${PUSH_DIR}" push --force "${PROTO}://${U_ENC}:${P_ENC}@${HOST}/${ORG}/${REPO_NAME}.git" HEAD:refs/heads/main
|
||||
fi
|
||||
|
||||
# ---- 可见性放宽:走到这里说明内容已经推对了,才允许公开 ----
|
||||
# 见上方 2026-09-02 的说明:翻转必须发生在成功推送之后。
|
||||
# 收紧方向(private)在同步前就做了,因为锁起来永远安全、早做没代价。
|
||||
if [[ "${VISIBILITY}" == "public" && "${CUR_PRIVATE}" == "true" ]]; then
|
||||
if [[ "${SET_PUBLIC}" == "1" ]]; then
|
||||
echo " ⚠ 内容已推送且排除校验通过 → 将 ${ORG}/${REPO_NAME} 翻为 public(不可逆)"
|
||||
curl -sS -o /dev/null -u "${GITEA_USER}:${GITEA_TOKEN}" -X PATCH \
|
||||
-H 'Content-Type: application/json' -d '{"private":false}' "${API}/repos/${ORG}/${REPO_NAME}" \
|
||||
&& echo " 已翻为 public"
|
||||
else
|
||||
echo ""
|
||||
echo " ⚠ ${ORG}/${REPO_NAME} 判定应为 public,但远端当前是 private。"
|
||||
echo " 内容已推送且排除校验通过;公开不可逆,确认后重跑并加 --set-public。"
|
||||
echo " 判定依据:许可证 ${LIC_LEVEL},排除 ${EXCLUDE_COUNT} 项后 redistributable=public"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---- 回写档案 ----
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env python3
|
||||
"""档案 schema 校验:在发布/镜像之前把结构性缺口挡住。
|
||||
|
||||
为什么需要这个
|
||||
--------------
|
||||
2026-09-02 的教训:我手写了一个临时探针去查某个档案的 claim_vs_reality,
|
||||
把 `risk.claim_vs_reality` 写成了顶层 `claim_vs_reality`,
|
||||
于是得到一个"该字段为 0 条"的错误结论,差点据此去补一个根本不存在的缺口。
|
||||
|
||||
问题不在探针写错——**每次都靠临时手写探针,出错是迟早的事**。
|
||||
写错的那一层恰好是层级,肉眼极难发现,因为它不会报错,只会安静地返回 0。
|
||||
|
||||
所以这个脚本的职责是:把"档案应该长什么样"固化成一处可执行的规则,
|
||||
而不是散落在每次的临时检查里。
|
||||
|
||||
用法
|
||||
----
|
||||
python scripts/validate.py # 校验全部档案,有问题则退出码 1
|
||||
python scripts/validate.py --id orca # 只校验一个
|
||||
|
||||
退出码:0 = 全部通过;1 = 有 ERROR;2 = 只有 WARN(--strict 时也返回 1)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from _common import PROJECTS, load_yaml # noqa: E402
|
||||
|
||||
# ---- 必填结构 ----
|
||||
REQUIRED_TOP = [
|
||||
"id", "name", "repo", "identity", "business", "scenario", "tech",
|
||||
"ecosystem", "risk", "chart", "verdict", "mirror",
|
||||
]
|
||||
REQUIRED_LICENSE = ["spdx", "openness", "redistributable"]
|
||||
REQUIRED_CHART = ["form_depth", "engine_neutral", "local_first_score", "deliverable_depth"]
|
||||
REQUIRED_MIRROR = ["upstream", "gitea_repo", "visibility", "mode"]
|
||||
|
||||
# claim_vs_reality 是整套体系的立身之本:
|
||||
# "宣称 vs 实测落差"是这个追踪体系区别于 README 复读机的唯一东西。
|
||||
# 少于 MIN_CVR 条基本等于没做深度分析。
|
||||
MIN_CVR = 3
|
||||
|
||||
# 会随样本增加而立即过时的表述。
|
||||
# 2026-09-02:maka 的 one_liner 写着「四个里唯一」,那时样本已经 7 个了——
|
||||
# 这种话每加一批样本就错一次,而且错得很隐蔽(数字还在,只是不对了)。
|
||||
# 「已收录样本中」是隐含的,不必写数字。
|
||||
COUNT_WORDS = re.compile(r"(七个里|七个样本里|四个里|四个样本里|\d+\s*个里|\d+\s*个样本里)")
|
||||
|
||||
|
||||
def validate_one(path: Path) -> list[tuple[str, str, str]]:
|
||||
"""返回 [(级别, 位置, 说明)],级别为 ERROR / WARN。"""
|
||||
issues: list[tuple[str, str, str]] = []
|
||||
d = load_yaml(path) or {}
|
||||
pid = d.get("id") or path.stem
|
||||
|
||||
def err(where: str, msg: str) -> None:
|
||||
issues.append(("ERROR", f"{pid}.{where}", msg))
|
||||
|
||||
def warn(where: str, msg: str) -> None:
|
||||
issues.append(("WARN", f"{pid}.{where}", msg))
|
||||
|
||||
# ---- 顶层字段 ----
|
||||
for k in REQUIRED_TOP:
|
||||
if not d.get(k):
|
||||
err(k, "必填字段缺失或为空")
|
||||
|
||||
# ---- 许可证 ----
|
||||
lic = (d.get("business") or {}).get("license") or {}
|
||||
for k in REQUIRED_LICENSE:
|
||||
if k not in lic:
|
||||
err(f"business.license.{k}", "许可证块缺该字段")
|
||||
|
||||
# ---- 目录切分许可的排除后判定 ----
|
||||
# 与 mirror_sync.sh 的 fail-closed 规则保持一致:
|
||||
# 声明了 excludes 就必须给出排除后的可分发性,否则镜像脚本会直接拒绝。
|
||||
# 在这里提前报,比跑到镜像那一步才炸要好定位得多。
|
||||
mirror = d.get("mirror") or {}
|
||||
if mirror.get("excludes") and "redistributable_after_excludes" not in lic:
|
||||
err("business.license.redistributable_after_excludes",
|
||||
"档案声明了 mirror.excludes,但许可证块没有排除后的可分发性判定,"
|
||||
"mirror_sync.sh 会拒绝执行")
|
||||
|
||||
# ---- claim_vs_reality ----
|
||||
cvr = (d.get("risk") or {}).get("claim_vs_reality") or []
|
||||
if len(cvr) < MIN_CVR:
|
||||
err("risk.claim_vs_reality",
|
||||
f"仅 {len(cvr)} 条,少于下限 {MIN_CVR}。这是本体系的核心字段,"
|
||||
f"不足意味着该样本没做深度分析")
|
||||
for i, item in enumerate(cvr):
|
||||
if not isinstance(item, dict):
|
||||
err(f"risk.claim_vs_reality[{i}]", "不是映射结构")
|
||||
continue
|
||||
for k in ("claim", "actual", "source"):
|
||||
if not item.get(k):
|
||||
err(f"risk.claim_vs_reality[{i}].{k}",
|
||||
"缺该字段——没有 source 的落差分析只是观点,不是核实过的结论")
|
||||
|
||||
# ---- 坐标 ----
|
||||
chart = d.get("chart") or {}
|
||||
for k in REQUIRED_CHART:
|
||||
if chart.get(k) is None:
|
||||
err(f"chart.{k}", "象限图坐标缺失,该项目不会出现在对比图里")
|
||||
|
||||
# ---- mirror ----
|
||||
for k in REQUIRED_MIRROR:
|
||||
if not mirror.get(k):
|
||||
err(f"mirror.{k}", "镜像配置缺该字段")
|
||||
|
||||
# ---- 会过时的计数表述 ----
|
||||
one_liner = str((d.get("verdict") or {}).get("one_liner") or "")
|
||||
m = COUNT_WORDS.search(one_liner)
|
||||
if m:
|
||||
warn("verdict.one_liner",
|
||||
f"含样本计数「{m.group(1)}」,样本增加后即过时。"
|
||||
f"去掉数字即可,「已收录样本中」本就是隐含的")
|
||||
|
||||
# ---- sync_note 与 mode 自相矛盾 ----
|
||||
# 2026-09-02:qwenpaw 的 mode 已是 source-only(镜像完成),
|
||||
# sync_note 却仍写着"尚未执行"。档案里写着与事实相反的话,比没有更糟。
|
||||
mode = str(mirror.get("mode") or "")
|
||||
note = str(mirror.get("sync_note") or "")
|
||||
done_modes = {"full", "source-only"}
|
||||
stale_words = ("尚未执行", "暂未镜像", "未执行", "待带宽窗口")
|
||||
if mode in done_modes and any(w in note for w in stale_words):
|
||||
warn("mirror.sync_note",
|
||||
f"mirror.mode={mode}(已镜像)但备注仍写着未执行类措辞,档案与事实矛盾")
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--id", help="只校验指定项目")
|
||||
ap.add_argument("--strict", action="store_true", help="WARN 也视为失败")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.id:
|
||||
files = [PROJECTS / f"{args.id}.yaml"]
|
||||
if not files[0].exists():
|
||||
print(f"档案不存在: {files[0]}", file=sys.stderr)
|
||||
return 1
|
||||
else:
|
||||
files = sorted(PROJECTS.glob("*.yaml"))
|
||||
|
||||
all_issues: list[tuple[str, str, str]] = []
|
||||
for f in files:
|
||||
all_issues.extend(validate_one(f))
|
||||
|
||||
n_err = sum(1 for lv, _, _ in all_issues if lv == "ERROR")
|
||||
n_warn = sum(1 for lv, _, _ in all_issues if lv == "WARN")
|
||||
|
||||
print(f"==> 校验 {len(files)} 个项目档案")
|
||||
for lv, where, msg in all_issues:
|
||||
mark = "✗" if lv == "ERROR" else "⚠"
|
||||
print(f" {mark} [{lv}] {where}: {msg}")
|
||||
|
||||
if not all_issues:
|
||||
print(" 全部通过")
|
||||
print(f" 合计:{n_err} 错误,{n_warn} 警告")
|
||||
|
||||
if n_err:
|
||||
return 1
|
||||
if n_warn and args.strict:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user