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 permissions: checks: read contents: read pull-requests: read outputs: changelog_only: ${{ steps.classify.outputs.changelog_only }} release_seal_only: ${{ steps.classify.outputs.release_seal_only }} changelog_changed: ${{ steps.classify.outputs.changelog_changed }} docs_only: ${{ steps.classify.outputs.docs_only }} full_suite: ${{ steps.classify.outputs.full_suite }} release_sensitive: ${{ steps.classify.outputs.release_sensitive }} interface_sensitive: ${{ steps.classify.outputs.interface_sensitive }} mcp_sensitive: ${{ steps.classify.outputs.mcp_sensitive }} edition_sensitive: ${{ steps.classify.outputs.edition_sensitive }} platform_sensitive: ${{ steps.classify.outputs.platform_sensitive }} steps: - name: Classify revision scope id: classify uses: actions/github-script@v7 with: script: | let changelogOnly = false; let releaseSealOnly = false; let changelogChanged = false; let docsOnly = false; let fullSuite = context.eventName === 'push'; let releaseSensitive = context.eventName === 'push'; let interfaceSensitive = context.eventName === 'push'; let mcpSensitive = context.eventName === 'push'; let editionSensitive = context.eventName === 'push'; let platformSensitive = context.eventName === 'push'; let files = []; let fastPathTrust = 'not evaluated'; const isDocsOnly = (filename) => typeof filename === 'string' && ( filename === 'CHANGELOG.md' || filename === 'README.md' || filename === 'README_zh.md' || filename === 'CONTRIBUTING.md' || filename === 'SECURITY.md' || filename === 'CODE_OF_CONDUCT.md' || filename === 'LICENSE' || filename === 'NOTICE' || filename === '.github/PULL_REQUEST_TEMPLATE.md' || filename.startsWith('.github/ISSUE_TEMPLATE/') || filename.startsWith('docs/') ); const isReleaseSensitive = (filename) => typeof filename === 'string' && ( filename.startsWith('scripts/release/') || filename.startsWith('scripts/install') || filename.startsWith('Formula/') || filename.startsWith('build/') || filename === '.goreleaser.yaml' || filename === '.github/workflows/release.yml' || filename === '.github/workflows/withdraw-release.yml' ); const isPlatformSensitive = (filename) => typeof filename === 'string' && ( filename.startsWith('internal/auth/') || filename.startsWith('internal/keychain/') || /_(darwin|windows|linux|unix)(?:_[a-z0-9]+)*\.go$/.test(filename) || filename === '.github/workflows/ci.yml' || filename === 'scripts/policy/run-platform-coverage-gate.sh' ); const isNativeGoChange = ({ filename, previous_filename, patch }) => { const paths = [filename, previous_filename].filter( (value) => typeof value === 'string' && value !== '' ); if (paths.some(isPlatformSensitive)) { return true; } if (!paths.some((value) => value.endsWith('.go'))) { return false; } if (typeof patch !== 'string') { return true; } return ( /\/\/go:build[^\n]*(darwin|windows|linux|unix)/.test(patch) || /runtime\.GO(OS|ARCH)/.test(patch) ); }; const isInterfaceSensitive = (filename) => typeof filename === 'string' && ( filename.startsWith('cmd/') || filename.startsWith('internal/app/') || filename.startsWith('internal/cli/') || filename.startsWith('internal/cobracmd/') || filename.startsWith('internal/corecmd/') || filename.startsWith('internal/helpers/') || filename.startsWith('internal/i18n/') || filename.startsWith('internal/interfacesnapshot/') || filename.startsWith('internal/ir/') || filename.startsWith('internal/shortcut/') || filename.startsWith('pkg/cmdutil/') || filename === 'skills_embed.go' || filename === 'test/fixtures/cli-interface-baseline.txt' || filename.startsWith('skills/') ); const isMCPSensitive = (filename) => typeof filename === 'string' && ( filename.startsWith('internal/mcp') || filename.startsWith('internal/transport/') || filename.startsWith('internal/helpers/') || filename.startsWith('internal/app/') || filename.startsWith('test/mock_mcp/') ); const isEditionSensitive = (filename) => typeof filename === 'string' && ( filename.startsWith('pkg/edition') || filename.startsWith('internal/edition') || filename === 'go.mod' || filename === 'go.sum' ); const isHighRisk = (filename) => typeof filename === 'string' && ( isPlatformSensitive(filename) || isReleaseSensitive(filename) || filename.startsWith('.github/workflows/') || filename === '.github/actionlint.yaml' || filename.startsWith('scripts/') || filename.startsWith('verify/') || filename.startsWith('internal/helpers/') || // Shortcut declarations feed the live command tree and Schema // assembly. Their reverse dependencies include the expensive // app and generator packages, which must run in separate shards. filename.startsWith('internal/shortcut/') || filename.startsWith('internal/generator/') || filename.startsWith('internal/cli/schema') || // Parameter aliases are reduced against the live command tree. // Their reverse-dependency set is too large for one focused // race job, so use the existing full-suite shards. filename === 'internal/cli/param_concepts.json' || filename === 'internal/cli/param_concepts.schema.json' || filename === 'internal/cli/param_aliases_generated.go' || filename.startsWith('internal/interfacesnapshot/') || filename.startsWith('internal/app/upgrade') || filename.startsWith('internal/transport/') || filename.startsWith('internal/syncdata/') || filename.includes('/testdata/') || filename.startsWith('testdata/') || filename.startsWith('test/fixtures/') || filename === 'Makefile' || filename === 'go.mod' || filename === 'go.sum' ); const isExactReleaseSeal = (candidates) => { const changelog = candidates.filter( ({ filename, status, previous_filename }) => filename === 'CHANGELOG.md' && status === 'modified' && !previous_filename ); if (changelog.length !== 1 || candidates.length < 2) { return false; } let version = ''; return candidates.every((file) => { if (file.filename === 'CHANGELOG.md') { return file.status === 'modified' && !file.previous_filename; } if ( file.status !== 'renamed' || typeof file.filename !== 'string' || typeof file.previous_filename !== 'string' || file.additions !== 0 || file.deletions !== 0 ) { return false; } const target = file.filename.match( /^\.changes\/released\/([0-9]+\.[0-9]+\.[0-9]+(?:-beta\.[1-9][0-9]*)?)\/([a-z0-9][a-z0-9._-]*\.md)$/ ); if (!target || file.previous_filename !== `.changes/${target[2]}`) { return false; } if (version && version !== target[1]) { return false; } version = target[1]; return true; }); }; const classifyFiles = (complete) => { const paths = files.flatMap(({ filename, previous_filename }) => [filename, previous_filename].filter( (value) => typeof value === 'string' && value !== '' ) ); const structuralChange = files.some( ({ status, previous_filename }) => status === 'removed' || Boolean(previous_filename) ); const goPackageShapeChange = files.some( ({ filename, previous_filename, status }) => ['added', 'removed'].includes(status) && [filename, previous_filename].some( (value) => typeof value === 'string' && value.endsWith('.go') ) ); docsOnly = complete && files.length > 0 && paths.length > 0 && paths.every(isDocsOnly); releaseSensitive = !complete || paths.some(isReleaseSensitive); platformSensitive = !complete || files.some(isNativeGoChange); interfaceSensitive = !complete || paths.some(isInterfaceSensitive) || paths.some(isEditionSensitive); mcpSensitive = !complete || paths.some(isMCPSensitive); editionSensitive = !complete || paths.some(isEditionSensitive); fullSuite = !complete || ( !docsOnly && ( context.eventName === 'push' || structuralChange || goPackageShapeChange || files.some(isNativeGoChange) || paths.some(isHighRisk) ) ); }; if (context.eventName === 'pull_request') { const expectedHead = context.payload.pull_request.head.sha; const expectedBase = context.payload.pull_request.base.sha; const assertCurrentRevision = async (phase) => { const { data: pull } = await github.rest.pulls.get({ owner: context.repo.owner, repo: context.repo.repo, pull_number: context.issue.number, }); if (pull.head.sha !== expectedHead || pull.base.sha !== expectedBase) { throw new Error( `Pull request revision changed during ${phase}: ` + `expected base/head ${expectedBase}/${expectedHead}, ` + `got ${pull.base.sha}/${pull.head.sha}` ); } return pull; }; const before = await assertCurrentRevision('pre-classification'); 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 after = await assertCurrentRevision('post-classification'); if ( before.changed_files !== files.length || after.changed_files !== files.length ) { throw new Error( `Pull request file list is incomplete: API reports ` + `${after.changed_files} changed files, pagination returned ${files.length}` ); } const exactChangelogDiff = files.length === 1 && files[0].filename === 'CHANGELOG.md' && files[0].status === 'modified' && !files[0].previous_filename; releaseSealOnly = isExactReleaseSeal(files); changelogOnly = exactChangelogDiff || releaseSealOnly; changelogChanged = files.some( ({ filename, previous_filename }) => filename === 'CHANGELOG.md' || previous_filename === 'CHANGELOG.md' ); classifyFiles(true); if (releaseSealOnly) { fullSuite = false; } fastPathTrust = changelogOnly ? releaseSealOnly ? 'exact release-seal fragment archival and synthetic merge policy' : 'exact CHANGELOG-only revision and synthetic merge policy' : docsOnly ? 'documentation-only focused admission' : fullSuite ? 'high-risk full admission' : 'changed-package focused admission'; } else if (context.eventName === 'push') { const expectedBefore = context.payload.before; const expectedAfter = context.payload.after; const fullCommit = /^[0-9a-f]{40}$/; const zeroCommit = '0'.repeat(40); if ( context.ref !== 'refs/heads/main' || context.payload.ref !== 'refs/heads/main' || context.payload.created !== false || context.payload.deleted !== false || context.payload.forced !== false || !fullCommit.test(expectedBefore || '') || !fullCommit.test(expectedAfter || '') || expectedBefore === zeroCommit || expectedAfter === zeroCommit || expectedAfter !== context.sha ) { fastPathTrust = 'push identity is not a non-forced update of the existing main branch'; } else { const { data: comparison } = await github.rest.repos.compareCommitsWithBasehead({ owner: context.repo.owner, repo: context.repo.repo, basehead: `${expectedBefore}...${expectedAfter}`, per_page: 100, }); files = Array.isArray(comparison.files) ? comparison.files : []; const pushFilesComplete = files.length < 300; classifyFiles(pushFilesComplete); const linearFromValidatedTip = comparison.status === 'ahead' && comparison.merge_base_commit?.sha === expectedBefore && comparison.behind_by === 0 && comparison.ahead_by > 0 && comparison.total_commits === comparison.ahead_by; const exactChangelogDiff = files.length === 1 && files[0].filename === 'CHANGELOG.md' && files[0].status === 'modified' && !files[0].previous_filename; const exactReleaseSealDiff = pushFilesComplete && isExactReleaseSeal(files); if (linearFromValidatedTip && (exactChangelogDiff || exactReleaseSealDiff)) { const requiredContexts = [ 'Lint', 'Test', 'Coverage', 'Policy', 'Edition', 'Interface Integrity', 'AI Behavior', 'CLI Smoke', 'Mock MCP', ]; const runs = await github.paginate( github.rest.checks.listForRef, { owner: context.repo.owner, repo: context.repo.repo, ref: expectedBefore, filter: 'latest', per_page: 100, } ); const latestByName = new Map(); for (const run of runs) { if ( run.head_sha !== expectedBefore || run.app?.slug !== 'github-actions' || !requiredContexts.includes(run.name) ) { continue; } const current = latestByName.get(run.name); if (!current || run.id > current.id) { latestByName.set(run.name, run); } } const missing = requiredContexts.filter( (name) => !latestByName.has(name) ); const nonSuccess = requiredContexts.flatMap((name) => { const run = latestByName.get(name); if (!run || run.conclusion === 'success') { return []; } return [ `${name}=${run.conclusion || run.status || 'unknown'}`, ]; }); if (missing.length === 0 && nonSuccess.length === 0) { changelogOnly = true; releaseSealOnly = exactReleaseSealDiff; changelogChanged = true; if (releaseSealOnly) { fullSuite = false; } fastPathTrust = releaseSealOnly ? `exact release-seal successor of validated ${expectedBefore}` : `exact CHANGELOG-only successor of validated ${expectedBefore}`; } else { fastPathTrust = 'predecessor Code Admission is not fully successful; ' + `missing=${missing.join(',') || 'none'}; ` + `non-success=${nonSuccess.join(',') || 'none'}`; } } else { fastPathTrust = 'push is not an exact linear CHANGELOG-only successor'; } } } core.setOutput('changelog_only', String(changelogOnly)); core.setOutput('release_seal_only', String(releaseSealOnly)); core.setOutput('changelog_changed', String(changelogChanged)); core.setOutput('docs_only', String(docsOnly)); core.setOutput('full_suite', String(fullSuite)); core.setOutput('release_sensitive', String(releaseSensitive)); core.setOutput('interface_sensitive', String(interfaceSensitive)); core.setOutput('mcp_sensitive', String(mcpSensitive)); core.setOutput('edition_sensitive', String(editionSensitive)); core.setOutput('platform_sensitive', String(platformSensitive)); await core.summary .addHeading('Code Admission scope') .addRaw(`- Event: \`${context.eventName}\`\n`) .addRaw(`- Metadata-only fast path: \`${changelogOnly}\`\n`) .addRaw(`- Release-seal fragments only: \`${releaseSealOnly}\`\n`) .addRaw(`- CHANGELOG touched: \`${changelogChanged}\`\n`) .addRaw(`- Documentation-only: \`${docsOnly}\`\n`) .addRaw(`- Full suite: \`${fullSuite}\`\n`) .addRaw(`- Release-sensitive: \`${releaseSensitive}\`\n`) .addRaw(`- Interface-sensitive: \`${interfaceSensitive}\`\n`) .addRaw(`- MCP-sensitive: \`${mcpSensitive}\`\n`) .addRaw(`- Edition-sensitive: \`${editionSensitive}\`\n`) .addRaw(`- Native-platform risk paths touched: \`${platformSensitive}\`\n`) .addRaw(`- Changed files: \`${files.length}\`\n`) .addRaw(`- Fast-path trust: ${fastPathTrust}\n`) .write(); - name: Record CHANGELOG-only fast path if: steps.classify.outputs.changelog_only == 'true' env: RELEASE_SEAL_ONLY: ${{ steps.classify.outputs.release_seal_only }} run: | if [ "$RELEASE_SEAL_ONLY" = true ]; then echo "Lint is satisfied by the trusted release-seal fragment Policy path." >> "$GITHUB_STEP_SUMMARY" else echo "Lint is satisfied by the trusted CHANGELOG-only Policy path." >> "$GITHUB_STEP_SUMMARY" fi - name: Record documentation-only fast path if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only == 'true' run: echo "Lint is satisfied by the non-executable documentation scope." >> "$GITHUB_STEP_SUMMARY" - name: Check out repository if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only != 'true' uses: actions/checkout@v4 - name: Set up Go if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only != 'true' uses: actions/setup-go@v5 with: go-version-file: go.mod - name: Verify Test Package Plan if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only != 'true' run: make test-plan - name: Format Check if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only != 'true' run: make format-check - name: Go Vet if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only != 'true' run: go vet ./... - name: Check GitHub Actions workflows if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only != 'true' run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 - name: Test reviewer routing policy if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only != 'true' run: node .github/reviewer-routing.test.js - name: Test npm installer smoke (prune, backup, publish) if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only != 'true' env: XDG_CONFIG_HOME: "" run: node test/scripts/install_js_smoke.mjs test-focused: name: "Test (focused: ${{ matrix.shard }})" needs: lint if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite != 'true' }} runs-on: ubuntu-latest # Each shard owns one bounded slice of the impacted set, so no single job # carries internal/app together with every reverse dependency. The shard # list and per-shard execution below mirror test-race, which runs the same # shards at full-suite scope; release-scripts is included because its # dedicated job only runs at full-suite or release-sensitive scope, and # dropping it here would stop testing test/scripts changes entirely. # internal/app is carried by one shard per bounded partition rather than a # single app shard: the partitions used to run end to end inside one job, # where the Schema partition alone owned most of the wall clock. The # app- names are pinned to the helper's partition set by # TestCIAppRacePartitionMatrixMatchesHelper, so a partition can never lose # its job silently. timeout-minutes: 20 strategy: fail-fast: false matrix: shard: - app-schema - app-a-b - app-c - app-d-r - app-s-z-example-fuzz - generators - helpers - cli - smoke - remaining - release-scripts steps: - name: Check out repository uses: actions/checkout@v4 with: fetch-depth: 0 - name: Verify authoritative synthetic merge env: PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | set -eu test "$(git rev-parse HEAD^1)" = "$PR_BASE_SHA" || { echo "focused test merge first parent does not match event base" >&2 exit 1 } test "$(git rev-parse HEAD^2)" = "$PR_HEAD_SHA" || { echo "focused test merge second parent does not match event head" >&2 exit 1 } echo "TEST_BASE_REF=$PR_BASE_SHA" >> "$GITHUB_ENV" echo "TEST_HEAD_REF=$(git rev-parse HEAD)" >> "$GITHUB_ENV" - name: Set up Go uses: actions/setup-go@v5 with: go-version-file: go.mod - name: Select impacted packages for shard id: select shell: bash env: TEST_SHARD: ${{ matrix.shard }} run: | set -euo pipefail # Every app partition shard tests the same single internal/app # package, so the impacted-package query uses the base shard name and # the partition only selects which tests run. package_shard="$TEST_SHARD" case "$TEST_SHARD" in app-*) package_shard=app ;; esac package_output="$( ./scripts/ci/changed-test-packages.sh \ list-shard "$package_shard" "$TEST_BASE_REF" "$TEST_HEAD_REF" )" if [ -z "$package_output" ]; then echo "No buildable Go package in shard $TEST_SHARD is affected by this revision." \ >> "$GITHUB_STEP_SUMMARY" echo "affected=false" >> "$GITHUB_OUTPUT" exit 0 fi # The package list travels through a file rather than a step output: # reading it with `mapfile < file` has unambiguous line semantics, # whereas a here-string over a multi-line output would append an extra # empty element if the value ever carried a trailing newline, and an # empty element would reach go test as an empty package argument. printf '%s\n' "$package_output" > "$RUNNER_TEMP/focused-shard-packages.txt" echo "affected=true" >> "$GITHUB_OUTPUT" - name: Build if: ${{ matrix.shard == 'remaining' && steps.select.outputs.affected == 'true' }} run: make build - name: Install archive tooling if: ${{ matrix.shard == 'release-scripts' && steps.select.outputs.affected == 'true' }} run: | if command -v zip >/dev/null && command -v unzip >/dev/null; then echo "zip and unzip are already available" else sudo apt-get update sudo apt-get install -y zip unzip fi - name: Test shard with Race Detection if: ${{ steps.select.outputs.affected == 'true' }} shell: bash env: DWS_PACKAGE_VERSION: 0.0.0-test TEST_SHARD: ${{ matrix.shard }} run: | set -euo pipefail mapfile -t packages < "$RUNNER_TEMP/focused-shard-packages.txt" test "${#packages[@]}" -gt 0 for package in "${packages[@]}"; do test -n "$package" || { echo "shard package list contains an empty entry" >&2 exit 1 } done case "$TEST_SHARD" in app-*) # A single long-lived app test process retains every constructed # command tree in framework registries. Each partition is its own # job, so that state is released when the process exits and the # partitions run concurrently instead of end to end. The helper # still verifies that the partition patterns cover every top-level # test exactly once before running the one it was asked for. test "${#packages[@]}" -eq 1 ./scripts/ci/run-app-race-tests.sh run "${packages[0]}" "${TEST_SHARD#app-}" exit 0 ;; esac if [ "$TEST_SHARD" = "release-scripts" ]; then # Mirror the dedicated release-contract job: these suites shell out # to archive tooling and are not race-instrumented there. go test -v -count=1 -timeout=10m "${packages[@]}" exit 0 fi # cli/smoke own heavy NewRootCommand / Schema assembly under -race; # give them a dedicated package timeout on slower hosted runners. timeout_budget=12m if [ "$TEST_SHARD" = "cli" ] || [ "$TEST_SHARD" = "smoke" ]; then timeout_budget=15m fi go test -v -race -count=1 -timeout="$timeout_budget" "${packages[@]}" test-race: name: "Test (race: ${{ matrix.shard }})" needs: lint if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true' }} runs-on: ubuntu-latest # internal/app is split across one shard per bounded partition so the # partitions run concurrently and each releases its framework registries # when the process exits; cli/smoke need headroom beyond go test -timeout for # setup + assembly. The app- names are pinned to the helper's # partition set by TestCIAppRacePartitionMatrixMatchesHelper. timeout-minutes: 20 strategy: fail-fast: false matrix: shard: - app-schema - app-a-b - app-c - app-d-r - app-s-z-example-fuzz - generators - helpers - cli - smoke - 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: Build if: ${{ matrix.shard == 'remaining' }} run: make build - name: Test shard with Race Detection shell: bash env: DWS_PACKAGE_VERSION: 0.0.0-test TEST_SHARD: ${{ matrix.shard }} run: | set -euo pipefail # Every app partition shard tests the same single internal/app # package, so the package query uses the base shard name and the # partition only selects which tests run. package_shard="$TEST_SHARD" case "$TEST_SHARD" in app-*) package_shard=app ;; esac package_output="$(./scripts/ci/test-packages.sh list "$package_shard")" test -n "$package_output" mapfile -t packages <<< "$package_output" test "${#packages[@]}" -gt 0 case "$TEST_SHARD" in app-*) # A single long-lived app test process retains every constructed # command tree in framework registries. Each partition is its own # job, so that state is released when the process exits and the # partitions run concurrently instead of end to end. The helper # still verifies that the partition patterns cover every top-level # test exactly once before running the one it was asked for. test "${#packages[@]}" -eq 1 ./scripts/ci/run-app-race-tests.sh run "${packages[0]}" "${TEST_SHARD#app-}" exit 0 ;; esac # cli/smoke own heavy NewRootCommand / Schema assembly under -race; # give them a dedicated package timeout on slower hosted runners. timeout_budget=12m if [ "$TEST_SHARD" = "cli" ] || [ "$TEST_SHARD" = "smoke" ]; then timeout_budget=15m fi go test -v -race -count=1 -timeout="$timeout_budget" "${packages[@]}" test-release-scripts: name: Test (workflow and release contracts) needs: lint if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.release_sensitive == 'true') }} runs-on: ubuntu-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: Install archive tooling run: | if command -v zip >/dev/null && command -v unzip >/dev/null; then echo "zip and unzip are already available" else sudo apt-get update sudo apt-get install -y zip unzip fi - name: Test release scripts shell: bash env: DWS_PACKAGE_VERSION: 0.0.0-test run: | set -euo pipefail package_output="$(./scripts/ci/test-packages.sh list release-scripts)" test -n "$package_output" mapfile -t packages <<< "$package_output" test "${#packages[@]}" -gt 0 go test -v -count=1 -timeout=10m "${packages[@]}" test-cross-platform: name: Test (cross-platform compile) needs: lint if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' }} 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: Compile supported operating systems shell: bash run: | set -eu for target in darwin/amd64 darwin/arm64 windows/amd64 windows/arm64; do goos="${target%/*}" goarch="${target#*/}" output="$RUNNER_TEMP/dws-${goos}-${goarch}" if [ "$goos" = windows ]; then output="${output}.exe" fi printf 'compile %s/%s\n' "$goos" "$goarch" CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" \ go build -o "$output" ./cmd done test: name: Test needs: - lint - test-focused - test-race - test-release-scripts - test-cross-platform - test-darwin - test-windows if: ${{ always() && needs.lint.result == 'success' }} runs-on: ubuntu-latest timeout-minutes: 5 permissions: {} steps: - name: Verify test shards env: CHANGELOG_ONLY: ${{ needs.lint.outputs.changelog_only }} DOCS_ONLY: ${{ needs.lint.outputs.docs_only }} FULL_SUITE: ${{ needs.lint.outputs.full_suite }} RELEASE_SENSITIVE: ${{ needs.lint.outputs.release_sensitive }} FOCUSED_RESULT: ${{ needs.test-focused.result }} RACE_RESULT: ${{ needs.test-race.result }} RELEASE_SCRIPTS_RESULT: ${{ needs.test-release-scripts.result }} CROSS_PLATFORM_RESULT: ${{ needs.test-cross-platform.result }} DARWIN_RESULT: ${{ needs.test-darwin.result }} WINDOWS_RESULT: ${{ needs.test-windows.result }} run: | failed=0 if [ "$CHANGELOG_ONLY" = true ] || [ "$DOCS_ONLY" = true ]; then for shard in \ "focused shards:$FOCUSED_RESULT" \ "race shards:$RACE_RESULT" \ "release scripts:$RELEASE_SCRIPTS_RESULT" \ "cross-platform compile:$CROSS_PLATFORM_RESULT" \ "macOS native:$DARWIN_RESULT" \ "Windows native:$WINDOWS_RESULT" do name="${shard%%:*}" result="${shard#*:}" printf '%s: %s\n' "$name" "$result" if [ "$result" != skipped ]; then failed=1 fi done test "$failed" -eq 0 exit fi focused_expected=success race_expected=skipped if [ "$FULL_SUITE" = true ]; then focused_expected=skipped race_expected=success fi release_expected=skipped if [ "$FULL_SUITE" = true ] || [ "$RELEASE_SENSITIVE" = true ]; then release_expected=success fi for shard in \ "focused shards:$FOCUSED_RESULT:$focused_expected" \ "race shards:$RACE_RESULT:$race_expected" \ "release scripts:$RELEASE_SCRIPTS_RESULT:$release_expected" \ "cross-platform compile:$CROSS_PLATFORM_RESULT:success" do name="${shard%%:*}" remainder="${shard#*:}" result="${remainder%%:*}" expected="${remainder#*:}" printf '%s: %s (expected %s)\n' "$name" "$result" "$expected" if [ "$result" != "$expected" ]; then failed=1 fi done native_expected=skipped if [ "$FULL_SUITE" = true ]; then native_expected=success fi for native in \ "macOS native:$DARWIN_RESULT" \ "Windows native:$WINDOWS_RESULT" do name="${native%%:*}" result="${native#*:}" printf '%s: %s\n' "$name" "$result" if [ "$result" != "$native_expected" ]; then failed=1 fi done test "$failed" -eq 0 test-darwin: name: Test (macOS auth/keychain) needs: lint if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true' }} 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 packages with Race Detection run: go test -v -race -count=1 -timeout=6m ./internal/keychain ./internal/auth - name: Test macOS auth migration, Keychain diagnostics, and upgrade self-heal with Race Detection run: go test -v -race -count=1 -timeout=5m ./internal/app -run '^(TestValidateNewBinary_RecoversFromUnsignedDarwin|Test(CrossPlatformCoverage)?Auth(MigrateKeychain|StatusDiagnosticReportsCiphertextKeyMismatch))' test-windows: name: Test (Windows) needs: lint if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true' }} 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) needs: lint if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.platform_sensitive == 'true' }} 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) needs: lint if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.platform_sensitive == 'true' }} 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 if: always() uses: actions/upload-artifact@v4 with: name: coverage-windows path: coverage-windows.txt coverage-current: name: Coverage (current) needs: lint if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite != 'true' }} 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: 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" echo "COVERAGE_HEAD_REF=$(git rev-parse HEAD)" >> "$GITHUB_ENV" - name: Build run: make build - name: Run scoped unit tests with coverage shell: bash env: DWS_PACKAGE_VERSION: 0.0.0-test run: | set -euo pipefail changed_output="$( ./scripts/ci/changed-test-packages.sh \ changed "$COVERAGE_BASE_REF" "$COVERAGE_HEAD_REF" )" impacted_output="$( ./scripts/ci/changed-test-packages.sh \ list "$COVERAGE_BASE_REF" "$COVERAGE_HEAD_REF" )" if [ -z "$changed_output" ] || [ -z "$impacted_output" ]; then printf 'mode: atomic\n' > coverage.txt echo "No buildable Go package needs scoped coverage." \ >> "$GITHUB_STEP_SUMMARY" else mapfile -t changed_packages <<< "$changed_output" mapfile -t impacted_packages <<< "$impacted_output" coverpkg="$(IFS=,; echo "${changed_packages[*]}")" go test -count=1 -p 1 \ -coverpkg="$coverpkg" \ -coverprofile=coverage.txt \ -covermode=atomic \ "${impacted_packages[@]}" fi if [ "$(wc -l < coverage.txt)" -gt 1 ]; then go tool cover -func=coverage.txt fi - name: Upload current coverage profile uses: actions/upload-artifact@v4 with: name: coverage-current-profile path: coverage.txt retention-days: 1 coverage-current-full: name: "Coverage (current: ${{ matrix.shard }})" needs: lint if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true' }} runs-on: ubuntu-latest timeout-minutes: 20 strategy: fail-fast: false matrix: shard: - app - cli - generators - helpers - remaining 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: | if command -v zip >/dev/null && command -v unzip >/dev/null; then echo "zip and unzip are already available" else sudo apt-get update sudo apt-get install -y zip unzip fi - name: Build run: make build # Each shard keeps -p 1 so the authoritative measurement stays serial # inside one instrumented process group; shards run on isolated runners, # and scripts/ci/test-packages.sh verify proves the shard union equals # the previous single full-suite package set exactly once. - name: Run current shard tests with coverage shell: bash env: DWS_PACKAGE_VERSION: 0.0.0-test COVERAGE_SHARD: ${{ matrix.shard }} run: | set -euo pipefail package_output="$(./scripts/ci/test-packages.sh list-coverage "$COVERAGE_SHARD")" test -n "$package_output" mapfile -t packages <<< "$package_output" test "${#packages[@]}" -gt 0 go test -count=1 -p 1 \ -coverprofile="coverage-shard-$COVERAGE_SHARD.txt" \ -covermode=atomic \ "${packages[@]}" go tool cover -func="coverage-shard-$COVERAGE_SHARD.txt" | tail -n 1 - name: Upload current shard coverage profile uses: actions/upload-artifact@v4 with: name: coverage-current-shard-${{ matrix.shard }} path: coverage-shard-${{ matrix.shard }}.txt retention-days: 1 coverage-supporting: name: Coverage (supporting) needs: lint if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true' }} 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: | if command -v zip >/dev/null && command -v unzip >/dev/null; then echo "zip and unzip are already available" else sudo apt-get update sudo apt-get install -y zip unzip fi - 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) needs: lint if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' }} 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 id: setup-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" # The merge-base full-suite profile is a pure function of the base # commit. Reuse the profile published by the last green push run of # exactly that commit instead of re-running the whole suite; any key # mismatch falls back to authoritative recomputation. Exact key only, # never prefix fallback: a near-miss profile would compare the # candidate against the wrong commit. - name: Restore cached merge-base coverage profile id: baseline-cache if: needs.lint.outputs.full_suite == 'true' uses: actions/cache/restore@v4 with: path: coverage-cache.txt key: dws-coverage-full-v2-${{ env.COVERAGE_BASE_REF }}-go${{ steps.setup-go.outputs.go-version }} - name: Materialize cached merge-base coverage profile if: needs.lint.outputs.full_suite == 'true' && steps.baseline-cache.outputs.cache-hit == 'true' run: | set -eu test -s coverage-cache.txt test "$(head -n 1 coverage-cache.txt)" = "mode: atomic" cp coverage-cache.txt coverage-base.txt - name: Install archive tooling if: needs.lint.outputs.full_suite == 'true' && steps.baseline-cache.outputs.cache-hit != 'true' run: | if command -v zip >/dev/null && command -v unzip >/dev/null; then echo "zip and unzip are already available" else sudo apt-get update sudo apt-get install -y zip unzip fi - name: Run baseline unit tests with coverage if: steps.baseline-cache.outputs.cache-hit != 'true' shell: bash env: DWS_PACKAGE_VERSION: 0.0.0-test FULL_SUITE: ${{ needs.lint.outputs.full_suite }} run: | set -euo pipefail changed_output= impacted_output= if [ "$FULL_SUITE" != true ]; then changed_output="$( ./scripts/ci/changed-test-packages.sh \ changed "$COVERAGE_BASE_REF" HEAD )" impacted_output="$( ./scripts/ci/changed-test-packages.sh \ list "$COVERAGE_BASE_REF" HEAD )" fi 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" if [ "$FULL_SUITE" = true ]; then go test -count=1 \ -p 1 \ -coverprofile="$GITHUB_WORKSPACE/coverage-base.txt" \ -covermode=atomic \ ./ ./cmd/... ./internal/... ./skills/... elif [ -z "$changed_output" ] || [ -z "$impacted_output" ]; then printf 'mode: atomic\n' > "$GITHUB_WORKSPACE/coverage-base.txt" else mapfile -t changed_packages <<< "$changed_output" mapfile -t impacted_packages <<< "$impacted_output" coverpkg="$(IFS=,; echo "${changed_packages[*]}")" go test -count=1 \ -p 1 \ -coverpkg="$coverpkg" \ -coverprofile="$GITHUB_WORKSPACE/coverage-base.txt" \ -covermode=atomic \ "${impacted_packages[@]}" fi ) - name: Prepare merge-base coverage profile cache if: needs.lint.outputs.full_suite == 'true' && steps.baseline-cache.outputs.cache-hit != 'true' run: | set -eu test -s coverage-base.txt test "$(head -n 1 coverage-base.txt)" = "mode: atomic" cp coverage-base.txt coverage-cache.txt - name: Save merge-base coverage profile cache if: needs.lint.outputs.full_suite == 'true' && steps.baseline-cache.outputs.cache-hit != 'true' uses: actions/cache/save@v4 with: path: coverage-cache.txt key: dws-coverage-full-v2-${{ env.COVERAGE_BASE_REF }}-go${{ steps.setup-go.outputs.go-version }} - 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: - lint - coverage-current - coverage-current-full - coverage-supporting - coverage-baseline - coverage-darwin - coverage-windows if: ${{ always() && needs.lint.result == 'success' }} runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Verify coverage profile jobs env: CHANGELOG_ONLY: ${{ needs.lint.outputs.changelog_only }} DOCS_ONLY: ${{ needs.lint.outputs.docs_only }} FULL_SUITE: ${{ needs.lint.outputs.full_suite }} PLATFORM_SENSITIVE: ${{ needs.lint.outputs.platform_sensitive }} CURRENT_RESULT: ${{ needs.coverage-current.result }} CURRENT_FULL_RESULT: ${{ needs.coverage-current-full.result }} SUPPORTING_RESULT: ${{ needs.coverage-supporting.result }} BASELINE_RESULT: ${{ needs.coverage-baseline.result }} DARWIN_RESULT: ${{ needs.coverage-darwin.result }} WINDOWS_RESULT: ${{ needs.coverage-windows.result }} run: | failed=0 current_expected=success current_full_expected=skipped supporting_expected=skipped baseline_expected=success native_expected=skipped if [ "$CHANGELOG_ONLY" = true ] || [ "$DOCS_ONLY" = true ]; then current_expected=skipped baseline_expected=skipped elif [ "$FULL_SUITE" = true ]; then current_expected=skipped current_full_expected=success supporting_expected=success fi if [ "$CHANGELOG_ONLY" != true ] && [ "$DOCS_ONLY" != true ] && [ "$PLATFORM_SENSITIVE" = true ]; then native_expected=success fi for profile in \ "current:$CURRENT_RESULT:$current_expected" \ "current shards:$CURRENT_FULL_RESULT:$current_full_expected" \ "supporting:$SUPPORTING_RESULT:$supporting_expected" \ "baseline:$BASELINE_RESULT:$baseline_expected" do name="${profile%%:*}" remainder="${profile#*:}" result="${remainder%%:*}" expected="${remainder#*:}" printf '%s: %s (expected %s)\n' "$name" "$result" "$expected" if [ "$result" != "$expected" ]; then failed=1 fi done for native in \ "macOS native:$DARWIN_RESULT" \ "Windows native:$WINDOWS_RESULT" do name="${native%%:*}" result="${native#*:}" printf '%s: %s (expected %s)\n' \ "$name" "$result" "$native_expected" if [ "$result" != "$native_expected" ]; then failed=1 fi done test "$failed" -eq 0 - name: Check out repository if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' 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 id: setup-go if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' uses: actions/setup-go@v5 with: go-version-file: go.mod - name: Resolve authoritative coverage base if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' 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 profiles if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' uses: actions/download-artifact@v4 with: pattern: coverage-current-* merge-multiple: true path: . - name: Download supporting coverage profiles if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true' uses: actions/download-artifact@v4 with: name: coverage-supporting-profiles path: . - name: Download baseline coverage profile if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' uses: actions/download-artifact@v4 with: name: coverage-baseline-profile path: . # Shard profiles cover disjoint package sets, so their block-level # concatenation is the same candidate profile one serial run produced. # Every expected shard must be present; a missing shard would silently # shrink the scope-matched overall comparison. - name: Assemble full-suite coverage profile if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true' shell: bash run: | set -euo pipefail test ! -f coverage.txt for shard in app cli generators helpers remaining; do profile="coverage-shard-$shard.txt" test -f "$profile" test "$(head -n 1 "$profile")" = "mode: atomic" done printf 'mode: atomic\n' > coverage.txt for shard in app cli generators helpers remaining; do tail -n +2 "coverage-shard-$shard.txt" >> coverage.txt done - name: Enforce coverage gate if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' env: FULL_SUITE: ${{ needs.lint.outputs.full_suite }} COVERAGE_TARGET: "100" COVERAGE_ENFORCE_OVERALL: "false" COVERAGE_OVERALL_TOLERANCE: "0.1" run: | policy_profile=coverage-policy.txt if [ "$FULL_SUITE" != true ]; then policy_profile= fi additional_profile= if [ -f coverage-shortcut.txt ]; then additional_profile=coverage-shortcut.txt fi COVERAGE_DIFF_PROFILE="$policy_profile" \ COVERAGE_ADDITIONAL_DIFF_PROFILE="$additional_profile" \ make coverage-gate BASE_REF="$COVERAGE_BASE_REF" # Publish this push's full-suite profile as the merge-base cache for # future PRs whose merge-base is exactly this commit. Saved only after # the gate passed so a broken run never becomes a baseline. Both producer # and consumer use coverage-cache.txt because the cache version includes # the configured path as well as the compression tool. - name: Prepare push coverage profile as merge-base cache if: github.event_name == 'push' && needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true' run: | set -eu test -s coverage.txt test "$(head -n 1 coverage.txt)" = "mode: atomic" cp coverage.txt coverage-cache.txt - name: Save push coverage profile as merge-base cache if: github.event_name == 'push' && needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true' uses: actions/cache/save@v4 with: path: coverage-cache.txt key: dws-coverage-full-v2-${{ github.sha }}-go${{ steps.setup-go.outputs.go-version }} - name: Generate coverage report if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' run: | if [ "$(wc -l < coverage.txt)" -gt 1 ]; then go tool cover -html=coverage.txt -o coverage.html else echo "No buildable Go package needed a coverage HTML report." \ >> "$GITHUB_STEP_SUMMARY" fi - name: Upload coverage artifact if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' uses: actions/upload-artifact@v4 with: name: coverage-report path: | coverage.txt coverage-base.txt coverage-policy.txt coverage-shortcut.txt coverage.html if-no-files-found: error policy: name: Policy needs: lint runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Check out repository uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up Go if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }} uses: actions/setup-go@v5 with: go-version-file: go.mod - name: Verify pull request merge revision if: github.event_name == 'pull_request' env: PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | set -eu test "$(git rev-parse HEAD^1)" = "$PR_BASE_SHA" || { echo "checked-out merge first parent does not match event base" >&2 exit 1 } test "$(git rev-parse HEAD^2)" = "$PR_HEAD_SHA" || { echo "checked-out merge second parent does not match event head" >&2 exit 1 } - name: Validate changed CHANGELOG content if: github.event_name == 'pull_request' env: CLASSIFIED_CHANGELOG_CHANGED: ${{ needs.lint.outputs.changelog_changed }} CHANGELOG_ONLY: ${{ needs.lint.outputs.changelog_only }} RELEASE_SEAL_ONLY: ${{ needs.lint.outputs.release_seal_only }} PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} run: | set -eu merge_changelog_changed=false if git diff --no-ext-diff --find-renames --name-status \ "$PR_BASE_SHA" HEAD | awk -F '\t' ' { for (field = 2; field <= NF; field++) { if ($field == "CHANGELOG.md") found = 1 } } END { exit !found } ' then merge_changelog_changed=true fi test "$merge_changelog_changed" = "$CLASSIFIED_CHANGELOG_CHANGED" || { echo "Files API and synthetic merge tree disagree on CHANGELOG scope" >&2 exit 1 } if [ "$merge_changelog_changed" != true ]; then exit 0 fi mode=--content-only if [ "$CHANGELOG_ONLY" = true ] && [ "$RELEASE_SEAL_ONLY" != true ]; then mode=--fast-path fi ./scripts/policy/check-changelog-pr.sh \ "$mode" "$PR_BASE_SHA" HEAD - name: Validate release fragment lifecycle if: github.event_name == 'pull_request' env: PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} run: ./scripts/policy/check-release-fragments.sh "$PR_BASE_SHA" HEAD - name: Validate trusted main metadata-only push if: github.event_name == 'push' && needs.lint.outputs.changelog_only == 'true' env: PUSH_BEFORE_SHA: ${{ github.event.before }} PUSH_AFTER_SHA: ${{ github.event.after }} RELEASE_SEAL_ONLY: ${{ needs.lint.outputs.release_seal_only }} run: | set -eu test "$(git rev-parse HEAD)" = "$PUSH_AFTER_SHA" || { echo "checked-out push revision does not match event after SHA" >&2 exit 1 } mode=--fast-path if [ "$RELEASE_SEAL_ONLY" = true ]; then mode=--content-only fi ./scripts/policy/check-changelog-pr.sh \ "$mode" "$PUSH_BEFORE_SHA" "$PUSH_AFTER_SHA" if [ "$RELEASE_SEAL_ONLY" = true ]; then ./scripts/policy/check-release-fragments.sh \ "$PUSH_BEFORE_SHA" "$PUSH_AFTER_SHA" fi - name: Record CHANGELOG-only fast path if: needs.lint.outputs.changelog_only == 'true' env: RELEASE_SEAL_ONLY: ${{ needs.lint.outputs.release_seal_only }} run: | if [ "$RELEASE_SEAL_ONLY" = true ]; then echo "Only the trusted release-seal and fragment validators ran; executable sources are unchanged." \ >> "$GITHUB_STEP_SUMMARY" else echo "Only the trusted base-equivalent CHANGELOG validator ran; executable sources are unchanged." \ >> "$GITHUB_STEP_SUMMARY" fi - name: Validate scoped policy if: ${{ needs.lint.outputs.changelog_only != 'true' && (needs.lint.outputs.docs_only == 'true' || (needs.lint.outputs.full_suite != 'true' && needs.lint.outputs.interface_sensitive != 'true')) }} env: 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="$PR_BASE_SHA" fi git rev-parse --verify "${base_ref}^{commit}" >/dev/null git diff --check "$base_ref" HEAD ./scripts/policy/check-open-source-assets.sh echo "Policy used the scoped content/source admission path." \ >> "$GITHUB_STEP_SUMMARY" - name: Build if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }} run: make build - name: Policy if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }} run: make policy interface-integrity: name: Interface Integrity needs: lint runs-on: ubuntu-latest timeout-minutes: 15 steps: - name: Check out repository if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }} 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 if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }} uses: actions/setup-go@v5 with: go-version-file: go.mod - name: Build if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }} run: make build - name: Resolve authoritative compatibility merge-base if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }} 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 candidate_ref="$(git rev-parse 'HEAD^{commit}')" if [ "$GITHUB_EVENT_NAME" = "pull_request" ] && [ "$candidate_ref" != "$PR_HEAD_SHA" ]; then echo "Compatibility checkout $candidate_ref does not match PR head $PR_HEAD_SHA" >&2 exit 1 fi git rev-parse --verify "${base_ref}^{commit}" >/dev/null . ./scripts/release/release-lib.sh stable_ref="" for tag in $(git tag --merged "$base_ref" --list 'v*' --sort=-version:refname); do release_is_stable_version "$tag" || continue if git rev-parse --verify --quiet "refs/tags/withdrawn/$tag" >/dev/null; then continue fi stable_ref="$tag" break done 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 printf '%s\n' \ "COMPATIBILITY_BASE_REF=$base_ref" \ "COMPATIBILITY_STABLE_REF=$stable_ref" \ "COMPATIBILITY_CANDIDATE_REF=$candidate_ref" >> "$GITHUB_ENV" - name: Check historical commands, help, and complete CLI compatibility if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }} run: | make authoritative-interface-integrity \ BASE_REF="$COMPATIBILITY_BASE_REF" \ STABLE_REF="$COMPATIBILITY_STABLE_REF" \ CANDIDATE_REF="$COMPATIBILITY_CANDIDATE_REF" - name: Check complete Schema compatibility if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }} run: | make schema-compatibility \ BASE_REF="$COMPATIBILITY_BASE_REF" \ STABLE_REF="$COMPATIBILITY_STABLE_REF" \ CANDIDATE_REF="$COMPATIBILITY_CANDIDATE_REF" - name: Check skill command references if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }} run: make skill-command-integrity - name: Record scoped fast path if: ${{ needs.lint.outputs.changelog_only == 'true' || needs.lint.outputs.docs_only == 'true' || (needs.lint.outputs.full_suite != 'true' && needs.lint.outputs.interface_sensitive != 'true') }} run: echo "Interface Integrity is unaffected by this classified revision." >> "$GITHUB_STEP_SUMMARY" cli-smoke: name: CLI Smoke needs: lint runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Check out repository if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }} uses: actions/checkout@v4 - name: Set up Go if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }} uses: actions/setup-go@v5 with: go-version-file: go.mod - name: Build if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }} run: make build - name: Check public top-level commands if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }} run: make cli-smoke - name: Record scoped fast path if: ${{ needs.lint.outputs.changelog_only == 'true' || needs.lint.outputs.docs_only == 'true' || (needs.lint.outputs.full_suite != 'true' && needs.lint.outputs.interface_sensitive != 'true') }} run: echo "CLI Smoke is unaffected by this classified revision." >> "$GITHUB_STEP_SUMMARY" mock-mcp-smoke: name: Mock MCP needs: lint runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Check out repository if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.mcp_sensitive == 'true') }} uses: actions/checkout@v4 - name: Set up Go if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.mcp_sensitive == 'true') }} uses: actions/setup-go@v5 with: go-version-file: go.mod - name: Check HTTP and stdio MCP transport if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.mcp_sensitive == 'true') }} run: make mock-mcp-smoke - name: Record scoped fast path if: ${{ needs.lint.outputs.changelog_only == 'true' || needs.lint.outputs.docs_only == 'true' || (needs.lint.outputs.full_suite != 'true' && needs.lint.outputs.mcp_sensitive != 'true') }} run: echo "Mock MCP is unaffected by this classified revision." >> "$GITHUB_STEP_SUMMARY" edition-tests: name: Edition needs: lint runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Check out repository if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.edition_sensitive == 'true') }} uses: actions/checkout@v4 - name: Set up Go if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.edition_sensitive == 'true') }} uses: actions/setup-go@v5 with: go-version-file: go.mod - name: Run edition contract tests if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.edition_sensitive == 'true') }} run: go test -v -count=1 ./pkg/editiontest/... - name: Record scoped fast path if: ${{ needs.lint.outputs.changelog_only == 'true' || needs.lint.outputs.docs_only == 'true' || (needs.lint.outputs.full_suite != 'true' && needs.lint.outputs.edition_sensitive != 'true') }} run: echo "Edition is unaffected by this classified revision." >> "$GITHUB_STEP_SUMMARY"