Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
560 changes: 560 additions & 0 deletions .github/workflows/ci-core.yml

Large diffs are not rendered by default.

26 changes: 26 additions & 0 deletions .github/workflows/ci-pr.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
name: CI

on:
pull_request:
branches:
- main
types:
- opened
- reopened
- synchronize
- ready_for_review

permissions:
contents: read
pull-requests: read

jobs:
core:
name: PR 核心检查
if: github.event.pull_request.draft == false
uses: ./.github/workflows/ci-core.yml
with:
pr_number: ${{ format('{0}', github.event.pull_request.number) }}
base_sha: ${{ github.event.pull_request.base.sha }}
head_sha: ${{ github.event.pull_request.head.sha }}
merge_sha: ${{ github.sha }}
125 changes: 125 additions & 0 deletions .github/workflows/ci-refresh-pr.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
name: CI Refresh PR

on:
workflow_call:
inputs:
pr_number:
required: true
type: string
initial_head_sha:
required: true
type: string
main_sha:
required: true
type: string

permissions:
contents: read
pull-requests: read
statuses: write

jobs:
resolve:
name: 等待最新 merge SHA
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
runnable: ${{ steps.merge.outputs.runnable }}
base_sha: ${{ steps.merge.outputs.base_sha }}
head_sha: ${{ steps.merge.outputs.head_sha }}
merge_sha: ${{ steps.merge.outputs.merge_sha }}
steps:
- name: 等待 GitHub 生成最新合并提交
id: merge
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ inputs.pr_number }}
INITIAL_HEAD_SHA: ${{ inputs.initial_head_sha }}
MAIN_SHA: ${{ inputs.main_sha }}
shell: bash
run: |
set -euo pipefail

api() {
curl --fail-with-body --silent --show-error \
--location \
--header "Authorization: Bearer $GH_TOKEN" \
--header "Accept: application/vnd.github+json" \
--header "X-GitHub-Api-Version: 2022-11-28" \
"$@"
}

echo "runnable=false" >> "$GITHUB_OUTPUT"
for attempt in {1..24}; do
echo "等待最新 merge SHA($attempt/24)"
pr_json=$(api "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER")
state=$(jq -r '.state' <<< "$pr_json")
draft=$(jq -r '.draft' <<< "$pr_json")
base_sha=$(jq -r '.base.sha' <<< "$pr_json")
head_sha=$(jq -r '.head.sha' <<< "$pr_json")
mergeable=$(jq -r '.mergeable // "unknown"' <<< "$pr_json")

if [[ "$state" != open || "$draft" == true || "$head_sha" != "$INITIAL_HEAD_SHA" ]]; then
echo "PR 状态或 head 已变化,由对应事件启动的新 CI 接管。"
exit 0
fi
if [[ "$base_sha" != "$MAIN_SHA" || "$mergeable" == unknown ]]; then
sleep 10
continue
fi
if [[ "$mergeable" == false ]]; then
echo "PR 与最新 main 冲突,跳过测试。"
exit 0
fi

merge_ref=$(api "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/git/ref/pull/$PR_NUMBER/merge")
merge_sha=$(jq -r '.object.sha' <<< "$merge_ref")
merge_commit=$(api "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/git/commits/$merge_sha")
first_parent=$(jq -r '.parents[0].sha' <<< "$merge_commit")
second_parent=$(jq -r '.parents[1].sha' <<< "$merge_commit")

if [[ "$first_parent" != "$MAIN_SHA" || "$second_parent" != "$head_sha" ]]; then
sleep 10
continue
fi

{
echo "runnable=true"
echo "base_sha=$base_sha"
echo "head_sha=$head_sha"
echo "merge_sha=$merge_sha"
} >> "$GITHUB_OUTPUT"

jq -n \
--arg state pending \
--arg context "CI / required" \
--arg description "正在针对最新 main 重新检查" \
--arg target_url "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \
'{
state: $state,
context: $context,
description: $description,
target_url: $target_url
}' > "$RUNNER_TEMP/status.json"

api \
--request POST \
--data-binary "@$RUNNER_TEMP/status.json" \
"$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/statuses/$merge_sha"
exit 0
done

echo "GitHub 未在等待窗口内生成包含最新 main 的 merge SHA。"
exit 1

core:
name: 对最新 main 重检
needs: resolve
if: needs.resolve.outputs.runnable == 'true'
uses: ./.github/workflows/ci-core.yml
with:
pr_number: ${{ inputs.pr_number }}
base_sha: ${{ needs.resolve.outputs.base_sha }}
head_sha: ${{ needs.resolve.outputs.head_sha }}
merge_sha: ${{ needs.resolve.outputs.merge_sha }}
publish_status: true
69 changes: 69 additions & 0 deletions .github/workflows/ci-refresh.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
name: CI Refresh

on:
push:
branches:
- main
workflow_dispatch:

permissions:
contents: read
pull-requests: read
statuses: write

jobs:
discover:
name: 查找开放 PR
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
prs: ${{ steps.prs.outputs.prs }}
steps:
- name: 查询以 main 为目标的开放 PR
id: prs
env:
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
set -euo pipefail

all='[]'
page=1
while true; do
response=$(curl --fail-with-body --silent --show-error \
--header "Authorization: Bearer $GH_TOKEN" \
--header "Accept: application/vnd.github+json" \
--header "X-GitHub-Api-Version: 2022-11-28" \
"$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/pulls?state=open&base=main&per_page=100&page=$page")
all=$(jq -cn \
--argjson all "$all" \
--argjson batch "$response" \
'$all + $batch')
count=$(jq 'length' <<< "$response")
if (( count < 100 )); then
break
fi
((page += 1))
done

prs=$(jq -c \
'[.[] | select(.draft == false) | {
number: (.number | tostring),
initial_head_sha: .head.sha
}]' <<< "$all")
echo "prs=$prs" >> "$GITHUB_OUTPUT"

refresh:
name: 重检 PR
needs: discover
if: needs.discover.outputs.prs != '[]'
strategy:
fail-fast: false
max-parallel: 10
matrix:
pr: ${{ fromJSON(needs.discover.outputs.prs) }}
uses: ./.github/workflows/ci-refresh-pr.yml
with:
pr_number: ${{ matrix.pr.number }}
initial_head_sha: ${{ matrix.pr.initial_head_sha }}
main_sha: ${{ github.sha }}
109 changes: 109 additions & 0 deletions .github/workflows/ci-status.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
name: CI Status

on:
workflow_run:
workflows:
- CI
types:
- completed

permissions:
actions: read
contents: read
pull-requests: read
statuses: write

jobs:
publish:
name: 发布 CI / required
if: github.event.workflow_run.event == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: 校验待测对象并发布状态
env:
GH_TOKEN: ${{ github.token }}
RUN_ID: ${{ github.event.workflow_run.id }}
RUN_CONCLUSION: ${{ github.event.workflow_run.conclusion }}
RUN_URL: ${{ github.event.workflow_run.html_url }}
shell: bash
run: |
set -euo pipefail

api() {
curl --fail-with-body --silent --show-error \
--location \
--header "Authorization: Bearer $GH_TOKEN" \
--header "Accept: application/vnd.github+json" \
--header "X-GitHub-Api-Version: 2022-11-28" \
"$@"
}

run_json=$(api "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/runs/$RUN_ID")
pr_number=$(jq -r '.pull_requests[0].number // empty' <<< "$run_json")
if [[ -z "$pr_number" ]]; then
echo "运行未关联 PR,不发布状态。"
exit 0
fi

artifact_name="ci-metadata-$pr_number-$RUN_ID"
artifacts=$(api "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts?per_page=100")
artifact_url=$(jq -r \
--arg name "$artifact_name" \
'.artifacts[] | select(.name == $name and .expired == false) | .archive_download_url' \
<<< "$artifacts" | head -n 1)
if [[ -z "$artifact_url" ]]; then
echo "未找到待测对象元数据;草稿、取消或初始化失败的运行不发布状态。"
exit 0
fi

mkdir -p "$RUNNER_TEMP/ci-metadata"
api "$artifact_url" > "$RUNNER_TEMP/ci-metadata.zip"
unzip -q "$RUNNER_TEMP/ci-metadata.zip" -d "$RUNNER_TEMP/ci-metadata"
metadata="$RUNNER_TEMP/ci-metadata/metadata.json"

recorded_pr=$(jq -r '.pr_number' "$metadata")
recorded_base=$(jq -r '.base_sha' "$metadata")
recorded_head=$(jq -r '.head_sha' "$metadata")
recorded_merge=$(jq -r '.merge_sha' "$metadata")
if [[ "$recorded_pr" != "$pr_number" ]]; then
echo "元数据中的 PR 编号不匹配,拒绝发布。"
exit 1
fi

pr_json=$(api "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/pulls/$pr_number")
current_base=$(jq -r '.base.sha' <<< "$pr_json")
current_head=$(jq -r '.head.sha' <<< "$pr_json")
current_merge=$(api "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/git/ref/pull/$pr_number/merge" \
| jq -r '.object.sha')

if [[ "$recorded_base" != "$current_base" ||
"$recorded_head" != "$current_head" ||
"$recorded_merge" != "$current_merge" ]]; then
echo "检测到过期 CI 结果,不覆盖当前 PR 状态。"
exit 0
fi

state=failure
description="UniSpeaking 必要 CI 检查未通过"
if [[ "$RUN_CONCLUSION" == success ]]; then
state=success
description="UniSpeaking 必要 CI 检查已通过"
fi

jq -n \
--arg state "$state" \
--arg context "CI / required" \
--arg description "$description" \
--arg target_url "$RUN_URL" \
'{
state: $state,
context: $context,
description: $description,
target_url: $target_url
}' > "$RUNNER_TEMP/status.json"

api \
--request POST \
--data-binary "@$RUNNER_TEMP/status.json" \
"$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/statuses/$recorded_merge"
21 changes: 18 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -527,12 +527,26 @@ src/test/java/com/unispeaking/{same-package}
- 真实供应商手工测试不放在默认 `src/test`,避免依赖密钥、网络和本机文件。
- 未引用夹具、旧包路径测试和已删除功能测试必须同步删除。
- Bug 修复必须添加能复现问题的回归测试。
- 每次修改生产代码时,必须同步新增或更新对应测试文件,覆盖新增行为、受影响分支和
兼容性边界;不得以“改动较小”或“后续补测”为由只提交实现代码。
- 后端自动化测试的全局行覆盖率不得低于 80%。启动类、纯 DTO、简单属性绑定类和明确
生成代码可以按 CI 约定排除,Service、Controller、Repository、Provider、鉴权、
会话、评分和状态机等核心业务代码不得通过排除规则规避覆盖率要求。
- 当前 CI 的强制门槛暂时保持为 70%,用于降低建设初期的协作阻力。这是过渡性自动
门禁,不代表开发质量标准降低;测试基线稳定达到 80% 后,再单独调整 CI 阈值。

提交前至少执行:

```bash
cd backend/unispeaking-server
./mvnw test
./mvnw --batch-mode --no-transfer-progress clean verify
./mvnw --batch-mode --no-transfer-progress \
-Pci-integration -DskipUnitTests verify
./mvnw --batch-mode --no-transfer-progress \
-Pcoverage-aggregate \
-DskipUnitTests \
-DskipIntegrationTests \
verify
```

修改前端接口契约时,还须执行前端构建和路由/实时协议检查。
Expand All @@ -559,5 +573,6 @@ cd backend/unispeaking-server
- 持久化仅使用 MyBatis-Plus,复合主键使用完整条件。
- 认证、授权、输入校验、超时和错误转换完整。
- 配置无真实密钥,日志无敏感数据。
- 新增行为有测试,默认测试不依赖外网或真实账号。
- `./mvnw test` 全部通过。
- 所有生产代码改动均同步补齐测试,默认测试不依赖外网或真实账号。
- 全局行覆盖率达到 80% 的开发质量标准;CI 在过渡期仍按 70% 自动门禁执行。
- 单元测试、容器集成测试和覆盖率检查全部通过。
Loading