name: CI on: push: branches: - main pull_request: permissions: contents: read concurrency: group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: lint: name: Lint 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: Format Check run: | 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 run: go vet ./... # golangci-lint temporarily disabled: v1.64.8 built with Go 1.24 is incompatible with Go 1.25 # - name: golangci-lint # uses: golangci/golangci-lint-action@v6 # with: # version: v1.64.8 # args: ./... actionlint: name: Actionlint 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 GitHub Actions workflows run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 test-race: name: "Test (race: ${{ matrix.shard }})" runs-on: ubuntu-latest timeout-minutes: 15 strategy: fail-fast: false matrix: shard: - app - generators - helpers - remaining 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: Build if: ${{ matrix.shard == 'remaining' }} run: make build - 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 timeout-minutes: 15 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: Test macOS auth and Keychain paths with Race Detection run: go test -v -race -count=1 -timeout=10m ./internal/keychain ./internal/auth ./internal/app test-windows: name: Test (Windows) runs-on: windows-latest timeout-minutes: 15 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 Windows CLI run: go build -o dws.exe ./cmd - name: Test Windows auth and DPAPI packages run: go test -v -count=1 -timeout=10m ./internal/keychain ./internal/auth - name: Test Windows auth migration and portable auth diagnostics run: go test -v -count=1 -timeout=5m ./internal/app -run '^Test(CrossPlatformCoverage)?Auth(MigrateKeychain|StatusDiagnosticReportsCiphertextKeyMismatch|ExportRejectsWindowsDPAPIBackend|ImportRejectsWindowsDPAPIBackend)' 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-current: name: Coverage (current) runs-on: ubuntu-latest 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 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: Install archive tooling run: sudo apt-get update && sudo apt-get install -y zip unzip - 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 baseline unit tests with coverage run: | set -eu 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 \ -p 1 \ -coverprofile="$GITHUB_WORKSPACE/coverage-base.txt" \ -covermode=atomic \ ./ ./cmd/... ./internal/... ./skills/... ) - 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: COVERAGE_TARGET: "80" COVERAGE_ENFORCE_OVERALL: "false" run: COVERAGE_ADDITIONAL_PROFILE=coverage-shortcut.txt make coverage-gate BASE_REF="$COVERAGE_BASE_REF" - name: Generate coverage report run: go tool cover -html=coverage.txt -o coverage.html - name: Upload coverage artifact uses: actions/upload-artifact@v4 with: name: coverage-report path: | coverage.txt coverage-base.txt coverage-policy.txt coverage-shortcut.txt coverage.html policy: name: Policy Check 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: Policy run: make policy 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 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: Run edition contract tests run: go test -v -count=1 ./pkg/editiontest/... ci-gate: name: CI Gate needs: - lint - actionlint - 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 }} ACTIONLINT_RESULT: ${{ needs.actionlint.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" \ "Actionlint:$ACTIONLINT_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: [ci-gate] runs-on: ubuntu-latest if: github.ref == 'refs/heads/main' && github.event_name == 'push' permissions: {} steps: - name: Trigger downstream CI run: | # Trigger internal GitLab CI pipeline via webhook. # WUKONG_TRIGGER_TOKEN is a repository secret. if [ -n "${{ secrets.WUKONG_TRIGGER_TOKEN }}" ]; then curl --fail --silent --show-error \ -X POST \ -F "token=${{ secrets.WUKONG_TRIGGER_TOKEN }}" \ -F "ref=main" \ -F "variables[UPSTREAM_SHA]=${{ github.sha }}" \ "${{ secrets.WUKONG_TRIGGER_URL }}" echo "Downstream CI triggered." else echo "No WUKONG_TRIGGER_TOKEN configured, skipping downstream notification." fi