#!/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: 一致 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; }