oh-my-muse/muse-cloud/scripts/verify-openapi-diff.sh
lili 053caf192f test(contract): openapi-diff 破坏性变更门禁本地实证 + 镜像钉 v1.19.1 + 复现脚本
- go install 同款 oasdiff v1.19.1 验证 openapi-diff.yml 检测逻辑+逐域循环聚合:
  对全部 7 域真实契约 5/5——无变更/新增可选参数/新增端点→放行;
  参数变必填(request-parameter-became-required)/删端点(api-path-removed-without-deprecation)→拦截
- workflow 镜像 tufin/oasdiff 钉 v1.19.1(机械门禁去 :latest 漂移)
- 新增可复现脚本 muse-cloud/scripts/verify-openapi-diff.sh(自举 oasdiff+临时 venv,冷启动 5/5 绿)
- 同步台账 §一②/TODO、contract-first §四(去内联 YAML 双源、指向 workflow SSOT)、knowledge §七
- 残留:GHA 触发管道(PR paths 触发/base.sha worktree/docker 拉取)待真实 PR 首跑

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 13:50:15 -07:00

104 lines
5.3 KiB
Bash
Executable File

#!/usr/bin/env bash
# 本地验证 .github/workflows/openapi-diff.yml 的破坏性变更门禁逻辑(test-for-the-test)。
#
# 由来:openapi-diff.yml 用 docker run tufin/oasdiff 在 PR 上比对 base/head 契约,本地无 Docker/
# GitHub Actions 无法整跑;但 oasdiff 是 Go 工具,可 go install 同款版本验证「检测逻辑 + 逐域循环聚合」。
# 本脚本忠实复刻 workflow 的逐域循环(oasdiff breaking base head --fail-on ERR + fail 聚合),对全部
# 真实契约跑 5 个场景,确认门禁「拦真破坏、放行安全演化」。oasdiff 升级或新增业务域后应复跑本脚本。
#
# 残留(本脚本覆盖不到):GitHub Actions 触发管道(pull_request paths 触发、base.sha worktree、docker run
# 镜像拉取)仍须一次真实 PR(改 docs/api-contracts/**)首跑确认。
#
# 用法:bash muse-cloud/scripts/verify-openapi-diff.sh
# 依赖:go(自举 oasdiff)+ python3(临时 venv 装 pyyaml 做语义级 YAML 变异);二者缺失会给出明确提示。
set -uo pipefail
OASDIFF_VERSION="v1.19.1" # 必须与 openapi-diff.yml 钉的 tufin/oasdiff:<ver> 一致
DOMAINS="account ai content events knowledge market meta"
EV='/app-api/muse/events' # 用于变异的既有端点(events 域)
# 仓库根:本脚本在 muse-cloud/scripts/ 下 → 根为 ../..
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
SRC="$REPO_ROOT/docs/api-contracts"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
# ---- 自举 oasdiff(优先用 PATH 上的;否则 go install 钉版本) ----
if command -v oasdiff >/dev/null 2>&1; then
OASDIFF="$(command -v oasdiff)"
elif command -v go >/dev/null 2>&1; then
echo "go install oasdiff@$OASDIFF_VERSION ..."
GOBIN="$WORK/bin" go install "github.com/oasdiff/oasdiff@$OASDIFF_VERSION" || {
echo "ERROR: go install oasdiff 失败"; exit 2; }
OASDIFF="$WORK/bin/oasdiff"
else
echo "ERROR: 需 oasdiff 或 go。安装:go install github.com/oasdiff/oasdiff@$OASDIFF_VERSION"; exit 2
fi
# ---- 自举 pyyaml(优先系统;否则临时 venv) ----
if python3 -c "import yaml" >/dev/null 2>&1; then
PY=python3
elif command -v python3 >/dev/null 2>&1; then
python3 -m venv "$WORK/venv" && "$WORK/venv/bin/pip" install -q pyyaml || {
echo "ERROR: 临时 venv 装 pyyaml 失败"; exit 2; }
PY="$WORK/venv/bin/python"
else
echo "ERROR: 需 python3(语义级 YAML 变异)"; exit 2
fi
# ---- 语义级变异器(load->mutate->dump,产出仍是合法 OAS3) ----
cat > "$WORK/mutate.py" <<'PYEOF'
import sys, yaml
path, mode, ev = sys.argv[1], sys.argv[2], sys.argv[3]
spec = yaml.safe_load(open(path)); paths = spec.get('paths', {})
if mode == 'param_required': # 破坏:既有可选参数改必填
for p in paths[ev]['get']['parameters']:
if p.get('name') == 'lastEventId': p['required'] = True
elif mode == 'remove_endpoint': # 破坏:删除既有端点
del paths[ev]
elif mode == 'add_optional_param': # 非破坏:新增可选参数
paths[ev]['get']['parameters'].append(
{'name': 'channel', 'in': 'query', 'required': False, 'schema': {'type': 'string'}})
elif mode == 'add_endpoint': # 非破坏:新增端点
paths[ev + '/health'] = {'get': {'operationId': 'eventsHealth',
'responses': {'200': {'description': 'ok'}}}}
else:
sys.exit('unknown mode ' + mode)
yaml.dump(spec, open(path, 'w'), sort_keys=False, allow_unicode=True)
PYEOF
# ---- 与 openapi-diff.yml 完全一致的门禁循环(参数化 base/head 根) ----
run_gate() {
local base_root="$1" head_root="$2" fail=0
for d in $DOMAINS; do
local base="$base_root/$d/openapi.yaml" head="$head_root/$d/openapi.yaml"
[ -f "$base" ] && [ -f "$head" ] || { echo " 跳过 $d"; continue; }
if ! "$OASDIFF" breaking "$base" "$head" --fail-on ERR >"$WORK/_out_$d.txt" 2>&1; then
fail=1; echo " [BREAKING] $d:"; sed 's/^/ /' "$WORK/_out_$d.txt" | head -4
fi
done
return $fail
}
mk_head() { # $1=name $2=mode(空=不变异) -> echo 头目录
local h="$WORK/head_$1"; mkdir -p "$h"; cp -R "$SRC/." "$h/"
[ -n "${2:-}" ] && "$PY" "$WORK/mutate.py" "$h/events/openapi.yaml" "$2" "$EV"
echo "$h"
}
mkdir -p "$WORK/base"; cp -R "$SRC/." "$WORK/base/"
PASS=0; FAILED=0
chk() { # $1=desc $2=expected_rc $3=actual_rc
if [ "$2" = "$3" ]; then echo " ✅ PASS: $1 (rc=$3)"; PASS=$((PASS+1));
else echo " ❌ FAIL: $1 (期望 rc=$2, 实得 $3)"; FAILED=$((FAILED+1)); fi
}
echo "================ openapi-diff 门禁本地验证(oasdiff $OASDIFF_VERSION) ================"
echo "--- S0 无变更 → 放行 rc=0 ---"; run_gate "$WORK/base" "$(mk_head s0 '')"; chk "无变更放行" 0 $?
echo "--- S1 可选参数改必填 → 拦截 rc=1 ---"; run_gate "$WORK/base" "$(mk_head s1 param_required)"; chk "参数变必填被拦截" 1 $?
echo "--- S2 删除既有端点 → 拦截 rc=1 ---"; run_gate "$WORK/base" "$(mk_head s2 remove_endpoint)"; chk "删端点被拦截" 1 $?
echo "--- S3 新增可选参数 → 放行 rc=0 ---"; run_gate "$WORK/base" "$(mk_head s3 add_optional_param)"; chk "新增可选参数放行" 0 $?
echo "--- S4 新增端点 → 放行 rc=0 ---"; run_gate "$WORK/base" "$(mk_head s4 add_endpoint)"; chk "新增端点放行" 0 $?
echo "================ 汇总: PASS=$PASS FAIL=$FAILED ================"
[ "$FAILED" -eq 0 ] && { echo "RESULT: ALL GREEN"; exit 0; } || { echo "RESULT: HAS FAILURES"; exit 1; }