Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e070a7e24 | ||
|
|
e867abd03c | ||
|
|
9d89965de9 | ||
|
|
41088bb965 | ||
|
|
8259116f15 | ||
|
|
9ec1fa0638 | ||
|
|
9afd3be79b | ||
|
|
67da5019e3 | ||
|
|
b0ded7deb8 | ||
|
|
22905fc41e | ||
|
|
ec9ff653fc | ||
|
|
876cf8e958 | ||
|
|
1c5ed6646e | ||
|
|
80549a80e0 | ||
|
|
883d416d83 | ||
|
|
25c70aeb24 | ||
|
|
6cfa9e3afb | ||
|
|
544a91e994 | ||
|
|
024d487a22 | ||
|
|
6b50cc41c5 | ||
|
|
11e50662f9 | ||
|
|
29abdb6e79 | ||
|
|
bc587ddd91 | ||
|
|
6196e2565e | ||
|
|
609d56305e | ||
|
|
e69a1084a7 |
No files matched your search
@@ -5,6 +5,13 @@
|
|||||||
|
|
||||||
## Verification
|
## 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 build`
|
||||||
- [ ] `make lint`
|
- [ ] `make lint`
|
||||||
- [ ] `make test`
|
- [ ] `make test`
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
name: AI Behavior Check
|
name: Code Admission — AI Behavior
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request_target:
|
pull_request_target:
|
||||||
types: [opened, synchronize, reopened, labeled, unlabeled]
|
types: [opened, synchronize, reopened, labeled, unlabeled]
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -11,7 +14,7 @@ permissions:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
ai-behavior-check:
|
ai-behavior-check:
|
||||||
name: AI Behavior Policy Evaluator
|
name: AI Behavior
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
steps:
|
steps:
|
||||||
@@ -21,75 +24,107 @@ jobs:
|
|||||||
uses: actions/github-script@v7
|
uses: actions/github-script@v7
|
||||||
with:
|
with:
|
||||||
script: |
|
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) =>
|
const setStatus = (state, description) =>
|
||||||
github.rest.repos.createCommitStatus({
|
github.rest.repos.createCommitStatus({
|
||||||
owner: context.repo.owner,
|
owner: context.repo.owner,
|
||||||
repo: context.repo.repo,
|
repo: context.repo.repo,
|
||||||
sha,
|
sha,
|
||||||
state,
|
state,
|
||||||
context: 'AI Behavior Check',
|
context: 'AI Behavior',
|
||||||
description,
|
description,
|
||||||
});
|
});
|
||||||
|
|
||||||
await setStatus('pending', 'Evaluating AI-generated PR boundaries');
|
await setStatus('pending', 'Evaluating AI-generated PR boundaries');
|
||||||
|
|
||||||
const labels = context.payload.pull_request.labels.map(({ name }) => name);
|
if (context.eventName === 'push') {
|
||||||
if (!labels.includes('ai-generated')) {
|
await setStatus('success', 'Not applicable to the protected main push');
|
||||||
await setStatus('success', 'Not labeled ai-generated');
|
core.notice('AI Behavior is a PR policy; the main push context is sealed.');
|
||||||
core.notice('Not an ai-generated PR; no AI-only policy applied.');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
try {
|
||||||
owner: context.repo.owner,
|
const expectedHead = pullRequest.head.sha;
|
||||||
repo: context.repo.repo,
|
const expectedBase = pullRequest.base.sha;
|
||||||
pull_number: context.issue.number,
|
const currentPull = async (phase) => {
|
||||||
per_page: 100,
|
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(
|
await setStatus(
|
||||||
'failure',
|
'success',
|
||||||
`Changes ${files.length} files; limit is ${maxChangedFiles}`
|
`Passed with ${files.length} changed files (limit ${maxChangedFiles})`
|
||||||
);
|
);
|
||||||
core.setFailed(
|
core.notice(
|
||||||
`AI-generated PR changes ${files.length} files; limit is ${maxChangedFiles}.`
|
`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}).`
|
|
||||||
);
|
|
||||||
+350
-113
@@ -18,48 +18,136 @@ jobs:
|
|||||||
name: Lint
|
name: Lint
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
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:
|
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
|
- name: Check out repository
|
||||||
|
if: steps.classify.outputs.changelog_only != 'true'
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
|
if: steps.classify.outputs.changelog_only != 'true'
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
|
|
||||||
- name: Format Check
|
- name: Format Check
|
||||||
|
if: steps.classify.outputs.changelog_only != 'true'
|
||||||
run: |
|
run: |
|
||||||
unformatted="$(find cmd internal test scripts/policy -name '*.go' -print0 | xargs -0r gofmt -l)"
|
unformatted="$(find cmd internal test scripts/policy -name '*.go' -print0 | xargs -0r gofmt -l)"
|
||||||
test -z "$unformatted" || (printf '%s\n' "$unformatted" && exit 1)
|
test -z "$unformatted" || (printf '%s\n' "$unformatted" && exit 1)
|
||||||
|
|
||||||
- name: Go Vet
|
- name: Go Vet
|
||||||
|
if: steps.classify.outputs.changelog_only != 'true'
|
||||||
run: go vet ./...
|
run: go vet ./...
|
||||||
|
|
||||||
# golangci-lint temporarily disabled: v1.64.8 built with Go 1.24 is incompatible with Go 1.25
|
|
||||||
# - name: golangci-lint
|
|
||||||
# uses: golangci/golangci-lint-action@v6
|
|
||||||
# with:
|
|
||||||
# version: v1.64.8
|
|
||||||
# args: ./...
|
|
||||||
|
|
||||||
actionlint:
|
|
||||||
name: Actionlint
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 10
|
|
||||||
steps:
|
|
||||||
- name: Check out repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Go
|
|
||||||
uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version-file: go.mod
|
|
||||||
|
|
||||||
- name: Check GitHub Actions workflows
|
- name: Check GitHub Actions workflows
|
||||||
|
if: steps.classify.outputs.changelog_only != 'true'
|
||||||
run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12
|
run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12
|
||||||
|
|
||||||
test-race:
|
test-race:
|
||||||
name: "Test (race: ${{ matrix.shard }})"
|
name: "Test (race: ${{ matrix.shard }})"
|
||||||
|
needs: lint
|
||||||
|
if: ${{ needs.lint.outputs.changelog_only != 'true' }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
strategy:
|
strategy:
|
||||||
@@ -116,6 +204,8 @@ jobs:
|
|||||||
|
|
||||||
test-release-scripts:
|
test-release-scripts:
|
||||||
name: Test (release scripts)
|
name: Test (release scripts)
|
||||||
|
needs: lint
|
||||||
|
if: ${{ needs.lint.outputs.changelog_only != 'true' }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
@@ -133,25 +223,85 @@ jobs:
|
|||||||
- name: Test release scripts
|
- name: Test release scripts
|
||||||
run: go test -v -count=1 -timeout=5m ./test/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:
|
test:
|
||||||
name: Test
|
name: Test
|
||||||
needs:
|
needs:
|
||||||
|
- lint
|
||||||
- test-race
|
- test-race
|
||||||
- test-release-scripts
|
- test-release-scripts
|
||||||
if: ${{ always() }}
|
- test-cross-platform
|
||||||
|
- test-darwin
|
||||||
|
- test-windows
|
||||||
|
if: ${{ always() && needs.lint.result == 'success' }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
permissions: {}
|
permissions: {}
|
||||||
steps:
|
steps:
|
||||||
- name: Verify test shards
|
- name: Verify test shards
|
||||||
env:
|
env:
|
||||||
|
CHANGELOG_ONLY: ${{ needs.lint.outputs.changelog_only }}
|
||||||
|
PLATFORM_SENSITIVE: ${{ needs.lint.outputs.platform_sensitive }}
|
||||||
RACE_RESULT: ${{ needs.test-race.result }}
|
RACE_RESULT: ${{ needs.test-race.result }}
|
||||||
RELEASE_SCRIPTS_RESULT: ${{ needs.test-release-scripts.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: |
|
run: |
|
||||||
failed=0
|
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 \
|
for shard in \
|
||||||
"race shards:$RACE_RESULT" \
|
"race shards:$RACE_RESULT" \
|
||||||
"release scripts:$RELEASE_SCRIPTS_RESULT"
|
"release scripts:$RELEASE_SCRIPTS_RESULT" \
|
||||||
|
"cross-platform compile:$CROSS_PLATFORM_RESULT"
|
||||||
do
|
do
|
||||||
name="${shard%%:*}"
|
name="${shard%%:*}"
|
||||||
result="${shard#*:}"
|
result="${shard#*:}"
|
||||||
@@ -160,10 +310,28 @@ jobs:
|
|||||||
failed=1
|
failed=1
|
||||||
fi
|
fi
|
||||||
done
|
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 "$failed" -eq 0
|
||||||
|
|
||||||
test-darwin:
|
test-darwin:
|
||||||
name: Test (macOS auth/keychain)
|
name: Test (macOS auth/keychain)
|
||||||
|
needs: lint
|
||||||
|
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.platform_sensitive == 'true' }}
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
steps:
|
steps:
|
||||||
@@ -180,6 +348,8 @@ jobs:
|
|||||||
|
|
||||||
test-windows:
|
test-windows:
|
||||||
name: Test (Windows)
|
name: Test (Windows)
|
||||||
|
needs: lint
|
||||||
|
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.platform_sensitive == 'true' }}
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
steps:
|
steps:
|
||||||
@@ -202,6 +372,8 @@ jobs:
|
|||||||
|
|
||||||
coverage-darwin:
|
coverage-darwin:
|
||||||
name: Coverage (macOS)
|
name: Coverage (macOS)
|
||||||
|
needs: lint
|
||||||
|
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.platform_sensitive == 'true' }}
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
steps:
|
steps:
|
||||||
@@ -244,6 +416,8 @@ jobs:
|
|||||||
|
|
||||||
coverage-windows:
|
coverage-windows:
|
||||||
name: Coverage (Windows)
|
name: Coverage (Windows)
|
||||||
|
needs: lint
|
||||||
|
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.platform_sensitive == 'true' }}
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
steps:
|
steps:
|
||||||
@@ -288,6 +462,8 @@ jobs:
|
|||||||
|
|
||||||
coverage-current:
|
coverage-current:
|
||||||
name: Coverage (current)
|
name: Coverage (current)
|
||||||
|
needs: lint
|
||||||
|
if: ${{ needs.lint.outputs.changelog_only != 'true' }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
steps:
|
steps:
|
||||||
@@ -321,6 +497,8 @@ jobs:
|
|||||||
|
|
||||||
coverage-supporting:
|
coverage-supporting:
|
||||||
name: Coverage (supporting)
|
name: Coverage (supporting)
|
||||||
|
needs: lint
|
||||||
|
if: ${{ needs.lint.outputs.changelog_only != 'true' }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
@@ -358,6 +536,8 @@ jobs:
|
|||||||
|
|
||||||
coverage-baseline:
|
coverage-baseline:
|
||||||
name: Coverage (baseline)
|
name: Coverage (baseline)
|
||||||
|
needs: lint
|
||||||
|
if: ${{ needs.lint.outputs.changelog_only != 'true' }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
steps:
|
steps:
|
||||||
@@ -421,20 +601,35 @@ jobs:
|
|||||||
coverage:
|
coverage:
|
||||||
name: Coverage
|
name: Coverage
|
||||||
needs:
|
needs:
|
||||||
|
- lint
|
||||||
- coverage-current
|
- coverage-current
|
||||||
- coverage-supporting
|
- coverage-supporting
|
||||||
- coverage-baseline
|
- coverage-baseline
|
||||||
if: ${{ always() }}
|
- coverage-darwin
|
||||||
|
- coverage-windows
|
||||||
|
if: ${{ always() && needs.lint.result == 'success' }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- name: Verify coverage profile jobs
|
- name: Verify coverage profile jobs
|
||||||
env:
|
env:
|
||||||
|
CHANGELOG_ONLY: ${{ needs.lint.outputs.changelog_only }}
|
||||||
|
PLATFORM_SENSITIVE: ${{ needs.lint.outputs.platform_sensitive }}
|
||||||
CURRENT_RESULT: ${{ needs.coverage-current.result }}
|
CURRENT_RESULT: ${{ needs.coverage-current.result }}
|
||||||
SUPPORTING_RESULT: ${{ needs.coverage-supporting.result }}
|
SUPPORTING_RESULT: ${{ needs.coverage-supporting.result }}
|
||||||
BASELINE_RESULT: ${{ needs.coverage-baseline.result }}
|
BASELINE_RESULT: ${{ needs.coverage-baseline.result }}
|
||||||
|
DARWIN_RESULT: ${{ needs.coverage-darwin.result }}
|
||||||
|
WINDOWS_RESULT: ${{ needs.coverage-windows.result }}
|
||||||
run: |
|
run: |
|
||||||
failed=0
|
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 \
|
for profile in \
|
||||||
"current:$CURRENT_RESULT" \
|
"current:$CURRENT_RESULT" \
|
||||||
"supporting:$SUPPORTING_RESULT" \
|
"supporting:$SUPPORTING_RESULT" \
|
||||||
@@ -443,24 +638,42 @@ jobs:
|
|||||||
name="${profile%%:*}"
|
name="${profile%%:*}"
|
||||||
result="${profile#*:}"
|
result="${profile#*:}"
|
||||||
printf '%s: %s\n' "$name" "$result"
|
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
|
failed=1
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
test "$failed" -eq 0
|
test "$failed" -eq 0
|
||||||
|
|
||||||
- name: Check out repository
|
- name: Check out repository
|
||||||
|
if: needs.lint.outputs.changelog_only != 'true'
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
|
if: needs.lint.outputs.changelog_only != 'true'
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
|
|
||||||
- name: Resolve authoritative coverage base
|
- name: Resolve authoritative coverage base
|
||||||
|
if: needs.lint.outputs.changelog_only != 'true'
|
||||||
env:
|
env:
|
||||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||||
@@ -478,33 +691,40 @@ jobs:
|
|||||||
echo "COVERAGE_BASE_REF=$base_ref" >> "$GITHUB_ENV"
|
echo "COVERAGE_BASE_REF=$base_ref" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Download current coverage profile
|
- name: Download current coverage profile
|
||||||
|
if: needs.lint.outputs.changelog_only != 'true'
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: coverage-current-profile
|
name: coverage-current-profile
|
||||||
path: .
|
path: .
|
||||||
|
|
||||||
- name: Download supporting coverage profiles
|
- name: Download supporting coverage profiles
|
||||||
|
if: needs.lint.outputs.changelog_only != 'true'
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: coverage-supporting-profiles
|
name: coverage-supporting-profiles
|
||||||
path: .
|
path: .
|
||||||
|
|
||||||
- name: Download baseline coverage profile
|
- name: Download baseline coverage profile
|
||||||
|
if: needs.lint.outputs.changelog_only != 'true'
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: coverage-baseline-profile
|
name: coverage-baseline-profile
|
||||||
path: .
|
path: .
|
||||||
|
|
||||||
- name: Enforce coverage gate
|
- name: Enforce coverage gate
|
||||||
|
if: needs.lint.outputs.changelog_only != 'true'
|
||||||
env:
|
env:
|
||||||
COVERAGE_TARGET: "80"
|
COVERAGE_TARGET: "100"
|
||||||
COVERAGE_ENFORCE_OVERALL: "false"
|
COVERAGE_ENFORCE_OVERALL: "false"
|
||||||
run: COVERAGE_ADDITIONAL_PROFILE=coverage-shortcut.txt make coverage-gate BASE_REF="$COVERAGE_BASE_REF"
|
COVERAGE_OVERALL_TOLERANCE: "0"
|
||||||
|
run: COVERAGE_ADDITIONAL_DIFF_PROFILE=coverage-shortcut.txt make coverage-gate BASE_REF="$COVERAGE_BASE_REF"
|
||||||
|
|
||||||
- name: Generate coverage report
|
- name: Generate coverage report
|
||||||
|
if: needs.lint.outputs.changelog_only != 'true'
|
||||||
run: go tool cover -html=coverage.txt -o coverage.html
|
run: go tool cover -html=coverage.txt -o coverage.html
|
||||||
|
|
||||||
- name: Upload coverage artifact
|
- name: Upload coverage artifact
|
||||||
|
if: needs.lint.outputs.changelog_only != 'true'
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: coverage-report
|
name: coverage-report
|
||||||
@@ -516,44 +736,114 @@ jobs:
|
|||||||
coverage.html
|
coverage.html
|
||||||
|
|
||||||
policy:
|
policy:
|
||||||
name: Policy Check
|
name: Policy
|
||||||
|
needs: lint
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repository
|
- name: Check out repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
|
if: needs.lint.outputs.changelog_only != 'true'
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
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
|
- name: Build
|
||||||
|
if: needs.lint.outputs.changelog_only != 'true'
|
||||||
run: make build
|
run: make build
|
||||||
|
|
||||||
- name: Policy
|
- name: Policy
|
||||||
|
if: needs.lint.outputs.changelog_only != 'true'
|
||||||
run: make policy
|
run: make policy
|
||||||
|
|
||||||
interface-integrity:
|
interface-integrity:
|
||||||
name: Interface Integrity
|
name: Interface Integrity
|
||||||
|
needs: lint
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repository
|
- name: Check out repository
|
||||||
|
if: needs.lint.outputs.changelog_only != 'true'
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
|
if: needs.lint.outputs.changelog_only != 'true'
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
|
if: needs.lint.outputs.changelog_only != 'true'
|
||||||
run: make build
|
run: make build
|
||||||
|
|
||||||
- name: Resolve authoritative compatibility merge-base
|
- name: Resolve authoritative compatibility merge-base
|
||||||
|
if: needs.lint.outputs.changelog_only != 'true'
|
||||||
env:
|
env:
|
||||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||||
@@ -578,6 +868,7 @@ jobs:
|
|||||||
echo "COMPATIBILITY_STABLE_REF=$stable_ref" >> "$GITHUB_ENV"
|
echo "COMPATIBILITY_STABLE_REF=$stable_ref" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Check historical commands and help compatibility
|
- name: Check historical commands and help compatibility
|
||||||
|
if: needs.lint.outputs.changelog_only != 'true'
|
||||||
run: |
|
run: |
|
||||||
make authoritative-interface-integrity \
|
make authoritative-interface-integrity \
|
||||||
BASE_REF="$COMPATIBILITY_BASE_REF"
|
BASE_REF="$COMPATIBILITY_BASE_REF"
|
||||||
@@ -587,143 +878,89 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Check complete Schema compatibility
|
- name: Check complete Schema compatibility
|
||||||
|
if: needs.lint.outputs.changelog_only != 'true'
|
||||||
run: make schema-compatibility BASE_REF="$COMPATIBILITY_BASE_REF"
|
run: make schema-compatibility BASE_REF="$COMPATIBILITY_BASE_REF"
|
||||||
|
|
||||||
- name: Check skill command references
|
- name: Check skill command references
|
||||||
|
if: needs.lint.outputs.changelog_only != 'true'
|
||||||
run: make skill-command-integrity
|
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:
|
cli-smoke:
|
||||||
name: CLI Smoke
|
name: CLI Smoke
|
||||||
|
needs: lint
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repository
|
- name: Check out repository
|
||||||
|
if: needs.lint.outputs.changelog_only != 'true'
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
|
if: needs.lint.outputs.changelog_only != 'true'
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
|
if: needs.lint.outputs.changelog_only != 'true'
|
||||||
run: make build
|
run: make build
|
||||||
|
|
||||||
- name: Check public top-level commands
|
- name: Check public top-level commands
|
||||||
|
if: needs.lint.outputs.changelog_only != 'true'
|
||||||
run: make cli-smoke
|
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:
|
mock-mcp-smoke:
|
||||||
name: Mock MCP Smoke
|
name: Mock MCP
|
||||||
|
needs: lint
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repository
|
- name: Check out repository
|
||||||
|
if: needs.lint.outputs.changelog_only != 'true'
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
|
if: needs.lint.outputs.changelog_only != 'true'
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
|
|
||||||
- name: Check HTTP and stdio MCP transport
|
- name: Check HTTP and stdio MCP transport
|
||||||
|
if: needs.lint.outputs.changelog_only != 'true'
|
||||||
run: make mock-mcp-smoke
|
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:
|
edition-tests:
|
||||||
name: Edition Contract Tests
|
name: Edition
|
||||||
|
needs: lint
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repository
|
- name: Check out repository
|
||||||
|
if: needs.lint.outputs.changelog_only != 'true'
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
|
if: needs.lint.outputs.changelog_only != 'true'
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
go-version-file: go.mod
|
||||||
|
|
||||||
- name: Run edition contract tests
|
- name: Run edition contract tests
|
||||||
|
if: needs.lint.outputs.changelog_only != 'true'
|
||||||
run: go test -v -count=1 ./pkg/editiontest/...
|
run: go test -v -count=1 ./pkg/editiontest/...
|
||||||
|
|
||||||
ci-gate:
|
- name: Record CHANGELOG-only fast path
|
||||||
name: CI Gate
|
if: needs.lint.outputs.changelog_only == 'true'
|
||||||
needs:
|
run: echo "Edition is unaffected by an exact CHANGELOG-only diff." >> "$GITHUB_STEP_SUMMARY"
|
||||||
- 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
|
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
name: Multi Profile E2E
|
name: Main Integration — 主干集成
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
|
||||||
push:
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
@@ -14,7 +15,7 @@ concurrency:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
multi-profile-e2e:
|
multi-profile-e2e:
|
||||||
name: Multi Profile E2E
|
name: Multi-profile E2E
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
env:
|
env:
|
||||||
@@ -36,7 +37,7 @@ jobs:
|
|||||||
mkdir -p .tmp-bin
|
mkdir -p .tmp-bin
|
||||||
bash scripts/dev/test-multi-profile-e2e.sh --keep-workdir | tee "$MULTI_PROFILE_E2E_LOG"
|
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 "- 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 "- Scope: isolated auth/profile storage, profile switch/use, one-shot profile override, CSV multi-profile aggregation, legacy migration"
|
||||||
echo "- Result: passed"
|
echo "- Result: passed"
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
name: Main Integration — Wukong Overlay
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows:
|
||||||
|
- CI
|
||||||
|
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
|
||||||
+513
-54
@@ -1,5 +1,5 @@
|
|||||||
name: Release
|
name: Release
|
||||||
run-name: ${{ github.event_name == 'workflow_dispatch' && inputs.governance_preflight_nonce != '' && format('Release governance preflight {0}', inputs.governance_preflight_nonce) || github.event_name == 'workflow_dispatch' && inputs.recover_release_version != '' && format('Release recovery {0} at {1} {2}', inputs.recover_release_version, inputs.recover_release_commit, inputs.recover_release_nonce) || github.event_name == 'workflow_dispatch' && inputs.repair_npm_version != '' && format('Release npm repair {0}', inputs.repair_npm_version) || github.workflow }}
|
run-name: ${{ github.event_name == 'workflow_dispatch' && inputs.governance_preflight_nonce != '' && format('Release governance preflight {0}', inputs.governance_preflight_nonce) || github.event_name == 'workflow_dispatch' && inputs.recover_release_version != '' && format('Release recovery {0} at {1} {2}', inputs.recover_release_version, inputs.recover_release_commit, inputs.recover_release_nonce) || github.event_name == 'workflow_dispatch' && inputs.repair_npm_version != '' && format('Release npm repair {0}', inputs.repair_npm_version) || github.event_name == 'workflow_dispatch' && inputs.repair_gitee_version != '' && format('Release Gitee repair {0}', inputs.repair_gitee_version) || github.event_name == 'workflow_dispatch' && inputs.repair_oss_version != '' && format('Release OSS repair {0}', inputs.repair_oss_version) || github.workflow }}
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -11,6 +11,14 @@ on:
|
|||||||
description: "Recover npm for a public immutable release without moving latest/beta"
|
description: "Recover npm for a public immutable release without moving latest/beta"
|
||||||
required: false
|
required: false
|
||||||
type: string
|
type: string
|
||||||
|
repair_gitee_version:
|
||||||
|
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:
|
governance_preflight_commit:
|
||||||
description: "Verify release governance for this exact default-branch commit without publishing"
|
description: "Verify release governance for this exact default-branch commit without publishing"
|
||||||
required: false
|
required: false
|
||||||
@@ -51,9 +59,9 @@ on:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
# Queue governance checks, tag releases, protected recoveries, and npm repairs
|
# Queue governance checks, tag releases, protected recoveries, and channel
|
||||||
# behind one publication lock. The local entrypoint also refuses to tag while
|
# repairs behind one publication lock. The local entrypoint also refuses to tag
|
||||||
# a Release run is active.
|
# while a Release run is active.
|
||||||
concurrency:
|
concurrency:
|
||||||
group: dws-release-publication
|
group: dws-release-publication
|
||||||
queue: max
|
queue: max
|
||||||
@@ -70,6 +78,8 @@ jobs:
|
|||||||
id: mode
|
id: mode
|
||||||
env:
|
env:
|
||||||
REPAIR_NPM_VERSION: ${{ inputs.repair_npm_version }}
|
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_COMMIT: ${{ inputs.governance_preflight_commit }}
|
||||||
GOVERNANCE_NONCE: ${{ inputs.governance_preflight_nonce }}
|
GOVERNANCE_NONCE: ${{ inputs.governance_preflight_nonce }}
|
||||||
RECOVER_VERSION: ${{ inputs.recover_release_version }}
|
RECOVER_VERSION: ${{ inputs.recover_release_version }}
|
||||||
@@ -81,22 +91,30 @@ jobs:
|
|||||||
RECOVER_CONFIRMATION: ${{ inputs.recover_release_confirmation }}
|
RECOVER_CONFIRMATION: ${{ inputs.recover_release_confirmation }}
|
||||||
run: |
|
run: |
|
||||||
set -eu
|
set -eu
|
||||||
repair=0
|
npm_repair=0
|
||||||
|
gitee_repair=0
|
||||||
|
oss_repair=0
|
||||||
governance=0
|
governance=0
|
||||||
recovery=0
|
recovery=0
|
||||||
test -z "$REPAIR_NPM_VERSION" || repair=1
|
test -z "$REPAIR_NPM_VERSION" || npm_repair=1
|
||||||
|
test -z "$REPAIR_GITEE_VERSION" || gitee_repair=1
|
||||||
|
test -z "$REPAIR_OSS_VERSION" || oss_repair=1
|
||||||
if test -n "$GOVERNANCE_COMMIT" || test -n "$GOVERNANCE_NONCE"; then governance=1; fi
|
if test -n "$GOVERNANCE_COMMIT" || test -n "$GOVERNANCE_NONCE"; then governance=1; fi
|
||||||
if test -n "$RECOVER_VERSION" || test -n "$RECOVER_TAG_OBJECT" || \
|
if test -n "$RECOVER_VERSION" || test -n "$RECOVER_TAG_OBJECT" || \
|
||||||
test -n "$RECOVER_COMMIT" || test -n "$RECOVER_FAILED_RUN_ID" || \
|
test -n "$RECOVER_COMMIT" || test -n "$RECOVER_FAILED_RUN_ID" || \
|
||||||
test -n "$RECOVER_FAILED_RUN_ATTEMPT" || \
|
test -n "$RECOVER_FAILED_RUN_ATTEMPT" || \
|
||||||
test -n "$RECOVER_NONCE" || \
|
test -n "$RECOVER_NONCE" || \
|
||||||
test -n "$RECOVER_CONFIRMATION"; then recovery=1; fi
|
test -n "$RECOVER_CONFIRMATION"; then recovery=1; fi
|
||||||
test $((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
|
echo "workflow_dispatch must select exactly one release mode" >&2
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
if test "$repair" -eq 1; then
|
if test "$npm_repair" -eq 1; then
|
||||||
echo "mode=repair_npm" >> "$GITHUB_OUTPUT"
|
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
|
elif test "$governance" -eq 1; then
|
||||||
if test -z "$GOVERNANCE_COMMIT" || test -z "$GOVERNANCE_NONCE"; then
|
if test -z "$GOVERNANCE_COMMIT" || test -z "$GOVERNANCE_NONCE"; then
|
||||||
echo "governance preflight requires both commit and nonce" >&2
|
echo "governance preflight requires both commit and nonce" >&2
|
||||||
@@ -226,26 +244,56 @@ jobs:
|
|||||||
exit 1
|
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
|
uses: actions/github-script@v7
|
||||||
env:
|
env:
|
||||||
PREFLIGHT_COMMIT: ${{ inputs.governance_preflight_commit }}
|
PREFLIGHT_COMMIT: ${{ inputs.governance_preflight_commit }}
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
const { owner, repo } = context.repo;
|
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,
|
owner,
|
||||||
repo,
|
repo,
|
||||||
ref: process.env.PREFLIGHT_COMMIT,
|
ref: sha,
|
||||||
check_name: "CI Gate",
|
|
||||||
filter: "latest",
|
filter: "latest",
|
||||||
per_page: 100,
|
per_page: 100,
|
||||||
});
|
});
|
||||||
const passed = data.check_runs.some(
|
const latestByName = new Map();
|
||||||
(run) => run.name === "CI Gate" && run.conclusion === "success",
|
for (const run of runs) {
|
||||||
);
|
if (run.head_sha !== sha || !requiredContexts.includes(run.name)) {
|
||||||
if (!passed) {
|
continue;
|
||||||
core.setFailed(`CI Gate has not succeeded for ${process.env.PREFLIGHT_COMMIT}`);
|
}
|
||||||
|
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
|
- name: Check out trusted preflight tooling
|
||||||
@@ -493,26 +541,56 @@ jobs:
|
|||||||
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-github-tag-authority.sh" \
|
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-github-tag-authority.sh" \
|
||||||
"$RELEASE_VERSION" "$RELEASE_COMMIT" "$RELEASE_TAG_OBJECT"
|
"$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
|
uses: actions/github-script@v7
|
||||||
env:
|
env:
|
||||||
RELEASE_COMMIT: ${{ steps.target.outputs.release_commit }}
|
RELEASE_COMMIT: ${{ steps.target.outputs.release_commit }}
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
const { owner, repo } = context.repo;
|
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,
|
owner,
|
||||||
repo,
|
repo,
|
||||||
ref: process.env.RELEASE_COMMIT,
|
ref: sha,
|
||||||
check_name: "CI Gate",
|
|
||||||
filter: "latest",
|
filter: "latest",
|
||||||
per_page: 100,
|
per_page: 100,
|
||||||
});
|
});
|
||||||
const passed = data.check_runs.some(
|
const latestByName = new Map();
|
||||||
(run) => run.name === "CI Gate" && run.conclusion === "success",
|
for (const run of runs) {
|
||||||
);
|
if (run.head_sha !== sha || !requiredContexts.includes(run.name)) {
|
||||||
if (!passed) {
|
continue;
|
||||||
core.setFailed(`CI Gate has not succeeded for ${process.env.RELEASE_COMMIT}`);
|
}
|
||||||
|
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
|
- name: Require delivered previous stable baseline
|
||||||
@@ -712,7 +790,7 @@ jobs:
|
|||||||
- name: Install archive tooling
|
- name: Install archive tooling
|
||||||
run: sudo apt-get update && sudo apt-get install -y zip unzip
|
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
|
run: bash scripts/dev/test-multi-profile-e2e.sh
|
||||||
|
|
||||||
- name: Install rcodesign (sign darwin binaries from Linux)
|
- name: Install rcodesign (sign darwin binaries from Linux)
|
||||||
@@ -929,6 +1007,7 @@ jobs:
|
|||||||
"$RELEASE_VERSION" "$RELEASE_COMMIT" "$RELEASE_TAG_OBJECT"
|
"$RELEASE_VERSION" "$RELEASE_COMMIT" "$RELEASE_TAG_OBJECT"
|
||||||
|
|
||||||
- name: Publish or reuse immutable GitHub Release
|
- name: Publish or reuse immutable GitHub Release
|
||||||
|
id: publish
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
RELEASE_CHANNEL: ${{ needs.release-contract.outputs.channel }}
|
RELEASE_CHANNEL: ${{ needs.release-contract.outputs.channel }}
|
||||||
@@ -941,9 +1020,36 @@ jobs:
|
|||||||
prerelease_flag="--prerelease"
|
prerelease_flag="--prerelease"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
release_id=""
|
||||||
if gh release view "$RELEASE_VERSION" >/dev/null 2>&1; then
|
if gh release view "$RELEASE_VERSION" >/dev/null 2>&1; then
|
||||||
is_draft="$(gh release view "$RELEASE_VERSION" --json isDraft --jq .isDraft)"
|
release_id="$(
|
||||||
is_prerelease="$(gh release view "$RELEASE_VERSION" --json isPrerelease --jq .isPrerelease)"
|
gh release view "$RELEASE_VERSION" \
|
||||||
|
--json databaseId \
|
||||||
|
--jq .databaseId
|
||||||
|
)"
|
||||||
|
printf '%s\n' "$release_id" | grep -Eq '^[1-9][0-9]*$' || {
|
||||||
|
echo "Existing GitHub Release has an invalid database ID: $release_id" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
resolved_tag="$(
|
||||||
|
gh api -H 'X-GitHub-Api-Version: 2026-03-10' \
|
||||||
|
"repos/$GITHUB_REPOSITORY/releases/$release_id" \
|
||||||
|
--jq .tag_name
|
||||||
|
)"
|
||||||
|
test "$resolved_tag" = "$RELEASE_VERSION" || {
|
||||||
|
echo "GitHub Release ID $release_id targets $resolved_tag, not $RELEASE_VERSION." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
is_draft="$(
|
||||||
|
gh api -H 'X-GitHub-Api-Version: 2026-03-10' \
|
||||||
|
"repos/$GITHUB_REPOSITORY/releases/$release_id" \
|
||||||
|
--jq .draft
|
||||||
|
)"
|
||||||
|
is_prerelease="$(
|
||||||
|
gh api -H 'X-GitHub-Api-Version: 2026-03-10' \
|
||||||
|
"repos/$GITHUB_REPOSITORY/releases/$release_id" \
|
||||||
|
--jq .prerelease
|
||||||
|
)"
|
||||||
if [ "$is_prerelease" != "$expected_prerelease" ]; then
|
if [ "$is_prerelease" != "$expected_prerelease" ]; then
|
||||||
echo "Existing release channel does not match $RELEASE_CHANNEL." >&2
|
echo "Existing release channel does not match $RELEASE_CHANNEL." >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -951,14 +1057,18 @@ jobs:
|
|||||||
if [ "$is_draft" = "false" ]; then
|
if [ "$is_draft" = "false" ]; then
|
||||||
is_immutable="$(
|
is_immutable="$(
|
||||||
gh api -H 'X-GitHub-Api-Version: 2026-03-10' \
|
gh api -H 'X-GitHub-Api-Version: 2026-03-10' \
|
||||||
"repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_VERSION" \
|
"repos/$GITHUB_REPOSITORY/releases/$release_id" \
|
||||||
--jq .immutable
|
--jq .immutable
|
||||||
)"
|
)"
|
||||||
if [ "$is_immutable" != "true" ]; then
|
if [ "$is_immutable" != "true" ]; then
|
||||||
echo "Published release is mutable; refusing to reuse its assets." >&2
|
echo "Published release is mutable; refusing to reuse its assets." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
remote_body="$(gh release view "$RELEASE_VERSION" --json body --jq .body)"
|
remote_body="$(
|
||||||
|
gh api -H 'X-GitHub-Api-Version: 2026-03-10' \
|
||||||
|
"repos/$GITHUB_REPOSITORY/releases/$release_id" \
|
||||||
|
--jq .body
|
||||||
|
)"
|
||||||
local_body="$(cat "$RUNNER_TEMP/release-notes.md")"
|
local_body="$(cat "$RUNNER_TEMP/release-notes.md")"
|
||||||
if [ "$remote_body" != "$local_body" ]; then
|
if [ "$remote_body" != "$local_body" ]; then
|
||||||
echo "Published release notes differ from the sealed CHANGELOG." >&2
|
echo "Published release notes differ from the sealed CHANGELOG." >&2
|
||||||
@@ -976,11 +1086,9 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
published_dir="$RUNNER_TEMP/published-release"
|
published_dir="$RUNNER_TEMP/published-release"
|
||||||
mkdir -p "$published_dir"
|
mkdir -p "$published_dir"
|
||||||
gh release download "$RELEASE_VERSION" \
|
DWS_GITHUB_RELEASE_ID="$release_id" \
|
||||||
--dir "$published_dir" \
|
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/download-github-release-assets.sh" \
|
||||||
--pattern 'dws-*' \
|
"$RELEASE_VERSION" "$published_dir"
|
||||||
--pattern 'checksums.txt' \
|
|
||||||
--clobber
|
|
||||||
DWS_PACKAGE_DIST_DIR="$published_dir" \
|
DWS_PACKAGE_DIST_DIR="$published_dir" \
|
||||||
./scripts/release/verify-release-artifacts.sh "$RELEASE_VERSION"
|
./scripts/release/verify-release-artifacts.sh "$RELEASE_VERSION"
|
||||||
for asset in "$published_dir"/dws-* "$published_dir"/checksums.txt; do
|
for asset in "$published_dir"/dws-* "$published_dir"/checksums.txt; do
|
||||||
@@ -993,12 +1101,10 @@ jobs:
|
|||||||
DWS_PACKAGE_DIST_DIR="$GITHUB_WORKSPACE/dist" \
|
DWS_PACKAGE_DIST_DIR="$GITHUB_WORKSPACE/dist" \
|
||||||
DWS_PACKAGE_SOURCE_ROOT="$GITHUB_WORKSPACE" \
|
DWS_PACKAGE_SOURCE_ROOT="$GITHUB_WORKSPACE" \
|
||||||
./scripts/release/stage-npm-package.sh "$RELEASE_VERSION"
|
./scripts/release/stage-npm-package.sh "$RELEASE_VERSION"
|
||||||
|
echo "release_id=$release_id" >> "$GITHUB_OUTPUT"
|
||||||
echo "Published GitHub Release is immutable and verified; reusing its exact assets."
|
echo "Published GitHub Release is immutable and verified; reusing its exact assets."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
gh release edit "$RELEASE_VERSION" \
|
|
||||||
--title "$RELEASE_VERSION" \
|
|
||||||
--notes-file "$RUNNER_TEMP/release-notes.md"
|
|
||||||
else
|
else
|
||||||
# shellcheck disable=SC2086
|
# shellcheck disable=SC2086
|
||||||
gh release create "$RELEASE_VERSION" \
|
gh release create "$RELEASE_VERSION" \
|
||||||
@@ -1007,6 +1113,63 @@ jobs:
|
|||||||
--title "$RELEASE_VERSION" \
|
--title "$RELEASE_VERSION" \
|
||||||
--notes-file "$RUNNER_TEMP/release-notes.md" \
|
--notes-file "$RUNNER_TEMP/release-notes.md" \
|
||||||
$prerelease_flag
|
$prerelease_flag
|
||||||
|
release_id="$(
|
||||||
|
gh release view "$RELEASE_VERSION" \
|
||||||
|
--json databaseId \
|
||||||
|
--jq .databaseId
|
||||||
|
)"
|
||||||
|
fi
|
||||||
|
printf '%s\n' "$release_id" | grep -Eq '^[1-9][0-9]*$' || {
|
||||||
|
echo "Draft GitHub Release has an invalid database ID: $release_id" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
resolved_tag="$(
|
||||||
|
gh api -H 'X-GitHub-Api-Version: 2026-03-10' \
|
||||||
|
"repos/$GITHUB_REPOSITORY/releases/$release_id" \
|
||||||
|
--jq .tag_name
|
||||||
|
)"
|
||||||
|
test "$resolved_tag" = "$RELEASE_VERSION" || {
|
||||||
|
echo "Draft GitHub Release ID $release_id targets $resolved_tag, not $RELEASE_VERSION." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
gh api --method PATCH \
|
||||||
|
-H 'X-GitHub-Api-Version: 2026-03-10' \
|
||||||
|
"repos/$GITHUB_REPOSITORY/releases/$release_id" \
|
||||||
|
-f tag_name="$RELEASE_VERSION" \
|
||||||
|
-f target_commitish="$RELEASE_COMMIT" \
|
||||||
|
-f name="$RELEASE_VERSION" \
|
||||||
|
-f body="$(cat "$RUNNER_TEMP/release-notes.md")" \
|
||||||
|
-F draft=true \
|
||||||
|
-F prerelease="$expected_prerelease" \
|
||||||
|
>/dev/null
|
||||||
|
draft_state="$(
|
||||||
|
gh api -H 'X-GitHub-Api-Version: 2026-03-10' \
|
||||||
|
"repos/$GITHUB_REPOSITORY/releases/$release_id" \
|
||||||
|
--jq '[.tag_name, .draft, .prerelease] | @tsv'
|
||||||
|
)"
|
||||||
|
test "$draft_state" = "$(printf '%s\ttrue\t%s' "$RELEASE_VERSION" "$expected_prerelease")" || {
|
||||||
|
echo "Draft GitHub Release identity or channel changed before upload." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
draft_body="$(
|
||||||
|
gh api -H 'X-GitHub-Api-Version: 2026-03-10' \
|
||||||
|
"repos/$GITHUB_REPOSITORY/releases/$release_id" \
|
||||||
|
--jq .body
|
||||||
|
)"
|
||||||
|
local_body="$(cat "$RUNNER_TEMP/release-notes.md")"
|
||||||
|
test "$draft_body" = "$local_body" || {
|
||||||
|
echo "Draft GitHub Release notes differ from the sealed CHANGELOG." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
if [ "$IS_RECOVERY" = "true" ]; then
|
||||||
|
recovery_marker="<!-- dws-release-recovery run=$GITHUB_RUN_ID tag-object=$RELEASE_TAG_OBJECT commit=$RELEASE_COMMIT -->"
|
||||||
|
case "$draft_body" in
|
||||||
|
*"$recovery_marker"*) ;;
|
||||||
|
*)
|
||||||
|
echo "Draft GitHub Release is not bound to this exact recovery run." >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
fi
|
fi
|
||||||
gh release upload "$RELEASE_VERSION" \
|
gh release upload "$RELEASE_VERSION" \
|
||||||
dist/dws-*.tar.gz \
|
dist/dws-*.tar.gz \
|
||||||
@@ -1014,15 +1177,24 @@ jobs:
|
|||||||
dist/dws-skills.zip \
|
dist/dws-skills.zip \
|
||||||
dist/checksums.txt \
|
dist/checksums.txt \
|
||||||
--clobber
|
--clobber
|
||||||
./scripts/release/verify-github-release-assets.sh "$RELEASE_VERSION"
|
uploaded_release_id="$(
|
||||||
|
gh release view "$RELEASE_VERSION" \
|
||||||
|
--json databaseId \
|
||||||
|
--jq .databaseId
|
||||||
|
)"
|
||||||
|
test "$uploaded_release_id" = "$release_id" || {
|
||||||
|
echo "GitHub Release identity changed during upload: $release_id -> $uploaded_release_id" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
DWS_GITHUB_RELEASE_ID="$release_id" \
|
||||||
|
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-github-release-assets.sh" \
|
||||||
|
"$RELEASE_VERSION"
|
||||||
sealed_upload="$RUNNER_TEMP/sealed-upload"
|
sealed_upload="$RUNNER_TEMP/sealed-upload"
|
||||||
rm -rf "$sealed_upload"
|
rm -rf "$sealed_upload"
|
||||||
mkdir -p "$sealed_upload"
|
mkdir -p "$sealed_upload"
|
||||||
gh release download "$RELEASE_VERSION" \
|
DWS_GITHUB_RELEASE_ID="$release_id" \
|
||||||
--dir "$sealed_upload" \
|
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/download-github-release-assets.sh" \
|
||||||
--pattern 'dws-*' \
|
"$RELEASE_VERSION" "$sealed_upload"
|
||||||
--pattern 'checksums.txt' \
|
|
||||||
--clobber
|
|
||||||
DWS_PACKAGE_DIST_DIR="$sealed_upload" \
|
DWS_PACKAGE_DIST_DIR="$sealed_upload" \
|
||||||
./scripts/release/verify-release-artifacts.sh "$RELEASE_VERSION"
|
./scripts/release/verify-release-artifacts.sh "$RELEASE_VERSION"
|
||||||
for remote_asset in "$sealed_upload"/dws-* "$sealed_upload"/checksums.txt; do
|
for remote_asset in "$sealed_upload"/dws-* "$sealed_upload"/checksums.txt; do
|
||||||
@@ -1032,23 +1204,76 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
done
|
done
|
||||||
gh release edit "$RELEASE_VERSION" --draft=false
|
gh api --method PATCH \
|
||||||
|
-H 'X-GitHub-Api-Version: 2026-03-10' \
|
||||||
|
"repos/$GITHUB_REPOSITORY/releases/$release_id" \
|
||||||
|
-f name="$RELEASE_VERSION" \
|
||||||
|
-f body="$(cat "$RUNNER_TEMP/release-notes.md")" \
|
||||||
|
-F prerelease="$expected_prerelease" \
|
||||||
|
-F draft=false \
|
||||||
|
>/dev/null
|
||||||
|
echo "release_id=$release_id" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Require immutable published GitHub Release
|
- name: Require immutable published GitHub Release
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
RELEASE_ID: ${{ steps.publish.outputs.release_id }}
|
||||||
|
RELEASE_CHANNEL: ${{ needs.release-contract.outputs.channel }}
|
||||||
run: |
|
run: |
|
||||||
set -eu
|
set -eu
|
||||||
immutable="$(
|
expected_prerelease=false
|
||||||
gh api -H 'X-GitHub-Api-Version: 2026-03-10' \
|
if [ "$RELEASE_CHANNEL" = "prerelease" ]; then expected_prerelease=true; fi
|
||||||
"repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_VERSION" \
|
printf '%s\n' "$RELEASE_ID" | grep -Eq '^[1-9][0-9]*$' || {
|
||||||
--jq .immutable
|
echo "Published GitHub Release has an invalid database ID: $RELEASE_ID" >&2
|
||||||
)"
|
|
||||||
test "$immutable" = true || {
|
|
||||||
echo "GitHub Release is not immutable; refusing downstream publication." >&2
|
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
./scripts/release/verify-github-release-assets.sh "$RELEASE_VERSION"
|
release_state="$(
|
||||||
|
gh api -H 'X-GitHub-Api-Version: 2026-03-10' \
|
||||||
|
"repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID" \
|
||||||
|
--jq '[.tag_name, .draft, .prerelease, .immutable] | @tsv'
|
||||||
|
)"
|
||||||
|
test "$release_state" = "$(printf '%s\tfalse\t%s\ttrue' "$RELEASE_VERSION" "$expected_prerelease")" || {
|
||||||
|
echo "GitHub Release ID $RELEASE_ID is not the exact public immutable $RELEASE_VERSION release." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
immutable_body="$(
|
||||||
|
gh api -H 'X-GitHub-Api-Version: 2026-03-10' \
|
||||||
|
"repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID" \
|
||||||
|
--jq .body
|
||||||
|
)"
|
||||||
|
local_body="$(cat "$RUNNER_TEMP/release-notes.md")"
|
||||||
|
test "$immutable_body" = "$local_body" || {
|
||||||
|
echo "Immutable GitHub Release notes differ from the sealed CHANGELOG." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
if [ "$IS_RECOVERY" = "true" ]; then
|
||||||
|
recovery_marker="<!-- dws-release-recovery run=$GITHUB_RUN_ID tag-object=$RELEASE_TAG_OBJECT commit=$RELEASE_COMMIT -->"
|
||||||
|
case "$immutable_body" in
|
||||||
|
*"$recovery_marker"*) ;;
|
||||||
|
*)
|
||||||
|
echo "Immutable GitHub Release is not bound to this exact recovery run." >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
DWS_GITHUB_RELEASE_ID="$RELEASE_ID" \
|
||||||
|
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-github-release-assets.sh" \
|
||||||
|
"$RELEASE_VERSION"
|
||||||
|
immutable_dir="$RUNNER_TEMP/immutable-release"
|
||||||
|
rm -rf "$immutable_dir"
|
||||||
|
mkdir -p "$immutable_dir"
|
||||||
|
DWS_GITHUB_RELEASE_ID="$RELEASE_ID" \
|
||||||
|
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/download-github-release-assets.sh" \
|
||||||
|
"$RELEASE_VERSION" "$immutable_dir"
|
||||||
|
DWS_PACKAGE_DIST_DIR="$immutable_dir" \
|
||||||
|
./scripts/release/verify-release-artifacts.sh "$RELEASE_VERSION"
|
||||||
|
for immutable_asset in "$immutable_dir"/dws-* "$immutable_dir"/checksums.txt; do
|
||||||
|
sealed_asset="dist/$(basename "$immutable_asset")"
|
||||||
|
cmp -s "$sealed_asset" "$immutable_asset" || {
|
||||||
|
echo "Immutable GitHub asset differs from this run's sealed bytes: $(basename "$immutable_asset")" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
done
|
||||||
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-github-tag-authority.sh" \
|
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-github-tag-authority.sh" \
|
||||||
"$RELEASE_VERSION" "$RELEASE_COMMIT" "$RELEASE_TAG_OBJECT"
|
"$RELEASE_VERSION" "$RELEASE_COMMIT" "$RELEASE_TAG_OBJECT"
|
||||||
|
|
||||||
@@ -1662,6 +1887,7 @@ jobs:
|
|||||||
- publish-channels
|
- publish-channels
|
||||||
- mirror-gitee-release
|
- mirror-gitee-release
|
||||||
- repair-npm
|
- repair-npm
|
||||||
|
- repair-channel
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 2
|
timeout-minutes: 2
|
||||||
permissions: {}
|
permissions: {}
|
||||||
@@ -1681,6 +1907,7 @@ jobs:
|
|||||||
PUBLISH_CHANNELS_RESULT: ${{ needs.publish-channels.result }}
|
PUBLISH_CHANNELS_RESULT: ${{ needs.publish-channels.result }}
|
||||||
MIRROR_GITEE_RESULT: ${{ needs.mirror-gitee-release.result }}
|
MIRROR_GITEE_RESULT: ${{ needs.mirror-gitee-release.result }}
|
||||||
REPAIR_NPM_RESULT: ${{ needs.repair-npm.result }}
|
REPAIR_NPM_RESULT: ${{ needs.repair-npm.result }}
|
||||||
|
REPAIR_CHANNEL_RESULT: ${{ needs.repair-channel.result }}
|
||||||
run: |
|
run: |
|
||||||
set -eu
|
set -eu
|
||||||
require_result() {
|
require_result() {
|
||||||
@@ -1710,6 +1937,7 @@ jobs:
|
|||||||
require_result authorize-recovery "$AUTHORIZE_RECOVERY_RESULT" skipped
|
require_result authorize-recovery "$AUTHORIZE_RECOVERY_RESULT" skipped
|
||||||
require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" skipped
|
require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" skipped
|
||||||
require_result repair-npm "$REPAIR_NPM_RESULT" skipped
|
require_result repair-npm "$REPAIR_NPM_RESULT" skipped
|
||||||
|
require_result repair-channel "$REPAIR_CHANNEL_RESULT" skipped
|
||||||
require_publication
|
require_publication
|
||||||
;;
|
;;
|
||||||
workflow_dispatch:recover_release)
|
workflow_dispatch:recover_release)
|
||||||
@@ -1717,6 +1945,7 @@ jobs:
|
|||||||
require_result authorize-recovery "$AUTHORIZE_RECOVERY_RESULT" success
|
require_result authorize-recovery "$AUTHORIZE_RECOVERY_RESULT" success
|
||||||
require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" skipped
|
require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" skipped
|
||||||
require_result repair-npm "$REPAIR_NPM_RESULT" skipped
|
require_result repair-npm "$REPAIR_NPM_RESULT" skipped
|
||||||
|
require_result repair-channel "$REPAIR_CHANNEL_RESULT" skipped
|
||||||
require_publication
|
require_publication
|
||||||
;;
|
;;
|
||||||
workflow_dispatch:governance_preflight)
|
workflow_dispatch:governance_preflight)
|
||||||
@@ -1730,6 +1959,7 @@ jobs:
|
|||||||
require_result publish-channels "$PUBLISH_CHANNELS_RESULT" skipped
|
require_result publish-channels "$PUBLISH_CHANNELS_RESULT" skipped
|
||||||
require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped
|
require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped
|
||||||
require_result repair-npm "$REPAIR_NPM_RESULT" skipped
|
require_result repair-npm "$REPAIR_NPM_RESULT" skipped
|
||||||
|
require_result repair-channel "$REPAIR_CHANNEL_RESULT" skipped
|
||||||
;;
|
;;
|
||||||
workflow_dispatch:repair_npm)
|
workflow_dispatch:repair_npm)
|
||||||
require_result dispatch-contract "$DISPATCH_RESULT" success
|
require_result dispatch-contract "$DISPATCH_RESULT" success
|
||||||
@@ -1742,9 +1972,238 @@ jobs:
|
|||||||
require_result publish-release "$PUBLISH_RELEASE_RESULT" skipped
|
require_result publish-release "$PUBLISH_RELEASE_RESULT" skipped
|
||||||
require_result publish-channels "$PUBLISH_CHANNELS_RESULT" skipped
|
require_result publish-channels "$PUBLISH_CHANNELS_RESULT" skipped
|
||||||
require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped
|
require_result mirror-gitee-release "$MIRROR_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-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
|
||||||
|
require_result release "$RELEASE_RESULT" skipped
|
||||||
|
require_result verify-darwin-signatures "$DARWIN_SIGNATURE_RESULT" skipped
|
||||||
|
require_result publish-release "$PUBLISH_RELEASE_RESULT" skipped
|
||||||
|
require_result publish-channels "$PUBLISH_CHANNELS_RESULT" skipped
|
||||||
|
require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped
|
||||||
|
require_result repair-npm "$REPAIR_NPM_RESULT" skipped
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "unsupported release mode: event=$EVENT_NAME mode=$DISPATCH_MODE" >&2
|
echo "unsupported release mode: event=$EVENT_NAME mode=$DISPATCH_MODE" >&2
|
||||||
exit 1
|
exit 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
# 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' || 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:
|
||||||
|
actions: read
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- name: Check out trusted release tooling
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ github.sha }}
|
||||||
|
path: tooling
|
||||||
|
persist-credentials: false
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Validate repair version
|
||||||
|
working-directory: tooling
|
||||||
|
env:
|
||||||
|
VERSION: ${{ inputs.repair_gitee_version || inputs.repair_oss_version }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
. ./scripts/release/release-lib.sh
|
||||||
|
if ! release_is_stable_version "$VERSION" && ! release_is_prerelease_version "$VERSION"; then
|
||||||
|
echo "Invalid release version: $VERSION" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Verify immutable release authority
|
||||||
|
id: authority
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
env:
|
||||||
|
VERSION: ${{ inputs.repair_gitee_version || inputs.repair_oss_version }}
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const { owner, repo } = context.repo;
|
||||||
|
const version = process.env.VERSION;
|
||||||
|
const ref = await github.rest.git.getRef({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
ref: `tags/${version}`,
|
||||||
|
});
|
||||||
|
if (ref.data.object.type !== "tag") {
|
||||||
|
core.setFailed(`${version} is not an annotated tag`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tag = await github.rest.git.getTag({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
tag_sha: ref.data.object.sha,
|
||||||
|
});
|
||||||
|
if (tag.data.tag !== version || tag.data.object.type !== "commit") {
|
||||||
|
core.setFailed(`${version} does not resolve to one annotated commit tag`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const commitSha = tag.data.object.sha;
|
||||||
|
const defaultBranch = context.payload.repository.default_branch;
|
||||||
|
const branch = await github.rest.repos.getBranch({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
branch: defaultBranch,
|
||||||
|
});
|
||||||
|
const comparison = await github.rest.repos.compareCommitsWithBasehead({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
basehead: `${commitSha}...${branch.data.commit.sha}`,
|
||||||
|
});
|
||||||
|
if (!["ahead", "identical"].includes(comparison.data.status)) {
|
||||||
|
core.setFailed(`${version} is not contained in ${defaultBranch}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const release = await github.rest.repos.getReleaseByTag({
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
tag: version,
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
release.data.tag_name !== version ||
|
||||||
|
release.data.draft ||
|
||||||
|
!release.data.immutable
|
||||||
|
) {
|
||||||
|
core.setFailed(`${version} is not a public immutable GitHub Release`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const expectedPrerelease = version.includes("-beta.");
|
||||||
|
if (release.data.prerelease !== expectedPrerelease) {
|
||||||
|
core.setFailed(`${version} prerelease metadata does not match its version`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const expectedAssets = new Set([
|
||||||
|
"dws-darwin-amd64.tar.gz",
|
||||||
|
"dws-darwin-arm64.tar.gz",
|
||||||
|
"dws-linux-amd64.tar.gz",
|
||||||
|
"dws-linux-arm64.tar.gz",
|
||||||
|
"dws-windows-amd64.zip",
|
||||||
|
"dws-windows-arm64.zip",
|
||||||
|
"dws-skills.zip",
|
||||||
|
"checksums.txt",
|
||||||
|
]);
|
||||||
|
const assetNames = release.data.assets.map((asset) => asset.name);
|
||||||
|
if (
|
||||||
|
assetNames.length !== expectedAssets.size ||
|
||||||
|
assetNames.some((name) => !expectedAssets.has(name)) ||
|
||||||
|
new Set(assetNames).size !== expectedAssets.size
|
||||||
|
) {
|
||||||
|
core.setFailed(`${version} does not contain the exact release asset set`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
core.setOutput("commit_sha", commitSha);
|
||||||
|
core.setOutput("tag_object", ref.data.object.sha);
|
||||||
|
|
||||||
|
- name: 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 || 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 \
|
||||||
|
https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git \
|
||||||
|
'+refs/tags/v*:refs/tags/v*'
|
||||||
|
./scripts/release/verify-github-tag-authority.sh \
|
||||||
|
"$VERSION" "$RELEASE_COMMIT" "$RELEASE_TAG_OBJECT"
|
||||||
|
|
||||||
|
- name: Require successful Release workflow delivery
|
||||||
|
working-directory: tooling
|
||||||
|
env:
|
||||||
|
VERSION: ${{ inputs.repair_gitee_version || 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 \
|
||||||
|
--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 || inputs.repair_oss_version }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
mkdir -p dist
|
||||||
|
gh release download "$VERSION" \
|
||||||
|
--repo "$GITHUB_REPOSITORY" \
|
||||||
|
--dir dist \
|
||||||
|
--pattern 'dws-*' \
|
||||||
|
--pattern 'checksums.txt' \
|
||||||
|
--clobber
|
||||||
|
DWS_PACKAGE_DIST_DIR="$GITHUB_WORKSPACE/dist" \
|
||||||
|
"$GITHUB_WORKSPACE/tooling/scripts/release/verify-release-artifacts.sh" "$VERSION"
|
||||||
|
|
||||||
|
- name: Mirror release to Gitee (China)
|
||||||
|
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 || 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"
|
||||||
@@ -13,6 +13,8 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- **Consistent access-token caching and errors** — runtime, recovery, Skill, PAT polling, and personal/portal event clients now resolve user access tokens through one expiry- and publication-aware manager, so long-running processes reload rotated credentials while keychain, refresh, parse, permission, and cancellation failures remain observable instead of being collapsed into “not authenticated.”
|
||||||
|
- **Tag-push GitHub Release publication** — Draft publication now locks one GitHub Release database ID, verifies its exact tag, channel, notes, recovery marker, asset set, and uploaded bytes, then publishes and rechecks that same ID as immutable. Recovery runs use the trusted default-branch release helpers instead of the sealed tag's historical scripts, fixing the Draft-only `GET /releases/tags/{tag}` 404 without allowing the release identity to drift during recovery.
|
||||||
- **Release preflight reliability** — source-mode installer tests now use isolated temporary checkouts and HOME directories instead of overwriting and deleting the real repository `dws` binary, release preflight explicitly rebuilds before policy checks, and the full-suite runner gives the growing script package a non-flaky five-minute per-suite budget.
|
- **Release preflight reliability** — source-mode installer tests now use isolated temporary checkouts and HOME directories instead of overwriting and deleting the real repository `dws` binary, release preflight explicitly rebuilds before policy checks, and the full-suite runner gives the growing script package a non-flaky five-minute per-suite budget.
|
||||||
|
|
||||||
## [1.0.53-beta.4] - 2026-07-17
|
## [1.0.53-beta.4] - 2026-07-17
|
||||||
|
|||||||
@@ -1,33 +1,33 @@
|
|||||||
class DingtalkWorkspaceCliBeta < Formula
|
class DingtalkWorkspaceCliBeta < Formula
|
||||||
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
|
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
|
||||||
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
|
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"
|
license "Apache-2.0"
|
||||||
keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"
|
keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"
|
||||||
|
|
||||||
on_macos do
|
on_macos do
|
||||||
if Hardware::CPU.arm?
|
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"
|
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.4/dws-darwin-arm64.tar.gz"
|
||||||
sha256 "47d3f470003a309f4a93a4dfe55ab39240018b7c698f7863d85834e1f0a3affa"
|
sha256 "32a442d5b42dfed8512a695a7cef513722db6912f3c3168954cfaefb68e0b075"
|
||||||
else
|
else
|
||||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.2/dws-darwin-amd64.tar.gz"
|
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.4/dws-darwin-amd64.tar.gz"
|
||||||
sha256 "2dcf90b515d934e71715d95098d3b3cec34299cdbe6c858e1106a81d23d3d51a"
|
sha256 "00c694677b9ce2e1a711535740681defe0a5f83d6b72140f305483501628faf8"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
on_linux do
|
on_linux do
|
||||||
if Hardware::CPU.arm?
|
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"
|
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.4/dws-linux-arm64.tar.gz"
|
||||||
sha256 "b4692a3c2690460c039e641f75f6793daf3b8549b8c9a35d01153a908f6cc2b1"
|
sha256 "98516620e861e516cf846cf418f1a0ad5c5eb9a8681bd066b1fd29f4847aca6a"
|
||||||
else
|
else
|
||||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.2/dws-linux-amd64.tar.gz"
|
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.4/dws-linux-amd64.tar.gz"
|
||||||
sha256 "000d29d4c81589553e5b23b573002b7014b16c073793b8d7c5617e9b89488175"
|
sha256 "266df80e8a989971789a157dd134685a7c9eda01dd1d082719ec9e09d5a07bf0"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
resource "skills" do
|
resource "skills" do
|
||||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.2/dws-skills.zip"
|
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.4/dws-skills.zip"
|
||||||
sha256 "b55a4eaaa63073147c3b9efff48b4713be75577c8a1d2dc8c6e11dc30b4f91c8"
|
sha256 "6770511ab9b04b4d97da1858069ff694830ba91d47c1e8564fd85856f95b016e"
|
||||||
end
|
end
|
||||||
|
|
||||||
def install
|
def install
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ help:
|
|||||||
@printf " make policy - Check the built dws plus open-source and Schema policies\n"
|
@printf " make policy - Check the built dws plus open-source and Schema policies\n"
|
||||||
@printf " make interface-integrity - Check historical commands and help contracts still work\n"
|
@printf " make interface-integrity - Check historical commands and help contracts still work\n"
|
||||||
@printf " make authoritative-interface-integrity BASE_REF=<ref> - Check the Git-owned PR merge-base\n"
|
@printf " make authoritative-interface-integrity BASE_REF=<ref> - Check the Git-owned PR merge-base\n"
|
||||||
@printf " make coverage-gate BASE_REF=<ref> - Enforce overall non-regression and changed-code coverage\n"
|
@printf " make coverage-gate BASE_REF=<ref> - Enforce overall non-regression and 100%% changed-code coverage\n"
|
||||||
@printf " make coverage-gate-platform BASE_REF=<ref> PROFILE=<file> - Enforce native-platform changed-code coverage\n"
|
@printf " make coverage-gate-platform BASE_REF=<ref> PROFILE=<file> - Enforce 100%% native changed-code coverage\n"
|
||||||
@printf " make update-interface-baseline - Add new CLI contracts without removing history\n"
|
@printf " make update-interface-baseline - Add new CLI contracts without removing history\n"
|
||||||
@printf " make reset-interface-baseline - DANGEROUS: replace all CLI compatibility history\n"
|
@printf " make reset-interface-baseline - DANGEROUS: replace all CLI compatibility history\n"
|
||||||
@printf " make schema-compatibility BASE_REF=<ref> - Check the complete Schema contract against the PR merge-base\n"
|
@printf " make schema-compatibility BASE_REF=<ref> - Check the complete Schema contract against the PR merge-base\n"
|
||||||
|
|||||||
@@ -43,3 +43,38 @@
|
|||||||
- `skills/`: bundled agent skills (mono/ and multi/ layouts)
|
- `skills/`: bundled agent skills (mono/ and multi/ layouts)
|
||||||
- `test/`: CLI, integration, contract, unit, and skill E2E tests
|
- `test/`: CLI, integration, contract, unit, and skill E2E tests
|
||||||
- `scripts/`: install scripts, policy checks, and CI helpers
|
- `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["CI"]
|
||||||
|
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.
|
||||||
@@ -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
|
jobs. Do not put publication secrets in temporary branches or create ad-hoc
|
||||||
recovery workflows.
|
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
|
## Handoff Checklist
|
||||||
|
|
||||||
Before handoff, include:
|
Before handoff, include:
|
||||||
|
|||||||
+133
-108
@@ -1,133 +1,158 @@
|
|||||||
# Pull request quality gates
|
# CI — 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
|
| Required context | Contract |
|
||||||
command path and alias must still resolve, every historical command must
|
|---|---|
|
||||||
still render `-h`, and historical flags must keep their type and shorthand.
|
| `Lint` | Stable PR revision classification, formatting, `go vet`, and Actionlint |
|
||||||
New commands, aliases, and flags are allowed. The same job compares the full
|
| `Test` | Race/unit/release-script tests plus fast cross-platform compilation |
|
||||||
complete `dws schema --all` contract with the PR merge-base, blocking removed
|
| `Coverage` | Overall non-regression and 100% changed-code coverage |
|
||||||
products/tools/parameters, incompatible parameter or interface mappings,
|
| `Policy` | Repository policy and the fail-closed CHANGELOG contract |
|
||||||
constraint drift, and safety-semantic drift. It also checks that executable
|
| `Edition` | Edition contract tests |
|
||||||
`dws ...` references in `skills/**/*.md` resolve to real commands.
|
| `Interface Integrity` | CLI, Schema, Skill, and stable-release compatibility |
|
||||||
Help compatibility covers command/alias/flag spelling, flag type and
|
| `AI Behavior` | Base-owned policy for PRs labeled `ai-generated` |
|
||||||
shorthand; descriptive prose may evolve without breaking the gate.
|
| `CLI Smoke` | Offline help for every public top-level command |
|
||||||
- **Coverage** runs unit tests on every pull request and prints both overall and
|
| `Mock MCP` | HTTP and stdio MCP lifecycle smoke tests |
|
||||||
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.
|
|
||||||
|
|
||||||
## Running the compatibility gates
|
The workflow display name is `CI`. 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["CI"]
|
||||||
|
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
|
```sh
|
||||||
make build
|
make build
|
||||||
|
make policy
|
||||||
make interface-integrity
|
make interface-integrity
|
||||||
make authoritative-interface-integrity BASE_REF=<merge-base>
|
make authoritative-interface-integrity BASE_REF=<merge-base>
|
||||||
make schema-compatibility BASE_REF=<merge-base>
|
make schema-compatibility BASE_REF=<merge-base>
|
||||||
make skill-command-integrity
|
make skill-command-integrity
|
||||||
make cli-smoke
|
make cli-smoke
|
||||||
# Run on the corresponding native runner with its generated profile:
|
make mock-mcp-smoke
|
||||||
make coverage-gate-platform BASE_REF=<merge-base> PROFILE=<coverage-profile>
|
go test -v -count=1 ./pkg/editiontest/...
|
||||||
```
|
```
|
||||||
|
|
||||||
`make coverage-gate` is the enforcement step, not a profile generator. It
|
For an exact CHANGELOG-only branch:
|
||||||
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:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
base_ref=$(git merge-base HEAD origin/main)
|
base_ref=$(git merge-base HEAD origin/main)
|
||||||
root=$(pwd)
|
./scripts/policy/check-changelog-pr.sh --fast-path "$base_ref" HEAD
|
||||||
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"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The native-platform target likewise expects `PROFILE` to have already been
|
`make coverage-gate` is an enforcement step, not a profile generator. CI
|
||||||
generated on that operating system. CI owns those generation steps; copying
|
generates the candidate, supporting, merge-base, and (when risk-selected)
|
||||||
only either enforcement command into a clean checkout is intentionally an
|
native profiles before the aggregate `Coverage` context evaluates them. The
|
||||||
incomplete invocation.
|
aggregate and native gates require 100% coverage for changed executable Go
|
||||||
|
statements. Overall coverage remains an unrounded, zero-tolerance merge-base
|
||||||
|
non-regression check. Candidate and baseline profiles are evaluated by the
|
||||||
|
same block-deduplicating checker; supporting policy and shortcut profiles
|
||||||
|
contribute to changed-code coverage only. The checked-in badge is presentation
|
||||||
|
only and is never read as a gate input.
|
||||||
|
|
||||||
CI derives the authoritative Interface snapshots from both the PR merge-base
|
Compatibility checks derive authoritative Interface snapshots from the PR
|
||||||
and the latest reachable stable release tag. The complete Schema snapshot comes
|
merge-base and the latest reachable stable release. The candidate cannot bless
|
||||||
from the PR merge-base, which contains the registry-first Schema introduced on
|
a breaking change by editing a fixture. Schema additions are allowed;
|
||||||
`main`. The candidate branch cannot bless a breaking change by editing a
|
historical products, tools, parameters, mappings, positional execution fields,
|
||||||
fixture. Schema additions are allowed; historical products, tools, parameters,
|
constraints, and safety semantics remain protected.
|
||||||
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.
|
|
||||||
|
|
||||||
## Required GitHub repository settings
|
## Required GitHub repository settings
|
||||||
|
|
||||||
Create a ruleset for `main` that requires pull requests and code-owner review,
|
The `main` quality ruleset must enable strict required-status-check policy
|
||||||
then mark these aggregate status checks as required:
|
(`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`
|
- `Lint`
|
||||||
- `Multi Profile E2E`
|
- `Test`
|
||||||
- `AI Behavior Check`
|
- `Coverage`
|
||||||
|
- `Policy`
|
||||||
|
- `Edition`
|
||||||
|
- `Interface Integrity`
|
||||||
|
- `AI Behavior`
|
||||||
|
- `CLI Smoke`
|
||||||
|
- `Mock MCP`
|
||||||
|
|
||||||
`CI Gate` fails closed unless every first-layer CI job succeeds, including
|
Do not require helper jobs, `Multi-profile E2E`, or an aggregate admission
|
||||||
lint, tests, native Linux/Windows/macOS coverage, policy,
|
alias. Update ruleset contexts only after the new names have appeared on the
|
||||||
Interface/Schema/Skill integrity, and smoke tests. Requiring the aggregate
|
protected branch, so a rename cannot silently remove enforcement or leave an
|
||||||
check keeps repository rules stable when an internal job is renamed or split.
|
unproducible required context.
|
||||||
|
|
||||||
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.
|
|
||||||
+14
-5
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
发布只走一条链路:本地脚本负责封板、验证并推送 annotated tag;GitHub Actions 负责构建和发布最终产物。不要直接运行 `goreleaser release`,也不要手工补打或移动 tag。
|
发布只走一条链路:本地脚本负责封板、验证并推送 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` 精确要求 `CI` workflow 的九个 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
|
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
|
```bash
|
||||||
dws-release v1.2.3-beta.1 --publish
|
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。
|
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 的紧急恢复
|
## 既有 tag 的紧急恢复
|
||||||
|
|
||||||
@@ -125,10 +134,10 @@ Homebrew 当前只属于本机预检/手工公式通道:预检会在当前 mac
|
|||||||
|
|
||||||
仓库管理员还需要在 GitHub 平台配置以下不可由脚本替代的规则:
|
仓库管理员还需要在 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 的短暂窗口。
|
- 必须启用 immutable releases;它只保护启用后发布的 release,因此应在第一次使用新流水线前配置。为 `v*` 增加 tag ruleset,限制创建权限,并在 release 发布前保护 tag 的短暂窗口。
|
||||||
- 配置 `RELEASE_GOVERNANCE_TOKEN` Actions secret,只授予目标仓库 `Administration: read`;内置 `GITHUB_TOKEN` 不具备 immutable-releases API 所需的仓库治理权限。每次本地预检和 tag workflow 都使用这一个身份进行 fail-closed 验证。
|
- 配置 `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 复用。
|
- 单独配置 `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 发布不受影响。
|
- 创建 `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 可能来自组织层,脚本不自动推断其最终作用范围;管理员确认不能省略,脚本约定也不能替代平台强制。
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||||
|
)
|
||||||
|
|
||||||
|
type tokenManagerSnapshotProvider struct {
|
||||||
|
load func() (*authpkg.TokenData, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p tokenManagerSnapshotProvider) GetAccessToken(context.Context) (string, error) {
|
||||||
|
data, err := p.load()
|
||||||
|
if err != nil || data == nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return data.AccessToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p tokenManagerSnapshotProvider) GetTokenSnapshot(context.Context) (*authpkg.TokenData, error) {
|
||||||
|
return p.load()
|
||||||
|
}
|
||||||
|
|
||||||
|
type tokenManagerLegacyGetter struct {
|
||||||
|
token string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g tokenManagerLegacyGetter) GetToken() (string, string, error) {
|
||||||
|
return g.token, "file", g.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func installTokenManagerFakes(t *testing.T, load func() (*authpkg.TokenData, error)) {
|
||||||
|
t.Helper()
|
||||||
|
oldProvider, oldLegacy := newAccessTokenProvider, newLegacyTokenManager
|
||||||
|
oldEdition := edition.Get()
|
||||||
|
edition.Override(&edition.Hooks{})
|
||||||
|
newAccessTokenProvider = func(string) accessTokenGetter {
|
||||||
|
return tokenManagerSnapshotProvider{load: load}
|
||||||
|
}
|
||||||
|
newLegacyTokenManager = func(string) legacyTokenGetter {
|
||||||
|
return tokenManagerLegacyGetter{err: authpkg.ErrTokenDataNotFound}
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
newAccessTokenProvider, newLegacyTokenManager = oldProvider, oldLegacy
|
||||||
|
edition.Override(oldEdition)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageTokenManagerCachesUntilMarkerRevisionChanges(t *testing.T) {
|
||||||
|
configDir := t.TempDir()
|
||||||
|
if err := authpkg.WriteTokenMarker(configDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var calls atomic.Int32
|
||||||
|
token := "token-a"
|
||||||
|
installTokenManagerFakes(t, func() (*authpkg.TokenData, error) {
|
||||||
|
calls.Add(1)
|
||||||
|
return &authpkg.TokenData{AccessToken: token, ExpiresAt: time.Now().Add(time.Hour)}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
manager := NewTokenManager()
|
||||||
|
first, err := manager.Get(context.Background(), configDir, "")
|
||||||
|
if err != nil || first.AccessToken != "token-a" {
|
||||||
|
t.Fatalf("first token = %#v, %v", first, err)
|
||||||
|
}
|
||||||
|
second, err := manager.Get(context.Background(), configDir, "")
|
||||||
|
if err != nil || second.AccessToken != "token-a" || calls.Load() != 1 {
|
||||||
|
t.Fatalf("cached token = %#v, %v, calls=%d", second, err, calls.Load())
|
||||||
|
}
|
||||||
|
|
||||||
|
token = "token-b"
|
||||||
|
if err := authpkg.WriteTokenMarker(configDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rotated, err := manager.Get(context.Background(), configDir, "")
|
||||||
|
if err != nil || rotated.AccessToken != "token-b" || calls.Load() != 2 {
|
||||||
|
t.Fatalf("rotated token = %#v, %v, calls=%d", rotated, err, calls.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageTokenManagerDoesNotCacheWithoutExpiryOrRevision(t *testing.T) {
|
||||||
|
configDir := t.TempDir()
|
||||||
|
var calls atomic.Int32
|
||||||
|
installTokenManagerFakes(t, func() (*authpkg.TokenData, error) {
|
||||||
|
calls.Add(1)
|
||||||
|
return &authpkg.TokenData{AccessToken: "token"}, nil
|
||||||
|
})
|
||||||
|
manager := NewTokenManager()
|
||||||
|
for range 2 {
|
||||||
|
if _, err := manager.Get(context.Background(), configDir, ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if calls.Load() != 2 {
|
||||||
|
t.Fatalf("provider calls = %d, want 2", calls.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageTokenManagerTreatsMalformedMarkerAsUncacheable(t *testing.T) {
|
||||||
|
configDir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(configDir, "token.json"), []byte("{"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var calls atomic.Int32
|
||||||
|
installTokenManagerFakes(t, func() (*authpkg.TokenData, error) {
|
||||||
|
calls.Add(1)
|
||||||
|
return &authpkg.TokenData{AccessToken: "token", ExpiresAt: time.Now().Add(time.Hour)}, nil
|
||||||
|
})
|
||||||
|
manager := NewTokenManager()
|
||||||
|
for range 2 {
|
||||||
|
if snapshot, err := manager.Get(context.Background(), configDir, ""); err != nil || snapshot.AccessToken != "token" {
|
||||||
|
t.Fatalf("snapshot = %#v, error = %v", snapshot, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if calls.Load() != 2 {
|
||||||
|
t.Fatalf("provider calls = %d, want 2", calls.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageTokenManagerDoesNotCacheOpaqueEditionStorageWithProviderFallback(t *testing.T) {
|
||||||
|
configDir := t.TempDir()
|
||||||
|
if err := authpkg.WriteTokenMarker(configDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var calls atomic.Int32
|
||||||
|
installTokenManagerFakes(t, func() (*authpkg.TokenData, error) {
|
||||||
|
calls.Add(1)
|
||||||
|
return &authpkg.TokenData{AccessToken: "token", ExpiresAt: time.Now().Add(time.Hour)}, nil
|
||||||
|
})
|
||||||
|
edition.Override(&edition.Hooks{
|
||||||
|
LoadToken: func(string) ([]byte, error) { return nil, nil },
|
||||||
|
TokenProvider: func(_ context.Context, fallback func() (string, error)) (string, error) {
|
||||||
|
return fallback()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
manager := NewTokenManager()
|
||||||
|
for range 2 {
|
||||||
|
if _, err := manager.Get(context.Background(), configDir, ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if calls.Load() != 2 {
|
||||||
|
t.Fatalf("provider calls = %d, want 2", calls.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageTokenManagerCoalescesConcurrentLoads(t *testing.T) {
|
||||||
|
configDir := t.TempDir()
|
||||||
|
if err := authpkg.WriteTokenMarker(configDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var calls atomic.Int32
|
||||||
|
release := make(chan struct{})
|
||||||
|
installTokenManagerFakes(t, func() (*authpkg.TokenData, error) {
|
||||||
|
calls.Add(1)
|
||||||
|
<-release
|
||||||
|
return &authpkg.TokenData{AccessToken: "token", ExpiresAt: time.Now().Add(time.Hour)}, nil
|
||||||
|
})
|
||||||
|
manager := NewTokenManager()
|
||||||
|
const workers = 8
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(workers)
|
||||||
|
errs := make(chan error, workers)
|
||||||
|
for range workers {
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
_, err := manager.Get(context.Background(), configDir, "")
|
||||||
|
errs <- err
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
for calls.Load() == 0 {
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
}
|
||||||
|
close(release)
|
||||||
|
wg.Wait()
|
||||||
|
close(errs)
|
||||||
|
for err := range errs {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if calls.Load() != 1 {
|
||||||
|
t.Fatalf("provider calls = %d, want 1", calls.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageTokenManagerPreservesProviderFailure(t *testing.T) {
|
||||||
|
configDir := t.TempDir()
|
||||||
|
want := errors.New("keychain permission denied")
|
||||||
|
installTokenManagerFakes(t, func() (*authpkg.TokenData, error) { return nil, want })
|
||||||
|
_, err := NewTokenManager().Get(context.Background(), configDir, "")
|
||||||
|
if !errors.Is(err, want) {
|
||||||
|
t.Fatalf("error = %v, want cause %v", err, want)
|
||||||
|
}
|
||||||
|
if errors.Is(err, authpkg.ErrTokenDataNotFound) {
|
||||||
|
t.Fatalf("provider failure was misclassified as missing credentials: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageTokenResolutionErrorOnlyClassifiesTrueMissingCredential(t *testing.T) {
|
||||||
|
missing := tokenResolutionError(authpkg.ErrTokenDataNotFound)
|
||||||
|
var typed interface{ Unwrap() error }
|
||||||
|
if !errors.As(missing, &typed) || !errors.Is(missing, authpkg.ErrTokenDataNotFound) {
|
||||||
|
t.Fatalf("missing error = %v", missing)
|
||||||
|
}
|
||||||
|
want := errors.New("decrypt failed")
|
||||||
|
if got := tokenResolutionError(want); !errors.Is(got, want) || errors.Is(got, authpkg.ErrTokenDataNotFound) {
|
||||||
|
t.Fatalf("storage error = %v", got)
|
||||||
|
}
|
||||||
|
if got := tokenResolutionError(context.Canceled); !errors.Is(got, context.Canceled) {
|
||||||
|
t.Fatalf("cancellation = %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,19 +21,61 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const accessTokenRefreshWindow = 5 * time.Minute
|
||||||
|
|
||||||
type legacyTokenGetter interface {
|
type legacyTokenGetter interface {
|
||||||
GetToken() (string, string, error)
|
GetToken() (string, string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type accessTokenSnapshotGetter interface {
|
||||||
|
GetTokenSnapshot(context.Context) (*authpkg.TokenData, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AccessTokenSnapshot is the minimal bearer view needed by the process cache.
|
||||||
|
// Refresh-token material never leaves the auth package.
|
||||||
|
type AccessTokenSnapshot struct {
|
||||||
|
AccessToken string
|
||||||
|
ExpiresAt time.Time
|
||||||
|
Source string
|
||||||
|
}
|
||||||
|
|
||||||
|
type tokenManagerKey struct {
|
||||||
|
configDir string
|
||||||
|
profile string
|
||||||
|
}
|
||||||
|
|
||||||
|
type tokenManagerEntry struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
snapshot AccessTokenSnapshot
|
||||||
|
revision string
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenManager is the only process cache for user access tokens. Cache entries
|
||||||
|
// are isolated by config directory and profile, expiry-aware, and invalidated
|
||||||
|
// by the credential publication marker written by auth storage.
|
||||||
|
type TokenManager struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
entries map[tokenManagerKey]*tokenManagerEntry
|
||||||
|
now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTokenManager() *TokenManager {
|
||||||
|
return &TokenManager{entries: make(map[tokenManagerKey]*tokenManagerEntry), now: time.Now}
|
||||||
|
}
|
||||||
|
|
||||||
|
var runtimeTokenManager = NewTokenManager()
|
||||||
|
|
||||||
var (
|
var (
|
||||||
newAccessTokenProvider = func(configDir string) accessTokenGetter {
|
newAccessTokenProvider = func(configDir string) accessTokenGetter {
|
||||||
disc := slog.New(slog.NewTextHandler(io.Discard, nil))
|
discard := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
provider := authpkg.NewOAuthProvider(configDir, disc)
|
provider := authpkg.NewOAuthProvider(configDir, discard)
|
||||||
configureOAuthProviderCompatibility(provider, configDir)
|
configureOAuthProviderCompatibility(provider, configDir)
|
||||||
return provider
|
return provider
|
||||||
}
|
}
|
||||||
@@ -44,64 +86,218 @@ var (
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// resolveAccessTokenFromDir loads OAuth then legacy token from configDir, applying
|
// Get resolves an access token for the active runtime profile.
|
||||||
// the same host compatibility hooks as MCP. It mirrors the former body of
|
func (m *TokenManager) Get(ctx context.Context, configDir, explicitToken string) (AccessTokenSnapshot, error) {
|
||||||
// getCachedRuntimeToken (excluding process-level cache and timing).
|
if token := strings.TrimSpace(explicitToken); token != "" {
|
||||||
func resolveAccessTokenFromDir(ctx context.Context, configDir string) (string, error) {
|
return AccessTokenSnapshot{AccessToken: token, Source: "explicit"}, nil
|
||||||
provider := newAccessTokenProvider(configDir)
|
|
||||||
token, tokenErr := provider.GetAccessToken(ctx)
|
|
||||||
if tokenErr == nil && strings.TrimSpace(token) != "" {
|
|
||||||
return strings.TrimSpace(token), nil
|
|
||||||
}
|
|
||||||
if tokenErr != nil && errors.Is(tokenErr, authpkg.ErrTokenDecryption) {
|
|
||||||
return "", tokenErr
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(authpkg.RuntimeProfile()) != "" {
|
|
||||||
if tokenErr != nil {
|
|
||||||
return "", tokenErr
|
|
||||||
}
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
manager := newLegacyTokenManager(configDir)
|
|
||||||
if leg, _, err := manager.GetToken(); err == nil && strings.TrimSpace(leg) != "" {
|
|
||||||
return strings.TrimSpace(leg), nil
|
|
||||||
}
|
|
||||||
if tokenErr != nil {
|
|
||||||
return "", tokenErr
|
|
||||||
}
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResolveAuxiliaryAccessToken resolves a bearer token for HTTP clients that should
|
|
||||||
// align with MCP tool calls. Non-empty explicitToken wins. When configDir matches
|
|
||||||
// the active edition config directory, the same process-cached path as MCP is used.
|
|
||||||
// Otherwise tokens are loaded from configDir with host compatibility hooks applied.
|
|
||||||
func ResolveAuxiliaryAccessToken(ctx context.Context, configDir, explicitToken string) (string, error) {
|
|
||||||
if t := strings.TrimSpace(explicitToken); t != "" {
|
|
||||||
return t, nil
|
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(configDir) == "" {
|
if strings.TrimSpace(configDir) == "" {
|
||||||
return "", fmt.Errorf("config directory is empty")
|
return AccessTokenSnapshot{}, fmt.Errorf("config directory is empty")
|
||||||
}
|
}
|
||||||
if filepath.Clean(configDir) == filepath.Clean(defaultConfigDir()) {
|
key := tokenManagerKey{
|
||||||
if tok := resolveRuntimeAuthToken(ctx, ""); tok != "" {
|
configDir: canonicalTokenConfigDir(configDir),
|
||||||
return tok, nil
|
profile: strings.TrimSpace(authpkg.RuntimeProfile()),
|
||||||
|
}
|
||||||
|
entry := m.entry(key)
|
||||||
|
entry.mu.Lock()
|
||||||
|
defer entry.mu.Unlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
if m != nil && m.now != nil {
|
||||||
|
now = m.now()
|
||||||
|
}
|
||||||
|
revision, present, err := authpkg.ReadTokenMarkerRevision(configDir)
|
||||||
|
if err != nil {
|
||||||
|
return AccessTokenSnapshot{}, err
|
||||||
|
}
|
||||||
|
if tokenSnapshotUsable(entry.snapshot, now) && present && revision != "" && revision == entry.revision {
|
||||||
|
return entry.snapshot, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Treat the marker and credential as one optimistic snapshot. A concurrent
|
||||||
|
// login/refresh between the reads causes a retry instead of caching stale A
|
||||||
|
// under the publication marker for B.
|
||||||
|
for attempt := 0; attempt < 4; attempt++ {
|
||||||
|
beforeRevision, beforePresent, err := authpkg.ReadTokenMarkerRevision(configDir)
|
||||||
|
if err != nil {
|
||||||
|
return AccessTokenSnapshot{}, err
|
||||||
}
|
}
|
||||||
return "", noCredentialsError()
|
snapshot, err := resolveTokenSnapshotWithEdition(ctx, configDir, key.profile)
|
||||||
|
if err != nil {
|
||||||
|
return AccessTokenSnapshot{}, err
|
||||||
|
}
|
||||||
|
afterRevision, afterPresent, err := authpkg.ReadTokenMarkerRevision(configDir)
|
||||||
|
if err != nil {
|
||||||
|
return AccessTokenSnapshot{}, err
|
||||||
|
}
|
||||||
|
if beforePresent != afterPresent || beforeRevision != afterRevision {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(snapshot.AccessToken) == "" {
|
||||||
|
return AccessTokenSnapshot{}, noCredentialsError()
|
||||||
|
}
|
||||||
|
if tokenSnapshotUsable(snapshot, now) && afterPresent && afterRevision != "" {
|
||||||
|
entry.snapshot = snapshot
|
||||||
|
entry.revision = afterRevision
|
||||||
|
} else {
|
||||||
|
entry.snapshot = AccessTokenSnapshot{}
|
||||||
|
entry.revision = ""
|
||||||
|
}
|
||||||
|
return snapshot, nil
|
||||||
}
|
}
|
||||||
tok, err := resolveAccessTokenFromDir(ctx, configDir)
|
return AccessTokenSnapshot{}, fmt.Errorf("token publication changed repeatedly while resolving credentials")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *TokenManager) entry(key tokenManagerKey) *tokenManagerEntry {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if m.entries == nil {
|
||||||
|
m.entries = make(map[tokenManagerKey]*tokenManagerEntry)
|
||||||
|
}
|
||||||
|
entry := m.entries[key]
|
||||||
|
if entry == nil {
|
||||||
|
entry = &tokenManagerEntry{}
|
||||||
|
m.entries[key] = entry
|
||||||
|
}
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *TokenManager) Invalidate() {
|
||||||
|
if m == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
m.entries = make(map[tokenManagerKey]*tokenManagerEntry)
|
||||||
|
m.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveTokenSnapshotWithEdition(ctx context.Context, configDir, profile string) (AccessTokenSnapshot, error) {
|
||||||
|
hooks := edition.Get()
|
||||||
|
opaqueStorage := hooks.LoadToken != nil || hooks.SaveToken != nil || hooks.DeleteToken != nil
|
||||||
|
provider := hooks.TokenProvider
|
||||||
|
if provider == nil {
|
||||||
|
snapshot, err := resolveAccessTokenSnapshotFromDir(ctx, configDir, profile)
|
||||||
|
if err != nil {
|
||||||
|
return AccessTokenSnapshot{}, err
|
||||||
|
}
|
||||||
|
// Opaque edition storage hooks have no publication-revision contract.
|
||||||
|
// Resolve them on every logical request instead of caching a token that
|
||||||
|
// may be replaced outside the default auth store.
|
||||||
|
if opaqueStorage {
|
||||||
|
snapshot.ExpiresAt = time.Time{}
|
||||||
|
}
|
||||||
|
return snapshot, nil
|
||||||
|
}
|
||||||
|
var fallbackSnapshot AccessTokenSnapshot
|
||||||
|
var fallbackCalled bool
|
||||||
|
token, err := provider(ctx, func() (string, error) {
|
||||||
|
fallbackCalled = true
|
||||||
|
var fallbackErr error
|
||||||
|
fallbackSnapshot, fallbackErr = resolveAccessTokenSnapshotFromDir(ctx, configDir, profile)
|
||||||
|
if fallbackErr != nil {
|
||||||
|
return "", fallbackErr
|
||||||
|
}
|
||||||
|
return fallbackSnapshot.AccessToken, nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return AccessTokenSnapshot{}, fmt.Errorf("edition token provider: %w", err)
|
||||||
|
}
|
||||||
|
token = strings.TrimSpace(token)
|
||||||
|
if token == "" {
|
||||||
|
return AccessTokenSnapshot{}, noCredentialsError()
|
||||||
|
}
|
||||||
|
if fallbackCalled && token == fallbackSnapshot.AccessToken {
|
||||||
|
if opaqueStorage {
|
||||||
|
fallbackSnapshot.ExpiresAt = time.Time{}
|
||||||
|
}
|
||||||
|
return fallbackSnapshot, nil
|
||||||
|
}
|
||||||
|
// Edition providers expose no lifetime metadata, so resolve them on every
|
||||||
|
// logical request instead of recreating a process-lifetime string cache.
|
||||||
|
return AccessTokenSnapshot{AccessToken: token, Source: "edition"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveAccessTokenSnapshotFromDir(ctx context.Context, configDir, profile string) (AccessTokenSnapshot, error) {
|
||||||
|
provider := newAccessTokenProvider(configDir)
|
||||||
|
if snapshotProvider, ok := provider.(accessTokenSnapshotGetter); ok {
|
||||||
|
data, err := snapshotProvider.GetTokenSnapshot(ctx)
|
||||||
|
if err == nil && data != nil && strings.TrimSpace(data.AccessToken) != "" {
|
||||||
|
return AccessTokenSnapshot{
|
||||||
|
AccessToken: strings.TrimSpace(data.AccessToken),
|
||||||
|
ExpiresAt: data.ExpiresAt,
|
||||||
|
Source: "oauth",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
if err != nil && !errors.Is(err, authpkg.ErrTokenDataNotFound) {
|
||||||
|
return AccessTokenSnapshot{}, err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(profile) != "" {
|
||||||
|
return AccessTokenSnapshot{}, authpkg.ErrTokenDataNotFound
|
||||||
|
}
|
||||||
|
return resolveLegacyToken(configDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := provider.GetAccessToken(ctx)
|
||||||
|
if err == nil && strings.TrimSpace(token) != "" {
|
||||||
|
return AccessTokenSnapshot{AccessToken: strings.TrimSpace(token), Source: "oauth_compat"}, nil
|
||||||
|
}
|
||||||
|
if err != nil && !errors.Is(err, authpkg.ErrTokenDataNotFound) {
|
||||||
|
return AccessTokenSnapshot{}, err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(profile) != "" {
|
||||||
|
return AccessTokenSnapshot{}, authpkg.ErrTokenDataNotFound
|
||||||
|
}
|
||||||
|
return resolveLegacyToken(configDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveLegacyToken(configDir string, oauthErr error) (AccessTokenSnapshot, error) {
|
||||||
|
token, source, err := newLegacyTokenManager(configDir).GetToken()
|
||||||
|
if err == nil && strings.TrimSpace(token) != "" {
|
||||||
|
return AccessTokenSnapshot{AccessToken: strings.TrimSpace(token), Source: source}, nil
|
||||||
|
}
|
||||||
|
if err != nil && !errors.Is(err, authpkg.ErrTokenDataNotFound) {
|
||||||
|
return AccessTokenSnapshot{}, err
|
||||||
|
}
|
||||||
|
if oauthErr != nil {
|
||||||
|
return AccessTokenSnapshot{}, oauthErr
|
||||||
|
}
|
||||||
|
return AccessTokenSnapshot{}, authpkg.ErrTokenDataNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveAccessTokenFromDir(ctx context.Context, configDir string) (string, error) {
|
||||||
|
snapshot, err := resolveAccessTokenSnapshotFromDir(ctx, configDir, authpkg.RuntimeProfile())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if tok != "" {
|
return snapshot.AccessToken, nil
|
||||||
return tok, nil
|
}
|
||||||
|
|
||||||
|
// ResolveAuxiliaryAccessToken resolves every non-runner bearer token through
|
||||||
|
// the same TokenManager used by MCP tool calls.
|
||||||
|
func ResolveAuxiliaryAccessToken(ctx context.Context, configDir, explicitToken string) (string, error) {
|
||||||
|
snapshot, err := runtimeTokenManager.Get(ctx, configDir, explicitToken)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
}
|
}
|
||||||
return "", noCredentialsError()
|
return snapshot.AccessToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func tokenSnapshotUsable(snapshot AccessTokenSnapshot, now time.Time) bool {
|
||||||
|
return strings.TrimSpace(snapshot.AccessToken) != "" &&
|
||||||
|
!snapshot.ExpiresAt.IsZero() &&
|
||||||
|
now.Before(snapshot.ExpiresAt.Add(-accessTokenRefreshWindow))
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalTokenConfigDir(configDir string) string {
|
||||||
|
if absolute, err := filepath.Abs(configDir); err == nil {
|
||||||
|
return filepath.Clean(absolute)
|
||||||
|
}
|
||||||
|
return filepath.Clean(configDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
func noCredentialsError() error {
|
func noCredentialsError() error {
|
||||||
if edition.Get().IsEmbedded {
|
if edition.Get().IsEmbedded {
|
||||||
return fmt.Errorf("认证信息已失效,请重新认证")
|
return fmt.Errorf("认证信息已失效,请重新认证: %w", authpkg.ErrTokenDataNotFound)
|
||||||
}
|
}
|
||||||
return fmt.Errorf("no credentials found, run: dws auth login")
|
return fmt.Errorf("no credentials found, run: dws auth login: %w", authpkg.ErrTokenDataNotFound)
|
||||||
}
|
}
|
||||||
@@ -34,6 +34,10 @@ func (g fakeAccessTokenGetter) GetAccessToken(context.Context) (string, error) {
|
|||||||
return g.token, g.err
|
return g.token, g.err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (g fakeAccessTokenGetter) ForceRefreshRejectedToken(context.Context, string) (string, error) {
|
||||||
|
return g.token, g.err
|
||||||
|
}
|
||||||
|
|
||||||
type fakeLegacyTokenGetter struct {
|
type fakeLegacyTokenGetter struct {
|
||||||
token string
|
token string
|
||||||
err error
|
err error
|
||||||
@@ -212,14 +216,16 @@ func TestCrossPlatformCoverageConfigAndTokenSeamsCoverage(t *testing.T) {
|
|||||||
if _, err := resolveAccessTokenFromDir(context.Background(), "unused"); !errors.Is(err, authpkg.ErrTokenDecryption) {
|
if _, err := resolveAccessTokenFromDir(context.Background(), "unused"); !errors.Is(err, authpkg.ErrTokenDecryption) {
|
||||||
t.Fatalf("decryption error = %v", err)
|
t.Fatalf("decryption error = %v", err)
|
||||||
}
|
}
|
||||||
newAccessTokenProvider = func(string) accessTokenGetter { return fakeAccessTokenGetter{err: errors.New("missing")} }
|
newAccessTokenProvider = func(string) accessTokenGetter {
|
||||||
|
return fakeAccessTokenGetter{err: authpkg.ErrTokenDataNotFound}
|
||||||
|
}
|
||||||
newLegacyTokenManager = func(string) legacyTokenGetter { return fakeLegacyTokenGetter{token: " legacy "} }
|
newLegacyTokenManager = func(string) legacyTokenGetter { return fakeLegacyTokenGetter{token: " legacy "} }
|
||||||
if got, err := resolveAccessTokenFromDir(context.Background(), "unused"); err != nil || got != "legacy" {
|
if got, err := resolveAccessTokenFromDir(context.Background(), "unused"); err != nil || got != "legacy" {
|
||||||
t.Fatalf("legacy token = %q, %v", got, err)
|
t.Fatalf("legacy token = %q, %v", got, err)
|
||||||
}
|
}
|
||||||
authpkg.SetRuntimeProfile("corp:user")
|
authpkg.SetRuntimeProfile("corp:user")
|
||||||
t.Cleanup(func() { authpkg.SetRuntimeProfile("") })
|
t.Cleanup(func() { authpkg.SetRuntimeProfile("") })
|
||||||
if got, err := resolveAccessTokenFromDir(context.Background(), "unused"); got != "" || err == nil || err.Error() != "missing" {
|
if got, err := resolveAccessTokenFromDir(context.Background(), "unused"); got != "" || !errors.Is(err, authpkg.ErrTokenDataNotFound) {
|
||||||
t.Fatalf("explicit profile fallback = token %q error %v, want profile error", got, err)
|
t.Fatalf("explicit profile fallback = token %q error %v, want profile error", got, err)
|
||||||
}
|
}
|
||||||
authpkg.SetRuntimeProfile("")
|
authpkg.SetRuntimeProfile("")
|
||||||
@@ -246,10 +252,10 @@ func TestCrossPlatformCoverageConfigAndTokenSeamsCoverage(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCrossPlatformCoverageForceRefreshAndStdioFailureCoverage(t *testing.T) {
|
func TestCrossPlatformCoverageForceRefreshAndStdioFailureCoverage(t *testing.T) {
|
||||||
oldMark, oldFactory := markAccessTokenStale, newRefreshProvider
|
oldLoad, oldFactory := loadRefreshTokenData, newRefreshProvider
|
||||||
oldStop := stopStdio
|
oldStop := stopStdio
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
markAccessTokenStale, newRefreshProvider = oldMark, oldFactory
|
loadRefreshTokenData, newRefreshProvider = oldLoad, oldFactory
|
||||||
stopStdio = oldStop
|
stopStdio = oldStop
|
||||||
stdioMu.Lock()
|
stdioMu.Lock()
|
||||||
stdioClients = make(map[string]*transport.StdioClient)
|
stdioClients = make(map[string]*transport.StdioClient)
|
||||||
@@ -257,11 +263,13 @@ func TestCrossPlatformCoverageForceRefreshAndStdioFailureCoverage(t *testing.T)
|
|||||||
})
|
})
|
||||||
fail := errors.New("failure")
|
fail := errors.New("failure")
|
||||||
_ = oldFactory(t.TempDir())
|
_ = oldFactory(t.TempDir())
|
||||||
markAccessTokenStale = func(string) error { return fail }
|
loadRefreshTokenData = func(string) (*authpkg.TokenData, error) { return nil, fail }
|
||||||
if _, err := ForceRefreshAccessToken(context.Background(), "config"); !errors.Is(err, fail) {
|
if _, err := ForceRefreshAccessToken(context.Background(), "config"); !errors.Is(err, fail) {
|
||||||
t.Fatalf("mark stale error = %v", err)
|
t.Fatalf("load rejected token error = %v", err)
|
||||||
|
}
|
||||||
|
loadRefreshTokenData = func(string) (*authpkg.TokenData, error) {
|
||||||
|
return &authpkg.TokenData{AccessToken: "rejected"}, nil
|
||||||
}
|
}
|
||||||
markAccessTokenStale = func(string) error { return nil }
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
getter fakeAccessTokenGetter
|
getter fakeAccessTokenGetter
|
||||||
want string
|
want string
|
||||||
@@ -270,7 +278,7 @@ func TestCrossPlatformCoverageForceRefreshAndStdioFailureCoverage(t *testing.T)
|
|||||||
{getter: fakeAccessTokenGetter{token: " "}, want: "empty"},
|
{getter: fakeAccessTokenGetter{token: " "}, want: "empty"},
|
||||||
{getter: fakeAccessTokenGetter{token: " refreshed "}},
|
{getter: fakeAccessTokenGetter{token: " refreshed "}},
|
||||||
} {
|
} {
|
||||||
newRefreshProvider = func(string) accessTokenGetter { return tc.getter }
|
newRefreshProvider = func(string) rejectedAccessTokenRefresher { return tc.getter }
|
||||||
got, err := ForceRefreshAccessToken(context.Background(), "config")
|
got, err := ForceRefreshAccessToken(context.Background(), "config")
|
||||||
if tc.want != "" && (err == nil || !strings.Contains(err.Error(), tc.want)) {
|
if tc.want != "" && (err == nil || !strings.Contains(err.Error(), tc.want)) {
|
||||||
t.Fatalf("refresh error = %v, want %q", err, tc.want)
|
t.Fatalf("refresh error = %v, want %q", err, tc.want)
|
||||||
|
|||||||
@@ -15,6 +15,15 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/logging"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/authretry"
|
||||||
)
|
)
|
||||||
|
|
||||||
// authRetryingKey marks a context that has already attempted one
|
// authRetryingKey marks a context that has already attempted one
|
||||||
@@ -23,8 +32,35 @@ import (
|
|||||||
// to the user instead.
|
// to the user instead.
|
||||||
type authRetryingKeyType struct{}
|
type authRetryingKeyType struct{}
|
||||||
|
|
||||||
|
type authRefreshFailureError struct {
|
||||||
|
rejection error
|
||||||
|
refresh error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *authRefreshFailureError) Error() string {
|
||||||
|
return "automatic access token refresh failed"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *authRefreshFailureError) Unwrap() []error {
|
||||||
|
if e == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []error{e.rejection, e.refresh}
|
||||||
|
}
|
||||||
|
|
||||||
var authRetryingKey = authRetryingKeyType{}
|
var authRetryingKey = authRetryingKeyType{}
|
||||||
|
|
||||||
|
var (
|
||||||
|
runnerForceRefreshRejectedAccessToken = forceRefreshRejectedAccessToken
|
||||||
|
runnerExecuteAuthRetry func(*runtimeRunner, context.Context, string, executor.Invocation) (executor.Result, error)
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
runnerExecuteAuthRetry = func(r *runtimeRunner, ctx context.Context, endpoint string, invocation executor.Invocation) (executor.Result, error) {
|
||||||
|
return r.executeInvocation(ctx, endpoint, invocation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// IsAuthRetrying reports whether the current context is already inside an
|
// IsAuthRetrying reports whether the current context is already inside an
|
||||||
// AuthRefreshRequired retry. Mirrors IsPatRetrying.
|
// AuthRefreshRequired retry. Mirrors IsPatRetrying.
|
||||||
func IsAuthRetrying(ctx context.Context) bool {
|
func IsAuthRetrying(ctx context.Context) bool {
|
||||||
@@ -34,3 +70,103 @@ func IsAuthRetrying(ctx context.Context) bool {
|
|||||||
v, _ := ctx.Value(authRetryingKey).(bool)
|
v, _ := ctx.Value(authRetryingKey).(bool)
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func withAuthRetrying(ctx context.Context) context.Context {
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
return context.WithValue(ctx, authRetryingKey, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func authRefreshLogger() *slog.Logger {
|
||||||
|
if logger := FileLoggerInstance(); logger != nil {
|
||||||
|
return logger
|
||||||
|
}
|
||||||
|
return slog.Default()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *runtimeRunner) managesRuntimeOAuth(hasPluginAuth bool) bool {
|
||||||
|
if r == nil || hasPluginAuth {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return r.globalFlags == nil || strings.TrimSpace(r.globalFlags.Token) == ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// retryAuthRefreshRequired consumes only the explicit edition marker. It does
|
||||||
|
// not infer retryability from free text, generic auth categories, HTTP 403, or
|
||||||
|
// ordinary business errors.
|
||||||
|
func (r *runtimeRunner) retryAuthRefreshRequired(
|
||||||
|
ctx context.Context,
|
||||||
|
endpoint string,
|
||||||
|
invocation executor.Invocation,
|
||||||
|
rejectedAccessToken string,
|
||||||
|
markerErr error,
|
||||||
|
hasPluginAuth bool,
|
||||||
|
) (executor.Result, error, bool) {
|
||||||
|
marker, marked := authretry.As(markerErr)
|
||||||
|
if !marked {
|
||||||
|
return executor.Result{}, nil, false
|
||||||
|
}
|
||||||
|
cause := marker.Cause
|
||||||
|
if cause == nil {
|
||||||
|
cause = markerErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explicit --token and plugin credentials are not backed by the default
|
||||||
|
// OAuth refresh store. Preserve the overlay cause without mutating an
|
||||||
|
// unrelated persisted login.
|
||||||
|
if !r.managesRuntimeOAuth(hasPluginAuth) {
|
||||||
|
return executor.Result{}, cause, true
|
||||||
|
}
|
||||||
|
if IsAuthRetrying(ctx) {
|
||||||
|
authRefreshLogger().Warn("auth.runtime.refresh.retry_exhausted",
|
||||||
|
"product", invocation.CanonicalProduct,
|
||||||
|
"tool", invocation.Tool,
|
||||||
|
)
|
||||||
|
return executor.Result{}, cause, true
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := runnerForceRefreshRejectedAccessToken(ctx, defaultConfigDir(), rejectedAccessToken); err != nil {
|
||||||
|
// Keep every log credential-safe. The returned error chain retains the
|
||||||
|
// complete cause for in-process diagnosis; even DWS_DEBUG_AUTH must not
|
||||||
|
// serialize an OAuth response body or other attacker-controlled text.
|
||||||
|
authRefreshLogger().Warn("auth.runtime.refresh.failed",
|
||||||
|
"product", invocation.CanonicalProduct,
|
||||||
|
"tool", invocation.Tool,
|
||||||
|
"stage", "force_refresh_rejected_token",
|
||||||
|
"error_type", fmt.Sprintf("%T", err),
|
||||||
|
)
|
||||||
|
logging.AuthDebug("auth.runtime.refresh.failed.detail",
|
||||||
|
"product", invocation.CanonicalProduct,
|
||||||
|
"tool", invocation.Tool,
|
||||||
|
"stage", "force_refresh_rejected_token",
|
||||||
|
"error_type", fmt.Sprintf("%T", err),
|
||||||
|
)
|
||||||
|
combined := &authRefreshFailureError{rejection: cause, refresh: err}
|
||||||
|
return executor.Result{}, apperrors.NewAuth(
|
||||||
|
"automatic access token refresh failed",
|
||||||
|
apperrors.WithOperation("auth/token/refresh"),
|
||||||
|
apperrors.WithReason("auth_refresh_failed"),
|
||||||
|
apperrors.WithHint("本地凭证已保留;可稍后重试,若持续失败请查看认证诊断日志。"),
|
||||||
|
apperrors.WithCause(combined),
|
||||||
|
), true
|
||||||
|
}
|
||||||
|
|
||||||
|
logging.AuthDebug("auth.runtime.refresh.succeeded",
|
||||||
|
"product", invocation.CanonicalProduct,
|
||||||
|
"tool", invocation.Tool,
|
||||||
|
)
|
||||||
|
result, err := runnerExecuteAuthRetry(r, withAuthRetrying(ctx), endpoint, invocation)
|
||||||
|
return result, err, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// isRefreshableTransportAuthError deliberately excludes HTTP/RPC 403 and
|
||||||
|
// generic CategoryAuth values. OnAuthError may request a refresh only for an
|
||||||
|
// exact transport-level unauthorized signal.
|
||||||
|
func isRefreshableTransportAuthError(err error) bool {
|
||||||
|
var typed *apperrors.Error
|
||||||
|
if !errors.As(err, &typed) || typed.Category != apperrors.CategoryAuth {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return typed.Reason == "http_401" || typed.RPCCode == 401
|
||||||
|
}
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
// Copyright 2026 Alibaba Group
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/audit"
|
||||||
|
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||||
|
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/authretry"
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||||
|
)
|
||||||
|
|
||||||
|
func installAuthRefreshRunnerSeams(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
previousHooks := edition.Get()
|
||||||
|
previousCall := runnerCallTool
|
||||||
|
previousPreflight := runnerPreflightDocDownload
|
||||||
|
previousRefresh := runnerForceRefreshRejectedAccessToken
|
||||||
|
previousRetry := runnerExecuteAuthRetry
|
||||||
|
previousCapture := runnerCaptureRuntimeFailure
|
||||||
|
previousProfile := authpkg.RuntimeProfile()
|
||||||
|
|
||||||
|
pluginAuthMu.Lock()
|
||||||
|
previousPlugins := pluginAuthRegistry
|
||||||
|
pluginAuthRegistry = make(map[string]*PluginAuth)
|
||||||
|
pluginAuthMu.Unlock()
|
||||||
|
|
||||||
|
runnerPreflightDocDownload = func(*runtimeRunner, context.Context, *transport.Client, string, executor.Invocation) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
runnerCaptureRuntimeFailure = func(executor.Invocation, error, error) {}
|
||||||
|
authpkg.SetRuntimeProfile("")
|
||||||
|
runtimeTokenManager.Invalidate()
|
||||||
|
t.Setenv("DWS_CONFIG_DIR", "")
|
||||||
|
t.Setenv("DWS_DEBUG_AUTH", "0")
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
edition.Override(previousHooks)
|
||||||
|
runnerCallTool = previousCall
|
||||||
|
runnerPreflightDocDownload = previousPreflight
|
||||||
|
runnerForceRefreshRejectedAccessToken = previousRefresh
|
||||||
|
runnerExecuteAuthRetry = previousRetry
|
||||||
|
runnerCaptureRuntimeFailure = previousCapture
|
||||||
|
authpkg.SetRuntimeProfile(previousProfile)
|
||||||
|
runtimeTokenManager.Invalidate()
|
||||||
|
pluginAuthMu.Lock()
|
||||||
|
pluginAuthRegistry = previousPlugins
|
||||||
|
pluginAuthMu.Unlock()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func authRefreshTestRunner(flags *GlobalFlags) *runtimeRunner {
|
||||||
|
return &runtimeRunner{
|
||||||
|
transport: transport.NewClient(nil),
|
||||||
|
globalFlags: flags,
|
||||||
|
auditSink: audit.NopSink{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func authRefreshTestInvocation() executor.Invocation {
|
||||||
|
return executor.Invocation{
|
||||||
|
CanonicalProduct: "auth-retry-test-product",
|
||||||
|
Tool: "test_tool",
|
||||||
|
Params: map[string]any{"value": "safe"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func authRefreshTokenHooks(configDir string, token *string, classify func(map[string]any) error) *edition.Hooks {
|
||||||
|
return &edition.Hooks{
|
||||||
|
ConfigDir: func() string { return configDir },
|
||||||
|
TokenProvider: func(context.Context, func() (string, error)) (string, error) {
|
||||||
|
return *token, nil
|
||||||
|
},
|
||||||
|
ClassifyToolResult: classify,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageRunnerRetriesEditionAuthMarkerOnce(t *testing.T) {
|
||||||
|
installAuthRefreshRunnerSeams(t)
|
||||||
|
configDir := t.TempDir()
|
||||||
|
token := "old-access"
|
||||||
|
rejection := apperrors.NewAuth("server rejected access token", apperrors.WithReason("access_token_rejected"))
|
||||||
|
edition.Override(authRefreshTokenHooks(configDir, &token, func(content map[string]any) error {
|
||||||
|
if expired, _ := content["expired"].(bool); expired {
|
||||||
|
return &authretry.AuthRefreshRequired{Cause: rejection}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}))
|
||||||
|
|
||||||
|
var callTokens []string
|
||||||
|
runnerCallTool = func(client *transport.Client, _ context.Context, _, _ string, _ map[string]any) (transport.ToolCallResult, error) {
|
||||||
|
callTokens = append(callTokens, client.AuthToken)
|
||||||
|
if len(callTokens) == 1 {
|
||||||
|
return transport.ToolCallResult{Content: map[string]any{"expired": true}}, nil
|
||||||
|
}
|
||||||
|
return transport.ToolCallResult{Content: map[string]any{"value": "ok"}}, nil
|
||||||
|
}
|
||||||
|
refreshCalls := 0
|
||||||
|
runnerForceRefreshRejectedAccessToken = func(_ context.Context, gotDir, rejected string) (string, error) {
|
||||||
|
refreshCalls++
|
||||||
|
if gotDir != configDir || rejected != "old-access" {
|
||||||
|
t.Fatalf("refresh input = dir %q token %q", gotDir, rejected)
|
||||||
|
}
|
||||||
|
token = "new-access"
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := authRefreshTestRunner(nil).executeInvocation(context.Background(), "https://example.test", authRefreshTestInvocation())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if refreshCalls != 1 || len(callTokens) != 2 || callTokens[0] != "old-access" || callTokens[1] != "new-access" {
|
||||||
|
t.Fatalf("refreshes=%d call tokens=%v", refreshCalls, callTokens)
|
||||||
|
}
|
||||||
|
content, _ := result.Response["content"].(map[string]any)
|
||||||
|
if content["value"] != "ok" || content["success"] != true {
|
||||||
|
t.Fatalf("result content = %#v", content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageRunnerRefreshFailurePreservesBothCausesAndSafeLog(t *testing.T) {
|
||||||
|
installAuthRefreshRunnerSeams(t)
|
||||||
|
t.Setenv("DWS_DEBUG_AUTH", "1")
|
||||||
|
configDir := t.TempDir()
|
||||||
|
token := "old-access"
|
||||||
|
rejection := apperrors.NewAuth("server rejected access token", apperrors.WithReason("access_token_rejected"))
|
||||||
|
edition.Override(authRefreshTokenHooks(configDir, &token, func(map[string]any) error {
|
||||||
|
return &authretry.AuthRefreshRequired{Cause: rejection}
|
||||||
|
}))
|
||||||
|
runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) {
|
||||||
|
return transport.ToolCallResult{Content: map[string]any{"expired": true}}, nil
|
||||||
|
}
|
||||||
|
refreshErr := errors.New(`oauth refresh response parse failed: body={"access_token":"access-token-secret","refresh_token":"refresh-token-secret","uid":"uid-secret-value"}`)
|
||||||
|
runnerForceRefreshRejectedAccessToken = func(context.Context, string, string) (string, error) {
|
||||||
|
return "", refreshErr
|
||||||
|
}
|
||||||
|
|
||||||
|
var logs bytes.Buffer
|
||||||
|
previousLogger := slog.Default()
|
||||||
|
slog.SetDefault(slog.New(slog.NewJSONHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})))
|
||||||
|
t.Cleanup(func() { slog.SetDefault(previousLogger) })
|
||||||
|
|
||||||
|
_, err := authRefreshTestRunner(nil).executeInvocation(context.Background(), "https://example.test", authRefreshTestInvocation())
|
||||||
|
if !errors.Is(err, rejection) || !errors.Is(err, refreshErr) {
|
||||||
|
t.Fatalf("error = %v, want rejection and refresh causes", err)
|
||||||
|
}
|
||||||
|
var typed *apperrors.Error
|
||||||
|
if !errors.As(err, &typed) || typed.Category != apperrors.CategoryAuth || typed.Reason != "auth_refresh_failed" || typed.Operation != "auth/token/refresh" {
|
||||||
|
t.Fatalf("refresh envelope = %#v", typed)
|
||||||
|
}
|
||||||
|
var rendered bytes.Buffer
|
||||||
|
if printErr := apperrors.PrintJSON(&rendered, err); printErr != nil {
|
||||||
|
t.Fatal(printErr)
|
||||||
|
}
|
||||||
|
for _, want := range []string{`"category": "auth"`, `"reason": "auth_refresh_failed"`, `"operation": "auth/token/refresh"`} {
|
||||||
|
if !strings.Contains(rendered.String(), want) {
|
||||||
|
t.Fatalf("structured stderr missing %s: %s", want, rendered.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, secret := range []string{"access-token-secret", "refresh-token-secret", "uid-secret-value"} {
|
||||||
|
if strings.Contains(err.Error(), secret) || strings.Contains(logs.String(), secret) || strings.Contains(rendered.String(), secret) {
|
||||||
|
t.Fatalf("auth output leaked %q: error=%q logs=%s stderr=%s", secret, err, logs.String(), rendered.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, want := range []string{"auth.runtime.refresh.failed", "auth.runtime.refresh.failed.detail", "force_refresh_rejected_token", "error_type"} {
|
||||||
|
if !strings.Contains(logs.String(), want) {
|
||||||
|
t.Fatalf("safe refresh log missing %q: %s", want, logs.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageRunnerSecondEditionMarkerReturnsSecondCause(t *testing.T) {
|
||||||
|
installAuthRefreshRunnerSeams(t)
|
||||||
|
configDir := t.TempDir()
|
||||||
|
token := "old-access"
|
||||||
|
firstCause := errors.New("first rejection")
|
||||||
|
secondCause := errors.New("second rejection")
|
||||||
|
edition.Override(authRefreshTokenHooks(configDir, &token, func(content map[string]any) error {
|
||||||
|
attempt, _ := content["attempt"].(int)
|
||||||
|
if attempt == 1 {
|
||||||
|
return &authretry.AuthRefreshRequired{Cause: firstCause}
|
||||||
|
}
|
||||||
|
return &authretry.AuthRefreshRequired{Cause: secondCause}
|
||||||
|
}))
|
||||||
|
calls := 0
|
||||||
|
runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) {
|
||||||
|
calls++
|
||||||
|
return transport.ToolCallResult{Content: map[string]any{"attempt": calls}}, nil
|
||||||
|
}
|
||||||
|
refreshCalls := 0
|
||||||
|
runnerForceRefreshRejectedAccessToken = func(context.Context, string, string) (string, error) {
|
||||||
|
refreshCalls++
|
||||||
|
token = "new-access"
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := authRefreshTestRunner(nil).executeInvocation(context.Background(), "https://example.test", authRefreshTestInvocation())
|
||||||
|
if !errors.Is(err, secondCause) || errors.Is(err, firstCause) {
|
||||||
|
t.Fatalf("error = %v, want only second rejection cause", err)
|
||||||
|
}
|
||||||
|
if calls != 2 || refreshCalls != 1 {
|
||||||
|
t.Fatalf("calls=%d refreshes=%d", calls, refreshCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageRunnerOnAuthErrorOnlyRetriesExactUnauthorized(t *testing.T) {
|
||||||
|
t.Run("http 401 marker retries once", func(t *testing.T) {
|
||||||
|
installAuthRefreshRunnerSeams(t)
|
||||||
|
configDir := t.TempDir()
|
||||||
|
token := "old-access"
|
||||||
|
rejection := errors.New("transport rejected token")
|
||||||
|
hookCalls := 0
|
||||||
|
hooks := authRefreshTokenHooks(configDir, &token, nil)
|
||||||
|
hooks.OnAuthError = func(string, error) error {
|
||||||
|
hookCalls++
|
||||||
|
return &authretry.AuthRefreshRequired{Cause: rejection}
|
||||||
|
}
|
||||||
|
edition.Override(hooks)
|
||||||
|
calls := 0
|
||||||
|
var callTokens []string
|
||||||
|
runnerCallTool = func(client *transport.Client, _ context.Context, _, _ string, _ map[string]any) (transport.ToolCallResult, error) {
|
||||||
|
calls++
|
||||||
|
callTokens = append(callTokens, client.AuthToken)
|
||||||
|
if calls == 1 {
|
||||||
|
return transport.ToolCallResult{}, apperrors.NewAuth("unauthorized", apperrors.WithReason("http_401"))
|
||||||
|
}
|
||||||
|
return transport.ToolCallResult{Content: map[string]any{"value": "ok"}}, nil
|
||||||
|
}
|
||||||
|
refreshCalls := 0
|
||||||
|
runnerForceRefreshRejectedAccessToken = func(context.Context, string, string) (string, error) {
|
||||||
|
refreshCalls++
|
||||||
|
token = "new-access"
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := authRefreshTestRunner(nil).executeInvocation(context.Background(), "https://example.test", authRefreshTestInvocation()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if hookCalls != 1 || refreshCalls != 1 || calls != 2 || strings.Join(callTokens, ",") != "old-access,new-access" {
|
||||||
|
t.Fatalf("hook=%d refresh=%d calls=%d tokens=%v", hookCalls, refreshCalls, calls, callTokens)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
}{
|
||||||
|
{name: "http 403", err: apperrors.NewAuth("forbidden", apperrors.WithReason("http_403"))},
|
||||||
|
{name: "ordinary auth", err: apperrors.NewAuth("load failed", apperrors.WithReason("auth_load_failed"))},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name+" does not enter hook", func(t *testing.T) {
|
||||||
|
installAuthRefreshRunnerSeams(t)
|
||||||
|
configDir := t.TempDir()
|
||||||
|
token := "old-access"
|
||||||
|
hookCalls := 0
|
||||||
|
hooks := authRefreshTokenHooks(configDir, &token, nil)
|
||||||
|
hooks.OnAuthError = func(string, error) error {
|
||||||
|
hookCalls++
|
||||||
|
return &authretry.AuthRefreshRequired{Cause: errors.New("must not run")}
|
||||||
|
}
|
||||||
|
edition.Override(hooks)
|
||||||
|
runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) {
|
||||||
|
return transport.ToolCallResult{}, tc.err
|
||||||
|
}
|
||||||
|
refreshCalls := 0
|
||||||
|
runnerForceRefreshRejectedAccessToken = func(context.Context, string, string) (string, error) {
|
||||||
|
refreshCalls++
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := authRefreshTestRunner(nil).executeInvocation(context.Background(), "https://example.test", authRefreshTestInvocation())
|
||||||
|
if !errors.Is(err, tc.err) || hookCalls != 0 || refreshCalls != 0 {
|
||||||
|
t.Fatalf("error=%v hook=%d refresh=%d", err, hookCalls, refreshCalls)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageRunnerDoesNotRefreshExplicitTokenMarker(t *testing.T) {
|
||||||
|
installAuthRefreshRunnerSeams(t)
|
||||||
|
rejection := errors.New("explicit token rejected")
|
||||||
|
edition.Override(&edition.Hooks{ClassifyToolResult: func(map[string]any) error {
|
||||||
|
return &authretry.AuthRefreshRequired{Cause: rejection}
|
||||||
|
}})
|
||||||
|
calls := 0
|
||||||
|
runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) {
|
||||||
|
calls++
|
||||||
|
return transport.ToolCallResult{Content: map[string]any{"expired": true}}, nil
|
||||||
|
}
|
||||||
|
refreshCalls := 0
|
||||||
|
runnerForceRefreshRejectedAccessToken = func(context.Context, string, string) (string, error) {
|
||||||
|
refreshCalls++
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := authRefreshTestRunner(&GlobalFlags{Token: "explicit-token"}).executeInvocation(context.Background(), "https://example.test", authRefreshTestInvocation())
|
||||||
|
if !errors.Is(err, rejection) || calls != 1 || refreshCalls != 0 {
|
||||||
|
t.Fatalf("error=%v calls=%d refresh=%d", err, calls, refreshCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageRunnerRetriesPreflightEditionMarkerOnce(t *testing.T) {
|
||||||
|
installAuthRefreshRunnerSeams(t)
|
||||||
|
configDir := t.TempDir()
|
||||||
|
token := "old-access"
|
||||||
|
rejection := errors.New("preflight token rejected")
|
||||||
|
edition.Override(authRefreshTokenHooks(configDir, &token, nil))
|
||||||
|
preflightCalls := 0
|
||||||
|
runnerPreflightDocDownload = func(*runtimeRunner, context.Context, *transport.Client, string, executor.Invocation) error {
|
||||||
|
preflightCalls++
|
||||||
|
if preflightCalls == 1 {
|
||||||
|
return &authretry.AuthRefreshRequired{Cause: rejection}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
toolCalls := 0
|
||||||
|
runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) {
|
||||||
|
toolCalls++
|
||||||
|
return transport.ToolCallResult{Content: map[string]any{"value": "ok"}}, nil
|
||||||
|
}
|
||||||
|
refreshCalls := 0
|
||||||
|
runnerForceRefreshRejectedAccessToken = func(context.Context, string, string) (string, error) {
|
||||||
|
refreshCalls++
|
||||||
|
token = "new-access"
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := authRefreshTestRunner(nil).executeInvocation(context.Background(), "https://example.test", authRefreshTestInvocation()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if preflightCalls != 2 || toolCalls != 1 || refreshCalls != 1 {
|
||||||
|
t.Fatalf("preflights=%d tools=%d refreshes=%d", preflightCalls, toolCalls, refreshCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -554,7 +554,7 @@ func TestCrossPlatformCoverageRecoveryRuntimeHTTP(t *testing.T) {
|
|||||||
defer server.Close()
|
defer server.Close()
|
||||||
SetDynamicServers([]mcptypes.ServerDescriptor{{Endpoint: server.URL, CLI: mcptypes.CLIOverlay{ID: "devdoc", Tools: []mcptypes.CLITool{{Name: "search_open_platform_docs_rag"}}}}})
|
SetDynamicServers([]mcptypes.ServerDescriptor{{Endpoint: server.URL, CLI: mcptypes.CLIOverlay{ID: "devdoc", Tools: []mcptypes.CLITool{{Name: "search_open_platform_docs_rag"}}}}})
|
||||||
t.Cleanup(func() { SetDynamicServers(nil) })
|
t.Cleanup(func() { SetDynamicServers(nil) })
|
||||||
runtime := &recoveryRuntime{transport: transport.NewClient(server.Client())}
|
runtime := &recoveryRuntime{transport: transport.NewClient(server.Client()), flags: &GlobalFlags{Token: "token"}}
|
||||||
got, err := runtime.Search(context.Background(), "query", recovery.RecoveryContext{ToolName: "search"})
|
got, err := runtime.Search(context.Background(), "query", recovery.RecoveryContext{ToolName: "search"})
|
||||||
if err != nil || got.DocSearch.Status != "success" || len(got.KBHits) == 0 {
|
if err != nil || got.DocSearch.Status != "success" || len(got.KBHits) == 0 {
|
||||||
t.Fatalf("recovery search = %#v %v", got, err)
|
t.Fatalf("recovery search = %#v %v", got, err)
|
||||||
@@ -1650,6 +1650,8 @@ func TestCrossPlatformCoveragePersonalSubscriptionAndSourceCoverage(t *testing.T
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCrossPlatformCoveragePersonalEventCommandRuntimeCoverage(t *testing.T) {
|
func TestCrossPlatformCoveragePersonalEventCommandRuntimeCoverage(t *testing.T) {
|
||||||
|
authpkg.SetRuntimeProfile("")
|
||||||
|
t.Cleanup(func() { authpkg.SetRuntimeProfile("") })
|
||||||
configDir := setupPersonalIdentityToken(t, &authpkg.TokenData{
|
configDir := setupPersonalIdentityToken(t, &authpkg.TokenData{
|
||||||
AccessToken: "access", RefreshToken: "refresh", ExpiresAt: time.Now().Add(time.Hour),
|
AccessToken: "access", RefreshToken: "refresh", ExpiresAt: time.Now().Add(time.Hour),
|
||||||
CorpID: "corp", UserID: "user", ClientID: "client",
|
CorpID: "corp", UserID: "user", ClientID: "client",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -164,7 +165,7 @@ func TestCrossPlatformCoverageRawAPIAndTokenCoverage(t *testing.T) {
|
|||||||
}
|
}
|
||||||
newAccessTokenProvider = func(string) accessTokenGetter { return fakeAccessTokenGetter{} }
|
newAccessTokenProvider = func(string) accessTokenGetter { return fakeAccessTokenGetter{} }
|
||||||
missing := t.TempDir()
|
missing := t.TempDir()
|
||||||
if got, err := resolveAccessTokenFromDir(context.Background(), missing); err != nil || got != "" {
|
if got, err := resolveAccessTokenFromDir(context.Background(), missing); got != "" || !errors.Is(err, authpkg.ErrTokenDataNotFound) {
|
||||||
t.Fatalf("missing access token = %q, %v", got, err)
|
t.Fatalf("missing access token = %q, %v", got, err)
|
||||||
}
|
}
|
||||||
if _, err := ResolveAuxiliaryAccessToken(context.Background(), missing, ""); err == nil {
|
if _, err := ResolveAuxiliaryAccessToken(context.Background(), missing, ""); err == nil {
|
||||||
|
|||||||
@@ -413,7 +413,7 @@ func eventStreamBusID(streamOpts eventStreamTicketOptions) string {
|
|||||||
return "portal-ticket-normal:" + sourceID
|
return "portal-ticket-normal:" + sourceID
|
||||||
}
|
}
|
||||||
|
|
||||||
func newEventSource(ctx context.Context, configDir, clientID, clientSecret string, streamOpts eventStreamTicketOptions) (*source.DingtalkSource, error) {
|
func newEventSource(_ context.Context, configDir, clientID, clientSecret string, streamOpts eventStreamTicketOptions) (*source.DingtalkSource, error) {
|
||||||
if !streamOpts.enabled() {
|
if !streamOpts.enabled() {
|
||||||
return eventNewDingtalkSource(source.Config{
|
return eventNewDingtalkSource(source.Config{
|
||||||
ClientID: clientID,
|
ClientID: clientID,
|
||||||
@@ -421,14 +421,6 @@ func newEventSource(ctx context.Context, configDir, clientID, clientSecret strin
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
token, err := eventResolveAccessToken(ctx, configDir, "")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("event stream ticket: resolve user token: %w", err)
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(token) == "" {
|
|
||||||
return nil, errors.New("event stream ticket: empty user token")
|
|
||||||
}
|
|
||||||
|
|
||||||
portalClientID := clientID
|
portalClientID := clientID
|
||||||
portalClientSecret := clientSecret
|
portalClientSecret := clientSecret
|
||||||
if streamOpts.usesPortalNormalMode() {
|
if streamOpts.usesPortalNormalMode() {
|
||||||
@@ -440,8 +432,10 @@ func newEventSource(ctx context.Context, configDir, clientID, clientSecret strin
|
|||||||
ClientID: portalClientID,
|
ClientID: portalClientID,
|
||||||
ClientSecret: portalClientSecret,
|
ClientSecret: portalClientSecret,
|
||||||
PortalTicket: &source.PortalTicketConfig{
|
PortalTicket: &source.PortalTicketConfig{
|
||||||
TicketURL: eventStreamTicketURL(streamOpts.TicketURL),
|
TicketURL: eventStreamTicketURL(streamOpts.TicketURL),
|
||||||
AccessToken: token,
|
AccessTokenProvider: func(ctx context.Context) (string, error) {
|
||||||
|
return eventResolveAccessToken(ctx, configDir, "")
|
||||||
|
},
|
||||||
SourceID: eventStreamSourceID(streamOpts.SourceID),
|
SourceID: eventStreamSourceID(streamOpts.SourceID),
|
||||||
Mode: streamOpts.Mode,
|
Mode: streamOpts.Mode,
|
||||||
ClientID: portalClientID,
|
ClientID: portalClientID,
|
||||||
|
|||||||
@@ -132,14 +132,18 @@ func TestCrossPlatformCoverageEventSourcesAndForegroundCoverage(t *testing.T) {
|
|||||||
if _, err := newEventSource(context.Background(), "config", "client", "secret", eventStreamTicketOptions{}); err != nil {
|
if _, err := newEventSource(context.Background(), "config", "client", "secret", eventStreamTicketOptions{}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
eventResolveAccessToken = func(context.Context, string, string) (string, error) { return "", fail }
|
|
||||||
stream := eventStreamTicketOptions{Mode: "custom"}
|
stream := eventStreamTicketOptions{Mode: "custom"}
|
||||||
if _, err := newEventSource(context.Background(), "config", "client", "secret", stream); !errors.Is(err, fail) {
|
var captured source.Config
|
||||||
t.Fatalf("stream token error = %v", err)
|
eventNewDingtalkSource = func(cfg source.Config, _ ...source.SourceOption) (*source.DingtalkSource, error) {
|
||||||
|
captured = cfg
|
||||||
|
return &source.DingtalkSource{}, nil
|
||||||
}
|
}
|
||||||
eventResolveAccessToken = func(context.Context, string, string) (string, error) { return " ", nil }
|
eventResolveAccessToken = func(context.Context, string, string) (string, error) { return "", fail }
|
||||||
if _, err := newEventSource(context.Background(), "config", "client", "secret", stream); err == nil {
|
if _, err := newEventSource(context.Background(), "config", "client", "secret", stream); err != nil {
|
||||||
t.Fatal("empty stream token succeeded")
|
t.Fatalf("stream source construction = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := captured.PortalTicket.AccessTokenProvider(context.Background()); !errors.Is(err, fail) {
|
||||||
|
t.Fatalf("stream token provider error = %v", err)
|
||||||
}
|
}
|
||||||
eventResolveAccessToken = func(context.Context, string, string) (string, error) { return "token", nil }
|
eventResolveAccessToken = func(context.Context, string, string) (string, error) { return "token", nil }
|
||||||
for _, mode := range []string{"custom", "normal"} {
|
for _, mode := range []string{"custom", "normal"} {
|
||||||
|
|||||||
@@ -259,7 +259,7 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
|
|||||||
return personalConsumeRun(ctx, cfg)
|
return personalConsumeRun(ctx, cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
client := personal.NewClient(personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
|
client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
|
||||||
sub, eventKey, ruleType, err := personalEnsureSubscription(ctx, client, identity, opts)
|
sub, eventKey, ruleType, err := personalEnsureSubscription(ctx, client, identity, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("event consume --as user: %w", err)
|
return fmt.Errorf("event consume --as user: %w", err)
|
||||||
@@ -498,7 +498,7 @@ func runPersonalEventStatus(c *cobra.Command, opts personalStatusOptions) error
|
|||||||
if status == "" || status == "all" {
|
if status == "" || status == "all" {
|
||||||
status = ""
|
status = ""
|
||||||
}
|
}
|
||||||
subs, err := personalListSubscriptions(personal.NewClient(personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity), ctx, personal.ListOptions{
|
subs, err := personalListSubscriptions(newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity), ctx, personal.ListOptions{
|
||||||
Status: status,
|
Status: status,
|
||||||
EventKey: opts.EventKey,
|
EventKey: opts.EventKey,
|
||||||
SubscribeID: opts.SubscribeID,
|
SubscribeID: opts.SubscribeID,
|
||||||
@@ -613,7 +613,7 @@ func runPersonalEventStop(c *cobra.Command, opts personalStopOptions) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("event stop --as user: %w", err)
|
return fmt.Errorf("event stop --as user: %w", err)
|
||||||
}
|
}
|
||||||
client := personal.NewClient(personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
|
client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
|
||||||
for _, id := range subscribeIDs {
|
for _, id := range subscribeIDs {
|
||||||
if err := personalDeleteSubscription(client, ctx, id); err != nil {
|
if err := personalDeleteSubscription(client, ctx, id); err != nil {
|
||||||
return fmt.Errorf("event stop --as user: cancel subscription %s: %w", id, err)
|
return fmt.Errorf("event stop --as user: cancel subscription %s: %w", id, err)
|
||||||
@@ -723,7 +723,10 @@ func resolvePersonalEventIdentity(ctx context.Context, configDir string, sourceI
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return personal.Identity{}, err
|
return personal.Identity{}, err
|
||||||
}
|
}
|
||||||
tokenData, _ := personalLoadTokenData(configDir)
|
tokenData, err := personalLoadTokenData(configDir)
|
||||||
|
if err != nil && !errors.Is(err, authpkg.ErrTokenDataNotFound) {
|
||||||
|
return personal.Identity{}, fmt.Errorf("load OAuth identity metadata: %w", err)
|
||||||
|
}
|
||||||
var corpID, userID, clientID, refreshToken string
|
var corpID, userID, clientID, refreshToken string
|
||||||
if tokenData != nil {
|
if tokenData != nil {
|
||||||
corpID = tokenData.CorpID
|
corpID = tokenData.CorpID
|
||||||
@@ -769,6 +772,15 @@ func resolvePersonalEventIdentity(ctx context.Context, configDir string, sourceI
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newPersonalEventControlClient(configDir, baseURL string, identity personal.Identity) *personal.Client {
|
||||||
|
identity.AccessToken = ""
|
||||||
|
client := personal.NewClient(baseURL, identity)
|
||||||
|
client.AccessTokenProvider = func(ctx context.Context) (string, error) {
|
||||||
|
return personalResolveAuxiliaryAccessToken(ctx, configDir, "")
|
||||||
|
}
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
func personalTokenSubject(kind, token string) string {
|
func personalTokenSubject(kind, token string) string {
|
||||||
token = strings.TrimSpace(token)
|
token = strings.TrimSpace(token)
|
||||||
if token == "" {
|
if token == "" {
|
||||||
@@ -817,7 +829,9 @@ func newPersonalStreamSource(ctx context.Context, opts personalStreamSourceOptio
|
|||||||
}
|
}
|
||||||
_ = ctx
|
_ = ctx
|
||||||
return source.NewPersonal(source.PersonalConfig{
|
return source.NewPersonal(source.PersonalConfig{
|
||||||
AccessToken: opts.Identity.AccessToken,
|
AccessTokenProvider: func(ctx context.Context) (string, error) {
|
||||||
|
return personalResolveAuxiliaryAccessToken(ctx, opts.ConfigDir, "")
|
||||||
|
},
|
||||||
ClientID: clientID,
|
ClientID: clientID,
|
||||||
ClientSecret: clientSecret,
|
ClientSecret: clientSecret,
|
||||||
SourceID: opts.Identity.SourceID,
|
SourceID: opts.Identity.SourceID,
|
||||||
|
|||||||
@@ -27,9 +27,13 @@ type accessTokenGetter interface {
|
|||||||
GetAccessToken(context.Context) (string, error)
|
GetAccessToken(context.Context) (string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type rejectedAccessTokenRefresher interface {
|
||||||
|
ForceRefreshRejectedToken(context.Context, string) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
markAccessTokenStale = authpkg.MarkAccessTokenStale
|
loadRefreshTokenData = authpkg.LoadTokenData
|
||||||
newRefreshProvider = func(configDir string) accessTokenGetter {
|
newRefreshProvider = func(configDir string) rejectedAccessTokenRefresher {
|
||||||
disc := slog.New(slog.NewTextHandler(io.Discard, nil))
|
disc := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
provider := authpkg.NewOAuthProvider(configDir, disc)
|
provider := authpkg.NewOAuthProvider(configDir, disc)
|
||||||
configureOAuthProviderCompatibility(provider, configDir)
|
configureOAuthProviderCompatibility(provider, configDir)
|
||||||
@@ -42,26 +46,33 @@ var (
|
|||||||
// server-side rejection (HTTP 401 or business code such as
|
// server-side rejection (HTTP 401 or business code such as
|
||||||
// TOKEN_VERIFIED_FAILED) on what locally appeared to be a still-valid token.
|
// TOKEN_VERIFIED_FAILED) on what locally appeared to be a still-valid token.
|
||||||
//
|
//
|
||||||
// Steps:
|
// It snapshots the current access token, then delegates to the OAuth
|
||||||
// 1. MarkAccessTokenStale rewrites ExpiresAt to a past instant so
|
// provider's dual-locked compare-and-refresh operation. If another caller has
|
||||||
// OAuthProvider.GetAccessToken's fast-path will miss.
|
// already rotated the token, that newer token is reused without another
|
||||||
// 2. NewOAuthProvider + GetAccessToken triggers lockedRefresh, which uses the
|
// refresh request.
|
||||||
// existing dual-layer lock (process + file) to serialize concurrent
|
|
||||||
// refresh attempts across goroutines and processes.
|
|
||||||
// 3. ResetRuntimeTokenCache clears the per-process sync.Once cache so the
|
|
||||||
// next resolveAuthToken call re-reads from disk.
|
|
||||||
//
|
|
||||||
// Existing OAuthProvider.GetAccessToken behaviour is unchanged; this helper
|
|
||||||
// is the only entry point that orchestrates "force refresh" semantics.
|
|
||||||
func ForceRefreshAccessToken(ctx context.Context, configDir string) (string, error) {
|
func ForceRefreshAccessToken(ctx context.Context, configDir string) (string, error) {
|
||||||
if strings.TrimSpace(configDir) == "" {
|
if strings.TrimSpace(configDir) == "" {
|
||||||
return "", fmt.Errorf("config directory is empty")
|
return "", fmt.Errorf("config directory is empty")
|
||||||
}
|
}
|
||||||
if err := markAccessTokenStale(configDir); err != nil {
|
data, err := loadRefreshTokenData(configDir)
|
||||||
return "", fmt.Errorf("mark access token stale: %w", err)
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if data == nil || strings.TrimSpace(data.AccessToken) == "" {
|
||||||
|
return "", fmt.Errorf("stored access token is empty")
|
||||||
|
}
|
||||||
|
return forceRefreshRejectedAccessToken(ctx, configDir, data.AccessToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
func forceRefreshRejectedAccessToken(ctx context.Context, configDir, rejectedAccessToken string) (string, error) {
|
||||||
|
if strings.TrimSpace(configDir) == "" {
|
||||||
|
return "", fmt.Errorf("config directory is empty")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(rejectedAccessToken) == "" {
|
||||||
|
return "", fmt.Errorf("rejected access token is empty")
|
||||||
}
|
}
|
||||||
provider := newRefreshProvider(configDir)
|
provider := newRefreshProvider(configDir)
|
||||||
tok, err := provider.GetAccessToken(ctx)
|
tok, err := provider.ForceRefreshRejectedToken(ctx, rejectedAccessToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ var openBrowserFunc = tryOpenBrowser
|
|||||||
var (
|
var (
|
||||||
patAuthorizationTimeout = PatAuthRetryTimeout
|
patAuthorizationTimeout = PatAuthRetryTimeout
|
||||||
patAuthorizationPollInterval = PatAuthPollInterval
|
patAuthorizationPollInterval = PatAuthPollInterval
|
||||||
patLoadTokenData = authpkg.LoadTokenData
|
patResolveAccessToken = ResolveAuxiliaryAccessToken
|
||||||
patWaitForAuthorization = WaitForPatAuthorization
|
patWaitForAuthorization = WaitForPatAuthorization
|
||||||
patPollDeviceFlowWithInterval = pollPatDeviceFlowWithInterval
|
patPollDeviceFlowWithInterval = pollPatDeviceFlowWithInterval
|
||||||
patSaveAppConfig = authpkg.SaveAppConfig
|
patSaveAppConfig = authpkg.SaveAppConfig
|
||||||
@@ -272,7 +272,7 @@ func patAuthorizationURIFromData(data map[string]any) string {
|
|||||||
|
|
||||||
// WaitForPatAuthorization polls until the user completes authorization or timeout.
|
// WaitForPatAuthorization polls until the user completes authorization or timeout.
|
||||||
// It returns true if authorization was completed, false if timed out or cancelled.
|
// It returns true if authorization was completed, false if timed out or cancelled.
|
||||||
func WaitForPatAuthorization(ctx context.Context, configDir string, output io.Writer) bool {
|
func WaitForPatAuthorization(ctx context.Context, configDir string, output io.Writer) (bool, error) {
|
||||||
timeout := patAuthorizationTimeout
|
timeout := patAuthorizationTimeout
|
||||||
deadline := time.Now().Add(timeout)
|
deadline := time.Now().Add(timeout)
|
||||||
pollTicker := time.NewTicker(patAuthorizationPollInterval)
|
pollTicker := time.NewTicker(patAuthorizationPollInterval)
|
||||||
@@ -290,27 +290,26 @@ func WaitForPatAuthorization(ctx context.Context, configDir string, output io.Wr
|
|||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
fmt.Fprintf(output, "%s 操作已取消\n", tui.StateMark("error"))
|
fmt.Fprintf(output, "%s 操作已取消\n", tui.StateMark("error"))
|
||||||
return false
|
return false, ctx.Err()
|
||||||
|
|
||||||
case <-time.After(time.Until(deadline)):
|
case <-time.After(time.Until(deadline)):
|
||||||
fmt.Fprintf(output, "%s 等待授权超时 (%s)\n", tui.StateMark("error"), timeout)
|
fmt.Fprintf(output, "%s 等待授权超时 (%s)\n", tui.StateMark("error"), timeout)
|
||||||
fmt.Fprintf(output, " %s 请重新执行命令\n", tui.Dim("ℹ"))
|
fmt.Fprintf(output, " %s 请重新执行命令\n", tui.Dim("ℹ"))
|
||||||
return false
|
return false, nil
|
||||||
|
|
||||||
case <-pollTicker.C:
|
case <-pollTicker.C:
|
||||||
pollCount++
|
pollCount++
|
||||||
elapsed := time.Since(start).Truncate(time.Second)
|
elapsed := time.Since(start).Truncate(time.Second)
|
||||||
remaining := time.Until(deadline).Truncate(time.Second)
|
remaining := time.Until(deadline).Truncate(time.Second)
|
||||||
|
|
||||||
// Check if token is now valid
|
// Check the same resolver used by every outbound bearer request.
|
||||||
tokenData, err := patLoadTokenData(configDir)
|
if _, err := patResolveAccessToken(ctx, configDir, ""); err == nil {
|
||||||
if err == nil && tokenData != nil {
|
fmt.Fprintf(output, "\r%s %s (%s 已用, %s 剩余) \n",
|
||||||
if tokenData.IsAccessTokenValid() || tokenData.IsRefreshTokenValid() {
|
tui.StateMark("ok"), tui.Bold("授权成功!"), elapsed, remaining)
|
||||||
fmt.Fprintf(output, "\r%s %s (%s 已用, %s 剩余) \n",
|
fmt.Fprintln(output)
|
||||||
tui.StateMark("ok"), tui.Bold("授权成功!"), elapsed, remaining)
|
return true, nil
|
||||||
fmt.Fprintln(output)
|
} else if !stderrors.Is(err, authpkg.ErrTokenDataNotFound) {
|
||||||
return true
|
return false, fmt.Errorf("check authorization token: %w", err)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show polling status
|
// Show polling status
|
||||||
@@ -340,7 +339,10 @@ func retryWithPatAuthRetry(ctx context.Context, runner executor.Runner, invocati
|
|||||||
PrintPatAuthError(output, scopeErr)
|
PrintPatAuthError(output, scopeErr)
|
||||||
|
|
||||||
// Wait for user to complete authorization
|
// Wait for user to complete authorization
|
||||||
authorized := patWaitForAuthorization(ctx, configDir, output)
|
authorized, waitErr := patWaitForAuthorization(ctx, configDir, output)
|
||||||
|
if waitErr != nil {
|
||||||
|
return executor.Result{}, waitErr
|
||||||
|
}
|
||||||
if !authorized {
|
if !authorized {
|
||||||
return executor.Result{}, apperrors.NewAuth(
|
return executor.Result{}, apperrors.NewAuth(
|
||||||
"等待用户授权超时",
|
"等待用户授权超时",
|
||||||
@@ -794,12 +796,6 @@ func pollPatDeviceFlowWithInterval(ctx context.Context, flowID string, configDir
|
|||||||
pollURL := fmt.Sprintf("%s%s?flowId=%s",
|
pollURL := fmt.Sprintf("%s%s?flowId=%s",
|
||||||
authpkg.GetMCPBaseURL(), authpkg.DevicePollPath, url.QueryEscape(flowID))
|
authpkg.GetMCPBaseURL(), authpkg.DevicePollPath, url.QueryEscape(flowID))
|
||||||
|
|
||||||
// Load user access token for the poll request header.
|
|
||||||
var accessToken string
|
|
||||||
if tokenData, err := authpkg.LoadTokenData(configDir); err == nil && tokenData != nil {
|
|
||||||
accessToken = tokenData.AccessToken
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use a client that does NOT follow redirects, so we can detect SSO 302.
|
// Use a client that does NOT follow redirects, so we can detect SSO 302.
|
||||||
noRedirectClient := &http.Client{
|
noRedirectClient := &http.Client{
|
||||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||||
@@ -828,6 +824,10 @@ func pollPatDeviceFlowWithInterval(ctx context.Context, flowID string, configDir
|
|||||||
slog.Debug("PAT poll: failed to create request", "error", err)
|
slog.Debug("PAT poll: failed to create request", "error", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
accessToken, tokenErr := patResolveAccessToken(ctx, configDir, "")
|
||||||
|
if tokenErr != nil && !stderrors.Is(tokenErr, authpkg.ErrTokenDataNotFound) {
|
||||||
|
return "", "", fmt.Errorf("resolve PAT poll access token: %w", tokenErr)
|
||||||
|
}
|
||||||
if accessToken != "" {
|
if accessToken != "" {
|
||||||
req.Header.Set("x-user-access-token", accessToken)
|
req.Header.Set("x-user-access-token", accessToken)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,39 +52,41 @@ func TestCrossPlatformCoveragePATRetryRemainingPureAndWaitCoverage(t *testing.T)
|
|||||||
|
|
||||||
oldTimeout := patAuthorizationTimeout
|
oldTimeout := patAuthorizationTimeout
|
||||||
oldInterval := patAuthorizationPollInterval
|
oldInterval := patAuthorizationPollInterval
|
||||||
oldLoad := patLoadTokenData
|
oldResolve := patResolveAccessToken
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
patAuthorizationTimeout = oldTimeout
|
patAuthorizationTimeout = oldTimeout
|
||||||
patAuthorizationPollInterval = oldInterval
|
patAuthorizationPollInterval = oldInterval
|
||||||
patLoadTokenData = oldLoad
|
patResolveAccessToken = oldResolve
|
||||||
})
|
})
|
||||||
patAuthorizationTimeout = 50 * time.Millisecond
|
patAuthorizationTimeout = 50 * time.Millisecond
|
||||||
patAuthorizationPollInterval = time.Millisecond
|
patAuthorizationPollInterval = time.Millisecond
|
||||||
patLoadTokenData = func(string) (*authpkg.TokenData, error) {
|
patResolveAccessToken = func(context.Context, string, string) (string, error) {
|
||||||
return &authpkg.TokenData{AccessToken: "token", ExpiresAt: time.Now().Add(time.Hour)}, nil
|
return "token", nil
|
||||||
}
|
}
|
||||||
out.Reset()
|
out.Reset()
|
||||||
if !WaitForPatAuthorization(context.Background(), "", &out) {
|
if ok, err := WaitForPatAuthorization(context.Background(), "", &out); err != nil || !ok {
|
||||||
t.Fatal("valid token did not authorize")
|
t.Fatal("valid token did not authorize")
|
||||||
}
|
}
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
cancel()
|
cancel()
|
||||||
out.Reset()
|
out.Reset()
|
||||||
if WaitForPatAuthorization(ctx, "", &out) {
|
if ok, err := WaitForPatAuthorization(ctx, "", &out); ok || !errors.Is(err, context.Canceled) {
|
||||||
t.Fatal("cancelled authorization succeeded")
|
t.Fatalf("cancelled authorization = %v, %v", ok, err)
|
||||||
}
|
}
|
||||||
patAuthorizationTimeout = time.Millisecond
|
patAuthorizationTimeout = time.Millisecond
|
||||||
patAuthorizationPollInterval = time.Hour
|
patAuthorizationPollInterval = time.Hour
|
||||||
out.Reset()
|
out.Reset()
|
||||||
if WaitForPatAuthorization(context.Background(), "", &out) {
|
if ok, err := WaitForPatAuthorization(context.Background(), "", &out); err != nil || ok {
|
||||||
t.Fatal("timed out authorization succeeded")
|
t.Fatalf("timed out authorization = %v, %v", ok, err)
|
||||||
}
|
}
|
||||||
patAuthorizationTimeout = 5 * time.Millisecond
|
patAuthorizationTimeout = 5 * time.Millisecond
|
||||||
patAuthorizationPollInterval = time.Millisecond
|
patAuthorizationPollInterval = time.Millisecond
|
||||||
patLoadTokenData = func(string) (*authpkg.TokenData, error) { return nil, nil }
|
patResolveAccessToken = func(context.Context, string, string) (string, error) {
|
||||||
|
return "", authpkg.ErrTokenDataNotFound
|
||||||
|
}
|
||||||
out.Reset()
|
out.Reset()
|
||||||
if WaitForPatAuthorization(context.Background(), "", &out) || !strings.Contains(out.String(), "等待授权中") {
|
if ok, err := WaitForPatAuthorization(context.Background(), "", &out); err != nil || ok || !strings.Contains(out.String(), "等待授权中") {
|
||||||
t.Fatalf("invalid-token polling output = %q", out.String())
|
t.Fatalf("invalid-token polling = %v, %v, output %q", ok, err, out.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,12 +111,12 @@ func TestCrossPlatformCoveragePATRetryRemainingOrchestrationCoverage(t *testing.
|
|||||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||||
|
|
||||||
scope := &PatScopeError{OriginalError: "missing", Identity: "user", ErrorType: "missing_scope", Message: "missing", Hint: "login", MissingScope: "calendar:read"}
|
scope := &PatScopeError{OriginalError: "missing", Identity: "user", ErrorType: "missing_scope", Message: "missing", Hint: "login", MissingScope: "calendar:read"}
|
||||||
patWaitForAuthorization = func(context.Context, string, io.Writer) bool { return false }
|
patWaitForAuthorization = func(context.Context, string, io.Writer) (bool, error) { return false, nil }
|
||||||
if _, err := retryWithPatAuthRetry(context.Background(), runnerCoverageFallback{}, executor.Invocation{}, scope, t.TempDir(), io.Discard); err == nil {
|
if _, err := retryWithPatAuthRetry(context.Background(), runnerCoverageFallback{}, executor.Invocation{}, scope, t.TempDir(), io.Discard); err == nil {
|
||||||
t.Fatal("PAT retry timeout succeeded")
|
t.Fatal("PAT retry timeout succeeded")
|
||||||
}
|
}
|
||||||
wantErr := errors.New("runner failed")
|
wantErr := errors.New("runner failed")
|
||||||
patWaitForAuthorization = func(context.Context, string, io.Writer) bool { return true }
|
patWaitForAuthorization = func(context.Context, string, io.Writer) (bool, error) { return true, nil }
|
||||||
if _, err := retryWithPatAuthRetry(context.Background(), runnerCoverageFallback{err: wantErr}, executor.Invocation{}, scope, t.TempDir(), io.Discard); !errors.Is(err, wantErr) {
|
if _, err := retryWithPatAuthRetry(context.Background(), runnerCoverageFallback{err: wantErr}, executor.Invocation{}, scope, t.TempDir(), io.Discard); !errors.Is(err, wantErr) {
|
||||||
t.Fatalf("authorized retry = %v", err)
|
t.Fatalf("authorized retry = %v", err)
|
||||||
}
|
}
|
||||||
@@ -215,15 +217,15 @@ func patRaw(flowID, clientID, secret string) string {
|
|||||||
func TestCrossPlatformCoveragePATRetryRemainingPollAndBrowserCoverage(t *testing.T) {
|
func TestCrossPlatformCoveragePATRetryRemainingPollAndBrowserCoverage(t *testing.T) {
|
||||||
oldDo := patPollHTTPDo
|
oldDo := patPollHTTPDo
|
||||||
oldRequest := patPollNewRequest
|
oldRequest := patPollNewRequest
|
||||||
oldLoad := patLoadTokenData
|
oldResolve := patResolveAccessToken
|
||||||
oldBrowser := patBrowserOpenCommand
|
oldBrowser := patBrowserOpenCommand
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
patPollHTTPDo = oldDo
|
patPollHTTPDo = oldDo
|
||||||
patPollNewRequest = oldRequest
|
patPollNewRequest = oldRequest
|
||||||
patLoadTokenData = oldLoad
|
patResolveAccessToken = oldResolve
|
||||||
patBrowserOpenCommand = oldBrowser
|
patBrowserOpenCommand = oldBrowser
|
||||||
})
|
})
|
||||||
patLoadTokenData = func(string) (*authpkg.TokenData, error) { return &authpkg.TokenData{AccessToken: "token"}, nil }
|
patResolveAccessToken = func(context.Context, string, string) (string, error) { return "token", nil }
|
||||||
cancelled, cancelNow := context.WithCancel(context.Background())
|
cancelled, cancelNow := context.WithCancel(context.Background())
|
||||||
cancelNow()
|
cancelNow()
|
||||||
if status, _, err := pollPatDeviceFlowWithInterval(cancelled, "flow", t.TempDir(), io.Discard, 0); err != nil || status != authpkg.StatusCancelled {
|
if status, _, err := pollPatDeviceFlowWithInterval(cancelled, "flow", t.TempDir(), io.Discard, 0); err != nil || status != authpkg.StatusCancelled {
|
||||||
|
|||||||
@@ -333,7 +333,11 @@ func (r *recoveryRuntime) CallToolDirect(ctx context.Context, serverID, toolName
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
tc := r.transport.WithAuth(resolveRuntimeAuthToken(ctx, recoveryRuntimeToken(r.flags)), resolveIdentityHeaders())
|
authToken, err := resolveRuntimeAuthToken(ctx, recoveryRuntimeToken(r.flags))
|
||||||
|
if err != nil {
|
||||||
|
return nil, tokenResolutionError(err)
|
||||||
|
}
|
||||||
|
tc := r.transport.WithAuth(authToken, resolveIdentityHeaders())
|
||||||
result, err := tc.CallTool(ctx, endpoint, toolName, args)
|
result, err := tc.CallTool(ctx, endpoint, toolName, args)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
+64
-54
@@ -235,7 +235,9 @@ func (r *runtimeRunner) runSingle(ctx context.Context, invocation executor.Invoc
|
|||||||
// ~70ms on macOS; starting it here lets the load overlap with endpoint
|
// ~70ms on macOS; starting it here lets the load overlap with endpoint
|
||||||
// resolution and catalog loading below.
|
// resolution and catalog loading below.
|
||||||
if prefetchToken {
|
if prefetchToken {
|
||||||
go runnerGetCachedRuntimeToken(ctx)
|
go func() {
|
||||||
|
_, _ = runnerGetCachedRuntimeToken(ctx)
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
if shouldUseDirectRuntime(invocation) {
|
if shouldUseDirectRuntime(invocation) {
|
||||||
@@ -534,8 +536,12 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
|||||||
authToken := ""
|
authToken := ""
|
||||||
if hasPluginAuth {
|
if hasPluginAuth {
|
||||||
authToken = pluginAuth.Token
|
authToken = pluginAuth.Token
|
||||||
} else {
|
} else if !invocation.DryRun && (r.globalFlags == nil || !r.globalFlags.Mock) {
|
||||||
authToken = r.resolveAuthToken(ctx)
|
var tokenErr error
|
||||||
|
authToken, tokenErr = r.resolveAuthToken(ctx)
|
||||||
|
if tokenErr != nil {
|
||||||
|
return executor.Result{}, tokenResolutionError(tokenErr)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var timeoutSec int
|
var timeoutSec int
|
||||||
@@ -617,6 +623,12 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
|||||||
}
|
}
|
||||||
return runnerHandlePatAuthCheck(ctx, r, invocation, patCheck, defaultConfigDir(), os.Stderr)
|
return runnerHandlePatAuthCheck(ctx, r, invocation, patCheck, defaultConfigDir(), os.Stderr)
|
||||||
}
|
}
|
||||||
|
if result, retryErr, handled := r.retryAuthRefreshRequired(ctx, endpoint, invocation, authToken, err, hasPluginAuth); handled {
|
||||||
|
if retryErr != nil {
|
||||||
|
runnerCaptureRuntimeFailure(invocation, err, retryErr)
|
||||||
|
}
|
||||||
|
return result, retryErr
|
||||||
|
}
|
||||||
runnerCaptureRuntimeFailure(invocation, err, err)
|
runnerCaptureRuntimeFailure(invocation, err, err)
|
||||||
return executor.Result{}, err
|
return executor.Result{}, err
|
||||||
}
|
}
|
||||||
@@ -625,9 +637,15 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
|||||||
callResult, err := runnerCallTool(tc, callCtx, endpoint, invocation.Tool, invocation.Params)
|
callResult, err := runnerCallTool(tc, callCtx, endpoint, invocation.Tool, invocation.Params)
|
||||||
RecordTiming(ctx, "mcp_call", time.Since(callStart))
|
RecordTiming(ctx, "mcp_call", time.Since(callStart))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if isAuthError(err) {
|
if isRefreshableTransportAuthError(err) {
|
||||||
if fn := edition.Get().OnAuthError; fn != nil {
|
if fn := edition.Get().OnAuthError; fn != nil {
|
||||||
if overrideErr := fn(defaultConfigDir(), err); overrideErr != nil {
|
if overrideErr := fn(defaultConfigDir(), err); overrideErr != nil {
|
||||||
|
if result, retryErr, handled := r.retryAuthRefreshRequired(ctx, endpoint, invocation, authToken, overrideErr, hasPluginAuth); handled {
|
||||||
|
if retryErr != nil {
|
||||||
|
runnerCaptureRuntimeFailure(invocation, err, retryErr)
|
||||||
|
}
|
||||||
|
return result, retryErr
|
||||||
|
}
|
||||||
runnerCaptureRuntimeFailure(invocation, err, overrideErr)
|
runnerCaptureRuntimeFailure(invocation, err, overrideErr)
|
||||||
return executor.Result{}, overrideErr
|
return executor.Result{}, overrideErr
|
||||||
}
|
}
|
||||||
@@ -652,6 +670,12 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
|||||||
}
|
}
|
||||||
return runnerHandlePatAuthCheck(ctx, r, invocation, patCheck, defaultConfigDir(), os.Stderr)
|
return runnerHandlePatAuthCheck(ctx, r, invocation, patCheck, defaultConfigDir(), os.Stderr)
|
||||||
}
|
}
|
||||||
|
if result, retryErr, handled := r.retryAuthRefreshRequired(ctx, endpoint, invocation, authToken, editionErr, hasPluginAuth); handled {
|
||||||
|
if retryErr != nil {
|
||||||
|
runnerCaptureRuntimeFailure(invocation, editionErr, retryErr)
|
||||||
|
}
|
||||||
|
return result, retryErr
|
||||||
|
}
|
||||||
return executor.Result{}, editionErr
|
return executor.Result{}, editionErr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -672,6 +696,12 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
|||||||
// patterns (PAT permission, gateway-auth) before generic handling.
|
// patterns (PAT permission, gateway-auth) before generic handling.
|
||||||
if classify := edition.Get().ClassifyToolResult; classify != nil {
|
if classify := edition.Get().ClassifyToolResult; classify != nil {
|
||||||
if hookErr := classify(callResult.Content); hookErr != nil {
|
if hookErr := classify(callResult.Content); hookErr != nil {
|
||||||
|
if result, retryErr, handled := r.retryAuthRefreshRequired(ctx, endpoint, invocation, authToken, hookErr, hasPluginAuth); handled {
|
||||||
|
if retryErr != nil {
|
||||||
|
runnerCaptureRuntimeFailure(invocation, hookErr, retryErr)
|
||||||
|
}
|
||||||
|
return result, retryErr
|
||||||
|
}
|
||||||
runnerCaptureRuntimeFailure(invocation, hookErr, hookErr)
|
runnerCaptureRuntimeFailure(invocation, hookErr, hookErr)
|
||||||
return executor.Result{}, hookErr
|
return executor.Result{}, hookErr
|
||||||
}
|
}
|
||||||
@@ -796,67 +826,49 @@ func (r *runtimeRunner) executeStdioInvocation(ctx context.Context, invocation e
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *runtimeRunner) resolveAuthToken(ctx context.Context) string {
|
func (r *runtimeRunner) resolveAuthToken(ctx context.Context) (string, error) {
|
||||||
explicitToken := ""
|
explicitToken := ""
|
||||||
if r != nil && r.globalFlags != nil {
|
if r != nil && r.globalFlags != nil {
|
||||||
explicitToken = r.globalFlags.Token
|
explicitToken = r.globalFlags.Token
|
||||||
}
|
}
|
||||||
if token := strings.TrimSpace(explicitToken); token != "" {
|
return resolveRuntimeAuthToken(ctx, explicitToken)
|
||||||
return token
|
|
||||||
}
|
|
||||||
if tp := edition.Get().TokenProvider; tp != nil {
|
|
||||||
token, _ := tp(ctx, func() (string, error) {
|
|
||||||
return resolveAccessTokenFromDir(ctx, defaultConfigDir())
|
|
||||||
})
|
|
||||||
return token
|
|
||||||
}
|
|
||||||
return getCachedRuntimeToken(ctx)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveRuntimeAuthToken(ctx context.Context, explicitToken string) string {
|
func resolveRuntimeAuthToken(ctx context.Context, explicitToken string) (string, error) {
|
||||||
if token := strings.TrimSpace(explicitToken); token != "" {
|
snapshot, err := runtimeTokenManager.Get(ctx, defaultConfigDir(), explicitToken)
|
||||||
return token
|
if err != nil {
|
||||||
|
return "", err
|
||||||
}
|
}
|
||||||
// Use cached token to avoid repeated Keychain access (~70ms per call)
|
return snapshot.AccessToken, nil
|
||||||
return getCachedRuntimeToken(ctx)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cached token state for process lifetime
|
// getCachedRuntimeToken is kept as the prefetch seam used by runner tests. The
|
||||||
var (
|
// cache itself lives exclusively in TokenManager.
|
||||||
cachedRuntimeTokenMu sync.Mutex
|
func getCachedRuntimeToken(ctx context.Context) (string, error) {
|
||||||
cachedRuntimeTokens = map[string]string{}
|
|
||||||
)
|
|
||||||
|
|
||||||
// getCachedRuntimeToken returns a cached access token, loading it only once per process.
|
|
||||||
// This avoids repeated Keychain access which takes ~70ms each time.
|
|
||||||
func getCachedRuntimeToken(ctx context.Context) string {
|
|
||||||
cacheKey := strings.TrimSpace(authpkg.RuntimeProfile())
|
|
||||||
if cacheKey == "" {
|
|
||||||
cacheKey = "__default__"
|
|
||||||
}
|
|
||||||
cachedRuntimeTokenMu.Lock()
|
|
||||||
if token := cachedRuntimeTokens[cacheKey]; token != "" {
|
|
||||||
cachedRuntimeTokenMu.Unlock()
|
|
||||||
return token
|
|
||||||
}
|
|
||||||
cachedRuntimeTokenMu.Unlock()
|
|
||||||
|
|
||||||
loadStart := time.Now()
|
loadStart := time.Now()
|
||||||
defer func() { RecordTiming(ctx, "auth_keychain", time.Since(loadStart)) }()
|
defer func() { RecordTiming(ctx, "auth_keychain", time.Since(loadStart)) }()
|
||||||
|
return resolveRuntimeAuthToken(ctx, "")
|
||||||
|
}
|
||||||
|
|
||||||
configDir := defaultConfigDir()
|
func tokenResolutionError(err error) error {
|
||||||
token, tokenErr := resolveAccessTokenFromDir(ctx, configDir)
|
if err == nil {
|
||||||
if tokenErr != nil && errors.Is(tokenErr, authpkg.ErrTokenDecryption) {
|
return nil
|
||||||
slog.Error(tokenErr.Error())
|
|
||||||
return ""
|
|
||||||
}
|
}
|
||||||
if token == "" {
|
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||||
return ""
|
return err
|
||||||
}
|
}
|
||||||
cachedRuntimeTokenMu.Lock()
|
if errors.Is(err, authpkg.ErrTokenDataNotFound) {
|
||||||
cachedRuntimeTokens[cacheKey] = token
|
return apperrors.NewAuth(
|
||||||
cachedRuntimeTokenMu.Unlock()
|
"未登录,请先执行 dws auth login",
|
||||||
return token
|
apperrors.WithReason("not_authenticated"),
|
||||||
|
apperrors.WithHint("运行 'dws auth login' 完成登录后重试"),
|
||||||
|
apperrors.WithActions("dws auth login"),
|
||||||
|
apperrors.WithCause(err),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Keychain, parse, permission, lock, and refresh failures are real local or
|
||||||
|
// network errors. Preserve their cause instead of disguising them as logout.
|
||||||
|
return fmt.Errorf("resolve access token: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// generateExecutionID returns a random 16-char hex string used to correlate
|
// generateExecutionID returns a random 16-char hex string used to correlate
|
||||||
@@ -871,9 +883,7 @@ func generateExecutionID() string {
|
|||||||
// ResetRuntimeTokenCache clears the cached token, forcing a reload on next access.
|
// ResetRuntimeTokenCache clears the cached token, forcing a reload on next access.
|
||||||
// This should be called after login/logout operations.
|
// This should be called after login/logout operations.
|
||||||
func ResetRuntimeTokenCache() {
|
func ResetRuntimeTokenCache() {
|
||||||
cachedRuntimeTokenMu.Lock()
|
runtimeTokenManager.Invalidate()
|
||||||
defer cachedRuntimeTokenMu.Unlock()
|
|
||||||
cachedRuntimeTokens = map[string]string{}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func newRuntimeContentScanner() safety.Scanner {
|
func newRuntimeContentScanner() safety.Scanner {
|
||||||
|
|||||||
@@ -50,9 +50,9 @@ func TestCrossPlatformCoverageRunnerRemainingRoutingCoverage(t *testing.T) {
|
|||||||
|
|
||||||
inv := executor.Invocation{CanonicalProduct: "product", Tool: "tool"}
|
inv := executor.Invocation{CanonicalProduct: "product", Tool: "tool"}
|
||||||
prefetched := make(chan struct{}, 1)
|
prefetched := make(chan struct{}, 1)
|
||||||
runnerGetCachedRuntimeToken = func(context.Context) string {
|
runnerGetCachedRuntimeToken = func(context.Context) (string, error) {
|
||||||
prefetched <- struct{}{}
|
prefetched <- struct{}{}
|
||||||
return ""
|
return "", nil
|
||||||
}
|
}
|
||||||
r := &runtimeRunner{
|
r := &runtimeRunner{
|
||||||
loader: cli.CatalogLoaderFrom(cli.Catalog{}, wantErr),
|
loader: cli.CatalogLoaderFrom(cli.Catalog{}, wantErr),
|
||||||
@@ -197,7 +197,7 @@ func TestCrossPlatformCoverageRunnerRemainingExecutionCoverage(t *testing.T) {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
authErr := apperrors.NewAuth("expired")
|
authErr := apperrors.NewAuth("expired", apperrors.WithReason("http_401"))
|
||||||
runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) {
|
runnerCallTool = func(*transport.Client, context.Context, string, string, map[string]any) (transport.ToolCallResult, error) {
|
||||||
return transport.ToolCallResult{}, authErr
|
return transport.ToolCallResult{}, authErr
|
||||||
}
|
}
|
||||||
@@ -329,19 +329,19 @@ func TestCrossPlatformCoverageRunnerRemainingStdioAuthAndHeadersCoverage(t *test
|
|||||||
}
|
}
|
||||||
|
|
||||||
r.globalFlags.Token = " explicit "
|
r.globalFlags.Token = " explicit "
|
||||||
if got := r.resolveAuthToken(context.Background()); got != "explicit" {
|
if got, err := r.resolveAuthToken(context.Background()); err != nil || got != "explicit" {
|
||||||
t.Fatalf("explicit auth token = %q", got)
|
t.Fatalf("explicit auth token = %q, %v", got, err)
|
||||||
}
|
}
|
||||||
edition.Override(&edition.Hooks{TokenProvider: func(_ context.Context, fallback func() (string, error)) (string, error) {
|
edition.Override(&edition.Hooks{TokenProvider: func(_ context.Context, fallback func() (string, error)) (string, error) {
|
||||||
_, _ = fallback()
|
_, _ = fallback()
|
||||||
return "provided", nil
|
return "provided", nil
|
||||||
}})
|
}})
|
||||||
r.globalFlags.Token = ""
|
r.globalFlags.Token = ""
|
||||||
if got := r.resolveAuthToken(context.Background()); got != "provided" {
|
if got, err := r.resolveAuthToken(context.Background()); err != nil || got != "provided" {
|
||||||
t.Fatalf("provided auth token = %q", got)
|
t.Fatalf("provided auth token = %q, %v", got, err)
|
||||||
}
|
}
|
||||||
if got := resolveRuntimeAuthToken(context.Background(), " runtime "); got != "runtime" {
|
if got, err := resolveRuntimeAuthToken(context.Background(), " runtime "); err != nil || got != "runtime" {
|
||||||
t.Fatalf("runtime explicit token = %q", got)
|
t.Fatalf("runtime explicit token = %q, %v", got, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Setenv(envDWSChannel, "channel")
|
t.Setenv(envDWSChannel, "channel")
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
"archive/zip"
|
"archive/zip"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"mime"
|
"mime"
|
||||||
@@ -36,25 +37,25 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
skillLoadAccessToken = loadSkillAccessToken
|
skillLoadAccessToken = loadSkillAccessToken
|
||||||
skillDownloadToTmp = downloadSkillToTmpDir
|
skillDownloadToTmp = downloadSkillToTmpDir
|
||||||
skillHTTPDo = func(client *http.Client, req *http.Request) (*http.Response, error) { return client.Do(req) }
|
skillHTTPDo = func(client *http.Client, req *http.Request) (*http.Response, error) { return client.Do(req) }
|
||||||
skillNewRequest = http.NewRequestWithContext
|
skillNewRequest = http.NewRequestWithContext
|
||||||
skillLoadTokenData = authpkg.LoadTokenData
|
skillResolveAccessToken = ResolveAuxiliaryAccessToken
|
||||||
skillResolveTargetPath = resolveSkillTargetPath
|
skillResolveTargetPath = resolveSkillTargetPath
|
||||||
skillFetchDownloadInfo = fetchSkillDownloadInfo
|
skillFetchDownloadInfo = fetchSkillDownloadInfo
|
||||||
skillDownloadFile = downloadSkillFile
|
skillDownloadFile = downloadSkillFile
|
||||||
skillExtractZip = extractSkillZip
|
skillExtractZip = extractSkillZip
|
||||||
skillUserHomeDir = os.UserHomeDir
|
skillUserHomeDir = os.UserHomeDir
|
||||||
skillMkdirTemp = os.MkdirTemp
|
skillMkdirTemp = os.MkdirTemp
|
||||||
skillCreate = os.Create
|
skillCreate = os.Create
|
||||||
skillCreateTemp = os.CreateTemp
|
skillCreateTemp = os.CreateTemp
|
||||||
skillRemoveAll = os.RemoveAll
|
skillRemoveAll = os.RemoveAll
|
||||||
skillRemove = os.Remove
|
skillRemove = os.Remove
|
||||||
skillMkdirAll = os.MkdirAll
|
skillMkdirAll = os.MkdirAll
|
||||||
skillOpenFile = os.OpenFile
|
skillOpenFile = os.OpenFile
|
||||||
skillCopy = io.Copy
|
skillCopy = io.Copy
|
||||||
skillOpenZipFile = func(file *zip.File) (io.ReadCloser, error) { return file.Open() }
|
skillOpenZipFile = func(file *zip.File) (io.ReadCloser, error) { return file.Open() }
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -296,7 +297,7 @@ func newSkillAddHintCommand() *cobra.Command {
|
|||||||
|
|
||||||
func runSkillGet(cmd *cobra.Command, args []string) error {
|
func runSkillGet(cmd *cobra.Command, args []string) error {
|
||||||
skillID, _ := cmd.Flags().GetString("skill-id")
|
skillID, _ := cmd.Flags().GetString("skill-id")
|
||||||
accessToken, err := skillLoadAccessToken()
|
accessToken, err := skillLoadAccessToken(cmd.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -319,7 +320,7 @@ func runSkillFind(cmd *cobra.Command, args []string) error {
|
|||||||
if source == "" {
|
if source == "" {
|
||||||
source, _ = cmd.Flags().GetString("scopes")
|
source, _ = cmd.Flags().GetString("scopes")
|
||||||
}
|
}
|
||||||
accessToken, err := skillLoadAccessToken()
|
accessToken, err := skillLoadAccessToken(cmd.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -388,7 +389,7 @@ func runSkillAdd(cmd *cobra.Command, args []string) error {
|
|||||||
return apperrors.NewValidation(fmt.Sprintf("invalid target '%s': %v. Supported targets: %s", target, err, supportedTargets()))
|
return apperrors.NewValidation(fmt.Sprintf("invalid target '%s': %v. Supported targets: %s", target, err, supportedTargets()))
|
||||||
}
|
}
|
||||||
|
|
||||||
accessToken, err := skillLoadAccessToken()
|
accessToken, err := skillLoadAccessToken(cmd.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -441,13 +442,16 @@ func runSkillAdd(cmd *cobra.Command, args []string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadSkillAccessToken() (string, error) {
|
func loadSkillAccessToken(ctx context.Context) (string, error) {
|
||||||
configDir := defaultConfigDir()
|
configDir := defaultConfigDir()
|
||||||
tokenData, err := skillLoadTokenData(configDir)
|
token, err := skillResolveAccessToken(ctx, configDir, "")
|
||||||
if err != nil || tokenData == nil || !tokenData.IsAccessTokenValid() {
|
if errors.Is(err, authpkg.ErrTokenDataNotFound) {
|
||||||
return "", skillAuthError()
|
return "", skillAuthError()
|
||||||
}
|
}
|
||||||
return tokenData.AccessToken, nil
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("resolve skill access token: %w", err)
|
||||||
|
}
|
||||||
|
return token, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func skillAuthError() error {
|
func skillAuthError() error {
|
||||||
|
|||||||
@@ -57,11 +57,11 @@ func TestCrossPlatformCoverageSkillCommandHighLevelRemainingCoverage(t *testing.
|
|||||||
})
|
})
|
||||||
fail := errors.New("failure")
|
fail := errors.New("failure")
|
||||||
cmd := skillCoverageCommand()
|
cmd := skillCoverageCommand()
|
||||||
skillLoadAccessToken = func() (string, error) { return "", fail }
|
skillLoadAccessToken = func(context.Context) (string, error) { return "", fail }
|
||||||
if err := runSkillGet(cmd, nil); !errors.Is(err, fail) {
|
if err := runSkillGet(cmd, nil); !errors.Is(err, fail) {
|
||||||
t.Fatalf("skill get auth error = %v", err)
|
t.Fatalf("skill get auth error = %v", err)
|
||||||
}
|
}
|
||||||
skillLoadAccessToken = func() (string, error) { return "token", nil }
|
skillLoadAccessToken = func(context.Context) (string, error) { return "token", nil }
|
||||||
skillNewRequest = func(context.Context, string, string, io.Reader) (*http.Request, error) { return nil, fail }
|
skillNewRequest = func(context.Context, string, string, io.Reader) (*http.Request, error) { return nil, fail }
|
||||||
if err := runSkillFind(cmd, nil); err == nil {
|
if err := runSkillFind(cmd, nil); err == nil {
|
||||||
t.Fatal("skill find request failure should propagate")
|
t.Fatal("skill find request failure should propagate")
|
||||||
@@ -72,11 +72,11 @@ func TestCrossPlatformCoverageSkillCommandHighLevelRemainingCoverage(t *testing.
|
|||||||
t.Fatalf("skill get download error = %v", err)
|
t.Fatalf("skill get download error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
skillLoadAccessToken = func() (string, error) { return "", fail }
|
skillLoadAccessToken = func(context.Context) (string, error) { return "", fail }
|
||||||
if err := runSkillFind(cmd, nil); !errors.Is(err, fail) {
|
if err := runSkillFind(cmd, nil); !errors.Is(err, fail) {
|
||||||
t.Fatalf("skill find auth error = %v", err)
|
t.Fatalf("skill find auth error = %v", err)
|
||||||
}
|
}
|
||||||
skillLoadAccessToken = func() (string, error) { return "token", nil }
|
skillLoadAccessToken = func(context.Context) (string, error) { return "token", nil }
|
||||||
skillHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) { return nil, fail }
|
skillHTTPDo = func(*http.Client, *http.Request) (*http.Response, error) { return nil, fail }
|
||||||
if err := runSkillFind(cmd, nil); err == nil {
|
if err := runSkillFind(cmd, nil); err == nil {
|
||||||
t.Fatal("skill find network failure should propagate")
|
t.Fatal("skill find network failure should propagate")
|
||||||
@@ -111,11 +111,11 @@ func TestCrossPlatformCoverageSkillCommandHighLevelRemainingCoverage(t *testing.
|
|||||||
t.Fatal("invalid skill target should fail")
|
t.Fatal("invalid skill target should fail")
|
||||||
}
|
}
|
||||||
skillResolveTargetPath = func(string) (string, error) { return "dest", nil }
|
skillResolveTargetPath = func(string) (string, error) { return "dest", nil }
|
||||||
skillLoadAccessToken = func() (string, error) { return "", fail }
|
skillLoadAccessToken = func(context.Context) (string, error) { return "", fail }
|
||||||
if err := runSkillAdd(cmd, []string{"id", "target"}); !errors.Is(err, fail) {
|
if err := runSkillAdd(cmd, []string{"id", "target"}); !errors.Is(err, fail) {
|
||||||
t.Fatalf("skill add auth error = %v", err)
|
t.Fatalf("skill add auth error = %v", err)
|
||||||
}
|
}
|
||||||
skillLoadAccessToken = func() (string, error) { return "token", nil }
|
skillLoadAccessToken = func(context.Context) (string, error) { return "token", nil }
|
||||||
skillFetchDownloadInfo = func(context.Context, string, string) (*downloadSkillResponse, error) { return nil, fail }
|
skillFetchDownloadInfo = func(context.Context, string, string) (*downloadSkillResponse, error) { return nil, fail }
|
||||||
if err := runSkillAdd(cmd, []string{"id", "target"}); !errors.Is(err, fail) {
|
if err := runSkillAdd(cmd, []string{"id", "target"}); !errors.Is(err, fail) {
|
||||||
t.Fatalf("skill info error = %v", err)
|
t.Fatalf("skill info error = %v", err)
|
||||||
@@ -152,25 +152,35 @@ func TestCrossPlatformCoverageSkillCommandHighLevelRemainingCoverage(t *testing.
|
|||||||
|
|
||||||
func TestCrossPlatformCoverageSkillCommandLowLevelRemainingCoverage(t *testing.T) {
|
func TestCrossPlatformCoverageSkillCommandLowLevelRemainingCoverage(t *testing.T) {
|
||||||
oldHTTP := skillHTTPDo
|
oldHTTP := skillHTTPDo
|
||||||
oldNewRequest, oldLoadToken := skillNewRequest, skillLoadTokenData
|
oldNewRequest, oldResolveToken := skillNewRequest, skillResolveAccessToken
|
||||||
oldHome := skillUserHomeDir
|
oldHome := skillUserHomeDir
|
||||||
oldMkdirTemp, oldCreate, oldCreateTemp := skillMkdirTemp, skillCreate, skillCreateTemp
|
oldMkdirTemp, oldCreate, oldCreateTemp := skillMkdirTemp, skillCreate, skillCreateTemp
|
||||||
oldRemoveAll, oldRemove, oldMkdir := skillRemoveAll, skillRemove, skillMkdirAll
|
oldRemoveAll, oldRemove, oldMkdir := skillRemoveAll, skillRemove, skillMkdirAll
|
||||||
oldOpen, oldCopy, oldZipOpen := skillOpenFile, skillCopy, skillOpenZipFile
|
oldOpen, oldCopy, oldZipOpen := skillOpenFile, skillCopy, skillOpenZipFile
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
skillHTTPDo = oldHTTP
|
skillHTTPDo = oldHTTP
|
||||||
skillNewRequest, skillLoadTokenData = oldNewRequest, oldLoadToken
|
skillNewRequest, skillResolveAccessToken = oldNewRequest, oldResolveToken
|
||||||
skillUserHomeDir = oldHome
|
skillUserHomeDir = oldHome
|
||||||
skillMkdirTemp, skillCreate, skillCreateTemp = oldMkdirTemp, oldCreate, oldCreateTemp
|
skillMkdirTemp, skillCreate, skillCreateTemp = oldMkdirTemp, oldCreate, oldCreateTemp
|
||||||
skillRemoveAll, skillRemove, skillMkdirAll = oldRemoveAll, oldRemove, oldMkdir
|
skillRemoveAll, skillRemove, skillMkdirAll = oldRemoveAll, oldRemove, oldMkdir
|
||||||
skillOpenFile, skillCopy, skillOpenZipFile = oldOpen, oldCopy, oldZipOpen
|
skillOpenFile, skillCopy, skillOpenZipFile = oldOpen, oldCopy, oldZipOpen
|
||||||
})
|
})
|
||||||
fail := errors.New("failure")
|
fail := errors.New("failure")
|
||||||
skillLoadTokenData = func(string) (*authpkg.TokenData, error) { return nil, nil }
|
skillResolveAccessToken = func(context.Context, string, string) (string, error) {
|
||||||
if _, err := loadSkillAccessToken(); err == nil {
|
return "", authpkg.ErrTokenDataNotFound
|
||||||
|
}
|
||||||
|
if _, err := loadSkillAccessToken(context.Background()); err == nil {
|
||||||
t.Fatal("invalid skill access token succeeded")
|
t.Fatal("invalid skill access token succeeded")
|
||||||
}
|
}
|
||||||
skillLoadTokenData = oldLoadToken
|
canceled, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
skillResolveAccessToken = func(ctx context.Context, _, _ string) (string, error) {
|
||||||
|
return "", ctx.Err()
|
||||||
|
}
|
||||||
|
if _, err := loadSkillAccessToken(canceled); !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("skill token cancellation = %v", err)
|
||||||
|
}
|
||||||
|
skillResolveAccessToken = oldResolveToken
|
||||||
skillNewRequest = func(context.Context, string, string, io.Reader) (*http.Request, error) { return nil, fail }
|
skillNewRequest = func(context.Context, string, string, io.Reader) (*http.Request, error) { return nil, fail }
|
||||||
if _, err := fetchSkillDownloadInfo(context.Background(), "token", "id"); err == nil {
|
if _, err := fetchSkillDownloadInfo(context.Background(), "token", "id"); err == nil {
|
||||||
t.Fatal("download-info request failure should propagate")
|
t.Fatal("download-info request failure should propagate")
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
@@ -397,9 +396,11 @@ func TestSkillInstallRequiresAuth(t *testing.T) {
|
|||||||
configDir := filepath.Join(tempDir, "config")
|
configDir := filepath.Join(tempDir, "config")
|
||||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||||
t.Cleanup(CloseFileLogger)
|
t.Cleanup(CloseFileLogger)
|
||||||
originalLoadToken := skillLoadTokenData
|
originalResolveToken := skillResolveAccessToken
|
||||||
skillLoadTokenData = func(string) (*authpkg.TokenData, error) { return nil, errors.New("missing") }
|
skillResolveAccessToken = func(context.Context, string, string) (string, error) {
|
||||||
t.Cleanup(func() { skillLoadTokenData = originalLoadToken })
|
return "", authpkg.ErrTokenDataNotFound
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { skillResolveAccessToken = originalResolveToken })
|
||||||
|
|
||||||
// Ensure the config directory exists but has no token
|
// Ensure the config directory exists but has no token
|
||||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||||
@@ -679,16 +680,11 @@ func TestSkillSearchUsesSourceQueryAndKeepsScopesCompat(t *testing.T) {
|
|||||||
configDir := filepath.Join(t.TempDir(), "config")
|
configDir := filepath.Join(t.TempDir(), "config")
|
||||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||||
t.Cleanup(CloseFileLogger)
|
t.Cleanup(CloseFileLogger)
|
||||||
originalLoadToken := skillLoadTokenData
|
originalResolveToken := skillResolveAccessToken
|
||||||
skillLoadTokenData = func(string) (*authpkg.TokenData, error) {
|
skillResolveAccessToken = func(context.Context, string, string) (string, error) {
|
||||||
return &authpkg.TokenData{
|
return "test-token", nil
|
||||||
AccessToken: "test-token",
|
|
||||||
RefreshToken: "refresh-token",
|
|
||||||
ExpiresAt: time.Now().Add(time.Hour),
|
|
||||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
t.Cleanup(func() { skillLoadTokenData = originalLoadToken })
|
t.Cleanup(func() { skillResolveAccessToken = originalResolveToken })
|
||||||
|
|
||||||
var gotSources []string
|
var gotSources []string
|
||||||
var gotScopes []string
|
var gotScopes []string
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ func TestMain(m *testing.M) {
|
|||||||
_ = os.RemoveAll(tmpDir)
|
_ = os.RemoveAll(tmpDir)
|
||||||
panic("set " + keychain.StorageDirEnv + ": " + err.Error())
|
panic("set " + keychain.StorageDirEnv + ": " + err.Error())
|
||||||
}
|
}
|
||||||
|
if err := os.Setenv(keychain.TestNamespaceEnv, tmpDir); err != nil {
|
||||||
|
_ = os.RemoveAll(tmpDir)
|
||||||
|
panic("set " + keychain.TestNamespaceEnv + ": " + err.Error())
|
||||||
|
}
|
||||||
if err := os.Setenv("DWS_CONFIG_DIR", filepath.Join(tmpDir, "config")); err != nil {
|
if err := os.Setenv("DWS_CONFIG_DIR", filepath.Join(tmpDir, "config")); err != nil {
|
||||||
_ = os.RemoveAll(tmpDir)
|
_ = os.RemoveAll(tmpDir)
|
||||||
panic("set DWS_CONFIG_DIR: " + err.Error())
|
panic("set DWS_CONFIG_DIR: " + err.Error())
|
||||||
@@ -79,6 +83,12 @@ func TestMain(m *testing.M) {
|
|||||||
StopAllStdioClients()
|
StopAllStdioClients()
|
||||||
CloseAuditSink()
|
CloseAuditSink()
|
||||||
CloseFileLogger()
|
CloseFileLogger()
|
||||||
|
if err := keychain.RemoveAuthTokenEntries(keychain.Service); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "internal/app keychain cleanup: %v\n", err)
|
||||||
|
if code == 0 {
|
||||||
|
code = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
if err := os.RemoveAll(tmpDir); err != nil {
|
if err := os.RemoveAll(tmpDir); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "internal/app test cleanup %s: %v\n", tmpDir, err)
|
fmt.Fprintf(os.Stderr, "internal/app test cleanup %s: %v\n", tmpDir, err)
|
||||||
if code == 0 {
|
if code == 0 {
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageOAuthProviderTokenSnapshotPreservesLoadFailure(t *testing.T) {
|
||||||
|
oldLoad := oauthLoadToken
|
||||||
|
want := errors.New("keychain permission denied")
|
||||||
|
oauthLoadToken = func(string) (*TokenData, error) { return nil, want }
|
||||||
|
t.Cleanup(func() { oauthLoadToken = oldLoad })
|
||||||
|
|
||||||
|
_, err := NewOAuthProvider(t.TempDir(), nil).GetTokenSnapshot(context.Background())
|
||||||
|
if !errors.Is(err, want) {
|
||||||
|
t.Fatalf("error = %v, want cause %v", err, want)
|
||||||
|
}
|
||||||
|
if errors.Is(err, ErrTokenDataNotFound) {
|
||||||
|
t.Fatalf("load failure was misclassified as missing credentials: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageOAuthProviderLoginPreservesLoadFailure(t *testing.T) {
|
||||||
|
oldLoad := oauthLoadToken
|
||||||
|
want := errors.New("keychain permission denied")
|
||||||
|
oauthLoadToken = func(string) (*TokenData, error) { return nil, want }
|
||||||
|
t.Cleanup(func() { oauthLoadToken = oldLoad })
|
||||||
|
|
||||||
|
_, err := NewOAuthProvider(t.TempDir(), nil).Login(context.Background(), false)
|
||||||
|
if !errors.Is(err, want) {
|
||||||
|
t.Fatalf("error = %v, want cause %v", err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageOAuthProviderTokenSnapshotReturnsExpiryMetadata(t *testing.T) {
|
||||||
|
oldLoad := oauthLoadToken
|
||||||
|
expiresAt := time.Now().Add(time.Hour)
|
||||||
|
oauthLoadToken = func(string) (*TokenData, error) {
|
||||||
|
return &TokenData{AccessToken: "token", ExpiresAt: expiresAt}, nil
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { oauthLoadToken = oldLoad })
|
||||||
|
|
||||||
|
snapshot, err := NewOAuthProvider(t.TempDir(), nil).GetTokenSnapshot(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if snapshot.AccessToken != "token" || !snapshot.ExpiresAt.Equal(expiresAt) {
|
||||||
|
t.Fatalf("snapshot = %#v", snapshot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageTokenMarkerRevisionChangesOnEveryPublication(t *testing.T) {
|
||||||
|
configDir := t.TempDir()
|
||||||
|
if err := WriteTokenMarker(configDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
first, present, err := ReadTokenMarkerRevision(configDir)
|
||||||
|
if err != nil || !present || first == "" {
|
||||||
|
t.Fatalf("first marker = %q, %v, %v", first, present, err)
|
||||||
|
}
|
||||||
|
if err := WriteTokenMarker(configDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
second, present, err := ReadTokenMarkerRevision(configDir)
|
||||||
|
if err != nil || !present || second == "" || second == first {
|
||||||
|
t.Fatalf("second marker = %q, %v, %v; first=%q", second, present, err, first)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,50 @@
|
|||||||
|
|
||||||
package auth
|
package auth
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||||
|
)
|
||||||
|
|
||||||
|
const rejectedTokenRefreshFailureCooldown = 3 * time.Second
|
||||||
|
|
||||||
|
type rejectedTokenRefreshKey struct {
|
||||||
|
configDir string
|
||||||
|
profile string
|
||||||
|
tokenDigest [sha256.Size]byte
|
||||||
|
}
|
||||||
|
|
||||||
|
type rejectedTokenRefreshCall struct {
|
||||||
|
done chan struct{}
|
||||||
|
participants int
|
||||||
|
token string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
type rejectedTokenRefreshFailure struct {
|
||||||
|
at time.Time
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
var rejectedTokenRefreshCoordinator = struct {
|
||||||
|
sync.Mutex
|
||||||
|
inFlight map[rejectedTokenRefreshKey]*rejectedTokenRefreshCall
|
||||||
|
failures map[rejectedTokenRefreshKey]rejectedTokenRefreshFailure
|
||||||
|
now func() time.Time
|
||||||
|
}{
|
||||||
|
inFlight: make(map[rejectedTokenRefreshKey]*rejectedTokenRefreshCall),
|
||||||
|
failures: make(map[rejectedTokenRefreshKey]rejectedTokenRefreshFailure),
|
||||||
|
now: time.Now,
|
||||||
|
}
|
||||||
|
|
||||||
// MarkAccessTokenStale loads the persisted TokenData, sets ExpiresAt to a past
|
// MarkAccessTokenStale loads the persisted TokenData, sets ExpiresAt to a past
|
||||||
// instant (preserving access_token and refresh_token), and writes it back. The
|
// instant (preserving access_token and refresh_token), and writes it back. The
|
||||||
@@ -39,3 +82,192 @@ func MarkAccessTokenStale(configDir string) error {
|
|||||||
data.ExpiresAt = time.Now().Add(-1 * time.Minute)
|
data.ExpiresAt = time.Now().Add(-1 * time.Minute)
|
||||||
return SaveTokenData(configDir, data)
|
return SaveTokenData(configDir, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ForceRefreshRejectedToken refreshes rejectedAccessToken only while it is
|
||||||
|
// still the credential stored for the active profile. The compare and refresh
|
||||||
|
// run under the same process + file lock used by ordinary expiry refresh, so a
|
||||||
|
// late rejection cannot invalidate or refresh over a token another caller has
|
||||||
|
// already rotated.
|
||||||
|
//
|
||||||
|
// When the stored token no longer matches, the newer token is returned without
|
||||||
|
// calling the refresh endpoint. Refresh failures leave the stored credential in
|
||||||
|
// place; login/logout remain the only owners of credential deletion.
|
||||||
|
func (p *OAuthProvider) ForceRefreshRejectedToken(ctx context.Context, rejectedAccessToken string) (string, error) {
|
||||||
|
if p == nil || strings.TrimSpace(p.configDir) == "" {
|
||||||
|
return "", fmt.Errorf("config directory is empty")
|
||||||
|
}
|
||||||
|
rejectedAccessToken = strings.TrimSpace(rejectedAccessToken)
|
||||||
|
if rejectedAccessToken == "" {
|
||||||
|
return "", fmt.Errorf("rejected access token is empty")
|
||||||
|
}
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
|
||||||
|
profile := strings.TrimSpace(RuntimeProfile())
|
||||||
|
key := newRejectedTokenRefreshKey(p.configDir, profile, rejectedAccessToken)
|
||||||
|
call, leader := beginRejectedTokenRefresh(key)
|
||||||
|
if !leader {
|
||||||
|
select {
|
||||||
|
case <-call.done:
|
||||||
|
return call.token, call.err
|
||||||
|
case <-ctx.Done():
|
||||||
|
return "", ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err, recordFailure := p.forceRefreshRejectedTokenOnce(ctx, profile, rejectedAccessToken, key)
|
||||||
|
finishRejectedTokenRefresh(key, call, token, err, recordFailure)
|
||||||
|
return token, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *OAuthProvider) forceRefreshRejectedTokenOnce(
|
||||||
|
ctx context.Context,
|
||||||
|
profile string,
|
||||||
|
rejectedAccessToken string,
|
||||||
|
key rejectedTokenRefreshKey,
|
||||||
|
) (string, error, bool) {
|
||||||
|
lock, err := oauthAcquireLock(ctx, p.configDir)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("acquiring dual lock: %w", err), false
|
||||||
|
}
|
||||||
|
defer lock.Release()
|
||||||
|
|
||||||
|
data, err := loadOAuthTokenUnderHeldLock(p.configDir, profile)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("reload rejected token: %w", err), false
|
||||||
|
}
|
||||||
|
current := strings.TrimSpace(data.AccessToken)
|
||||||
|
if current == "" {
|
||||||
|
clearRejectedTokenRefreshFailure(key)
|
||||||
|
return "", fmt.Errorf("stored access token is empty"), false
|
||||||
|
}
|
||||||
|
if current != rejectedAccessToken {
|
||||||
|
clearRejectedTokenRefreshFailure(key)
|
||||||
|
return current, nil, false
|
||||||
|
}
|
||||||
|
if cachedErr := recentRejectedTokenRefreshFailure(key); cachedErr != nil {
|
||||||
|
return "", cachedErr, false
|
||||||
|
}
|
||||||
|
if !data.IsRefreshTokenValid() {
|
||||||
|
return "", fmt.Errorf("refresh_token 已过期"), true
|
||||||
|
}
|
||||||
|
if err := preflightTokenRefreshPersistence(p.configDir, data); err != nil {
|
||||||
|
return "", fmt.Errorf("本地登录态无法安全更新: %w", err), true
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshed, err := oauthRefreshToken(p, ctx, data)
|
||||||
|
if err != nil {
|
||||||
|
return "", err, true
|
||||||
|
}
|
||||||
|
if refreshed == nil || strings.TrimSpace(refreshed.AccessToken) == "" {
|
||||||
|
return "", fmt.Errorf("force refresh returned empty access token"), true
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(refreshed.AccessToken), nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRejectedTokenRefreshKey(configDir, profile, rejectedAccessToken string) rejectedTokenRefreshKey {
|
||||||
|
canonicalDir := filepath.Clean(configDir)
|
||||||
|
if absolute, err := filepath.Abs(configDir); err == nil {
|
||||||
|
canonicalDir = filepath.Clean(absolute)
|
||||||
|
}
|
||||||
|
return rejectedTokenRefreshKey{
|
||||||
|
configDir: canonicalDir,
|
||||||
|
profile: strings.TrimSpace(profile),
|
||||||
|
tokenDigest: sha256.Sum256([]byte(strings.TrimSpace(rejectedAccessToken))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func beginRejectedTokenRefresh(key rejectedTokenRefreshKey) (*rejectedTokenRefreshCall, bool) {
|
||||||
|
rejectedTokenRefreshCoordinator.Lock()
|
||||||
|
defer rejectedTokenRefreshCoordinator.Unlock()
|
||||||
|
|
||||||
|
if call := rejectedTokenRefreshCoordinator.inFlight[key]; call != nil {
|
||||||
|
call.participants++
|
||||||
|
return call, false
|
||||||
|
}
|
||||||
|
call := &rejectedTokenRefreshCall{done: make(chan struct{}), participants: 1}
|
||||||
|
rejectedTokenRefreshCoordinator.inFlight[key] = call
|
||||||
|
return call, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func finishRejectedTokenRefresh(
|
||||||
|
key rejectedTokenRefreshKey,
|
||||||
|
call *rejectedTokenRefreshCall,
|
||||||
|
token string,
|
||||||
|
err error,
|
||||||
|
recordFailure bool,
|
||||||
|
) {
|
||||||
|
rejectedTokenRefreshCoordinator.Lock()
|
||||||
|
defer rejectedTokenRefreshCoordinator.Unlock()
|
||||||
|
|
||||||
|
call.token = token
|
||||||
|
call.err = err
|
||||||
|
delete(rejectedTokenRefreshCoordinator.inFlight, key)
|
||||||
|
if err == nil {
|
||||||
|
delete(rejectedTokenRefreshCoordinator.failures, key)
|
||||||
|
} else if recordFailure && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
now := time.Now()
|
||||||
|
if rejectedTokenRefreshCoordinator.now != nil {
|
||||||
|
now = rejectedTokenRefreshCoordinator.now()
|
||||||
|
}
|
||||||
|
rejectedTokenRefreshCoordinator.failures[key] = rejectedTokenRefreshFailure{at: now, err: err}
|
||||||
|
}
|
||||||
|
close(call.done)
|
||||||
|
}
|
||||||
|
|
||||||
|
func recentRejectedTokenRefreshFailure(key rejectedTokenRefreshKey) error {
|
||||||
|
rejectedTokenRefreshCoordinator.Lock()
|
||||||
|
defer rejectedTokenRefreshCoordinator.Unlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
if rejectedTokenRefreshCoordinator.now != nil {
|
||||||
|
now = rejectedTokenRefreshCoordinator.now()
|
||||||
|
}
|
||||||
|
for failureKey, failure := range rejectedTokenRefreshCoordinator.failures {
|
||||||
|
age := now.Sub(failure.at)
|
||||||
|
if age < 0 || age >= rejectedTokenRefreshFailureCooldown {
|
||||||
|
delete(rejectedTokenRefreshCoordinator.failures, failureKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if failure, ok := rejectedTokenRefreshCoordinator.failures[key]; ok {
|
||||||
|
return failure.err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearRejectedTokenRefreshFailure(key rejectedTokenRefreshKey) {
|
||||||
|
rejectedTokenRefreshCoordinator.Lock()
|
||||||
|
delete(rejectedTokenRefreshCoordinator.failures, key)
|
||||||
|
rejectedTokenRefreshCoordinator.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadOAuthTokenUnderHeldLock mirrors LoadTokenDataForProfile without taking a
|
||||||
|
// second, non-reentrant auth lock. Opaque edition storage hooks (for example
|
||||||
|
// Wukong's encrypted .data file) are read inside the caller's dual lock so the
|
||||||
|
// compare-and-refresh decision covers both Core and embedded storage.
|
||||||
|
func loadOAuthTokenUnderHeldLock(configDir, profile string) (*TokenData, error) {
|
||||||
|
hooks := edition.Get()
|
||||||
|
if hooks.LoadToken == nil {
|
||||||
|
data, err := oauthLoadTokenLocked(configDir, profile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if data == nil {
|
||||||
|
return nil, fmt.Errorf("stored token data is empty")
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(profile) != "" {
|
||||||
|
return nil, fmt.Errorf("profile selection is not supported by the current auth backend")
|
||||||
|
}
|
||||||
|
blob, err := hooks.LoadToken(configDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var data TokenData
|
||||||
|
if err := json.Unmarshal(blob, &data); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing token data from hook: %w", err)
|
||||||
|
}
|
||||||
|
return &data, nil
|
||||||
|
}
|
||||||
@@ -47,8 +47,10 @@ func (m *Manager) GetToken() (string, string, error) {
|
|||||||
}
|
}
|
||||||
return token, "file", nil
|
return token, "file", nil
|
||||||
}
|
}
|
||||||
|
if err != nil && !os.IsNotExist(err) {
|
||||||
return "", "", fmt.Errorf("%s", i18n.T("未找到认证信息,请运行 dws auth login"))
|
return "", "", fmt.Errorf("load legacy token: %w", err)
|
||||||
|
}
|
||||||
|
return "", "", fmt.Errorf("%s: %w", i18n.T("未找到认证信息,请运行 dws auth login"), ErrTokenDataNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) GetMCPURL() (string, error) {
|
func (m *Manager) GetMCPURL() (string, error) {
|
||||||
|
|||||||
@@ -115,6 +115,12 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
|||||||
// Smart degradation: try silent refresh before opening browser.
|
// Smart degradation: try silent refresh before opening browser.
|
||||||
if !force {
|
if !force {
|
||||||
data, err := oauthLoadToken(p.configDir)
|
data, err := oauthLoadToken(p.configDir)
|
||||||
|
if err != nil && !errors.Is(err, ErrTokenDataNotFound) && !os.IsNotExist(err) {
|
||||||
|
if preflightErr := preflightTokenPersistence(p.configDir); preflightErr != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %w", i18n.T("本地登录态无法安全更新"), preflightErr)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("load existing access token: %w", err)
|
||||||
|
}
|
||||||
if err == nil {
|
if err == nil {
|
||||||
// Case 1: access_token still valid — no action needed.
|
// Case 1: access_token still valid — no action needed.
|
||||||
if data.IsAccessTokenValid() {
|
if data.IsAccessTokenValid() {
|
||||||
@@ -636,36 +642,50 @@ continueLogin:
|
|||||||
return tokenData, nil
|
return tokenData, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAccessToken returns a valid access token, auto-refreshing if needed.
|
// GetTokenSnapshot returns a valid token together with its expiry metadata.
|
||||||
// Uses a file lock with double-check pattern to prevent concurrent refresh
|
// Storage and refresh failures retain their original cause; only a confirmed
|
||||||
// from multiple CLI processes.
|
// missing credential is reported as ErrTokenDataNotFound.
|
||||||
func (p *OAuthProvider) GetAccessToken(ctx context.Context) (string, error) {
|
func (p *OAuthProvider) GetTokenSnapshot(ctx context.Context) (*TokenData, error) {
|
||||||
data, err := oauthLoadToken(p.configDir)
|
data, err := oauthLoadToken(p.configDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", errors.New(i18n.T("未登录,请运行 dws auth login"))
|
if errors.Is(err, ErrTokenDataNotFound) || os.IsNotExist(err) {
|
||||||
|
return nil, fmt.Errorf("%s: %w", i18n.T("未登录,请运行 dws auth login"), ErrTokenDataNotFound)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("load access token: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fast path: access_token still valid — no lock needed.
|
// Fast path: access_token still valid — no lock needed.
|
||||||
if data.IsAccessTokenValid() {
|
if data.IsAccessTokenValid() {
|
||||||
return data.AccessToken, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Slow path: token expired — try locked refresh.
|
// Slow path: token expired — try locked refresh.
|
||||||
if data.IsRefreshTokenValid() {
|
if data.IsRefreshTokenValid() {
|
||||||
refreshed, rErr := p.lockedRefresh(ctx)
|
refreshed, rErr := p.lockedRefresh(ctx)
|
||||||
if rErr == nil {
|
if rErr == nil {
|
||||||
return refreshed.AccessToken, nil
|
return refreshed, nil
|
||||||
}
|
}
|
||||||
_ = oauthMarkProfile(p.configDir, TokenProfileSelector(data), ProfileStatusExpired)
|
_ = oauthMarkProfile(p.configDir, TokenProfileSelector(data), ProfileStatusExpired)
|
||||||
if p.logger != nil {
|
if p.logger != nil {
|
||||||
p.logger.Warn(i18n.T("refresh_token 刷新失败"), "error", rErr)
|
p.logger.Warn(i18n.T("refresh_token 刷新失败"), "error", rErr)
|
||||||
}
|
}
|
||||||
return "", fmt.Errorf("%s: %w", i18n.T("refresh_token 刷新失败"), rErr)
|
return nil, fmt.Errorf("%s: %w", i18n.T("refresh_token 刷新失败"), rErr)
|
||||||
} else {
|
} else {
|
||||||
_ = oauthMarkProfile(p.configDir, TokenProfileSelector(data), ProfileStatusExpired)
|
_ = oauthMarkProfile(p.configDir, TokenProfileSelector(data), ProfileStatusExpired)
|
||||||
}
|
}
|
||||||
|
|
||||||
return "", errors.New(i18n.T("所有凭证已失效,请运行 dws auth login 重新登录"))
|
return nil, fmt.Errorf("%s: %w", i18n.T("所有凭证已失效,请运行 dws auth login 重新登录"), ErrTokenDataNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAccessToken returns a valid access token, auto-refreshing if needed.
|
||||||
|
// Uses a file lock with double-check pattern to prevent concurrent refresh
|
||||||
|
// from multiple CLI processes.
|
||||||
|
func (p *OAuthProvider) GetAccessToken(ctx context.Context) (string, error) {
|
||||||
|
data, err := p.GetTokenSnapshot(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(data.AccessToken), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// lockedRefresh attempts to refresh the token while holding dual-layer locks.
|
// lockedRefresh attempts to refresh the token while holding dual-layer locks.
|
||||||
@@ -697,7 +717,7 @@ func (p *OAuthProvider) lockedRefresh(ctx context.Context) (*TokenData, error) {
|
|||||||
|
|
||||||
// Double-check: re-load from disk — another goroutine/process may have refreshed
|
// Double-check: re-load from disk — another goroutine/process may have refreshed
|
||||||
// while we were waiting for the lock.
|
// while we were waiting for the lock.
|
||||||
data, err := oauthLoadTokenLocked(p.configDir, RuntimeProfile())
|
data, err := loadOAuthTokenUnderHeldLock(p.configDir, RuntimeProfile())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import (
|
|||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||||
)
|
)
|
||||||
|
|
||||||
type oauthLoginFixture struct {
|
type oauthLoginFixture struct {
|
||||||
@@ -33,8 +35,79 @@ type oauthLoginFixture struct {
|
|||||||
exchangeError atomic.Bool
|
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 {
|
func newOAuthLoginFixture(t *testing.T, status func(int32) CLIAuthStatus) *oauthLoginFixture {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
isolateOAuthPersistence(t)
|
||||||
SetClientID("")
|
SetClientID("")
|
||||||
SetClientSecret("")
|
SetClientSecret("")
|
||||||
resetClientIDFromMCP()
|
resetClientIDFromMCP()
|
||||||
@@ -91,6 +164,10 @@ func newOAuthLoginFixture(t *testing.T, status func(int32) CLIAuthStatus) *oauth
|
|||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
t.Cleanup(f.server.Close)
|
t.Cleanup(f.server.Close)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
closeOAuthRelease(f.exchangeRelease)
|
||||||
|
closeOAuthRelease(f.statusRelease)
|
||||||
|
})
|
||||||
f.configDir = setupMCPConfigDir(t, f.server.URL)
|
f.configDir = setupMCPConfigDir(t, f.server.URL)
|
||||||
|
|
||||||
oldClient := oauthHTTPClient
|
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) {
|
func httpGetBody(t *testing.T, rawURL string) (int, string) {
|
||||||
t.Helper()
|
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}
|
client := &http.Client{Timeout: 2 * time.Second}
|
||||||
resp, err := client.Get(rawURL)
|
resp, err := client.Get(rawURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GET %s: %v", rawURL, err)
|
return oauthHTTPResult{err: err}
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
data, err := io.ReadAll(resp.Body)
|
data, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
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) {
|
func TestCrossPlatformCoverageOAuthLoginCallbackAndAPIs(t *testing.T) {
|
||||||
@@ -136,17 +221,7 @@ func TestCrossPlatformCoverageOAuthLoginCallbackAndAPIs(t *testing.T) {
|
|||||||
return CLIAuthStatus{Success: true, Result: &CLIAuthResult{CLIAuthEnabled: true}}
|
return CLIAuthStatus{Success: true, Result: &CLIAuthResult{CLIAuthEnabled: true}}
|
||||||
})
|
})
|
||||||
|
|
||||||
loginDone := make(chan struct {
|
loginDone := startOAuthLogin(t, context.Background(), f)
|
||||||
token *TokenData
|
|
||||||
err error
|
|
||||||
}, 1)
|
|
||||||
go func() {
|
|
||||||
token, err := f.provider.Login(context.Background(), true)
|
|
||||||
loginDone <- struct {
|
|
||||||
token *TokenData
|
|
||||||
err error
|
|
||||||
}{token, err}
|
|
||||||
}()
|
|
||||||
|
|
||||||
for _, path := range []string{"/api/superAdmin", "/api/sendApply?adminStaffId=admin-1", "/api/cliAuthEnabled"} {
|
for _, path := range []string{"/api/superAdmin", "/api/sendApply?adminStaffId=admin-1", "/api/cliAuthEnabled"} {
|
||||||
_, body := httpGetBody(t, f.callbackBase+path)
|
_, body := httpGetBody(t, f.callbackBase+path)
|
||||||
@@ -166,18 +241,17 @@ func TestCrossPlatformCoverageOAuthLoginCallbackAndAPIs(t *testing.T) {
|
|||||||
t.Fatalf("success page = %q", body)
|
t.Fatalf("success page = %q", body)
|
||||||
}
|
}
|
||||||
|
|
||||||
callbackDone := make(chan string, 1)
|
callbackDone := make(chan oauthHTTPResult, 1)
|
||||||
go func() {
|
go func() {
|
||||||
_, callbackBody := httpGetBody(t, f.callbackBase+CallbackPath+"?code=good")
|
callbackDone <- getHTTPBody(f.callbackBase + CallbackPath + "?code=good")
|
||||||
callbackDone <- callbackBody
|
|
||||||
}()
|
}()
|
||||||
<-f.exchangeEntered
|
waitOAuthSignal(t, f.exchangeEntered, loginDone, "token exchange")
|
||||||
_, body = httpGetBody(t, f.callbackBase+CallbackPath+"?authCode=good")
|
_, body = httpGetBody(t, f.callbackBase+CallbackPath+"?authCode=good")
|
||||||
if !strings.Contains(body, "正在处理授权") {
|
if !strings.Contains(body, "正在处理授权") {
|
||||||
t.Fatalf("concurrent callback = %q", body)
|
t.Fatalf("concurrent callback = %q", body)
|
||||||
}
|
}
|
||||||
close(f.exchangeRelease)
|
closeOAuthRelease(f.exchangeRelease)
|
||||||
<-f.statusEntered
|
waitOAuthSignal(t, f.statusEntered, loginDone, "CLI auth status check")
|
||||||
|
|
||||||
_, body = httpGetBody(t, f.callbackBase+CallbackPath+"?code=good")
|
_, body = httpGetBody(t, f.callbackBase+CallbackPath+"?code=good")
|
||||||
if !strings.Contains(body, "<html") {
|
if !strings.Contains(body, "<html") {
|
||||||
@@ -204,11 +278,16 @@ func TestCrossPlatformCoverageOAuthLoginCallbackAndAPIs(t *testing.T) {
|
|||||||
t.Fatalf("auth enabled API = %q", body)
|
t.Fatalf("auth enabled API = %q", body)
|
||||||
}
|
}
|
||||||
|
|
||||||
close(f.statusRelease)
|
closeOAuthRelease(f.statusRelease)
|
||||||
if callbackBody := <-callbackDone; !strings.Contains(callbackBody, "<html") {
|
select {
|
||||||
t.Fatalf("callback body = %q", callbackBody)
|
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" {
|
if result.err != nil || result.token == nil || result.token.AccessToken != "access" {
|
||||||
t.Fatalf("Login = %#v, %v", result.token, result.err)
|
t.Fatalf("Login = %#v, %v", result.token, result.err)
|
||||||
}
|
}
|
||||||
@@ -228,23 +307,20 @@ func TestCrossPlatformCoverageOAuthLoginMissingCallbackCode(t *testing.T) {
|
|||||||
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus {
|
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus {
|
||||||
return CLIAuthStatus{Success: true, Result: &CLIAuthResult{CLIAuthEnabled: true}}
|
return CLIAuthStatus{Success: true, Result: &CLIAuthResult{CLIAuthEnabled: true}}
|
||||||
})
|
})
|
||||||
close(f.exchangeRelease)
|
closeOAuthRelease(f.exchangeRelease)
|
||||||
close(f.statusRelease)
|
closeOAuthRelease(f.statusRelease)
|
||||||
done := make(chan error, 1)
|
done := startOAuthLogin(t, context.Background(), f)
|
||||||
go func() {
|
|
||||||
_, err := f.provider.Login(context.Background(), true)
|
|
||||||
done <- err
|
|
||||||
}()
|
|
||||||
status, body := httpGetBody(t, f.callbackBase+CallbackPath)
|
status, body := httpGetBody(t, f.callbackBase+CallbackPath)
|
||||||
if status != http.StatusBadRequest || strings.TrimSpace(body) == "" {
|
if status != http.StatusBadRequest || strings.TrimSpace(body) == "" {
|
||||||
t.Fatalf("missing callback = %d %q", status, 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")
|
t.Fatal("missing callback code did not fail login")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCrossPlatformCoverageOAuthLoginEarlyAndListenerEdges(t *testing.T) {
|
func TestCrossPlatformCoverageOAuthLoginEarlyAndListenerEdges(t *testing.T) {
|
||||||
|
isolateOAuthPersistence(t)
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
p := &OAuthProvider{configDir: t.TempDir(), Output: &buf}
|
p := &OAuthProvider{configDir: t.TempDir(), Output: &buf}
|
||||||
if p.output() != &buf || (*OAuthProvider)(nil).output() != io.Discard {
|
if p.output() != &buf || (*OAuthProvider)(nil).output() != io.Discard {
|
||||||
@@ -311,6 +387,7 @@ func TestCrossPlatformCoverageOAuthLoginTimeoutAndServerError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCrossPlatformCoverageOAuthProviderOtherMethods(t *testing.T) {
|
func TestCrossPlatformCoverageOAuthProviderOtherMethods(t *testing.T) {
|
||||||
|
isolateOAuthPersistence(t)
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
_ = DeleteTokenDataKeychain()
|
_ = DeleteTokenDataKeychain()
|
||||||
t.Cleanup(func() { _ = DeleteTokenDataKeychain() })
|
t.Cleanup(func() { _ = DeleteTokenDataKeychain() })
|
||||||
@@ -363,6 +440,7 @@ func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCrossPlatformCoverageOAuthPersistConfigEdges(t *testing.T) {
|
func TestCrossPlatformCoverageOAuthPersistConfigEdges(t *testing.T) {
|
||||||
|
isolateOAuthPersistence(t)
|
||||||
p := &OAuthProvider{configDir: t.TempDir(), logger: slog.Default()}
|
p := &OAuthProvider{configDir: t.TempDir(), logger: slog.Default()}
|
||||||
SetClientID("")
|
SetClientID("")
|
||||||
SetClientSecret("")
|
SetClientSecret("")
|
||||||
@@ -425,16 +503,12 @@ func TestCrossPlatformCoverageOAuthLoginDenialAndPolling(t *testing.T) {
|
|||||||
for _, tt := range terminal {
|
for _, tt := range terminal {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus { return tt.status })
|
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus { return tt.status })
|
||||||
close(f.exchangeRelease)
|
closeOAuthRelease(f.exchangeRelease)
|
||||||
close(f.statusRelease)
|
closeOAuthRelease(f.statusRelease)
|
||||||
f.provider.NoBrowser = false
|
f.provider.NoBrowser = false
|
||||||
done := make(chan error, 1)
|
done := startOAuthLogin(t, context.Background(), f)
|
||||||
go func() {
|
|
||||||
_, err := f.provider.Login(context.Background(), true)
|
|
||||||
done <- err
|
|
||||||
}()
|
|
||||||
_, _ = httpGetBody(t, f.callbackBase+CallbackPath+"?code=denied")
|
_, _ = 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")
|
t.Fatal("denied login succeeded")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -445,15 +519,11 @@ func TestCrossPlatformCoverageOAuthLoginDenialAndPolling(t *testing.T) {
|
|||||||
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus {
|
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus {
|
||||||
return CLIAuthStatus{Success: true, Result: &CLIAuthResult{ChannelScope: "specified", AllowedChannels: []string{"allowed"}}}
|
return CLIAuthStatus{Success: true, Result: &CLIAuthResult{ChannelScope: "specified", AllowedChannels: []string{"allowed"}}}
|
||||||
})
|
})
|
||||||
close(f.exchangeRelease)
|
closeOAuthRelease(f.exchangeRelease)
|
||||||
close(f.statusRelease)
|
closeOAuthRelease(f.statusRelease)
|
||||||
done := make(chan error, 1)
|
done := startOAuthLogin(t, context.Background(), f)
|
||||||
go func() {
|
|
||||||
_, err := f.provider.Login(context.Background(), true)
|
|
||||||
done <- err
|
|
||||||
}()
|
|
||||||
_, _ = httpGetBody(t, f.callbackBase+CallbackPath+"?code=denied")
|
_, _ = 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")
|
t.Fatal("channel-denied login succeeded")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -463,15 +533,11 @@ func TestCrossPlatformCoverageOAuthLoginDenialAndPolling(t *testing.T) {
|
|||||||
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus {
|
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus {
|
||||||
return CLIAuthStatus{Success: true, Result: &CLIAuthResult{}}
|
return CLIAuthStatus{Success: true, Result: &CLIAuthResult{}}
|
||||||
})
|
})
|
||||||
close(f.exchangeRelease)
|
closeOAuthRelease(f.exchangeRelease)
|
||||||
close(f.statusRelease)
|
closeOAuthRelease(f.statusRelease)
|
||||||
done := make(chan error, 1)
|
done := startOAuthLogin(t, context.Background(), f)
|
||||||
go func() {
|
|
||||||
_, err := f.provider.Login(context.Background(), true)
|
|
||||||
done <- err
|
|
||||||
}()
|
|
||||||
_, _ = httpGetBody(t, f.callbackBase+CallbackPath+"?code=pending")
|
_, _ = 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")
|
t.Fatal("approval timeout login succeeded")
|
||||||
}
|
}
|
||||||
oauthApprovalTimeout = oldApproval
|
oauthApprovalTimeout = oldApproval
|
||||||
@@ -481,16 +547,12 @@ func TestCrossPlatformCoverageOAuthLoginDenialAndPolling(t *testing.T) {
|
|||||||
f := newOAuthLoginFixture(t, func(call int32) CLIAuthStatus {
|
f := newOAuthLoginFixture(t, func(call int32) CLIAuthStatus {
|
||||||
return CLIAuthStatus{Success: true, Result: &CLIAuthResult{CLIAuthEnabled: call > 1}}
|
return CLIAuthStatus{Success: true, Result: &CLIAuthResult{CLIAuthEnabled: call > 1}}
|
||||||
})
|
})
|
||||||
close(f.exchangeRelease)
|
closeOAuthRelease(f.exchangeRelease)
|
||||||
close(f.statusRelease)
|
closeOAuthRelease(f.statusRelease)
|
||||||
done := make(chan error, 1)
|
done := startOAuthLogin(t, context.Background(), f)
|
||||||
go func() {
|
|
||||||
_, err := f.provider.Login(context.Background(), true)
|
|
||||||
done <- err
|
|
||||||
}()
|
|
||||||
_, _ = httpGetBody(t, f.callbackBase+CallbackPath+"?code=pending")
|
_, _ = httpGetBody(t, f.callbackBase+CallbackPath+"?code=pending")
|
||||||
if err := <-done; err != nil {
|
if result := awaitOAuthLogin(t, done); result.err != nil {
|
||||||
t.Fatalf("poll-enabled login failed: %v", err)
|
t.Fatalf("poll-enabled login failed: %v", result.err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -498,17 +560,13 @@ func TestCrossPlatformCoverageOAuthLoginDenialAndPolling(t *testing.T) {
|
|||||||
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus {
|
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus {
|
||||||
return CLIAuthStatus{Success: true, Result: &CLIAuthResult{}}
|
return CLIAuthStatus{Success: true, Result: &CLIAuthResult{}}
|
||||||
})
|
})
|
||||||
close(f.exchangeRelease)
|
closeOAuthRelease(f.exchangeRelease)
|
||||||
close(f.statusRelease)
|
closeOAuthRelease(f.statusRelease)
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
done := make(chan error, 1)
|
done := startOAuthLogin(t, ctx, f)
|
||||||
go func() {
|
|
||||||
_, err := f.provider.Login(ctx, true)
|
|
||||||
done <- err
|
|
||||||
}()
|
|
||||||
_, _ = httpGetBody(t, f.callbackBase+CallbackPath+"?code=pending")
|
_, _ = httpGetBody(t, f.callbackBase+CallbackPath+"?code=pending")
|
||||||
cancel()
|
cancel()
|
||||||
if err := <-done; err == nil {
|
if result := awaitOAuthLogin(t, done); result.err == nil {
|
||||||
t.Fatal("canceled pending login succeeded")
|
t.Fatal("canceled pending login succeeded")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -519,23 +577,20 @@ func TestCrossPlatformCoverageOAuthLoginExchangeFailure(t *testing.T) {
|
|||||||
return CLIAuthStatus{Success: true, Result: &CLIAuthResult{CLIAuthEnabled: true}}
|
return CLIAuthStatus{Success: true, Result: &CLIAuthResult{CLIAuthEnabled: true}}
|
||||||
})
|
})
|
||||||
f.exchangeError.Store(true)
|
f.exchangeError.Store(true)
|
||||||
close(f.exchangeRelease)
|
closeOAuthRelease(f.exchangeRelease)
|
||||||
close(f.statusRelease)
|
closeOAuthRelease(f.statusRelease)
|
||||||
done := make(chan error, 1)
|
done := startOAuthLogin(t, context.Background(), f)
|
||||||
go func() {
|
|
||||||
_, err := f.provider.Login(context.Background(), true)
|
|
||||||
done <- err
|
|
||||||
}()
|
|
||||||
_, body := httpGetBody(t, f.callbackBase+CallbackPath+"?code=bad")
|
_, body := httpGetBody(t, f.callbackBase+CallbackPath+"?code=bad")
|
||||||
if !strings.Contains(body, "failed") {
|
if !strings.Contains(body, "failed") {
|
||||||
t.Fatalf("exchange failure page = %q", body)
|
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")
|
t.Fatal("exchange failure login succeeded")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCrossPlatformCoverageOAuthRefreshAndParsingEdges(t *testing.T) {
|
func TestCrossPlatformCoverageOAuthRefreshAndParsingEdges(t *testing.T) {
|
||||||
|
isolateOAuthPersistence(t)
|
||||||
t.Setenv("DWS_CLIENT_ID", "")
|
t.Setenv("DWS_CLIENT_ID", "")
|
||||||
t.Setenv("DWS_CLIENT_SECRET", "")
|
t.Setenv("DWS_CLIENT_SECRET", "")
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
@@ -640,6 +695,7 @@ func TestCrossPlatformCoverageOAuthRefreshAndParsingEdges(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCrossPlatformCoverageOAuthProviderHighLevelEdges(t *testing.T) {
|
func TestCrossPlatformCoverageOAuthProviderHighLevelEdges(t *testing.T) {
|
||||||
|
isolateOAuthPersistence(t)
|
||||||
oldLoad := oauthLoadToken
|
oldLoad := oauthLoadToken
|
||||||
oldLoadLocked := oauthLoadTokenLocked
|
oldLoadLocked := oauthLoadTokenLocked
|
||||||
oldAcquire := oauthAcquireLock
|
oldAcquire := oauthAcquireLock
|
||||||
@@ -800,29 +856,24 @@ func TestCrossPlatformCoverageOAuthCallbackRemainingEdges(t *testing.T) {
|
|||||||
oauthSuccessPause = 0
|
oauthSuccessPause = 0
|
||||||
oauthSleep = func(time.Duration) {}
|
oauthSleep = func(time.Duration) {}
|
||||||
|
|
||||||
type loginResult struct {
|
finishExchange := func(t *testing.T, f *oauthLoginFixture, done <-chan oauthLoginResult, code string) string {
|
||||||
token *TokenData
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
startLogin := func(ctx context.Context, f *oauthLoginFixture) <-chan loginResult {
|
|
||||||
t.Helper()
|
t.Helper()
|
||||||
done := make(chan loginResult, 1)
|
bodyCh := make(chan oauthHTTPResult, 1)
|
||||||
go func() {
|
go func() {
|
||||||
token, err := f.provider.Login(ctx, true)
|
bodyCh <- getHTTPBody(f.callbackBase + CallbackPath + "?code=" + url.QueryEscape(code))
|
||||||
done <- loginResult{token: token, err: err}
|
|
||||||
}()
|
}()
|
||||||
return done
|
waitOAuthSignal(t, f.exchangeEntered, done, "token exchange")
|
||||||
}
|
closeOAuthRelease(f.exchangeRelease)
|
||||||
finishExchange := func(t *testing.T, f *oauthLoginFixture, code string) string {
|
select {
|
||||||
t.Helper()
|
case result := <-bodyCh:
|
||||||
bodyCh := make(chan string, 1)
|
if result.err != nil {
|
||||||
go func() {
|
t.Fatalf("OAuth callback failed: %v", result.err)
|
||||||
_, body := httpGetBody(t, f.callbackBase+CallbackPath+"?code="+url.QueryEscape(code))
|
}
|
||||||
bodyCh <- body
|
return result.body
|
||||||
}()
|
case <-time.After(oauthTestWaitTimeout):
|
||||||
<-f.exchangeEntered
|
t.Fatal("timed out waiting for OAuth callback")
|
||||||
close(f.exchangeRelease)
|
return ""
|
||||||
return <-bodyCh
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Run("switch organization and cached disabled pages", func(t *testing.T) {
|
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
|
return &CLIAuthStatus{Success: true, Result: &CLIAuthResult{CLIAuthEnabled: true}}, nil
|
||||||
}
|
}
|
||||||
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus { return CLIAuthStatus{} })
|
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus { return CLIAuthStatus{} })
|
||||||
done := startLogin(context.Background(), f)
|
done := startOAuthLogin(t, context.Background(), f)
|
||||||
if body := finishExchange(t, f, "first"); !strings.Contains(body, "<html") {
|
if body := finishExchange(t, f, done, "first"); !strings.Contains(body, "<html") {
|
||||||
t.Fatalf("disabled callback body = %q", body)
|
t.Fatalf("disabled callback body = %q", body)
|
||||||
}
|
}
|
||||||
if _, body := httpGetBody(t, f.callbackBase+CallbackPath+"?code=first"); !strings.Contains(body, "<html") {
|
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") {
|
if _, body := httpGetBody(t, f.callbackBase+CallbackPath+"?code=second"); !strings.Contains(body, "<html") {
|
||||||
t.Fatalf("switched callback = %q", body)
|
t.Fatalf("switched callback = %q", body)
|
||||||
}
|
}
|
||||||
result := <-done
|
result := awaitOAuthLogin(t, done)
|
||||||
if result.err != nil || result.token == nil || result.token.AccessToken != "access" {
|
if result.err != nil || result.token == nil || result.token.AccessToken != "access" {
|
||||||
t.Fatalf("switched login = %#v %v", result.token, result.err)
|
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 }
|
oauthSendApply = func(context.Context, string, string) (*SendApplyResponse, error) { return nil, fail }
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus { return CLIAuthStatus{} })
|
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus { return CLIAuthStatus{} })
|
||||||
done := startLogin(ctx, f)
|
done := startOAuthLogin(t, ctx, f)
|
||||||
finishExchange(t, f, "errors")
|
finishExchange(t, f, done, "errors")
|
||||||
for _, path := range []string{"/api/superAdmin", "/api/sendApply?adminStaffId=admin", "/api/cliAuthEnabled"} {
|
for _, path := range []string{"/api/superAdmin", "/api/sendApply?adminStaffId=admin", "/api/cliAuthEnabled"} {
|
||||||
if _, body := httpGetBody(t, f.callbackBase+path); !strings.Contains(body, "hook failure") {
|
if _, body := httpGetBody(t, f.callbackBase+path); !strings.Contains(body, "hook failure") {
|
||||||
t.Fatalf("API error %s = %q", path, body)
|
t.Fatalf("API error %s = %q", path, body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cancel()
|
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)
|
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{} })
|
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus { return CLIAuthStatus{} })
|
||||||
var output bytes.Buffer
|
var output bytes.Buffer
|
||||||
f.provider.Output = &output
|
f.provider.Output = &output
|
||||||
done := startLogin(ctx, f)
|
done := startOAuthLogin(t, ctx, f)
|
||||||
finishExchange(t, f, "apply")
|
finishExchange(t, f, done, "apply")
|
||||||
if _, body := httpGetBody(t, f.callbackBase+"/api/sendApply?adminStaffId=admin"); !strings.Contains(body, "true") {
|
if _, body := httpGetBody(t, f.callbackBase+"/api/sendApply?adminStaffId=admin"); !strings.Contains(body, "true") {
|
||||||
t.Fatalf("apply response = %q", body)
|
t.Fatalf("apply response = %q", body)
|
||||||
}
|
}
|
||||||
time.Sleep(20 * time.Millisecond)
|
time.Sleep(20 * time.Millisecond)
|
||||||
cancel()
|
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)
|
t.Fatalf("canceled apply login = %v", result.err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(output.String(), "Waiting for admin approval") && !strings.Contains(output.String(), "等待管理员审批中") {
|
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
|
return &CLIAuthStatus{Success: false, ErrorCode: "ENTERPRISE_NOT_AUTHORIZED"}, nil
|
||||||
}
|
}
|
||||||
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus { return CLIAuthStatus{} })
|
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus { return CLIAuthStatus{} })
|
||||||
done := startLogin(context.Background(), f)
|
done := startOAuthLogin(t, context.Background(), f)
|
||||||
finishExchange(t, f, "enterprise")
|
finishExchange(t, f, done, "enterprise")
|
||||||
if result := <-done; result.err == nil || !strings.Contains(result.err.Error(), "企业安全认证") {
|
if result := awaitOAuthLogin(t, done); result.err == nil || !strings.Contains(result.err.Error(), "企业安全认证") {
|
||||||
t.Fatalf("enterprise denial = %v", result.err)
|
t.Fatalf("enterprise denial = %v", result.err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -933,15 +984,16 @@ func TestCrossPlatformCoverageOAuthCallbackRemainingEdges(t *testing.T) {
|
|||||||
fail := errors.New("save failure")
|
fail := errors.New("save failure")
|
||||||
oauthSaveToken = func(string, *TokenData) error { return fail }
|
oauthSaveToken = func(string, *TokenData) error { return fail }
|
||||||
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus { return CLIAuthStatus{} })
|
f := newOAuthLoginFixture(t, func(int32) CLIAuthStatus { return CLIAuthStatus{} })
|
||||||
done := startLogin(context.Background(), f)
|
done := startOAuthLogin(t, context.Background(), f)
|
||||||
finishExchange(t, f, "save")
|
finishExchange(t, f, done, "save")
|
||||||
if result := <-done; !errors.Is(result.err, fail) {
|
if result := awaitOAuthLogin(t, done); !errors.Is(result.err, fail) {
|
||||||
t.Fatalf("save failure login = %v", result.err)
|
t.Fatalf("save failure login = %v", result.err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCrossPlatformCoverageOAuthHelperRemainingEdges(t *testing.T) {
|
func TestCrossPlatformCoverageOAuthHelperRemainingEdges(t *testing.T) {
|
||||||
|
isolateOAuthPersistence(t)
|
||||||
oldClient := oauthHTTPClient
|
oldClient := oauthHTTPClient
|
||||||
oldRequest := oauthNewRequest
|
oldRequest := oauthNewRequest
|
||||||
oldRetry := oauthRetryAfter
|
oldRetry := oauthRetryAfter
|
||||||
|
|||||||
@@ -0,0 +1,510 @@
|
|||||||
|
// Copyright 2026 Alibaba Group
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||||
|
)
|
||||||
|
|
||||||
|
type rejectedTokenHookStore struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
data TokenData
|
||||||
|
deletes int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *rejectedTokenHookStore) load(string) ([]byte, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return json.Marshal(s.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *rejectedTokenHookStore) save(_ string, blob []byte) error {
|
||||||
|
var data TokenData
|
||||||
|
if err := json.Unmarshal(blob, &data); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
s.data = data
|
||||||
|
s.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *rejectedTokenHookStore) delete(string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.data = TokenData{}
|
||||||
|
s.deletes++
|
||||||
|
s.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *rejectedTokenHookStore) snapshot() (TokenData, int) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.data, s.deletes
|
||||||
|
}
|
||||||
|
|
||||||
|
func installRejectedTokenHookStore(t *testing.T, data TokenData) *rejectedTokenHookStore {
|
||||||
|
t.Helper()
|
||||||
|
store := &rejectedTokenHookStore{data: data}
|
||||||
|
previousHooks := edition.Get()
|
||||||
|
edition.Override(&edition.Hooks{
|
||||||
|
LoadToken: store.load,
|
||||||
|
SaveToken: store.save,
|
||||||
|
DeleteToken: store.delete,
|
||||||
|
})
|
||||||
|
t.Cleanup(func() { edition.Override(previousHooks) })
|
||||||
|
return store
|
||||||
|
}
|
||||||
|
|
||||||
|
func installOAuthRefreshStub(t *testing.T, fn func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error)) {
|
||||||
|
t.Helper()
|
||||||
|
resetRejectedTokenRefreshCoordinator(t)
|
||||||
|
previous := oauthRefreshToken
|
||||||
|
oauthRefreshToken = fn
|
||||||
|
t.Cleanup(func() { oauthRefreshToken = previous })
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetRejectedTokenRefreshCoordinator(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
reset := func() {
|
||||||
|
rejectedTokenRefreshCoordinator.Lock()
|
||||||
|
rejectedTokenRefreshCoordinator.inFlight = make(map[rejectedTokenRefreshKey]*rejectedTokenRefreshCall)
|
||||||
|
rejectedTokenRefreshCoordinator.failures = make(map[rejectedTokenRefreshKey]rejectedTokenRefreshFailure)
|
||||||
|
rejectedTokenRefreshCoordinator.now = time.Now
|
||||||
|
rejectedTokenRefreshCoordinator.Unlock()
|
||||||
|
}
|
||||||
|
reset()
|
||||||
|
t.Cleanup(reset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForRejectedTokenRefreshParticipants(t *testing.T, key rejectedTokenRefreshKey, want int) {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for {
|
||||||
|
rejectedTokenRefreshCoordinator.Lock()
|
||||||
|
call := rejectedTokenRefreshCoordinator.inFlight[key]
|
||||||
|
got := 0
|
||||||
|
if call != nil {
|
||||||
|
got = call.participants
|
||||||
|
}
|
||||||
|
rejectedTokenRefreshCoordinator.Unlock()
|
||||||
|
if got >= want {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatalf("refresh participants = %d, want %d", got, want)
|
||||||
|
}
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func installProfilesAcquireProbe(t *testing.T) <-chan struct{} {
|
||||||
|
t.Helper()
|
||||||
|
previous := profilesAcquireDualLock
|
||||||
|
attempted := make(chan struct{}, 1)
|
||||||
|
profilesAcquireDualLock = func(ctx context.Context, configDir string) (*DualLock, error) {
|
||||||
|
attempted <- struct{}{}
|
||||||
|
return previous(ctx, configDir)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { profilesAcquireDualLock = previous })
|
||||||
|
return attempted
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForProfilesAcquire(t *testing.T, attempted <-chan struct{}) {
|
||||||
|
t.Helper()
|
||||||
|
select {
|
||||||
|
case <-attempted:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("public opaque token mutation did not enter the Core dual lock")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validRejectedTokenData(accessToken string) TokenData {
|
||||||
|
return TokenData{
|
||||||
|
AccessToken: accessToken,
|
||||||
|
RefreshToken: "refresh-token",
|
||||||
|
ExpiresAt: time.Now().Add(time.Hour),
|
||||||
|
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||||
|
Source: "mcp",
|
||||||
|
ClientID: "client-id",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageForceRefreshRejectedTokenConcurrentCallersExchangeOnce(t *testing.T) {
|
||||||
|
store := installRejectedTokenHookStore(t, validRejectedTokenData("old-access"))
|
||||||
|
var refreshCalls atomic.Int32
|
||||||
|
started := make(chan struct{})
|
||||||
|
release := make(chan struct{})
|
||||||
|
installOAuthRefreshStub(t, func(p *OAuthProvider, _ context.Context, data *TokenData) (*TokenData, error) {
|
||||||
|
if refreshCalls.Add(1) == 1 {
|
||||||
|
close(started)
|
||||||
|
}
|
||||||
|
<-release
|
||||||
|
updated := *data
|
||||||
|
updated.AccessToken = "new-access"
|
||||||
|
updated.ExpiresAt = time.Now().Add(time.Hour)
|
||||||
|
if err := saveTokenDataLocked(p.configDir, &updated); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &updated, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
provider := NewOAuthProvider(t.TempDir(), nil)
|
||||||
|
const workers = 8
|
||||||
|
results := make(chan string, workers)
|
||||||
|
errs := make(chan error, workers)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(workers)
|
||||||
|
for range workers {
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
token, err := provider.ForceRefreshRejectedToken(context.Background(), "old-access")
|
||||||
|
results <- token
|
||||||
|
errs <- err
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
<-started
|
||||||
|
close(release)
|
||||||
|
wg.Wait()
|
||||||
|
close(results)
|
||||||
|
close(errs)
|
||||||
|
|
||||||
|
for err := range errs {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for token := range results {
|
||||||
|
if token != "new-access" {
|
||||||
|
t.Fatalf("token = %q, want new-access", token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := refreshCalls.Load(); got != 1 {
|
||||||
|
t.Fatalf("refresh calls = %d, want 1", got)
|
||||||
|
}
|
||||||
|
stored, deletes := store.snapshot()
|
||||||
|
if stored.AccessToken != "new-access" || deletes != 0 {
|
||||||
|
t.Fatalf("stored token = %q, deletes = %d", stored.AccessToken, deletes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageForceRefreshRejectedTokenFailurePreservesCredential(t *testing.T) {
|
||||||
|
store := installRejectedTokenHookStore(t, validRejectedTokenData("old-access"))
|
||||||
|
refreshErr := errors.New("temporary refresh failure")
|
||||||
|
installOAuthRefreshStub(t, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) {
|
||||||
|
return nil, refreshErr
|
||||||
|
})
|
||||||
|
|
||||||
|
_, err := NewOAuthProvider(t.TempDir(), nil).ForceRefreshRejectedToken(context.Background(), "old-access")
|
||||||
|
if !errors.Is(err, refreshErr) {
|
||||||
|
t.Fatalf("error = %v, want refresh cause", err)
|
||||||
|
}
|
||||||
|
stored, deletes := store.snapshot()
|
||||||
|
if stored.AccessToken != "old-access" || stored.RefreshToken != "refresh-token" || deletes != 0 {
|
||||||
|
t.Fatalf("credential changed after transient failure: %#v, deletes=%d", stored, deletes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageForceRefreshRejectedTokenFailureIsSingleflightAndCooledDown(t *testing.T) {
|
||||||
|
store := installRejectedTokenHookStore(t, validRejectedTokenData("old-access"))
|
||||||
|
previousProfile := RuntimeProfile()
|
||||||
|
SetRuntimeProfile("")
|
||||||
|
t.Cleanup(func() { SetRuntimeProfile(previousProfile) })
|
||||||
|
|
||||||
|
refreshErr := errors.New("temporary refresh failure")
|
||||||
|
var refreshCalls atomic.Int32
|
||||||
|
started := make(chan struct{})
|
||||||
|
release := make(chan struct{})
|
||||||
|
baseNow := time.Now()
|
||||||
|
var nowNanos atomic.Int64
|
||||||
|
nowNanos.Store(baseNow.UnixNano())
|
||||||
|
installOAuthRefreshStub(t, func(p *OAuthProvider, _ context.Context, data *TokenData) (*TokenData, error) {
|
||||||
|
call := refreshCalls.Add(1)
|
||||||
|
if call == 1 {
|
||||||
|
close(started)
|
||||||
|
<-release
|
||||||
|
return nil, refreshErr
|
||||||
|
}
|
||||||
|
updated := *data
|
||||||
|
updated.AccessToken = "recovered-access"
|
||||||
|
updated.ExpiresAt = time.Now().Add(time.Hour)
|
||||||
|
if err := saveTokenDataLocked(p.configDir, &updated); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &updated, nil
|
||||||
|
})
|
||||||
|
rejectedTokenRefreshCoordinator.Lock()
|
||||||
|
rejectedTokenRefreshCoordinator.now = func() time.Time {
|
||||||
|
return time.Unix(0, nowNanos.Load())
|
||||||
|
}
|
||||||
|
rejectedTokenRefreshCoordinator.Unlock()
|
||||||
|
|
||||||
|
configDir := t.TempDir()
|
||||||
|
provider := NewOAuthProvider(configDir, nil)
|
||||||
|
const workers = 8
|
||||||
|
start := make(chan struct{})
|
||||||
|
ready := make(chan struct{}, workers)
|
||||||
|
errs := make(chan error, workers)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(workers)
|
||||||
|
for range workers {
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
ready <- struct{}{}
|
||||||
|
<-start
|
||||||
|
_, err := provider.ForceRefreshRejectedToken(context.Background(), "old-access")
|
||||||
|
errs <- err
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
for range workers {
|
||||||
|
<-ready
|
||||||
|
}
|
||||||
|
close(start)
|
||||||
|
<-started
|
||||||
|
key := newRejectedTokenRefreshKey(configDir, "", "old-access")
|
||||||
|
waitForRejectedTokenRefreshParticipants(t, key, workers)
|
||||||
|
close(release)
|
||||||
|
wg.Wait()
|
||||||
|
close(errs)
|
||||||
|
|
||||||
|
for err := range errs {
|
||||||
|
if !errors.Is(err, refreshErr) {
|
||||||
|
t.Fatalf("shared refresh error = %v, want %v", err, refreshErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := refreshCalls.Load(); got != 1 {
|
||||||
|
t.Fatalf("refresh calls after concurrent failure = %d, want 1", got)
|
||||||
|
}
|
||||||
|
stored, deletes := store.snapshot()
|
||||||
|
if stored.AccessToken != "old-access" || stored.RefreshToken != "refresh-token" || deletes != 0 {
|
||||||
|
t.Fatalf("credential changed after shared failure: %#v, deletes=%d", stored, deletes)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := provider.ForceRefreshRejectedToken(context.Background(), "old-access"); !errors.Is(err, refreshErr) {
|
||||||
|
t.Fatalf("cooldown error = %v, want %v", err, refreshErr)
|
||||||
|
}
|
||||||
|
if got := refreshCalls.Load(); got != 1 {
|
||||||
|
t.Fatalf("refresh calls inside cooldown = %d, want 1", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
nowNanos.Store(baseNow.Add(rejectedTokenRefreshFailureCooldown + time.Nanosecond).UnixNano())
|
||||||
|
token, err := provider.ForceRefreshRejectedToken(context.Background(), "old-access")
|
||||||
|
if err != nil || token != "recovered-access" {
|
||||||
|
t.Fatalf("refresh after cooldown = %q, %v", token, err)
|
||||||
|
}
|
||||||
|
if got := refreshCalls.Load(); got != 2 {
|
||||||
|
t.Fatalf("refresh calls after cooldown = %d, want 2", got)
|
||||||
|
}
|
||||||
|
stored, deletes = store.snapshot()
|
||||||
|
if stored.AccessToken != "recovered-access" || deletes != 0 {
|
||||||
|
t.Fatalf("stored token after cooldown recovery = %q, deletes=%d", stored.AccessToken, deletes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageForceRefreshRejectedTokenChangedDuringCooldownUsesNewToken(t *testing.T) {
|
||||||
|
store := installRejectedTokenHookStore(t, validRejectedTokenData("old-access"))
|
||||||
|
previousProfile := RuntimeProfile()
|
||||||
|
SetRuntimeProfile("")
|
||||||
|
t.Cleanup(func() { SetRuntimeProfile(previousProfile) })
|
||||||
|
|
||||||
|
refreshErr := errors.New("temporary refresh failure")
|
||||||
|
var refreshCalls atomic.Int32
|
||||||
|
baseNow := time.Now()
|
||||||
|
installOAuthRefreshStub(t, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) {
|
||||||
|
refreshCalls.Add(1)
|
||||||
|
return nil, refreshErr
|
||||||
|
})
|
||||||
|
rejectedTokenRefreshCoordinator.Lock()
|
||||||
|
rejectedTokenRefreshCoordinator.now = func() time.Time { return baseNow }
|
||||||
|
rejectedTokenRefreshCoordinator.Unlock()
|
||||||
|
|
||||||
|
configDir := t.TempDir()
|
||||||
|
provider := NewOAuthProvider(configDir, nil)
|
||||||
|
if _, err := provider.ForceRefreshRejectedToken(context.Background(), "old-access"); !errors.Is(err, refreshErr) {
|
||||||
|
t.Fatalf("initial refresh error = %v, want %v", err, refreshErr)
|
||||||
|
}
|
||||||
|
if got := refreshCalls.Load(); got != 1 {
|
||||||
|
t.Fatalf("initial refresh calls = %d, want 1", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
store.mu.Lock()
|
||||||
|
store.data = validRejectedTokenData("externally-refreshed")
|
||||||
|
store.mu.Unlock()
|
||||||
|
token, err := provider.ForceRefreshRejectedToken(context.Background(), "old-access")
|
||||||
|
if err != nil || token != "externally-refreshed" {
|
||||||
|
t.Fatalf("refresh after external publication = %q, %v", token, err)
|
||||||
|
}
|
||||||
|
if got := refreshCalls.Load(); got != 1 {
|
||||||
|
t.Fatalf("external publication triggered another exchange: calls=%d", got)
|
||||||
|
}
|
||||||
|
key := newRejectedTokenRefreshKey(configDir, "", "old-access")
|
||||||
|
rejectedTokenRefreshCoordinator.Lock()
|
||||||
|
_, failurePresent := rejectedTokenRefreshCoordinator.failures[key]
|
||||||
|
rejectedTokenRefreshCoordinator.Unlock()
|
||||||
|
if failurePresent {
|
||||||
|
t.Fatal("old-token failure cache was not cleared after external publication")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageOpaquePublisherWaitsForRejectedTokenRefresh(t *testing.T) {
|
||||||
|
store := installRejectedTokenHookStore(t, validRejectedTokenData("old-access"))
|
||||||
|
started := make(chan struct{})
|
||||||
|
release := make(chan struct{})
|
||||||
|
var releaseOnce sync.Once
|
||||||
|
t.Cleanup(func() { releaseOnce.Do(func() { close(release) }) })
|
||||||
|
installOAuthRefreshStub(t, func(p *OAuthProvider, _ context.Context, data *TokenData) (*TokenData, error) {
|
||||||
|
close(started)
|
||||||
|
<-release
|
||||||
|
updated := *data
|
||||||
|
updated.AccessToken = "refreshed-from-old"
|
||||||
|
updated.ExpiresAt = time.Now().Add(time.Hour)
|
||||||
|
if err := saveTokenDataLocked(p.configDir, &updated); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &updated, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
configDir := t.TempDir()
|
||||||
|
provider := NewOAuthProvider(configDir, nil)
|
||||||
|
refreshResult := make(chan struct {
|
||||||
|
token string
|
||||||
|
err error
|
||||||
|
}, 1)
|
||||||
|
go func() {
|
||||||
|
token, err := provider.ForceRefreshRejectedToken(context.Background(), "old-access")
|
||||||
|
refreshResult <- struct {
|
||||||
|
token string
|
||||||
|
err error
|
||||||
|
}{token: token, err: err}
|
||||||
|
}()
|
||||||
|
<-started
|
||||||
|
|
||||||
|
acquireAttempted := installProfilesAcquireProbe(t)
|
||||||
|
publishResult := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
publishResult <- SaveTokenData(configDir, ptrTokenData(validRejectedTokenData("login-published")))
|
||||||
|
}()
|
||||||
|
waitForProfilesAcquire(t, acquireAttempted)
|
||||||
|
releaseOnce.Do(func() { close(release) })
|
||||||
|
|
||||||
|
refresh := <-refreshResult
|
||||||
|
if refresh.err != nil || refresh.token != "refreshed-from-old" {
|
||||||
|
t.Fatalf("refresh result = %q, %v", refresh.token, refresh.err)
|
||||||
|
}
|
||||||
|
if err := <-publishResult; err != nil {
|
||||||
|
t.Fatalf("publish token: %v", err)
|
||||||
|
}
|
||||||
|
stored, deletes := store.snapshot()
|
||||||
|
if stored.AccessToken != "login-published" || deletes != 0 {
|
||||||
|
t.Fatalf("older refresh overwrote login publication: token=%q deletes=%d", stored.AccessToken, deletes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageOpaqueLogoutWaitsForRejectedTokenRefresh(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
logout func(string) error
|
||||||
|
}{
|
||||||
|
{name: "current profile", logout: func(configDir string) error {
|
||||||
|
return DeleteTokenDataForProfile(configDir, "")
|
||||||
|
}},
|
||||||
|
{name: "all profiles", logout: DeleteAllTokenData},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
store := installRejectedTokenHookStore(t, validRejectedTokenData("old-access"))
|
||||||
|
started := make(chan struct{})
|
||||||
|
release := make(chan struct{})
|
||||||
|
var releaseOnce sync.Once
|
||||||
|
t.Cleanup(func() { releaseOnce.Do(func() { close(release) }) })
|
||||||
|
installOAuthRefreshStub(t, func(p *OAuthProvider, _ context.Context, data *TokenData) (*TokenData, error) {
|
||||||
|
close(started)
|
||||||
|
<-release
|
||||||
|
updated := *data
|
||||||
|
updated.AccessToken = "refreshed-before-logout"
|
||||||
|
updated.ExpiresAt = time.Now().Add(time.Hour)
|
||||||
|
if err := saveTokenDataLocked(p.configDir, &updated); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &updated, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
configDir := t.TempDir()
|
||||||
|
provider := NewOAuthProvider(configDir, nil)
|
||||||
|
refreshResult := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
_, err := provider.ForceRefreshRejectedToken(context.Background(), "old-access")
|
||||||
|
refreshResult <- err
|
||||||
|
}()
|
||||||
|
<-started
|
||||||
|
|
||||||
|
acquireAttempted := installProfilesAcquireProbe(t)
|
||||||
|
logoutResult := make(chan error, 1)
|
||||||
|
go func() { logoutResult <- tc.logout(configDir) }()
|
||||||
|
waitForProfilesAcquire(t, acquireAttempted)
|
||||||
|
releaseOnce.Do(func() { close(release) })
|
||||||
|
|
||||||
|
if err := <-refreshResult; err != nil {
|
||||||
|
t.Fatalf("refresh: %v", err)
|
||||||
|
}
|
||||||
|
if err := <-logoutResult; err != nil {
|
||||||
|
t.Fatalf("logout: %v", err)
|
||||||
|
}
|
||||||
|
stored, deletes := store.snapshot()
|
||||||
|
if stored.AccessToken != "" || stored.RefreshToken != "" || deletes != 1 {
|
||||||
|
t.Fatalf("refresh resurrected logged-out credential: %#v deletes=%d", stored, deletes)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ptrTokenData(data TokenData) *TokenData {
|
||||||
|
return &data
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageOAuthLockedRefreshReadsOpaqueEditionStore(t *testing.T) {
|
||||||
|
data := validRejectedTokenData("expired-access")
|
||||||
|
data.ExpiresAt = time.Now().Add(-time.Hour)
|
||||||
|
store := installRejectedTokenHookStore(t, data)
|
||||||
|
var refreshCalls atomic.Int32
|
||||||
|
installOAuthRefreshStub(t, func(p *OAuthProvider, _ context.Context, current *TokenData) (*TokenData, error) {
|
||||||
|
refreshCalls.Add(1)
|
||||||
|
updated := *current
|
||||||
|
updated.AccessToken = "proactively-refreshed"
|
||||||
|
updated.ExpiresAt = time.Now().Add(time.Hour)
|
||||||
|
if err := saveTokenDataLocked(p.configDir, &updated); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &updated, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
token, err := NewOAuthProvider(t.TempDir(), nil).GetAccessToken(context.Background())
|
||||||
|
if err != nil || token != "proactively-refreshed" {
|
||||||
|
t.Fatalf("GetAccessToken() = %q, %v", token, err)
|
||||||
|
}
|
||||||
|
if refreshCalls.Load() != 1 {
|
||||||
|
t.Fatalf("refresh calls = %d, want 1", refreshCalls.Load())
|
||||||
|
}
|
||||||
|
stored, deletes := store.snapshot()
|
||||||
|
if stored.AccessToken != "proactively-refreshed" || deletes != 0 {
|
||||||
|
t.Fatalf("stored token = %q, deletes = %d", stored.AccessToken, deletes)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -34,7 +35,17 @@ func TestMain(m *testing.M) {
|
|||||||
_ = os.RemoveAll(tmpDir)
|
_ = os.RemoveAll(tmpDir)
|
||||||
panic("set " + keychain.StorageDirEnv + ": " + err.Error())
|
panic("set " + keychain.StorageDirEnv + ": " + err.Error())
|
||||||
}
|
}
|
||||||
|
if err := os.Setenv(keychain.TestNamespaceEnv, tmpDir); err != nil {
|
||||||
|
_ = os.RemoveAll(tmpDir)
|
||||||
|
panic("set " + keychain.TestNamespaceEnv + ": " + err.Error())
|
||||||
|
}
|
||||||
code := m.Run()
|
code := m.Run()
|
||||||
|
if err := keychain.RemoveAuthTokenEntries(keychain.Service); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "internal/auth keychain cleanup: %v\n", err)
|
||||||
|
if code == 0 {
|
||||||
|
code = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
_ = os.RemoveAll(tmpDir)
|
_ = os.RemoveAll(tmpDir)
|
||||||
os.Exit(code)
|
os.Exit(code)
|
||||||
}
|
}
|
||||||
+37
-11
@@ -127,6 +127,10 @@ const tokenJSONFile = "token.json"
|
|||||||
type TokenMarker struct {
|
type TokenMarker struct {
|
||||||
UpdatedAt string `json:"updated_at"`
|
UpdatedAt string `json:"updated_at"`
|
||||||
ManualToken bool `json:"manual_token,omitempty"`
|
ManualToken bool `json:"manual_token,omitempty"`
|
||||||
|
// Revision changes on every credential publication. Runtime token caches
|
||||||
|
// use it as a cheap cross-process invalidation signal without reading the
|
||||||
|
// platform keychain on every request.
|
||||||
|
Revision string `json:"revision,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// WriteTokenMarker writes a token.json marker containing only an updated_at
|
// WriteTokenMarker writes a token.json marker containing only an updated_at
|
||||||
@@ -147,6 +151,7 @@ func writeTokenMarker(configDir string, manual bool) error {
|
|||||||
marker := TokenMarker{
|
marker := TokenMarker{
|
||||||
UpdatedAt: time.Now().Format(time.RFC3339),
|
UpdatedAt: time.Now().Format(time.RFC3339),
|
||||||
ManualToken: manual,
|
ManualToken: manual,
|
||||||
|
Revision: uuid.NewString(),
|
||||||
}
|
}
|
||||||
data, _ := tokenJSONMarshalIndent(marker, "", " ")
|
data, _ := tokenJSONMarshalIndent(marker, "", " ")
|
||||||
if err := tokenMkdirAll(configDir, 0o700); err != nil {
|
if err := tokenMkdirAll(configDir, 0o700); err != nil {
|
||||||
@@ -159,6 +164,27 @@ func writeTokenMarker(configDir string, manual bool) error {
|
|||||||
return tokenRename(tmp, filepath.Join(configDir, tokenJSONFile))
|
return tokenRename(tmp, filepath.Join(configDir, tokenJSONFile))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReadTokenMarkerRevision returns the current credential publication revision.
|
||||||
|
// Existing markers without a revision remain readable, but callers must avoid
|
||||||
|
// caching them because they cannot prove that the credential is unchanged.
|
||||||
|
func ReadTokenMarkerRevision(configDir string) (revision string, present bool, err error) {
|
||||||
|
data, err := tokenReadFile(filepath.Join(configDir, tokenJSONFile))
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
return "", false, fmt.Errorf("read token marker: %w", err)
|
||||||
|
}
|
||||||
|
var marker TokenMarker
|
||||||
|
if err := json.Unmarshal(data, &marker); err != nil {
|
||||||
|
// The marker is only a cache-coherency hint. A malformed historical or
|
||||||
|
// externally modified marker must disable caching, not make an otherwise
|
||||||
|
// valid credential unusable.
|
||||||
|
return "", true, nil
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(marker.Revision), true, nil
|
||||||
|
}
|
||||||
|
|
||||||
func manualTokenMarkerActive(configDir string) (bool, error) {
|
func manualTokenMarkerActive(configDir string) (bool, error) {
|
||||||
data, err := tokenReadFile(filepath.Join(configDir, tokenJSONFile))
|
data, err := tokenReadFile(filepath.Join(configDir, tokenJSONFile))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -184,13 +210,10 @@ func DeleteTokenMarker(configDir string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SaveTokenData persists TokenData. When an edition hook (SaveToken) is
|
// SaveTokenData persists TokenData under the auth dual lock. When an edition
|
||||||
// registered, it delegates entirely to the hook; otherwise it falls back
|
// hook (SaveToken) is registered, the locked write delegates to that hook;
|
||||||
// to the default keychain-based storage.
|
// otherwise it falls back to the default keychain-based storage.
|
||||||
func SaveTokenData(configDir string, data *TokenData) error {
|
func SaveTokenData(configDir string, data *TokenData) error {
|
||||||
if h := edition.Get(); h.SaveToken != nil {
|
|
||||||
return saveTokenViaHook(h, configDir, data)
|
|
||||||
}
|
|
||||||
return withProfilesLock(configDir, func() error {
|
return withProfilesLock(configDir, func() error {
|
||||||
return saveTokenDataLocked(configDir, data)
|
return saveTokenDataLocked(configDir, data)
|
||||||
})
|
})
|
||||||
@@ -464,9 +487,8 @@ func tokenLoadProfileIdentity(profile Profile) (*TokenData, error) {
|
|||||||
return orgData, nil
|
return orgData, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteTokenData removes token data. When an edition hook (DeleteToken) is
|
// DeleteTokenData removes token data. Edition hooks and the default keychain
|
||||||
// registered, it delegates entirely to the hook; otherwise it falls back
|
// path are both serialized with refresh through the auth dual lock.
|
||||||
// to keychain + legacy cleanup.
|
|
||||||
func DeleteTokenData(configDir string) error {
|
func DeleteTokenData(configDir string) error {
|
||||||
return DeleteTokenDataForProfile(configDir, RuntimeProfile())
|
return DeleteTokenDataForProfile(configDir, RuntimeProfile())
|
||||||
}
|
}
|
||||||
@@ -478,7 +500,9 @@ func DeleteTokenDataForProfile(configDir, profile string) error {
|
|||||||
if strings.TrimSpace(profile) != "" {
|
if strings.TrimSpace(profile) != "" {
|
||||||
return fmt.Errorf("profile selection is not supported by the current auth backend")
|
return fmt.Errorf("profile selection is not supported by the current auth backend")
|
||||||
}
|
}
|
||||||
return h.DeleteToken(configDir)
|
return withProfilesLock(configDir, func() error {
|
||||||
|
return h.DeleteToken(configDir)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return withProfilesLock(configDir, func() error {
|
return withProfilesLock(configDir, func() error {
|
||||||
return deleteTokenDataForProfileLocked(configDir, profile)
|
return deleteTokenDataForProfileLocked(configDir, profile)
|
||||||
@@ -913,7 +937,9 @@ func restoreTokenMarker(configDir string, marker tokenMarkerSnapshot) error {
|
|||||||
// DeleteAllTokenData removes all profile-scoped and legacy token data.
|
// DeleteAllTokenData removes all profile-scoped and legacy token data.
|
||||||
func DeleteAllTokenData(configDir string) error {
|
func DeleteAllTokenData(configDir string) error {
|
||||||
if h := edition.Get(); h.DeleteToken != nil {
|
if h := edition.Get(); h.DeleteToken != nil {
|
||||||
return h.DeleteToken(configDir)
|
return withProfilesLock(configDir, func() error {
|
||||||
|
return h.DeleteToken(configDir)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return withProfilesLock(configDir, func() error {
|
return withProfilesLock(configDir, func() error {
|
||||||
var firstErr error
|
var firstErr error
|
||||||
|
|||||||
@@ -377,9 +377,11 @@ func TestCrossPlatformCoverageDaemonMethodEdges(t *testing.T) {
|
|||||||
d.conns.Store("not-a-conn", struct{}{})
|
d.conns.Store("not-a-conn", struct{}{})
|
||||||
d.conns.Store(&queryConn{}, struct{}{})
|
d.conns.Store(&queryConn{}, struct{}{})
|
||||||
d.consumerWG.Add(1)
|
d.consumerWG.Add(1)
|
||||||
d.shutdown()
|
acceptDone := make(chan struct{})
|
||||||
|
close(acceptDone)
|
||||||
|
d.shutdown(acceptDone)
|
||||||
d.consumerWG.Done()
|
d.consumerWG.Done()
|
||||||
d.shutdown()
|
d.shutdown(acceptDone)
|
||||||
|
|
||||||
pr, pw, err := os.Pipe()
|
pr, pw, err := os.Pipe()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -246,10 +246,11 @@ func Run(ctx context.Context, cfg Config) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 7. Graceful shutdown — cancel runCtx first so all background
|
// 7. Graceful shutdown — cancel runCtx first so all background
|
||||||
// goroutines wake up, then close listener / drain consumers.
|
// goroutines wake up, then stop accepting connections before draining
|
||||||
|
// consumers. The accept-loop barrier is required before WaitGroup.Wait:
|
||||||
|
// sync.WaitGroup forbids a positive Add racing with Wait.
|
||||||
cancelRun()
|
cancelRun()
|
||||||
d.shutdown()
|
d.shutdown(acceptDone)
|
||||||
<-acceptDone
|
|
||||||
<-idleDone
|
<-idleDone
|
||||||
<-dropWarnDone
|
<-dropWarnDone
|
||||||
|
|
||||||
@@ -291,6 +292,10 @@ func (d *daemon) acceptLoop(ctx context.Context) {
|
|||||||
d.log.Warn("bus: accept error", "err", err)
|
d.log.Warn("bus: accept error", "err", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// Track the connection before publishing the handler goroutine. Once
|
||||||
|
// acceptLoop returns, shutdown can therefore close every accepted
|
||||||
|
// connection before waiting for handlers to drain.
|
||||||
|
d.conns.Store(conn, struct{}{})
|
||||||
d.consumerWG.Add(1)
|
d.consumerWG.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer d.consumerWG.Done()
|
defer d.consumerWG.Done()
|
||||||
@@ -303,7 +308,6 @@ func (d *daemon) acceptLoop(ctx context.Context) {
|
|||||||
// Hello → register with Hub → spawn writer goroutine → read until EOF/Bye.
|
// Hello → register with Hub → spawn writer goroutine → read until EOF/Bye.
|
||||||
// Always Unregisters and Closes on exit (plan invariant #5).
|
// Always Unregisters and Closes on exit (plan invariant #5).
|
||||||
func (d *daemon) handleConnection(ctx context.Context, conn net.Conn) {
|
func (d *daemon) handleConnection(ctx context.Context, conn net.Conn) {
|
||||||
d.conns.Store(conn, struct{}{})
|
|
||||||
defer func() {
|
defer func() {
|
||||||
d.conns.Delete(conn)
|
d.conns.Delete(conn)
|
||||||
conn.Close()
|
conn.Close()
|
||||||
@@ -478,9 +482,10 @@ func (d *daemon) triggerShutdown(reason string) {
|
|||||||
// 1. mark shuttingDown so acceptLoop exits cleanly
|
// 1. mark shuttingDown so acceptLoop exits cleanly
|
||||||
// 2. broadcast Bye to all consumers
|
// 2. broadcast Bye to all consumers
|
||||||
// 3. close listener (interrupts pending Accept)
|
// 3. close listener (interrupts pending Accept)
|
||||||
// 4. wait for all per-connection goroutines to drain
|
// 4. wait for acceptLoop to return so no future consumerWG.Add can occur
|
||||||
// 5. lock + meta cleanup via Run's defers
|
// 5. close all accepted connections and wait for handlers to drain
|
||||||
func (d *daemon) shutdown() {
|
// 6. lock + meta cleanup via Run's defers
|
||||||
|
func (d *daemon) shutdown(acceptDone <-chan struct{}) {
|
||||||
d.shutdownMu.Lock()
|
d.shutdownMu.Lock()
|
||||||
defer d.shutdownMu.Unlock()
|
defer d.shutdownMu.Unlock()
|
||||||
if !d.shuttingDown.CompareAndSwap(false, true) {
|
if !d.shuttingDown.CompareAndSwap(false, true) {
|
||||||
@@ -488,6 +493,7 @@ func (d *daemon) shutdown() {
|
|||||||
}
|
}
|
||||||
d.hub.Broadcast(transport.Bye{Type: transport.FrameTypeBye, Reason: "shutdown"})
|
d.hub.Broadcast(transport.Bye{Type: transport.FrameTypeBye, Reason: "shutdown"})
|
||||||
_ = d.listener.Close()
|
_ = d.listener.Close()
|
||||||
|
<-acceptDone
|
||||||
// Force-close all open IPC connections so any reader goroutine blocked
|
// Force-close all open IPC connections so any reader goroutine blocked
|
||||||
// on Read() returns with a network error and exits cleanly. Without
|
// on Read() returns with a network error and exits cleanly. Without
|
||||||
// this the consumerWG never drains and Run hangs forever.
|
// this the consumerWG never drains and Run hangs forever.
|
||||||
|
|||||||
@@ -63,9 +63,10 @@ func (i Identity) Key() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
BaseURL string
|
BaseURL string
|
||||||
HTTPClient *http.Client
|
HTTPClient *http.Client
|
||||||
Identity Identity
|
Identity Identity
|
||||||
|
AccessTokenProvider func(context.Context) (string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateSubscriptionRequest struct {
|
type CreateSubscriptionRequest struct {
|
||||||
@@ -337,8 +338,9 @@ func (c *Client) do(ctx context.Context, method, path string, q url.Values, body
|
|||||||
if c == nil {
|
if c == nil {
|
||||||
return errors.New("personal event: nil client")
|
return errors.New("personal event: nil client")
|
||||||
}
|
}
|
||||||
if c.Identity.AccessToken == "" {
|
accessToken, err := c.resolveAccessToken(ctx)
|
||||||
return errors.New("personal event: access token is required")
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
u := strings.TrimRight(c.BaseURL, "/") + path
|
u := strings.TrimRight(c.BaseURL, "/") + path
|
||||||
if len(q) > 0 {
|
if len(q) > 0 {
|
||||||
@@ -358,7 +360,7 @@ func (c *Client) do(ctx context.Context, method, path string, q url.Values, body
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("personal event: create request: %w", err)
|
return fmt.Errorf("personal event: create request: %w", err)
|
||||||
}
|
}
|
||||||
c.decorate(req)
|
c.decorate(req, accessToken)
|
||||||
if body != nil {
|
if body != nil {
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
}
|
}
|
||||||
@@ -424,9 +426,26 @@ func (c *Client) do(ctx context.Context, method, path string, q url.Values, body
|
|||||||
return json.Unmarshal(data, out)
|
return json.Unmarshal(data, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) decorate(req *http.Request) {
|
func (c *Client) resolveAccessToken(ctx context.Context) (string, error) {
|
||||||
req.Header.Set("Authorization", "Bearer "+c.Identity.AccessToken)
|
if c.AccessTokenProvider != nil {
|
||||||
req.Header.Set("x-user-access-token", c.Identity.AccessToken)
|
token, err := c.AccessTokenProvider(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("personal event: resolve access token: %w", err)
|
||||||
|
}
|
||||||
|
if token = strings.TrimSpace(token); token != "" {
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
return "", errors.New("personal event: access token provider returned empty token")
|
||||||
|
}
|
||||||
|
if token := strings.TrimSpace(c.Identity.AccessToken); token != "" {
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
return "", errors.New("personal event: access token is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) decorate(req *http.Request, accessToken string) {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||||
|
req.Header.Set("x-user-access-token", accessToken)
|
||||||
req.Header.Set("X-DWS-Client-Id", c.Identity.ClientID)
|
req.Header.Set("X-DWS-Client-Id", c.Identity.ClientID)
|
||||||
req.Header.Set("X-DWS-Source-Id", c.Identity.SourceID)
|
req.Header.Set("X-DWS-Source-Id", c.Identity.SourceID)
|
||||||
if c.Identity.CorpID != "" {
|
if c.Identity.CorpID != "" {
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package personal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type accessTokenRoundTripper func(*http.Request) (*http.Response, error)
|
||||||
|
|
||||||
|
func (f accessTokenRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
return f(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageClientResolvesAccessTokenPerRequest(t *testing.T) {
|
||||||
|
tokens := []string{"token-a", "token-b"}
|
||||||
|
calls := 0
|
||||||
|
client := NewClient("https://control.test", Identity{AccessToken: "stale", ClientID: "client", SourceID: "source"})
|
||||||
|
client.AccessTokenProvider = func(context.Context) (string, error) {
|
||||||
|
token := tokens[calls]
|
||||||
|
calls++
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
client.HTTPClient = &http.Client{Transport: accessTokenRoundTripper(func(req *http.Request) (*http.Response, error) {
|
||||||
|
want := tokens[calls-1]
|
||||||
|
if got := req.Header.Get("Authorization"); got != "Bearer "+want {
|
||||||
|
t.Fatalf("Authorization = %q, want Bearer %s", got, want)
|
||||||
|
}
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"success":true,"result":{"items":[]}}`)), Header: make(http.Header)}, nil
|
||||||
|
})}
|
||||||
|
for range 2 {
|
||||||
|
if _, err := client.ListSubscriptions(context.Background(), ListOptions{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if calls != 2 {
|
||||||
|
t.Fatalf("provider calls = %d, want 2", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageClientDoesNotFallBackAfterProviderFailure(t *testing.T) {
|
||||||
|
want := errors.New("keychain failed")
|
||||||
|
client := NewClient("https://control.test", Identity{AccessToken: "stale", ClientID: "client", SourceID: "source"})
|
||||||
|
client.AccessTokenProvider = func(context.Context) (string, error) { return "", want }
|
||||||
|
client.HTTPClient = &http.Client{Transport: accessTokenRoundTripper(func(*http.Request) (*http.Response, error) {
|
||||||
|
t.Fatal("HTTP must not run after token provider failure")
|
||||||
|
return nil, nil
|
||||||
|
})}
|
||||||
|
_, err := client.ListSubscriptions(context.Background(), ListOptions{})
|
||||||
|
if !errors.Is(err, want) {
|
||||||
|
t.Fatalf("error = %v, want %v", err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,19 +41,22 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type PersonalConfig struct {
|
type PersonalConfig struct {
|
||||||
AccessToken string
|
AccessToken string
|
||||||
ClientID string
|
AccessTokenProvider AccessTokenProvider
|
||||||
ClientSecret string
|
ClientID string
|
||||||
SourceID string
|
ClientSecret string
|
||||||
TicketURL string
|
SourceID string
|
||||||
TicketMode string
|
TicketURL string
|
||||||
HTTPClient *http.Client
|
TicketMode string
|
||||||
WebSocketDialer *websocket.Dialer
|
HTTPClient *http.Client
|
||||||
Now func() time.Time
|
WebSocketDialer *websocket.Dialer
|
||||||
ReconnectMin time.Duration
|
Now func() time.Time
|
||||||
ReconnectMax time.Duration
|
ReconnectMin time.Duration
|
||||||
|
ReconnectMax time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AccessTokenProvider func(context.Context) (string, error)
|
||||||
|
|
||||||
type PersonalSource struct {
|
type PersonalSource struct {
|
||||||
cfg PersonalConfig
|
cfg PersonalConfig
|
||||||
machine *Machine
|
machine *Machine
|
||||||
@@ -73,8 +76,8 @@ type ticketResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewPersonal(cfg PersonalConfig) (*PersonalSource, error) {
|
func NewPersonal(cfg PersonalConfig) (*PersonalSource, error) {
|
||||||
if strings.TrimSpace(cfg.AccessToken) == "" {
|
if cfg.AccessTokenProvider == nil && strings.TrimSpace(cfg.AccessToken) == "" {
|
||||||
return nil, errors.New("personal source: AccessToken is required")
|
return nil, errors.New("personal source: AccessToken or AccessTokenProvider is required")
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(cfg.ClientID) == "" {
|
if strings.TrimSpace(cfg.ClientID) == "" {
|
||||||
return nil, errors.New("personal source: ClientID is required")
|
return nil, errors.New("personal source: ClientID is required")
|
||||||
@@ -192,6 +195,10 @@ func (s *PersonalSource) runAttempt(ctx context.Context, emit dwsevent.EmitFn) (
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *PersonalSource) fetchTicket(ctx context.Context) (*ticketResponse, error) {
|
func (s *PersonalSource) fetchTicket(ctx context.Context) (*ticketResponse, error) {
|
||||||
|
accessToken, err := resolveSourceAccessToken(ctx, s.cfg.AccessTokenProvider, s.cfg.AccessToken, "personal source")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
body := map[string]any{
|
body := map[string]any{
|
||||||
"sourceId": s.cfg.SourceID,
|
"sourceId": s.cfg.SourceID,
|
||||||
"mode": s.cfg.TicketMode,
|
"mode": s.cfg.TicketMode,
|
||||||
@@ -207,8 +214,8 @@ func (s *PersonalSource) fetchTicket(ctx context.Context) (*ticketResponse, erro
|
|||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
req.Header.Set("Accept", "application/json")
|
req.Header.Set("Accept", "application/json")
|
||||||
req.Header.Set("x-user-access-token", s.cfg.AccessToken)
|
req.Header.Set("x-user-access-token", accessToken)
|
||||||
req.Header.Set("Authorization", "Bearer "+s.cfg.AccessToken)
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||||
req.Header.Set("X-DWS-Client-Id", s.cfg.ClientID)
|
req.Header.Set("X-DWS-Client-Id", s.cfg.ClientID)
|
||||||
req.Header.Set("X-DWS-Source-Id", s.cfg.SourceID)
|
req.Header.Set("X-DWS-Source-Id", s.cfg.SourceID)
|
||||||
|
|
||||||
@@ -238,6 +245,23 @@ func (s *PersonalSource) fetchTicket(ctx context.Context) (*ticketResponse, erro
|
|||||||
return ticket, nil
|
return ticket, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func resolveSourceAccessToken(ctx context.Context, provider AccessTokenProvider, fallback, component string) (string, error) {
|
||||||
|
if provider != nil {
|
||||||
|
token, err := provider(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("%s: resolve access token: %w", component, err)
|
||||||
|
}
|
||||||
|
if token = strings.TrimSpace(token); token != "" {
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("%s: access token provider returned empty token", component)
|
||||||
|
}
|
||||||
|
if token := strings.TrimSpace(fallback); token != "" {
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("%s: access token is required", component)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *PersonalSource) handleFrame(conn *websocket.Conn, data []byte, emit dwsevent.EmitFn) error {
|
func (s *PersonalSource) handleFrame(conn *websocket.Conn, data []byte, emit dwsevent.EmitFn) error {
|
||||||
df, err := payload.DecodeDataFrame(data)
|
df, err := payload.DecodeDataFrame(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -39,14 +39,15 @@ const (
|
|||||||
// normal mode uses portal-side managed credentials; custom mode asks portal to
|
// normal mode uses portal-side managed credentials; custom mode asks portal to
|
||||||
// open the user connection with the caller-provided clientId/clientSecret.
|
// open the user connection with the caller-provided clientId/clientSecret.
|
||||||
type PortalTicketConfig struct {
|
type PortalTicketConfig struct {
|
||||||
TicketURL string
|
TicketURL string
|
||||||
AccessToken string
|
AccessToken string
|
||||||
SourceID string
|
AccessTokenProvider AccessTokenProvider
|
||||||
Mode string
|
SourceID string
|
||||||
ClientID string
|
Mode string
|
||||||
ClientSecret string
|
ClientID string
|
||||||
UserAgent string
|
ClientSecret string
|
||||||
HTTPClient *http.Client
|
UserAgent string
|
||||||
|
HTTPClient *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
var portalWriteMessage = func(conn *websocket.Conn, messageType int, data []byte) error {
|
var portalWriteMessage = func(conn *websocket.Conn, messageType int, data []byte) error {
|
||||||
@@ -60,8 +61,8 @@ func (c *PortalTicketConfig) Valid() error {
|
|||||||
if strings.TrimSpace(c.TicketURL) == "" {
|
if strings.TrimSpace(c.TicketURL) == "" {
|
||||||
return errors.New("source: portal ticket URL is required")
|
return errors.New("source: portal ticket URL is required")
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(c.AccessToken) == "" {
|
if c.AccessTokenProvider == nil && strings.TrimSpace(c.AccessToken) == "" {
|
||||||
return errors.New("source: portal access token is required")
|
return errors.New("source: portal access token or provider is required")
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(c.SourceID) == "" {
|
if strings.TrimSpace(c.SourceID) == "" {
|
||||||
return errors.New("source: portal sourceId is required")
|
return errors.New("source: portal sourceId is required")
|
||||||
@@ -161,6 +162,10 @@ type portalStreamTicket struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func requestPortalTicket(ctx context.Context, cfg *PortalTicketConfig) (portalStreamTicket, error) {
|
func requestPortalTicket(ctx context.Context, cfg *PortalTicketConfig) (portalStreamTicket, error) {
|
||||||
|
accessToken, err := resolveSourceAccessToken(ctx, cfg.AccessTokenProvider, cfg.AccessToken, "source: portal ticket")
|
||||||
|
if err != nil {
|
||||||
|
return portalStreamTicket{}, err
|
||||||
|
}
|
||||||
httpClient := cfg.HTTPClient
|
httpClient := cfg.HTTPClient
|
||||||
if httpClient == nil {
|
if httpClient == nil {
|
||||||
httpClient = &http.Client{Timeout: 20 * time.Second}
|
httpClient = &http.Client{Timeout: 20 * time.Second}
|
||||||
@@ -184,7 +189,7 @@ func requestPortalTicket(ctx context.Context, cfg *PortalTicketConfig) (portalSt
|
|||||||
if ua := strings.TrimSpace(cfg.UserAgent); ua != "" {
|
if ua := strings.TrimSpace(cfg.UserAgent); ua != "" {
|
||||||
req.Header.Set("User-Agent", ua)
|
req.Header.Set("User-Agent", ua)
|
||||||
}
|
}
|
||||||
req.Header.Set("x-user-access-token", cfg.AccessToken)
|
req.Header.Set("x-user-access-token", accessToken)
|
||||||
|
|
||||||
resp, err := httpClient.Do(req)
|
resp, err := httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package source
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type tokenProviderRoundTripper func(*http.Request) (*http.Response, error)
|
||||||
|
|
||||||
|
func (f tokenProviderRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
return f(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoveragePersonalSourceResolvesTokenForEveryTicketRequest(t *testing.T) {
|
||||||
|
tokens := []string{"token-a", "token-b"}
|
||||||
|
calls := 0
|
||||||
|
source, err := NewPersonal(PersonalConfig{
|
||||||
|
AccessTokenProvider: func(context.Context) (string, error) {
|
||||||
|
token := tokens[calls]
|
||||||
|
calls++
|
||||||
|
return token, nil
|
||||||
|
},
|
||||||
|
ClientID: "client",
|
||||||
|
SourceID: "source",
|
||||||
|
TicketURL: "https://ticket.test",
|
||||||
|
HTTPClient: &http.Client{Transport: tokenProviderRoundTripper(func(req *http.Request) (*http.Response, error) {
|
||||||
|
want := tokens[calls-1]
|
||||||
|
if got := req.Header.Get("x-user-access-token"); got != want {
|
||||||
|
t.Fatalf("token header = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"endpoint":"wss://stream.test","ticket":"ticket"}`)), Header: make(http.Header)}, nil
|
||||||
|
})},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for range 2 {
|
||||||
|
if _, err := source.fetchTicket(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if calls != 2 {
|
||||||
|
t.Fatalf("provider calls = %d, want 2", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossPlatformCoveragePortalTicketProviderFailureStopsBeforeHTTP(t *testing.T) {
|
||||||
|
want := errors.New("token store failed")
|
||||||
|
httpCalled := false
|
||||||
|
_, err := requestPortalTicket(context.Background(), &PortalTicketConfig{
|
||||||
|
TicketURL: "https://ticket.test",
|
||||||
|
AccessTokenProvider: func(context.Context) (string, error) { return "", want },
|
||||||
|
SourceID: "source",
|
||||||
|
HTTPClient: &http.Client{Transport: tokenProviderRoundTripper(func(*http.Request) (*http.Response, error) {
|
||||||
|
httpCalled = true
|
||||||
|
return nil, errors.New("unexpected HTTP")
|
||||||
|
})},
|
||||||
|
})
|
||||||
|
if !errors.Is(err, want) || httpCalled {
|
||||||
|
t.Fatalf("request error = %v, httpCalled=%v", err, httpCalled)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,11 @@ const (
|
|||||||
// parallel. When empty, the platform default applies.
|
// parallel. When empty, the platform default applies.
|
||||||
StorageDirEnv = "DWS_KEYCHAIN_DIR"
|
StorageDirEnv = "DWS_KEYCHAIN_DIR"
|
||||||
|
|
||||||
|
// TestNamespaceEnv isolates the Windows HKCU registry backend for tests.
|
||||||
|
// Production code must not set it. Other platforms already isolate secure
|
||||||
|
// storage through StorageDirEnv and ignore this value.
|
||||||
|
TestNamespaceEnv = "DWS_KEYCHAIN_TEST_NAMESPACE"
|
||||||
|
|
||||||
// DisableKeychainEnv opts the macOS implementation out of system
|
// DisableKeychainEnv opts the macOS implementation out of system
|
||||||
// Keychain access for the DEK, falling back to a file-based DEK
|
// Keychain access for the DEK, falling back to a file-based DEK
|
||||||
// (same scheme as Linux). Intended for sandboxed runtimes where
|
// (same scheme as Linux). Intended for sandboxed runtimes where
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
package keychain
|
package keychain
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -25,8 +26,15 @@ func TestMain(m *testing.M) {
|
|||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
_ = os.Setenv(StorageDirEnv, dir)
|
_ = os.Setenv(StorageDirEnv, dir)
|
||||||
|
_ = os.Setenv(TestNamespaceEnv, dir)
|
||||||
_ = os.Setenv(DisableKeychainEnv, "1")
|
_ = os.Setenv(DisableKeychainEnv, "1")
|
||||||
code := m.Run()
|
code := m.Run()
|
||||||
|
if err := RemoveAuthTokenEntries(Service); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "internal/keychain test cleanup: %v\n", err)
|
||||||
|
if code == 0 {
|
||||||
|
code = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
_ = os.RemoveAll(dir)
|
_ = os.RemoveAll(dir)
|
||||||
os.Exit(code)
|
os.Exit(code)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
package keychain
|
package keychain
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -47,9 +48,8 @@ const regRootPath = `Software\DwsCli\keychain`
|
|||||||
// The Windows keychain backend keeps secrets in DPAPI-protected HKCU registry
|
// The Windows keychain backend keeps secrets in DPAPI-protected HKCU registry
|
||||||
// values rather than on disk, so this path is used only by the portable
|
// values rather than on disk, so this path is used only by the portable
|
||||||
// auth-bundle export/import (internal/auth) to colocate config. When the
|
// auth-bundle export/import (internal/auth) to colocate config. When the
|
||||||
// DWS_KEYCHAIN_DIR environment variable is set (used by tests for isolation),
|
// DWS_KEYCHAIN_DIR environment variable is set, the storage root is taken from
|
||||||
// the storage root is taken from that env var instead; otherwise it defaults
|
// that env var; otherwise it defaults to %LocalAppData%\<service>.
|
||||||
// to %LocalAppData%\<service>.
|
|
||||||
func StorageDir(service string) string {
|
func StorageDir(service string) string {
|
||||||
if override := os.Getenv(StorageDirEnv); override != "" {
|
if override := os.Getenv(StorageDirEnv); override != "" {
|
||||||
return filepath.Join(override, service)
|
return filepath.Join(override, service)
|
||||||
@@ -66,7 +66,22 @@ func StorageDir(service string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func registryPathForService(service string) string {
|
func registryPathForService(service string) string {
|
||||||
return regRootPath + `\` + safeRegistryComponent(service)
|
path := regRootPath + `\` + safeRegistryComponent(service)
|
||||||
|
namespace := strings.TrimSpace(os.Getenv(TestNamespaceEnv))
|
||||||
|
if namespace == "" {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// Windows stores credentials in HKCU instead of DWS_KEYCHAIN_DIR. Tests set
|
||||||
|
// an explicit process namespace so concurrent package binaries cannot
|
||||||
|
// delete each other's credentials. Hash it to avoid leaking temp paths or
|
||||||
|
// introducing registry separators.
|
||||||
|
namespace = filepath.Clean(namespace)
|
||||||
|
if absolute, err := filepath.Abs(namespace); err == nil {
|
||||||
|
namespace = absolute
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256([]byte(strings.ToLower(namespace)))
|
||||||
|
return fmt.Sprintf(`%s\test-%x`, path, sum[:16])
|
||||||
}
|
}
|
||||||
|
|
||||||
var safeRegRe = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
|
var safeRegRe = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
|
||||||
|
|||||||
@@ -17,12 +17,37 @@ package keychain
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"golang.org/x/sys/windows"
|
"golang.org/x/sys/windows"
|
||||||
"golang.org/x/sys/windows/registry"
|
"golang.org/x/sys/windows/registry"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestCrossPlatformCoverageRegistryPathForServiceHonorsTestNamespace(t *testing.T) {
|
||||||
|
t.Setenv(TestNamespaceEnv, "")
|
||||||
|
defaultPath := registryPathForService("service")
|
||||||
|
if defaultPath != regRootPath+`\service` {
|
||||||
|
t.Fatalf("default registry path = %q, want historical path %q", defaultPath, regRootPath+`\service`)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv(TestNamespaceEnv, t.TempDir())
|
||||||
|
firstPath := registryPathForService("service")
|
||||||
|
|
||||||
|
t.Setenv(TestNamespaceEnv, t.TempDir())
|
||||||
|
secondPath := registryPathForService("service")
|
||||||
|
|
||||||
|
if firstPath == defaultPath || secondPath == defaultPath {
|
||||||
|
t.Fatalf("isolated registry paths = %q, %q; want paths distinct from %q", firstPath, secondPath, defaultPath)
|
||||||
|
}
|
||||||
|
if firstPath == secondPath {
|
||||||
|
t.Fatalf("isolated registry paths collide: %q", firstPath)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(firstPath, defaultPath+`\test-`) {
|
||||||
|
t.Fatalf("isolated registry path = %q, want prefix %q", firstPath, defaultPath+`\test-`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDeleteRegistryValuePropagatesFailure(t *testing.T) {
|
func TestDeleteRegistryValuePropagatesFailure(t *testing.T) {
|
||||||
originalDelete := registryDeleteValue
|
originalDelete := registryDeleteValue
|
||||||
failure := errors.New("delete failed")
|
failure := errors.New("delete failed")
|
||||||
|
|||||||
Executable
+339
@@ -0,0 +1,339 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
# Validate CHANGELOG.md changes against the release contract.
|
||||||
|
#
|
||||||
|
# --fast-path additionally requires the complete PR diff to be exactly one
|
||||||
|
# in-place CHANGELOG.md modification. --content-only validates CHANGELOG.md
|
||||||
|
# while allowing other files to change in the same PR.
|
||||||
|
|
||||||
|
ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
printf '%s\n' "usage: $0 (--fast-path|--content-only) BASE HEAD" >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "$#" -ne 3 ]; then
|
||||||
|
usage
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
--fast-path)
|
||||||
|
VALIDATION_MODE="fast-path"
|
||||||
|
;;
|
||||||
|
--content-only)
|
||||||
|
VALIDATION_MODE="content-only"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
usage
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
BASE_REF="$2"
|
||||||
|
HEAD_REF="$3"
|
||||||
|
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
BASE_COMMIT="$(git rev-parse --verify --quiet "${BASE_REF}^{commit}")" || {
|
||||||
|
printf 'error: CHANGELOG base ref is not an available commit: %s\n' "$BASE_REF" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
HEAD_COMMIT="$(git rev-parse --verify --quiet "${HEAD_REF}^{commit}")" || {
|
||||||
|
printf 'error: CHANGELOG head ref is not an available commit: %s\n' "$HEAD_REF" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
TMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/dws-changelog-pr.XXXXXX")"
|
||||||
|
trap 'rm -rf "$TMP_ROOT"' EXIT HUP INT TERM
|
||||||
|
|
||||||
|
if ! git merge-base --all "$BASE_COMMIT" "$HEAD_COMMIT" >"$TMP_ROOT/merge-bases"; then
|
||||||
|
printf 'error: CHANGELOG base and head commits have no merge base\n' >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
merge_base_count="$(wc -l <"$TMP_ROOT/merge-bases" | tr -d '[:space:]')"
|
||||||
|
if [ "$merge_base_count" -ne 1 ]; then
|
||||||
|
printf 'error: expected exactly one merge base, found %s\n' "$merge_base_count" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
IFS= read -r MERGE_BASE <"$TMP_ROOT/merge-bases"
|
||||||
|
|
||||||
|
require_regular_changelog() {
|
||||||
|
_rrc_label="$1"
|
||||||
|
_rrc_commit="$2"
|
||||||
|
if ! git ls-tree "$_rrc_commit" -- CHANGELOG.md |
|
||||||
|
awk '
|
||||||
|
NR == 1 &&
|
||||||
|
$1 == "100644" &&
|
||||||
|
$2 == "blob" &&
|
||||||
|
$4 == "CHANGELOG.md" {
|
||||||
|
valid = 1
|
||||||
|
}
|
||||||
|
END {
|
||||||
|
exit !(NR == 1 && valid)
|
||||||
|
}
|
||||||
|
'; then
|
||||||
|
printf 'error: CHANGELOG.md must be a regular 100644 blob at %s\n' \
|
||||||
|
"$_rrc_label" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# The merge base is the effective base of the PR diff. Also validate the
|
||||||
|
# caller-provided base commit so a stale or malformed target ref fails closed.
|
||||||
|
require_regular_changelog "base" "$BASE_COMMIT"
|
||||||
|
if [ "$MERGE_BASE" != "$BASE_COMMIT" ]; then
|
||||||
|
require_regular_changelog "merge base" "$MERGE_BASE"
|
||||||
|
fi
|
||||||
|
require_regular_changelog "head" "$HEAD_COMMIT"
|
||||||
|
|
||||||
|
git diff --no-ext-diff --find-renames --name-status \
|
||||||
|
"$MERGE_BASE" "$HEAD_COMMIT" >"$TMP_ROOT/name-status"
|
||||||
|
case "$VALIDATION_MODE" in
|
||||||
|
fast-path)
|
||||||
|
printf 'M\tCHANGELOG.md\n' >"$TMP_ROOT/expected-name-status"
|
||||||
|
if ! cmp -s "$TMP_ROOT/expected-name-status" "$TMP_ROOT/name-status"; then
|
||||||
|
printf '%s\n' 'error: fast path requires exactly one in-place modification: CHANGELOG.md' >&2
|
||||||
|
if [ -s "$TMP_ROOT/name-status" ]; then
|
||||||
|
sed 's/^/ /' "$TMP_ROOT/name-status" >&2
|
||||||
|
else
|
||||||
|
printf '%s\n' ' (no changed files)' >&2
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
content-only)
|
||||||
|
if ! awk -F ' ' '
|
||||||
|
$1 == "M" && $2 == "CHANGELOG.md" && NF == 2 {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
END {
|
||||||
|
exit count != 1
|
||||||
|
}
|
||||||
|
' "$TMP_ROOT/name-status"; then
|
||||||
|
printf '%s\n' 'error: content-only validation requires an in-place modification: CHANGELOG.md' >&2
|
||||||
|
if [ -s "$TMP_ROOT/name-status" ]; then
|
||||||
|
sed 's/^/ /' "$TMP_ROOT/name-status" >&2
|
||||||
|
else
|
||||||
|
printf '%s\n' ' (no changed files)' >&2
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if ! git diff --no-ext-diff --check "$MERGE_BASE" "$HEAD_COMMIT" -- CHANGELOG.md; then
|
||||||
|
printf '%s\n' 'error: CHANGELOG diff contains whitespace errors' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
"$ROOT/scripts/policy/open-source-audit.sh"
|
||||||
|
|
||||||
|
if ! git show "${MERGE_BASE}:CHANGELOG.md" >"$TMP_ROOT/base-changelog" ||
|
||||||
|
! git show "${HEAD_COMMIT}:CHANGELOG.md" >"$TMP_ROOT/head-changelog"; then
|
||||||
|
printf '%s\n' 'error: could not read CHANGELOG.md from both commits' >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
git diff --no-ext-diff --unified=0 \
|
||||||
|
"$MERGE_BASE" "$HEAD_COMMIT" -- CHANGELOG.md >"$TMP_ROOT/changelog.patch"
|
||||||
|
if grep -q '^Binary files ' "$TMP_ROOT/changelog.patch"; then
|
||||||
|
printf '%s\n' 'error: CHANGELOG.md must remain a text file' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! awk '
|
||||||
|
/^\+\+\+ / { next }
|
||||||
|
/^\+/ {
|
||||||
|
line = substr($0, 2)
|
||||||
|
if (line ~ /(^|[^[:alnum:]_])(TODO|TBD)([^[:alnum:]_]|$)/) {
|
||||||
|
bad = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
END { exit bad }
|
||||||
|
' "$TMP_ROOT/changelog.patch"; then
|
||||||
|
printf '%s\n' 'error: changed CHANGELOG additions must not contain TODO/TBD placeholders' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
. "$ROOT/scripts/release/release-lib.sh"
|
||||||
|
|
||||||
|
extract_labeled_section() {
|
||||||
|
_els_file="$1"
|
||||||
|
_els_wanted="$2"
|
||||||
|
awk -v wanted="$_els_wanted" '
|
||||||
|
/^## / {
|
||||||
|
active = 0
|
||||||
|
if (substr($0, 1, 4) == "## [") {
|
||||||
|
rest = substr($0, 5)
|
||||||
|
close_pos = index(rest, "]")
|
||||||
|
if (close_pos > 1 && substr(rest, 1, close_pos - 1) == wanted) {
|
||||||
|
active = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
active { print }
|
||||||
|
' "$_els_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
extract_unmanaged_content() {
|
||||||
|
_euc_file="$1"
|
||||||
|
awk '
|
||||||
|
BEGIN { active = 1 }
|
||||||
|
/^## / {
|
||||||
|
active = 1
|
||||||
|
if (substr($0, 1, 4) == "## [") {
|
||||||
|
rest = substr($0, 5)
|
||||||
|
close_pos = index(rest, "]")
|
||||||
|
if (close_pos > 1) {
|
||||||
|
active = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
active { print }
|
||||||
|
' "$_euc_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
label_heading_count() {
|
||||||
|
_lhc_file="$1"
|
||||||
|
_lhc_wanted="$2"
|
||||||
|
awk -v wanted="$_lhc_wanted" '
|
||||||
|
/^## / && substr($0, 1, 4) == "## [" {
|
||||||
|
rest = substr($0, 5)
|
||||||
|
close_pos = index(rest, "]")
|
||||||
|
if (close_pos > 1 && substr(rest, 1, close_pos - 1) == wanted) count++
|
||||||
|
}
|
||||||
|
END { print count + 0 }
|
||||||
|
' "$_lhc_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
calendar_date_is_valid() {
|
||||||
|
_cdiv_date="$1"
|
||||||
|
awk -v date="$_cdiv_date" 'BEGIN {
|
||||||
|
if (date !~ /^[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]$/) exit 1
|
||||||
|
split(date, part, "-")
|
||||||
|
year = part[1] + 0
|
||||||
|
month = part[2] + 0
|
||||||
|
day = part[3] + 0
|
||||||
|
if (year < 1 || month < 1 || month > 12 || day < 1) exit 1
|
||||||
|
days = 31
|
||||||
|
if (month == 4 || month == 6 || month == 9 || month == 11) days = 30
|
||||||
|
if (month == 2) {
|
||||||
|
days = 28
|
||||||
|
if ((year % 4 == 0 && year % 100 != 0) || year % 400 == 0) days = 29
|
||||||
|
}
|
||||||
|
exit day > days
|
||||||
|
}'
|
||||||
|
}
|
||||||
|
|
||||||
|
release_heading_date() {
|
||||||
|
_rhd_file="$1"
|
||||||
|
_rhd_version="$2"
|
||||||
|
awk -v wanted="$_rhd_version" '
|
||||||
|
BEGIN { prefix = "## [" wanted "] - " }
|
||||||
|
index($0, prefix) == 1 {
|
||||||
|
print substr($0, length(prefix) + 1)
|
||||||
|
}
|
||||||
|
' "$_rhd_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_release_notes() {
|
||||||
|
_vrn_file="$1"
|
||||||
|
set +e
|
||||||
|
awk '
|
||||||
|
NR == 1 { next }
|
||||||
|
{
|
||||||
|
if ($0 ~ /[^[:space:]]/) meaningful = 1
|
||||||
|
if ($0 ~ /^- /) bullet = 1
|
||||||
|
if ($0 ~ /(^|[^[:alnum:]_])(TODO|TBD)([^[:alnum:]_]|$)/) placeholder = 1
|
||||||
|
}
|
||||||
|
END {
|
||||||
|
if (!meaningful || !bullet) exit 43
|
||||||
|
if (placeholder) exit 44
|
||||||
|
}
|
||||||
|
' "$_vrn_file"
|
||||||
|
_vrn_status=$?
|
||||||
|
set -e
|
||||||
|
case "$_vrn_status" in
|
||||||
|
0) return 0 ;;
|
||||||
|
43) printf '%s\n' 'error: changed release section must contain notes and at least one bullet' >&2 ;;
|
||||||
|
44) printf '%s\n' 'error: changed release section still contains TODO/TBD placeholders' >&2 ;;
|
||||||
|
*) printf '%s\n' 'error: failed to validate changed release notes' >&2 ;;
|
||||||
|
esac
|
||||||
|
return "$_vrn_status"
|
||||||
|
}
|
||||||
|
|
||||||
|
extract_unmanaged_content "$TMP_ROOT/base-changelog" >"$TMP_ROOT/base-unmanaged"
|
||||||
|
extract_unmanaged_content "$TMP_ROOT/head-changelog" >"$TMP_ROOT/head-unmanaged"
|
||||||
|
if ! cmp -s "$TMP_ROOT/base-unmanaged" "$TMP_ROOT/head-unmanaged"; then
|
||||||
|
printf '%s\n' 'error: CHANGELOG validation only permits notes inside Unreleased or versioned release sections' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
unreleased_count="$(label_heading_count "$TMP_ROOT/head-changelog" Unreleased)"
|
||||||
|
unreleased_exact_count="$(awk '$0 == "## [Unreleased]" { count++ } END { print count + 0 }' \
|
||||||
|
"$TMP_ROOT/head-changelog")"
|
||||||
|
if [ "$unreleased_count" -ne 1 ] || [ "$unreleased_exact_count" -ne 1 ]; then
|
||||||
|
printf 'error: CHANGELOG must contain exactly one heading: ## [Unreleased]\n' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
extract_labeled_section "$TMP_ROOT/base-changelog" Unreleased >"$TMP_ROOT/base-unreleased"
|
||||||
|
extract_labeled_section "$TMP_ROOT/head-changelog" Unreleased >"$TMP_ROOT/head-unreleased"
|
||||||
|
unreleased_changed=0
|
||||||
|
if ! cmp -s "$TMP_ROOT/base-unreleased" "$TMP_ROOT/head-unreleased"; then
|
||||||
|
unreleased_changed=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
awk '
|
||||||
|
/^## / && substr($0, 1, 4) == "## [" {
|
||||||
|
rest = substr($0, 5)
|
||||||
|
close_pos = index(rest, "]")
|
||||||
|
if (close_pos > 1) {
|
||||||
|
label = substr(rest, 1, close_pos - 1)
|
||||||
|
if (label != "Unreleased") print label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
' "$TMP_ROOT/base-changelog" "$TMP_ROOT/head-changelog" |
|
||||||
|
LC_ALL=C sort -u >"$TMP_ROOT/release-labels"
|
||||||
|
|
||||||
|
changed_release_count=0
|
||||||
|
section_index=0
|
||||||
|
while IFS= read -r version; do
|
||||||
|
[ -n "$version" ] || continue
|
||||||
|
section_index=$((section_index + 1))
|
||||||
|
base_section="$TMP_ROOT/base-release-$section_index"
|
||||||
|
head_section="$TMP_ROOT/head-release-$section_index"
|
||||||
|
extract_labeled_section "$TMP_ROOT/base-changelog" "$version" >"$base_section"
|
||||||
|
extract_labeled_section "$TMP_ROOT/head-changelog" "$version" >"$head_section"
|
||||||
|
if cmp -s "$base_section" "$head_section"; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
changed_release_count=$((changed_release_count + 1))
|
||||||
|
if ! release_channel_for_version "v$version" >/dev/null 2>&1; then
|
||||||
|
printf 'error: changed CHANGELOG release heading has an invalid version: %s\n' "$version" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$(label_heading_count "$TMP_ROOT/head-changelog" "$version")" -ne 1 ]; then
|
||||||
|
printf 'error: CHANGELOG must contain exactly one well-formed section for %s\n' \
|
||||||
|
"$version" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! validate_release_notes "$head_section"; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
release_date="$(release_heading_date "$TMP_ROOT/head-changelog" "$version")"
|
||||||
|
if ! calendar_date_is_valid "$release_date"; then
|
||||||
|
printf 'error: CHANGELOG section for %s has an invalid calendar date: %s\n' \
|
||||||
|
"$version" "$release_date" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done <"$TMP_ROOT/release-labels"
|
||||||
|
|
||||||
|
if [ "$changed_release_count" -eq 0 ] && [ "$unreleased_changed" -eq 0 ]; then
|
||||||
|
printf '%s\n' 'error: CHANGELOG modification did not change an eligible notes section' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'CHANGELOG PR check: ok (mode=%s release_sections=%s unreleased_changed=%s)\n' \
|
||||||
|
"$VALIDATION_MODE" "$changed_release_count" "$unreleased_changed"
|
||||||
@@ -4,17 +4,17 @@ set -eu
|
|||||||
ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
|
ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
|
||||||
BASE_REF=""
|
BASE_REF=""
|
||||||
OVERALL_PROFILE="coverage.txt"
|
OVERALL_PROFILE="coverage.txt"
|
||||||
ADDITIONAL_PROFILE="${COVERAGE_ADDITIONAL_PROFILE:-}"
|
ADDITIONAL_DIFF_PROFILE="${COVERAGE_ADDITIONAL_DIFF_PROFILE:-${COVERAGE_ADDITIONAL_PROFILE:-}}"
|
||||||
BASELINE_PROFILE="coverage-base.txt"
|
BASELINE_PROFILE="coverage-base.txt"
|
||||||
DIFF_PROFILE="coverage-policy.txt"
|
DIFF_PROFILE="coverage-policy.txt"
|
||||||
TARGET="${COVERAGE_TARGET:-80}"
|
TARGET="${COVERAGE_TARGET:-100}"
|
||||||
OVERALL_TOLERANCE="${COVERAGE_OVERALL_TOLERANCE:-0.1}"
|
OVERALL_TOLERANCE="${COVERAGE_OVERALL_TOLERANCE:-0}"
|
||||||
ENFORCE_OVERALL="${COVERAGE_ENFORCE_OVERALL:-false}"
|
ENFORCE_OVERALL="${COVERAGE_ENFORCE_OVERALL:-false}"
|
||||||
CHANGED_ONLY="false"
|
CHANGED_ONLY="false"
|
||||||
SCOPE_BUILDABLE="false"
|
SCOPE_BUILDABLE="false"
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
printf '%s\n' "usage: $0 --base-ref <ref> [--changed-only] [--scope-buildable] [--overall-profile <file>] [--additional-profile <file>] [--baseline-profile <file>] [--diff-profile <file>]" >&2
|
printf '%s\n' "usage: $0 --base-ref <ref> [--changed-only] [--scope-buildable] [--overall-profile <file>] [--additional-diff-profile <file>] [--baseline-profile <file>] [--diff-profile <file>]" >&2
|
||||||
}
|
}
|
||||||
|
|
||||||
while [ "$#" -gt 0 ]; do
|
while [ "$#" -gt 0 ]; do
|
||||||
@@ -29,9 +29,9 @@ while [ "$#" -gt 0 ]; do
|
|||||||
OVERALL_PROFILE="$2"
|
OVERALL_PROFILE="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
--additional-profile)
|
--additional-diff-profile|--additional-profile)
|
||||||
[ "$#" -ge 2 ] || { usage; exit 2; }
|
[ "$#" -ge 2 ] || { usage; exit 2; }
|
||||||
ADDITIONAL_PROFILE="$2"
|
ADDITIONAL_DIFF_PROFILE="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
--baseline-profile)
|
--baseline-profile)
|
||||||
@@ -87,19 +87,12 @@ set -- "$CHECKER" \
|
|||||||
if [ "$CHANGED_ONLY" = "true" ]; then
|
if [ "$CHANGED_ONLY" = "true" ]; then
|
||||||
set -- "$@" --changed-only
|
set -- "$@" --changed-only
|
||||||
else
|
else
|
||||||
baseline="$(go tool cover -func="$BASELINE_PROFILE" | awk '/^total:/ { gsub(/%/, "", $3); print $3 }')"
|
|
||||||
[ -n "$baseline" ] || {
|
|
||||||
printf 'error: cannot parse authoritative coverage from %s\n' "$BASELINE_PROFILE" >&2
|
|
||||||
exit 2
|
|
||||||
}
|
|
||||||
set -- "$@" \
|
set -- "$@" \
|
||||||
--overall-profile "$OVERALL_PROFILE" \
|
--overall-profile "$OVERALL_PROFILE" \
|
||||||
--diff-profile "$OVERALL_PROFILE" \
|
--diff-profile "$OVERALL_PROFILE" \
|
||||||
--baseline-overall "$baseline"
|
--baseline-profile "$BASELINE_PROFILE"
|
||||||
if [ -n "$ADDITIONAL_PROFILE" ]; then
|
if [ -n "$ADDITIONAL_DIFF_PROFILE" ]; then
|
||||||
set -- "$@" \
|
set -- "$@" --diff-profile "$ADDITIONAL_DIFF_PROFILE"
|
||||||
--overall-profile "$ADDITIONAL_PROFILE" \
|
|
||||||
--diff-profile "$ADDITIONAL_PROFILE"
|
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
if [ "$SCOPE_BUILDABLE" = "true" ]; then
|
if [ "$SCOPE_BUILDABLE" = "true" ]; then
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import (
|
|||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -37,11 +36,13 @@ func (values *stringList) Set(value string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type coverageBlock struct {
|
type coverageBlock struct {
|
||||||
File string
|
File string
|
||||||
StartLine int
|
StartLine int
|
||||||
EndLine int
|
StartColumn int
|
||||||
Statements int
|
EndLine int
|
||||||
Count int
|
EndColumn int
|
||||||
|
Statements int
|
||||||
|
Count int
|
||||||
}
|
}
|
||||||
|
|
||||||
type lineRange struct {
|
type lineRange struct {
|
||||||
@@ -78,6 +79,7 @@ func run(
|
|||||||
buildableLoader func() (map[string]bool, error),
|
buildableLoader func() (map[string]bool, error),
|
||||||
) int {
|
) int {
|
||||||
var overallPaths stringList
|
var overallPaths stringList
|
||||||
|
var baselinePaths stringList
|
||||||
var diffPaths stringList
|
var diffPaths stringList
|
||||||
var baseRef string
|
var baseRef string
|
||||||
var modulePath string
|
var modulePath string
|
||||||
@@ -90,12 +92,13 @@ func run(
|
|||||||
flags := flag.NewFlagSet("coverage-gate", flag.ContinueOnError)
|
flags := flag.NewFlagSet("coverage-gate", flag.ContinueOnError)
|
||||||
flags.SetOutput(stderr)
|
flags.SetOutput(stderr)
|
||||||
flags.Var(&overallPaths, "overall-profile", "coverage profile used for overall coverage (repeatable)")
|
flags.Var(&overallPaths, "overall-profile", "coverage profile used for overall coverage (repeatable)")
|
||||||
|
flags.Var(&baselinePaths, "baseline-profile", "merge-base coverage profile evaluated with the same model as the candidate (repeatable)")
|
||||||
flags.Var(&diffPaths, "diff-profile", "coverage profile used for changed-code coverage (repeatable)")
|
flags.Var(&diffPaths, "diff-profile", "coverage profile used for changed-code coverage (repeatable)")
|
||||||
flags.StringVar(&baseRef, "base-ref", "", "Git merge-base or previous main SHA")
|
flags.StringVar(&baseRef, "base-ref", "", "Git merge-base or previous main SHA")
|
||||||
flags.StringVar(&modulePath, "module", "", "Go module path used to normalize profile filenames")
|
flags.StringVar(&modulePath, "module", "", "Go module path used to normalize profile filenames")
|
||||||
flags.Float64Var(&baselineOverall, "baseline-overall", -1, "authoritative overall coverage percentage")
|
flags.Float64Var(&baselineOverall, "baseline-overall", -1, "authoritative overall coverage percentage")
|
||||||
flags.Float64Var(&overallTolerance, "overall-tolerance", 0.1, "allowed overall coverage measurement variance in percentage points")
|
flags.Float64Var(&overallTolerance, "overall-tolerance", 0, "allowed overall coverage measurement variance in percentage points")
|
||||||
flags.Float64Var(&target, "target", 80, "required changed-code and eventual overall coverage percentage")
|
flags.Float64Var(&target, "target", 100, "required changed-code coverage percentage and optional overall floor")
|
||||||
flags.BoolVar(&enforceOverall, "enforce-overall-target", false, "require overall coverage to reach target")
|
flags.BoolVar(&enforceOverall, "enforce-overall-target", false, "require overall coverage to reach target")
|
||||||
flags.BoolVar(&changedOnly, "changed-only", false, "enforce changed-code coverage without an overall baseline")
|
flags.BoolVar(&changedOnly, "changed-only", false, "enforce changed-code coverage without an overall baseline")
|
||||||
flags.BoolVar(&scopeBuildable, "scope-buildable", false, "only evaluate changed files buildable on the current platform")
|
flags.BoolVar(&scopeBuildable, "scope-buildable", false, "only evaluate changed files buildable on the current platform")
|
||||||
@@ -103,8 +106,13 @@ func run(
|
|||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(diffPaths) == 0 || baseRef == "" || modulePath == "" || (!changedOnly && (len(overallPaths) == 0 || baselineOverall < 0)) {
|
if len(diffPaths) == 0 || baseRef == "" || modulePath == "" ||
|
||||||
fmt.Fprintln(stderr, "coverage-gate requires --diff-profile, --base-ref, and --module; overall mode also requires --overall-profile and --baseline-overall")
|
(!changedOnly && (len(overallPaths) == 0 || (len(baselinePaths) == 0 && baselineOverall < 0))) {
|
||||||
|
fmt.Fprintln(stderr, "coverage-gate requires --diff-profile, --base-ref, and --module; overall mode also requires --overall-profile and either --baseline-profile or --baseline-overall")
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
if len(baselinePaths) > 0 && baselineOverall >= 0 {
|
||||||
|
fmt.Fprintln(stderr, "coverage-gate accepts either --baseline-profile or --baseline-overall, not both")
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
var overall []coverageBlock
|
var overall []coverageBlock
|
||||||
@@ -115,6 +123,14 @@ func run(
|
|||||||
fmt.Fprintln(stderr, err)
|
fmt.Fprintln(stderr, err)
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
|
if len(baselinePaths) > 0 {
|
||||||
|
baseline, baselineErr := readProfiles(baselinePaths, modulePath)
|
||||||
|
if baselineErr != nil {
|
||||||
|
fmt.Fprintln(stderr, baselineErr)
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
baselineOverall = coveragePercent(baseline)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
diff, err := readProfiles(diffPaths, modulePath)
|
diff, err := readProfiles(diffPaths, modulePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -150,12 +166,12 @@ func run(
|
|||||||
if enforceOverall {
|
if enforceOverall {
|
||||||
mode = "required"
|
mode = "required"
|
||||||
}
|
}
|
||||||
fmt.Fprintf(stdout, "overall coverage: %.1f%% (merge-base %.1f%%; tolerance %.1fpp; target %.1f%%; %s)\n", result.Overall, baselineOverall, overallTolerance, target, mode)
|
fmt.Fprintf(stdout, "overall coverage: %.4f%% (merge-base %.4f%%; tolerance %.4fpp; target %.4f%%; %s)\n", result.Overall, baselineOverall, overallTolerance, target, mode)
|
||||||
}
|
}
|
||||||
if result.ChangedStatements == 0 {
|
if result.ChangedStatements == 0 {
|
||||||
fmt.Fprintf(stdout, "changed code coverage: n/a (no changed executable statements; target %.1f%%)\n", target)
|
fmt.Fprintf(stdout, "changed code coverage: n/a (no changed executable statements; target %.4f%%)\n", target)
|
||||||
} else {
|
} else {
|
||||||
fmt.Fprintf(stdout, "changed code coverage: %.1f%% (%d executable statements; target %.1f%%)\n", result.ChangedCoverage, result.ChangedStatements, target)
|
fmt.Fprintf(stdout, "changed code coverage: %.4f%% (%d executable statements; target %.4f%%)\n", result.ChangedCoverage, result.ChangedStatements, target)
|
||||||
}
|
}
|
||||||
if len(result.Failures) > 0 {
|
if len(result.Failures) > 0 {
|
||||||
fmt.Fprintln(stderr, "coverage gate failed:")
|
fmt.Fprintln(stderr, "coverage gate failed:")
|
||||||
@@ -171,13 +187,11 @@ func evaluate(input gateInput) gateResult {
|
|||||||
result := gateResult{Failures: []string{}}
|
result := gateResult{Failures: []string{}}
|
||||||
if !input.ChangedOnly {
|
if !input.ChangedOnly {
|
||||||
result.Overall = coveragePercent(input.Overall)
|
result.Overall = coveragePercent(input.Overall)
|
||||||
baselineRounded := roundOne(input.BaselineOverall)
|
if result.Overall+input.OverallTolerance < input.BaselineOverall {
|
||||||
overallRounded := roundOne(result.Overall)
|
result.Failures = append(result.Failures, fmt.Sprintf("overall coverage regressed from %.4f%% to %.4f%%", input.BaselineOverall, result.Overall))
|
||||||
if overallRounded+input.OverallTolerance+1e-9 < baselineRounded {
|
|
||||||
result.Failures = append(result.Failures, fmt.Sprintf("overall coverage regressed from %.1f%% to %.1f%%", baselineRounded, overallRounded))
|
|
||||||
}
|
}
|
||||||
if input.EnforceOverall && overallRounded < input.Target {
|
if input.EnforceOverall && result.Overall < input.Target {
|
||||||
result.Failures = append(result.Failures, fmt.Sprintf("overall coverage %.1f%% is below target %.1f%%", overallRounded, input.Target))
|
result.Failures = append(result.Failures, fmt.Sprintf("overall coverage %.4f%% is below target %.4f%%", result.Overall, input.Target))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,8 +220,8 @@ func evaluate(input gateInput) gateResult {
|
|||||||
result.ChangedStatements = total
|
result.ChangedStatements = total
|
||||||
if total > 0 {
|
if total > 0 {
|
||||||
result.ChangedCoverage = float64(covered) * 100 / float64(total)
|
result.ChangedCoverage = float64(covered) * 100 / float64(total)
|
||||||
if result.ChangedCoverage+1e-9 < input.Target {
|
if result.ChangedCoverage < input.Target {
|
||||||
result.Failures = append(result.Failures, fmt.Sprintf("changed code coverage %.1f%% is below target %.1f%%", result.ChangedCoverage, input.Target))
|
result.Failures = append(result.Failures, fmt.Sprintf("changed code coverage %.4f%% is below target %.4f%%", result.ChangedCoverage, input.Target))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
@@ -253,11 +267,13 @@ func readProfiles(paths []string, modulePath string) ([]coverageBlock, error) {
|
|||||||
values = append(values, value)
|
values = append(values, value)
|
||||||
}
|
}
|
||||||
blocks = append(blocks, coverageBlock{
|
blocks = append(blocks, coverageBlock{
|
||||||
File: normalizeProfilePath(match[1], modulePath),
|
File: normalizeProfilePath(match[1], modulePath),
|
||||||
StartLine: values[0],
|
StartLine: values[0],
|
||||||
EndLine: values[2],
|
StartColumn: values[1],
|
||||||
Statements: values[4],
|
EndLine: values[2],
|
||||||
Count: values[5],
|
EndColumn: values[3],
|
||||||
|
Statements: values[4],
|
||||||
|
Count: values[5],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
err = scanner.Err()
|
err = scanner.Err()
|
||||||
@@ -402,7 +418,15 @@ func coveragePercent(blocks []coverageBlock) float64 {
|
|||||||
func mergeCoverageBlocks(blocks []coverageBlock) []coverageBlock {
|
func mergeCoverageBlocks(blocks []coverageBlock) []coverageBlock {
|
||||||
merged := make(map[string]coverageBlock, len(blocks))
|
merged := make(map[string]coverageBlock, len(blocks))
|
||||||
for _, block := range blocks {
|
for _, block := range blocks {
|
||||||
key := fmt.Sprintf("%s:%d:%d:%d", block.File, block.StartLine, block.EndLine, block.Statements)
|
key := fmt.Sprintf(
|
||||||
|
"%s:%d:%d:%d:%d:%d",
|
||||||
|
block.File,
|
||||||
|
block.StartLine,
|
||||||
|
block.StartColumn,
|
||||||
|
block.EndLine,
|
||||||
|
block.EndColumn,
|
||||||
|
block.Statements,
|
||||||
|
)
|
||||||
current, ok := merged[key]
|
current, ok := merged[key]
|
||||||
if !ok || block.Count > current.Count {
|
if !ok || block.Count > current.Count {
|
||||||
merged[key] = block
|
merged[key] = block
|
||||||
@@ -419,5 +443,3 @@ func mergeCoverageBlocks(blocks []coverageBlock) []coverageBlock {
|
|||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func roundOne(value float64) float64 { return math.Round(value*10) / 10 }
|
|
||||||
@@ -39,7 +39,7 @@ func TestRun(t *testing.T) {
|
|||||||
if code := run(args, &stdout, &stderr, loader, nil); code != 0 {
|
if code := run(args, &stdout, &stderr, loader, nil); code != 0 {
|
||||||
t.Fatalf("run code=%d stderr=%s", code, stderr.String())
|
t.Fatalf("run code=%d stderr=%s", code, stderr.String())
|
||||||
}
|
}
|
||||||
if !strings.Contains(stdout.String(), "overall coverage: 100.0%") || !strings.Contains(stdout.String(), "changed code coverage: 100.0%") {
|
if !strings.Contains(stdout.String(), "overall coverage: 100.0000%") || !strings.Contains(stdout.String(), "changed code coverage: 100.0000%") {
|
||||||
t.Fatalf("unexpected output %q", stdout.String())
|
t.Fatalf("unexpected output %q", stdout.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,6 +54,115 @@ func TestRun(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunDefaultsToFullChangedCodeCoverage(t *testing.T) {
|
||||||
|
profile := filepath.Join(t.TempDir(), "coverage.out")
|
||||||
|
partialBody := "mode: atomic\n" +
|
||||||
|
"example.com/project/internal/a.go:10.1,12.2 9 1\n" +
|
||||||
|
"example.com/project/internal/a.go:13.1,13.2 1 0\n"
|
||||||
|
if err := os.WriteFile(profile, []byte(partialBody), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
args := []string{
|
||||||
|
"--changed-only",
|
||||||
|
"--diff-profile", profile,
|
||||||
|
"--base-ref", "base",
|
||||||
|
"--module", "example.com/project",
|
||||||
|
}
|
||||||
|
loader := func(string) (map[string][]lineRange, error) {
|
||||||
|
return map[string][]lineRange{"internal/a.go": {{Start: 10, End: 13}}}, nil
|
||||||
|
}
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
if code := run(args, &stdout, &stderr, loader, nil); code != 1 {
|
||||||
|
t.Fatalf("run code=%d, want 1; stdout=%s stderr=%s", code, stdout.String(), stderr.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), "changed code coverage 90.0000% is below target 100.0000%") {
|
||||||
|
t.Fatalf("default target was not enforced: %q", stderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
fullBody := "mode: atomic\n" +
|
||||||
|
"example.com/project/internal/a.go:10.1,12.2 9 1\n" +
|
||||||
|
"example.com/project/internal/a.go:13.1,13.2 1 1\n"
|
||||||
|
if err := os.WriteFile(profile, []byte(fullBody), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
stdout.Reset()
|
||||||
|
stderr.Reset()
|
||||||
|
if code := run(args, &stdout, &stderr, loader, nil); code != 0 {
|
||||||
|
t.Fatalf("100%% changed coverage code=%d; stdout=%s stderr=%s", code, stdout.String(), stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunEvaluatesBaselineProfileWithCandidateCoverageModel(t *testing.T) {
|
||||||
|
profile := filepath.Join(t.TempDir(), "coverage.out")
|
||||||
|
body := "mode: atomic\n" +
|
||||||
|
"example.com/project/internal/a.go:10.1,12.2 5 0\n" +
|
||||||
|
"example.com/project/internal/a.go:10.1,12.2 5 1\n" +
|
||||||
|
"example.com/project/internal/a.go:20.1,22.2 5 0\n"
|
||||||
|
if err := os.WriteFile(profile, []byte(body), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
args := []string{
|
||||||
|
"--overall-profile", profile,
|
||||||
|
"--baseline-profile", profile,
|
||||||
|
"--diff-profile", profile,
|
||||||
|
"--base-ref", "base",
|
||||||
|
"--module", "example.com/project",
|
||||||
|
}
|
||||||
|
loader := func(string) (map[string][]lineRange, error) {
|
||||||
|
return map[string][]lineRange{}, nil
|
||||||
|
}
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
if code := run(args, &stdout, &stderr, loader, nil); code != 0 {
|
||||||
|
t.Fatalf("run code=%d stderr=%s", code, stderr.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(stdout.String(), "overall coverage: 50.0000% (merge-base 50.0000%") {
|
||||||
|
t.Fatalf("baseline and candidate did not share one coverage model: %q", stdout.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunFailsClosedWhenBaselineProfileIsMissing(t *testing.T) {
|
||||||
|
profile := filepath.Join(t.TempDir(), "coverage.out")
|
||||||
|
body := "mode: atomic\nexample.com/project/internal/a.go:10.1,12.2 5 1\n"
|
||||||
|
if err := os.WriteFile(profile, []byte(body), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
args := []string{
|
||||||
|
"--overall-profile", profile,
|
||||||
|
"--baseline-profile", filepath.Join(t.TempDir(), "missing.out"),
|
||||||
|
"--diff-profile", profile,
|
||||||
|
"--base-ref", "base",
|
||||||
|
"--module", "example.com/project",
|
||||||
|
}
|
||||||
|
loader := func(string) (map[string][]lineRange, error) {
|
||||||
|
return map[string][]lineRange{}, nil
|
||||||
|
}
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
if code := run(args, &stdout, &stderr, loader, nil); code != 2 {
|
||||||
|
t.Fatalf("missing baseline profile code=%d, want 2; stdout=%s stderr=%s", code, stdout.String(), stderr.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), "open coverage profile") {
|
||||||
|
t.Fatalf("missing baseline profile did not fail closed: %q", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunRejectsConflictingBaselineSources(t *testing.T) {
|
||||||
|
args := []string{
|
||||||
|
"--overall-profile", "candidate.out",
|
||||||
|
"--baseline-profile", "baseline.out",
|
||||||
|
"--baseline-overall", "100",
|
||||||
|
"--diff-profile", "candidate.out",
|
||||||
|
"--base-ref", "base",
|
||||||
|
"--module", "example.com/project",
|
||||||
|
}
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
if code := run(args, &stdout, &stderr, nil, nil); code != 2 {
|
||||||
|
t.Fatalf("conflicting baseline sources code=%d, want 2; stdout=%s stderr=%s", code, stdout.String(), stderr.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), "either --baseline-profile or --baseline-overall, not both") {
|
||||||
|
t.Fatalf("conflicting baseline sources were not rejected clearly: %q", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCrossPlatformCoverageRunUnionsRepeatedOverallProfiles(t *testing.T) {
|
func TestCrossPlatformCoverageRunUnionsRepeatedOverallProfiles(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
uncovered := filepath.Join(dir, "uncovered.out")
|
uncovered := filepath.Join(dir, "uncovered.out")
|
||||||
@@ -81,7 +190,7 @@ func TestCrossPlatformCoverageRunUnionsRepeatedOverallProfiles(t *testing.T) {
|
|||||||
if code := run(args, &stdout, &stderr, loader, nil); code != 0 {
|
if code := run(args, &stdout, &stderr, loader, nil); code != 0 {
|
||||||
t.Fatalf("run code=%d stderr=%s", code, stderr.String())
|
t.Fatalf("run code=%d stderr=%s", code, stderr.String())
|
||||||
}
|
}
|
||||||
if !strings.Contains(stdout.String(), "overall coverage: 100.0%") || !strings.Contains(stdout.String(), "changed code coverage: 100.0%") {
|
if !strings.Contains(stdout.String(), "overall coverage: 100.0000%") || !strings.Contains(stdout.String(), "changed code coverage: 100.0000%") {
|
||||||
t.Fatalf("repeated profiles were not unioned: %q", stdout.String())
|
t.Fatalf("repeated profiles were not unioned: %q", stdout.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -113,7 +222,7 @@ func TestRunChangedOnlyWithBuildableScope(t *testing.T) {
|
|||||||
if code := run(args, &stdout, &stderr, loader, buildableLoader); code != 0 {
|
if code := run(args, &stdout, &stderr, loader, buildableLoader); code != 0 {
|
||||||
t.Fatalf("run code=%d stderr=%s", code, stderr.String())
|
t.Fatalf("run code=%d stderr=%s", code, stderr.String())
|
||||||
}
|
}
|
||||||
if strings.Contains(stdout.String(), "overall coverage") || !strings.Contains(stdout.String(), "changed code coverage: 100.0%") {
|
if strings.Contains(stdout.String(), "overall coverage") || !strings.Contains(stdout.String(), "changed code coverage: 100.0000%") {
|
||||||
t.Fatalf("unexpected changed-only output %q", stdout.String())
|
t.Fatalf("unexpected changed-only output %q", stdout.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,7 +252,11 @@ func TestReadProfiles(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(blocks) != 1 || blocks[0].File != "internal/a.go" || blocks[0].Statements != 3 {
|
if len(blocks) != 1 ||
|
||||||
|
blocks[0].File != "internal/a.go" ||
|
||||||
|
blocks[0].StartColumn != 1 ||
|
||||||
|
blocks[0].EndColumn != 2 ||
|
||||||
|
blocks[0].Statements != 3 {
|
||||||
t.Fatalf("blocks=%v", blocks)
|
t.Fatalf("blocks=%v", blocks)
|
||||||
}
|
}
|
||||||
if _, err := readProfiles([]string{filepath.Join(t.TempDir(), "missing")}, "example.com/project"); err == nil {
|
if _, err := readProfiles([]string{filepath.Join(t.TempDir(), "missing")}, "example.com/project"); err == nil {
|
||||||
@@ -275,6 +388,41 @@ func TestCrossPlatformCoverageUnionsDuplicateCrossPackageCoverageBlocks(t *testi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCoverageKeepsDistinctBlocksOnTheSameLine(t *testing.T) {
|
||||||
|
result := evaluate(gateInput{
|
||||||
|
Diff: []coverageBlock{
|
||||||
|
{
|
||||||
|
File: "internal/a.go",
|
||||||
|
StartLine: 10,
|
||||||
|
StartColumn: 1,
|
||||||
|
EndLine: 10,
|
||||||
|
EndColumn: 5,
|
||||||
|
Statements: 1,
|
||||||
|
Count: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
File: "internal/a.go",
|
||||||
|
StartLine: 10,
|
||||||
|
StartColumn: 6,
|
||||||
|
EndLine: 10,
|
||||||
|
EndColumn: 10,
|
||||||
|
Statements: 1,
|
||||||
|
Count: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Changed: map[string][]lineRange{"internal/a.go": {{Start: 10, End: 10}}},
|
||||||
|
Target: 100,
|
||||||
|
ChangedOnly: true,
|
||||||
|
})
|
||||||
|
if result.ChangedCoverage != 50 || result.ChangedStatements != 2 {
|
||||||
|
t.Fatalf("same-line blocks were collapsed: %#v", result)
|
||||||
|
}
|
||||||
|
if len(result.Failures) != 1 ||
|
||||||
|
!strings.Contains(result.Failures[0], "changed code coverage 50.0000% is below target 100.0000%") {
|
||||||
|
t.Fatalf("same-line uncovered block did not fail the gate: %v", result.Failures)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestEvaluateAllowsMeasurementTolerance(t *testing.T) {
|
func TestEvaluateAllowsMeasurementTolerance(t *testing.T) {
|
||||||
result := evaluate(gateInput{
|
result := evaluate(gateInput{
|
||||||
Overall: []coverageBlock{{Statements: 403, Count: 1}, {Statements: 597, Count: 0}},
|
Overall: []coverageBlock{{Statements: 403, Count: 1}, {Statements: 597, Count: 0}},
|
||||||
@@ -288,6 +436,52 @@ func TestEvaluateAllowsMeasurementTolerance(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEvaluateDefaultsToZeroOverallTolerance(t *testing.T) {
|
||||||
|
result := evaluate(gateInput{
|
||||||
|
Overall: []coverageBlock{{Statements: 403, Count: 1}, {Statements: 597, Count: 0}},
|
||||||
|
Changed: map[string][]lineRange{},
|
||||||
|
BaselineOverall: 40.4,
|
||||||
|
OverallTolerance: 0,
|
||||||
|
Target: 100,
|
||||||
|
})
|
||||||
|
if len(result.Failures) != 1 ||
|
||||||
|
!strings.Contains(result.Failures[0], "overall coverage regressed from 40.4000% to 40.3000%") {
|
||||||
|
t.Fatalf("zero-tolerance regression failures = %v", result.Failures)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluateRejectsRegressionHiddenByOneDecimalRounding(t *testing.T) {
|
||||||
|
result := evaluate(gateInput{
|
||||||
|
Overall: []coverageBlock{
|
||||||
|
{Statements: 1999, Count: 1},
|
||||||
|
{Statements: 1, Count: 0},
|
||||||
|
},
|
||||||
|
Changed: map[string][]lineRange{},
|
||||||
|
BaselineOverall: 100,
|
||||||
|
Target: 100,
|
||||||
|
})
|
||||||
|
if len(result.Failures) != 1 ||
|
||||||
|
!strings.Contains(result.Failures[0], "overall coverage regressed from 100.0000% to 99.9500%") {
|
||||||
|
t.Fatalf("sub-tenth regression failures = %v", result.Failures)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluateRejectsChangedCoverageBelow100WithoutRounding(t *testing.T) {
|
||||||
|
result := evaluate(gateInput{
|
||||||
|
Diff: []coverageBlock{
|
||||||
|
{File: "internal/a.go", StartLine: 1, EndLine: 1999, Statements: 1999, Count: 1},
|
||||||
|
{File: "internal/a.go", StartLine: 2000, EndLine: 2000, Statements: 1, Count: 0},
|
||||||
|
},
|
||||||
|
Changed: map[string][]lineRange{"internal/a.go": {{Start: 1, End: 2000}}},
|
||||||
|
Target: 100,
|
||||||
|
ChangedOnly: true,
|
||||||
|
})
|
||||||
|
if len(result.Failures) != 1 ||
|
||||||
|
!strings.Contains(result.Failures[0], "changed code coverage 99.9500% is below target 100.0000%") {
|
||||||
|
t.Fatalf("sub-100 changed-code failures = %v", result.Failures)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestEvaluateFailsClosed(t *testing.T) {
|
func TestEvaluateFailsClosed(t *testing.T) {
|
||||||
result := evaluate(gateInput{
|
result := evaluate(gateInput{
|
||||||
Overall: []coverageBlock{
|
Overall: []coverageBlock{
|
||||||
|
|||||||
+87
@@ -0,0 +1,87 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||||
|
TAG="${1:-}"
|
||||||
|
DEST_DIR="${2:-}"
|
||||||
|
REPOSITORY="${GITHUB_REPOSITORY:-}"
|
||||||
|
RELEASE_ID="${DWS_GITHUB_RELEASE_ID:-}"
|
||||||
|
|
||||||
|
[ -n "$TAG" ] && [ -n "$DEST_DIR" ] && [ -n "$REPOSITORY" ] || {
|
||||||
|
printf 'usage: GITHUB_REPOSITORY=owner/repo [DWS_GITHUB_RELEASE_ID=id] download-github-release-assets.sh <tag> <destination>\n' >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
[ -n "$RELEASE_ID" ] || {
|
||||||
|
printf 'DWS_GITHUB_RELEASE_ID is required for an exact release download\n' >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
printf '%s\n' "$RELEASE_ID" | grep -Eq '^[1-9][0-9]*$' || {
|
||||||
|
printf 'invalid GitHub Release ID: %s\n' "$RELEASE_ID" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
command -v gh >/dev/null 2>&1 || { printf 'gh is required\n' >&2; exit 1; }
|
||||||
|
|
||||||
|
DWS_GITHUB_RELEASE_ID="$RELEASE_ID" \
|
||||||
|
"$SCRIPT_DIR/verify-github-release-assets.sh" "$TAG"
|
||||||
|
|
||||||
|
tmp="$(mktemp -d "${TMPDIR:-/tmp}/dws-github-release-download.XXXXXX")"
|
||||||
|
trap 'rm -rf "$tmp"' EXIT HUP INT TERM
|
||||||
|
tab="$(printf '\t')"
|
||||||
|
cat > "$tmp/expected" <<'EOF'
|
||||||
|
checksums.txt
|
||||||
|
dws-darwin-amd64.tar.gz
|
||||||
|
dws-darwin-arm64.tar.gz
|
||||||
|
dws-linux-amd64.tar.gz
|
||||||
|
dws-linux-arm64.tar.gz
|
||||||
|
dws-skills.zip
|
||||||
|
dws-windows-amd64.zip
|
||||||
|
dws-windows-arm64.zip
|
||||||
|
EOF
|
||||||
|
LC_ALL=C sort "$tmp/expected" -o "$tmp/expected"
|
||||||
|
gh api -H 'Accept: application/vnd.github+json' \
|
||||||
|
"repos/$REPOSITORY/releases/$RELEASE_ID" \
|
||||||
|
--jq '.assets[] | [.id, .name] | @tsv' > "$tmp/assets.tsv"
|
||||||
|
cut -f 2 "$tmp/assets.tsv" | LC_ALL=C sort > "$tmp/actual"
|
||||||
|
if ! diff -u "$tmp/expected" "$tmp/actual"; then
|
||||||
|
printf 'GitHub Release %s must contain exactly the supported assets\n' "$TAG" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
while IFS="$tab" read -r asset_id asset_name; do
|
||||||
|
printf '%s\n' "$asset_id" | grep -Eq '^[1-9][0-9]*$' || {
|
||||||
|
printf 'invalid GitHub Release asset ID: %s\n' "$asset_id" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
case "$asset_name" in
|
||||||
|
checksums.txt | \
|
||||||
|
dws-darwin-amd64.tar.gz | dws-darwin-arm64.tar.gz | \
|
||||||
|
dws-linux-amd64.tar.gz | dws-linux-arm64.tar.gz | \
|
||||||
|
dws-windows-amd64.zip | dws-windows-arm64.zip | \
|
||||||
|
dws-skills.zip) ;;
|
||||||
|
*)
|
||||||
|
printf 'unsupported GitHub Release asset name: %s\n' "$asset_name" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
gh api -H 'Accept: application/octet-stream' \
|
||||||
|
"repos/$REPOSITORY/releases/assets/$asset_id" > "$tmp/$asset_name"
|
||||||
|
done < "$tmp/assets.tsv"
|
||||||
|
|
||||||
|
mkdir -p "$DEST_DIR"
|
||||||
|
for asset_name in \
|
||||||
|
checksums.txt \
|
||||||
|
dws-darwin-amd64.tar.gz \
|
||||||
|
dws-darwin-arm64.tar.gz \
|
||||||
|
dws-linux-amd64.tar.gz \
|
||||||
|
dws-linux-arm64.tar.gz \
|
||||||
|
dws-windows-amd64.zip \
|
||||||
|
dws-windows-arm64.zip \
|
||||||
|
dws-skills.zip; do
|
||||||
|
[ -f "$tmp/$asset_name" ] || {
|
||||||
|
printf 'GitHub Release asset was not downloaded: %s\n' "$asset_name" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
mv -f "$tmp/$asset_name" "$DEST_DIR/$asset_name"
|
||||||
|
done
|
||||||
|
|
||||||
|
printf 'GitHub Release assets downloaded and verified: %s\n' "$TAG"
|
||||||
@@ -130,8 +130,7 @@ release_extract_changelog() {
|
|||||||
print
|
print
|
||||||
if ($0 ~ /[^[:space:]]/) meaningful = 1
|
if ($0 ~ /[^[:space:]]/) meaningful = 1
|
||||||
if ($0 ~ /^- /) bullet = 1
|
if ($0 ~ /^- /) bullet = 1
|
||||||
lowered = tolower($0)
|
if ($0 ~ /(^|[^[:alnum:]_])(TODO|TBD)([^[:alnum:]_]|$)/) placeholder = 1
|
||||||
if (lowered ~ /todo|tbd/) placeholder = 1
|
|
||||||
}
|
}
|
||||||
END {
|
END {
|
||||||
if (found != 1) exit 41
|
if (found != 1) exit 41
|
||||||
|
|||||||
@@ -134,16 +134,44 @@ require_github_publication_authority() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sealed_commit="$(git rev-parse HEAD)"
|
sealed_commit="$(git rev-parse HEAD)"
|
||||||
passed_gate="$(
|
admission_check_runs="$(
|
||||||
gh api -H 'Accept: application/vnd.github+json' \
|
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" \
|
"repos/$github_repository/commits/$sealed_commit/check-runs?filter=latest&per_page=100" \
|
||||||
--jq '[.check_runs[] | select(.name == "CI Gate" and .conclusion == "success")] | length'
|
--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
|
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
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,19 @@ if [ -n "$missing" ]; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
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 ──────────────────────────────────────────────────────────
|
# ── Resolve version ──────────────────────────────────────────────────────────
|
||||||
VERSION="${VERSION:-$(git describe --tags --always 2>/dev/null || echo dev)}"
|
VERSION="${VERSION:-$(git describe --tags --always 2>/dev/null || echo dev)}"
|
||||||
CHANNEL="${DWS_RELEASE_CHANNEL:-$(release_channel_for_version "$VERSION")}"
|
CHANNEL="${DWS_RELEASE_CHANNEL:-$(release_channel_for_version "$VERSION")}"
|
||||||
|
|||||||
@@ -3,13 +3,32 @@ set -eu
|
|||||||
|
|
||||||
TAG="${1:-}"
|
TAG="${1:-}"
|
||||||
REPOSITORY="${GITHUB_REPOSITORY:-}"
|
REPOSITORY="${GITHUB_REPOSITORY:-}"
|
||||||
|
RELEASE_ID="${DWS_GITHUB_RELEASE_ID:-}"
|
||||||
|
|
||||||
[ -n "$TAG" ] && [ -n "$REPOSITORY" ] || {
|
[ -n "$TAG" ] && [ -n "$REPOSITORY" ] || {
|
||||||
printf 'usage: GITHUB_REPOSITORY=owner/repo verify-github-release-assets.sh <tag>\n' >&2
|
printf 'usage: GITHUB_REPOSITORY=owner/repo [DWS_GITHUB_RELEASE_ID=id] verify-github-release-assets.sh <tag>\n' >&2
|
||||||
exit 2
|
exit 2
|
||||||
}
|
}
|
||||||
command -v gh >/dev/null 2>&1 || { printf 'gh is required\n' >&2; exit 1; }
|
command -v gh >/dev/null 2>&1 || { printf 'gh is required\n' >&2; exit 1; }
|
||||||
|
|
||||||
|
if [ -z "$RELEASE_ID" ]; then
|
||||||
|
RELEASE_ID="$(
|
||||||
|
gh release view "$TAG" \
|
||||||
|
--repo "$REPOSITORY" \
|
||||||
|
--json databaseId,isDraft \
|
||||||
|
--jq 'select(.isDraft == true) | .databaseId' 2>/dev/null || true
|
||||||
|
)"
|
||||||
|
fi
|
||||||
|
if [ -n "$RELEASE_ID" ]; then
|
||||||
|
printf '%s\n' "$RELEASE_ID" | grep -Eq '^[1-9][0-9]*$' || {
|
||||||
|
printf 'invalid GitHub Release ID: %s\n' "$RELEASE_ID" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
release_endpoint="repos/$REPOSITORY/releases/$RELEASE_ID"
|
||||||
|
else
|
||||||
|
release_endpoint="repos/$REPOSITORY/releases/tags/$TAG"
|
||||||
|
fi
|
||||||
|
|
||||||
tmp="$(mktemp -d "${TMPDIR:-/tmp}/dws-github-assets.XXXXXX")"
|
tmp="$(mktemp -d "${TMPDIR:-/tmp}/dws-github-assets.XXXXXX")"
|
||||||
trap 'rm -rf "$tmp"' EXIT HUP INT TERM
|
trap 'rm -rf "$tmp"' EXIT HUP INT TERM
|
||||||
cat > "$tmp/expected" <<'EOF'
|
cat > "$tmp/expected" <<'EOF'
|
||||||
@@ -24,8 +43,17 @@ dws-windows-arm64.zip
|
|||||||
EOF
|
EOF
|
||||||
LC_ALL=C sort "$tmp/expected" -o "$tmp/expected"
|
LC_ALL=C sort "$tmp/expected" -o "$tmp/expected"
|
||||||
|
|
||||||
|
resolved_tag="$(
|
||||||
|
gh api -H 'Accept: application/vnd.github+json' \
|
||||||
|
"$release_endpoint" \
|
||||||
|
--jq '.tag_name'
|
||||||
|
)"
|
||||||
|
[ "$resolved_tag" = "$TAG" ] || {
|
||||||
|
printf 'GitHub Release ID/tag mismatch: expected %s, got %s\n' "$TAG" "$resolved_tag" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
gh api -H 'Accept: application/vnd.github+json' \
|
gh api -H 'Accept: application/vnd.github+json' \
|
||||||
"repos/$REPOSITORY/releases/tags/$TAG" \
|
"$release_endpoint" \
|
||||||
--jq '.assets[].name' | LC_ALL=C sort > "$tmp/actual"
|
--jq '.assets[].name' | LC_ALL=C sort > "$tmp/actual"
|
||||||
if ! diff -u "$tmp/expected" "$tmp/actual"; then
|
if ! diff -u "$tmp/expected" "$tmp/actual"; then
|
||||||
printf 'GitHub Release %s must contain exactly the supported assets\n' "$TAG" >&2
|
printf 'GitHub Release %s must contain exactly the supported assets\n' "$TAG" >&2
|
||||||
|
|||||||
@@ -4,12 +4,28 @@ set -eu
|
|||||||
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||||
. "$SCRIPT_DIR/release-lib.sh"
|
. "$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:-}"
|
TAG="${1:-}"
|
||||||
EXPECTED_COMMIT="${2:-}"
|
EXPECTED_COMMIT="${2:-}"
|
||||||
REPOSITORY="${DWS_RELEASE_OFFICIAL_REPOSITORY:-DingTalk-Real-AI/dingtalk-workspace-cli}"
|
REPOSITORY="${DWS_RELEASE_OFFICIAL_REPOSITORY:-DingTalk-Real-AI/dingtalk-workspace-cli}"
|
||||||
|
|
||||||
[ -n "$TAG" ] && [ -n "$EXPECTED_COMMIT" ] || {
|
[ -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
|
exit 2
|
||||||
}
|
}
|
||||||
if ! release_is_stable_version "$TAG" && ! release_is_prerelease_version "$TAG"; then
|
if ! release_is_stable_version "$TAG" && ! release_is_prerelease_version "$TAG"; then
|
||||||
@@ -65,6 +81,200 @@ for run in runs:
|
|||||||
done
|
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)"
|
push_delivery="$(find_push_delivery || true)"
|
||||||
if [ -n "$push_delivery" ]; then
|
if [ -n "$push_delivery" ]; then
|
||||||
printf 'Release workflow delivery verified through exact-tag push run %s: %s -> %s\n' \
|
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
|
exit 0
|
||||||
fi
|
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() {
|
find_recovery_identity() {
|
||||||
page=1
|
page=1
|
||||||
while :; do
|
while :; do
|
||||||
|
|||||||
@@ -0,0 +1,638 @@
|
|||||||
|
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: CI",
|
||||||
|
"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'",
|
||||||
|
`COVERAGE_TARGET: "100"`,
|
||||||
|
`COVERAGE_ENFORCE_OVERALL: "false"`,
|
||||||
|
`COVERAGE_OVERALL_TOLERANCE: "0"`,
|
||||||
|
`COVERAGE_ADDITIONAL_DIFF_PROFILE=coverage-shortcut.txt`,
|
||||||
|
} {
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
notification := readWorkflow(".github/workflows/notify-wukong.yml")
|
||||||
|
if !strings.Contains(notification, "- CI") {
|
||||||
|
t.Error("Wukong notification must follow the renamed CI workflow")
|
||||||
|
}
|
||||||
|
if strings.Contains(notification, "Code Admission — PR 合入门禁") {
|
||||||
|
t.Error("Wukong notification still follows the retired workflow display name")
|
||||||
|
}
|
||||||
|
|
||||||
|
coverageGate := readWorkflow("scripts/policy/check-coverage-gate.sh")
|
||||||
|
if !strings.Contains(coverageGate, `TARGET="${COVERAGE_TARGET:-100}"`) {
|
||||||
|
t.Error("coverage gate must default to 100% changed-code coverage")
|
||||||
|
}
|
||||||
|
if !strings.Contains(coverageGate, `OVERALL_TOLERANCE="${COVERAGE_OVERALL_TOLERANCE:-0}"`) {
|
||||||
|
t.Error("coverage gate must reject any reported overall regression")
|
||||||
|
}
|
||||||
|
if !strings.Contains(coverageGate, `--baseline-profile "$BASELINE_PROFILE"`) {
|
||||||
|
t.Error("coverage gate must evaluate the merge-base profile with the candidate checker")
|
||||||
|
}
|
||||||
|
if strings.Contains(coverageGate, `--overall-profile "$ADDITIONAL_DIFF_PROFILE"`) {
|
||||||
|
t.Error("supporting changed-code coverage must not inflate candidate overall coverage")
|
||||||
|
}
|
||||||
|
if strings.Contains(coverageGate, `go tool cover -func="$BASELINE_PROFILE"`) {
|
||||||
|
t.Error("coverage baseline must not use a different coverage calculator")
|
||||||
|
}
|
||||||
|
|
||||||
|
aiBehavior := readWorkflow(".github/workflows/ai-behavior-check.yml")
|
||||||
|
for _, want := range []string{
|
||||||
|
"name: Code Admission — AI Behavior",
|
||||||
|
"\n name: AI Behavior\n",
|
||||||
|
"context: 'AI Behavior'",
|
||||||
|
"pull_request_target:",
|
||||||
|
"pull.head.sha !== expectedHead",
|
||||||
|
"pull.base.sha !== expectedBase",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(aiBehavior, want) {
|
||||||
|
t.Errorf("AI Behavior workflow missing contract %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
integration := readWorkflow(".github/workflows/multi-profile-e2e.yml")
|
||||||
|
for _, want := range []string{
|
||||||
|
"name: Main Integration — 主干集成",
|
||||||
|
"\n name: Multi-profile E2E\n",
|
||||||
|
"branches:",
|
||||||
|
"- main",
|
||||||
|
"workflow_dispatch:",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(integration, want) {
|
||||||
|
t.Errorf("main integration workflow missing contract %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(integration, "pull_request:") {
|
||||||
|
t.Error("complete Multi-profile E2E must not run as a pull-request admission context")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChangelogPRGateRejectsUnsafeChanges(t *testing.T) {
|
||||||
|
validRelease := `# Changelog
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.0.1-beta.1] - 2026-07-17
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Valid release note.
|
||||||
|
|
||||||
|
## [1.0.0] - 2026-07-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Initial release.
|
||||||
|
`
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
changelog string
|
||||||
|
mutate func(*testing.T, *changelogGateRepo)
|
||||||
|
wantOutput string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "second changed file",
|
||||||
|
changelog: validRelease,
|
||||||
|
mutate: func(t *testing.T, repo *changelogGateRepo) {
|
||||||
|
changelogGateWrite(t, repo.root, "extra.txt", "extra\n", 0o644)
|
||||||
|
},
|
||||||
|
wantOutput: "exactly one in-place modification",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid calendar date",
|
||||||
|
changelog: strings.Replace(
|
||||||
|
validRelease,
|
||||||
|
"2026-07-17",
|
||||||
|
"2026-02-30",
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
wantOutput: "invalid calendar date",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "duplicate release heading",
|
||||||
|
changelog: strings.Replace(
|
||||||
|
validRelease,
|
||||||
|
"## [1.0.0] - 2026-07-01",
|
||||||
|
"## [1.0.1-beta.1] - 2026-07-17\n\n- Duplicate.\n\n## [1.0.0] - 2026-07-01",
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
wantOutput: "exactly one well-formed section",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing bullet",
|
||||||
|
changelog: strings.Replace(
|
||||||
|
validRelease,
|
||||||
|
"- Valid release note.",
|
||||||
|
"Valid release note.",
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
wantOutput: "at least one bullet",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "placeholder",
|
||||||
|
changelog: strings.Replace(
|
||||||
|
validRelease,
|
||||||
|
"- Valid release note.",
|
||||||
|
"- TODO: write release notes.",
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
wantOutput: "must not contain TODO/TBD",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "malformed duplicate unreleased heading",
|
||||||
|
changelog: strings.Replace(
|
||||||
|
validRelease,
|
||||||
|
"## [Unreleased]",
|
||||||
|
"## [Unreleased]\n\n## [Unreleased] junk",
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
wantOutput: "exactly one heading",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "preamble change",
|
||||||
|
changelog: strings.Replace(
|
||||||
|
validRelease,
|
||||||
|
"# Changelog",
|
||||||
|
"# Release history",
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
wantOutput: "only permits notes inside",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "rename changelog",
|
||||||
|
changelog: changelogGateBase,
|
||||||
|
mutate: func(t *testing.T, repo *changelogGateRepo) {
|
||||||
|
if err := os.Rename(
|
||||||
|
filepath.Join(repo.root, "CHANGELOG.md"),
|
||||||
|
filepath.Join(repo.root, "CHANGES.md"),
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("Rename CHANGELOG.md error = %v", err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
wantOutput: "regular 100644 blob at head",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
repo := newChangelogGateRepo(t)
|
||||||
|
changelogGateWrite(t, repo.root, "CHANGELOG.md", test.changelog, 0o644)
|
||||||
|
if test.mutate != nil {
|
||||||
|
test.mutate(t, repo)
|
||||||
|
}
|
||||||
|
repo.commit(t, test.name)
|
||||||
|
|
||||||
|
output, err := repo.run(t)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("unsafe change unexpectedly passed:\n%s", output)
|
||||||
|
}
|
||||||
|
if !strings.Contains(output, test.wantOutput) {
|
||||||
|
t.Fatalf("gate output missing %q:\n%s", test.wantOutput, output)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,18 @@ var expectedPackagedSkillTargets = []string{
|
|||||||
".hermes/skills/dws",
|
".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) {
|
func seedDistArchive(t *testing.T, path string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
file, err := os.Create(path)
|
file, err := os.Create(path)
|
||||||
@@ -630,12 +642,16 @@ func TestReleaseWorkflowUsesDedicatedGovernanceIdentity(t *testing.T) {
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
checksCall = "github.rest.checks.listForRef"
|
checksCall = "github.rest.checks.listForRef"
|
||||||
|
paginatedCall = "github.paginate(github.rest.checks.listForRef"
|
||||||
immutableCall = `"GET /repos/{owner}/{repo}/immutable-releases"`
|
immutableCall = `"GET /repos/{owner}/{repo}/immutable-releases"`
|
||||||
governanceID = `github-token: ${{ secrets.RELEASE_GOVERNANCE_TOKEN }}`
|
governanceID = `github-token: ${{ secrets.RELEASE_GOVERNANCE_TOKEN }}`
|
||||||
)
|
)
|
||||||
if got := strings.Count(workflow, checksCall); got != 2 {
|
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)
|
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 {
|
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)
|
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{
|
for _, required := range []string{
|
||||||
"checks: read",
|
"checks: read",
|
||||||
checksCall,
|
checksCall,
|
||||||
|
paginatedCall,
|
||||||
|
"run.head_sha !== sha",
|
||||||
|
"const missing = requiredContexts.filter",
|
||||||
|
"const nonSuccess = requiredContexts.flatMap",
|
||||||
|
"missing:",
|
||||||
|
"non-success:",
|
||||||
immutableCall,
|
immutableCall,
|
||||||
governanceID,
|
governanceID,
|
||||||
"RELEASE_GOVERNANCE_TOKEN with repository Administration read permission is required",
|
"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)
|
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") {
|
if strings.Contains(section, "contents: write") {
|
||||||
t.Errorf("%s governance path must not grant contents write permission", name)
|
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)
|
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) {
|
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)
|
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")
|
homebrewCanary := strings.Index(preflight, "Verify Homebrew PR automation permission")
|
||||||
if ciGate == -1 || homebrewCanary == -1 || ciGate > homebrewCanary {
|
if admission == -1 || homebrewCanary == -1 || admission > homebrewCanary {
|
||||||
t.Error("governance preflight must validate the exact CI Gate before exposing Homebrew credentials")
|
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")
|
mirror := releaseWorkflowSection(t, workflow, " mirror-gitee-release:\n", "\n repair-npm:\n")
|
||||||
@@ -724,6 +799,128 @@ func TestReleaseWorkflowGovernancePreflightCannotPublish(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-channel:\n")
|
||||||
|
if start == -1 {
|
||||||
|
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`,
|
||||||
|
`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("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' || 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",
|
||||||
|
"release_is_stable_version",
|
||||||
|
"release_is_prerelease_version",
|
||||||
|
`ref: ` + "`tags/${version}`",
|
||||||
|
`["ahead", "identical"].includes(comparison.data.status)`,
|
||||||
|
"!release.data.immutable",
|
||||||
|
`release.data.prerelease !== expectedPrerelease`,
|
||||||
|
"assetNames.length !== expectedAssets.size",
|
||||||
|
"new Set(assetNames).size !== expectedAssets.size",
|
||||||
|
`core.setOutput("tag_object", ref.data.object.sha)`,
|
||||||
|
`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("channel repair authority is missing %q", required)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, asset := range []string{
|
||||||
|
"dws-darwin-amd64.tar.gz",
|
||||||
|
"dws-darwin-arm64.tar.gz",
|
||||||
|
"dws-linux-amd64.tar.gz",
|
||||||
|
"dws-linux-arm64.tar.gz",
|
||||||
|
"dws-windows-amd64.zip",
|
||||||
|
"dws-windows-arm64.zip",
|
||||||
|
"dws-skills.zip",
|
||||||
|
"checksums.txt",
|
||||||
|
} {
|
||||||
|
if strings.Count(repair, `"`+asset+`"`) != 1 {
|
||||||
|
t.Errorf("channel repair must require exactly one %s asset declaration", asset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(repair, "contents: write") {
|
||||||
|
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("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("channel repair trusted tooling working-directory count = %d, want at least 4", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestReleaseWorkflowRecoveryReusesGuardedJobs(t *testing.T) {
|
func TestReleaseWorkflowRecoveryReusesGuardedJobs(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
workflow := readReleaseWorkflow(t)
|
workflow := readReleaseWorkflow(t)
|
||||||
@@ -759,7 +956,8 @@ func TestReleaseWorkflowRecoveryReusesGuardedJobs(t *testing.T) {
|
|||||||
"dws-release-recovery run=%s tag-object=%s commit=%s",
|
"dws-release-recovery run=%s tag-object=%s commit=%s",
|
||||||
"Public release is not bound to this exact recovery run.",
|
"Public release is not bound to this exact recovery run.",
|
||||||
"Public recovery asset differs from this run's sealed artifact",
|
"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`,
|
`path: tmp/trusted-release-tooling`,
|
||||||
`ref: ${{ github.sha }}`,
|
`ref: ${{ github.sha }}`,
|
||||||
`step.name === "Require immutable published GitHub Release"`,
|
`step.name === "Require immutable published GitHub Release"`,
|
||||||
@@ -799,6 +997,94 @@ func TestReleaseWorkflowRecoveryReusesGuardedJobs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestReleaseWorkflowDraftLifecycleUsesOneReleaseID(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
workflow := readReleaseWorkflow(t)
|
||||||
|
publishJob := releaseWorkflowSection(t, workflow, " publish-release:\n", "\n publish-channels:\n")
|
||||||
|
publishStep := releaseWorkflowSection(
|
||||||
|
t,
|
||||||
|
publishJob,
|
||||||
|
" - name: Publish or reuse immutable GitHub Release\n",
|
||||||
|
"\n - name: Require immutable published GitHub Release\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
for _, required := range []string{
|
||||||
|
"id: publish",
|
||||||
|
"--json databaseId",
|
||||||
|
`"repos/$GITHUB_REPOSITORY/releases/$release_id"`,
|
||||||
|
`uploaded_release_id="$(`,
|
||||||
|
`test "$uploaded_release_id" = "$release_id"`,
|
||||||
|
"Draft GitHub Release ID $release_id targets",
|
||||||
|
"Draft GitHub Release notes differ from the sealed CHANGELOG.",
|
||||||
|
"Draft GitHub Release is not bound to this exact recovery run.",
|
||||||
|
`DWS_GITHUB_RELEASE_ID="$release_id"`,
|
||||||
|
`tmp/trusted-release-tooling/scripts/release/verify-github-release-assets.sh`,
|
||||||
|
`tmp/trusted-release-tooling/scripts/release/download-github-release-assets.sh`,
|
||||||
|
`cmp -s "$local_asset" "$remote_asset"`,
|
||||||
|
"-F draft=false",
|
||||||
|
`echo "release_id=$release_id" >> "$GITHUB_OUTPUT"`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(publishStep, required) {
|
||||||
|
t.Errorf("Draft release lifecycle is missing %q", required)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, forbidden := range []string{
|
||||||
|
`gh release download "$RELEASE_VERSION"`,
|
||||||
|
`gh release edit "$RELEASE_VERSION" --draft=false`,
|
||||||
|
`"repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_VERSION"`,
|
||||||
|
} {
|
||||||
|
if strings.Contains(publishStep, forbidden) {
|
||||||
|
t.Errorf("Draft release lifecycle must not switch back from the locked release ID via %q", forbidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tagGuard := strings.Index(publishStep, "Draft GitHub Release ID $release_id targets")
|
||||||
|
draftPatch := strings.Index(publishStep, "-F draft=true")
|
||||||
|
bodyVerify := strings.Index(publishStep, "Draft GitHub Release notes differ from the sealed CHANGELOG.")
|
||||||
|
markerVerify := strings.Index(publishStep, "Draft GitHub Release is not bound to this exact recovery run.")
|
||||||
|
upload := strings.Index(publishStep, `gh release upload "$RELEASE_VERSION"`)
|
||||||
|
idRecheck := strings.Index(publishStep, `test "$uploaded_release_id" = "$release_id"`)
|
||||||
|
verify := strings.Index(publishStep, "tmp/trusted-release-tooling/scripts/release/verify-github-release-assets.sh")
|
||||||
|
download := strings.LastIndex(publishStep, "tmp/trusted-release-tooling/scripts/release/download-github-release-assets.sh")
|
||||||
|
byteCompare := strings.Index(publishStep, `cmp -s "$local_asset" "$remote_asset"`)
|
||||||
|
publish := strings.Index(publishStep, "-F draft=false")
|
||||||
|
if tagGuard == -1 || draftPatch == -1 || bodyVerify == -1 || markerVerify == -1 ||
|
||||||
|
upload == -1 || idRecheck == -1 || verify == -1 || download == -1 || byteCompare == -1 || publish == -1 ||
|
||||||
|
!(tagGuard < draftPatch && draftPatch < bodyVerify && bodyVerify < markerVerify && markerVerify < upload &&
|
||||||
|
upload < idRecheck && idRecheck < verify && verify < download && download < byteCompare && byteCompare < publish) {
|
||||||
|
t.Fatal("Draft must retain one release ID through upload, exact verification, download, byte comparison, and publication")
|
||||||
|
}
|
||||||
|
|
||||||
|
terminalStep := publishJob[strings.Index(publishJob, " - name: Require immutable published GitHub Release\n"):]
|
||||||
|
for _, required := range []string{
|
||||||
|
`RELEASE_ID: ${{ steps.publish.outputs.release_id }}`,
|
||||||
|
`RELEASE_CHANNEL: ${{ needs.release-contract.outputs.channel }}`,
|
||||||
|
`"repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID"`,
|
||||||
|
`DWS_GITHUB_RELEASE_ID="$RELEASE_ID"`,
|
||||||
|
`tmp/trusted-release-tooling/scripts/release/verify-github-release-assets.sh`,
|
||||||
|
`tmp/trusted-release-tooling/scripts/release/download-github-release-assets.sh`,
|
||||||
|
`[.tag_name, .draft, .prerelease, .immutable] | @tsv`,
|
||||||
|
`printf '%s\tfalse\t%s\ttrue' "$RELEASE_VERSION" "$expected_prerelease"`,
|
||||||
|
"Immutable GitHub Release notes differ from the sealed CHANGELOG.",
|
||||||
|
"Immutable GitHub Release is not bound to this exact recovery run.",
|
||||||
|
`DWS_PACKAGE_DIST_DIR="$immutable_dir"`,
|
||||||
|
`cmp -s "$sealed_asset" "$immutable_asset"`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(terminalStep, required) {
|
||||||
|
t.Errorf("terminal immutable release gate is missing %q", required)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
immutableState := strings.Index(terminalStep, `[.tag_name, .draft, .prerelease, .immutable] | @tsv`)
|
||||||
|
immutableBody := strings.Index(terminalStep, "Immutable GitHub Release notes differ from the sealed CHANGELOG.")
|
||||||
|
immutableDownload := strings.Index(terminalStep, "tmp/trusted-release-tooling/scripts/release/download-github-release-assets.sh")
|
||||||
|
immutableVerify := strings.Index(terminalStep, `DWS_PACKAGE_DIST_DIR="$immutable_dir"`)
|
||||||
|
immutableCompare := strings.Index(terminalStep, `cmp -s "$sealed_asset" "$immutable_asset"`)
|
||||||
|
if immutableState == -1 || immutableBody == -1 || immutableDownload == -1 || immutableVerify == -1 || immutableCompare == -1 ||
|
||||||
|
!(immutableState < immutableBody && immutableBody < immutableDownload && immutableDownload < immutableVerify && immutableVerify < immutableCompare) {
|
||||||
|
t.Fatal("terminal gate must reverify immutable notes and exact sealed bytes on the locked release ID")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRecoverReleaseBindsOneFailedRunAttempt(t *testing.T) {
|
func TestRecoverReleaseBindsOneFailedRunAttempt(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "recover-release.sh"))
|
scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "recover-release.sh"))
|
||||||
@@ -890,11 +1176,7 @@ func TestReleaseWorkflowPublicationBypassesSkippedDispatchButStopsOnCancellation
|
|||||||
func TestReleaseWorkflowDeliveryGateFailsClosed(t *testing.T) {
|
func TestReleaseWorkflowDeliveryGateFailsClosed(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
workflow := readReleaseWorkflow(t)
|
workflow := readReleaseWorkflow(t)
|
||||||
start := strings.Index(workflow, " release-delivery-gate:\n")
|
gate := releaseWorkflowSection(t, workflow, " release-delivery-gate:\n", "\n repair-channel:\n")
|
||||||
if start == -1 {
|
|
||||||
t.Fatal("release workflow is missing the terminal delivery gate")
|
|
||||||
}
|
|
||||||
gate := workflow[start:]
|
|
||||||
|
|
||||||
for _, required := range []string{
|
for _, required := range []string{
|
||||||
"name: Release delivery gate",
|
"name: Release delivery gate",
|
||||||
@@ -909,6 +1191,8 @@ func TestReleaseWorkflowDeliveryGateFailsClosed(t *testing.T) {
|
|||||||
"- publish-channels",
|
"- publish-channels",
|
||||||
"- mirror-gitee-release",
|
"- mirror-gitee-release",
|
||||||
"- repair-npm",
|
"- repair-npm",
|
||||||
|
"- repair-channel",
|
||||||
|
`REPAIR_CHANNEL_RESULT: ${{ needs.repair-channel.result }}`,
|
||||||
"require_publication",
|
"require_publication",
|
||||||
`require_result release-contract "$RELEASE_CONTRACT_RESULT" success`,
|
`require_result release-contract "$RELEASE_CONTRACT_RESULT" success`,
|
||||||
`require_result release "$RELEASE_RESULT" success`,
|
`require_result release "$RELEASE_RESULT" success`,
|
||||||
@@ -918,6 +1202,10 @@ func TestReleaseWorkflowDeliveryGateFailsClosed(t *testing.T) {
|
|||||||
"workflow_dispatch:recover_release",
|
"workflow_dispatch:recover_release",
|
||||||
"workflow_dispatch:governance_preflight",
|
"workflow_dispatch:governance_preflight",
|
||||||
"workflow_dispatch:repair_npm",
|
"workflow_dispatch:repair_npm",
|
||||||
|
"workflow_dispatch:repair_gitee",
|
||||||
|
"workflow_dispatch:repair_oss",
|
||||||
|
`require_result repair-channel "$REPAIR_CHANNEL_RESULT" success`,
|
||||||
|
`require_result repair-channel "$REPAIR_CHANNEL_RESULT" skipped`,
|
||||||
"unsupported release mode",
|
"unsupported release mode",
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(gate, required) {
|
if !strings.Contains(gate, required) {
|
||||||
@@ -1090,7 +1378,7 @@ func TestReleaseWorkflowUsesAppleCodesignBeforePublication(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
codesign := strings.Index(workflow[verifyJob:publishJob], "codesign --verify --strict --verbose=4")
|
codesign := strings.Index(workflow[verifyJob:publishJob], "codesign --verify --strict --verbose=4")
|
||||||
publish := strings.Index(workflow[publishJob:], `gh release edit "$RELEASE_VERSION" --draft=false`)
|
publish := strings.Index(workflow[publishJob:], "-F draft=false")
|
||||||
if codesign == -1 || publish == -1 {
|
if codesign == -1 || publish == -1 {
|
||||||
t.Fatal("macOS codesign verification and explicit Draft publication are required")
|
t.Fatal("macOS codesign verification and explicit Draft publication are required")
|
||||||
}
|
}
|
||||||
@@ -1166,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)
|
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")
|
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")
|
tagHomebrew := strings.Index(tagContract, "Verify Homebrew PR automation permission")
|
||||||
if tagCI == -1 || tagHomebrew == -1 || tagCI > tagHomebrew {
|
if tagAdmission == -1 || tagHomebrew == -1 || tagAdmission > tagHomebrew {
|
||||||
t.Error("tag contract must validate the sealed CI Gate before exposing Homebrew credentials")
|
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")
|
start := strings.Index(workflow, "- name: Open stable Homebrew formula PR")
|
||||||
|
|||||||
@@ -259,7 +259,25 @@ func TestReleaseGitHubAssetSetIsExact(t *testing.T) {
|
|||||||
}
|
}
|
||||||
binDir := t.TempDir()
|
binDir := t.TempDir()
|
||||||
fakeGH := filepath.Join(binDir, "gh")
|
fakeGH := filepath.Join(binDir, "gh")
|
||||||
mustWriteFile(t, fakeGH, []byte("#!/bin/sh\nset -eu\nprintf '%s\\n' \"$GH_ASSETS\"\n"), 0o755)
|
mustWriteFile(t, fakeGH, []byte(`#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
case "${1:-}" in
|
||||||
|
release)
|
||||||
|
[ "${2:-}" = "view" ] || exit 2
|
||||||
|
[ "${GH_DRAFT:-false}" = "true" ] || exit 1
|
||||||
|
printf '123\n'
|
||||||
|
;;
|
||||||
|
api)
|
||||||
|
printf '%s\n' "$*" >> "$GH_LOG"
|
||||||
|
case "$*" in
|
||||||
|
*tag_name*) printf '%s\n' "$GH_RELEASE_TAG" ;;
|
||||||
|
*assets*name*) printf '%s\n' "$GH_ASSETS" ;;
|
||||||
|
*) exit 2 ;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
|
*) exit 2 ;;
|
||||||
|
esac
|
||||||
|
`), 0o755)
|
||||||
exact := strings.Join([]string{
|
exact := strings.Join([]string{
|
||||||
"dws-windows-arm64.zip",
|
"dws-windows-arm64.zip",
|
||||||
"dws-linux-amd64.tar.gz",
|
"dws-linux-amd64.tar.gz",
|
||||||
@@ -271,23 +289,218 @@ func TestReleaseGitHubAssetSetIsExact(t *testing.T) {
|
|||||||
"dws-darwin-arm64.tar.gz",
|
"dws-darwin-arm64.tar.gz",
|
||||||
}, "\n")
|
}, "\n")
|
||||||
script := filepath.Join(sourceRoot, "scripts", "release", "verify-github-release-assets.sh")
|
script := filepath.Join(sourceRoot, "scripts", "release", "verify-github-release-assets.sh")
|
||||||
run := func(assets string) (string, error) {
|
run := func(assets string, draft bool, releaseTag, releaseID string) (string, string, error) {
|
||||||
|
draftValue := "false"
|
||||||
|
if draft {
|
||||||
|
draftValue = "true"
|
||||||
|
}
|
||||||
|
logPath := filepath.Join(t.TempDir(), "gh.log")
|
||||||
cmd := exec.Command("sh", script, "v1.2.3")
|
cmd := exec.Command("sh", script, "v1.2.3")
|
||||||
cmd.Env = []string{
|
cmd.Env = []string{
|
||||||
"PATH=" + binDir + string(os.PathListSeparator) + os.Getenv("PATH"),
|
"PATH=" + binDir + string(os.PathListSeparator) + os.Getenv("PATH"),
|
||||||
"HOME=" + t.TempDir(),
|
"HOME=" + t.TempDir(),
|
||||||
"GITHUB_REPOSITORY=owner/repo",
|
"GITHUB_REPOSITORY=owner/repo",
|
||||||
"GH_ASSETS=" + assets,
|
"GH_ASSETS=" + assets,
|
||||||
|
"GH_DRAFT=" + draftValue,
|
||||||
|
"GH_LOG=" + logPath,
|
||||||
|
"GH_RELEASE_TAG=" + releaseTag,
|
||||||
|
"DWS_GITHUB_RELEASE_ID=" + releaseID,
|
||||||
}
|
}
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
return string(output), err
|
log, readErr := os.ReadFile(logPath)
|
||||||
|
if readErr != nil && !os.IsNotExist(readErr) {
|
||||||
|
t.Fatalf("ReadFile(%s) error = %v", logPath, readErr)
|
||||||
|
}
|
||||||
|
return string(output), string(log), err
|
||||||
}
|
}
|
||||||
if output, err := run(exact); err != nil {
|
if output, _, err := run(exact, false, "v1.2.3", ""); err != nil {
|
||||||
t.Fatalf("exact GitHub assets rejected: %v\n%s", err, output)
|
t.Fatalf("exact GitHub assets rejected: %v\n%s", err, output)
|
||||||
}
|
}
|
||||||
if output, err := run(exact + "\nmalware.exe"); err == nil || !strings.Contains(output, "exactly the supported assets") {
|
if output, log, err := run(exact, true, "v1.2.3", ""); err != nil {
|
||||||
|
t.Fatalf("exact Draft GitHub assets rejected: %v\n%s", err, output)
|
||||||
|
} else if !strings.Contains(log, "repos/owner/repo/releases/123") ||
|
||||||
|
strings.Contains(log, "repos/owner/repo/releases/tags/v1.2.3") {
|
||||||
|
t.Fatalf("Draft verification did not use the release ID endpoint:\n%s", log)
|
||||||
|
}
|
||||||
|
if output, _, err := run(exact+"\nmalware.exe", false, "v1.2.3", ""); err == nil || !strings.Contains(output, "exactly the supported assets") {
|
||||||
t.Fatalf("extra GitHub asset was not rejected: err=%v\n%s", err, output)
|
t.Fatalf("extra GitHub asset was not rejected: err=%v\n%s", err, output)
|
||||||
}
|
}
|
||||||
|
if output, _, err := run(exact, true, "v9.9.9", ""); err == nil || !strings.Contains(output, "ID/tag mismatch") {
|
||||||
|
t.Fatalf("wrong Draft release ID target was not rejected: err=%v\n%s", err, output)
|
||||||
|
}
|
||||||
|
if output, _, err := run(exact, false, "v1.2.3", "invalid"); err == nil || !strings.Contains(output, "invalid GitHub Release ID") {
|
||||||
|
t.Fatalf("invalid explicit release ID was not rejected: err=%v\n%s", err, output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReleaseGitHubAssetsDownloadByExactReleaseID(t *testing.T) {
|
||||||
|
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Abs(repo root) error = %v", err)
|
||||||
|
}
|
||||||
|
binDir := t.TempDir()
|
||||||
|
fakeGH := filepath.Join(binDir, "gh")
|
||||||
|
mustWriteFile(t, fakeGH, []byte(`#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
[ "${1:-}" = "api" ] || exit 2
|
||||||
|
shift
|
||||||
|
|
||||||
|
accept=""
|
||||||
|
endpoint=""
|
||||||
|
query=""
|
||||||
|
while [ "$#" -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
-H)
|
||||||
|
accept="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--jq)
|
||||||
|
query="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
repos/*)
|
||||||
|
endpoint="$1"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
printf '%s|%s|%s\n' "$accept" "$endpoint" "$query" >> "$GH_LOG"
|
||||||
|
|
||||||
|
case "$accept" in
|
||||||
|
*application/octet-stream*)
|
||||||
|
case "$endpoint" in
|
||||||
|
repos/owner/repo/releases/assets/*) ;;
|
||||||
|
*) exit 31 ;;
|
||||||
|
esac
|
||||||
|
asset_id="${endpoint##*/}"
|
||||||
|
asset_name="$(
|
||||||
|
awk -F ' ' -v id="$asset_id" '
|
||||||
|
$1 == id { print $2; found = 1 }
|
||||||
|
END { if (!found) exit 1 }
|
||||||
|
' "$GH_ASSET_ROWS"
|
||||||
|
)" || exit 32
|
||||||
|
printf 'asset-bytes:%s:%s\n' "$asset_id" "$asset_name"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
[ "$endpoint" = "repos/owner/repo/releases/$GH_EXPECTED_RELEASE_ID" ] || exit 33
|
||||||
|
case "$query" in
|
||||||
|
'.tag_name')
|
||||||
|
printf '%s\n' "$GH_RELEASE_TAG"
|
||||||
|
;;
|
||||||
|
'.assets[].name')
|
||||||
|
cut -f 2 "$GH_ASSET_ROWS"
|
||||||
|
;;
|
||||||
|
'.assets[] | [.id, .name] | @tsv')
|
||||||
|
cat "$GH_ASSET_ROWS"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
exit 34
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
`), 0o755)
|
||||||
|
|
||||||
|
type releaseAsset struct {
|
||||||
|
id string
|
||||||
|
name string
|
||||||
|
}
|
||||||
|
assets := []releaseAsset{
|
||||||
|
{id: "1008", name: "dws-windows-arm64.zip"},
|
||||||
|
{id: "1003", name: "dws-darwin-arm64.tar.gz"},
|
||||||
|
{id: "1001", name: "checksums.txt"},
|
||||||
|
{id: "1005", name: "dws-linux-arm64.tar.gz"},
|
||||||
|
{id: "1002", name: "dws-darwin-amd64.tar.gz"},
|
||||||
|
{id: "1007", name: "dws-windows-amd64.zip"},
|
||||||
|
{id: "1004", name: "dws-linux-amd64.tar.gz"},
|
||||||
|
{id: "1006", name: "dws-skills.zip"},
|
||||||
|
}
|
||||||
|
rowsFor := func(items []releaseAsset) string {
|
||||||
|
var rows []string
|
||||||
|
for _, asset := range items {
|
||||||
|
rows = append(rows, asset.id+"\t"+asset.name)
|
||||||
|
}
|
||||||
|
return strings.Join(rows, "\n") + "\n"
|
||||||
|
}
|
||||||
|
script := filepath.Join(sourceRoot, "scripts", "release", "download-github-release-assets.sh")
|
||||||
|
type runResult struct {
|
||||||
|
output string
|
||||||
|
log string
|
||||||
|
dest string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
run := func(items []releaseAsset, releaseTag, releaseID string) runResult {
|
||||||
|
runDir := t.TempDir()
|
||||||
|
rowsPath := filepath.Join(runDir, "assets.tsv")
|
||||||
|
logPath := filepath.Join(runDir, "gh.log")
|
||||||
|
dest := filepath.Join(runDir, "download")
|
||||||
|
mustWriteFile(t, rowsPath, []byte(rowsFor(items)), 0o644)
|
||||||
|
cmd := exec.Command("sh", script, "v1.2.3", dest)
|
||||||
|
cmd.Env = []string{
|
||||||
|
"PATH=" + binDir + string(os.PathListSeparator) + os.Getenv("PATH"),
|
||||||
|
"HOME=" + t.TempDir(),
|
||||||
|
"GITHUB_REPOSITORY=owner/repo",
|
||||||
|
"DWS_GITHUB_RELEASE_ID=" + releaseID,
|
||||||
|
"GH_ASSET_ROWS=" + rowsPath,
|
||||||
|
"GH_EXPECTED_RELEASE_ID=456",
|
||||||
|
"GH_LOG=" + logPath,
|
||||||
|
"GH_RELEASE_TAG=" + releaseTag,
|
||||||
|
}
|
||||||
|
output, runErr := cmd.CombinedOutput()
|
||||||
|
log, readErr := os.ReadFile(logPath)
|
||||||
|
if readErr != nil && !os.IsNotExist(readErr) {
|
||||||
|
t.Fatalf("ReadFile(%s) error = %v", logPath, readErr)
|
||||||
|
}
|
||||||
|
return runResult{
|
||||||
|
output: string(output),
|
||||||
|
log: string(log),
|
||||||
|
dest: dest,
|
||||||
|
err: runErr,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
success := run(assets, "v1.2.3", "456")
|
||||||
|
if success.err != nil {
|
||||||
|
t.Fatalf("exact GitHub Release download was rejected: %v\n%s", success.err, success.output)
|
||||||
|
}
|
||||||
|
if strings.Contains(success.log, "releases/tags/") {
|
||||||
|
t.Fatalf("download fell back to a tag endpoint:\n%s", success.log)
|
||||||
|
}
|
||||||
|
if got := strings.Count(success.log, "application/octet-stream"); got != len(assets) {
|
||||||
|
t.Fatalf("asset download count = %d, want %d\nlog:\n%s", got, len(assets), success.log)
|
||||||
|
}
|
||||||
|
for _, asset := range assets {
|
||||||
|
wantEndpoint := "repos/owner/repo/releases/assets/" + asset.id
|
||||||
|
if !strings.Contains(success.log, wantEndpoint) {
|
||||||
|
t.Errorf("asset %s was not downloaded by ID %s\nlog:\n%s", asset.name, asset.id, success.log)
|
||||||
|
}
|
||||||
|
data, readErr := os.ReadFile(filepath.Join(success.dest, asset.name))
|
||||||
|
if readErr != nil {
|
||||||
|
t.Errorf("ReadFile(%s) error = %v", asset.name, readErr)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
want := fmt.Sprintf("asset-bytes:%s:%s\n", asset.id, asset.name)
|
||||||
|
if string(data) != want {
|
||||||
|
t.Errorf("%s bytes = %q, want %q", asset.name, data, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extra := append(append([]releaseAsset{}, assets...), releaseAsset{id: "1009", name: "malware.exe"})
|
||||||
|
if result := run(extra, "v1.2.3", "456"); result.err == nil || !strings.Contains(result.output, "exactly the supported assets") {
|
||||||
|
t.Fatalf("extra GitHub Release asset was not rejected: err=%v\n%s", result.err, result.output)
|
||||||
|
}
|
||||||
|
if result := run(assets[:len(assets)-1], "v1.2.3", "456"); result.err == nil || !strings.Contains(result.output, "exactly the supported assets") {
|
||||||
|
t.Fatalf("missing GitHub Release asset was not rejected: err=%v\n%s", result.err, result.output)
|
||||||
|
}
|
||||||
|
if result := run(assets, "v9.9.9", "456"); result.err == nil || !strings.Contains(result.output, "ID/tag mismatch") {
|
||||||
|
t.Fatalf("wrong GitHub Release tag was not rejected: err=%v\n%s", result.err, result.output)
|
||||||
|
}
|
||||||
|
if result := run(assets, "v1.2.3", "invalid"); result.err == nil || !strings.Contains(result.output, "invalid GitHub Release ID") {
|
||||||
|
t.Fatalf("invalid GitHub Release ID was not rejected: err=%v\n%s", result.err, result.output)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReleaseDeliveredStableRequiresSuccessfulPublicDelivery(t *testing.T) {
|
func TestReleaseDeliveredStableRequiresSuccessfulPublicDelivery(t *testing.T) {
|
||||||
@@ -461,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 {
|
func releaseChangelog(sections ...string) string {
|
||||||
text := "# Changelog\n\n## [Unreleased]\n\n"
|
text := "# Changelog\n\n## [Unreleased]\n\n"
|
||||||
for _, section := range sections {
|
for _, section := range sections {
|
||||||
@@ -847,6 +1311,7 @@ func TestReleaseMirrorUsesChannelSpecificPointer(t *testing.T) {
|
|||||||
"OSS_ACCESS_KEY_ID=test-key",
|
"OSS_ACCESS_KEY_ID=test-key",
|
||||||
"OSS_ACCESS_KEY_SECRET=test-secret",
|
"OSS_ACCESS_KEY_SECRET=test-secret",
|
||||||
"OSS_ENDPOINT=https://oss.example.com",
|
"OSS_ENDPOINT=https://oss.example.com",
|
||||||
|
"OSS_REGION=cn-test",
|
||||||
"OSS_BUCKET=test-bucket",
|
"OSS_BUCKET=test-bucket",
|
||||||
"OSS_PREFIX=dws",
|
"OSS_PREFIX=dws",
|
||||||
"OSSUTIL="+fakeOSSUtil,
|
"OSSUTIL="+fakeOSSUtil,
|
||||||
@@ -917,6 +1382,7 @@ func TestReleaseMirrorFailsClosedWhenPointerCannotBeRead(t *testing.T) {
|
|||||||
"OSS_ACCESS_KEY_ID=test-key",
|
"OSS_ACCESS_KEY_ID=test-key",
|
||||||
"OSS_ACCESS_KEY_SECRET=test-secret",
|
"OSS_ACCESS_KEY_SECRET=test-secret",
|
||||||
"OSS_ENDPOINT=https://oss.example.com",
|
"OSS_ENDPOINT=https://oss.example.com",
|
||||||
|
"OSS_REGION=cn-test",
|
||||||
"OSS_BUCKET=test-bucket",
|
"OSS_BUCKET=test-bucket",
|
||||||
"OSS_PREFIX=dws",
|
"OSS_PREFIX=dws",
|
||||||
"OSSUTIL="+fakeOSSUtil,
|
"OSSUTIL="+fakeOSSUtil,
|
||||||
@@ -953,6 +1419,7 @@ func TestReleaseMirrorRepairsHistoricalAssetsWithoutMovingNewerPointer(t *testin
|
|||||||
"OSS_ACCESS_KEY_ID=test-key",
|
"OSS_ACCESS_KEY_ID=test-key",
|
||||||
"OSS_ACCESS_KEY_SECRET=test-secret",
|
"OSS_ACCESS_KEY_SECRET=test-secret",
|
||||||
"OSS_ENDPOINT=https://oss.example.com",
|
"OSS_ENDPOINT=https://oss.example.com",
|
||||||
|
"OSS_REGION=cn-test",
|
||||||
"OSS_BUCKET=test-bucket",
|
"OSS_BUCKET=test-bucket",
|
||||||
"OSS_PREFIX=dws",
|
"OSS_PREFIX=dws",
|
||||||
"OSSUTIL="+fakeOSSUtil,
|
"OSSUTIL="+fakeOSSUtil,
|
||||||
|
|||||||
Reference in new issue
Block a user