Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25c70aeb24 | ||
|
|
6cfa9e3afb | ||
|
|
544a91e994 | ||
|
|
024d487a22 | ||
|
|
6b50cc41c5 | ||
|
|
11e50662f9 | ||
|
|
29abdb6e79 | ||
|
|
bc587ddd91 | ||
|
|
6196e2565e |
@@ -5,6 +5,13 @@
|
||||
|
||||
## Verification
|
||||
|
||||
For an exact in-place `CHANGELOG.md`-only pull request, the full-suite checks
|
||||
may be marked `N/A`, but the targeted CHANGELOG check is required. For every
|
||||
other pull request, mark the targeted check `N/A` and complete the applicable
|
||||
full-suite checks.
|
||||
|
||||
- [ ] Exact `CHANGELOG.md`-only check (otherwise `N/A`):
|
||||
`./scripts/policy/check-changelog-pr.sh --fast-path "$(git merge-base HEAD origin/main)" HEAD`
|
||||
- [ ] `make build`
|
||||
- [ ] `make lint`
|
||||
- [ ] `make test`
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
name: AI Behavior Check
|
||||
name: Code Admission — AI Behavior
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened, labeled, unlabeled]
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -11,7 +14,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
ai-behavior-check:
|
||||
name: AI Behavior Policy Evaluator
|
||||
name: AI Behavior
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
@@ -21,75 +24,107 @@ jobs:
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const sha = context.payload.pull_request.head.sha;
|
||||
const pullRequest = context.payload.pull_request;
|
||||
const sha = context.eventName === 'push' ? context.sha : pullRequest.head.sha;
|
||||
const setStatus = (state, description) =>
|
||||
github.rest.repos.createCommitStatus({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
sha,
|
||||
state,
|
||||
context: 'AI Behavior Check',
|
||||
context: 'AI Behavior',
|
||||
description,
|
||||
});
|
||||
|
||||
await setStatus('pending', 'Evaluating AI-generated PR boundaries');
|
||||
|
||||
const labels = context.payload.pull_request.labels.map(({ name }) => name);
|
||||
if (!labels.includes('ai-generated')) {
|
||||
await setStatus('success', 'Not labeled ai-generated');
|
||||
core.notice('Not an ai-generated PR; no AI-only policy applied.');
|
||||
if (context.eventName === 'push') {
|
||||
await setStatus('success', 'Not applicable to the protected main push');
|
||||
core.notice('AI Behavior is a PR policy; the main push context is sealed.');
|
||||
return;
|
||||
}
|
||||
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.issue.number,
|
||||
per_page: 100,
|
||||
});
|
||||
try {
|
||||
const expectedHead = pullRequest.head.sha;
|
||||
const expectedBase = pullRequest.base.sha;
|
||||
const currentPull = 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 currentPull('pre-policy check');
|
||||
const labels = before.labels.map(({ name }) => name);
|
||||
if (!labels.includes('ai-generated')) {
|
||||
await setStatus('success', 'Not labeled ai-generated');
|
||||
core.notice('Not an ai-generated PR; no AI-only policy applied.');
|
||||
return;
|
||||
}
|
||||
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.issue.number,
|
||||
per_page: 100,
|
||||
});
|
||||
await currentPull('post-policy check');
|
||||
|
||||
const maxChangedFiles = 30;
|
||||
if (files.length > maxChangedFiles) {
|
||||
await setStatus(
|
||||
'failure',
|
||||
`Changes ${files.length} files; limit is ${maxChangedFiles}`
|
||||
);
|
||||
core.setFailed(
|
||||
`AI-generated PR changes ${files.length} files; limit is ${maxChangedFiles}.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const isProtectedPath = (filename) =>
|
||||
typeof filename === 'string' &&
|
||||
(
|
||||
filename.startsWith('.github/workflows/') ||
|
||||
filename.startsWith('scripts/policy/') ||
|
||||
filename.startsWith('scripts/release/') ||
|
||||
filename === 'test/fixtures/cli-interface-baseline.txt' ||
|
||||
filename === '.goreleaser.yaml' ||
|
||||
filename === 'Makefile'
|
||||
);
|
||||
const protectedPaths = [...new Set(
|
||||
files
|
||||
.flatMap(({ filename, previous_filename }) => [filename, previous_filename])
|
||||
.filter(isProtectedPath)
|
||||
)];
|
||||
|
||||
if (protectedPaths.length > 0) {
|
||||
await setStatus('failure', 'Modifies protected release/CI infrastructure');
|
||||
core.setFailed(
|
||||
'AI-generated PR modifies protected release/CI infrastructure:\n' +
|
||||
protectedPaths.map((filename) => ` - ${filename}`).join('\n') +
|
||||
'\nSplit these changes into a human-owned PR with explicit review.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const maxChangedFiles = 30;
|
||||
if (files.length > maxChangedFiles) {
|
||||
await setStatus(
|
||||
'failure',
|
||||
`Changes ${files.length} files; limit is ${maxChangedFiles}`
|
||||
'success',
|
||||
`Passed with ${files.length} changed files (limit ${maxChangedFiles})`
|
||||
);
|
||||
core.setFailed(
|
||||
`AI-generated PR changes ${files.length} files; limit is ${maxChangedFiles}.`
|
||||
core.notice(
|
||||
`AI behavior check passed (${files.length} changed files; limit ${maxChangedFiles}).`
|
||||
);
|
||||
return;
|
||||
} catch (error) {
|
||||
await setStatus('error', 'Could not evaluate the exact pull request revision');
|
||||
throw error;
|
||||
}
|
||||
|
||||
const isProtectedPath = (filename) =>
|
||||
typeof filename === 'string' &&
|
||||
(
|
||||
filename.startsWith('.github/workflows/') ||
|
||||
filename.startsWith('scripts/policy/') ||
|
||||
filename.startsWith('scripts/release/') ||
|
||||
filename === 'test/fixtures/cli-interface-baseline.txt' ||
|
||||
filename === '.goreleaser.yaml' ||
|
||||
filename === 'Makefile'
|
||||
);
|
||||
const protectedPaths = [...new Set(
|
||||
files
|
||||
.flatMap(({ filename, previous_filename }) => [filename, previous_filename])
|
||||
.filter(isProtectedPath)
|
||||
)];
|
||||
|
||||
if (protectedPaths.length > 0) {
|
||||
await setStatus('failure', 'Modifies protected release/CI infrastructure');
|
||||
core.setFailed(
|
||||
'AI-generated PR modifies protected release/CI infrastructure:\n' +
|
||||
protectedPaths.map((filename) => ` - ${filename}`).join('\n') +
|
||||
'\nSplit these changes into a human-owned PR with explicit review.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await setStatus(
|
||||
'success',
|
||||
`Passed with ${files.length} changed files (limit ${maxChangedFiles})`
|
||||
);
|
||||
core.notice(
|
||||
`AI behavior check passed (${files.length} changed files; limit ${maxChangedFiles}).`
|
||||
);
|
||||
|
||||
+348
-112
@@ -1,4 +1,4 @@
|
||||
name: CI
|
||||
name: Code Admission — PR 合入门禁
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -18,48 +18,136 @@ jobs:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
outputs:
|
||||
changelog_only: ${{ steps.classify.outputs.changelog_only }}
|
||||
changelog_changed: ${{ steps.classify.outputs.changelog_changed }}
|
||||
platform_sensitive: ${{ steps.classify.outputs.platform_sensitive }}
|
||||
steps:
|
||||
- name: Classify pull request scope
|
||||
id: classify
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
let changelogOnly = false;
|
||||
let changelogChanged = false;
|
||||
let platformSensitive = context.eventName === 'push';
|
||||
let files = [];
|
||||
|
||||
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}`
|
||||
);
|
||||
}
|
||||
|
||||
changelogOnly =
|
||||
files.length === 1 &&
|
||||
files[0].filename === 'CHANGELOG.md' &&
|
||||
files[0].status === 'modified' &&
|
||||
!files[0].previous_filename;
|
||||
changelogChanged = files.some(
|
||||
({ filename, previous_filename }) =>
|
||||
filename === 'CHANGELOG.md' ||
|
||||
previous_filename === 'CHANGELOG.md'
|
||||
);
|
||||
|
||||
const isPlatformSensitive = (filename) =>
|
||||
typeof filename === 'string' &&
|
||||
(
|
||||
filename.startsWith('internal/auth/') ||
|
||||
filename.startsWith('internal/keychain/') ||
|
||||
/_(darwin|windows|linux|unix)\.go$/.test(filename) ||
|
||||
filename.startsWith('scripts/release/') ||
|
||||
filename.startsWith('scripts/install') ||
|
||||
filename.startsWith('Formula/') ||
|
||||
filename.startsWith('build/npm/') ||
|
||||
filename === '.goreleaser.yaml' ||
|
||||
filename === '.github/workflows/release.yml'
|
||||
);
|
||||
platformSensitive = files.some(
|
||||
({ filename, previous_filename }) =>
|
||||
isPlatformSensitive(filename) ||
|
||||
isPlatformSensitive(previous_filename)
|
||||
);
|
||||
}
|
||||
|
||||
core.setOutput('changelog_only', String(changelogOnly));
|
||||
core.setOutput('changelog_changed', String(changelogChanged));
|
||||
core.setOutput('platform_sensitive', String(platformSensitive));
|
||||
await core.summary
|
||||
.addHeading('Code Admission scope')
|
||||
.addRaw(`- Event: \`${context.eventName}\`\n`)
|
||||
.addRaw(`- Exact modified CHANGELOG only: \`${changelogOnly}\`\n`)
|
||||
.addRaw(`- CHANGELOG touched: \`${changelogChanged}\`\n`)
|
||||
.addRaw(`- Native-platform risk paths touched: \`${platformSensitive}\`\n`)
|
||||
.addRaw(`- Changed files: \`${files.length}\`\n`)
|
||||
.write();
|
||||
|
||||
- name: Record CHANGELOG-only fast path
|
||||
if: steps.classify.outputs.changelog_only == 'true'
|
||||
run: echo "Lint is satisfied by the trusted CHANGELOG-only Policy path." >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Check out repository
|
||||
if: steps.classify.outputs.changelog_only != 'true'
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
if: steps.classify.outputs.changelog_only != 'true'
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Format Check
|
||||
if: steps.classify.outputs.changelog_only != 'true'
|
||||
run: |
|
||||
unformatted="$(find cmd internal test scripts/policy -name '*.go' -print0 | xargs -0r gofmt -l)"
|
||||
test -z "$unformatted" || (printf '%s\n' "$unformatted" && exit 1)
|
||||
|
||||
- name: Go Vet
|
||||
if: steps.classify.outputs.changelog_only != 'true'
|
||||
run: go vet ./...
|
||||
|
||||
# golangci-lint temporarily disabled: v1.64.8 built with Go 1.24 is incompatible with Go 1.25
|
||||
# - name: golangci-lint
|
||||
# uses: golangci/golangci-lint-action@v6
|
||||
# with:
|
||||
# version: v1.64.8
|
||||
# args: ./...
|
||||
|
||||
actionlint:
|
||||
name: Actionlint
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Check GitHub Actions workflows
|
||||
if: steps.classify.outputs.changelog_only != 'true'
|
||||
run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12
|
||||
|
||||
test-race:
|
||||
name: "Test (race: ${{ matrix.shard }})"
|
||||
needs: lint
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
@@ -116,6 +204,8 @@ jobs:
|
||||
|
||||
test-release-scripts:
|
||||
name: Test (release scripts)
|
||||
needs: lint
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
@@ -133,25 +223,85 @@ jobs:
|
||||
- name: Test release scripts
|
||||
run: go test -v -count=1 -timeout=5m ./test/scripts
|
||||
|
||||
test-cross-platform:
|
||||
name: Test (cross-platform compile)
|
||||
needs: lint
|
||||
if: ${{ needs.lint.outputs.changelog_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-race
|
||||
- test-release-scripts
|
||||
if: ${{ always() }}
|
||||
- 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 }}
|
||||
PLATFORM_SENSITIVE: ${{ needs.lint.outputs.platform_sensitive }}
|
||||
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 ]; then
|
||||
for shard in \
|
||||
"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
|
||||
|
||||
for shard in \
|
||||
"race shards:$RACE_RESULT" \
|
||||
"release scripts:$RELEASE_SCRIPTS_RESULT"
|
||||
"release scripts:$RELEASE_SCRIPTS_RESULT" \
|
||||
"cross-platform compile:$CROSS_PLATFORM_RESULT"
|
||||
do
|
||||
name="${shard%%:*}"
|
||||
result="${shard#*:}"
|
||||
@@ -160,10 +310,28 @@ jobs:
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
|
||||
native_expected=skipped
|
||||
if [ "$PLATFORM_SENSITIVE" = 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.platform_sensitive == 'true' }}
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
@@ -180,6 +348,8 @@ jobs:
|
||||
|
||||
test-windows:
|
||||
name: Test (Windows)
|
||||
needs: lint
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.platform_sensitive == 'true' }}
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
@@ -202,6 +372,8 @@ jobs:
|
||||
|
||||
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:
|
||||
@@ -244,6 +416,8 @@ jobs:
|
||||
|
||||
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:
|
||||
@@ -288,6 +462,8 @@ jobs:
|
||||
|
||||
coverage-current:
|
||||
name: Coverage (current)
|
||||
needs: lint
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
@@ -321,6 +497,8 @@ jobs:
|
||||
|
||||
coverage-supporting:
|
||||
name: Coverage (supporting)
|
||||
needs: lint
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
@@ -358,6 +536,8 @@ jobs:
|
||||
|
||||
coverage-baseline:
|
||||
name: Coverage (baseline)
|
||||
needs: lint
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
@@ -421,20 +601,35 @@ jobs:
|
||||
coverage:
|
||||
name: Coverage
|
||||
needs:
|
||||
- lint
|
||||
- coverage-current
|
||||
- coverage-supporting
|
||||
- coverage-baseline
|
||||
if: ${{ always() }}
|
||||
- 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 }}
|
||||
PLATFORM_SENSITIVE: ${{ needs.lint.outputs.platform_sensitive }}
|
||||
CURRENT_RESULT: ${{ needs.coverage-current.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
|
||||
expected=success
|
||||
native_expected=skipped
|
||||
if [ "$CHANGELOG_ONLY" = true ]; then
|
||||
expected=skipped
|
||||
elif [ "$PLATFORM_SENSITIVE" = true ]; then
|
||||
native_expected=success
|
||||
fi
|
||||
|
||||
for profile in \
|
||||
"current:$CURRENT_RESULT" \
|
||||
"supporting:$SUPPORTING_RESULT" \
|
||||
@@ -443,24 +638,42 @@ jobs:
|
||||
name="${profile%%:*}"
|
||||
result="${profile#*:}"
|
||||
printf '%s: %s\n' "$name" "$result"
|
||||
if [ "$result" != "success" ]; then
|
||||
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\n' "$name" "$result"
|
||||
if [ "$CHANGELOG_ONLY" = true ]; then
|
||||
native_expected=skipped
|
||||
fi
|
||||
if [ "$result" != "$native_expected" ]; then
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
test "$failed" -eq 0
|
||||
|
||||
- name: Check out repository
|
||||
if: needs.lint.outputs.changelog_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
|
||||
if: needs.lint.outputs.changelog_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'
|
||||
env:
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
@@ -478,33 +691,39 @@ jobs:
|
||||
echo "COVERAGE_BASE_REF=$base_ref" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Download current coverage profile
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: coverage-current-profile
|
||||
path: .
|
||||
|
||||
- name: Download supporting coverage profiles
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: coverage-supporting-profiles
|
||||
path: .
|
||||
|
||||
- name: Download baseline coverage profile
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: coverage-baseline-profile
|
||||
path: .
|
||||
|
||||
- name: Enforce coverage gate
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
env:
|
||||
COVERAGE_TARGET: "80"
|
||||
COVERAGE_ENFORCE_OVERALL: "false"
|
||||
run: COVERAGE_ADDITIONAL_PROFILE=coverage-shortcut.txt make coverage-gate BASE_REF="$COVERAGE_BASE_REF"
|
||||
|
||||
- name: Generate coverage report
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
run: go tool cover -html=coverage.txt -o coverage.html
|
||||
|
||||
- name: Upload coverage artifact
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-report
|
||||
@@ -516,44 +735,114 @@ jobs:
|
||||
coverage.html
|
||||
|
||||
policy:
|
||||
name: Policy Check
|
||||
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'
|
||||
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 }}
|
||||
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 ]; then
|
||||
mode=--fast-path
|
||||
fi
|
||||
./scripts/policy/check-changelog-pr.sh \
|
||||
"$mode" "$PR_BASE_SHA" HEAD
|
||||
|
||||
- name: Record CHANGELOG-only fast path
|
||||
if: needs.lint.outputs.changelog_only == 'true'
|
||||
run: |
|
||||
echo "Only the base-equivalent CHANGELOG validator ran; full Policy resumes on main." \
|
||||
>> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Build
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
run: make build
|
||||
|
||||
- name: Policy
|
||||
if: needs.lint.outputs.changelog_only != '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'
|
||||
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'
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Build
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
run: make build
|
||||
|
||||
- name: Resolve authoritative compatibility merge-base
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
env:
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
@@ -578,6 +867,7 @@ jobs:
|
||||
echo "COMPATIBILITY_STABLE_REF=$stable_ref" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Check historical commands and help compatibility
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
run: |
|
||||
make authoritative-interface-integrity \
|
||||
BASE_REF="$COMPATIBILITY_BASE_REF"
|
||||
@@ -587,143 +877,89 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Check complete Schema compatibility
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
run: make schema-compatibility BASE_REF="$COMPATIBILITY_BASE_REF"
|
||||
|
||||
- name: Check skill command references
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
run: make skill-command-integrity
|
||||
|
||||
- name: Record CHANGELOG-only fast path
|
||||
if: needs.lint.outputs.changelog_only == 'true'
|
||||
run: echo "Interface Integrity is unaffected by an exact CHANGELOG-only diff." >> "$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'
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Build
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
run: make build
|
||||
|
||||
- name: Check public top-level commands
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
run: make cli-smoke
|
||||
|
||||
- name: Record CHANGELOG-only fast path
|
||||
if: needs.lint.outputs.changelog_only == 'true'
|
||||
run: echo "CLI Smoke is unaffected by an exact CHANGELOG-only diff." >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
mock-mcp-smoke:
|
||||
name: 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'
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
if: needs.lint.outputs.changelog_only != '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'
|
||||
run: make mock-mcp-smoke
|
||||
|
||||
- name: Record CHANGELOG-only fast path
|
||||
if: needs.lint.outputs.changelog_only == 'true'
|
||||
run: echo "Mock MCP is unaffected by an exact CHANGELOG-only diff." >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
edition-tests:
|
||||
name: Edition Contract Tests
|
||||
name: Edition
|
||||
needs: lint
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Check out repository
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Run edition contract tests
|
||||
if: needs.lint.outputs.changelog_only != 'true'
|
||||
run: go test -v -count=1 ./pkg/editiontest/...
|
||||
|
||||
ci-gate:
|
||||
name: CI Gate
|
||||
needs:
|
||||
- lint
|
||||
- actionlint
|
||||
- test
|
||||
- test-darwin
|
||||
- test-windows
|
||||
- coverage
|
||||
- coverage-darwin
|
||||
- coverage-windows
|
||||
- policy
|
||||
- interface-integrity
|
||||
- cli-smoke
|
||||
- mock-mcp-smoke
|
||||
- edition-tests
|
||||
if: ${{ always() }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions: {}
|
||||
steps:
|
||||
- name: Verify required checks
|
||||
env:
|
||||
LINT_RESULT: ${{ needs.lint.result }}
|
||||
ACTIONLINT_RESULT: ${{ needs.actionlint.result }}
|
||||
TEST_RESULT: ${{ needs.test.result }}
|
||||
TEST_DARWIN_RESULT: ${{ needs.test-darwin.result }}
|
||||
TEST_WINDOWS_RESULT: ${{ needs.test-windows.result }}
|
||||
COVERAGE_RESULT: ${{ needs.coverage.result }}
|
||||
COVERAGE_DARWIN_RESULT: ${{ needs.coverage-darwin.result }}
|
||||
COVERAGE_WINDOWS_RESULT: ${{ needs.coverage-windows.result }}
|
||||
POLICY_RESULT: ${{ needs.policy.result }}
|
||||
INTERFACE_INTEGRITY_RESULT: ${{ needs.interface-integrity.result }}
|
||||
CLI_SMOKE_RESULT: ${{ needs.cli-smoke.result }}
|
||||
MOCK_MCP_SMOKE_RESULT: ${{ needs.mock-mcp-smoke.result }}
|
||||
EDITION_TESTS_RESULT: ${{ needs.edition-tests.result }}
|
||||
run: |
|
||||
failed=0
|
||||
for check in \
|
||||
"Lint:$LINT_RESULT" \
|
||||
"Actionlint:$ACTIONLINT_RESULT" \
|
||||
"Test:$TEST_RESULT" \
|
||||
"Test (macOS auth/keychain):$TEST_DARWIN_RESULT" \
|
||||
"Test (Windows):$TEST_WINDOWS_RESULT" \
|
||||
"Coverage:$COVERAGE_RESULT" \
|
||||
"Coverage (macOS):$COVERAGE_DARWIN_RESULT" \
|
||||
"Coverage (Windows):$COVERAGE_WINDOWS_RESULT" \
|
||||
"Policy Check:$POLICY_RESULT" \
|
||||
"Interface Integrity:$INTERFACE_INTEGRITY_RESULT" \
|
||||
"CLI Smoke:$CLI_SMOKE_RESULT" \
|
||||
"Mock MCP Smoke:$MOCK_MCP_SMOKE_RESULT" \
|
||||
"Edition Contract Tests:$EDITION_TESTS_RESULT"
|
||||
do
|
||||
name="${check%%:*}"
|
||||
result="${check#*:}"
|
||||
printf '%s: %s\n' "$name" "$result"
|
||||
if [ "$result" != "success" ]; then
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
test "$failed" -eq 0
|
||||
|
||||
notify-downstream:
|
||||
name: Notify Wukong Overlay
|
||||
needs: [ci-gate]
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
permissions: {}
|
||||
steps:
|
||||
- name: Trigger downstream CI
|
||||
run: |
|
||||
# Trigger internal GitLab CI pipeline via webhook.
|
||||
# WUKONG_TRIGGER_TOKEN is a repository secret.
|
||||
if [ -n "${{ secrets.WUKONG_TRIGGER_TOKEN }}" ]; then
|
||||
curl --fail --silent --show-error \
|
||||
-X POST \
|
||||
-F "token=${{ secrets.WUKONG_TRIGGER_TOKEN }}" \
|
||||
-F "ref=main" \
|
||||
-F "variables[UPSTREAM_SHA]=${{ github.sha }}" \
|
||||
"${{ secrets.WUKONG_TRIGGER_URL }}"
|
||||
echo "Downstream CI triggered."
|
||||
else
|
||||
echo "No WUKONG_TRIGGER_TOKEN configured, skipping downstream notification."
|
||||
fi
|
||||
- name: Record CHANGELOG-only fast path
|
||||
if: needs.lint.outputs.changelog_only == 'true'
|
||||
run: echo "Edition is unaffected by an exact CHANGELOG-only diff." >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
name: Multi Profile E2E
|
||||
name: Main Integration — 主干集成
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -14,7 +15,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
multi-profile-e2e:
|
||||
name: Multi Profile E2E
|
||||
name: Multi-profile E2E
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
@@ -36,7 +37,7 @@ jobs:
|
||||
mkdir -p .tmp-bin
|
||||
bash scripts/dev/test-multi-profile-e2e.sh --keep-workdir | tee "$MULTI_PROFILE_E2E_LOG"
|
||||
{
|
||||
echo "### Multi Profile E2E"
|
||||
echo "### Multi-profile E2E"
|
||||
echo "- Command: \`bash scripts/dev/test-multi-profile-e2e.sh --keep-workdir\`"
|
||||
echo "- Scope: isolated auth/profile storage, profile switch/use, one-shot profile override, CSV multi-profile aggregation, legacy migration"
|
||||
echo "- Result: passed"
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
name: Main Integration — Wukong Overlay
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows:
|
||||
- Code Admission — PR 合入门禁
|
||||
types:
|
||||
- completed
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
notify-downstream:
|
||||
name: Notify Wukong Overlay
|
||||
if: >-
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.event == 'push' &&
|
||||
github.event.workflow_run.head_branch == 'main'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Trigger downstream CI
|
||||
env:
|
||||
UPSTREAM_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
WUKONG_TRIGGER_TOKEN: ${{ secrets.WUKONG_TRIGGER_TOKEN }}
|
||||
WUKONG_TRIGGER_URL: ${{ secrets.WUKONG_TRIGGER_URL }}
|
||||
run: |
|
||||
if [ -n "$WUKONG_TRIGGER_TOKEN" ]; then
|
||||
curl --fail --silent --show-error \
|
||||
-X POST \
|
||||
-F "token=$WUKONG_TRIGGER_TOKEN" \
|
||||
-F "ref=main" \
|
||||
-F "variables[UPSTREAM_SHA]=$UPSTREAM_SHA" \
|
||||
"$WUKONG_TRIGGER_URL"
|
||||
echo "Downstream CI triggered."
|
||||
else
|
||||
echo "No WUKONG_TRIGGER_TOKEN configured, skipping downstream notification."
|
||||
fi
|
||||
+286
-27
@@ -1,5 +1,5 @@
|
||||
name: Release
|
||||
run-name: ${{ github.event_name == 'workflow_dispatch' && inputs.governance_preflight_nonce != '' && format('Release governance preflight {0}', inputs.governance_preflight_nonce) || github.event_name == 'workflow_dispatch' && inputs.recover_release_version != '' && format('Release recovery {0} at {1} {2}', inputs.recover_release_version, inputs.recover_release_commit, inputs.recover_release_nonce) || github.event_name == 'workflow_dispatch' && inputs.repair_npm_version != '' && format('Release npm repair {0}', inputs.repair_npm_version) || github.workflow }}
|
||||
run-name: ${{ github.event_name == 'workflow_dispatch' && inputs.governance_preflight_nonce != '' && format('Release governance preflight {0}', inputs.governance_preflight_nonce) || github.event_name == 'workflow_dispatch' && inputs.recover_release_version != '' && format('Release recovery {0} at {1} {2}', inputs.recover_release_version, inputs.recover_release_commit, inputs.recover_release_nonce) || github.event_name == 'workflow_dispatch' && inputs.repair_npm_version != '' && format('Release npm repair {0}', inputs.repair_npm_version) || github.event_name == 'workflow_dispatch' && inputs.repair_gitee_version != '' && format('Release Gitee repair {0}', inputs.repair_gitee_version) || github.workflow }}
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -11,6 +11,10 @@ on:
|
||||
description: "Recover npm for a public immutable release without moving latest/beta"
|
||||
required: false
|
||||
type: string
|
||||
repair_gitee_version:
|
||||
description: "Mirror an existing public immutable release's assets to Gitee"
|
||||
required: false
|
||||
type: string
|
||||
governance_preflight_commit:
|
||||
description: "Verify release governance for this exact default-branch commit without publishing"
|
||||
required: false
|
||||
@@ -51,9 +55,9 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Queue governance checks, tag releases, protected recoveries, and npm repairs
|
||||
# behind one publication lock. The local entrypoint also refuses to tag while
|
||||
# a Release run is active.
|
||||
# Queue governance checks, tag releases, protected recoveries, and channel
|
||||
# repairs behind one publication lock. The local entrypoint also refuses to tag
|
||||
# while a Release run is active.
|
||||
concurrency:
|
||||
group: dws-release-publication
|
||||
queue: max
|
||||
@@ -70,6 +74,7 @@ jobs:
|
||||
id: mode
|
||||
env:
|
||||
REPAIR_NPM_VERSION: ${{ inputs.repair_npm_version }}
|
||||
REPAIR_GITEE_VERSION: ${{ inputs.repair_gitee_version }}
|
||||
GOVERNANCE_COMMIT: ${{ inputs.governance_preflight_commit }}
|
||||
GOVERNANCE_NONCE: ${{ inputs.governance_preflight_nonce }}
|
||||
RECOVER_VERSION: ${{ inputs.recover_release_version }}
|
||||
@@ -81,22 +86,26 @@ jobs:
|
||||
RECOVER_CONFIRMATION: ${{ inputs.recover_release_confirmation }}
|
||||
run: |
|
||||
set -eu
|
||||
repair=0
|
||||
npm_repair=0
|
||||
gitee_repair=0
|
||||
governance=0
|
||||
recovery=0
|
||||
test -z "$REPAIR_NPM_VERSION" || repair=1
|
||||
test -z "$REPAIR_NPM_VERSION" || npm_repair=1
|
||||
test -z "$REPAIR_GITEE_VERSION" || gitee_repair=1
|
||||
if test -n "$GOVERNANCE_COMMIT" || test -n "$GOVERNANCE_NONCE"; then governance=1; fi
|
||||
if test -n "$RECOVER_VERSION" || test -n "$RECOVER_TAG_OBJECT" || \
|
||||
test -n "$RECOVER_COMMIT" || test -n "$RECOVER_FAILED_RUN_ID" || \
|
||||
test -n "$RECOVER_FAILED_RUN_ATTEMPT" || \
|
||||
test -n "$RECOVER_NONCE" || \
|
||||
test -n "$RECOVER_CONFIRMATION"; then recovery=1; fi
|
||||
test $((repair + governance + recovery)) -eq 1 || {
|
||||
test $((npm_repair + gitee_repair + governance + recovery)) -eq 1 || {
|
||||
echo "workflow_dispatch must select exactly one release mode" >&2
|
||||
exit 1
|
||||
}
|
||||
if test "$repair" -eq 1; then
|
||||
if test "$npm_repair" -eq 1; then
|
||||
echo "mode=repair_npm" >> "$GITHUB_OUTPUT"
|
||||
elif test "$gitee_repair" -eq 1; then
|
||||
echo "mode=repair_gitee" >> "$GITHUB_OUTPUT"
|
||||
elif test "$governance" -eq 1; then
|
||||
if test -z "$GOVERNANCE_COMMIT" || test -z "$GOVERNANCE_NONCE"; then
|
||||
echo "governance preflight requires both commit and nonce" >&2
|
||||
@@ -226,26 +235,56 @@ jobs:
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Require successful CI Gate on the preflight commit
|
||||
- name: Require successful Code Admission contexts on the preflight commit
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
PREFLIGHT_COMMIT: ${{ inputs.governance_preflight_commit }}
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const { data } = await github.rest.checks.listForRef({
|
||||
const sha = process.env.PREFLIGHT_COMMIT;
|
||||
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,
|
||||
repo,
|
||||
ref: process.env.PREFLIGHT_COMMIT,
|
||||
check_name: "CI Gate",
|
||||
ref: sha,
|
||||
filter: "latest",
|
||||
per_page: 100,
|
||||
});
|
||||
const passed = data.check_runs.some(
|
||||
(run) => run.name === "CI Gate" && run.conclusion === "success",
|
||||
);
|
||||
if (!passed) {
|
||||
core.setFailed(`CI Gate has not succeeded for ${process.env.PREFLIGHT_COMMIT}`);
|
||||
const latestByName = new Map();
|
||||
for (const run of runs) {
|
||||
if (run.head_sha !== sha || !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) {
|
||||
core.setFailed(
|
||||
`Code Admission contexts are not all successful for ${sha}; ` +
|
||||
`missing: ${missing.length > 0 ? missing.join(", ") : "none"}; ` +
|
||||
`non-success: ${nonSuccess.length > 0 ? nonSuccess.join(", ") : "none"}`,
|
||||
);
|
||||
}
|
||||
|
||||
- name: Check out trusted preflight tooling
|
||||
@@ -493,26 +532,56 @@ jobs:
|
||||
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-github-tag-authority.sh" \
|
||||
"$RELEASE_VERSION" "$RELEASE_COMMIT" "$RELEASE_TAG_OBJECT"
|
||||
|
||||
- name: Require successful CI Gate on the sealed commit
|
||||
- name: Require successful Code Admission contexts on the sealed commit
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
RELEASE_COMMIT: ${{ steps.target.outputs.release_commit }}
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const { data } = await github.rest.checks.listForRef({
|
||||
const sha = process.env.RELEASE_COMMIT;
|
||||
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,
|
||||
repo,
|
||||
ref: process.env.RELEASE_COMMIT,
|
||||
check_name: "CI Gate",
|
||||
ref: sha,
|
||||
filter: "latest",
|
||||
per_page: 100,
|
||||
});
|
||||
const passed = data.check_runs.some(
|
||||
(run) => run.name === "CI Gate" && run.conclusion === "success",
|
||||
);
|
||||
if (!passed) {
|
||||
core.setFailed(`CI Gate has not succeeded for ${process.env.RELEASE_COMMIT}`);
|
||||
const latestByName = new Map();
|
||||
for (const run of runs) {
|
||||
if (run.head_sha !== sha || !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) {
|
||||
core.setFailed(
|
||||
`Code Admission contexts are not all successful for ${sha}; ` +
|
||||
`missing: ${missing.length > 0 ? missing.join(", ") : "none"}; ` +
|
||||
`non-success: ${nonSuccess.length > 0 ? nonSuccess.join(", ") : "none"}`,
|
||||
);
|
||||
}
|
||||
|
||||
- name: Require delivered previous stable baseline
|
||||
@@ -712,7 +781,7 @@ jobs:
|
||||
- name: Install archive tooling
|
||||
run: sudo apt-get update && sudo apt-get install -y zip unzip
|
||||
|
||||
- name: Multi Profile E2E
|
||||
- name: Multi-profile E2E
|
||||
run: bash scripts/dev/test-multi-profile-e2e.sh
|
||||
|
||||
- name: Install rcodesign (sign darwin binaries from Linux)
|
||||
@@ -1809,6 +1878,7 @@ jobs:
|
||||
- publish-channels
|
||||
- mirror-gitee-release
|
||||
- repair-npm
|
||||
- repair-gitee
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 2
|
||||
permissions: {}
|
||||
@@ -1828,6 +1898,7 @@ jobs:
|
||||
PUBLISH_CHANNELS_RESULT: ${{ needs.publish-channels.result }}
|
||||
MIRROR_GITEE_RESULT: ${{ needs.mirror-gitee-release.result }}
|
||||
REPAIR_NPM_RESULT: ${{ needs.repair-npm.result }}
|
||||
REPAIR_GITEE_RESULT: ${{ needs.repair-gitee.result }}
|
||||
run: |
|
||||
set -eu
|
||||
require_result() {
|
||||
@@ -1857,6 +1928,7 @@ jobs:
|
||||
require_result authorize-recovery "$AUTHORIZE_RECOVERY_RESULT" skipped
|
||||
require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" skipped
|
||||
require_result repair-npm "$REPAIR_NPM_RESULT" skipped
|
||||
require_result repair-gitee "$REPAIR_GITEE_RESULT" skipped
|
||||
require_publication
|
||||
;;
|
||||
workflow_dispatch:recover_release)
|
||||
@@ -1864,6 +1936,7 @@ jobs:
|
||||
require_result authorize-recovery "$AUTHORIZE_RECOVERY_RESULT" success
|
||||
require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" skipped
|
||||
require_result repair-npm "$REPAIR_NPM_RESULT" skipped
|
||||
require_result repair-gitee "$REPAIR_GITEE_RESULT" skipped
|
||||
require_publication
|
||||
;;
|
||||
workflow_dispatch:governance_preflight)
|
||||
@@ -1877,6 +1950,7 @@ jobs:
|
||||
require_result publish-channels "$PUBLISH_CHANNELS_RESULT" skipped
|
||||
require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped
|
||||
require_result repair-npm "$REPAIR_NPM_RESULT" skipped
|
||||
require_result repair-gitee "$REPAIR_GITEE_RESULT" skipped
|
||||
;;
|
||||
workflow_dispatch:repair_npm)
|
||||
require_result dispatch-contract "$DISPATCH_RESULT" success
|
||||
@@ -1889,9 +1963,194 @@ jobs:
|
||||
require_result publish-release "$PUBLISH_RELEASE_RESULT" skipped
|
||||
require_result publish-channels "$PUBLISH_CHANNELS_RESULT" skipped
|
||||
require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped
|
||||
require_result repair-gitee "$REPAIR_GITEE_RESULT" skipped
|
||||
;;
|
||||
workflow_dispatch:repair_gitee)
|
||||
require_result dispatch-contract "$DISPATCH_RESULT" success
|
||||
require_result repair-gitee "$REPAIR_GITEE_RESULT" success
|
||||
require_result authorize-recovery "$AUTHORIZE_RECOVERY_RESULT" skipped
|
||||
require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" skipped
|
||||
require_result release-contract "$RELEASE_CONTRACT_RESULT" skipped
|
||||
require_result release "$RELEASE_RESULT" skipped
|
||||
require_result verify-darwin-signatures "$DARWIN_SIGNATURE_RESULT" skipped
|
||||
require_result publish-release "$PUBLISH_RELEASE_RESULT" skipped
|
||||
require_result publish-channels "$PUBLISH_CHANNELS_RESULT" skipped
|
||||
require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped
|
||||
require_result repair-npm "$REPAIR_NPM_RESULT" skipped
|
||||
;;
|
||||
*)
|
||||
echo "unsupported release mode: event=$EVENT_NAME mode=$DISPATCH_MODE" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
# Mirror an existing public immutable release's assets to Gitee on demand. The
|
||||
# push-triggered mirror-gitee-release job depends on the same run's finalized
|
||||
# dist artifact, so a tag that was already published (including one delivered
|
||||
# by a recovery dispatch) cannot reuse it. This dispatch path re-derives the
|
||||
# asset set from the immutable GitHub Release itself and mirrors it. The
|
||||
# release tag and asset checksums remain the sealed authority boundary.
|
||||
repair-gitee:
|
||||
name: Mirror an existing immutable release to Gitee
|
||||
needs: dispatch-contract
|
||||
if: ${{ !cancelled() && needs.dispatch-contract.result == 'success' && needs.dispatch-contract.outputs.mode == 'repair_gitee' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && github.repository == 'DingTalk-Real-AI/dingtalk-workspace-cli' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
steps:
|
||||
- name: Check out trusted release tooling
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
path: tooling
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Validate repair version
|
||||
working-directory: tooling
|
||||
env:
|
||||
VERSION: ${{ inputs.repair_gitee_version }}
|
||||
run: |
|
||||
set -eu
|
||||
. ./scripts/release/release-lib.sh
|
||||
if ! release_is_stable_version "$VERSION" && ! release_is_prerelease_version "$VERSION"; then
|
||||
echo "Invalid release version: $VERSION" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
- name: Verify immutable release authority
|
||||
id: authority
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
VERSION: ${{ inputs.repair_gitee_version }}
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const version = process.env.VERSION;
|
||||
const ref = await github.rest.git.getRef({
|
||||
owner,
|
||||
repo,
|
||||
ref: `tags/${version}`,
|
||||
});
|
||||
if (ref.data.object.type !== "tag") {
|
||||
core.setFailed(`${version} is not an annotated tag`);
|
||||
return;
|
||||
}
|
||||
const tag = await github.rest.git.getTag({
|
||||
owner,
|
||||
repo,
|
||||
tag_sha: ref.data.object.sha,
|
||||
});
|
||||
if (tag.data.tag !== version || tag.data.object.type !== "commit") {
|
||||
core.setFailed(`${version} does not resolve to one annotated commit tag`);
|
||||
return;
|
||||
}
|
||||
const commitSha = tag.data.object.sha;
|
||||
const defaultBranch = context.payload.repository.default_branch;
|
||||
const branch = await github.rest.repos.getBranch({
|
||||
owner,
|
||||
repo,
|
||||
branch: defaultBranch,
|
||||
});
|
||||
const comparison = await github.rest.repos.compareCommitsWithBasehead({
|
||||
owner,
|
||||
repo,
|
||||
basehead: `${commitSha}...${branch.data.commit.sha}`,
|
||||
});
|
||||
if (!["ahead", "identical"].includes(comparison.data.status)) {
|
||||
core.setFailed(`${version} is not contained in ${defaultBranch}`);
|
||||
return;
|
||||
}
|
||||
const release = await github.rest.repos.getReleaseByTag({
|
||||
owner,
|
||||
repo,
|
||||
tag: version,
|
||||
});
|
||||
if (
|
||||
release.data.tag_name !== version ||
|
||||
release.data.draft ||
|
||||
!release.data.immutable
|
||||
) {
|
||||
core.setFailed(`${version} is not a public immutable GitHub Release`);
|
||||
return;
|
||||
}
|
||||
const expectedPrerelease = version.includes("-beta.");
|
||||
if (release.data.prerelease !== expectedPrerelease) {
|
||||
core.setFailed(`${version} prerelease metadata does not match its version`);
|
||||
return;
|
||||
}
|
||||
const expectedAssets = new Set([
|
||||
"dws-darwin-amd64.tar.gz",
|
||||
"dws-darwin-arm64.tar.gz",
|
||||
"dws-linux-amd64.tar.gz",
|
||||
"dws-linux-arm64.tar.gz",
|
||||
"dws-windows-amd64.zip",
|
||||
"dws-windows-arm64.zip",
|
||||
"dws-skills.zip",
|
||||
"checksums.txt",
|
||||
]);
|
||||
const assetNames = release.data.assets.map((asset) => asset.name);
|
||||
if (
|
||||
assetNames.length !== expectedAssets.size ||
|
||||
assetNames.some((name) => !expectedAssets.has(name)) ||
|
||||
new Set(assetNames).size !== expectedAssets.size
|
||||
) {
|
||||
core.setFailed(`${version} does not contain the exact release asset set`);
|
||||
return;
|
||||
}
|
||||
core.setOutput("commit_sha", commitSha);
|
||||
core.setOutput("tag_object", ref.data.object.sha);
|
||||
|
||||
- name: Fetch and verify sealed release tag
|
||||
working-directory: tooling
|
||||
env:
|
||||
VERSION: ${{ inputs.repair_gitee_version }}
|
||||
RELEASE_COMMIT: ${{ steps.authority.outputs.commit_sha }}
|
||||
RELEASE_TAG_OBJECT: ${{ steps.authority.outputs.tag_object }}
|
||||
run: |
|
||||
set -eu
|
||||
git fetch --force --no-tags \
|
||||
https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git \
|
||||
'+refs/tags/v*:refs/tags/v*'
|
||||
./scripts/release/verify-github-tag-authority.sh \
|
||||
"$VERSION" "$RELEASE_COMMIT" "$RELEASE_TAG_OBJECT"
|
||||
|
||||
- name: Require successful Release workflow delivery
|
||||
working-directory: tooling
|
||||
env:
|
||||
VERSION: ${{ inputs.repair_gitee_version }}
|
||||
RELEASE_COMMIT: ${{ steps.authority.outputs.commit_sha }}
|
||||
DWS_RELEASE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
./scripts/release/verify-release-workflow-delivery.sh \
|
||||
"$VERSION" "$RELEASE_COMMIT"
|
||||
|
||||
- name: Download and verify immutable GitHub Release assets
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ inputs.repair_gitee_version }}
|
||||
run: |
|
||||
set -eu
|
||||
mkdir -p dist
|
||||
gh release download "$VERSION" \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--dir dist \
|
||||
--pattern 'dws-*' \
|
||||
--pattern 'checksums.txt' \
|
||||
--clobber
|
||||
DWS_PACKAGE_DIST_DIR="$GITHUB_WORKSPACE/dist" \
|
||||
"$GITHUB_WORKSPACE/tooling/scripts/release/verify-release-artifacts.sh" "$VERSION"
|
||||
|
||||
- name: Mirror release to Gitee (China)
|
||||
timeout-minutes: 100
|
||||
working-directory: tooling
|
||||
run: |
|
||||
"$GITHUB_WORKSPACE/tooling/scripts/release/sync-to-gitee.sh"
|
||||
env:
|
||||
VERSION: ${{ inputs.repair_gitee_version }}
|
||||
DIST_DIR: ${{ github.workspace }}/dist
|
||||
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
|
||||
GITEE_USER: ${{ secrets.GITEE_USER }}
|
||||
GITEE_REPO: ${{ secrets.GITEE_REPO }}
|
||||
DWS_REQUIRE_GITEE: "1"
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
class DingtalkWorkspaceCliBeta < Formula
|
||||
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
|
||||
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
version "1.0.53-beta.2"
|
||||
version "1.0.53-beta.4"
|
||||
license "Apache-2.0"
|
||||
keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"
|
||||
|
||||
on_macos do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.2/dws-darwin-arm64.tar.gz"
|
||||
sha256 "47d3f470003a309f4a93a4dfe55ab39240018b7c698f7863d85834e1f0a3affa"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.4/dws-darwin-arm64.tar.gz"
|
||||
sha256 "32a442d5b42dfed8512a695a7cef513722db6912f3c3168954cfaefb68e0b075"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.2/dws-darwin-amd64.tar.gz"
|
||||
sha256 "2dcf90b515d934e71715d95098d3b3cec34299cdbe6c858e1106a81d23d3d51a"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.4/dws-darwin-amd64.tar.gz"
|
||||
sha256 "00c694677b9ce2e1a711535740681defe0a5f83d6b72140f305483501628faf8"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.2/dws-linux-arm64.tar.gz"
|
||||
sha256 "b4692a3c2690460c039e641f75f6793daf3b8549b8c9a35d01153a908f6cc2b1"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.4/dws-linux-arm64.tar.gz"
|
||||
sha256 "98516620e861e516cf846cf418f1a0ad5c5eb9a8681bd066b1fd29f4847aca6a"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.2/dws-linux-amd64.tar.gz"
|
||||
sha256 "000d29d4c81589553e5b23b573002b7014b16c073793b8d7c5617e9b89488175"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.4/dws-linux-amd64.tar.gz"
|
||||
sha256 "266df80e8a989971789a157dd134685a7c9eda01dd1d082719ec9e09d5a07bf0"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.2/dws-skills.zip"
|
||||
sha256 "b55a4eaaa63073147c3b9efff48b4713be75577c8a1d2dc8c6e11dc30b4f91c8"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.4/dws-skills.zip"
|
||||
sha256 "6770511ab9b04b4d97da1858069ff694830ba91d47c1e8564fd85856f95b016e"
|
||||
end
|
||||
|
||||
def install
|
||||
|
||||
@@ -43,3 +43,38 @@
|
||||
- `skills/`: bundled agent skills (mono/ and multi/ layouts)
|
||||
- `test/`: CLI, integration, contract, unit, and skill E2E tests
|
||||
- `scripts/`: install scripts, policy checks, and CI helpers
|
||||
|
||||
## Quality Pipeline
|
||||
|
||||
Quality enforcement is layered so a pull request receives fast, deterministic
|
||||
admission feedback without pretending that downstream integration has already
|
||||
run.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
PR["Pull request"] --> CA["Code Admission — PR 合入门禁"]
|
||||
subgraph CA_CHECKS["Nine required contexts"]
|
||||
L["Lint"]
|
||||
T["Test"]
|
||||
C["Coverage"]
|
||||
P["Policy"]
|
||||
E["Edition"]
|
||||
I["Interface Integrity"]
|
||||
A["AI Behavior"]
|
||||
S["CLI Smoke"]
|
||||
M["Mock MCP"]
|
||||
end
|
||||
CA --> CA_CHECKS
|
||||
CA_CHECKS --> MAIN["Protected main"]
|
||||
MAIN --> MP["Main Integration — 主干集成<br/>Multi-profile E2E"]
|
||||
MAIN --> PLATFORM["Risk-selected / release native platform validation"]
|
||||
MP --> RELEASE["Release delivery"]
|
||||
PLATFORM --> RELEASE
|
||||
```
|
||||
|
||||
Complete Multi-profile E2E and the ordinary full native-platform matrix are
|
||||
downstream of PR admission. PRs still run primary-environment assurance and
|
||||
fast cross-platform compilation; auth, keychain, OS-specific, installer, and
|
||||
release changes additionally select native platform tests before merge. See
|
||||
[`docs/ci-pr-gates.md`](ci-pr-gates.md) for the exact context and ruleset
|
||||
contract.
|
||||
|
||||
+127
-108
@@ -1,133 +1,152 @@
|
||||
# Pull request quality gates
|
||||
# Code Admission — PR 合入门禁
|
||||
|
||||
The repository defines five focused checks in addition to its existing CI:
|
||||
The pull-request admission layer has exactly nine required external contexts:
|
||||
|
||||
- **Interface Integrity** enforces backwards compatibility. Every historical
|
||||
command path and alias must still resolve, every historical command must
|
||||
still render `-h`, and historical flags must keep their type and shorthand.
|
||||
New commands, aliases, and flags are allowed. The same job compares the full
|
||||
complete `dws schema --all` contract with the PR merge-base, blocking removed
|
||||
products/tools/parameters, incompatible parameter or interface mappings,
|
||||
constraint drift, and safety-semantic drift. It also checks that executable
|
||||
`dws ...` references in `skills/**/*.md` resolve to real commands.
|
||||
Help compatibility covers command/alias/flag spelling, flag type and
|
||||
shorthand; descriptive prose may evolve without breaking the gate.
|
||||
- **Coverage** runs unit tests on every pull request and prints both overall and
|
||||
changed-code statement coverage. During the migration to the 80% repository
|
||||
target, overall coverage may not regress from a profile generated from the
|
||||
merge-base with the same test command, while changed production Go
|
||||
statements must meet 80%. Linux, Windows, and macOS each generate a native
|
||||
coverage profile for changed packages and enforce the threshold against
|
||||
changed files buildable on that platform, so build-tagged source cannot be
|
||||
hidden by an Ubuntu-only profile. Overall non-regression allows 0.1 percentage point of measurement
|
||||
variance to avoid failing unchanged code on test-path noise. Set
|
||||
`COVERAGE_ENFORCE_OVERALL=true` once repository coverage reaches 80% to make
|
||||
the overall target fail closed as well.
|
||||
CI generates the candidate, supporting, and merge-base profiles on three
|
||||
independent runners, then downloads all profiles into the aggregate
|
||||
`Coverage` job and applies the same fail-closed gate. The split changes only
|
||||
scheduling: the tested packages, profile contents, merge-base comparison,
|
||||
and final coverage thresholds remain unchanged.
|
||||
- **CLI Smoke** builds the release binary, reads the root command list from the
|
||||
structured Interface contract, and renders offline help for every public
|
||||
top-level command. It rejects Cobra's unknown-command root-help fallback and
|
||||
fails when the checked-in development fixture is stale.
|
||||
- **Mock MCP Smoke** runs the existing HTTP and stdio MCP lifecycle tests
|
||||
(`Initialize -> ListTools -> CallTool`).
|
||||
- **AI Behavior Check** applies to pull requests labeled `ai-generated`. It
|
||||
limits the change to 30 files and blocks release/CI infrastructure changes,
|
||||
including policy implementations and the checked-in Interface fixture.
|
||||
It uses `pull_request_target` without checking out PR code, so the policy
|
||||
cannot be bypassed by changing the workflow in the same pull request. The
|
||||
evaluator writes an `AI Behavior Check` commit status to the PR head SHA so
|
||||
GitHub rulesets can require it.
|
||||
| Required context | Contract |
|
||||
|---|---|
|
||||
| `Lint` | Stable PR revision classification, formatting, `go vet`, and Actionlint |
|
||||
| `Test` | Race/unit/release-script tests plus fast cross-platform compilation |
|
||||
| `Coverage` | Overall non-regression and changed-code coverage |
|
||||
| `Policy` | Repository policy and the fail-closed CHANGELOG contract |
|
||||
| `Edition` | Edition contract tests |
|
||||
| `Interface Integrity` | CLI, Schema, Skill, and stable-release compatibility |
|
||||
| `AI Behavior` | Base-owned policy for PRs labeled `ai-generated` |
|
||||
| `CLI Smoke` | Offline help for every public top-level command |
|
||||
| `Mock MCP` | HTTP and stdio MCP lifecycle smoke tests |
|
||||
|
||||
## Running the compatibility gates
|
||||
The workflow display name is `Code Admission — PR 合入门禁`. Parallel helper
|
||||
jobs may implement `Test` and `Coverage`, but they are not ruleset contexts.
|
||||
Do not require an aggregate alias or a downstream integration check in place of
|
||||
the nine contracts above.
|
||||
|
||||
Run:
|
||||
`AI Behavior` is evaluated by a `pull_request_target` workflow that never
|
||||
checks out or executes PR code. It writes the exact `AI Behavior` status to the
|
||||
current PR head. Its Files API read is bracketed by base/head revision checks,
|
||||
so a synchronize race fails closed. The same workflow supplies a successful
|
||||
`AI Behavior` check run on protected `main` pushes for release governance.
|
||||
|
||||
## Exact CHANGELOG-only fast path
|
||||
|
||||
A pull request qualifies only when GitHub reports exactly one changed file,
|
||||
that file is an in-place modification of `CHANGELOG.md`, and the base and head
|
||||
both retain it as a regular non-executable `100644` blob. Add, delete, rename,
|
||||
symlink, executable-mode, and second-file changes do not qualify.
|
||||
|
||||
`Lint` classifies the Files API result only after verifying that the API's base
|
||||
and head equal the event revision both before and after pagination. `Policy`
|
||||
checks out GitHub's PR merge ref and verifies its parents:
|
||||
|
||||
```text
|
||||
HEAD^1 = pull_request.base.sha
|
||||
HEAD^2 = pull_request.head.sha
|
||||
```
|
||||
|
||||
It then runs:
|
||||
|
||||
```sh
|
||||
./scripts/policy/check-changelog-pr.sh \
|
||||
--fast-path "$PR_BASE_SHA" HEAD
|
||||
```
|
||||
|
||||
Because the verified PR diff contains only `CHANGELOG.md`, the validator and
|
||||
its policy dependencies in that merge tree are byte-for-byte the current base
|
||||
versions. Validation targets the synthetic merge tree, not the feature-branch
|
||||
tree, so a stale branch cannot supply an older validator or combine with newer
|
||||
base notes into an invalid final CHANGELOG.
|
||||
|
||||
All nine admission contexts are still emitted and must succeed. Expensive
|
||||
implementation helpers are skipped; the named contexts record that their code
|
||||
surface is unaffected. After merge, the protected `main` push executes the
|
||||
full admission suite.
|
||||
|
||||
Any PR that touches `CHANGELOG.md` but also changes another file runs the same
|
||||
content contract in `Policy` with `--content-only`. That mode permits the
|
||||
second file but still rejects invalid dates or versions, missing bullets,
|
||||
placeholder `TODO`/`TBD`, unmanaged-section changes, and unsafe tree modes.
|
||||
Adding a second file therefore cannot bypass CHANGELOG validation.
|
||||
|
||||
## Platform and downstream boundaries
|
||||
|
||||
Ordinary PRs run the primary Linux assurance plus fast Darwin/Windows compile
|
||||
checks. Full native macOS/Windows tests and platform coverage run on a PR only
|
||||
when its diff touches auth, keychain, OS-specific Go files, installers,
|
||||
packaging, Formulae, or release automation. Protected `main` pushes run the
|
||||
complete native matrix.
|
||||
|
||||
Complete `Multi-profile E2E` is not a PR admission context. It belongs to the
|
||||
`Main Integration — 主干集成` workflow and runs only after a push to `main` (or
|
||||
an explicit manual dispatch). A failing downstream run remains a real
|
||||
regression and must be repaired, but it must not be represented by a synthetic
|
||||
successful PR check.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
PR["Pull request"] --> ADMISSION["Code Admission — PR 合入门禁"]
|
||||
ADMISSION --> L["Lint"]
|
||||
ADMISSION --> T["Test"]
|
||||
ADMISSION --> C["Coverage"]
|
||||
ADMISSION --> P["Policy"]
|
||||
ADMISSION --> E["Edition"]
|
||||
ADMISSION --> I["Interface Integrity"]
|
||||
ADMISSION --> A["AI Behavior"]
|
||||
ADMISSION --> S["CLI Smoke"]
|
||||
ADMISSION --> M["Mock MCP"]
|
||||
ADMISSION --> MAIN["Protected main"]
|
||||
MAIN --> NATIVE["Full native platform matrix"]
|
||||
MAIN --> E2E["Multi-profile E2E"]
|
||||
MAIN --> RELEASE["Release delivery"]
|
||||
```
|
||||
|
||||
## Running focused gates locally
|
||||
|
||||
Run the contracts relevant to the change:
|
||||
|
||||
```sh
|
||||
make build
|
||||
make policy
|
||||
make interface-integrity
|
||||
make authoritative-interface-integrity BASE_REF=<merge-base>
|
||||
make schema-compatibility BASE_REF=<merge-base>
|
||||
make skill-command-integrity
|
||||
make cli-smoke
|
||||
# Run on the corresponding native runner with its generated profile:
|
||||
make coverage-gate-platform BASE_REF=<merge-base> PROFILE=<coverage-profile>
|
||||
make mock-mcp-smoke
|
||||
go test -v -count=1 ./pkg/editiontest/...
|
||||
```
|
||||
|
||||
`make coverage-gate` is the enforcement step, not a profile generator. It
|
||||
expects the candidate, policy, shortcut, and merge-base profiles
|
||||
(`coverage.txt`, `coverage-policy.txt`, `coverage-shortcut.txt`, and
|
||||
`coverage-base.txt`) produced by the parallel CI profile jobs. A clean local
|
||||
checkout can reproduce the Linux/overall CI gate sequentially with:
|
||||
For an exact CHANGELOG-only branch:
|
||||
|
||||
```sh
|
||||
base_ref=$(git merge-base HEAD origin/main)
|
||||
root=$(pwd)
|
||||
base_worktree=$(mktemp -d "${TMPDIR:-/tmp}/dws-coverage-base.XXXXXX")
|
||||
rmdir "$base_worktree"
|
||||
cleanup() { git worktree remove --force "$base_worktree" >/dev/null 2>&1 || true; }
|
||||
trap cleanup EXIT HUP INT TERM
|
||||
|
||||
go test -count=1 -p 1 -coverprofile=coverage.txt -covermode=atomic \
|
||||
./ ./cmd/... ./internal/... ./skills/...
|
||||
go test -count=1 -coverprofile=coverage-policy.txt -covermode=atomic \
|
||||
./pkg/... ./scripts/policy/...
|
||||
go test -count=1 \
|
||||
-run '^(TestAllShortcuts|TestCrossPlatformCoverage)' \
|
||||
-coverpkg=./internal/app,./internal/helpers,./internal/shortcut/... \
|
||||
-coverprofile=coverage-shortcut.txt \
|
||||
-covermode=atomic \
|
||||
./internal/app ./internal/helpers ./internal/shortcut/...
|
||||
git worktree add --detach "$base_worktree" "$base_ref"
|
||||
(
|
||||
cd "$base_worktree"
|
||||
go test -count=1 -p 1 \
|
||||
-coverprofile="$root/coverage-base.txt" -covermode=atomic \
|
||||
./ ./cmd/... ./internal/... ./skills/...
|
||||
)
|
||||
COVERAGE_ADDITIONAL_PROFILE=coverage-shortcut.txt \
|
||||
make coverage-gate BASE_REF="$base_ref"
|
||||
./scripts/policy/check-changelog-pr.sh --fast-path "$base_ref" HEAD
|
||||
```
|
||||
|
||||
The native-platform target likewise expects `PROFILE` to have already been
|
||||
generated on that operating system. CI owns those generation steps; copying
|
||||
only either enforcement command into a clean checkout is intentionally an
|
||||
incomplete invocation.
|
||||
`make coverage-gate` is an enforcement step, not a profile generator. CI
|
||||
generates the candidate, supporting, merge-base, and (when risk-selected)
|
||||
native profiles before the aggregate `Coverage` context evaluates them.
|
||||
|
||||
CI derives the authoritative Interface snapshots from both the PR merge-base
|
||||
and the latest reachable stable release tag. The complete Schema snapshot comes
|
||||
from the PR merge-base, which contains the registry-first Schema introduced on
|
||||
`main`. The candidate branch cannot bless a breaking change by editing a
|
||||
fixture. Schema additions are allowed; historical products, tools, parameters,
|
||||
parameter mappings, positional execution fields, constraints, and safety
|
||||
semantics remain protected. Positional descriptions are documentation and may
|
||||
change without breaking compatibility.
|
||||
|
||||
`make update-interface-baseline` still extends the local checked-in Interface
|
||||
fixture used by `make interface-integrity`. Updates are monotonic: they add new
|
||||
commands and flags without removing history.
|
||||
|
||||
For an intentional compatibility reset at a major-version boundary, run
|
||||
`make reset-interface-baseline`. This replaces all CLI compatibility history
|
||||
with the current command tree and must receive explicit human review.
|
||||
Compatibility checks derive authoritative Interface snapshots from the PR
|
||||
merge-base and the latest reachable stable release. The candidate cannot bless
|
||||
a breaking change by editing a fixture. Schema additions are allowed;
|
||||
historical products, tools, parameters, mappings, positional execution fields,
|
||||
constraints, and safety semantics remain protected.
|
||||
|
||||
## Required GitHub repository settings
|
||||
|
||||
Create a ruleset for `main` that requires pull requests and code-owner review,
|
||||
then mark these aggregate status checks as required:
|
||||
The `main` quality ruleset must enable strict required-status-check policy
|
||||
(`strict_required_status_checks_policy=true`) so a PR is revalidated whenever
|
||||
`main` advances. It must require these exact contexts and no legacy aliases:
|
||||
|
||||
- `CI Gate`
|
||||
- `Multi Profile E2E`
|
||||
- `AI Behavior Check`
|
||||
- `Lint`
|
||||
- `Test`
|
||||
- `Coverage`
|
||||
- `Policy`
|
||||
- `Edition`
|
||||
- `Interface Integrity`
|
||||
- `AI Behavior`
|
||||
- `CLI Smoke`
|
||||
- `Mock MCP`
|
||||
|
||||
`CI Gate` fails closed unless every first-layer CI job succeeds, including
|
||||
lint, tests, native Linux/Windows/macOS coverage, policy,
|
||||
Interface/Schema/Skill integrity, and smoke tests. Requiring the aggregate
|
||||
check keeps repository rules stable when an internal job is renamed or split.
|
||||
|
||||
The `ai-generated` label must be applied by the PR-creation automation or by a
|
||||
maintainer; GitHub cannot infer reliably whether a human-authored PR contains
|
||||
AI-generated code.
|
||||
Do not require helper jobs, `Multi-profile E2E`, or an aggregate admission
|
||||
alias. Update ruleset contexts only after the new names have appeared on the
|
||||
protected branch, so a rename cannot silently remove enforcement or leave an
|
||||
unproducible required context.
|
||||
|
||||
+4
-4
@@ -2,7 +2,7 @@
|
||||
|
||||
发布只走一条链路:本地脚本负责封板、验证并推送 annotated tag;GitHub Actions 负责构建和发布最终产物。不要直接运行 `goreleaser release`,也不要手工补打或移动 tag。
|
||||
|
||||
发布前必须完成平台治理:目标 GitHub 仓库已启用 immutable releases,`main` 要求 `CI Gate`,操作机已安装并登录 `gh`。本地脚本会在封 tag 前通过 API 检查 immutable releases、当前 SHA 的 `CI Gate` 和在途 Release;`v*` tag ruleset 仍需仓库管理员预先配置并由操作人确认。
|
||||
发布前必须完成平台治理:目标 GitHub 仓库已启用 immutable releases,`main` 精确要求 `Code Admission — PR 合入门禁` 的九个 context:`Lint`、`Test`、`Coverage`、`Policy`、`Edition`、`Interface Integrity`、`AI Behavior`、`CLI Smoke`、`Mock MCP`,操作机已安装并登录 `gh`。本地脚本会在封 tag 前通过 API 检查 immutable releases、当前 SHA 的全部九个 context 和在途 Release;`v*` tag ruleset 仍需仓库管理员预先配置并由操作人确认。
|
||||
|
||||
## 日常只用一个入口
|
||||
|
||||
@@ -45,7 +45,7 @@ dws-release v1.2.3-beta.1
|
||||
dws-release v1.2.3-beta.1
|
||||
```
|
||||
|
||||
预检包含测试、策略检查、旧正式版命令树兼容检查、全平台打包、npm 安装验证,以及 macOS 环境下的 Homebrew 安装验证。它还会从默认分支触发一次无发布权限的 `Release governance preflight`,用正式流水线相同的身份检查 `CI Gate` 和 immutable releases。通过后会在当前 Git worktree 的私有 Git 状态目录写入一个有效期六小时的证明,绑定版本、精确 commit、发布仓库、beta/stable 基线和远端 `main`:
|
||||
预检包含测试、策略检查、旧正式版命令树兼容检查、全平台打包、npm 安装验证,以及 macOS 环境下的 Homebrew 安装验证。它还会从默认分支触发一次无发布权限的 `Release governance preflight`,用正式流水线相同的身份检查该精确 commit 的九个 Code Admission context 和 immutable releases。通过后会在当前 Git worktree 的私有 Git 状态目录写入一个有效期六小时的证明,绑定版本、精确 commit、发布仓库、beta/stable 基线和远端 `main`:
|
||||
|
||||
```bash
|
||||
dws-release v1.2.3-beta.1 --publish
|
||||
@@ -125,10 +125,10 @@ Homebrew 当前只属于本机预检/手工公式通道:预检会在当前 mac
|
||||
|
||||
仓库管理员还需要在 GitHub 平台配置以下不可由脚本替代的规则:
|
||||
|
||||
- `main` 必须要求精确的 `CI Gate`;tag workflow 也会通过 Checks API 再确认该封板 SHA 已通过。
|
||||
- `main` 必须精确要求 `Lint`、`Test`、`Coverage`、`Policy`、`Edition`、`Interface Integrity`、`AI Behavior`、`CLI Smoke`、`Mock MCP` 九个 Code Admission context;tag workflow 也会通过 Checks API 再确认该封板 SHA 上九项全部成功。
|
||||
- 必须启用 immutable releases;它只保护启用后发布的 release,因此应在第一次使用新流水线前配置。为 `v*` 增加 tag ruleset,限制创建权限,并在 release 发布前保护 tag 的短暂窗口。
|
||||
- 配置 `RELEASE_GOVERNANCE_TOKEN` Actions secret,只授予目标仓库 `Administration: read`;内置 `GITHUB_TOKEN` 不具备 immutable-releases API 所需的仓库治理权限。每次本地预检和 tag workflow 都使用这一个身份进行 fail-closed 验证。
|
||||
- 单独配置 `HOMEBREW_PR_TOKEN`,优先使用仅授权本仓库且具备 `Contents: write`、`Pull requests: write` 的 fine-grained PAT;若组织策略不允许该账号使用 fine-grained PAT,则回退到仅带 `public_repo` scope 的专用 classic PAT。治理预检和 tag contract 会验证 token 身份、classic scope,并用 `[skip ci]` 临时分支和 draft PR 完成真实写权限 canary,随后立即关闭 PR、删除分支;任何清理失败都会 fail closed。门禁也会拒绝与治理 token 复用。
|
||||
- 创建 `release-recovery` environment,只允许受保护分支,设置 required reviewer、禁止自审并关闭管理员绕过。workflow 会读取 environment 的 required-reviewer、prevent-self-review 和 protected-branch 规则;规则缺失时紧急恢复会失败,正常 beta/stable tag 发布不受影响。
|
||||
|
||||
immutable releases 或 `CI Gate` 缺失时,发布脚本会自动拒绝封 tag。tag ruleset 可能来自组织层,脚本不自动推断其最终作用范围;管理员确认不能省略,脚本约定也不能替代平台强制。
|
||||
immutable releases,或任一 Code Admission context 缺失、未成功时,发布脚本会自动拒绝封 tag。tag ruleset 可能来自组织层,脚本不自动推断其最终作用范围;管理员确认不能省略,脚本约定也不能替代平台强制。
|
||||
|
||||
@@ -377,9 +377,11 @@ func TestCrossPlatformCoverageDaemonMethodEdges(t *testing.T) {
|
||||
d.conns.Store("not-a-conn", struct{}{})
|
||||
d.conns.Store(&queryConn{}, struct{}{})
|
||||
d.consumerWG.Add(1)
|
||||
d.shutdown()
|
||||
acceptDone := make(chan struct{})
|
||||
close(acceptDone)
|
||||
d.shutdown(acceptDone)
|
||||
d.consumerWG.Done()
|
||||
d.shutdown()
|
||||
d.shutdown(acceptDone)
|
||||
|
||||
pr, pw, err := os.Pipe()
|
||||
if err != nil {
|
||||
|
||||
@@ -246,10 +246,11 @@ func Run(ctx context.Context, cfg Config) error {
|
||||
}
|
||||
|
||||
// 7. Graceful shutdown — cancel runCtx first so all background
|
||||
// goroutines wake up, then close listener / drain consumers.
|
||||
// goroutines wake up, then stop accepting connections before draining
|
||||
// consumers. The accept-loop barrier is required before WaitGroup.Wait:
|
||||
// sync.WaitGroup forbids a positive Add racing with Wait.
|
||||
cancelRun()
|
||||
d.shutdown()
|
||||
<-acceptDone
|
||||
d.shutdown(acceptDone)
|
||||
<-idleDone
|
||||
<-dropWarnDone
|
||||
|
||||
@@ -291,6 +292,10 @@ func (d *daemon) acceptLoop(ctx context.Context) {
|
||||
d.log.Warn("bus: accept error", "err", err)
|
||||
continue
|
||||
}
|
||||
// Track the connection before publishing the handler goroutine. Once
|
||||
// acceptLoop returns, shutdown can therefore close every accepted
|
||||
// connection before waiting for handlers to drain.
|
||||
d.conns.Store(conn, struct{}{})
|
||||
d.consumerWG.Add(1)
|
||||
go func() {
|
||||
defer d.consumerWG.Done()
|
||||
@@ -303,7 +308,6 @@ func (d *daemon) acceptLoop(ctx context.Context) {
|
||||
// Hello → register with Hub → spawn writer goroutine → read until EOF/Bye.
|
||||
// Always Unregisters and Closes on exit (plan invariant #5).
|
||||
func (d *daemon) handleConnection(ctx context.Context, conn net.Conn) {
|
||||
d.conns.Store(conn, struct{}{})
|
||||
defer func() {
|
||||
d.conns.Delete(conn)
|
||||
conn.Close()
|
||||
@@ -478,9 +482,10 @@ func (d *daemon) triggerShutdown(reason string) {
|
||||
// 1. mark shuttingDown so acceptLoop exits cleanly
|
||||
// 2. broadcast Bye to all consumers
|
||||
// 3. close listener (interrupts pending Accept)
|
||||
// 4. wait for all per-connection goroutines to drain
|
||||
// 5. lock + meta cleanup via Run's defers
|
||||
func (d *daemon) shutdown() {
|
||||
// 4. wait for acceptLoop to return so no future consumerWG.Add can occur
|
||||
// 5. close all accepted connections and wait for handlers to drain
|
||||
// 6. lock + meta cleanup via Run's defers
|
||||
func (d *daemon) shutdown(acceptDone <-chan struct{}) {
|
||||
d.shutdownMu.Lock()
|
||||
defer d.shutdownMu.Unlock()
|
||||
if !d.shuttingDown.CompareAndSwap(false, true) {
|
||||
@@ -488,6 +493,7 @@ func (d *daemon) shutdown() {
|
||||
}
|
||||
d.hub.Broadcast(transport.Bye{Type: transport.FrameTypeBye, Reason: "shutdown"})
|
||||
_ = d.listener.Close()
|
||||
<-acceptDone
|
||||
// Force-close all open IPC connections so any reader goroutine blocked
|
||||
// on Read() returns with a network error and exits cleanly. Without
|
||||
// this the consumerWG never drains and Run hangs forever.
|
||||
|
||||
Executable
+339
@@ -0,0 +1,339 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
# Validate CHANGELOG.md changes against the release contract.
|
||||
#
|
||||
# --fast-path additionally requires the complete PR diff to be exactly one
|
||||
# in-place CHANGELOG.md modification. --content-only validates CHANGELOG.md
|
||||
# while allowing other files to change in the same PR.
|
||||
|
||||
ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
|
||||
|
||||
usage() {
|
||||
printf '%s\n' "usage: $0 (--fast-path|--content-only) BASE HEAD" >&2
|
||||
}
|
||||
|
||||
if [ "$#" -ne 3 ]; then
|
||||
usage
|
||||
exit 2
|
||||
fi
|
||||
|
||||
case "$1" in
|
||||
--fast-path)
|
||||
VALIDATION_MODE="fast-path"
|
||||
;;
|
||||
--content-only)
|
||||
VALIDATION_MODE="content-only"
|
||||
;;
|
||||
*)
|
||||
usage
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
BASE_REF="$2"
|
||||
HEAD_REF="$3"
|
||||
|
||||
cd "$ROOT"
|
||||
|
||||
BASE_COMMIT="$(git rev-parse --verify --quiet "${BASE_REF}^{commit}")" || {
|
||||
printf 'error: CHANGELOG base ref is not an available commit: %s\n' "$BASE_REF" >&2
|
||||
exit 2
|
||||
}
|
||||
HEAD_COMMIT="$(git rev-parse --verify --quiet "${HEAD_REF}^{commit}")" || {
|
||||
printf 'error: CHANGELOG head ref is not an available commit: %s\n' "$HEAD_REF" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
TMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/dws-changelog-pr.XXXXXX")"
|
||||
trap 'rm -rf "$TMP_ROOT"' EXIT HUP INT TERM
|
||||
|
||||
if ! git merge-base --all "$BASE_COMMIT" "$HEAD_COMMIT" >"$TMP_ROOT/merge-bases"; then
|
||||
printf 'error: CHANGELOG base and head commits have no merge base\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
merge_base_count="$(wc -l <"$TMP_ROOT/merge-bases" | tr -d '[:space:]')"
|
||||
if [ "$merge_base_count" -ne 1 ]; then
|
||||
printf 'error: expected exactly one merge base, found %s\n' "$merge_base_count" >&2
|
||||
exit 2
|
||||
fi
|
||||
IFS= read -r MERGE_BASE <"$TMP_ROOT/merge-bases"
|
||||
|
||||
require_regular_changelog() {
|
||||
_rrc_label="$1"
|
||||
_rrc_commit="$2"
|
||||
if ! git ls-tree "$_rrc_commit" -- CHANGELOG.md |
|
||||
awk '
|
||||
NR == 1 &&
|
||||
$1 == "100644" &&
|
||||
$2 == "blob" &&
|
||||
$4 == "CHANGELOG.md" {
|
||||
valid = 1
|
||||
}
|
||||
END {
|
||||
exit !(NR == 1 && valid)
|
||||
}
|
||||
'; then
|
||||
printf 'error: CHANGELOG.md must be a regular 100644 blob at %s\n' \
|
||||
"$_rrc_label" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# The merge base is the effective base of the PR diff. Also validate the
|
||||
# caller-provided base commit so a stale or malformed target ref fails closed.
|
||||
require_regular_changelog "base" "$BASE_COMMIT"
|
||||
if [ "$MERGE_BASE" != "$BASE_COMMIT" ]; then
|
||||
require_regular_changelog "merge base" "$MERGE_BASE"
|
||||
fi
|
||||
require_regular_changelog "head" "$HEAD_COMMIT"
|
||||
|
||||
git diff --no-ext-diff --find-renames --name-status \
|
||||
"$MERGE_BASE" "$HEAD_COMMIT" >"$TMP_ROOT/name-status"
|
||||
case "$VALIDATION_MODE" in
|
||||
fast-path)
|
||||
printf 'M\tCHANGELOG.md\n' >"$TMP_ROOT/expected-name-status"
|
||||
if ! cmp -s "$TMP_ROOT/expected-name-status" "$TMP_ROOT/name-status"; then
|
||||
printf '%s\n' 'error: fast path requires exactly one in-place modification: CHANGELOG.md' >&2
|
||||
if [ -s "$TMP_ROOT/name-status" ]; then
|
||||
sed 's/^/ /' "$TMP_ROOT/name-status" >&2
|
||||
else
|
||||
printf '%s\n' ' (no changed files)' >&2
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
content-only)
|
||||
if ! awk -F ' ' '
|
||||
$1 == "M" && $2 == "CHANGELOG.md" && NF == 2 {
|
||||
count++
|
||||
}
|
||||
END {
|
||||
exit count != 1
|
||||
}
|
||||
' "$TMP_ROOT/name-status"; then
|
||||
printf '%s\n' 'error: content-only validation requires an in-place modification: CHANGELOG.md' >&2
|
||||
if [ -s "$TMP_ROOT/name-status" ]; then
|
||||
sed 's/^/ /' "$TMP_ROOT/name-status" >&2
|
||||
else
|
||||
printf '%s\n' ' (no changed files)' >&2
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if ! git diff --no-ext-diff --check "$MERGE_BASE" "$HEAD_COMMIT" -- CHANGELOG.md; then
|
||||
printf '%s\n' 'error: CHANGELOG diff contains whitespace errors' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
"$ROOT/scripts/policy/open-source-audit.sh"
|
||||
|
||||
if ! git show "${MERGE_BASE}:CHANGELOG.md" >"$TMP_ROOT/base-changelog" ||
|
||||
! git show "${HEAD_COMMIT}:CHANGELOG.md" >"$TMP_ROOT/head-changelog"; then
|
||||
printf '%s\n' 'error: could not read CHANGELOG.md from both commits' >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
git diff --no-ext-diff --unified=0 \
|
||||
"$MERGE_BASE" "$HEAD_COMMIT" -- CHANGELOG.md >"$TMP_ROOT/changelog.patch"
|
||||
if grep -q '^Binary files ' "$TMP_ROOT/changelog.patch"; then
|
||||
printf '%s\n' 'error: CHANGELOG.md must remain a text file' >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! awk '
|
||||
/^\+\+\+ / { next }
|
||||
/^\+/ {
|
||||
line = substr($0, 2)
|
||||
if (line ~ /(^|[^[:alnum:]_])(TODO|TBD)([^[:alnum:]_]|$)/) {
|
||||
bad = 1
|
||||
}
|
||||
}
|
||||
END { exit bad }
|
||||
' "$TMP_ROOT/changelog.patch"; then
|
||||
printf '%s\n' 'error: changed CHANGELOG additions must not contain TODO/TBD placeholders' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
. "$ROOT/scripts/release/release-lib.sh"
|
||||
|
||||
extract_labeled_section() {
|
||||
_els_file="$1"
|
||||
_els_wanted="$2"
|
||||
awk -v wanted="$_els_wanted" '
|
||||
/^## / {
|
||||
active = 0
|
||||
if (substr($0, 1, 4) == "## [") {
|
||||
rest = substr($0, 5)
|
||||
close_pos = index(rest, "]")
|
||||
if (close_pos > 1 && substr(rest, 1, close_pos - 1) == wanted) {
|
||||
active = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
active { print }
|
||||
' "$_els_file"
|
||||
}
|
||||
|
||||
extract_unmanaged_content() {
|
||||
_euc_file="$1"
|
||||
awk '
|
||||
BEGIN { active = 1 }
|
||||
/^## / {
|
||||
active = 1
|
||||
if (substr($0, 1, 4) == "## [") {
|
||||
rest = substr($0, 5)
|
||||
close_pos = index(rest, "]")
|
||||
if (close_pos > 1) {
|
||||
active = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
active { print }
|
||||
' "$_euc_file"
|
||||
}
|
||||
|
||||
label_heading_count() {
|
||||
_lhc_file="$1"
|
||||
_lhc_wanted="$2"
|
||||
awk -v wanted="$_lhc_wanted" '
|
||||
/^## / && substr($0, 1, 4) == "## [" {
|
||||
rest = substr($0, 5)
|
||||
close_pos = index(rest, "]")
|
||||
if (close_pos > 1 && substr(rest, 1, close_pos - 1) == wanted) count++
|
||||
}
|
||||
END { print count + 0 }
|
||||
' "$_lhc_file"
|
||||
}
|
||||
|
||||
calendar_date_is_valid() {
|
||||
_cdiv_date="$1"
|
||||
awk -v date="$_cdiv_date" 'BEGIN {
|
||||
if (date !~ /^[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]$/) exit 1
|
||||
split(date, part, "-")
|
||||
year = part[1] + 0
|
||||
month = part[2] + 0
|
||||
day = part[3] + 0
|
||||
if (year < 1 || month < 1 || month > 12 || day < 1) exit 1
|
||||
days = 31
|
||||
if (month == 4 || month == 6 || month == 9 || month == 11) days = 30
|
||||
if (month == 2) {
|
||||
days = 28
|
||||
if ((year % 4 == 0 && year % 100 != 0) || year % 400 == 0) days = 29
|
||||
}
|
||||
exit day > days
|
||||
}'
|
||||
}
|
||||
|
||||
release_heading_date() {
|
||||
_rhd_file="$1"
|
||||
_rhd_version="$2"
|
||||
awk -v wanted="$_rhd_version" '
|
||||
BEGIN { prefix = "## [" wanted "] - " }
|
||||
index($0, prefix) == 1 {
|
||||
print substr($0, length(prefix) + 1)
|
||||
}
|
||||
' "$_rhd_file"
|
||||
}
|
||||
|
||||
validate_release_notes() {
|
||||
_vrn_file="$1"
|
||||
set +e
|
||||
awk '
|
||||
NR == 1 { next }
|
||||
{
|
||||
if ($0 ~ /[^[:space:]]/) meaningful = 1
|
||||
if ($0 ~ /^- /) bullet = 1
|
||||
if ($0 ~ /(^|[^[:alnum:]_])(TODO|TBD)([^[:alnum:]_]|$)/) placeholder = 1
|
||||
}
|
||||
END {
|
||||
if (!meaningful || !bullet) exit 43
|
||||
if (placeholder) exit 44
|
||||
}
|
||||
' "$_vrn_file"
|
||||
_vrn_status=$?
|
||||
set -e
|
||||
case "$_vrn_status" in
|
||||
0) return 0 ;;
|
||||
43) printf '%s\n' 'error: changed release section must contain notes and at least one bullet' >&2 ;;
|
||||
44) printf '%s\n' 'error: changed release section still contains TODO/TBD placeholders' >&2 ;;
|
||||
*) printf '%s\n' 'error: failed to validate changed release notes' >&2 ;;
|
||||
esac
|
||||
return "$_vrn_status"
|
||||
}
|
||||
|
||||
extract_unmanaged_content "$TMP_ROOT/base-changelog" >"$TMP_ROOT/base-unmanaged"
|
||||
extract_unmanaged_content "$TMP_ROOT/head-changelog" >"$TMP_ROOT/head-unmanaged"
|
||||
if ! cmp -s "$TMP_ROOT/base-unmanaged" "$TMP_ROOT/head-unmanaged"; then
|
||||
printf '%s\n' 'error: CHANGELOG validation only permits notes inside Unreleased or versioned release sections' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
unreleased_count="$(label_heading_count "$TMP_ROOT/head-changelog" Unreleased)"
|
||||
unreleased_exact_count="$(awk '$0 == "## [Unreleased]" { count++ } END { print count + 0 }' \
|
||||
"$TMP_ROOT/head-changelog")"
|
||||
if [ "$unreleased_count" -ne 1 ] || [ "$unreleased_exact_count" -ne 1 ]; then
|
||||
printf 'error: CHANGELOG must contain exactly one heading: ## [Unreleased]\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
extract_labeled_section "$TMP_ROOT/base-changelog" Unreleased >"$TMP_ROOT/base-unreleased"
|
||||
extract_labeled_section "$TMP_ROOT/head-changelog" Unreleased >"$TMP_ROOT/head-unreleased"
|
||||
unreleased_changed=0
|
||||
if ! cmp -s "$TMP_ROOT/base-unreleased" "$TMP_ROOT/head-unreleased"; then
|
||||
unreleased_changed=1
|
||||
fi
|
||||
|
||||
awk '
|
||||
/^## / && substr($0, 1, 4) == "## [" {
|
||||
rest = substr($0, 5)
|
||||
close_pos = index(rest, "]")
|
||||
if (close_pos > 1) {
|
||||
label = substr(rest, 1, close_pos - 1)
|
||||
if (label != "Unreleased") print label
|
||||
}
|
||||
}
|
||||
' "$TMP_ROOT/base-changelog" "$TMP_ROOT/head-changelog" |
|
||||
LC_ALL=C sort -u >"$TMP_ROOT/release-labels"
|
||||
|
||||
changed_release_count=0
|
||||
section_index=0
|
||||
while IFS= read -r version; do
|
||||
[ -n "$version" ] || continue
|
||||
section_index=$((section_index + 1))
|
||||
base_section="$TMP_ROOT/base-release-$section_index"
|
||||
head_section="$TMP_ROOT/head-release-$section_index"
|
||||
extract_labeled_section "$TMP_ROOT/base-changelog" "$version" >"$base_section"
|
||||
extract_labeled_section "$TMP_ROOT/head-changelog" "$version" >"$head_section"
|
||||
if cmp -s "$base_section" "$head_section"; then
|
||||
continue
|
||||
fi
|
||||
|
||||
changed_release_count=$((changed_release_count + 1))
|
||||
if ! release_channel_for_version "v$version" >/dev/null 2>&1; then
|
||||
printf 'error: changed CHANGELOG release heading has an invalid version: %s\n' "$version" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$(label_heading_count "$TMP_ROOT/head-changelog" "$version")" -ne 1 ]; then
|
||||
printf 'error: CHANGELOG must contain exactly one well-formed section for %s\n' \
|
||||
"$version" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! validate_release_notes "$head_section"; then
|
||||
exit 1
|
||||
fi
|
||||
release_date="$(release_heading_date "$TMP_ROOT/head-changelog" "$version")"
|
||||
if ! calendar_date_is_valid "$release_date"; then
|
||||
printf 'error: CHANGELOG section for %s has an invalid calendar date: %s\n' \
|
||||
"$version" "$release_date" >&2
|
||||
exit 1
|
||||
fi
|
||||
done <"$TMP_ROOT/release-labels"
|
||||
|
||||
if [ "$changed_release_count" -eq 0 ] && [ "$unreleased_changed" -eq 0 ]; then
|
||||
printf '%s\n' 'error: CHANGELOG modification did not change an eligible notes section' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'CHANGELOG PR check: ok (mode=%s release_sections=%s unreleased_changed=%s)\n' \
|
||||
"$VALIDATION_MODE" "$changed_release_count" "$unreleased_changed"
|
||||
@@ -130,8 +130,7 @@ release_extract_changelog() {
|
||||
print
|
||||
if ($0 ~ /[^[:space:]]/) meaningful = 1
|
||||
if ($0 ~ /^- /) bullet = 1
|
||||
lowered = tolower($0)
|
||||
if (lowered ~ /todo|tbd/) placeholder = 1
|
||||
if ($0 ~ /(^|[^[:alnum:]_])(TODO|TBD)([^[:alnum:]_]|$)/) placeholder = 1
|
||||
}
|
||||
END {
|
||||
if (found != 1) exit 41
|
||||
|
||||
@@ -134,16 +134,44 @@ require_github_publication_authority() {
|
||||
}
|
||||
|
||||
sealed_commit="$(git rev-parse HEAD)"
|
||||
passed_gate="$(
|
||||
admission_check_runs="$(
|
||||
gh api -H 'Accept: application/vnd.github+json' \
|
||||
"repos/$github_repository/commits/$sealed_commit/check-runs?check_name=CI%20Gate&filter=latest&per_page=100" \
|
||||
--jq '[.check_runs[] | select(.name == "CI Gate" and .conclusion == "success")] | length'
|
||||
"repos/$github_repository/commits/$sealed_commit/check-runs?filter=latest&per_page=100" \
|
||||
--jq '.check_runs | group_by(.name) | map(max_by(.id)) | .[] | [.name, (.conclusion // .status // "unknown")] | @tsv'
|
||||
)" || {
|
||||
printf 'could not query CI Gate for %s\n' "$sealed_commit" >&2
|
||||
printf 'could not query Code Admission contexts for %s\n' "$sealed_commit" >&2
|
||||
return 1
|
||||
}
|
||||
[ "$passed_gate" -gt 0 ] || {
|
||||
printf 'CI Gate has not succeeded for sealed commit %s in %s\n' "$sealed_commit" "$github_repository" >&2
|
||||
|
||||
missing_contexts=""
|
||||
non_success_contexts=""
|
||||
while IFS= read -r required_context; do
|
||||
context_state="$(
|
||||
printf '%s\n' "$admission_check_runs" |
|
||||
awk -F '\t' -v required="$required_context" '$1 == required { state = $2 } END { print state }'
|
||||
)"
|
||||
if [ -z "$context_state" ]; then
|
||||
missing_contexts="${missing_contexts}${missing_contexts:+, }$required_context"
|
||||
elif [ "$context_state" != "success" ]; then
|
||||
non_success_contexts="${non_success_contexts}${non_success_contexts:+, }$required_context=$context_state"
|
||||
fi
|
||||
done <<'ADMISSION_CONTEXTS'
|
||||
Lint
|
||||
Test
|
||||
Coverage
|
||||
Policy
|
||||
Edition
|
||||
Interface Integrity
|
||||
AI Behavior
|
||||
CLI Smoke
|
||||
Mock MCP
|
||||
ADMISSION_CONTEXTS
|
||||
[ -z "$missing_contexts" ] && [ -z "$non_success_contexts" ] || {
|
||||
printf 'Code Admission contexts are not all successful for sealed commit %s in %s; missing: %s; non-success: %s\n' \
|
||||
"$sealed_commit" \
|
||||
"$github_repository" \
|
||||
"${missing_contexts:-none}" \
|
||||
"${non_success_contexts:-none}" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,609 @@
|
||||
package scripts_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type changelogGateRepo struct {
|
||||
root string
|
||||
gate string
|
||||
base string
|
||||
}
|
||||
|
||||
const changelogGateBase = `# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.0] - 2026-07-01
|
||||
|
||||
### Added
|
||||
|
||||
- Initial release.
|
||||
`
|
||||
|
||||
const changelogGateValidRelease = `# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.1-beta.1] - 2026-07-17
|
||||
|
||||
### Changed
|
||||
|
||||
- Valid release note.
|
||||
|
||||
## [1.0.0] - 2026-07-01
|
||||
|
||||
### Added
|
||||
|
||||
- Initial release.
|
||||
`
|
||||
|
||||
func newChangelogGateRepo(t *testing.T) *changelogGateRepo {
|
||||
t.Helper()
|
||||
|
||||
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(repo root) error = %v", err)
|
||||
}
|
||||
root := t.TempDir()
|
||||
|
||||
for _, path := range []string{
|
||||
"LICENSE",
|
||||
"NOTICE",
|
||||
"README.md",
|
||||
"CONTRIBUTING.md",
|
||||
"SECURITY.md",
|
||||
"CODE_OF_CONDUCT.md",
|
||||
".env.example",
|
||||
".github/workflows/ci.yml",
|
||||
".github/PULL_REQUEST_TEMPLATE.md",
|
||||
"docs/architecture.md",
|
||||
"scripts/README.md",
|
||||
"build/README.md",
|
||||
} {
|
||||
changelogGateWrite(t, root, path, "fixture\n", 0o644)
|
||||
}
|
||||
for _, path := range []string{
|
||||
"scripts/policy/check-changelog-pr.sh",
|
||||
"scripts/policy/open-source-audit.sh",
|
||||
"scripts/release/release-lib.sh",
|
||||
} {
|
||||
data, err := os.ReadFile(filepath.Join(sourceRoot, path))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(%s) error = %v", path, err)
|
||||
}
|
||||
mode := os.FileMode(0o644)
|
||||
if strings.HasSuffix(path, ".sh") {
|
||||
mode = 0o755
|
||||
}
|
||||
changelogGateWrite(t, root, path, string(data), mode)
|
||||
}
|
||||
changelogGateWrite(t, root, "CHANGELOG.md", changelogGateBase, 0o644)
|
||||
|
||||
changelogGateGit(t, root, "init", "-b", "main")
|
||||
changelogGateGit(t, root, "config", "user.name", "Changelog Gate Test")
|
||||
changelogGateGit(t, root, "config", "user.email", "changelog-gate@example.com")
|
||||
changelogGateGit(t, root, "add", ".")
|
||||
changelogGateGit(t, root, "commit", "-m", "seed repository")
|
||||
|
||||
return &changelogGateRepo{
|
||||
root: root,
|
||||
gate: filepath.Join(root, "scripts", "policy", "check-changelog-pr.sh"),
|
||||
base: strings.TrimSpace(changelogGateGit(t, root, "rev-parse", "HEAD")),
|
||||
}
|
||||
}
|
||||
|
||||
func changelogGateWrite(t *testing.T, root, path, content string, mode os.FileMode) {
|
||||
t.Helper()
|
||||
full := filepath.Join(root, path)
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll(%s) error = %v", filepath.Dir(full), err)
|
||||
}
|
||||
if err := os.WriteFile(full, []byte(content), mode); err != nil {
|
||||
t.Fatalf("WriteFile(%s) error = %v", full, err)
|
||||
}
|
||||
}
|
||||
|
||||
func changelogGateGit(t *testing.T, root string, args ...string) string {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = root
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %s error = %v\noutput:\n%s", strings.Join(args, " "), err, output)
|
||||
}
|
||||
return string(output)
|
||||
}
|
||||
|
||||
func (r *changelogGateRepo) commit(t *testing.T, message string) {
|
||||
t.Helper()
|
||||
changelogGateGit(t, r.root, "add", "-A")
|
||||
changelogGateGit(t, r.root, "commit", "-m", message)
|
||||
}
|
||||
|
||||
func (r *changelogGateRepo) run(t *testing.T) (string, error) {
|
||||
t.Helper()
|
||||
return r.runMode(t, "--fast-path")
|
||||
}
|
||||
|
||||
func (r *changelogGateRepo) runMode(t *testing.T, mode string) (string, error) {
|
||||
t.Helper()
|
||||
return r.runRefs(t, mode, r.base, "HEAD")
|
||||
}
|
||||
|
||||
func (r *changelogGateRepo) runRefs(t *testing.T, mode, base, head string) (string, error) {
|
||||
t.Helper()
|
||||
cmd := exec.Command("sh", r.gate, mode, base, head)
|
||||
cmd.Dir = r.root
|
||||
output, err := cmd.CombinedOutput()
|
||||
return string(output), err
|
||||
}
|
||||
|
||||
func TestChangelogPRGateAcceptsTargetedChanges(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
changelog string
|
||||
}{
|
||||
{
|
||||
name: "new release section with lowercase todo product",
|
||||
changelog: `# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.1-beta.1] - 2026-07-17
|
||||
|
||||
### Changed
|
||||
|
||||
- Improve the lowercase todo command family without leaving a placeholder.
|
||||
|
||||
## [1.0.0] - 2026-07-01
|
||||
|
||||
### Added
|
||||
|
||||
- Initial release.
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "unreleased note",
|
||||
changelog: `# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- Document the next release candidate.
|
||||
|
||||
## [1.0.0] - 2026-07-01
|
||||
|
||||
### Added
|
||||
|
||||
- Initial release.
|
||||
`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
repo := newChangelogGateRepo(t)
|
||||
changelogGateWrite(t, repo.root, "CHANGELOG.md", test.changelog, 0o644)
|
||||
repo.commit(t, test.name)
|
||||
|
||||
output, err := repo.run(t)
|
||||
if err != nil {
|
||||
t.Fatalf("gate error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
if !strings.Contains(output, "CHANGELOG PR check: ok") {
|
||||
t.Fatalf("gate output missing success marker:\n%s", output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangelogPRContentOnlyAllowsOtherFiles(t *testing.T) {
|
||||
repo := newChangelogGateRepo(t)
|
||||
changelogGateWrite(t, repo.root, "CHANGELOG.md", changelogGateValidRelease, 0o644)
|
||||
changelogGateWrite(t, repo.root, "internal/change.go", "package internal\n", 0o644)
|
||||
repo.commit(t, "change code with release notes")
|
||||
|
||||
output, err := repo.runMode(t, "--content-only")
|
||||
if err != nil {
|
||||
t.Fatalf("content-only gate error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
if !strings.Contains(output, "CHANGELOG PR check: ok (mode=content-only") {
|
||||
t.Fatalf("content-only gate output missing success marker:\n%s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangelogPRContentOnlyStillValidatesContentWithOtherFiles(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
changelog string
|
||||
wantOutput string
|
||||
}{
|
||||
{
|
||||
name: "invalid calendar date",
|
||||
changelog: strings.Replace(
|
||||
changelogGateValidRelease,
|
||||
"2026-07-17",
|
||||
"2026-02-30",
|
||||
1,
|
||||
),
|
||||
wantOutput: "invalid calendar date",
|
||||
},
|
||||
{
|
||||
name: "placeholder",
|
||||
changelog: strings.Replace(
|
||||
changelogGateValidRelease,
|
||||
"- Valid release note.",
|
||||
"- TODO: write release notes.",
|
||||
1,
|
||||
),
|
||||
wantOutput: "must not contain TODO/TBD",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
repo := newChangelogGateRepo(t)
|
||||
changelogGateWrite(t, repo.root, "CHANGELOG.md", test.changelog, 0o644)
|
||||
changelogGateWrite(t, repo.root, "internal/change.go", "package internal\n", 0o644)
|
||||
repo.commit(t, test.name)
|
||||
|
||||
output, err := repo.runMode(t, "--content-only")
|
||||
if err == nil {
|
||||
t.Fatalf("unsafe content-only change unexpectedly passed:\n%s", output)
|
||||
}
|
||||
if !strings.Contains(output, test.wantOutput) {
|
||||
t.Fatalf("content-only gate output missing %q:\n%s", test.wantOutput, output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangelogPRGateValidatesSyntheticMergeTree(t *testing.T) {
|
||||
repo := newChangelogGateRepo(t)
|
||||
commonChangelog := `# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.0] - 2026-07-01
|
||||
|
||||
### Added
|
||||
|
||||
- Initial release.
|
||||
|
||||
## [0.9.0] - 2026-06-01
|
||||
|
||||
### Added
|
||||
|
||||
- Earlier release.
|
||||
`
|
||||
changelogGateWrite(t, repo.root, "CHANGELOG.md", commonChangelog, 0o644)
|
||||
repo.commit(t, "expand changelog history")
|
||||
common := strings.TrimSpace(changelogGateGit(t, repo.root, "rev-parse", "HEAD"))
|
||||
repo.base = common
|
||||
|
||||
changelogGateGit(t, repo.root, "switch", "-c", "feature")
|
||||
featureChangelog := strings.Replace(
|
||||
commonChangelog,
|
||||
"## [1.0.0] - 2026-07-01",
|
||||
`## [1.0.1-beta.1] - 2026-07-17
|
||||
|
||||
### Changed
|
||||
|
||||
- Feature branch release note.
|
||||
|
||||
## [1.0.0] - 2026-07-01`,
|
||||
1,
|
||||
)
|
||||
changelogGateWrite(t, repo.root, "CHANGELOG.md", featureChangelog, 0o644)
|
||||
repo.commit(t, "add feature release note")
|
||||
featureHead := strings.TrimSpace(changelogGateGit(t, repo.root, "rev-parse", "HEAD"))
|
||||
if output, err := repo.runRefs(t, "--fast-path", common, featureHead); err != nil {
|
||||
t.Fatalf("feature head should be valid before merging: %v\n%s", err, output)
|
||||
}
|
||||
|
||||
changelogGateGit(t, repo.root, "switch", "main")
|
||||
mainChangelog := strings.Replace(
|
||||
commonChangelog,
|
||||
"## [0.9.0] - 2026-06-01",
|
||||
`## [1.0.1-beta.1] - 2026-07-17
|
||||
|
||||
### Changed
|
||||
|
||||
- Main branch release note.
|
||||
|
||||
## [0.9.0] - 2026-06-01`,
|
||||
1,
|
||||
)
|
||||
changelogGateWrite(t, repo.root, "CHANGELOG.md", mainChangelog, 0o644)
|
||||
repo.commit(t, "add main release note")
|
||||
mergeBase := strings.TrimSpace(changelogGateGit(t, repo.root, "rev-parse", "HEAD"))
|
||||
changelogGateGit(t, repo.root, "merge", "--no-ff", "feature", "-m", "merge feature")
|
||||
mergeHead := strings.TrimSpace(changelogGateGit(t, repo.root, "rev-parse", "HEAD"))
|
||||
|
||||
output, err := repo.runRefs(t, "--fast-path", mergeBase, mergeHead)
|
||||
if err == nil {
|
||||
t.Fatalf("synthetic merge with duplicate release heading unexpectedly passed:\n%s", output)
|
||||
}
|
||||
if !strings.Contains(output, "exactly one well-formed section") {
|
||||
t.Fatalf("synthetic merge rejection missing duplicate-section evidence:\n%s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangelogPRGateRejectsExecutableModeInBothModes(t *testing.T) {
|
||||
for _, mode := range []string{"--fast-path", "--content-only"} {
|
||||
t.Run(strings.TrimPrefix(mode, "--"), func(t *testing.T) {
|
||||
repo := newChangelogGateRepo(t)
|
||||
changelogGateWrite(t, repo.root, "CHANGELOG.md", changelogGateValidRelease, 0o644)
|
||||
changelogGateGit(t, repo.root, "add", "CHANGELOG.md")
|
||||
changelogGateGit(t, repo.root, "update-index", "--chmod=+x", "CHANGELOG.md")
|
||||
changelogGateGit(t, repo.root, "commit", "-m", "make changelog executable")
|
||||
|
||||
output, err := repo.runMode(t, mode)
|
||||
if err == nil {
|
||||
t.Fatalf("executable CHANGELOG unexpectedly passed:\n%s", output)
|
||||
}
|
||||
if !strings.Contains(output, "regular 100644 blob at head") {
|
||||
t.Fatalf("gate output missing regular-file rejection:\n%s", output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseChangelogExtractionAllowsLowercaseTodoProductName(t *testing.T) {
|
||||
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(repo root) error = %v", err)
|
||||
}
|
||||
changelog := filepath.Join(t.TempDir(), "CHANGELOG.md")
|
||||
changelogGateWrite(t, filepath.Dir(changelog), filepath.Base(changelog), `# Changelog
|
||||
|
||||
## [1.0.1-beta.1] - 2026-07-17
|
||||
|
||||
### Changed
|
||||
|
||||
- Improve the lowercase todo command family.
|
||||
`, 0o644)
|
||||
|
||||
cmd := exec.Command(
|
||||
"sh",
|
||||
"-c",
|
||||
`. "$1"; release_extract_changelog "$2" 1.0.1-beta.1 -`,
|
||||
"sh",
|
||||
filepath.Join(sourceRoot, "scripts", "release", "release-lib.sh"),
|
||||
changelog,
|
||||
)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("release_extract_changelog error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangelogPRFastPathWorkflowContract(t *testing.T) {
|
||||
root, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(repo root) error = %v", err)
|
||||
}
|
||||
|
||||
readWorkflow := func(path string) string {
|
||||
t.Helper()
|
||||
data, readErr := os.ReadFile(filepath.Join(root, path))
|
||||
if readErr != nil {
|
||||
t.Fatalf("ReadFile(%s) error = %v", path, readErr)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
admission := readWorkflow(".github/workflows/ci.yml")
|
||||
for _, want := range []string{
|
||||
"name: Code Admission — PR 合入门禁",
|
||||
"files.length === 1",
|
||||
"files[0].filename === 'CHANGELOG.md'",
|
||||
"files[0].status === 'modified'",
|
||||
"!files[0].previous_filename",
|
||||
"pre-classification",
|
||||
"post-classification",
|
||||
"before.changed_files !== files.length",
|
||||
"after.changed_files !== files.length",
|
||||
`test "$(git rev-parse HEAD^1)" = "$PR_BASE_SHA"`,
|
||||
`test "$(git rev-parse HEAD^2)" = "$PR_HEAD_SHA"`,
|
||||
"Files API and synthetic merge tree disagree on CHANGELOG scope",
|
||||
"mode=--content-only",
|
||||
"mode=--fast-path",
|
||||
`"$mode" "$PR_BASE_SHA" HEAD`,
|
||||
"needs.lint.outputs.platform_sensitive == 'true'",
|
||||
} {
|
||||
if !strings.Contains(admission, want) {
|
||||
t.Errorf("Code Admission workflow missing contract %q", want)
|
||||
}
|
||||
}
|
||||
for _, context := range []string{
|
||||
"Lint",
|
||||
"Test",
|
||||
"Coverage",
|
||||
"Policy",
|
||||
"Edition",
|
||||
"Interface Integrity",
|
||||
"CLI Smoke",
|
||||
"Mock MCP",
|
||||
} {
|
||||
if !strings.Contains(admission, "\n name: "+context+"\n") {
|
||||
t.Errorf("Code Admission workflow missing exact context %q", context)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{
|
||||
"name: CI Gate",
|
||||
"name: Changelog Check",
|
||||
"name: Policy Check",
|
||||
"name: Edition Contract Tests",
|
||||
"name: Mock MCP Smoke",
|
||||
} {
|
||||
if strings.Contains(admission, forbidden) {
|
||||
t.Errorf("Code Admission workflow retains legacy context %q", forbidden)
|
||||
}
|
||||
}
|
||||
if strings.Contains(admission, "paths-ignore:") {
|
||||
t.Error("Code Admission must not suppress required contexts with paths-ignore")
|
||||
}
|
||||
|
||||
aiBehavior := readWorkflow(".github/workflows/ai-behavior-check.yml")
|
||||
for _, want := range []string{
|
||||
"name: Code Admission — AI Behavior",
|
||||
"\n name: AI Behavior\n",
|
||||
"context: 'AI Behavior'",
|
||||
"pull_request_target:",
|
||||
"pull.head.sha !== expectedHead",
|
||||
"pull.base.sha !== expectedBase",
|
||||
} {
|
||||
if !strings.Contains(aiBehavior, want) {
|
||||
t.Errorf("AI Behavior workflow missing contract %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
integration := readWorkflow(".github/workflows/multi-profile-e2e.yml")
|
||||
for _, want := range []string{
|
||||
"name: Main Integration — 主干集成",
|
||||
"\n name: Multi-profile E2E\n",
|
||||
"branches:",
|
||||
"- main",
|
||||
"workflow_dispatch:",
|
||||
} {
|
||||
if !strings.Contains(integration, want) {
|
||||
t.Errorf("main integration workflow missing contract %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(integration, "pull_request:") {
|
||||
t.Error("complete Multi-profile E2E must not run as a pull-request admission context")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangelogPRGateRejectsUnsafeChanges(t *testing.T) {
|
||||
validRelease := `# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.1-beta.1] - 2026-07-17
|
||||
|
||||
### Changed
|
||||
|
||||
- Valid release note.
|
||||
|
||||
## [1.0.0] - 2026-07-01
|
||||
|
||||
### Added
|
||||
|
||||
- Initial release.
|
||||
`
|
||||
tests := []struct {
|
||||
name string
|
||||
changelog string
|
||||
mutate func(*testing.T, *changelogGateRepo)
|
||||
wantOutput string
|
||||
}{
|
||||
{
|
||||
name: "second changed file",
|
||||
changelog: validRelease,
|
||||
mutate: func(t *testing.T, repo *changelogGateRepo) {
|
||||
changelogGateWrite(t, repo.root, "extra.txt", "extra\n", 0o644)
|
||||
},
|
||||
wantOutput: "exactly one in-place modification",
|
||||
},
|
||||
{
|
||||
name: "invalid calendar date",
|
||||
changelog: strings.Replace(
|
||||
validRelease,
|
||||
"2026-07-17",
|
||||
"2026-02-30",
|
||||
1,
|
||||
),
|
||||
wantOutput: "invalid calendar date",
|
||||
},
|
||||
{
|
||||
name: "duplicate release heading",
|
||||
changelog: strings.Replace(
|
||||
validRelease,
|
||||
"## [1.0.0] - 2026-07-01",
|
||||
"## [1.0.1-beta.1] - 2026-07-17\n\n- Duplicate.\n\n## [1.0.0] - 2026-07-01",
|
||||
1,
|
||||
),
|
||||
wantOutput: "exactly one well-formed section",
|
||||
},
|
||||
{
|
||||
name: "missing bullet",
|
||||
changelog: strings.Replace(
|
||||
validRelease,
|
||||
"- Valid release note.",
|
||||
"Valid release note.",
|
||||
1,
|
||||
),
|
||||
wantOutput: "at least one bullet",
|
||||
},
|
||||
{
|
||||
name: "placeholder",
|
||||
changelog: strings.Replace(
|
||||
validRelease,
|
||||
"- Valid release note.",
|
||||
"- TODO: write release notes.",
|
||||
1,
|
||||
),
|
||||
wantOutput: "must not contain TODO/TBD",
|
||||
},
|
||||
{
|
||||
name: "malformed duplicate unreleased heading",
|
||||
changelog: strings.Replace(
|
||||
validRelease,
|
||||
"## [Unreleased]",
|
||||
"## [Unreleased]\n\n## [Unreleased] junk",
|
||||
1,
|
||||
),
|
||||
wantOutput: "exactly one heading",
|
||||
},
|
||||
{
|
||||
name: "preamble change",
|
||||
changelog: strings.Replace(
|
||||
validRelease,
|
||||
"# Changelog",
|
||||
"# Release history",
|
||||
1,
|
||||
),
|
||||
wantOutput: "only permits notes inside",
|
||||
},
|
||||
{
|
||||
name: "rename changelog",
|
||||
changelog: changelogGateBase,
|
||||
mutate: func(t *testing.T, repo *changelogGateRepo) {
|
||||
if err := os.Rename(
|
||||
filepath.Join(repo.root, "CHANGELOG.md"),
|
||||
filepath.Join(repo.root, "CHANGES.md"),
|
||||
); err != nil {
|
||||
t.Fatalf("Rename CHANGELOG.md error = %v", err)
|
||||
}
|
||||
},
|
||||
wantOutput: "regular 100644 blob at head",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
repo := newChangelogGateRepo(t)
|
||||
changelogGateWrite(t, repo.root, "CHANGELOG.md", test.changelog, 0o644)
|
||||
if test.mutate != nil {
|
||||
test.mutate(t, repo)
|
||||
}
|
||||
repo.commit(t, test.name)
|
||||
|
||||
output, err := repo.run(t)
|
||||
if err == nil {
|
||||
t.Fatalf("unsafe change unexpectedly passed:\n%s", output)
|
||||
}
|
||||
if !strings.Contains(output, test.wantOutput) {
|
||||
t.Fatalf("gate output missing %q:\n%s", test.wantOutput, output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,18 @@ var expectedPackagedSkillTargets = []string{
|
||||
".hermes/skills/dws",
|
||||
}
|
||||
|
||||
var expectedReleaseAdmissionContexts = []string{
|
||||
"Lint",
|
||||
"Test",
|
||||
"Coverage",
|
||||
"Policy",
|
||||
"Edition",
|
||||
"Interface Integrity",
|
||||
"AI Behavior",
|
||||
"CLI Smoke",
|
||||
"Mock MCP",
|
||||
}
|
||||
|
||||
func seedDistArchive(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
file, err := os.Create(path)
|
||||
@@ -630,12 +642,16 @@ func TestReleaseWorkflowUsesDedicatedGovernanceIdentity(t *testing.T) {
|
||||
|
||||
const (
|
||||
checksCall = "github.rest.checks.listForRef"
|
||||
paginatedCall = "github.paginate(github.rest.checks.listForRef"
|
||||
immutableCall = `"GET /repos/{owner}/{repo}/immutable-releases"`
|
||||
governanceID = `github-token: ${{ secrets.RELEASE_GOVERNANCE_TOKEN }}`
|
||||
)
|
||||
if got := strings.Count(workflow, checksCall); got != 2 {
|
||||
t.Fatalf("release workflow Checks API call count = %d, want one tag check and one preflight check", got)
|
||||
}
|
||||
if got := strings.Count(workflow, paginatedCall); got != 2 {
|
||||
t.Fatalf("release workflow paginated Checks API call count = %d, want one tag check and one preflight check", got)
|
||||
}
|
||||
if got := strings.Count(workflow, immutableCall); got != 2 {
|
||||
t.Fatalf("release workflow immutable governance call count = %d, want one tag check and one preflight check", got)
|
||||
}
|
||||
@@ -651,6 +667,12 @@ func TestReleaseWorkflowUsesDedicatedGovernanceIdentity(t *testing.T) {
|
||||
for _, required := range []string{
|
||||
"checks: read",
|
||||
checksCall,
|
||||
paginatedCall,
|
||||
"run.head_sha !== sha",
|
||||
"const missing = requiredContexts.filter",
|
||||
"const nonSuccess = requiredContexts.flatMap",
|
||||
"missing:",
|
||||
"non-success:",
|
||||
immutableCall,
|
||||
governanceID,
|
||||
"RELEASE_GOVERNANCE_TOKEN with repository Administration read permission is required",
|
||||
@@ -659,6 +681,14 @@ func TestReleaseWorkflowUsesDedicatedGovernanceIdentity(t *testing.T) {
|
||||
t.Errorf("%s governance path is missing %q", name, required)
|
||||
}
|
||||
}
|
||||
for _, context := range expectedReleaseAdmissionContexts {
|
||||
if !strings.Contains(section, fmt.Sprintf("%q", context)) {
|
||||
t.Errorf("%s governance path is missing exact Code Admission context %q", name, context)
|
||||
}
|
||||
}
|
||||
if strings.Contains(section, "check_name:") {
|
||||
t.Errorf("%s governance path must fetch all check runs in one exact-SHA query", name)
|
||||
}
|
||||
if strings.Contains(section, "contents: write") {
|
||||
t.Errorf("%s governance path must not grant contents write permission", name)
|
||||
}
|
||||
@@ -666,6 +696,51 @@ func TestReleaseWorkflowUsesDedicatedGovernanceIdentity(t *testing.T) {
|
||||
t.Errorf("%s immutable governance path must not fall back to GITHUB_TOKEN", name)
|
||||
}
|
||||
}
|
||||
if strings.Contains(workflow, "CI"+" Gate") {
|
||||
t.Error("release workflow must not retain the retired aggregate gate name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseScriptRequiresExactCodeAdmissionContexts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "release.sh"))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(release.sh) error = %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(scriptPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(%s) error = %v", scriptPath, err)
|
||||
}
|
||||
script := string(data)
|
||||
|
||||
const checksQuery = `commits/$sealed_commit/check-runs?filter=latest&per_page=100`
|
||||
if got := strings.Count(script, checksQuery); got != 1 {
|
||||
t.Fatalf("release script exact-SHA Checks API query count = %d, want 1", got)
|
||||
}
|
||||
for _, required := range []string{
|
||||
`group_by(.name) | map(max_by(.id))`,
|
||||
`missing_contexts=""`,
|
||||
`non_success_contexts=""`,
|
||||
`"$context_state" != "success"`,
|
||||
"Code Admission contexts are not all successful for sealed commit",
|
||||
"missing: %s; non-success: %s",
|
||||
} {
|
||||
if !strings.Contains(script, required) {
|
||||
t.Errorf("release script Code Admission gate is missing %q", required)
|
||||
}
|
||||
}
|
||||
for _, context := range expectedReleaseAdmissionContexts {
|
||||
if !strings.Contains(script, "\n"+context+"\n") {
|
||||
t.Errorf("release script is missing exact Code Admission context %q", context)
|
||||
}
|
||||
}
|
||||
if strings.Contains(script, "check_name=") {
|
||||
t.Error("release script must fetch all check runs in one exact-SHA query")
|
||||
}
|
||||
if strings.Contains(script, "CI"+" Gate") {
|
||||
t.Error("release script must not retain the retired aggregate gate name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseWorkflowGovernancePreflightCannotPublish(t *testing.T) {
|
||||
@@ -703,10 +778,10 @@ func TestReleaseWorkflowGovernancePreflightCannotPublish(t *testing.T) {
|
||||
t.Errorf("governance preflight must not contain publishing behavior %q", forbidden)
|
||||
}
|
||||
}
|
||||
ciGate := strings.Index(preflight, "Require successful CI Gate on the preflight commit")
|
||||
admission := strings.Index(preflight, "Require successful Code Admission contexts on the preflight commit")
|
||||
homebrewCanary := strings.Index(preflight, "Verify Homebrew PR automation permission")
|
||||
if ciGate == -1 || homebrewCanary == -1 || ciGate > homebrewCanary {
|
||||
t.Error("governance preflight must validate the exact CI Gate before exposing Homebrew credentials")
|
||||
if admission == -1 || homebrewCanary == -1 || admission > homebrewCanary {
|
||||
t.Error("governance preflight must validate all exact Code Admission contexts before exposing Homebrew credentials")
|
||||
}
|
||||
|
||||
mirror := releaseWorkflowSection(t, workflow, " mirror-gitee-release:\n", "\n repair-npm:\n")
|
||||
@@ -724,6 +799,86 @@ func TestReleaseWorkflowGovernancePreflightCannotPublish(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseWorkflowGiteeRepairUsesSealedReleaseAuthority(t *testing.T) {
|
||||
t.Parallel()
|
||||
workflow := readReleaseWorkflow(t)
|
||||
dispatch := releaseWorkflowSection(t, workflow, " dispatch-contract:\n", "\n authorize-recovery:\n")
|
||||
start := strings.Index(workflow, " repair-gitee:\n")
|
||||
if start == -1 {
|
||||
t.Fatal("release workflow is missing the Gitee repair job")
|
||||
}
|
||||
repair := workflow[start:]
|
||||
|
||||
for _, required := range []string{
|
||||
"repair_gitee_version:",
|
||||
`format('Release Gitee repair {0}', inputs.repair_gitee_version)`,
|
||||
`REPAIR_GITEE_VERSION: ${{ inputs.repair_gitee_version }}`,
|
||||
"gitee_repair=0",
|
||||
`test -z "$REPAIR_GITEE_VERSION" || gitee_repair=1`,
|
||||
"npm_repair + gitee_repair + governance + recovery",
|
||||
`echo "mode=repair_gitee" >> "$GITHUB_OUTPUT"`,
|
||||
} {
|
||||
if !strings.Contains(dispatch, required) && !strings.Contains(workflow, required) {
|
||||
t.Errorf("Gitee repair dispatch contract is missing %q", required)
|
||||
}
|
||||
}
|
||||
|
||||
for _, required := range []string{
|
||||
"needs: dispatch-contract",
|
||||
`if: ${{ !cancelled() && needs.dispatch-contract.result == 'success' && needs.dispatch-contract.outputs.mode == 'repair_gitee'`,
|
||||
`github.ref == format('refs/heads/{0}', github.event.repository.default_branch)`,
|
||||
`github.repository == 'DingTalk-Real-AI/dingtalk-workspace-cli'`,
|
||||
"actions: read",
|
||||
`ref: ${{ github.sha }}`,
|
||||
"path: tooling",
|
||||
"persist-credentials: false",
|
||||
"release_is_stable_version",
|
||||
"release_is_prerelease_version",
|
||||
`ref: ` + "`tags/${version}`",
|
||||
`["ahead", "identical"].includes(comparison.data.status)`,
|
||||
"!release.data.immutable",
|
||||
`release.data.prerelease !== expectedPrerelease`,
|
||||
"assetNames.length !== expectedAssets.size",
|
||||
"new Set(assetNames).size !== expectedAssets.size",
|
||||
`core.setOutput("tag_object", ref.data.object.sha)`,
|
||||
"verify-github-tag-authority.sh",
|
||||
"verify-release-workflow-delivery.sh",
|
||||
`DWS_RELEASE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}`,
|
||||
"verify-release-artifacts.sh",
|
||||
`--repo "$GITHUB_REPOSITORY"`,
|
||||
`DWS_REQUIRE_GITEE: "1"`,
|
||||
"working-directory: tooling\n run: |\n " +
|
||||
`"$GITHUB_WORKSPACE/tooling/scripts/release/sync-to-gitee.sh"`,
|
||||
} {
|
||||
if !strings.Contains(repair, required) {
|
||||
t.Errorf("Gitee repair authority is missing %q", required)
|
||||
}
|
||||
}
|
||||
for _, asset := range []string{
|
||||
"dws-darwin-amd64.tar.gz",
|
||||
"dws-darwin-arm64.tar.gz",
|
||||
"dws-linux-amd64.tar.gz",
|
||||
"dws-linux-arm64.tar.gz",
|
||||
"dws-windows-amd64.zip",
|
||||
"dws-windows-arm64.zip",
|
||||
"dws-skills.zip",
|
||||
"checksums.txt",
|
||||
} {
|
||||
if strings.Count(repair, `"`+asset+`"`) != 1 {
|
||||
t.Errorf("Gitee repair must require exactly one %s asset declaration", asset)
|
||||
}
|
||||
}
|
||||
if strings.Contains(repair, "contents: write") {
|
||||
t.Error("Gitee repair must not grant contents write permission")
|
||||
}
|
||||
if strings.Contains(repair, "ref: ${{ github.event.repository.default_branch }}") {
|
||||
t.Error("Gitee repair must not check out a floating default branch")
|
||||
}
|
||||
if got := strings.Count(repair, "working-directory: tooling"); got < 4 {
|
||||
t.Errorf("Gitee repair trusted tooling working-directory count = %d, want at least 4", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseWorkflowRecoveryReusesGuardedJobs(t *testing.T) {
|
||||
t.Parallel()
|
||||
workflow := readReleaseWorkflow(t)
|
||||
@@ -759,7 +914,8 @@ func TestReleaseWorkflowRecoveryReusesGuardedJobs(t *testing.T) {
|
||||
"dws-release-recovery run=%s tag-object=%s commit=%s",
|
||||
"Public release is not bound to this exact recovery run.",
|
||||
"Public recovery asset differs from this run's sealed artifact",
|
||||
`ref: process.env.RELEASE_COMMIT`,
|
||||
`const sha = process.env.RELEASE_COMMIT`,
|
||||
`ref: sha`,
|
||||
`path: tmp/trusted-release-tooling`,
|
||||
`ref: ${{ github.sha }}`,
|
||||
`step.name === "Require immutable published GitHub Release"`,
|
||||
@@ -978,11 +1134,7 @@ func TestReleaseWorkflowPublicationBypassesSkippedDispatchButStopsOnCancellation
|
||||
func TestReleaseWorkflowDeliveryGateFailsClosed(t *testing.T) {
|
||||
t.Parallel()
|
||||
workflow := readReleaseWorkflow(t)
|
||||
start := strings.Index(workflow, " release-delivery-gate:\n")
|
||||
if start == -1 {
|
||||
t.Fatal("release workflow is missing the terminal delivery gate")
|
||||
}
|
||||
gate := workflow[start:]
|
||||
gate := releaseWorkflowSection(t, workflow, " release-delivery-gate:\n", "\n repair-gitee:\n")
|
||||
|
||||
for _, required := range []string{
|
||||
"name: Release delivery gate",
|
||||
@@ -997,6 +1149,8 @@ func TestReleaseWorkflowDeliveryGateFailsClosed(t *testing.T) {
|
||||
"- publish-channels",
|
||||
"- mirror-gitee-release",
|
||||
"- repair-npm",
|
||||
"- repair-gitee",
|
||||
`REPAIR_GITEE_RESULT: ${{ needs.repair-gitee.result }}`,
|
||||
"require_publication",
|
||||
`require_result release-contract "$RELEASE_CONTRACT_RESULT" success`,
|
||||
`require_result release "$RELEASE_RESULT" success`,
|
||||
@@ -1006,6 +1160,9 @@ func TestReleaseWorkflowDeliveryGateFailsClosed(t *testing.T) {
|
||||
"workflow_dispatch:recover_release",
|
||||
"workflow_dispatch:governance_preflight",
|
||||
"workflow_dispatch:repair_npm",
|
||||
"workflow_dispatch:repair_gitee",
|
||||
`require_result repair-gitee "$REPAIR_GITEE_RESULT" success`,
|
||||
`require_result repair-gitee "$REPAIR_GITEE_RESULT" skipped`,
|
||||
"unsupported release mode",
|
||||
} {
|
||||
if !strings.Contains(gate, required) {
|
||||
@@ -1254,10 +1411,10 @@ func TestReleaseWorkflowOpensHomebrewPROnlyForOfficialStableTags(t *testing.T) {
|
||||
t.Errorf("Homebrew PR permission preflight count = %d, want one default-branch preflight and one tag contract", got)
|
||||
}
|
||||
tagContract := releaseWorkflowSection(t, workflow, " release-contract:\n", "\n release:\n")
|
||||
tagCI := strings.Index(tagContract, "Require successful CI Gate on the sealed commit")
|
||||
tagAdmission := strings.Index(tagContract, "Require successful Code Admission contexts on the sealed commit")
|
||||
tagHomebrew := strings.Index(tagContract, "Verify Homebrew PR automation permission")
|
||||
if tagCI == -1 || tagHomebrew == -1 || tagCI > tagHomebrew {
|
||||
t.Error("tag contract must validate the sealed CI Gate before exposing Homebrew credentials")
|
||||
if tagAdmission == -1 || tagHomebrew == -1 || tagAdmission > tagHomebrew {
|
||||
t.Error("tag contract must validate all exact Code Admission contexts before exposing Homebrew credentials")
|
||||
}
|
||||
|
||||
start := strings.Index(workflow, "- name: Open stable Homebrew formula PR")
|
||||
|
||||
Reference in New Issue
Block a user