From e2ab422787629dc8062231a5d311424126a50974 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= Date: Thu, 16 Jul 2026 14:42:37 +0800 Subject: [PATCH 1/2] ci: parallelize PR test and coverage jobs --- .github/workflows/ci.yml | 273 +++++++++++++++++++++++++++++++++++---- docs/ci-pr-gates.md | 26 +++- 2 files changed, 266 insertions(+), 33 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9c509570..6cc7893d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,10 @@ on: permissions: contents: read +concurrency: + group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: lint: name: Lint @@ -54,10 +58,18 @@ jobs: - name: Check GitHub Actions workflows run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 - test: - name: Test + test-race: + name: "Test (race: ${{ matrix.shard }})" runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + shard: + - app + - generators + - helpers + - remaining steps: - name: Check out repository uses: actions/checkout@v4 @@ -71,19 +83,85 @@ jobs: run: sudo apt-get update && sudo apt-get install -y zip unzip - name: Build + if: ${{ matrix.shard == 'remaining' }} run: make build - - name: Test with Race Detection - # The registry-first final-delivery gate validates all public commands - # and the complete generated Catalog under the race detector. Keep the - # package timeout aligned with the macOS race job so the Linux runner's - # five-minute default does not expire while that gate is still making - # progress. - run: go test -v -race -count=1 -timeout=10m ./cmd/... ./internal/... + - name: Test shard with Race Detection + env: + TEST_SHARD: ${{ matrix.shard }} + run: | + case "$TEST_SHARD" in + app) + packages=(./internal/app) + ;; + generators) + packages=(./internal/generator/...) + ;; + helpers) + packages=(./internal/helpers/...) + ;; + remaining) + mapfile -t packages < <( + go list ./cmd/... ./internal/... | + grep -Ev '/internal/(app|generator|helpers)(/|$)' + ) + ;; + *) + printf 'unknown test shard: %s\n' "$TEST_SHARD" >&2 + exit 1 + ;; + esac + test "${#packages[@]}" -gt 0 + go test -v -race -count=1 -timeout=10m "${packages[@]}" + + test-release-scripts: + name: Test (release scripts) + 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: Install archive tooling + run: sudo apt-get update && sudo apt-get install -y zip unzip - name: Test release scripts run: go test -v -count=1 -timeout=5m ./test/scripts + test: + name: Test + needs: + - test-race + - test-release-scripts + if: ${{ always() }} + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} + steps: + - name: Verify test shards + env: + RACE_RESULT: ${{ needs.test-race.result }} + RELEASE_SCRIPTS_RESULT: ${{ needs.test-release-scripts.result }} + run: | + failed=0 + for shard in \ + "race shards:$RACE_RESULT" \ + "release scripts:$RELEASE_SCRIPTS_RESULT" + do + name="${shard%%:*}" + result="${shard#*:}" + printf '%s: %s\n' "$name" "$result" + if [ "$result" != "success" ]; then + failed=1 + fi + done + test "$failed" -eq 0 + test-darwin: name: Test (macOS auth/keychain) runs-on: macos-latest @@ -208,10 +286,80 @@ jobs: name: coverage-windows path: coverage-windows.txt - coverage: - name: Coverage + coverage-current: + name: Coverage (current) runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 20 + steps: + - name: Check out repository + uses: actions/checkout@v4 + with: + 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: Install archive tooling + run: sudo apt-get update && sudo apt-get install -y zip unzip + + - name: Build + run: make build + + - name: Run current unit tests with coverage + run: | + go test -count=1 -p 1 -coverprofile=coverage.txt -covermode=atomic ./ ./cmd/... ./internal/... ./skills/... + go tool cover -func=coverage.txt + + - name: Upload current coverage profile + uses: actions/upload-artifact@v4 + with: + name: coverage-current-profile + path: coverage.txt + retention-days: 1 + + coverage-supporting: + name: Coverage (supporting) + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out repository + uses: actions/checkout@v4 + with: + 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: Install archive tooling + run: sudo apt-get update && sudo apt-get install -y zip unzip + + - name: Run policy and shortcut coverage + run: | + go test -count=1 -coverprofile=coverage-policy.txt -covermode=atomic ./pkg/... ./scripts/policy/... + go test -count=1 \ + -run '^(TestAllShortcuts|TestCrossPlatformCoverage)' \ + -coverpkg=./internal/app,./internal/helpers,./internal/shortcut/... \ + -coverprofile=coverage-shortcut.txt \ + -covermode=atomic \ + ./internal/app ./internal/helpers ./internal/shortcut/... + + - name: Upload supporting coverage profiles + uses: actions/upload-artifact@v4 + with: + name: coverage-supporting-profiles + path: | + coverage-policy.txt + coverage-shortcut.txt + retention-days: 1 + + coverage-baseline: + name: Coverage (baseline) + runs-on: ubuntu-latest + timeout-minutes: 20 steps: - name: Check out repository uses: actions/checkout@v4 @@ -227,9 +375,6 @@ jobs: - name: Install archive tooling run: sudo apt-get update && sudo apt-get install -y zip unzip - - name: Build - run: make build - - name: Resolve authoritative coverage base env: PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} @@ -247,18 +392,9 @@ jobs: 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 + - name: Run baseline unit tests with coverage run: | set -eu - go test -count=1 -p 1 -coverprofile=coverage.txt -covermode=atomic ./ ./cmd/... ./internal/... ./skills/... - go test -count=1 -coverprofile=coverage-policy.txt -covermode=atomic ./pkg/... ./scripts/policy/... - go test -count=1 \ - -run '^(TestAllShortcuts|TestCrossPlatformCoverage)' \ - -coverpkg=./internal/app,./internal/helpers,./internal/shortcut/... \ - -coverprofile=coverage-shortcut.txt \ - -covermode=atomic \ - ./internal/app ./internal/helpers ./internal/shortcut/... - base_worktree="$(mktemp -d "${RUNNER_TEMP}/dws-coverage-base.XXXXXX")" rmdir "$base_worktree" cleanup() { @@ -274,7 +410,90 @@ jobs: -covermode=atomic \ ./ ./cmd/... ./internal/... ./skills/... ) - go tool cover -func=coverage.txt + + - name: Upload baseline coverage profile + uses: actions/upload-artifact@v4 + with: + name: coverage-baseline-profile + path: coverage-base.txt + retention-days: 1 + + coverage: + name: Coverage + needs: + - coverage-current + - coverage-supporting + - coverage-baseline + if: ${{ always() }} + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Verify coverage profile jobs + env: + CURRENT_RESULT: ${{ needs.coverage-current.result }} + SUPPORTING_RESULT: ${{ needs.coverage-supporting.result }} + BASELINE_RESULT: ${{ needs.coverage-baseline.result }} + run: | + failed=0 + for profile in \ + "current:$CURRENT_RESULT" \ + "supporting:$SUPPORTING_RESULT" \ + "baseline:$BASELINE_RESULT" + do + name="${profile%%:*}" + result="${profile#*:}" + printf '%s: %s\n' "$name" "$result" + if [ "$result" != "success" ]; then + failed=1 + fi + done + test "$failed" -eq 0 + + - 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: Download current coverage profile + uses: actions/download-artifact@v4 + with: + name: coverage-current-profile + path: . + + - name: Download supporting coverage profiles + uses: actions/download-artifact@v4 + with: + name: coverage-supporting-profiles + path: . + + - name: Download baseline coverage profile + uses: actions/download-artifact@v4 + with: + name: coverage-baseline-profile + path: . - name: Enforce coverage gate env: diff --git a/docs/ci-pr-gates.md b/docs/ci-pr-gates.md index ee7f229f..0a94b945 100644 --- a/docs/ci-pr-gates.md +++ b/docs/ci-pr-gates.md @@ -23,6 +23,11 @@ The repository defines five focused checks in addition to its existing CI: variance to avoid failing unchanged code on test-path noise. Set `COVERAGE_ENFORCE_OVERALL=true` once repository coverage reaches 80% to make the overall target fail closed as well. + CI generates the candidate, supporting, and merge-base profiles on three + independent runners, then downloads all profiles into the aggregate + `Coverage` job and applies the same fail-closed gate. The split changes only + scheduling: the tested packages, profile contents, merge-base comparison, + and final coverage thresholds remain unchanged. - **CLI Smoke** builds the release binary, reads the root command list from the structured Interface contract, and renders offline help for every public top-level command. It rejects Cobra's unknown-command root-help fallback and @@ -53,9 +58,10 @@ make coverage-gate-platform BASE_REF= PROFILE= ``` `make coverage-gate` is the enforcement step, not a profile generator. It -expects the candidate, policy, and merge-base profiles (`coverage.txt`, -`coverage-policy.txt`, and `coverage-base.txt`) produced by the preceding CI -steps. A clean local checkout can reproduce the Linux/overall CI gate with: +expects the candidate, policy, shortcut, and merge-base profiles +(`coverage.txt`, `coverage-policy.txt`, `coverage-shortcut.txt`, and +`coverage-base.txt`) produced by the parallel CI profile jobs. A clean local +checkout can reproduce the Linux/overall CI gate sequentially with: ```sh base_ref=$(git merge-base HEAD origin/main) @@ -65,17 +71,25 @@ rmdir "$base_worktree" cleanup() { git worktree remove --force "$base_worktree" >/dev/null 2>&1 || true; } trap cleanup EXIT HUP INT TERM -go test -count=1 -coverprofile=coverage.txt -covermode=atomic \ +go test -count=1 -p 1 -coverprofile=coverage.txt -covermode=atomic \ ./ ./cmd/... ./internal/... ./skills/... go test -count=1 -coverprofile=coverage-policy.txt -covermode=atomic \ ./pkg/... ./scripts/policy/... +go test -count=1 \ + -run '^(TestAllShortcuts|TestCrossPlatformCoverage)' \ + -coverpkg=./internal/app,./internal/helpers,./internal/shortcut/... \ + -coverprofile=coverage-shortcut.txt \ + -covermode=atomic \ + ./internal/app ./internal/helpers ./internal/shortcut/... git worktree add --detach "$base_worktree" "$base_ref" ( cd "$base_worktree" - go test -count=1 -coverprofile="$root/coverage-base.txt" -covermode=atomic \ + go test -count=1 -p 1 \ + -coverprofile="$root/coverage-base.txt" -covermode=atomic \ ./ ./cmd/... ./internal/... ./skills/... ) -make coverage-gate BASE_REF="$base_ref" +COVERAGE_ADDITIONAL_PROFILE=coverage-shortcut.txt \ + make coverage-gate BASE_REF="$base_ref" ``` The native-platform target likewise expects `PROFILE` to have already been From da7b490e0858f427b9aec41acf35888801f391d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= Date: Thu, 16 Jul 2026 17:05:43 +0800 Subject: [PATCH 2/2] ci: include app subpackages in race shard --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6cc7893d..21a81439 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -92,7 +92,7 @@ jobs: run: | case "$TEST_SHARD" in app) - packages=(./internal/app) + packages=(./internal/app/...) ;; generators) packages=(./internal/generator/...)