Files

2359 lines
99 KiB
YAML

name: CI
on:
push:
branches:
- main
pull_request:
types: [opened, synchronize, reopened, ready_for_review, edited, auto_merge_enabled, auto_merge_disabled]
permissions:
contents: read
concurrency:
# Keep only the latest revision of a pull request: a stale run must not
# compete with its replacement for hosted runners. A replacement that sees
# a cold baseline cache recomputes it authoritatively. Protected-main pushes
# remain keyed by exact SHA so every potential merge base has a producer.
group: ci-${{ github.workflow }}-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || format('push-{0}', github.sha) }}
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-<partition> names are pinned to the helper's partition set by
# TestCIAppRacePartitionMatrixMatchesHelper, so a partition can never lose
# its job silently. The CrossPlatformCoverage-heavy C range is split again
# to retain headroom on runners reclaimed near the five-minute mark.
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
shard:
- app-schema
- app-a-b
- app-c-a-l
- app-c-m-o
- app-c-p-r
- app-c-s-z
- app-c-other
- 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-<partition> names are pinned to the helper's
# partition set by TestCIAppRacePartitionMatrixMatchesHelper. The
# CrossPlatformCoverage-heavy C range is split again for runner headroom.
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
shard:
- app-schema
- app-a-b
- app-c-a-l
- app-c-m-o
- app-c-p-r
- app-c-s-z
- app-c-other
- 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:
contents: read
pull-requests: read
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
# Null and non-built-in merge identities emit either the protected-main
# push or the trusted pull_request_target closed repair. The built-in
# Actions identity is the exceptional unsafe path, so its own token must
# prove that main-merge-writers never lets it update main.
- name: Verify auto-merge identity
if: github.event_name == 'pull_request' && github.event.pull_request.draft == false
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
env:
REVIEWER_ROUTER_APP_SLUG: ${{ vars.REVIEWER_ROUTER_APP_SLUG }}
with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const pullNumber = context.payload.pull_request.number;
const eventHeadSha = context.payload.pull_request.head.sha;
const eventBaseSha = context.payload.pull_request.base.sha;
const configuredAppSlug = process.env.REVIEWER_ROUTER_APP_SLUG?.trim();
const reviewedForkAppSlug = 'dingtalk-dws-reviewer-router';
const pullHeadRepository =
context.payload.pull_request.head.repo.full_name?.toLowerCase();
const baseRepository = `${owner}/${repo}`.toLowerCase();
const isForkPull =
Boolean(pullHeadRepository) && pullHeadRepository !== baseRepository;
const appSlug =
configuredAppSlug || (isForkPull ? reviewedForkAppSlug : '');
if (
!appSlug ||
appSlug !== appSlug.toLowerCase() ||
appSlug === 'github-actions'
) {
core.setFailed(
'Reviewer Router App slug repository variable is missing or unsafe.',
);
return;
}
if (!configuredAppSlug) {
core.info(
`Fork pull request cannot read the repository App slug variable; using the reviewed public slug ${reviewedForkAppSlug}.`,
);
}
const expectedAppOwner = `${appSlug}[bot]`;
const writerRulesetName = 'main-merge-writers';
const skipWorkflowPattern =
/\[(?:skip ci|ci skip|no ci|skip actions|actions skip)\]|\bskip-checks\s*:\s*true\b/i;
const {data: repository} = await github.rest.repos.get({owner, repo});
function classifyMergeDefaults(repository) {
if (
repository === null ||
typeof repository !== 'object' ||
Array.isArray(repository)
) {
return 'invalid';
}
const hasTitle = Object.prototype.hasOwnProperty.call(
repository,
'merge_commit_title',
);
const hasMessage = Object.prototype.hasOwnProperty.call(
repository,
'merge_commit_message',
);
if (!hasTitle && !hasMessage) {
return 'omitted';
}
if (!hasTitle || !hasMessage) {
return 'invalid';
}
if (
repository.merge_commit_title === 'MERGE_MESSAGE' &&
['PR_TITLE', 'BLANK'].includes(repository.merge_commit_message)
) {
return 'reviewed';
}
return 'invalid';
}
const mergeDefaultsProjection = classifyMergeDefaults(repository);
if (mergeDefaultsProjection === 'invalid') {
core.setFailed(
'Repository merge-message defaults are malformed or changed from their reviewed values.',
);
return;
}
if (mergeDefaultsProjection === 'omitted') {
core.info(
'Read-only CI cannot observe repository merge-message defaults; exact validation is delegated to the dedicated App.',
);
}
const appliedRules = await github.paginate(
'GET /repos/{owner}/{repo}/rules/branches/{branch}',
{owner, repo, branch: 'main', per_page: 100},
);
const repositorySource = `${owner}/${repo}`.toLowerCase();
const applicableRulesetIDs = [
...new Set(
appliedRules
.filter(rule =>
rule.ruleset_source_type === 'Repository' &&
rule.ruleset_source?.toLowerCase() === repositorySource &&
Number.isSafeInteger(Number(rule.ruleset_id)) &&
Number(rule.ruleset_id) > 0,
)
.map(rule => Number(rule.ruleset_id)),
),
];
const activeMainRulesets = [];
for (const rulesetID of applicableRulesetIDs) {
const {data: ruleset} = await github.request(
'GET /repos/{owner}/{repo}/rulesets/{ruleset_id}',
{owner, repo, ruleset_id: rulesetID},
);
if (
ruleset.enforcement !== 'active' ||
ruleset.target !== 'branch' ||
ruleset.source_type !== 'Repository' ||
ruleset.source?.toLowerCase() !== repositorySource
) {
core.setFailed(
`Applicable repository ruleset ${ruleset.name || rulesetID} is not an active branch ruleset owned by this repository.`,
);
return;
}
activeMainRulesets.push(ruleset);
}
const writerRulesets = activeMainRulesets.filter(
ruleset => ruleset.name === writerRulesetName,
);
if (writerRulesets.length !== 1) {
core.setFailed(
`Expected exactly one active ${writerRulesetName} ruleset on main; found ${writerRulesets.length}.`,
);
return;
}
const writerRuleset = writerRulesets[0];
const writerIncludes = writerRuleset.conditions?.ref_name?.include || [];
const writerExcludes = writerRuleset.conditions?.ref_name?.exclude || [];
// GitHub's read projection omits the entire parameters property
// when this exception is disabled. Accept only that exact omission
// or a one-field object containing exact false.
function isStrictUpdateRule(rule) {
if (rule?.type !== 'update') {
return false;
}
if (!Object.prototype.hasOwnProperty.call(rule, 'parameters')) {
return true;
}
const parameters = rule.parameters;
if (
parameters === null ||
typeof parameters !== 'object' ||
Array.isArray(parameters)
) {
return false;
}
const parameterKeys = Object.keys(parameters);
return (
parameterKeys.length === 1 &&
parameterKeys[0] === 'update_allows_fetch_and_merge' &&
parameters.update_allows_fetch_and_merge === false
);
}
function isStrictGraphQLUpdateRule(restRuleset, graphRuleset) {
const restRulesetID = Number(restRuleset?.id);
const graphRulesetID = Number(graphRuleset?.databaseId);
const graphRules = graphRuleset?.rules;
const graphRule = graphRules?.nodes?.[0];
return (
Number.isSafeInteger(restRulesetID) &&
restRulesetID > 0 &&
graphRulesetID === restRulesetID &&
graphRuleset.name === restRuleset.name &&
graphRuleset.enforcement === 'ACTIVE' &&
graphRuleset.target === 'BRANCH' &&
graphRules?.totalCount === 1 &&
graphRules.nodes?.length === 1 &&
graphRule?.type === 'UPDATE' &&
graphRule.parameters?.__typename === 'UpdateParameters' &&
graphRule.parameters.updateAllowsFetchAndMerge === false
);
}
if (
writerIncludes.length !== 1 ||
writerIncludes[0] !== 'refs/heads/main' ||
writerExcludes.length !== 0 ||
typeof writerRuleset.node_id !== 'string' ||
!writerRuleset.node_id ||
writerRuleset.rules?.length !== 1 ||
!isStrictUpdateRule(writerRuleset.rules[0]) ||
writerRuleset.current_user_can_bypass !== 'never'
) {
core.setFailed(
`${writerRulesetName} must target only refs/heads/main, contain only the strict update rule, and deny this built-in Actions identity any bypass.`,
);
return;
}
const {node: graphWriterRuleset} = await github.graphql(
`query ReviewerRouterWriterRule($rulesetID: ID!) {
node(id: $rulesetID) {
... on RepositoryRuleset {
databaseId
name
enforcement
target
rules(first: 2) {
totalCount
nodes {
type
parameters {
__typename
... on UpdateParameters {
updateAllowsFetchAndMerge
}
}
}
}
}
}
}`,
{rulesetID: writerRuleset.node_id},
);
if (!isStrictGraphQLUpdateRule(writerRuleset, graphWriterRuleset)) {
core.setFailed(
`${writerRulesetName} must expose one strict UPDATE rule with updateAllowsFetchAndMerge=false through GraphQL.`,
);
return;
}
const maxAttempts = 6;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
const {data: currentPull} = await github.rest.pulls.get({
owner,
repo,
pull_number: pullNumber,
});
if (
currentPull.head.sha !== eventHeadSha ||
currentPull.base.sha !== eventBaseSha ||
currentPull.state !== 'open' ||
currentPull.draft ||
currentPull.base.ref !== 'main'
) {
core.setFailed(
`PR #${pullNumber} state or revision changed before the Test aggregate verified auto-merge identity.`,
);
return;
}
const mergeTexts = [
currentPull.title,
currentPull.auto_merge?.commit_title,
currentPull.auto_merge?.commit_message,
].filter(value => typeof value === 'string');
if (mergeTexts.some(value => skipWorkflowPattern.test(value))) {
core.setFailed(
`PR #${pullNumber} merge metadata contains a GitHub workflow-skip directive.`,
);
return;
}
if (!currentPull.auto_merge) {
core.info(
`PR #${pullNumber} has no auto-merge request; protected-main push or closed-event repair remains authoritative.`,
);
return;
}
const enabledBy = currentPull.auto_merge.enabled_by?.login?.toLowerCase();
const safeCommitHeadline = `Merge pull request #${pullNumber}`;
const safeCommitBody =
`Merged by the dedicated Reviewer Router GitHub App for PR #${pullNumber}.`;
if (
enabledBy === expectedAppOwner &&
currentPull.auto_merge.commit_title === safeCommitHeadline &&
currentPull.auto_merge.commit_message === safeCommitBody
) {
core.info(
`PR #${pullNumber} auto-merge is owned by the reviewed ${expectedAppOwner} identity with fixed metadata.`,
);
return;
}
if (attempt < maxAttempts) {
core.info(
`PR #${pullNumber} auto-merge owner or metadata is not the reviewed App value; waiting for Reviewer Router takeover (${attempt}/${maxAttempts}).`,
);
await new Promise(resolve => setTimeout(resolve, 5000));
continue;
}
core.setFailed(
`PR #${pullNumber} auto-merge must be null or owned by ${expectedAppOwner} with the reviewed fixed metadata.`,
);
}
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: 30
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
- name: Set up Go
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
# Documentation and release-seal pushes do not change executable coverage,
# but their new main SHA is still a future PR merge base. Promote only an
# exact predecessor cache after independently proving the whole push changed
# metadata paths; fall back to a full authoritative profile on a cold chain.
coverage-main-metadata:
name: Coverage (main metadata cache)
needs: lint
if: ${{ github.event_name == 'push' && (needs.lint.outputs.changelog_only == 'true' || needs.lint.outputs.docs_only == 'true') }}
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Check out exact metadata-only main revision
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.sha }}
- name: Set up Go
id: setup-go-metadata
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Verify metadata-only main successor
shell: bash
env:
PUSH_BEFORE_SHA: ${{ github.event.before }}
PUSH_AFTER_SHA: ${{ github.event.after }}
run: |
set -euo pipefail
full_commit='^[0-9a-f]{40}$'
[[ "$PUSH_BEFORE_SHA" =~ $full_commit ]]
[[ "$PUSH_AFTER_SHA" =~ $full_commit ]]
test "$PUSH_BEFORE_SHA" != 0000000000000000000000000000000000000000
test "$PUSH_AFTER_SHA" = "$GITHUB_SHA"
test "$(git rev-parse HEAD)" = "$GITHUB_SHA"
git rev-parse --verify "${PUSH_BEFORE_SHA}^{commit}" >/dev/null
git merge-base --is-ancestor "$PUSH_BEFORE_SHA" "$PUSH_AFTER_SHA"
changed_count=0
while IFS= read -r -d '' path; do
changed_count=$((changed_count + 1))
case "$path" in
CHANGELOG.md|README.md|README_zh.md|CONTRIBUTING.md|SECURITY.md|CODE_OF_CONDUCT.md|LICENSE|NOTICE|.github/PULL_REQUEST_TEMPLATE.md|.github/ISSUE_TEMPLATE/*|docs/*)
;;
.changes/*)
if [[ "$path" =~ ^\.changes/[a-z0-9][a-z0-9._-]*\.md$ ]] ||
[[ "$path" =~ ^\.changes/released/[0-9]+\.[0-9]+\.[0-9]+(-beta\.[1-9][0-9]*)?/[a-z0-9][a-z0-9._-]*\.md$ ]]; then
continue
fi
echo "Refusing coverage-cache promotion for unreviewed change-fragment path: $path" >&2
exit 1
;;
*)
echo "Refusing coverage-cache promotion for executable path: $path" >&2
exit 1
;;
esac
done < <(git diff --name-only --no-renames -z "$PUSH_BEFORE_SHA" "$PUSH_AFTER_SHA")
test "$changed_count" -gt 0
echo "COVERAGE_SOURCE_REF=$PUSH_BEFORE_SHA" >> "$GITHUB_ENV"
- name: Restore existing current-SHA coverage profile
id: metadata-current-cache
uses: actions/cache/restore@v4
with:
path: coverage-cache.txt
key: dws-coverage-full-v2-${{ github.sha }}-go${{ steps.setup-go-metadata.outputs.go-version }}
- name: Validate existing current-SHA coverage profile
if: steps.metadata-current-cache.outputs.cache-hit == 'true'
run: |
set -eu
test -s coverage-cache.txt
test "$(head -n 1 coverage-cache.txt)" = "mode: atomic"
- name: Restore exact predecessor coverage profile
id: metadata-source-cache
if: steps.metadata-current-cache.outputs.cache-hit != 'true'
uses: actions/cache/restore@v4
with:
path: coverage-cache.txt
key: dws-coverage-full-v2-${{ env.COVERAGE_SOURCE_REF }}-go${{ steps.setup-go-metadata.outputs.go-version }}
- name: Validate promoted predecessor coverage profile
if: steps.metadata-current-cache.outputs.cache-hit != 'true' && steps.metadata-source-cache.outputs.cache-hit == 'true'
run: |
set -eu
test -s coverage-cache.txt
test "$(head -n 1 coverage-cache.txt)" = "mode: atomic"
- name: Install archive tooling for cold metadata baseline
if: steps.metadata-current-cache.outputs.cache-hit != 'true' && steps.metadata-source-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: Recompute cold metadata baseline
if: steps.metadata-current-cache.outputs.cache-hit != 'true' && steps.metadata-source-cache.outputs.cache-hit != 'true'
env:
DWS_PACKAGE_VERSION: 0.0.0-test
run: |
set -euo pipefail
go test -count=1 -p 1 \
-coverprofile=coverage-cache.txt \
-covermode=atomic \
./ ./cmd/... ./internal/... ./skills/...
test -s coverage-cache.txt
test "$(head -n 1 coverage-cache.txt)" = "mode: atomic"
- name: Save metadata main SHA coverage profile
if: steps.metadata-current-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@v4
with:
path: coverage-cache.txt
key: dws-coverage-full-v2-${{ github.sha }}-go${{ steps.setup-go-metadata.outputs.go-version }}
# actions/cache/save reports upload failures as warnings. Convert an
# absent exact target key into a hard producer failure.
- name: Verify metadata main SHA coverage cache exists
id: metadata-target-cache-verification
uses: actions/cache/restore@v4
with:
path: coverage-cache.txt
key: dws-coverage-full-v2-${{ github.sha }}-go${{ steps.setup-go-metadata.outputs.go-version }}
lookup-only: true
fail-on-cache-miss: true
- name: Require exact metadata main SHA coverage cache
env:
EXACT_CACHE_HIT: ${{ steps.metadata-target-cache-verification.outputs.cache-hit }}
run: test "$EXACT_CACHE_HIT" = true
coverage:
name: Coverage
needs:
- lint
- coverage-current
- coverage-current-full
- coverage-supporting
- coverage-baseline
- coverage-main-metadata
- 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 }}
MAIN_METADATA_RESULT: ${{ needs.coverage-main-metadata.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
main_metadata_expected=skipped
native_expected=skipped
if [ "$CHANGELOG_ONLY" = true ] || [ "$DOCS_ONLY" = true ]; then
current_expected=skipped
baseline_expected=skipped
if [ "$GITHUB_EVENT_NAME" = push ]; then
main_metadata_expected=success
fi
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" \
"main metadata cache:$MAIN_METADATA_RESULT:$main_metadata_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: Verify push coverage cache exists
id: push-cache-verification
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/restore@v4
with:
path: coverage-cache.txt
key: dws-coverage-full-v2-${{ github.sha }}-go${{ steps.setup-go.outputs.go-version }}
lookup-only: true
fail-on-cache-miss: true
- name: Require exact push coverage cache
if: github.event_name == 'push' && needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true'
env:
EXACT_CACHE_HIT: ${{ steps.push-cache-verification.outputs.cache-hit }}
run: test "$EXACT_CACHE_HIT" = true
- 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
# Full policy regenerates and validates the runtime Schema several times.
# Keep job-level headroom for large reviewed command-surface additions;
# individual policy gates retain their own fail-closed checks.
timeout-minutes: 15
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"