ci: add CLI compatibility and PR quality gates (#602)

* ci(interface): 添加接口完整性和CLI烟雾测试检查

- 在Makefile中新增interface-integrity、update-interface-baseline、cli-smoke和mock-mcp-smoke目标
- 实现接口基线脚本以比较CLI公共命令树与基线文件
- 新增脚本确保二进制文件可渲染所有顶层命令的帮助信息
- 添加mock-mcp-smoke测试验证HTTP和stdio MCP请求/响应传输
- 在GitHub Actions CI工作流中加入interface-integrity、cli-smoke和mock-mcp-smoke检查
- 新增AI行为检查工作流,限制AI生成PR的改动范围和禁改保护文件
- 文档中补充PR质量门禁要求及接口变更流程说明

* feat(policy): 增加多项兼容性和完整性检查

- Makefile中新增reset-interface-baseline、schema-compatibility、update-schema-baseline、skill-command-integrity等目标
- CI流程新增schema-compatibility和skill-command-integrity步骤
- 文档中详细说明接口兼容性基线更新和重置流程及schema基线注意事项
- 实现interface-baseline工具支持兼容性重置和合并,多项接口兼容性检查逻辑完善
- 新增schema-compat工具用于标准化schema列表及兼容性检查与合并
- 新增skill-command-integrity检查确保技能中引用命令存在
- 为interface-baseline和schema-compat添加单元测试覆盖基本兼容性规则

* test(skill-command-check): 增加命令解析和解析结果测试用例

- 补充对 parseReference 函数的边界情况和跳过条件的测试
- 新增 resolveCommandReference 函数测试,覆盖有效、无效及跳过场景
- 增加 isPlaceholder 函数的测试,验证占位符识别准确性

refactor(skill-command-check): 优化命令路径解析和验证逻辑

- 使用 resolveCommandReference 统一处理命令解析结果,清晰区分有效、无效和跳过情况
- 新增 commandResolution 类型及常量,提升代码可读性和扩展性
- 调整 parseReference 增加对 shell 组合符 “ & ” 的跳过处理

docs(mono): 更新最佳实践和产品文档命令示例

- 优化《best_practices/07-minutes.md》中行动项与摘要拉取示例命令,提升准确性
- 修改产品文档中 ALIDOC 表格数据和多维表格记录相关命令,命令路径更规范统一
- 同步多端技能文档,确保命令示例一致且正确

* ci: check interface against PR merge-base

* feat(policy): enforce CLI contract compatibility

* ci: enforce PR coverage thresholds

* ci: add fail-closed CI gate

* ci: adapt schema compatibility gate to runtime catalog

* fix(ci): close schema compatibility gate gaps

* test(auth): skip POSIX mode assertion on Windows

* test(auth): scope POSIX file-backend checks

* fix(ci): enforce native platform coverage

* test(ci): make skill paths portable

---------

Co-authored-by: 修雨 <huyizhou.hyz@alibaba-inc.com>
This commit is contained in:
SCzhengand修雨 authored and GitHub committed 2026-07-15 14:53:34 +08:00
1 parent 3e9e76df2c
commit 809d026b7e
31 files changed
+6759 -50

No files matched your search

+88
View File
@@ -0,0 +1,88 @@
name: AI Behavior Check
on:
pull_request_target:
types: [opened, synchronize, reopened, labeled, unlabeled]
permissions:
contents: read
pull-requests: read
statuses: write
jobs:
ai-behavior-check:
name: AI Behavior Policy Evaluator
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
# Deliberately do not check out or execute pull-request code here.
# pull_request_target keeps this policy anchored to the base branch.
- name: Check AI-generated PR boundaries
uses: actions/github-script@v7
with:
script: |
const sha = context.payload.pull_request.head.sha;
const setStatus = (state, description) =>
github.rest.repos.createCommitStatus({
owner: context.repo.owner,
repo: context.repo.repo,
sha,
state,
context: 'AI Behavior Check',
description,
});
await setStatus('pending', 'Evaluating AI-generated PR boundaries');
const labels = context.payload.pull_request.labels.map(({ name }) => name);
if (!labels.includes('ai-generated')) {
await setStatus('success', 'Not labeled ai-generated');
core.notice('Not an ai-generated PR; no AI-only policy applied.');
return;
}
const files = await github.paginate(github.rest.pulls.listFiles, {
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number,
per_page: 100,
});
const maxChangedFiles = 30;
if (files.length > maxChangedFiles) {
await setStatus(
'failure',
`Changes ${files.length} files; limit is ${maxChangedFiles}`
);
core.setFailed(
`AI-generated PR changes ${files.length} files; limit is ${maxChangedFiles}.`
);
return;
}
const protectedPaths = files
.map(({ filename }) => filename)
.filter((filename) =>
filename.startsWith('.github/workflows/') ||
filename.startsWith('scripts/release/') ||
filename === '.goreleaser.yaml' ||
filename === 'Makefile'
);
if (protectedPaths.length > 0) {
await setStatus('failure', 'Modifies protected release/CI infrastructure');
core.setFailed(
'AI-generated PR modifies protected release/CI infrastructure:\n' +
protectedPaths.map((filename) => ` - ${filename}`).join('\n') +
'\nSplit these changes into a human-owned PR with explicit review.'
);
return;
}
await setStatus(
'success',
`Passed with ${files.length} changed files (limit ${maxChangedFiles})`
);
core.notice(
`AI behavior check passed (${files.length} changed files; limit ${maxChangedFiles}).`
);
+297 -37
View File
@@ -7,8 +7,7 @@ on:
pull_request:
permissions:
contents: write
pull-requests: write
contents: read
jobs:
lint:
@@ -26,7 +25,7 @@ jobs:
- name: Format Check
run: |
unformatted="$(find cmd internal test -name '*.go' -print0 | xargs -0r gofmt -l)"
unformatted="$(find cmd internal test scripts/policy -name '*.go' -print0 | xargs -0r gofmt -l)"
test -z "$unformatted" || (printf '%s\n' "$unformatted" && exit 1)
- name: Go Vet
@@ -101,18 +100,108 @@ jobs:
- name: Build Windows CLI
run: go build -o dws.exe ./cmd
- name: Test Windows auth and DPAPI paths
run: |
go test -v -count=1 -timeout=10m ./internal/keychain ./internal/auth
go test -v -count=1 -timeout=5m ./internal/app -run '^TestAuth(MigrateKeychain|StatusDiagnosticReportsCiphertextKeyMismatch)'
- name: Test Windows auth and DPAPI packages
run: go test -v -count=1 -timeout=10m ./internal/keychain ./internal/auth
coverage:
name: Coverage
runs-on: ubuntu-latest
- name: Test Windows auth migration diagnostics
run: go test -v -count=1 -timeout=5m ./internal/app -run '^TestAuth(MigrateKeychain|StatusDiagnosticReportsCiphertextKeyMismatch)'
coverage-darwin:
name: Coverage (macOS)
runs-on: macos-latest
timeout-minutes: 20
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Resolve authoritative coverage base
env:
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
PUSH_BEFORE_SHA: ${{ github.event.before }}
run: |
set -eu
base_ref="$PUSH_BEFORE_SHA"
if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
base_ref="$(git merge-base "$PR_HEAD_SHA" "$PR_BASE_SHA")"
fi
if [ -z "$base_ref" ] || [ "$base_ref" = "0000000000000000000000000000000000000000" ]; then
base_ref="$(git rev-parse HEAD^)"
fi
git rev-parse --verify "${base_ref}^{commit}" >/dev/null
echo "COVERAGE_BASE_REF=$base_ref" >> "$GITHUB_ENV"
- name: Run and enforce macOS changed-code coverage
run: make coverage-gate-platform BASE_REF="$COVERAGE_BASE_REF" PROFILE=coverage-darwin.txt
- name: Upload macOS coverage artifact
uses: actions/upload-artifact@v4
with:
name: coverage-darwin
path: coverage-darwin.txt
coverage-windows:
name: Coverage (Windows)
runs-on: windows-latest
timeout-minutes: 20
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Resolve authoritative coverage base
shell: bash
env:
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
PUSH_BEFORE_SHA: ${{ github.event.before }}
run: |
set -eu
base_ref="$PUSH_BEFORE_SHA"
if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
base_ref="$(git merge-base "$PR_HEAD_SHA" "$PR_BASE_SHA")"
fi
if [ -z "$base_ref" ] || [ "$base_ref" = "0000000000000000000000000000000000000000" ]; then
base_ref="$(git rev-parse HEAD^)"
fi
git rev-parse --verify "${base_ref}^{commit}" >/dev/null
echo "COVERAGE_BASE_REF=$base_ref" >> "$GITHUB_ENV"
- name: Run and enforce Windows changed-code coverage
shell: bash
run: ./scripts/policy/run-platform-coverage-gate.sh --base-ref "$COVERAGE_BASE_REF" --profile coverage-windows.txt
- name: Upload Windows coverage artifact
uses: actions/upload-artifact@v4
with:
name: coverage-windows
path: coverage-windows.txt
coverage:
name: Coverage
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
- name: Set up Go
uses: actions/setup-go@v5
@@ -125,11 +214,51 @@ jobs:
- name: Build
run: make build
- name: Run tests with coverage
- name: Resolve authoritative coverage base
env:
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
PUSH_BEFORE_SHA: ${{ github.event.before }}
run: |
go test -coverprofile=coverage.txt -covermode=atomic ./cmd/... ./internal/...
set -eu
base_ref="$PUSH_BEFORE_SHA"
if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
base_ref="$(git merge-base "$PR_HEAD_SHA" "$PR_BASE_SHA")"
fi
if [ -z "$base_ref" ] || [ "$base_ref" = "0000000000000000000000000000000000000000" ]; then
base_ref="$(git rev-parse HEAD^)"
fi
git rev-parse --verify "${base_ref}^{commit}" >/dev/null
echo "COVERAGE_BASE_REF=$base_ref" >> "$GITHUB_ENV"
- name: Run current and baseline unit tests with coverage
run: |
set -eu
go test -count=1 -coverprofile=coverage.txt -covermode=atomic ./ ./cmd/... ./internal/... ./skills/...
go test -count=1 -coverprofile=coverage-policy.txt -covermode=atomic ./pkg/... ./scripts/policy/...
base_worktree="$(mktemp -d "${RUNNER_TEMP}/dws-coverage-base.XXXXXX")"
rmdir "$base_worktree"
cleanup() {
git worktree remove --force "$base_worktree" >/dev/null 2>&1 || true
}
trap cleanup EXIT
git worktree add --detach "$base_worktree" "$COVERAGE_BASE_REF"
(
cd "$base_worktree"
go test -count=1 \
-coverprofile="$GITHUB_WORKSPACE/coverage-base.txt" \
-covermode=atomic \
./ ./cmd/... ./internal/... ./skills/...
)
go tool cover -func=coverage.txt
- name: Enforce coverage gate
env:
COVERAGE_TARGET: "80"
COVERAGE_ENFORCE_OVERALL: "false"
run: make coverage-gate BASE_REF="$COVERAGE_BASE_REF"
- name: Generate coverage report
run: go tool cover -html=coverage.txt -o coverage.html
@@ -139,32 +268,10 @@ jobs:
name: coverage-report
path: |
coverage.txt
coverage-base.txt
coverage-policy.txt
coverage.html
- name: Update coverage badge
if: github.ref == 'refs/heads/main'
run: |
COVERAGE=$(go tool cover -func=coverage.txt | grep total | awk '{print $3}' | sed 's/%//')
echo "Coverage: ${COVERAGE}%"
if (( $(echo "$COVERAGE >= 80" | bc -l) )); then
COLOR="brightgreen"
elif (( $(echo "$COVERAGE >= 60" | bc -l) )); then
COLOR="yellow"
else
COLOR="red"
fi
mkdir -p .github/badges
curl -s "https://img.shields.io/badge/coverage-${COVERAGE}%25-${COLOR}" > .github/badges/coverage.svg
- name: Commit badge
if: github.ref == 'refs/heads/main'
run: |
git config --local user.email "github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
git add .github/badges/coverage.svg || true
git diff --staged --quiet || git commit -m "chore: update coverage badge [skip ci]"
git push || true
policy:
name: Policy Check
runs-on: ubuntu-latest
@@ -187,6 +294,99 @@ jobs:
- name: Generated Drift
run: ./scripts/policy/check-generated-drift.sh
interface-integrity:
name: Interface Integrity
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Build
run: make build
- name: Resolve authoritative compatibility merge-base
env:
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
PUSH_BEFORE_SHA: ${{ github.event.before }}
run: |
set -eu
base_ref="$PUSH_BEFORE_SHA"
if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
base_ref="$(git merge-base "$PR_HEAD_SHA" "$PR_BASE_SHA")"
fi
if [ -z "$base_ref" ] || [ "$base_ref" = "0000000000000000000000000000000000000000" ]; then
base_ref="$(git rev-parse HEAD^)"
fi
git rev-parse --verify "${base_ref}^{commit}" >/dev/null
stable_ref="$(git tag --merged "$base_ref" --list 'v[0-9]*' --sort=-version:refname | awk 'index($0, "-") == 0 { print; exit }')"
if [ -z "$stable_ref" ]; then
echo "No stable release tag is reachable from compatibility base $base_ref" >&2
exit 1
fi
git rev-parse --verify "${stable_ref}^{commit}" >/dev/null
echo "COMPATIBILITY_BASE_REF=$base_ref" >> "$GITHUB_ENV"
echo "COMPATIBILITY_STABLE_REF=$stable_ref" >> "$GITHUB_ENV"
- name: Check historical commands and help compatibility
run: |
make authoritative-interface-integrity \
BASE_REF="$COMPATIBILITY_BASE_REF"
if [ "$(git rev-parse "${COMPATIBILITY_BASE_REF}^{commit}")" != "$(git rev-parse "${COMPATIBILITY_STABLE_REF}^{commit}")" ]; then
make authoritative-interface-integrity \
BASE_REF="$COMPATIBILITY_STABLE_REF"
fi
- name: Check complete Schema compatibility
run: make schema-compatibility BASE_REF="$COMPATIBILITY_BASE_REF"
- name: Check skill command references
run: make skill-command-integrity
cli-smoke:
name: CLI Smoke
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Build
run: make build
- name: Check public top-level commands
run: make cli-smoke
mock-mcp-smoke:
name: Mock MCP Smoke
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Check HTTP and stdio MCP transport
run: make mock-mcp-smoke
edition-tests:
name: Edition Contract Tests
runs-on: ubuntu-latest
@@ -203,11 +403,71 @@ jobs:
- name: Run edition contract tests
run: go test -v -count=1 ./pkg/editiontest/...
ci-gate:
name: CI Gate
needs:
- lint
- test
- test-darwin
- test-windows
- coverage
- coverage-darwin
- coverage-windows
- policy
- interface-integrity
- cli-smoke
- mock-mcp-smoke
- edition-tests
if: ${{ always() }}
runs-on: ubuntu-latest
timeout-minutes: 5
permissions: {}
steps:
- name: Verify required checks
env:
LINT_RESULT: ${{ needs.lint.result }}
TEST_RESULT: ${{ needs.test.result }}
TEST_DARWIN_RESULT: ${{ needs.test-darwin.result }}
TEST_WINDOWS_RESULT: ${{ needs.test-windows.result }}
COVERAGE_RESULT: ${{ needs.coverage.result }}
COVERAGE_DARWIN_RESULT: ${{ needs.coverage-darwin.result }}
COVERAGE_WINDOWS_RESULT: ${{ needs.coverage-windows.result }}
POLICY_RESULT: ${{ needs.policy.result }}
INTERFACE_INTEGRITY_RESULT: ${{ needs.interface-integrity.result }}
CLI_SMOKE_RESULT: ${{ needs.cli-smoke.result }}
MOCK_MCP_SMOKE_RESULT: ${{ needs.mock-mcp-smoke.result }}
EDITION_TESTS_RESULT: ${{ needs.edition-tests.result }}
run: |
failed=0
for check in \
"Lint:$LINT_RESULT" \
"Test:$TEST_RESULT" \
"Test (macOS auth/keychain):$TEST_DARWIN_RESULT" \
"Test (Windows):$TEST_WINDOWS_RESULT" \
"Coverage:$COVERAGE_RESULT" \
"Coverage (macOS):$COVERAGE_DARWIN_RESULT" \
"Coverage (Windows):$COVERAGE_WINDOWS_RESULT" \
"Policy Check:$POLICY_RESULT" \
"Interface Integrity:$INTERFACE_INTEGRITY_RESULT" \
"CLI Smoke:$CLI_SMOKE_RESULT" \
"Mock MCP Smoke:$MOCK_MCP_SMOKE_RESULT" \
"Edition Contract Tests:$EDITION_TESTS_RESULT"
do
name="${check%%:*}"
result="${check#*:}"
printf '%s: %s\n' "$name" "$result"
if [ "$result" != "success" ]; then
failed=1
fi
done
test "$failed" -eq 0
notify-downstream:
name: Notify Wukong Overlay
needs: [test, policy, edition-tests]
needs: [ci-gate]
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
permissions: {}
steps:
- name: Trigger downstream CI
run: |