Compare commits

...
Author SHA1 Message Date
SCzheng f17aee32c1 Revert "fix(auth): unify token resolution and recover rejected tokens" 2026-07-20 16:07:48 +09:00
修雨 e867abd03c Merge pull request #687 from shangguanxuan633-lab/codex/auth-token-manager-complete
fix(auth): unify token resolution and recover rejected tokens
2026-07-20 15:07:07 +08:00
修雨 9d89965de9 Merge branch 'main' into codex/auth-token-manager-complete 2026-07-20 14:53:06 +08:00
修雨 41088bb965 fix(release): derive OSS_REGION for ossutil v2 V4 signing (#692)
ossutil 2.x signs requests with V4 and refuses to run without an
explicit region, so the OSS mirror sync would fail in CI even with
valid credentials. Derive OSS_REGION from the endpoint host
(including -internal variants) and fail fast when it cannot be
derived.
2026-07-20 14:51:41 +08:00
修雨 8259116f15 test(auth): isolate Windows keychain packages 2026-07-20 14:33:17 +08:00
上官玄 9ec1fa0638 test(auth): use synthetic log redaction sentinel 2026-07-20 14:22:18 +08:00
修雨 9afd3be79b Merge branch 'main' into codex/auth-token-manager-complete 2026-07-20 14:15:48 +08:00
修雨 67da5019e3 Merge pull request #689 from DingTalk-Real-AI/codex/fix-beta4-channel-repair
fix(release): recover immutable mirror channels safely
2026-07-20 14:07:54 +08:00
shangguanxuan.sgx b0ded7deb8 fix(auth): retry rejected access tokens safely 2026-07-20 13:37:18 +08:00
shangguanxuan.sgx 22905fc41e fix(auth): unify access token resolution 2026-07-20 12:17:04 +08:00
修雨 ec9ff653fc fix(release): recover immutable mirror channels safely 2026-07-20 11:35:32 +08:00
修雨 876cf8e958 Merge pull request #685 from shangguanxuan633-lab/codex/fix-oauth-coverage-fixture-isolation-20260720
test(auth): isolate OAuth coverage fixtures
2026-07-20 10:41:05 +08:00
修雨 1c5ed6646e Merge branch 'main' into codex/fix-oauth-coverage-fixture-isolation-20260720 2026-07-20 09:57:51 +08:00
修雨 80549a80e0 Merge pull request #665 from DingTalk-Real-AI/agent/changelog-fast-path
ci: align Code Admission gates and trusted changelog fast path
2026-07-20 09:34:43 +08:00
上官玄 883d416d83 test(auth): isolate OAuth coverage fixtures 2026-07-20 07:29:24 +08:00
修雨 25c70aeb24 ci: align admission gates and changelog fast path 2026-07-19 23:59:48 +08:00
修雨 6cfa9e3afb ci: fast-path changelog-only pull requests 2026-07-19 23:11:04 +08:00
修雨 544a91e994 Merge pull request #667 from DingTalk-Real-AI/codex/repair-gitee-dispatch
ci(release): add dispatch repair-gitee job to mirror an existing release
2026-07-19 23:01:37 +08:00
修雨 024d487a22 Merge pull request #682 from DingTalk-Real-AI/automation/homebrew-beta-v1.0.53-beta.4
chore: update Homebrew beta formula for v1.0.53-beta.4
2026-07-19 22:52:31 +08:00
DWS Release Bot bc587ddd91 chore: update beta formula for v1.0.53-beta.4 2026-07-19 13:21:07 +00:00
20 changed files with 2631 additions and 487 deletions
+7
View File
@@ -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`
+90 -55
View File
@@ -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
View File
@@ -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"
+5 -4
View File
@@ -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"
+38
View File
@@ -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
+157 -44
View File
@@ -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.event_name == 'workflow_dispatch' && inputs.repair_gitee_version != '' && format('Release Gitee repair {0}', inputs.repair_gitee_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.event_name == 'workflow_dispatch' && inputs.repair_oss_version != '' && format('Release OSS repair {0}', inputs.repair_oss_version) || github.workflow }}
on:
push:
@@ -15,6 +15,10 @@ on:
description: "Mirror an existing public immutable release's assets to Gitee"
required: false
type: string
repair_oss_version:
description: "Mirror an existing public immutable release's assets to OSS"
required: false
type: string
governance_preflight_commit:
description: "Verify release governance for this exact default-branch commit without publishing"
required: false
@@ -75,6 +79,7 @@ jobs:
env:
REPAIR_NPM_VERSION: ${{ inputs.repair_npm_version }}
REPAIR_GITEE_VERSION: ${{ inputs.repair_gitee_version }}
REPAIR_OSS_VERSION: ${{ inputs.repair_oss_version }}
GOVERNANCE_COMMIT: ${{ inputs.governance_preflight_commit }}
GOVERNANCE_NONCE: ${{ inputs.governance_preflight_nonce }}
RECOVER_VERSION: ${{ inputs.recover_release_version }}
@@ -88,17 +93,19 @@ jobs:
set -eu
npm_repair=0
gitee_repair=0
oss_repair=0
governance=0
recovery=0
test -z "$REPAIR_NPM_VERSION" || npm_repair=1
test -z "$REPAIR_GITEE_VERSION" || gitee_repair=1
test -z "$REPAIR_OSS_VERSION" || oss_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 $((npm_repair + gitee_repair + governance + recovery)) -eq 1 || {
test $((npm_repair + gitee_repair + oss_repair + governance + recovery)) -eq 1 || {
echo "workflow_dispatch must select exactly one release mode" >&2
exit 1
}
@@ -106,6 +113,8 @@ jobs:
echo "mode=repair_npm" >> "$GITHUB_OUTPUT"
elif test "$gitee_repair" -eq 1; then
echo "mode=repair_gitee" >> "$GITHUB_OUTPUT"
elif test "$oss_repair" -eq 1; then
echo "mode=repair_oss" >> "$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
@@ -235,26 +244,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
@@ -502,26 +541,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
@@ -721,7 +790,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)
@@ -1818,7 +1887,7 @@ jobs:
- publish-channels
- mirror-gitee-release
- repair-npm
- repair-gitee
- repair-channel
runs-on: ubuntu-latest
timeout-minutes: 2
permissions: {}
@@ -1838,7 +1907,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 }}
REPAIR_CHANNEL_RESULT: ${{ needs.repair-channel.result }}
run: |
set -eu
require_result() {
@@ -1868,7 +1937,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_result repair-channel "$REPAIR_CHANNEL_RESULT" skipped
require_publication
;;
workflow_dispatch:recover_release)
@@ -1876,7 +1945,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_result repair-channel "$REPAIR_CHANNEL_RESULT" skipped
require_publication
;;
workflow_dispatch:governance_preflight)
@@ -1890,7 +1959,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
require_result repair-channel "$REPAIR_CHANNEL_RESULT" skipped
;;
workflow_dispatch:repair_npm)
require_result dispatch-contract "$DISPATCH_RESULT" success
@@ -1903,11 +1972,24 @@ 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
require_result repair-channel "$REPAIR_CHANNEL_RESULT" skipped
;;
workflow_dispatch:repair_gitee)
require_result dispatch-contract "$DISPATCH_RESULT" success
require_result repair-gitee "$REPAIR_GITEE_RESULT" success
require_result repair-channel "$REPAIR_CHANNEL_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
;;
workflow_dispatch:repair_oss)
require_result dispatch-contract "$DISPATCH_RESULT" success
require_result repair-channel "$REPAIR_CHANNEL_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
@@ -1923,16 +2005,16 @@ jobs:
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
# Repair one downstream channel from an existing public immutable release on
# demand. The push-triggered channel jobs depend on the original run's
# finalized dist artifact, so a tag that was already published cannot reuse
# it. This dispatch path re-derives the asset set from the immutable GitHub
# Release itself. The release tag, delivery provenance, and asset checksums
# remain the sealed authority boundary.
repair-channel:
name: Repair an existing immutable release channel
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' }}
if: ${{ !cancelled() && needs.dispatch-contract.result == 'success' && (needs.dispatch-contract.outputs.mode == 'repair_gitee' || needs.dispatch-contract.outputs.mode == 'repair_oss') && 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:
@@ -1950,7 +2032,7 @@ jobs:
- name: Validate repair version
working-directory: tooling
env:
VERSION: ${{ inputs.repair_gitee_version }}
VERSION: ${{ inputs.repair_gitee_version || inputs.repair_oss_version }}
run: |
set -eu
. ./scripts/release/release-lib.sh
@@ -1963,7 +2045,7 @@ jobs:
id: authority
uses: actions/github-script@v7
env:
VERSION: ${{ inputs.repair_gitee_version }}
VERSION: ${{ inputs.repair_gitee_version || inputs.repair_oss_version }}
with:
script: |
const { owner, repo } = context.repo;
@@ -2042,12 +2124,20 @@ jobs:
core.setOutput("commit_sha", commitSha);
core.setOutput("tag_object", ref.data.object.sha);
- name: Check out sealed release source
uses: actions/checkout@v4
with:
ref: ${{ steps.authority.outputs.commit_sha }}
path: release-source
persist-credentials: false
- name: Fetch and verify sealed release tag
working-directory: tooling
env:
VERSION: ${{ inputs.repair_gitee_version }}
VERSION: ${{ inputs.repair_gitee_version || inputs.repair_oss_version }}
RELEASE_COMMIT: ${{ steps.authority.outputs.commit_sha }}
RELEASE_TAG_OBJECT: ${{ steps.authority.outputs.tag_object }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -eu
git fetch --force --no-tags \
@@ -2059,17 +2149,23 @@ jobs:
- name: Require successful Release workflow delivery
working-directory: tooling
env:
VERSION: ${{ inputs.repair_gitee_version }}
VERSION: ${{ inputs.repair_gitee_version || inputs.repair_oss_version }}
RELEASE_COMMIT: ${{ steps.authority.outputs.commit_sha }}
REPAIR_MODE: ${{ needs.dispatch-contract.outputs.mode }}
DWS_RELEASE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
case "$REPAIR_MODE" in
repair_gitee) target=gitee ;;
repair_oss) target=oss ;;
*) echo "Unsupported channel repair mode: $REPAIR_MODE" >&2; exit 2 ;;
esac
./scripts/release/verify-release-workflow-delivery.sh \
"$VERSION" "$RELEASE_COMMIT"
--channel-repair "$target" "$VERSION" "$RELEASE_COMMIT"
- name: Download and verify immutable GitHub Release assets
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ inputs.repair_gitee_version }}
VERSION: ${{ inputs.repair_gitee_version || inputs.repair_oss_version }}
run: |
set -eu
mkdir -p dist
@@ -2083,14 +2179,31 @@ jobs:
"$GITHUB_WORKSPACE/tooling/scripts/release/verify-release-artifacts.sh" "$VERSION"
- name: Mirror release to Gitee (China)
if: ${{ needs.dispatch-contract.outputs.mode == 'repair_gitee' }}
timeout-minutes: 100
working-directory: tooling
run: |
"$GITHUB_WORKSPACE/tooling/scripts/release/sync-to-gitee.sh"
env:
VERSION: ${{ inputs.repair_gitee_version }}
VERSION: ${{ inputs.repair_gitee_version || inputs.repair_oss_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"
- name: Mirror release to OSS (China)
if: ${{ needs.dispatch-contract.outputs.mode == 'repair_oss' }}
timeout-minutes: 30
working-directory: release-source
run: |
"$GITHUB_WORKSPACE/tooling/scripts/release/sync-to-oss.sh"
env:
VERSION: ${{ inputs.repair_gitee_version || inputs.repair_oss_version }}
DIST_DIR: ${{ github.workspace }}/dist
OSS_ACCESS_KEY_ID: ${{ secrets.OSS_ACCESS_KEY_ID }}
OSS_ACCESS_KEY_SECRET: ${{ secrets.OSS_ACCESS_KEY_SECRET }}
OSS_ENDPOINT: ${{ secrets.OSS_ENDPOINT }}
OSS_BUCKET: ${{ secrets.OSS_BUCKET }}
OSS_PREFIX: ${{ secrets.OSS_PREFIX }}
DWS_REQUIRE_OSS: "1"
+11 -11
View File
@@ -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
+35
View File
@@ -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.
+16
View File
@@ -108,6 +108,22 @@ commit, and failed tag-push run all match; it then reuses the normal release
jobs. Do not put publication secrets in temporary branches or create ad-hoc
recovery workflows.
If the immutable GitHub Release and npm package were delivered but a downstream
China mirror failed, dispatch the normal `Release` workflow from the protected
default branch with exactly one of `repair_gitee_version` or
`repair_oss_version`. Channel repair accepts a failed exact-tag run only when
its latest attempt completed the release contract, build, Apple signature,
immutable GitHub publication, and npm delivery checks for the exact tagged
commit. It then downloads and re-verifies the immutable assets before invoking
only the selected mirror. An OSS repair requires the OSS step itself to be the
recorded failure. A Gitee repair accepts either a failed Gitee job or a Gitee
job that was skipped behind that OSS failure; the latter is an explicit Gitee
backfill and does not claim that OSS has been repaired. Gitee repair requires
`GITEE_TOKEN`, `GITEE_USER`, and `GITEE_REPO`; OSS repair requires
`OSS_ACCESS_KEY_ID`, `OSS_ACCESS_KEY_SECRET`, `OSS_ENDPOINT`, and `OSS_BUCKET`
(with optional `OSS_PREFIX`) as Actions secrets. Missing credentials fail the
selected repair closed.
## Handoff Checklist
Before handoff, include:
+127 -108
View File
@@ -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.
+14 -5
View File
@@ -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
@@ -97,7 +97,16 @@ dws-release v1.2.3 --from-beta v1.2.3-beta.1 --publish
npm 补发只允许从默认分支触发 Release workflow 的 `repair_npm_version`。它只支持启用 immutable releases 后、由本流水线成功产出的公开 immutable release:目标必须是 `main` 历史中的 annotated tag,并且同 commit 的 `Build immutable GitHub Release` job 已成功。即使后续 npm 分发失败,这个独立的产物封存边界仍可作为补发依据。补发会用目标 commit 的 npm 模板重组包,逐平台核验资产和二进制版本,再发布到隔离的 `backfill` dist-tag,不会回滚 `latest` / `beta`。历史 mutable release 不进入自动补发路径,避免把可被替换的资产带入 npm。
OSS/Gitee 分发失败时直接重跑该 tag 的 `Publish npm and mirrors` failed job;各步会复用 immutable GitHub 资产并保持 channel 单调。独立 Gitee release workflow 和本地直发脚本已停用,避免绕开 publication queue 或用重新构建的不同字节覆盖镜像。
OSS/Gitee 分发失败且 GitHub immutable Release、npm 已交付时,从受保护的默认分支触发
Release workflow,并且只填写 `repair_oss_version` 或 `repair_gitee_version` 之一。channel
repair 会精确绑定失败 tag run 的最新 attempt;contract、构建、Developer ID 签名、
immutable GitHub 发布和 npm delivery 必须全部成功,且只能有一个 OSS/Gitee 下游失败,
随后才会下载并重新校验原始资产、修复所选镜像。OSS repair 必须匹配失败的 OSS step;
Gitee repair 还允许其 job 因该 OSS 失败而 skipped,此时只代表 Gitee backfill 成功,
不会把仍未修复的 OSS 标成成功。该证据不能用于 beta → stable 或
stable baseline,后两者仍要求整条 Release 成功或受保护 recovery 成功。不要重跑旧
attempt 的单个 failed job,以免在 attempts 之间拼接交付证据。独立 Gitee release
workflow 和本地直发脚本已停用,避免绕开 publication queue 或用重新构建的不同字节覆盖镜像。
## 既有 tag 的紧急恢复
@@ -125,10 +134,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 可能来自组织层,脚本不自动推断其最终作用范围;管理员确认不能省略,脚本约定也不能替代平台强制。
+168 -116
View File
@@ -18,6 +18,8 @@ import (
"sync/atomic"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
)
type oauthLoginFixture struct {
@@ -33,8 +35,79 @@ type oauthLoginFixture struct {
exchangeError atomic.Bool
}
type oauthLoginResult struct {
token *TokenData
err error
}
type oauthHTTPResult struct {
status int
body string
err error
}
const oauthTestWaitTimeout = 5 * time.Second
func isolateOAuthPersistence(t *testing.T) {
t.Helper()
t.Setenv(keychain.DisableKeychainEnv, "1")
cleanupKeychain(t)
}
func startOAuthLogin(t *testing.T, parent context.Context, f *oauthLoginFixture) <-chan oauthLoginResult {
t.Helper()
ctx, cancel := context.WithCancel(parent)
done := make(chan oauthLoginResult, 1)
finished := make(chan struct{})
go func() {
defer close(finished)
token, err := f.provider.Login(ctx, true)
done <- oauthLoginResult{token: token, err: err}
}()
t.Cleanup(func() {
cancel()
select {
case <-finished:
case <-time.After(oauthTestWaitTimeout):
t.Errorf("OAuth Login goroutine did not stop after cancellation")
}
})
return done
}
func awaitOAuthLogin(t *testing.T, done <-chan oauthLoginResult) oauthLoginResult {
t.Helper()
select {
case result := <-done:
return result
case <-time.After(oauthTestWaitTimeout):
t.Fatal("timed out waiting for OAuth Login")
return oauthLoginResult{}
}
}
func waitOAuthSignal(t *testing.T, signal <-chan struct{}, done <-chan oauthLoginResult, name string) {
t.Helper()
select {
case <-signal:
case result := <-done:
t.Fatalf("OAuth Login returned before %s: token=%#v err=%v", name, result.token, result.err)
case <-time.After(oauthTestWaitTimeout):
t.Fatalf("timed out waiting for OAuth %s", name)
}
}
func closeOAuthRelease(ch chan struct{}) {
select {
case <-ch:
default:
close(ch)
}
}
func newOAuthLoginFixture(t *testing.T, status func(int32) CLIAuthStatus) *oauthLoginFixture {
t.Helper()
isolateOAuthPersistence(t)
SetClientID("")
SetClientSecret("")
resetClientIDFromMCP()
@@ -91,6 +164,10 @@ func newOAuthLoginFixture(t *testing.T, status func(int32) CLIAuthStatus) *oauth
}
}))
t.Cleanup(f.server.Close)
t.Cleanup(func() {
closeOAuthRelease(f.exchangeRelease)
closeOAuthRelease(f.statusRelease)
})
f.configDir = setupMCPConfigDir(t, f.server.URL)
oldClient := oauthHTTPClient
@@ -118,17 +195,25 @@ func newOAuthLoginFixture(t *testing.T, status func(int32) CLIAuthStatus) *oauth
func httpGetBody(t *testing.T, rawURL string) (int, string) {
t.Helper()
result := getHTTPBody(rawURL)
if result.err != nil {
t.Fatalf("GET %s: %v", rawURL, result.err)
}
return result.status, result.body
}
func getHTTPBody(rawURL string) oauthHTTPResult {
client := &http.Client{Timeout: 2 * time.Second}
resp, err := client.Get(rawURL)
if err != nil {
t.Fatalf("GET %s: %v", rawURL, err)
return oauthHTTPResult{err: err}
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
return oauthHTTPResult{err: err}
}
return resp.StatusCode, string(data)
return oauthHTTPResult{status: resp.StatusCode, body: string(data)}
}
func TestCrossPlatformCoverageOAuthLoginCallbackAndAPIs(t *testing.T) {
@@ -136,17 +221,7 @@ func TestCrossPlatformCoverageOAuthLoginCallbackAndAPIs(t *testing.T) {
return CLIAuthStatus{Success: true, Result: &CLIAuthResult{CLIAuthEnabled: true}}
})
loginDone := make(chan struct {
token *TokenData
err error
}, 1)
go func() {
token, err := f.provider.Login(context.Background(), true)
loginDone <- struct {
token *TokenData
err error
}{token, err}
}()
loginDone := startOAuthLogin(t, context.Background(), f)
for _, path := range []string{"/api/superAdmin", "/api/sendApply?adminStaffId=admin-1", "/api/cliAuthEnabled"} {
_, body := httpGetBody(t, f.callbackBase+path)
@@ -166,18 +241,17 @@ func TestCrossPlatformCoverageOAuthLoginCallbackAndAPIs(t *testing.T) {
t.Fatalf("success page = %q", body)
}
callbackDone := make(chan string, 1)
callbackDone := make(chan oauthHTTPResult, 1)
go func() {
_, callbackBody := httpGetBody(t, f.callbackBase+CallbackPath+"?code=good")
callbackDone <- callbackBody
callbackDone <- getHTTPBody(f.callbackBase + CallbackPath + "?code=good")
}()
<-f.exchangeEntered
waitOAuthSignal(t, f.exchangeEntered, loginDone, "token exchange")
_, body = httpGetBody(t, f.callbackBase+CallbackPath+"?authCode=good")
if !strings.Contains(body, "正在处理授权") {
t.Fatalf("concurrent callback = %q", body)
}
close(f.exchangeRelease)
<-f.statusEntered
closeOAuthRelease(f.exchangeRelease)
waitOAuthSignal(t, f.statusEntered, loginDone, "CLI auth status check")
_, body = httpGetBody(t, f.callbackBase+CallbackPath+"?code=good")
if !strings.Contains(body, "<html") {
@@ -204,11 +278,16 @@ func TestCrossPlatformCoverageOAuthLoginCallbackAndAPIs(t *testing.T) {
t.Fatalf("auth enabled API = %q", body)
}
close(f.statusRelease)
if callbackBody := <-callbackDone; !strings.Contains(callbackBody, "<html") {
t.Fatalf("callback body = %q", callbackBody)
closeOAuthRelease(f.statusRelease)
select {
case callback := <-callbackDone:
if callback.err != nil || !strings.Contains(callback.body, "<html") {
t.Fatalf("callback body = %q, %v", callback.body, callback.err)
}
case <-time.After(oauthTestWaitTimeout):
t.Fatal("timed out waiting for OAuth callback")
}
result := <-loginDone
result := awaitOAuthLogin(t, loginDone)
if result.err != nil || result.token == nil || result.token.AccessToken != "access" {
t.Fatalf("Login = %#v, %v", result.token, result.err)
}
@@ -228,23 +307,20 @@ func TestCrossPlatformCoverageOAuthLoginMissingCallbackCode(t *testing.T) {
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus {
return CLIAuthStatus{Success: true, Result: &CLIAuthResult{CLIAuthEnabled: true}}
})
close(f.exchangeRelease)
close(f.statusRelease)
done := make(chan error, 1)
go func() {
_, err := f.provider.Login(context.Background(), true)
done <- err
}()
closeOAuthRelease(f.exchangeRelease)
closeOAuthRelease(f.statusRelease)
done := startOAuthLogin(t, context.Background(), f)
status, body := httpGetBody(t, f.callbackBase+CallbackPath)
if status != http.StatusBadRequest || strings.TrimSpace(body) == "" {
t.Fatalf("missing callback = %d %q", status, body)
}
if err := <-done; err == nil {
if result := awaitOAuthLogin(t, done); result.err == nil {
t.Fatal("missing callback code did not fail login")
}
}
func TestCrossPlatformCoverageOAuthLoginEarlyAndListenerEdges(t *testing.T) {
isolateOAuthPersistence(t)
var buf bytes.Buffer
p := &OAuthProvider{configDir: t.TempDir(), Output: &buf}
if p.output() != &buf || (*OAuthProvider)(nil).output() != io.Discard {
@@ -311,6 +387,7 @@ func TestCrossPlatformCoverageOAuthLoginTimeoutAndServerError(t *testing.T) {
}
func TestCrossPlatformCoverageOAuthProviderOtherMethods(t *testing.T) {
isolateOAuthPersistence(t)
dir := t.TempDir()
_ = DeleteTokenDataKeychain()
t.Cleanup(func() { _ = DeleteTokenDataKeychain() })
@@ -363,6 +440,7 @@ func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) {
}
func TestCrossPlatformCoverageOAuthPersistConfigEdges(t *testing.T) {
isolateOAuthPersistence(t)
p := &OAuthProvider{configDir: t.TempDir(), logger: slog.Default()}
SetClientID("")
SetClientSecret("")
@@ -425,16 +503,12 @@ func TestCrossPlatformCoverageOAuthLoginDenialAndPolling(t *testing.T) {
for _, tt := range terminal {
t.Run(tt.name, func(t *testing.T) {
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus { return tt.status })
close(f.exchangeRelease)
close(f.statusRelease)
closeOAuthRelease(f.exchangeRelease)
closeOAuthRelease(f.statusRelease)
f.provider.NoBrowser = false
done := make(chan error, 1)
go func() {
_, err := f.provider.Login(context.Background(), true)
done <- err
}()
done := startOAuthLogin(t, context.Background(), f)
_, _ = httpGetBody(t, f.callbackBase+CallbackPath+"?code=denied")
if err := <-done; err == nil {
if result := awaitOAuthLogin(t, done); result.err == nil {
t.Fatal("denied login succeeded")
}
})
@@ -445,15 +519,11 @@ func TestCrossPlatformCoverageOAuthLoginDenialAndPolling(t *testing.T) {
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus {
return CLIAuthStatus{Success: true, Result: &CLIAuthResult{ChannelScope: "specified", AllowedChannels: []string{"allowed"}}}
})
close(f.exchangeRelease)
close(f.statusRelease)
done := make(chan error, 1)
go func() {
_, err := f.provider.Login(context.Background(), true)
done <- err
}()
closeOAuthRelease(f.exchangeRelease)
closeOAuthRelease(f.statusRelease)
done := startOAuthLogin(t, context.Background(), f)
_, _ = httpGetBody(t, f.callbackBase+CallbackPath+"?code=denied")
if err := <-done; err == nil {
if result := awaitOAuthLogin(t, done); result.err == nil {
t.Fatal("channel-denied login succeeded")
}
})
@@ -463,15 +533,11 @@ func TestCrossPlatformCoverageOAuthLoginDenialAndPolling(t *testing.T) {
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus {
return CLIAuthStatus{Success: true, Result: &CLIAuthResult{}}
})
close(f.exchangeRelease)
close(f.statusRelease)
done := make(chan error, 1)
go func() {
_, err := f.provider.Login(context.Background(), true)
done <- err
}()
closeOAuthRelease(f.exchangeRelease)
closeOAuthRelease(f.statusRelease)
done := startOAuthLogin(t, context.Background(), f)
_, _ = httpGetBody(t, f.callbackBase+CallbackPath+"?code=pending")
if err := <-done; err == nil {
if result := awaitOAuthLogin(t, done); result.err == nil {
t.Fatal("approval timeout login succeeded")
}
oauthApprovalTimeout = oldApproval
@@ -481,16 +547,12 @@ func TestCrossPlatformCoverageOAuthLoginDenialAndPolling(t *testing.T) {
f := newOAuthLoginFixture(t, func(call int32) CLIAuthStatus {
return CLIAuthStatus{Success: true, Result: &CLIAuthResult{CLIAuthEnabled: call > 1}}
})
close(f.exchangeRelease)
close(f.statusRelease)
done := make(chan error, 1)
go func() {
_, err := f.provider.Login(context.Background(), true)
done <- err
}()
closeOAuthRelease(f.exchangeRelease)
closeOAuthRelease(f.statusRelease)
done := startOAuthLogin(t, context.Background(), f)
_, _ = httpGetBody(t, f.callbackBase+CallbackPath+"?code=pending")
if err := <-done; err != nil {
t.Fatalf("poll-enabled login failed: %v", err)
if result := awaitOAuthLogin(t, done); result.err != nil {
t.Fatalf("poll-enabled login failed: %v", result.err)
}
})
@@ -498,17 +560,13 @@ func TestCrossPlatformCoverageOAuthLoginDenialAndPolling(t *testing.T) {
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus {
return CLIAuthStatus{Success: true, Result: &CLIAuthResult{}}
})
close(f.exchangeRelease)
close(f.statusRelease)
closeOAuthRelease(f.exchangeRelease)
closeOAuthRelease(f.statusRelease)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() {
_, err := f.provider.Login(ctx, true)
done <- err
}()
done := startOAuthLogin(t, ctx, f)
_, _ = httpGetBody(t, f.callbackBase+CallbackPath+"?code=pending")
cancel()
if err := <-done; err == nil {
if result := awaitOAuthLogin(t, done); result.err == nil {
t.Fatal("canceled pending login succeeded")
}
})
@@ -519,23 +577,20 @@ func TestCrossPlatformCoverageOAuthLoginExchangeFailure(t *testing.T) {
return CLIAuthStatus{Success: true, Result: &CLIAuthResult{CLIAuthEnabled: true}}
})
f.exchangeError.Store(true)
close(f.exchangeRelease)
close(f.statusRelease)
done := make(chan error, 1)
go func() {
_, err := f.provider.Login(context.Background(), true)
done <- err
}()
closeOAuthRelease(f.exchangeRelease)
closeOAuthRelease(f.statusRelease)
done := startOAuthLogin(t, context.Background(), f)
_, body := httpGetBody(t, f.callbackBase+CallbackPath+"?code=bad")
if !strings.Contains(body, "failed") {
t.Fatalf("exchange failure page = %q", body)
}
if err := <-done; err == nil {
if result := awaitOAuthLogin(t, done); result.err == nil {
t.Fatal("exchange failure login succeeded")
}
}
func TestCrossPlatformCoverageOAuthRefreshAndParsingEdges(t *testing.T) {
isolateOAuthPersistence(t)
t.Setenv("DWS_CLIENT_ID", "")
t.Setenv("DWS_CLIENT_SECRET", "")
dir := t.TempDir()
@@ -640,6 +695,7 @@ func TestCrossPlatformCoverageOAuthRefreshAndParsingEdges(t *testing.T) {
}
func TestCrossPlatformCoverageOAuthProviderHighLevelEdges(t *testing.T) {
isolateOAuthPersistence(t)
oldLoad := oauthLoadToken
oldLoadLocked := oauthLoadTokenLocked
oldAcquire := oauthAcquireLock
@@ -800,29 +856,24 @@ func TestCrossPlatformCoverageOAuthCallbackRemainingEdges(t *testing.T) {
oauthSuccessPause = 0
oauthSleep = func(time.Duration) {}
type loginResult struct {
token *TokenData
err error
}
startLogin := func(ctx context.Context, f *oauthLoginFixture) <-chan loginResult {
finishExchange := func(t *testing.T, f *oauthLoginFixture, done <-chan oauthLoginResult, code string) string {
t.Helper()
done := make(chan loginResult, 1)
bodyCh := make(chan oauthHTTPResult, 1)
go func() {
token, err := f.provider.Login(ctx, true)
done <- loginResult{token: token, err: err}
bodyCh <- getHTTPBody(f.callbackBase + CallbackPath + "?code=" + url.QueryEscape(code))
}()
return done
}
finishExchange := func(t *testing.T, f *oauthLoginFixture, code string) string {
t.Helper()
bodyCh := make(chan string, 1)
go func() {
_, body := httpGetBody(t, f.callbackBase+CallbackPath+"?code="+url.QueryEscape(code))
bodyCh <- body
}()
<-f.exchangeEntered
close(f.exchangeRelease)
return <-bodyCh
waitOAuthSignal(t, f.exchangeEntered, done, "token exchange")
closeOAuthRelease(f.exchangeRelease)
select {
case result := <-bodyCh:
if result.err != nil {
t.Fatalf("OAuth callback failed: %v", result.err)
}
return result.body
case <-time.After(oauthTestWaitTimeout):
t.Fatal("timed out waiting for OAuth callback")
return ""
}
}
t.Run("switch organization and cached disabled pages", func(t *testing.T) {
@@ -835,8 +886,8 @@ func TestCrossPlatformCoverageOAuthCallbackRemainingEdges(t *testing.T) {
return &CLIAuthStatus{Success: true, Result: &CLIAuthResult{CLIAuthEnabled: true}}, nil
}
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus { return CLIAuthStatus{} })
done := startLogin(context.Background(), f)
if body := finishExchange(t, f, "first"); !strings.Contains(body, "<html") {
done := startOAuthLogin(t, context.Background(), f)
if body := finishExchange(t, f, done, "first"); !strings.Contains(body, "<html") {
t.Fatalf("disabled callback body = %q", body)
}
if _, body := httpGetBody(t, f.callbackBase+CallbackPath+"?code=first"); !strings.Contains(body, "<html") {
@@ -848,7 +899,7 @@ func TestCrossPlatformCoverageOAuthCallbackRemainingEdges(t *testing.T) {
if _, body := httpGetBody(t, f.callbackBase+CallbackPath+"?code=second"); !strings.Contains(body, "<html") {
t.Fatalf("switched callback = %q", body)
}
result := <-done
result := awaitOAuthLogin(t, done)
if result.err != nil || result.token == nil || result.token.AccessToken != "access" {
t.Fatalf("switched login = %#v %v", result.token, result.err)
}
@@ -862,15 +913,15 @@ func TestCrossPlatformCoverageOAuthCallbackRemainingEdges(t *testing.T) {
oauthSendApply = func(context.Context, string, string) (*SendApplyResponse, error) { return nil, fail }
ctx, cancel := context.WithCancel(context.Background())
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus { return CLIAuthStatus{} })
done := startLogin(ctx, f)
finishExchange(t, f, "errors")
done := startOAuthLogin(t, ctx, f)
finishExchange(t, f, done, "errors")
for _, path := range []string{"/api/superAdmin", "/api/sendApply?adminStaffId=admin", "/api/cliAuthEnabled"} {
if _, body := httpGetBody(t, f.callbackBase+path); !strings.Contains(body, "hook failure") {
t.Fatalf("API error %s = %q", path, body)
}
}
cancel()
if result := <-done; !errors.Is(result.err, context.Canceled) {
if result := awaitOAuthLogin(t, done); !errors.Is(result.err, context.Canceled) {
t.Fatalf("canceled error login = %v", result.err)
}
})
@@ -887,14 +938,14 @@ func TestCrossPlatformCoverageOAuthCallbackRemainingEdges(t *testing.T) {
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus { return CLIAuthStatus{} })
var output bytes.Buffer
f.provider.Output = &output
done := startLogin(ctx, f)
finishExchange(t, f, "apply")
done := startOAuthLogin(t, ctx, f)
finishExchange(t, f, done, "apply")
if _, body := httpGetBody(t, f.callbackBase+"/api/sendApply?adminStaffId=admin"); !strings.Contains(body, "true") {
t.Fatalf("apply response = %q", body)
}
time.Sleep(20 * time.Millisecond)
cancel()
if result := <-done; !errors.Is(result.err, context.Canceled) {
if result := awaitOAuthLogin(t, done); !errors.Is(result.err, context.Canceled) {
t.Fatalf("canceled apply login = %v", result.err)
}
if !strings.Contains(output.String(), "Waiting for admin approval") && !strings.Contains(output.String(), "等待管理员审批中") {
@@ -908,9 +959,9 @@ func TestCrossPlatformCoverageOAuthCallbackRemainingEdges(t *testing.T) {
return &CLIAuthStatus{Success: false, ErrorCode: "ENTERPRISE_NOT_AUTHORIZED"}, nil
}
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus { return CLIAuthStatus{} })
done := startLogin(context.Background(), f)
finishExchange(t, f, "enterprise")
if result := <-done; result.err == nil || !strings.Contains(result.err.Error(), "企业安全认证") {
done := startOAuthLogin(t, context.Background(), f)
finishExchange(t, f, done, "enterprise")
if result := awaitOAuthLogin(t, done); result.err == nil || !strings.Contains(result.err.Error(), "企业安全认证") {
t.Fatalf("enterprise denial = %v", result.err)
}
})
@@ -933,15 +984,16 @@ func TestCrossPlatformCoverageOAuthCallbackRemainingEdges(t *testing.T) {
fail := errors.New("save failure")
oauthSaveToken = func(string, *TokenData) error { return fail }
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus { return CLIAuthStatus{} })
done := startLogin(context.Background(), f)
finishExchange(t, f, "save")
if result := <-done; !errors.Is(result.err, fail) {
done := startOAuthLogin(t, context.Background(), f)
finishExchange(t, f, done, "save")
if result := awaitOAuthLogin(t, done); !errors.Is(result.err, fail) {
t.Fatalf("save failure login = %v", result.err)
}
})
}
func TestCrossPlatformCoverageOAuthHelperRemainingEdges(t *testing.T) {
isolateOAuthPersistence(t)
oldClient := oauthHTTPClient
oldRequest := oauthNewRequest
oldRetry := oauthRetryAfter
+339
View File
@@ -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"
+1 -2
View File
@@ -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
+34 -6
View File
@@ -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
}
+13
View File
@@ -47,6 +47,19 @@ if [ -n "$missing" ]; then
exit 0
fi
# ossutil v2 signs with V4 and requires an explicit region. Derive it from the
# endpoint host (oss-<region>[-internal].aliyuncs.com) unless OSS_REGION is set.
if [ -z "${OSS_REGION:-}" ]; then
OSS_REGION="$(printf '%s' "$OSS_ENDPOINT" \
| sed -n 's#^\(https\{0,1\}://\)\{0,1\}oss-\([a-z0-9-]*[a-z0-9]\)\.aliyuncs\.com.*#\2#p' \
| sed 's#-internal$##')"
fi
if [ -z "$OSS_REGION" ]; then
echo "❌ Could not derive OSS_REGION from OSS_ENDPOINT=${OSS_ENDPOINT}; set OSS_REGION explicitly." >&2
exit 1
fi
export OSS_REGION
# ── Resolve version ──────────────────────────────────────────────────────────
VERSION="${VERSION:-$(git describe --tags --always 2>/dev/null || echo dev)}"
CHANNEL="${DWS_RELEASE_CHANNEL:-$(release_channel_for_version "$VERSION")}"
@@ -4,12 +4,28 @@ set -eu
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
. "$SCRIPT_DIR/release-lib.sh"
MODE="strict"
CHANNEL_REPAIR_TARGET=""
if [ "${1:-}" = "--channel-repair" ]; then
MODE="channel-repair"
shift
CHANNEL_REPAIR_TARGET="${1:-}"
shift
case "$CHANNEL_REPAIR_TARGET" in
oss|gitee) ;;
*)
printf 'channel repair target must be oss or gitee\n' >&2
exit 2
;;
esac
fi
TAG="${1:-}"
EXPECTED_COMMIT="${2:-}"
REPOSITORY="${DWS_RELEASE_OFFICIAL_REPOSITORY:-DingTalk-Real-AI/dingtalk-workspace-cli}"
[ -n "$TAG" ] && [ -n "$EXPECTED_COMMIT" ] || {
printf 'usage: verify-release-workflow-delivery.sh <tag> <commit>\n' >&2
printf 'usage: verify-release-workflow-delivery.sh [--channel-repair <oss|gitee>] <tag> <commit>\n' >&2
exit 2
}
if ! release_is_stable_version "$TAG" && ! release_is_prerelease_version "$TAG"; then
@@ -65,6 +81,200 @@ for run in runs:
done
}
find_failed_push_delivery() {
matches=""
page=1
while :; do
page_result="$(
github_get "repos/$REPOSITORY/actions/workflows/release.yml/runs?branch=$TAG&event=push&status=completed&per_page=100&page=$page" \
| python3 -c 'import json,sys
tag,commit,repository=sys.argv[1:]
runs=json.load(sys.stdin).get("workflow_runs", [])
print(len(runs))
for run in runs:
if (run.get("head_sha") == commit and run.get("head_branch") == tag
and run.get("event") == "push" and run.get("status") == "completed"
and run.get("conclusion") == "failure"
and run.get("path") == ".github/workflows/release.yml"
and run.get("repository", {}).get("full_name") == repository):
run_id=run.get("id", "")
attempt=run.get("run_attempt", "")
if isinstance(run_id, int) and run_id > 0 and isinstance(attempt, int) and attempt > 0:
print(f"{run_id}\t{attempt}")' "$TAG" "$EXPECTED_COMMIT" "$REPOSITORY"
)" || return 1
page_count="$(printf '%s\n' "$page_result" | sed -n '1p')"
page_matches="$(printf '%s\n' "$page_result" | sed '1d')"
matches="$(printf '%s\n%s\n' "$matches" "$page_matches" | sed '/^$/d')"
[ "$page_count" -eq 100 ] || break
page=$((page + 1))
done
match_count="$(printf '%s\n' "$matches" | sed '/^$/d' | wc -l | tr -d ' ')"
[ "$match_count" -eq 1 ] || {
printf 'expected exactly one failed exact-tag push run for channel repair, found %s\n' \
"$match_count" >&2
return 1
}
printf '%s\n' "$matches"
}
verify_channel_repair_delivery() {
run_id="$1"
run_attempt="$2"
jobs_dir="$(mktemp -d "${TMPDIR:-/tmp}/dws-release-channel-jobs.XXXXXX")"
page=1
while :; do
jobs_page="$jobs_dir/jobs-$page.json"
if ! github_get "repos/$REPOSITORY/actions/runs/$run_id/attempts/$run_attempt/jobs?per_page=100&page=$page" \
>"$jobs_page"; then
rm -rf "$jobs_dir"
return 1
fi
if ! page_count="$(python3 -c 'import json,sys; print(len(json.load(open(sys.argv[1])).get("jobs", [])))' "$jobs_page")"; then
rm -rf "$jobs_dir"
return 1
fi
[ "$page_count" -eq 100 ] || break
page=$((page + 1))
done
result=0
python3 - "$EXPECTED_COMMIT" "$run_id" "$run_attempt" "$TAG" "$CHANNEL_REPAIR_TARGET" "$jobs_dir"/jobs-*.json <<'PY' || result=$?
import json
import sys
commit, run_id, run_attempt, tag, target, *pages = sys.argv[1:]
jobs = []
for page in pages:
with open(page, encoding="utf-8") as handle:
jobs.extend(json.load(handle).get("jobs", []))
def fail(message):
print(
f"failed Release run {run_id} attempt {run_attempt} is not safe "
f"channel-repair authority for {tag}: {message}",
file=sys.stderr,
)
raise SystemExit(1)
def one_job(name):
matches = [job for job in jobs if job.get("name") == name]
if len(matches) != 1:
fail(f"expected exactly one latest-attempt job {name!r}, found {len(matches)}")
job = matches[0]
if job.get("head_sha") != commit:
fail(f"job {name!r} is not bound to {commit}")
if job.get("status") != "completed":
fail(f"job {name!r} is not completed")
return job
for name in (
"release-contract",
"Build signed release artifacts",
"Verify Apple Developer ID signatures",
"Publish immutable GitHub Release",
):
if one_job(name).get("conclusion") != "success":
fail(f"required job {name!r} did not succeed")
publish_release = one_job("Publish immutable GitHub Release")
immutable_steps = [
step for step in publish_release.get("steps", [])
if step.get("name") == "Require immutable published GitHub Release"
]
if len(immutable_steps) != 1:
fail("expected exactly one immutable GitHub Release verification step")
if (
immutable_steps[0].get("status") != "completed"
or immutable_steps[0].get("conclusion") != "success"
):
fail("immutable GitHub Release verification did not succeed")
channels = one_job("Publish npm and mirrors")
if channels.get("conclusion") not in {"success", "failure"}:
fail("channel publication job was not completed with a conclusive result")
steps = channels.get("steps", [])
for name in (
"Download and verify immutable GitHub Release",
"Verify immutable npm package without publication credentials",
"Inspect npm channel state",
"Verify npm channel delivery",
):
matches = [step for step in steps if step.get("name") == name]
if len(matches) != 1:
fail(f"expected exactly one channel step {name!r}, found {len(matches)}")
step = matches[0]
if step.get("status") != "completed" or step.get("conclusion") != "success":
fail(f"required channel step {name!r} did not succeed")
failed_channel_steps = [
step.get("name", "")
for step in steps
if step.get("conclusion") == "failure"
]
if channels.get("conclusion") == "failure":
if failed_channel_steps != ["Sync release artifacts to China OSS mirror"]:
fail(
"failed channel publication must have exactly one failed OSS mirror step, "
f"got {failed_channel_steps!r}"
)
elif failed_channel_steps:
fail(f"successful channel publication contains failed steps {failed_channel_steps!r}")
gitee = one_job("Mirror immutable release to Gitee")
if gitee.get("conclusion") not in {"success", "skipped", "failure"}:
fail("Gitee mirror job did not complete with an allowed channel result")
delivery_gate = one_job("Release delivery gate")
if delivery_gate.get("conclusion") != "failure":
fail("failed channel-repair run must end in a failed delivery gate")
allowed_failures = {
"Publish npm and mirrors",
"Mirror immutable release to Gitee",
"Release delivery gate",
}
for job in jobs:
if job.get("status") != "completed":
fail(f"job {job.get('name', '')!r} is not completed")
conclusion = job.get("conclusion")
if conclusion not in {"success", "skipped", "failure"}:
fail(f"job {job.get('name', '')!r} has disallowed conclusion {conclusion!r}")
if conclusion == "failure" and job.get("name") not in allowed_failures:
fail(f"unrelated job {job.get('name', '')!r} failed")
business_failures = [
job.get("name")
for job in (channels, gitee)
if job.get("conclusion") == "failure"
]
if len(business_failures) != 1:
fail(f"expected exactly one failed downstream channel job, got {business_failures!r}")
if target == "oss":
if business_failures != ["Publish npm and mirrors"] or gitee.get("conclusion") != "skipped":
fail(
"OSS repair requires the OSS mirror step to be the only failed "
"downstream channel and Gitee to be skipped"
)
elif target == "gitee":
if gitee.get("conclusion") == "failure":
if business_failures != ["Mirror immutable release to Gitee"]:
fail("Gitee repair evidence contains a different failed downstream channel")
elif gitee.get("conclusion") == "skipped":
if business_failures != ["Publish npm and mirrors"]:
fail(
"skipped Gitee backfill requires the upstream OSS mirror to be "
"the only failed downstream channel"
)
else:
fail("Gitee repair requires its mirror job to be failed or skipped")
else:
fail(f"unsupported channel repair target {target!r}")
PY
rm -rf "$jobs_dir"
return "$result"
}
push_delivery="$(find_push_delivery || true)"
if [ -n "$push_delivery" ]; then
printf 'Release workflow delivery verified through exact-tag push run %s: %s -> %s\n' \
@@ -72,6 +282,18 @@ if [ -n "$push_delivery" ]; then
exit 0
fi
if [ "$MODE" = "channel-repair" ]; then
failed_push_identity="$(find_failed_push_delivery || true)"
failed_push_delivery="$(printf '%s\n' "$failed_push_identity" | cut -f1)"
failed_push_attempt="$(printf '%s\n' "$failed_push_identity" | cut -f2)"
if [ -n "$failed_push_delivery" ] &&
verify_channel_repair_delivery "$failed_push_delivery" "$failed_push_attempt"; then
printf 'Release %s channel-repair authority verified through failed exact-tag push run %s attempt %s: %s -> %s\n' \
"$CHANNEL_REPAIR_TARGET" "$failed_push_delivery" "$failed_push_attempt" "$TAG" "$EXPECTED_COMMIT"
exit 0
fi
fi
find_recovery_identity() {
page=1
while :; do
+609
View File
@@ -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)
}
})
}
}
+142 -23
View File
@@ -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,36 +799,49 @@ func TestReleaseWorkflowGovernancePreflightCannotPublish(t *testing.T) {
}
}
func TestReleaseWorkflowGiteeRepairUsesSealedReleaseAuthority(t *testing.T) {
func TestReleaseWorkflowChannelRepairUsesSealedReleaseAuthority(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")
start := strings.Index(workflow, " repair-channel:\n")
if start == -1 {
t.Fatal("release workflow is missing the Gitee repair job")
t.Fatal("release workflow is missing the channel repair job")
}
repair := workflow[start:]
tagAuthority := releaseWorkflowSection(
t,
repair,
" - name: Fetch and verify sealed release tag\n",
"\n - name: Require successful Release workflow delivery\n",
)
for _, required := range []string{
"repair_gitee_version:",
`format('Release Gitee repair {0}', inputs.repair_gitee_version)`,
`REPAIR_GITEE_VERSION: ${{ inputs.repair_gitee_version }}`,
"repair_oss_version:",
`format('Release OSS repair {0}', inputs.repair_oss_version)`,
`REPAIR_OSS_VERSION: ${{ inputs.repair_oss_version }}`,
"gitee_repair=0",
"oss_repair=0",
`test -z "$REPAIR_GITEE_VERSION" || gitee_repair=1`,
"npm_repair + gitee_repair + governance + recovery",
`test -z "$REPAIR_OSS_VERSION" || oss_repair=1`,
"npm_repair + gitee_repair + oss_repair + governance + recovery",
`echo "mode=repair_gitee" >> "$GITHUB_OUTPUT"`,
`echo "mode=repair_oss" >> "$GITHUB_OUTPUT"`,
} {
if !strings.Contains(dispatch, required) && !strings.Contains(workflow, required) {
t.Errorf("Gitee repair dispatch contract is missing %q", required)
t.Errorf("channel 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'`,
`if: ${{ !cancelled() && needs.dispatch-contract.result == 'success' && (needs.dispatch-contract.outputs.mode == 'repair_gitee' || needs.dispatch-contract.outputs.mode == 'repair_oss') && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && github.repository == 'DingTalk-Real-AI/dingtalk-workspace-cli' }}`,
`github.ref == format('refs/heads/{0}', github.event.repository.default_branch)`,
`github.repository == 'DingTalk-Real-AI/dingtalk-workspace-cli'`,
"actions: read",
"contents: read",
`ref: ${{ github.sha }}`,
"path: tooling",
"persist-credentials: false",
@@ -766,17 +854,34 @@ func TestReleaseWorkflowGiteeRepairUsesSealedReleaseAuthority(t *testing.T) {
"assetNames.length !== expectedAssets.size",
"new Set(assetNames).size !== expectedAssets.size",
`core.setOutput("tag_object", ref.data.object.sha)`,
`ref: ${{ steps.authority.outputs.commit_sha }}`,
"path: release-source",
"verify-github-tag-authority.sh",
`GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}`,
"verify-release-workflow-delivery.sh",
`REPAIR_MODE: ${{ needs.dispatch-contract.outputs.mode }}`,
"repair_gitee) target=gitee",
"repair_oss) target=oss",
`--channel-repair "$target"`,
`DWS_RELEASE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}`,
"verify-release-artifacts.sh",
`--repo "$GITHUB_REPOSITORY"`,
`if: ${{ needs.dispatch-contract.outputs.mode == 'repair_gitee' }}`,
`DWS_REQUIRE_GITEE: "1"`,
`if: ${{ needs.dispatch-contract.outputs.mode == 'repair_oss' }}`,
`DWS_REQUIRE_OSS: "1"`,
`OSS_ACCESS_KEY_ID: ${{ secrets.OSS_ACCESS_KEY_ID }}`,
`OSS_ACCESS_KEY_SECRET: ${{ secrets.OSS_ACCESS_KEY_SECRET }}`,
`OSS_ENDPOINT: ${{ secrets.OSS_ENDPOINT }}`,
`OSS_BUCKET: ${{ secrets.OSS_BUCKET }}`,
`OSS_PREFIX: ${{ secrets.OSS_PREFIX }}`,
"working-directory: release-source",
"working-directory: tooling\n run: |\n " +
`"$GITHUB_WORKSPACE/tooling/scripts/release/sync-to-gitee.sh"`,
`"$GITHUB_WORKSPACE/tooling/scripts/release/sync-to-oss.sh"`,
} {
if !strings.Contains(repair, required) {
t.Errorf("Gitee repair authority is missing %q", required)
t.Errorf("channel repair authority is missing %q", required)
}
}
for _, asset := range []string{
@@ -790,17 +895,29 @@ func TestReleaseWorkflowGiteeRepairUsesSealedReleaseAuthority(t *testing.T) {
"checksums.txt",
} {
if strings.Count(repair, `"`+asset+`"`) != 1 {
t.Errorf("Gitee repair must require exactly one %s asset declaration", asset)
t.Errorf("channel 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")
t.Error("channel repair must not grant contents write permission")
}
for _, forbidden := range []string{
"RELEASE_GOVERNANCE_TOKEN",
"HOMEBREW_PR_TOKEN",
"NPM_TOKEN",
} {
if strings.Contains(repair, forbidden) {
t.Errorf("channel repair must not expose unrelated credential %s", forbidden)
}
}
if strings.Contains(repair, "ref: ${{ github.event.repository.default_branch }}") {
t.Error("Gitee repair must not check out a floating default branch")
t.Error("channel repair must not check out a floating default branch")
}
if !strings.Contains(tagAuthority, `GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}`) {
t.Error("remote annotated-tag authority step must receive the read-only GitHub token")
}
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)
t.Errorf("channel repair trusted tooling working-directory count = %d, want at least 4", got)
}
}
@@ -839,7 +956,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"`,
@@ -1058,7 +1176,7 @@ func TestReleaseWorkflowPublicationBypassesSkippedDispatchButStopsOnCancellation
func TestReleaseWorkflowDeliveryGateFailsClosed(t *testing.T) {
t.Parallel()
workflow := readReleaseWorkflow(t)
gate := releaseWorkflowSection(t, workflow, " release-delivery-gate:\n", "\n repair-gitee:\n")
gate := releaseWorkflowSection(t, workflow, " release-delivery-gate:\n", "\n repair-channel:\n")
for _, required := range []string{
"name: Release delivery gate",
@@ -1073,8 +1191,8 @@ func TestReleaseWorkflowDeliveryGateFailsClosed(t *testing.T) {
"- publish-channels",
"- mirror-gitee-release",
"- repair-npm",
"- repair-gitee",
`REPAIR_GITEE_RESULT: ${{ needs.repair-gitee.result }}`,
"- repair-channel",
`REPAIR_CHANNEL_RESULT: ${{ needs.repair-channel.result }}`,
"require_publication",
`require_result release-contract "$RELEASE_CONTRACT_RESULT" success`,
`require_result release "$RELEASE_RESULT" success`,
@@ -1085,8 +1203,9 @@ func TestReleaseWorkflowDeliveryGateFailsClosed(t *testing.T) {
"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`,
"workflow_dispatch:repair_oss",
`require_result repair-channel "$REPAIR_CHANNEL_RESULT" success`,
`require_result repair-channel "$REPAIR_CHANNEL_RESULT" skipped`,
"unsupported release mode",
} {
if !strings.Contains(gate, required) {
@@ -1335,10 +1454,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")
+254
View File
@@ -674,6 +674,257 @@ esac
}
}
func TestReleaseWorkflowDeliveryChannelRepairRequiresLatestAttemptCoreDelivery(t *testing.T) {
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
if err != nil {
t.Fatalf("Abs(repo root) error = %v", err)
}
binDir := t.TempDir()
fakeCurl := filepath.Join(binDir, "curl")
mustWriteFile(t, fakeCurl, []byte(`#!/bin/sh
set -eu
for argument in "$@"; do endpoint="$argument"; done
case "$endpoint" in
*event=push*)
python3 - <<'PY'
import json
import os
run = {
"id": 77,
"event": "push",
"status": "completed",
"conclusion": "failure",
"head_branch": os.environ["TAG"],
"head_sha": os.environ["RELEASE_COMMIT"],
"path": os.environ.get("RUN_PATH", ".github/workflows/release.yml"),
"repository": {"full_name": os.environ.get("RUN_REPOSITORY", "owner/repo")},
"run_attempt": int(os.environ.get("RUN_ATTEMPT", "2")),
}
runs = [run]
if os.environ.get("DUPLICATE_RUN") == "1":
duplicate = dict(run)
duplicate["id"] = 78
runs.append(duplicate)
print(json.dumps({"workflow_runs": runs}))
PY
;;
*event=workflow_dispatch*)
printf '{"workflow_runs":[]}\n'
;;
*/actions/runs/77/attempts/2/jobs*)
python3 - <<'PY'
import json
import os
commit = os.environ.get("JOB_SHA", os.environ["RELEASE_COMMIT"])
core = [
"release-contract",
"Build signed release artifacts",
"Verify Apple Developer ID signatures",
"Publish immutable GitHub Release",
]
jobs = [{
"name": name,
"status": "completed",
"conclusion": (
os.environ.get("CORE_CONCLUSION", "success")
if name == os.environ.get("CORE_JOB", "Build signed release artifacts")
else "success"
),
"head_sha": commit,
"steps": (
[{
"name": "Require immutable published GitHub Release",
"status": "completed",
"conclusion": os.environ.get("IMMUTABLE_STEP_CONCLUSION", "success"),
}]
if name == "Publish immutable GitHub Release"
else []
),
} for name in core]
if os.environ.get("DUPLICATE_CORE") == "1":
jobs.append(dict(jobs[1]))
required_steps = [
"Download and verify immutable GitHub Release",
"Verify immutable npm package without publication credentials",
"Inspect npm channel state",
"Verify npm channel delivery",
]
jobs.append({
"name": "Publish npm and mirrors",
"status": "completed",
"conclusion": os.environ.get("CHANNEL_JOB_CONCLUSION", "failure"),
"head_sha": commit,
"steps": [{
"name": name,
"status": "completed",
"conclusion": (
os.environ.get("CHANNEL_STEP_CONCLUSION", "success")
if name == os.environ.get("CHANNEL_STEP", "Verify npm channel delivery")
else "success"
),
} for name in required_steps] + [{
"name": "Sync release artifacts to China OSS mirror",
"status": "completed",
"conclusion": os.environ.get("OSS_STEP_CONCLUSION", "failure"),
}],
})
jobs.extend([
{
"name": "Mirror immutable release to Gitee",
"status": "completed",
"conclusion": os.environ.get("GITEE_CONCLUSION", "skipped"),
"head_sha": commit,
"steps": [],
},
{
"name": "Release delivery gate",
"status": "completed",
"conclusion": os.environ.get("DELIVERY_GATE_CONCLUSION", "failure"),
"head_sha": commit,
"steps": [],
},
])
if os.environ.get("UNRELATED_FAILURE") == "1":
jobs.append({
"name": "Unrelated release job",
"status": "completed",
"conclusion": "failure",
"head_sha": commit,
"steps": [],
})
print(json.dumps({"jobs": jobs}))
PY
;;
*/actions/runs/77/*/jobs*)
echo "channel repair must inspect the exact latest run attempt" >&2
exit 91
;;
*) exit 1 ;;
esac
`), 0o755)
script := filepath.Join(sourceRoot, "scripts", "release", "verify-release-workflow-delivery.sh")
tag := "v1.2.3-beta.1"
commit := strings.Repeat("a", 40)
run := func(args []string, overrides ...string) (string, error) {
cmd := exec.Command("sh", append([]string{script}, args...)...)
cmd.Env = append([]string{
"PATH=" + binDir + string(os.PathListSeparator) + os.Getenv("PATH"),
"HOME=" + t.TempDir(),
"DWS_RELEASE_OFFICIAL_REPOSITORY=owner/repo",
"TAG=" + tag,
"RELEASE_COMMIT=" + commit,
}, overrides...)
output, err := cmd.CombinedOutput()
return string(output), err
}
repairArgs := func(target string) []string {
return []string{"--channel-repair", target, tag, commit}
}
if output, err := run([]string{tag, commit}); err == nil ||
!strings.Contains(output, "did not deliver") {
t.Fatalf("strict delivery accepted a failed tag run: err=%v\noutput:\n%s", err, output)
}
if output, err := run(repairArgs("oss")); err != nil ||
!strings.Contains(output, "failed exact-tag push run 77") {
t.Fatalf("safe channel repair delivery was rejected: err=%v\noutput:\n%s", err, output)
}
if output, err := run(
repairArgs("oss"),
"CORE_CONCLUSION=failure",
); err == nil || !strings.Contains(output, "required job 'Build signed release artifacts' did not succeed") {
t.Fatalf("failed core release job passed: err=%v\noutput:\n%s", err, output)
}
if output, err := run(
repairArgs("oss"),
"CHANNEL_STEP_CONCLUSION=failure",
); err == nil || !strings.Contains(output, "required channel step 'Verify npm channel delivery' did not succeed") {
t.Fatalf("failed npm delivery proof passed: err=%v\noutput:\n%s", err, output)
}
if output, err := run(
repairArgs("oss"),
"DUPLICATE_CORE=1",
); err == nil || !strings.Contains(output, "expected exactly one latest-attempt job 'Build signed release artifacts'") {
t.Fatalf("duplicate latest-attempt core job passed: err=%v\noutput:\n%s", err, output)
}
if output, err := run(
repairArgs("oss"),
"JOB_SHA="+strings.Repeat("b", 40),
); err == nil || !strings.Contains(output, "is not bound to "+commit) {
t.Fatalf("wrong-sha release jobs passed: err=%v\noutput:\n%s", err, output)
}
if output, err := run(
repairArgs("oss"),
"UNRELATED_FAILURE=1",
); err == nil || !strings.Contains(output, "unrelated job 'Unrelated release job' failed") {
t.Fatalf("unrelated failed job passed: err=%v\noutput:\n%s", err, output)
}
if output, err := run(
repairArgs("oss"),
"IMMUTABLE_STEP_CONCLUSION=failure",
); err == nil || !strings.Contains(output, "immutable GitHub Release verification did not succeed") {
t.Fatalf("failed immutable release verification passed: err=%v\noutput:\n%s", err, output)
}
if output, err := run(
repairArgs("gitee"),
"CHANNEL_JOB_CONCLUSION=success",
"OSS_STEP_CONCLUSION=success",
"GITEE_CONCLUSION=failure",
); err != nil || !strings.Contains(output, "channel-repair authority verified") {
t.Fatalf("single failed Gitee mirror was rejected: err=%v\noutput:\n%s", err, output)
}
if output, err := run(
repairArgs("oss"),
"CHANNEL_JOB_CONCLUSION=success",
"OSS_STEP_CONCLUSION=success",
"GITEE_CONCLUSION=failure",
); err == nil || !strings.Contains(output, "OSS repair requires") {
t.Fatalf("Gitee-only failure was accepted as OSS evidence: err=%v\noutput:\n%s", err, output)
}
if output, err := run(
repairArgs("gitee"),
); err != nil || !strings.Contains(output, "channel-repair authority verified") {
t.Fatalf("skipped Gitee backfill was rejected after upstream OSS failure: err=%v\noutput:\n%s", err, output)
}
if output, err := run(
repairArgs("oss"),
"GITEE_CONCLUSION=failure",
); err == nil || !strings.Contains(output, "expected exactly one failed downstream channel job") {
t.Fatalf("two failed downstream channels passed: err=%v\noutput:\n%s", err, output)
}
if output, err := run(
repairArgs("oss"),
"DELIVERY_GATE_CONCLUSION=success",
); err == nil || !strings.Contains(output, "must end in a failed delivery gate") {
t.Fatalf("successful terminal gate on a failed run passed: err=%v\noutput:\n%s", err, output)
}
if output, err := run(
repairArgs("oss"),
"RUN_ATTEMPT=1",
); err == nil || strings.Contains(output, "channel-repair authority verified") {
t.Fatalf("non-latest run attempt passed: err=%v\noutput:\n%s", err, output)
}
if output, err := run(
repairArgs("oss"),
"RUN_REPOSITORY=other/repo",
); err == nil || strings.Contains(output, "channel-repair authority verified") {
t.Fatalf("wrong-repository release run passed: err=%v\noutput:\n%s", err, output)
}
if output, err := run(
repairArgs("oss"),
"RUN_PATH=.github/workflows/other.yml",
); err == nil || strings.Contains(output, "channel-repair authority verified") {
t.Fatalf("wrong-workflow release run passed: err=%v\noutput:\n%s", err, output)
}
if output, err := run(
repairArgs("oss"),
"DUPLICATE_RUN=1",
); err == nil || strings.Contains(output, "channel-repair authority verified") {
t.Fatalf("ambiguous failed release runs passed: err=%v\noutput:\n%s", err, output)
}
}
func releaseChangelog(sections ...string) string {
text := "# Changelog\n\n## [Unreleased]\n\n"
for _, section := range sections {
@@ -1060,6 +1311,7 @@ func TestReleaseMirrorUsesChannelSpecificPointer(t *testing.T) {
"OSS_ACCESS_KEY_ID=test-key",
"OSS_ACCESS_KEY_SECRET=test-secret",
"OSS_ENDPOINT=https://oss.example.com",
"OSS_REGION=cn-test",
"OSS_BUCKET=test-bucket",
"OSS_PREFIX=dws",
"OSSUTIL="+fakeOSSUtil,
@@ -1130,6 +1382,7 @@ func TestReleaseMirrorFailsClosedWhenPointerCannotBeRead(t *testing.T) {
"OSS_ACCESS_KEY_ID=test-key",
"OSS_ACCESS_KEY_SECRET=test-secret",
"OSS_ENDPOINT=https://oss.example.com",
"OSS_REGION=cn-test",
"OSS_BUCKET=test-bucket",
"OSS_PREFIX=dws",
"OSSUTIL="+fakeOSSUtil,
@@ -1166,6 +1419,7 @@ func TestReleaseMirrorRepairsHistoricalAssetsWithoutMovingNewerPointer(t *testin
"OSS_ACCESS_KEY_ID=test-key",
"OSS_ACCESS_KEY_SECRET=test-secret",
"OSS_ENDPOINT=https://oss.example.com",
"OSS_REGION=cn-test",
"OSS_BUCKET=test-bucket",
"OSS_PREFIX=dws",
"OSSUTIL="+fakeOSSUtil,