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