Compare commits

...
Author SHA1 Message Date
修雨 d93925a892 Merge branch 'main' into codex/cloud-release-withdrawal 2026-07-21 10:28:17 +08:00
修雨 faab9e0282 Merge pull request #700 from DingTalk-Real-AI/codex/ci-test-contract
ci: enforce complete Go test coverage
2026-07-21 10:20:01 +08:00
修雨 76d301268d ci: add cloud release and withdrawal workflows 2026-07-21 10:03:25 +08:00
修雨 7fddace8df ci: enforce complete Go test coverage 2026-07-21 09:41:18 +08:00
修雨 076d77da8e Merge pull request #699 from DingTalk-Real-AI/codex/ci-coverage-100
ci: shorten workflow name and require 100% changed-code coverage
2026-07-20 17:44:56 +08:00
33 changed files with 4313 additions and 483 deletions
+1
View File
@@ -95,6 +95,7 @@ jobs:
typeof filename === 'string' &&
(
filename.startsWith('.github/workflows/') ||
filename.startsWith('scripts/ci/') ||
filename.startsWith('scripts/policy/') ||
filename.startsWith('scripts/release/') ||
filename === 'test/fixtures/cli-interface-baseline.txt' ||
+22 -29
View File
@@ -130,11 +130,13 @@ jobs:
with:
go-version-file: go.mod
- name: Verify Test Package Plan
if: steps.classify.outputs.changelog_only != 'true'
run: make test-plan
- 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)
run: make format-check
- name: Go Vet
if: steps.classify.outputs.changelog_only != 'true'
@@ -167,38 +169,20 @@ jobs:
with:
go-version-file: go.mod
- name: Install archive tooling
run: sudo apt-get update && sudo apt-get install -y zip unzip
- name: Build
if: ${{ matrix.shard == 'remaining' }}
run: make build
- name: Test shard with Race Detection
shell: bash
env:
DWS_PACKAGE_VERSION: 0.0.0-test
TEST_SHARD: ${{ matrix.shard }}
run: |
case "$TEST_SHARD" in
app)
packages=(./internal/app/...)
;;
generators)
packages=(./internal/generator/...)
;;
helpers)
packages=(./internal/helpers/...)
;;
remaining)
mapfile -t packages < <(
go list ./cmd/... ./internal/... |
grep -Ev '/internal/(app|generator|helpers)(/|$)'
)
;;
*)
printf 'unknown test shard: %s\n' "$TEST_SHARD" >&2
exit 1
;;
esac
set -euo pipefail
package_output="$(./scripts/ci/test-packages.sh list "$TEST_SHARD")"
test -n "$package_output"
mapfile -t packages <<< "$package_output"
test "${#packages[@]}" -gt 0
go test -v -race -count=1 -timeout=10m "${packages[@]}"
@@ -207,7 +191,7 @@ jobs:
needs: lint
if: ${{ needs.lint.outputs.changelog_only != 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 10
timeout-minutes: 15
steps:
- name: Check out repository
uses: actions/checkout@v4
@@ -221,7 +205,16 @@ jobs:
run: sudo apt-get update && sudo apt-get install -y zip unzip
- name: Test release scripts
run: go test -v -count=1 -timeout=5m ./test/scripts
shell: bash
env:
DWS_PACKAGE_VERSION: 0.0.0-test
run: |
set -euo pipefail
package_output="$(./scripts/ci/test-packages.sh list release-scripts)"
test -n "$package_output"
mapfile -t packages <<< "$package_output"
test "${#packages[@]}" -gt 0
go test -v -count=1 -timeout=10m "${packages[@]}"
test-cross-platform:
name: Test (cross-platform compile)
+623 -127
View File
@@ -1,5 +1,5 @@
name: Release
run-name: ${{ github.event_name == 'workflow_dispatch' && inputs.governance_preflight_nonce != '' && format('Release governance preflight {0}', inputs.governance_preflight_nonce) || github.event_name == 'workflow_dispatch' && inputs.recover_release_version != '' && format('Release recovery {0} at {1} {2}', inputs.recover_release_version, inputs.recover_release_commit, inputs.recover_release_nonce) || github.event_name == 'workflow_dispatch' && inputs.repair_npm_version != '' && format('Release npm repair {0}', inputs.repair_npm_version) || github.event_name == 'workflow_dispatch' && inputs.repair_gitee_version != '' && format('Release Gitee repair {0}', inputs.repair_gitee_version) || github.event_name == 'workflow_dispatch' && inputs.repair_oss_version != '' && format('Release OSS repair {0}', inputs.repair_oss_version) || github.workflow }}
run-name: ${{ github.event_name == 'workflow_dispatch' && inputs.release_operation != 'none' && format('Release {0} {1} ({2})', inputs.release_operation, inputs.release_channel, inputs.release_bump) || github.event_name == 'workflow_dispatch' && inputs.governance_preflight_nonce != '' && format('Release governance preflight {0}', inputs.governance_preflight_nonce) || github.event_name == 'workflow_dispatch' && inputs.recover_release_version != '' && format('Release recovery {0} at {1} {2}', inputs.recover_release_version, inputs.recover_release_commit, inputs.recover_release_nonce) || github.event_name == 'workflow_dispatch' && inputs.repair_npm_version != '' && format('Release npm repair {0}', inputs.repair_npm_version) || github.event_name == 'workflow_dispatch' && inputs.repair_gitee_version != '' && format('Release Gitee repair {0}', inputs.repair_gitee_version) || github.event_name == 'workflow_dispatch' && inputs.repair_oss_version != '' && format('Release OSS repair {0}', inputs.repair_oss_version) || github.workflow }}
on:
push:
@@ -7,6 +7,36 @@ on:
- "v*"
workflow_dispatch:
inputs:
release_operation:
description: "Plan the next version or publish it from the current default-branch commit"
required: true
default: none
type: choice
options:
- none
- plan
- publish
release_channel:
description: "Release channel (used only when release_operation is plan/publish)"
required: true
default: beta
type: choice
options:
- beta
- stable
release_bump:
description: "New beta core bump; ignored while continuing an open beta line"
required: true
default: patch
type: choice
options:
- patch
- minor
- major
release_confirmation:
description: "For publish, type PUBLISH beta or PUBLISH stable"
required: false
type: string
repair_npm_version:
description: "Recover npm for a public immutable release without moving latest/beta"
required: false
@@ -89,13 +119,21 @@ jobs:
RECOVER_FAILED_RUN_ATTEMPT: ${{ inputs.recover_failed_run_attempt }}
RECOVER_NONCE: ${{ inputs.recover_release_nonce }}
RECOVER_CONFIRMATION: ${{ inputs.recover_release_confirmation }}
RELEASE_OPERATION: ${{ inputs.release_operation }}
RELEASE_CHANNEL: ${{ inputs.release_channel }}
RELEASE_BUMP: ${{ inputs.release_bump }}
RELEASE_CONFIRMATION: ${{ inputs.release_confirmation }}
run: |
set -eu
release_flow=0
npm_repair=0
gitee_repair=0
oss_repair=0
governance=0
recovery=0
if test -n "$RELEASE_OPERATION" && test "$RELEASE_OPERATION" != none; then
release_flow=1
fi
test -z "$REPAIR_NPM_VERSION" || npm_repair=1
test -z "$REPAIR_GITEE_VERSION" || gitee_repair=1
test -z "$REPAIR_OSS_VERSION" || oss_repair=1
@@ -105,11 +143,36 @@ jobs:
test -n "$RECOVER_FAILED_RUN_ATTEMPT" || \
test -n "$RECOVER_NONCE" || \
test -n "$RECOVER_CONFIRMATION"; then recovery=1; fi
test $((npm_repair + gitee_repair + oss_repair + governance + recovery)) -eq 1 || {
test $((release_flow + npm_repair + gitee_repair + oss_repair + governance + recovery)) -eq 1 || {
echo "workflow_dispatch must select exactly one release mode" >&2
exit 1
}
if test "$npm_repair" -eq 1; then
if test "$release_flow" -eq 1; then
case "$RELEASE_CHANNEL" in beta|stable) ;; *)
echo "release_channel must be beta or stable" >&2
exit 1
esac
case "$RELEASE_BUMP" in patch|minor|major) ;; *)
echo "release_bump must be patch, minor, or major" >&2
exit 1
esac
if test "$RELEASE_OPERATION" = plan; then
test -z "$RELEASE_CONFIRMATION" || {
echo "release_confirmation must be empty for a plan" >&2
exit 1
}
echo "mode=plan_release" >> "$GITHUB_OUTPUT"
elif test "$RELEASE_OPERATION" = publish; then
test "$RELEASE_CONFIRMATION" = "PUBLISH $RELEASE_CHANNEL" || {
echo "release_confirmation must be exactly: PUBLISH $RELEASE_CHANNEL" >&2
exit 1
}
echo "mode=create_release" >> "$GITHUB_OUTPUT"
else
echo "release_operation must be none, plan, or publish" >&2
exit 1
fi
elif 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"
@@ -197,7 +260,7 @@ jobs:
governance-preflight:
name: Release governance preflight
needs: dispatch-contract
if: ${{ needs.dispatch-contract.outputs.mode == 'governance_preflight' }}
if: ${{ needs.dispatch-contract.outputs.mode == 'governance_preflight' || needs.dispatch-contract.outputs.mode == 'create_release' }}
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
@@ -208,7 +271,8 @@ jobs:
env:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
EXPECTED_REPOSITORY: DingTalk-Real-AI/dingtalk-workspace-cli
PREFLIGHT_COMMIT: ${{ inputs.governance_preflight_commit }}
DISPATCH_MODE: ${{ needs.dispatch-contract.outputs.mode }}
PREFLIGHT_COMMIT: ${{ needs.dispatch-contract.outputs.mode == 'create_release' && github.sha || inputs.governance_preflight_commit }}
PREFLIGHT_NONCE: ${{ inputs.governance_preflight_nonce }}
REPAIR_NPM_VERSION: ${{ inputs.repair_npm_version }}
run: |
@@ -225,10 +289,24 @@ jobs:
echo "governance preflight cannot be combined with npm repair" >&2
exit 1
}
if test -z "$PREFLIGHT_COMMIT" || test -z "$PREFLIGHT_NONCE"; then
echo "governance_preflight_commit and governance_preflight_nonce are both required" >&2
exit 1
fi
case "$DISPATCH_MODE" in
governance_preflight)
if test -z "$PREFLIGHT_COMMIT" || test -z "$PREFLIGHT_NONCE"; then
echo "governance_preflight_commit and governance_preflight_nonce are both required" >&2
exit 1
fi
;;
create_release)
test -z "$PREFLIGHT_NONCE" || {
echo "cloud release cannot be combined with a governance nonce" >&2
exit 1
}
;;
*)
echo "unsupported governance preflight mode: $DISPATCH_MODE" >&2
exit 1
;;
esac
case "$PREFLIGHT_COMMIT" in
*[!0-9a-f]*)
echo "governance_preflight_commit must be a lowercase commit SHA" >&2
@@ -247,7 +325,7 @@ jobs:
- name: Require successful Code Admission contexts on the preflight commit
uses: actions/github-script@v7
env:
PREFLIGHT_COMMIT: ${{ inputs.governance_preflight_commit }}
PREFLIGHT_COMMIT: ${{ needs.dispatch-contract.outputs.mode == 'create_release' && github.sha || inputs.governance_preflight_commit }}
with:
script: |
const { owner, repo } = context.repo;
@@ -299,7 +377,7 @@ jobs:
- name: Check out trusted preflight tooling
uses: actions/checkout@v4
with:
ref: ${{ inputs.governance_preflight_commit }}
ref: ${{ needs.dispatch-contract.outputs.mode == 'create_release' && github.sha || inputs.governance_preflight_commit }}
persist-credentials: false
fetch-depth: 0
@@ -348,8 +426,8 @@ jobs:
./scripts/release/verify-homebrew-pr-token.sh "$GITHUB_REPOSITORY" --canary
release-contract:
needs: [dispatch-contract, authorize-recovery]
if: ${{ !cancelled() && (github.event_name == 'push' || (needs.dispatch-contract.result == 'success' && needs.dispatch-contract.outputs.mode == 'recover_release' && needs.authorize-recovery.result == 'success')) }}
needs: [dispatch-contract, authorize-recovery, governance-preflight, release-plan, seal-release]
if: ${{ !cancelled() && (github.event_name == 'push' || (needs.dispatch-contract.result == 'success' && needs.dispatch-contract.outputs.mode == 'recover_release' && needs.authorize-recovery.result == 'success') || (needs.dispatch-contract.result == 'success' && needs.dispatch-contract.outputs.mode == 'create_release' && needs.governance-preflight.result == 'success' && needs.release-plan.result == 'success' && needs.seal-release.result == 'success')) }}
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
@@ -377,16 +455,28 @@ jobs:
RECOVER_COMMIT: ${{ inputs.recover_release_commit }}
RECOVER_FAILED_RUN_ID: ${{ inputs.recover_failed_run_id }}
RECOVER_FAILED_RUN_ATTEMPT: ${{ inputs.recover_failed_run_attempt }}
DISPATCH_MODE: ${{ needs.dispatch-contract.outputs.mode }}
SEALED_VERSION: ${{ needs.seal-release.outputs.release_version }}
SEALED_COMMIT: ${{ needs.seal-release.outputs.release_commit }}
SEALED_TAG_OBJECT: ${{ needs.seal-release.outputs.release_tag_object }}
with:
script: |
const { owner, repo } = context.repo;
const isRecovery = context.eventName === "workflow_dispatch";
const dispatchMode = process.env.DISPATCH_MODE;
const isRecovery =
context.eventName === "workflow_dispatch" && dispatchMode === "recover_release";
const isCreate =
context.eventName === "workflow_dispatch" && dispatchMode === "create_release";
let version = context.ref.replace("refs/tags/", "");
let commit = context.sha;
let expectedTagObject = "";
let failedRunId = "";
let failedRunAttempt = "";
if (isRecovery) {
if (context.eventName === "workflow_dispatch" && !isRecovery && !isCreate) {
core.setFailed(`unsupported publication dispatch mode: ${dispatchMode}`);
return;
}
if (isRecovery || isCreate) {
if (
context.payload.repository.full_name !== "DingTalk-Real-AI/dingtalk-workspace-cli" ||
context.ref !== `refs/heads/${context.payload.repository.default_branch}`
@@ -394,24 +484,28 @@ jobs:
core.setFailed("release recovery must be dispatched from the official default branch");
return;
}
version = process.env.RECOVER_VERSION;
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;
version = isCreate ? process.env.SEALED_VERSION : process.env.RECOVER_VERSION;
commit = isCreate ? process.env.SEALED_COMMIT : process.env.RECOVER_COMMIT;
expectedTagObject = isCreate
? process.env.SEALED_TAG_OBJECT
: process.env.RECOVER_TAG_OBJECT;
if (isRecovery) {
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}`);
core.setFailed(`invalid dispatched release version: ${version}`);
return;
}
if (!/^[0-9a-f]{40}$/.test(commit) || !/^[0-9a-f]{40}$/.test(expectedTagObject)) {
core.setFailed("recovery tag object and commit must be full lowercase SHA-1 values");
core.setFailed("dispatched tag object and commit must be full lowercase SHA-1 values");
return;
}
if (!/^[1-9]\d*$/.test(failedRunId)) {
if (isRecovery && !/^[1-9]\d*$/.test(failedRunId)) {
core.setFailed("recover_failed_run_id must be a positive workflow run ID");
return;
}
if (!/^[1-9]\d*$/.test(failedRunAttempt)) {
if (isRecovery && !/^[1-9]\d*$/.test(failedRunAttempt)) {
core.setFailed("recover_failed_run_attempt must be a positive run attempt");
return;
}
@@ -432,12 +526,67 @@ jobs:
core.setFailed(`${version} resolves to ${tag.data.object.sha}, expected ${commit}`);
return;
}
if (isRecovery && tagObject !== expectedTagObject) {
if ((isRecovery || isCreate) && tagObject !== expectedTagObject) {
core.setFailed(`${version} tag object is ${tagObject}, expected ${expectedTagObject}`);
return;
}
const tagFields = new Map();
for (const line of tag.data.message.split(/\r?\n/)) {
const separator = line.indexOf(": ");
if (separator < 1) continue;
const key = line.slice(0, separator);
if (tagFields.has(key)) {
core.setFailed(`${version} annotated tag contains duplicate ${key} metadata`);
return;
}
tagFields.set(key, line.slice(separator + 2));
}
const cloudKeys = [
"Channel",
"Release-Run",
"Release-Run-Attempt",
"Requested-By",
"Requested-By-ID",
"Sealed-Commit",
"Workflow-Commit",
"Allocation-Fingerprint",
];
const hasAnyCloudMetadata = cloudKeys.some((key) => tagFields.has(key));
const isCloudSeal = cloudKeys.every((key) => tagFields.has(key));
if (hasAnyCloudMetadata && !isCloudSeal) {
core.setFailed(`${version} contains incomplete cloud seal metadata`);
return;
}
if (isCloudSeal) {
const expectedChannel = version.includes("-beta.") ? "prerelease" : "stable";
const actorId = String(context.payload.sender?.id || "");
if (
tagFields.get("Channel") !== expectedChannel ||
tagFields.get("Sealed-Commit") !== commit ||
tagFields.get("Workflow-Commit") !== commit ||
!/^[0-9a-f]{64}$/.test(tagFields.get("Allocation-Fingerprint")) ||
!/^[1-9]\d*$/.test(tagFields.get("Release-Run")) ||
!/^[1-9]\d*$/.test(tagFields.get("Release-Run-Attempt")) ||
!/^[1-9]\d*$/.test(tagFields.get("Requested-By-ID"))
) {
core.setFailed(`${version} cloud seal metadata does not match its tag authority`);
return;
}
if (isCreate && (
tagFields.get("Release-Run") !== String(context.runId) ||
tagFields.get("Release-Run-Attempt") !== process.env.GITHUB_RUN_ATTEMPT ||
tagFields.get("Requested-By") !== context.actor ||
tagFields.get("Requested-By-ID") !== actorId
)) {
core.setFailed(`${version} cloud seal is not bound to this release request`);
return;
}
} else if (isCreate) {
core.setFailed(`${version} is missing cloud seal metadata`);
return;
}
if (isRecovery) {
if (isRecovery || isCreate) {
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({
@@ -449,6 +598,8 @@ jobs:
core.setFailed(`${version} commit is not contained in ${defaultBranch}`);
return;
}
}
if (isRecovery) {
const failedRun = await github.request(
"GET /repos/{owner}/{repo}/actions/runs/{run_id}/attempts/{attempt_number}",
{
@@ -459,19 +610,34 @@ jobs:
},
);
const run = failedRun.data;
const failedByCloud = isCloudSeal;
const expectedBranch = failedByCloud
? context.payload.repository.default_branch
: version;
const expectedEvent = failedByCloud ? "workflow_dispatch" : "push";
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" ||
run.event !== expectedEvent ||
run.status !== "completed" ||
!["failure", "cancelled", "timed_out", "startup_failure", "stale"].includes(run.conclusion) ||
run.head_branch !== version ||
run.head_branch !== expectedBranch ||
run.head_sha !== commit
) {
core.setFailed(
`run ${failedRunId} attempt ${failedRunAttempt} is not the failed exact-tag Release attempt for ${version}`,
`run ${failedRunId} attempt ${failedRunAttempt} is not the failed sealed Release attempt for ${version}`,
);
return;
}
if (failedByCloud && (
tagFields.get("Release-Run") !== failedRunId ||
tagFields.get("Release-Run-Attempt") !== failedRunAttempt ||
tagFields.get("Requested-By-ID") !== String(run.actor?.id || "")
)) {
core.setFailed(
`run ${failedRunId} attempt ${failedRunAttempt} is not bound by the cloud seal for ${version}`,
);
return;
}
@@ -512,7 +678,8 @@ jobs:
'+refs/heads/main:refs/remotes/origin/main'
git fetch --force --no-tags \
https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git \
'+refs/tags/v*:refs/tags/v*'
'+refs/tags/v*:refs/tags/v*' \
'+refs/tags/withdrawn/v*:refs/tags/withdrawn/v*'
- name: Validate release contract
id: contract
@@ -757,7 +924,8 @@ jobs:
'+refs/heads/main:refs/remotes/origin/main'
git fetch --force --no-tags \
https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git \
'+refs/tags/v*:refs/tags/v*'
'+refs/tags/v*:refs/tags/v*' \
'+refs/tags/withdrawn/v*:refs/tags/withdrawn/v*'
- name: Verify sealed tag before executing release source
env:
@@ -952,7 +1120,8 @@ jobs:
'+refs/heads/main:refs/remotes/origin/main'
git fetch --force --no-tags \
https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git \
'+refs/tags/v*:refs/tags/v*'
'+refs/tags/v*:refs/tags/v*' \
'+refs/tags/withdrawn/v*:refs/tags/withdrawn/v*'
- name: Verify sealed tag before executing publication source
env:
@@ -1337,7 +1506,8 @@ jobs:
'+refs/heads/main:refs/remotes/origin/main'
git fetch --force --no-tags \
https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git \
'+refs/tags/v*:refs/tags/v*'
'+refs/tags/v*:refs/tags/v*' \
'+refs/tags/withdrawn/v*:refs/tags/withdrawn/v*'
- name: Verify sealed tag before executing channel source
env:
@@ -1568,7 +1738,8 @@ jobs:
run: |
git fetch --force --no-tags \
https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git \
'+refs/tags/v*:refs/tags/v*'
'+refs/tags/v*:refs/tags/v*' \
'+refs/tags/withdrawn/v*:refs/tags/withdrawn/v*'
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-github-tag-authority.sh" \
"$RELEASE_VERSION" "$RELEASE_COMMIT" "$RELEASE_TAG_OBJECT"
@@ -1628,6 +1799,17 @@ jobs:
script: |
const { owner, repo } = context.repo;
const version = process.env.VERSION;
try {
await github.rest.git.getRef({
owner,
repo,
ref: `tags/withdrawn/${version}`,
});
core.setFailed(`${version} was withdrawn and cannot be repaired`);
return;
} catch (error) {
if (error.status !== 404) throw error;
}
const ref = await github.rest.git.getRef({
owner,
repo,
@@ -1694,100 +1876,18 @@ jobs:
core.setFailed(`${version} does not contain the exact release asset set`);
return;
}
const runs = await github.paginate(github.rest.actions.listWorkflowRuns, {
owner,
repo,
workflow_id: "release.yml",
branch: version,
event: "push",
status: "completed",
per_page: 100,
});
let passed = false;
for (const run of runs) {
if (run.head_sha !== commitSha || run.head_branch !== version) continue;
const jobs = await github.paginate(github.rest.actions.listJobsForWorkflowRun, {
owner,
repo,
run_id: run.id,
filter: "all",
per_page: 100,
});
if (
jobs.some(
(job) =>
job.name === "Publish immutable GitHub Release" &&
job.head_sha === commitSha &&
job.steps?.some(
(step) =>
step.name === "Require immutable published GitHub Release" &&
step.conclusion === "success",
),
)
) {
passed = true;
break;
}
}
if (!passed) {
const recoveryTitle = `Release recovery ${version} at ${commitSha}`;
const recoveries = await github.paginate(github.rest.actions.listWorkflowRuns, {
owner,
repo,
workflow_id: "release.yml",
branch: defaultBranch,
event: "workflow_dispatch",
status: "completed",
per_page: 100,
});
for (const run of recoveries) {
const nonce = run.display_title.startsWith(`${recoveryTitle} `)
? run.display_title.slice(recoveryTitle.length + 1)
: "";
if (
!new RegExp(`^${commitSha}-[0-9]+-[0-9]+$`).test(nonce) ||
run.head_branch !== defaultBranch ||
run.path !== ".github/workflows/release.yml" ||
run.repository.full_name !== `${owner}/${repo}`
) {
continue;
}
const workflowComparison = await github.rest.repos.compareCommitsWithBasehead({
owner,
repo,
basehead: `${run.head_sha}...${branch.data.commit.sha}`,
});
if (!["ahead", "identical"].includes(workflowComparison.data.status)) continue;
const jobs = await github.paginate(github.rest.actions.listJobsForWorkflowRun, {
owner,
repo,
run_id: run.id,
filter: "all",
per_page: 100,
});
if (
jobs.some(
(job) =>
job.name === "Publish immutable GitHub Release" &&
job.head_sha === run.head_sha &&
job.steps?.some(
(step) =>
step.name === "Require immutable published GitHub Release" &&
step.conclusion === "success",
),
)
) {
passed = true;
break;
}
}
}
if (!passed) {
core.setFailed(`Immutable GitHub Release job did not succeed for ${version} at ${commitSha}`);
return;
}
core.setOutput("commit_sha", commitSha);
- name: Require successful Release workflow delivery
working-directory: tooling
env:
VERSION: ${{ inputs.repair_npm_version }}
RELEASE_COMMIT: ${{ steps.authority.outputs.commit_sha }}
DWS_RELEASE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
./scripts/release/verify-release-workflow-delivery.sh --npm-repair \
"$VERSION" "$RELEASE_COMMIT"
- name: Check out target release source
uses: actions/checkout@v4
with:
@@ -1888,6 +1988,8 @@ jobs:
- mirror-gitee-release
- repair-npm
- repair-channel
- release-plan
- seal-release
runs-on: ubuntu-latest
timeout-minutes: 2
permissions: {}
@@ -1908,6 +2010,8 @@ jobs:
MIRROR_GITEE_RESULT: ${{ needs.mirror-gitee-release.result }}
REPAIR_NPM_RESULT: ${{ needs.repair-npm.result }}
REPAIR_CHANNEL_RESULT: ${{ needs.repair-channel.result }}
RELEASE_PLAN_RESULT: ${{ needs.release-plan.result }}
SEAL_RELEASE_RESULT: ${{ needs.seal-release.result }}
run: |
set -eu
require_result() {
@@ -1931,6 +2035,10 @@ jobs:
require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped
fi
}
require_cloud_jobs_skipped() {
require_result release-plan "$RELEASE_PLAN_RESULT" skipped
require_result seal-release "$SEAL_RELEASE_RESULT" skipped
}
case "$EVENT_NAME:$DISPATCH_MODE" in
push:)
require_result dispatch-contract "$DISPATCH_RESULT" skipped
@@ -1938,14 +2046,41 @@ jobs:
require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" skipped
require_result repair-npm "$REPAIR_NPM_RESULT" skipped
require_result repair-channel "$REPAIR_CHANNEL_RESULT" skipped
require_cloud_jobs_skipped
require_publication
;;
workflow_dispatch:create_release)
require_result dispatch-contract "$DISPATCH_RESULT" success
require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" success
require_result release-plan "$RELEASE_PLAN_RESULT" success
require_result seal-release "$SEAL_RELEASE_RESULT" success
require_result authorize-recovery "$AUTHORIZE_RECOVERY_RESULT" skipped
require_result repair-npm "$REPAIR_NPM_RESULT" skipped
require_result repair-channel "$REPAIR_CHANNEL_RESULT" skipped
require_publication
;;
workflow_dispatch:plan_release)
require_result dispatch-contract "$DISPATCH_RESULT" success
require_result release-plan "$RELEASE_PLAN_RESULT" success
require_result seal-release "$SEAL_RELEASE_RESULT" skipped
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
require_result repair-channel "$REPAIR_CHANNEL_RESULT" skipped
;;
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-channel "$REPAIR_CHANNEL_RESULT" skipped
require_cloud_jobs_skipped
require_publication
;;
workflow_dispatch:governance_preflight)
@@ -1960,6 +2095,7 @@ jobs:
require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped
require_result repair-npm "$REPAIR_NPM_RESULT" skipped
require_result repair-channel "$REPAIR_CHANNEL_RESULT" skipped
require_cloud_jobs_skipped
;;
workflow_dispatch:repair_npm)
require_result dispatch-contract "$DISPATCH_RESULT" success
@@ -1973,6 +2109,7 @@ jobs:
require_result publish-channels "$PUBLISH_CHANNELS_RESULT" skipped
require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped
require_result repair-channel "$REPAIR_CHANNEL_RESULT" skipped
require_cloud_jobs_skipped
;;
workflow_dispatch:repair_gitee)
require_result dispatch-contract "$DISPATCH_RESULT" success
@@ -1986,6 +2123,7 @@ jobs:
require_result publish-channels "$PUBLISH_CHANNELS_RESULT" skipped
require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped
require_result repair-npm "$REPAIR_NPM_RESULT" skipped
require_cloud_jobs_skipped
;;
workflow_dispatch:repair_oss)
require_result dispatch-contract "$DISPATCH_RESULT" success
@@ -1999,6 +2137,7 @@ jobs:
require_result publish-channels "$PUBLISH_CHANNELS_RESULT" skipped
require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped
require_result repair-npm "$REPAIR_NPM_RESULT" skipped
require_cloud_jobs_skipped
;;
*)
echo "unsupported release mode: event=$EVENT_NAME mode=$DISPATCH_MODE" >&2
@@ -2050,6 +2189,17 @@ jobs:
script: |
const { owner, repo } = context.repo;
const version = process.env.VERSION;
try {
await github.rest.git.getRef({
owner,
repo,
ref: `tags/withdrawn/${version}`,
});
core.setFailed(`${version} was withdrawn and cannot be repaired`);
return;
} catch (error) {
if (error.status !== 404) throw error;
}
const ref = await github.rest.git.getRef({
owner,
repo,
@@ -2142,7 +2292,8 @@ jobs:
set -eu
git fetch --force --no-tags \
https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git \
'+refs/tags/v*:refs/tags/v*'
'+refs/tags/v*:refs/tags/v*' \
'+refs/tags/withdrawn/v*:refs/tags/withdrawn/v*'
./scripts/release/verify-github-tag-authority.sh \
"$VERSION" "$RELEASE_COMMIT" "$RELEASE_TAG_OBJECT"
@@ -2207,3 +2358,348 @@ jobs:
OSS_BUCKET: ${{ secrets.OSS_BUCKET }}
OSS_PREFIX: ${{ secrets.OSS_PREFIX }}
DWS_REQUIRE_OSS: "1"
release-plan:
name: Plan next cloud release
needs: [dispatch-contract, governance-preflight]
if: ${{ !cancelled() && needs.dispatch-contract.result == 'success' && (needs.dispatch-contract.outputs.mode == 'plan_release' || (needs.dispatch-contract.outputs.mode == 'create_release' && needs.governance-preflight.result == 'success')) }}
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
actions: read
contents: read
outputs:
release_version: ${{ steps.allocate.outputs.release_version }}
release_commit: ${{ steps.allocate.outputs.release_commit }}
channel: ${{ steps.allocate.outputs.channel }}
from_beta: ${{ steps.allocate.outputs.from_beta }}
base: ${{ steps.allocate.outputs.base }}
refs_fingerprint: ${{ steps.allocate.outputs.refs_fingerprint }}
steps:
- name: Validate official default-branch release request
env:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
EXPECTED_REPOSITORY: DingTalk-Real-AI/dingtalk-workspace-cli
run: |
set -eu
test "$GITHUB_REPOSITORY" = "$EXPECTED_REPOSITORY" || {
echo "cloud releases are restricted to $EXPECTED_REPOSITORY" >&2
exit 1
}
test "$GITHUB_REF" = "refs/heads/$DEFAULT_BRANCH" || {
echo "cloud releases must be dispatched from $DEFAULT_BRANCH" >&2
exit 1
}
printf '%s\n' "$GITHUB_SHA" | grep -Eq '^[0-9a-f]{40}$' || {
echo "cloud release commit must be a full lowercase SHA" >&2
exit 1
}
- name: Check out exact default-branch commit
uses: actions/checkout@v4
with:
ref: ${{ github.sha }}
persist-credentials: false
fetch-depth: 0
- name: Fetch current main and all allocated versions
run: |
set -eu
git fetch --force origin \
'+refs/heads/main:refs/remotes/origin/main'
git fetch --force --no-tags \
https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git \
'+refs/tags/v*:refs/tags/v*' \
'+refs/tags/withdrawn/v*:refs/tags/withdrawn/v*'
test "$(git rev-parse HEAD)" = "$GITHUB_SHA" || {
echo "checked-out release commit differs from the dispatched commit" >&2
exit 1
}
test "$(git rev-parse refs/remotes/origin/main)" = "$GITHUB_SHA" || {
echo "main moved after this release was dispatched; run it again from current main" >&2
exit 1
}
- name: Allocate the next version
id: allocate
env:
REQUESTED_CHANNEL: ${{ inputs.release_channel }}
REQUESTED_BUMP: ${{ inputs.release_bump }}
run: |
set -eu
allocation="$RUNNER_TEMP/next-release-version"
./scripts/release/next-release-version.sh \
--channel "$REQUESTED_CHANNEL" \
--bump "$REQUESTED_BUMP" > "$allocation"
for key in release_version from_beta channel base; do
count="$(grep -c "^$key=" "$allocation")"
test "$count" -eq 1 || {
echo "allocator returned $count values for $key" >&2
exit 1
}
value="$(sed -n "s/^$key=//p" "$allocation")"
printf '%s=%s\n' "$key" "$value" >> "$GITHUB_OUTPUT"
done
refs_fingerprint="$(
git for-each-ref --format='%(refname)=%(objectname)' \
refs/tags/v refs/tags/withdrawn/v \
| LC_ALL=C sort \
| sha256sum \
| awk '{print $1}'
)"
echo "release_commit=$GITHUB_SHA" >> "$GITHUB_OUTPUT"
echo "refs_fingerprint=$refs_fingerprint" >> "$GITHUB_OUTPUT"
release_version="$(sed -n 's/^release_version=//p' "$allocation")"
from_beta="$(sed -n 's/^from_beta=//p' "$allocation")"
channel="$(sed -n 's/^channel=//p' "$allocation")"
{
echo "## Cloud release plan"
echo
echo "- Version: \`$release_version\`"
echo "- Channel: \`$channel\`"
echo "- Commit: \`$GITHUB_SHA\`"
if test -n "$from_beta"; then
echo "- Promotes beta: \`$from_beta\`"
fi
if test "${{ needs.dispatch-contract.outputs.mode }}" = plan_release; then
echo
echo "Plan only: no tag or package was created. Add the exact \`CHANGELOG.md\` section, merge it to main, then run publish."
fi
} >> "$GITHUB_STEP_SUMMARY"
- name: Validate the candidate release contract before sealing
if: ${{ needs.dispatch-contract.outputs.mode == 'create_release' }}
env:
RELEASE_VERSION: ${{ steps.allocate.outputs.release_version }}
RELEASE_CHANNEL: ${{ steps.allocate.outputs.channel }}
FROM_BETA: ${{ steps.allocate.outputs.from_beta }}
run: |
set -eu
git config user.name "dws release cloud seal"
git config user.email "dws-release@users.noreply.github.com"
{
printf 'Release %s\n\n' "$RELEASE_VERSION"
printf 'Channel: %s\n' "$RELEASE_CHANNEL"
test -z "$FROM_BETA" || printf 'From-Beta: %s\n' "$FROM_BETA"
} > "$RUNNER_TEMP/candidate-tag-message"
git tag -a "$RELEASE_VERSION" HEAD -F "$RUNNER_TEMP/candidate-tag-message"
set -- \
--channel "$RELEASE_CHANNEL" \
--version "$RELEASE_VERSION" \
--context ci \
--remote origin \
--branch main \
--notes-output "$RUNNER_TEMP/release-notes.md" \
--metadata-output "$RUNNER_TEMP/release-contract-output"
if test -n "$FROM_BETA"; then
set -- "$@" --from-beta "$FROM_BETA"
fi
./scripts/release/release-contract.sh "$@"
- name: Require delivered previous stable baseline before sealing
if: ${{ needs.dispatch-contract.outputs.mode == 'create_release' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DWS_RELEASE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -eu
previous_stable="$(sed -n 's/^previous_stable=//p' "$RUNNER_TEMP/release-contract-output")"
previous_commit="$(sed -n 's/^previous_stable_commit=//p' "$RUNNER_TEMP/release-contract-output")"
if test -n "$previous_stable"; then
./scripts/release/verify-delivered-stable.sh "$previous_stable" "$previous_commit"
fi
- name: Require delivered beta before sealing stable
if: ${{ needs.dispatch-contract.outputs.mode == 'create_release' && steps.allocate.outputs.channel == 'stable' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DWS_RELEASE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
FROM_BETA: ${{ steps.allocate.outputs.from_beta }}
run: |
set -eu
from_beta_commit="$(git rev-parse "$FROM_BETA^{commit}")"
release_state="$(
gh api -H 'Accept: application/vnd.github+json' \
"repos/$GITHUB_REPOSITORY/releases/tags/$FROM_BETA" \
--jq '[.tag_name, .draft, .prerelease, .immutable] | @tsv'
)"
test "$release_state" = "$(printf '%s\tfalse\ttrue\ttrue' "$FROM_BETA")" || {
echo "$FROM_BETA is not a published immutable prerelease" >&2
exit 1
}
./scripts/release/verify-github-release-assets.sh "$FROM_BETA"
./scripts/release/verify-release-workflow-delivery.sh \
"$FROM_BETA" "$from_beta_commit"
seal-release:
name: Seal cloud release tag
needs: [dispatch-contract, governance-preflight, release-plan]
if: ${{ !cancelled() && needs.dispatch-contract.result == 'success' && needs.dispatch-contract.outputs.mode == 'create_release' && needs.governance-preflight.result == 'success' && needs.release-plan.result == 'success' }}
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: write
outputs:
release_version: ${{ steps.seal.outputs.release_version }}
release_commit: ${{ steps.seal.outputs.release_commit }}
release_tag_object: ${{ steps.seal.outputs.release_tag_object }}
steps:
- name: Create one immutable annotated release tag
id: seal
uses: actions/github-script@v7
env:
RELEASE_VERSION: ${{ needs.release-plan.outputs.release_version }}
RELEASE_COMMIT: ${{ needs.release-plan.outputs.release_commit }}
RELEASE_CHANNEL: ${{ needs.release-plan.outputs.channel }}
FROM_BETA: ${{ needs.release-plan.outputs.from_beta }}
REFS_FINGERPRINT: ${{ needs.release-plan.outputs.refs_fingerprint }}
with:
script: |
const crypto = require("crypto");
const { owner, repo } = context.repo;
const expectedRepository = "DingTalk-Real-AI/dingtalk-workspace-cli";
const defaultBranch = context.payload.repository.default_branch;
const version = process.env.RELEASE_VERSION;
const commit = process.env.RELEASE_COMMIT;
const channel = process.env.RELEASE_CHANNEL;
const fromBeta = process.env.FROM_BETA;
const expectedFingerprint = process.env.REFS_FINGERPRINT;
if (
context.payload.repository.full_name !== expectedRepository ||
context.ref !== `refs/heads/${defaultBranch}` ||
context.sha !== commit
) {
core.setFailed("cloud release seal is not bound to the official dispatched default-branch commit");
return;
}
if (
!/^v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-beta\.[1-9]\d*)?$/.test(version) ||
!/^[0-9a-f]{40}$/.test(commit) ||
!/^[0-9a-f]{64}$/.test(expectedFingerprint)
) {
core.setFailed("cloud release plan contains an invalid sealed identity");
return;
}
if (
(channel === "stable" && !fromBeta) ||
(channel === "prerelease" && fromBeta)
) {
core.setFailed("cloud release channel and beta promotion baseline disagree");
return;
}
const branch = await github.rest.repos.getBranch({
owner,
repo,
branch: defaultBranch,
});
if (branch.data.commit.sha !== commit) {
core.setFailed(`${defaultBranch} moved to ${branch.data.commit.sha}; run the release again`);
return;
}
const refs = [];
for (const prefix of ["tags/v", "tags/withdrawn/v"]) {
const matching = await github.paginate(github.rest.git.listMatchingRefs, {
owner,
repo,
ref: prefix,
per_page: 100,
});
for (const ref of matching) {
refs.push(`${ref.ref}=${ref.object.sha}`);
}
}
refs.sort();
const actualFingerprint = crypto
.createHash("sha256")
.update(refs.length > 0 ? `${refs.join("\n")}\n` : "")
.digest("hex");
if (actualFingerprint !== expectedFingerprint) {
core.setFailed("allocated release refs changed after planning; run the release again");
return;
}
try {
await github.rest.git.getRef({ owner, repo, ref: `tags/${version}` });
core.setFailed(`release tag already exists: ${version}`);
return;
} catch (error) {
if (error.status !== 404) throw error;
}
try {
await github.rest.git.getRef({
owner,
repo,
ref: `tags/withdrawn/${version}`,
});
core.setFailed(`release version was withdrawn and cannot be reused: ${version}`);
return;
} catch (error) {
if (error.status !== 404) throw error;
}
const message = [
`Release ${version}`,
"",
`Channel: ${channel}`,
...(fromBeta ? [`From-Beta: ${fromBeta}`] : []),
`Release-Run: ${context.runId}`,
`Release-Run-Attempt: ${process.env.GITHUB_RUN_ATTEMPT}`,
`Requested-By: ${context.actor}`,
`Requested-By-ID: ${context.payload.sender?.id || ""}`,
`Sealed-Commit: ${commit}`,
`Workflow-Commit: ${context.sha}`,
`Allocation-Fingerprint: ${expectedFingerprint}`,
].join("\n");
const tag = await github.rest.git.createTag({
owner,
repo,
tag: version,
message,
object: commit,
type: "commit",
});
await github.rest.git.createRef({
owner,
repo,
ref: `refs/tags/${version}`,
sha: tag.data.sha,
});
const sealedRef = await github.rest.git.getRef({
owner,
repo,
ref: `tags/${version}`,
});
if (
sealedRef.data.object.type !== "tag" ||
sealedRef.data.object.sha !== tag.data.sha
) {
core.setFailed(`${version} did not seal to the new annotated tag object`);
return;
}
const sealedTag = await github.rest.git.getTag({
owner,
repo,
tag_sha: tag.data.sha,
});
if (
sealedTag.data.tag !== version ||
sealedTag.data.object.type !== "commit" ||
sealedTag.data.object.sha !== commit ||
sealedTag.data.message !== message
) {
core.setFailed(`${version} annotated tag authority differs after creation`);
return;
}
core.setOutput("release_version", version);
core.setOutput("release_commit", commit);
core.setOutput("release_tag_object", tag.data.sha);
await core.summary
.addHeading("Cloud release sealed")
.addRaw(`\`${version}\` → \`${commit}\` (run ${context.runId})`)
.write();
+148
View File
@@ -0,0 +1,148 @@
name: Withdraw release
run-name: Withdraw ${{ inputs.version }}
on:
workflow_dispatch:
inputs:
version:
description: "Exact published version to withdraw (vX.Y.Z or vX.Y.Z-beta.N)"
required: true
type: string
reason:
description: "Public, single-line withdrawal reason (8-300 characters)"
required: true
type: string
confirmation:
description: "Type WITHDRAW followed by a space and the exact version"
required: true
type: string
permissions:
contents: read
# Share the publication lock with release.yml. A withdrawal and a publication
# must never mutate channel pointers concurrently.
concurrency:
group: dws-release-publication
cancel-in-progress: false
jobs:
withdraw:
name: Withdraw release from every distribution channel
environment: release-withdrawal
runs-on: ubuntu-latest
timeout-minutes: 180
permissions:
actions: read
contents: write
steps:
- name: Verify withdrawal environment protection
uses: actions/github-script@v7
with:
script: |
const { owner, repo } = context.repo;
const response = await github.request(
"GET /repos/{owner}/{repo}/environments/{environment_name}",
{ owner, repo, environment_name: "release-withdrawal" },
);
const reviewerRule = response.data.protection_rules.find(
(rule) => rule.type === "required_reviewers",
);
if (
!reviewerRule ||
reviewerRule.prevent_self_review !== true ||
!Array.isArray(reviewerRule.reviewers) ||
reviewerRule.reviewers.length === 0
) {
core.setFailed("release-withdrawal must require a reviewer and prevent self-review");
return;
}
if (response.data.deployment_branch_policy?.protected_branches !== true) {
core.setFailed("release-withdrawal must allow only protected branches");
}
if (response.data.can_admins_bypass !== false) {
core.setFailed("release-withdrawal must not allow administrator bypass");
}
- name: Require the exact current official default-branch commit
uses: actions/github-script@v7
with:
script: |
const expectedRepository = "DingTalk-Real-AI/dingtalk-workspace-cli";
const defaultBranch = context.payload.repository.default_branch;
if (context.eventName !== "workflow_dispatch") {
core.setFailed("release withdrawal accepts workflow_dispatch only");
return;
}
if (`${context.repo.owner}/${context.repo.repo}` !== expectedRepository) {
core.setFailed(`release withdrawal is restricted to ${expectedRepository}`);
return;
}
if (context.ref !== `refs/heads/${defaultBranch}`) {
core.setFailed(`release withdrawal must be dispatched from ${defaultBranch}`);
return;
}
const branch = await github.rest.git.getRef({
...context.repo,
ref: `heads/${defaultBranch}`,
});
if (branch.data.object.sha !== context.sha) {
core.setFailed(
`default branch advanced to ${branch.data.object.sha}; re-dispatch from the new head`,
);
}
- name: Check out trusted withdrawal tooling
uses: actions/checkout@v4
with:
ref: ${{ github.sha }}
fetch-depth: 0
persist-credentials: false
- name: Set up Node.js for npm channel withdrawal
uses: actions/setup-node@v4
with:
node-version: "22"
registry-url: "https://registry.npmjs.org"
- name: Withdraw immutable release and roll back channels
id: withdrawal
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
GITHUB_EVENT_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
WITHDRAW_VERSION: ${{ inputs.version }}
WITHDRAW_REASON: ${{ inputs.reason }}
WITHDRAW_CONFIRMATION: ${{ inputs.confirmation }}
OSS_ACCESS_KEY_ID: ${{ secrets.OSS_ACCESS_KEY_ID }}
OSS_ACCESS_KEY_SECRET: ${{ secrets.OSS_ACCESS_KEY_SECRET }}
OSS_ENDPOINT: ${{ secrets.OSS_ENDPOINT }}
OSS_BUCKET: ${{ secrets.OSS_BUCKET }}
OSS_PREFIX: ${{ secrets.OSS_PREFIX }}
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
GITEE_USER: ${{ secrets.GITEE_USER }}
GITEE_REPO: ${{ secrets.GITEE_REPO }}
DWS_GITEE_ENABLED: ${{ vars.ENABLE_GITEE_UPLOAD_FALLBACK == 'true' && 'true' || 'false' }}
HOMEBREW_PR_TOKEN: ${{ secrets.HOMEBREW_PR_TOKEN }}
run: |
./scripts/release/withdraw-release.sh \
"$WITHDRAW_VERSION" \
"$WITHDRAW_REASON" \
"$WITHDRAW_CONFIRMATION"
- name: Report withdrawal boundary
if: ${{ always() }}
env:
VERSION: ${{ inputs.version }}
RESULT: ${{ steps.withdrawal.outcome }}
run: |
{
echo "### Release withdrawal: ${VERSION}"
echo
echo "- Workflow result: ${RESULT}"
echo "- Success means every configured channel was verified and the permanent withdrawn/${VERSION} tombstone remains as the version-reuse barrier."
echo "- Failure may occur before or after the tombstone/channel mutations; inspect the failed step and rerun the exact same inputs after fixing the cause."
echo "- The problem GitHub Release and original tag are removed after npm/OSS/Gitee rollback so GitHub installers stop resolving the bad version while the Homebrew rollback PR is reviewed."
echo "- npm is deprecated rather than unpublished; already-installed clients cannot be remotely downgraded."
echo "- If a Homebrew rollback PR was opened, this run remains failed until that PR is independently reviewed, merged, and the workflow is rerun."
} >> "$GITHUB_STEP_SUMMARY"
+1 -1
View File
@@ -36,8 +36,8 @@ Common repository checks already used here include:
./scripts/policy/check-open-source-assets.sh
go test ./...
make test
make test-plan
make lint
bash test/scripts/run_all_tests.sh --jobs 8
./scripts/policy/check-generated-drift.sh
./scripts/policy/check-command-surface.sh --strict
./scripts/release/verify-package-managers.sh
+28 -5
View File
@@ -1,12 +1,14 @@
GO ?= go
DWS_PACKAGE_VERSION ?= 0.0.0-test
REMOTE ?=
PUBLISH ?= 0
YES ?= 0
DWS_POLICY_TMPDIR ?= $(CURDIR)/.worktrees/policy-tmp
POLICY_GOTMPDIR ?= $(DWS_POLICY_TMPDIR)/go
POLICY_ENV = DWS_POLICY_TMPDIR="$(DWS_POLICY_TMPDIR)" GOTMPDIR="$(POLICY_GOTMPDIR)"
GO_SOURCE_LIST = git ls-files -z --cached --others --exclude-standard -- '*.go'
.PHONY: all help build rebuild test lint fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema generate-schema-agent-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks
.PHONY: all help build rebuild test test-plan lint format-check fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema generate-schema-agent-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks
all: setup-hooks fmt lint build test rebuild
@@ -14,8 +16,10 @@ help:
@printf "Available targets:\n"
@printf " make build - Build the dws CLI binary\n"
@printf " make test - Run the Go test suite\n"
@printf " make lint - Run formatting checks and golangci-lint when available\n"
@printf " make fmt - Format Go source files\n"
@printf " make test-plan - Verify every default Go package belongs to one CI test shard\n"
@printf " make lint - Run formatting checks, go vet, and staticcheck\n"
@printf " make format-check - Check all repository Go source files with gofmt\n"
@printf " make fmt - Format all repository Go source files\n"
@printf " make policy - Check the built dws plus open-source and Schema policies\n"
@printf " make interface-integrity - Check historical commands and help contracts still work\n"
@printf " make authoritative-interface-integrity BASE_REF=<ref> - Check the Git-owned PR merge-base\n"
@@ -45,13 +49,32 @@ rebuild:
@./scripts/dev/build.sh
test:
@./test/scripts/run_all_tests.sh --timeout 5m
@DWS_PACKAGE_VERSION="$(DWS_PACKAGE_VERSION)" $(GO) test -count=1 -timeout=10m ./...
test-plan:
@./scripts/ci/test-packages.sh verify
lint:
@./scripts/dev/lint.sh
format-check:
@set -eu; \
go_files="$$(mktemp "$${TMPDIR:-/tmp}/dws-go-files.XXXXXX")"; \
trap 'rm -f "$$go_files"' EXIT HUP INT TERM; \
$(GO_SOURCE_LIST) > "$$go_files"; \
unformatted="$$(xargs -0 sh -c 'if [ "$$#" -gt 0 ]; then exec gofmt -l -- "$$@"; fi' sh < "$$go_files")"; \
if [ -n "$$unformatted" ]; then \
printf '%s\n' "$$unformatted"; \
printf '%s\n' "Go files are not formatted. Run 'make fmt'." >&2; \
exit 1; \
fi
fmt:
@find cmd internal test scripts/policy -name '*.go' -print0 2>/dev/null | xargs -0r gofmt -w
@set -eu; \
go_files="$$(mktemp "$${TMPDIR:-/tmp}/dws-go-files.XXXXXX")"; \
trap 'rm -f "$$go_files"' EXIT HUP INT TERM; \
$(GO_SOURCE_LIST) > "$$go_files"; \
xargs -0 sh -c 'if [ "$$#" -gt 0 ]; then exec gofmt -w -- "$$@"; fi' sh < "$$go_files"
policy:
@mkdir -p "$(POLICY_GOTMPDIR)"
+64 -14
View File
@@ -1,10 +1,52 @@
# 发布手册(预发 / 正式)
发布只走一条链路:本地脚本负责封板、验证并推送 annotated tag;GitHub Actions 负责构建和发布最终产物。不要直接运行 `goreleaser release`,也不要手工补打或移动 tag。
发布只走一条受控链路:GitHub Actions 的 `Release` workflow 负责版本分配、封板、构建、签名和下游发布;Homebrew 以 workflow 自动创建的 Formula PR 经独立审核合入为交付边界。本地 `dws-release` 仍是兼容入口,但不再要求某一台固定电脑承担打包;不要直接运行 `goreleaser release`,也不要手工补打、移动或复用 tag。
发布前必须完成平台治理:目标 GitHub 仓库已启用 immutable releases,`main` 精确要求 `CI` workflow 的九个 context:`Lint`、`Test`、`Coverage`、`Policy`、`Edition`、`Interface Integrity`、`AI Behavior`、`CLI Smoke`、`Mock MCP`,操作机已安装并登录 `gh`。本地脚本会在封 tag 前通过 API 检查 immutable releases、当前 SHA 的全部九个 context 和在途 Release;`v*` tag ruleset 仍需仓库管理员预先配置并由操作人确认。
发布前必须完成平台治理:目标 GitHub 仓库已启用 immutable releases,`main` 精确要求 `CI` workflow 的九个 context:`Lint`、`Test`、`Coverage`、`Policy`、`Edition`、`Interface Integrity`、`AI Behavior`、`CLI Smoke`、`Mock MCP`。云端和本地入口都会在封 tag 前检查 immutable releases、当前 SHA 的全部九个 context 和在途 Release;`v*` tag ruleset 仍需仓库管理员预先配置。
## 日常只用一个入口
## 推荐入口:GitHub 云端发布
任何具有仓库写权限、因而可以手动运行 Actions workflow 的成员,都可以基于当时最新的 `main` 发起发布:
1. 在 GitHub Actions 打开 `Release`,选择 `Run workflow`,分支必须是默认分支 `main`。
2. `release_operation=plan`,选择 `release_channel=beta|stable`;仅在开始新 beta 线时选择 `release_bump=patch|minor|major`。
3. workflow summary 会给出唯一的下一版本。把对应的精确 `CHANGELOG.md` 章节通过 PR 合入 `main`。
4. 再次运行,改为 `release_operation=publish`,并输入 `PUBLISH beta` 或 `PUBLISH stable`。
`plan` 是纯只读操作,不创建 tag、预留版本号或生成包。CHANGELOG 合入期间若另一个发布先占用了该版本,`publish` 会重新分配并因 CHANGELOG 章节不匹配而拒绝,需要重新 plan。`publish` 会先再次确认 dispatch SHA 仍是当前 `main`、Code Admission 和平台治理均通过,再由唯一的 write job 使用 GitHub API 原子创建 annotated tag;同一次 run 随即进入既有的跨平台构建、GitHub/npm/OSS/Gitee 发布和 Homebrew PR DAG。内置 `GITHUB_TOKEN` 创建的 tag 不依赖第二条 workflow 被再次触发。
## 自动版本规则
- beta:如果存在尚未封正式版的最高版本线,自动取 `beta.N+1`;否则从最新已分配正式版按所选 patch/minor/major 开新线并取 `beta.1`。
- stable:先锁定最高开放版本线上的最新已分配 beta,再要求它已成功交付且未撤回;不会跳过失败/撤回的最新 beta 去选择更早版本。正式版 core 与该 beta 完全相同。
- `vX.Y.Z`、`vX.Y.Z-beta.N` 一经分配就永久占用。撤回时创建 `withdrawn/v...` 墓碑,原编号永不复用。
- 例如撤回 `v1.0.53-beta.5` 后,下一 beta 是 `v1.0.53-beta.6`;撤回正式版 `v1.0.53` 后,下一 patch 修复线是 `v1.0.54-beta.1`,验证后再发布 `v1.0.54`。
- 如果最新 beta 已撤回,禁止直接用更早 beta 晋级正式版;必须先构建下一个 beta。
## 全平台撤回与回滚
已公开版本出现问题时,在 GitHub Actions 运行 `Withdraw release`,分支必须选择当前默认分支 `main`,并填写:
- `version`:精确版本,例如 `v1.0.53` 或 `v1.0.53-beta.5`。
- `reason`:8–300 字符的单行公开原因。
- `confirmation`:精确输入 `WITHDRAW <version>`,例如 `WITHDRAW v1.0.53`。
该 workflow 使用与发布相同的串行 publication lock,并进入受保护的 `release-withdrawal` environment。它只接受已经由 Release workflow 完整交付的 public immutable release,自动选择同一渠道中最新的、更早且未撤回的完整版本作为回退目标,然后按以下顺序执行:
1. 先创建永久 annotated tag `withdrawn/<version>`,记录原 tag object、commit、原因、申请人和 workflow run。这个墓碑是版本号永久占用记录,永不移动、永不删除。
2. 先验证 Homebrew Formula;若它仍指向问题版本,先创建回退 PR,再继续其他渠道撤回。这样 PR 创建失败时只留下可安全续跑的墓碑,不会先造成渠道分裂。若 Formula 尚未指向问题版本或已经处于安全版本,则直接校验。
3. GitHub Release 先标记为 withdrawn;npm 精确版本执行 `deprecate`,并把 `latest` / `beta` dist-tag 回退;OSS 先补齐回退版本资产,再移动 `latest.txt` / `beta.txt` 并删除问题版本目录;启用 Gitee 时同样先补齐回退 Release,再删除问题 Release 和 tag。
4. npm、OSS 和 Gitee 均已验证安全后,删除 GitHub 上的问题 Release 和原 `v...` tag,并验证 `/releases/latest` 对正式版回到安全版本。若本次创建了 Homebrew PR,run 最后故意保持失败,直到另一名维护者审核合入;合入后,从新的 `main` 使用完全相同的 version、reason 和 confirmation 重跑并完成。永久 `withdrawn/v...` 墓碑始终保留。
GitHub、npm、OSS、Gitee 和 Homebrew 的“回滚”指新的安装、升级和渠道解析不再拿到问题版本。已经装到用户电脑上的二进制无法被服务端强制降级;用户必须重新安装回退版本、安装后续修复版,或使用 CLI 自带的本地 rollback 能力。npm 不执行 `unpublish`:问题版本保留明确的弃用警告,但 `latest` / `beta` 不再指向它;即使 registry 允许删除,已发布过的版本号也不会重新使用。
撤回前必须存在同一渠道中更早、完整交付且未撤回的安全版本;若目标是该渠道第一个版本、没有安全候选,workflow 会在创建墓碑或修改任何渠道前 fail closed,需要先决定明确的替代策略。CLI 本地 rollback 也只有在本机仍保留上一次升级备份时可用。
撤回以“精确版本”为单位,不会因为正式版曾由某个 beta 晋级就隐式级联修改另一个渠道。若同一缺陷同时存在于正式版及其 beta,应先撤回正式版,再撤回对应 beta,并分别使用各自的精确确认串;每次都只会把该渠道回退到自己的安全候选。
撤回正式版 `v1.0.53` 后,`v1.0.53` 仍被墓碑视为已分配。下一次 patch 发布从 `v1.0.54-beta.1` 开始,验证后晋级 `v1.0.54`。撤回 `v1.0.53-beta.5` 后,同一开放版本线继续为 `v1.0.53-beta.6`;不会退回或复用 `beta.5`。
## 兼容入口:本地发布
安装发布 Skill 后直接运行:
@@ -29,7 +71,7 @@ main 上的候选代码 + beta CHANGELOG
→ vX.Y.Z(正式发布)
```
正式版必须显式指定本次验证过的 beta。脚本会比较两者:除 `CHANGELOG.md` 外只要有任何文件变化,就拒绝正式发布。这样预发测过的代码、命令树和正式发布的代码是同一份。
云端入口自动选择本次最新、已交付且未撤回的 beta;本地入口必须显式指定。流水线会比较两者:除 `CHANGELOG.md` 外只要有任何文件变化,就拒绝正式发布。这样预发测过的代码、命令树和正式发布的代码是同一份。
## 预发发布
@@ -68,7 +110,7 @@ dws-release v1.2.3 --from-beta v1.2.3-beta.1
dws-release v1.2.3 --from-beta v1.2.3-beta.1 --publish
```
`FROM_BETA` 不会自动推断,并会写入 stable annotated tag 的 `From-Beta` 元数据,CI 会再次读取和验证。
本地入口的 `FROM_BETA` 不会自动推断;云端入口会按上述规则唯一选择。两种入口都会把它写入 stable annotated tag 的 `From-Beta` 元数据,CI 会再次读取和验证。
## CHANGELOG 契约
@@ -86,14 +128,14 @@ dws-release v1.2.3 --from-beta v1.2.3-beta.1 --publish
## CI/CD 保证
- 只接受 `vX.Y.Z-beta.N` 和 `vX.Y.Z`,且新版本必须高于上一正式版。这里的“上一正式版”必须同时具备公开非草稿 GitHub Release 和同 tag/commit 的成功 Release workflow;只有 tag、没有交付成功的孤儿版本会阻断后续发布,要求先重跑补齐。历史版本若曾通过专用 recovery workflow 完成交付,只能使用仓库内 `delivered-stable-recoveries.json` 中精确到 tag、commit、run、workflow SHA 与 attempt 的 reviewed 证据;验证仍要求 release、Darwin 签名和最终发布三个 job 全部成功,不能接受任意 workflow_dispatch。
- 只接受 `vX.Y.Z-beta.N` 和 `vX.Y.Z`,且新版本必须高于上一正式版。这里的“上一正式版”必须同时具备公开非草稿 GitHub Release 和同 tag/commit 的成功 Release workflow;只有 tag、没有交付成功的孤儿版本会阻断后续发布,要求走受保护恢复补齐。云端 tag 会固定 `Release-Run`、requester、commit 和版本分配指纹,交付验证按该精确 run/attempt 及完整 job graph 取证,不接受任意 `workflow_dispatch`。历史版本若曾通过专用 recovery workflow 完成交付,只能使用仓库内 `delivered-stable-recoveries.json` 中精确到 tag、commit、run、workflow SHA 与 attempt 的 reviewed 证据。
- tag 必须是 annotated tag;本地脚本在推送前重新确认 HEAD 与远端 `main` 完全一致,CI 允许其后 `main` 前进,但要求封板提交仍位于 `main` 历史中。
- 日常 CI 和发布前都会对比“最新已交付正式版”的完整命令树;若长时间预检期间该 baseline 发生变化,会针对新的 baseline 重新比较。
- GoReleaser 只构建;Darwin 重签、checksums 重算和 npm 安装验证通过后,才统一上传 GitHub Release 的最终产物。
- 六个平台归档会逐个解包并核验二进制内嵌版本;公开资产集合、checksums 集合和 npm tarball integrity 都必须精确一致。npm tarball 固定由 npm `10.9.2` 打包,避免重跑时因 runner 自带 npm 漂移产生不同字节。
- stable 发布到 npm `latest`,更新 OSS `latest.txt` 和共享安装脚本;prerelease 发布到 npm `beta`,只更新 OSS `beta.txt`,不会覆盖稳定入口。
- Release workflow 使用一个最多容纳 100 个 pending run 的串行 publication queue;本地入口仍要求上一条 Release 完成后才能封下一个 tag。
- 本地 tag push 失败时会删除本次新建的本地 tag。tag 一旦成功推送,后续发布归 CI 所有,禁止改 tag 指向或复用版本号。
- Release workflow 使用一个最多容纳 100 个 pending run 的串行 publication queue;版本规划、云端封板、发布、恢复、修复和撤回共享同一发布锁。
- 本地 tag push 失败时会删除本次新建的本地 tag。远端 tag 一旦创建,后续发布归 CI 所有;发布中途失败时走受保护恢复,禁止改 tag 指向或复用版本号。只有已经公开版本经过受保护的全渠道撤回并留下永久 `withdrawn/...` 墓碑后,撤回 workflow 才会在最后一步删除原 tag。
npm 补发只允许从默认分支触发 Release workflow 的 `repair_npm_version`。它只支持启用 immutable releases 后、由本流水线成功产出的公开 immutable release:目标必须是 `main` 历史中的 annotated tag,并且同 commit 的 `Build immutable GitHub Release` job 已成功。即使后续 npm 分发失败,这个独立的产物封存边界仍可作为补发依据。补发会用目标 commit 的 npm 模板重组包,逐平台核验资产和二进制版本,再发布到隔离的 `backfill` dist-tag,不会回滚 `latest` / `beta`。历史 mutable release 不进入自动补发路径,避免把可被替换的资产带入 npm。
@@ -110,25 +152,27 @@ workflow 和本地直发脚本已停用,避免绕开 publication queue 或用
## 既有 tag 的紧急恢复
tag push 已成功、但 Release workflow 失败且 GitHub Release 尚未公开时,不要新建临时 workflow、移动 tag 或跳过门禁。在最新且干净的 `main` worktree 运行:
云端封板或本地 tag push 已成功、但 Release workflow 失败且 GitHub Release 尚未公开时,不要新建临时 workflow、移动 tag 或跳过门禁。在最新且干净的 `main` worktree 运行:
```bash
dws-release recover v1.2.3-beta.1
```
命令会自动解析 annotated tag object、peeled commit 和最近一次匹配的失败 tag-push run;也可以用 `--failed-run <run-id>` 精确指定。确认完整版本号后,它从默认分支触发受保护的恢复模式并等待完成。恢复模式必须满足:
命令会自动解析 annotated tag object、peeled commit,以及 tag 绑定的失败云端 run 或最近一次匹配的失败 tag-push run;也可以用 `--failed-run <run-id>` 精确指定。确认完整版本号后,它从默认分支触发受保护的恢复模式并等待完成。恢复模式必须满足:
- 输入精确绑定原 annotated tag object、commit 和失败的 exact-tag `Release` run;commit 必须仍在 `main` 历史中。
- 目标只允许不存在 GitHub Release 或仍为 Draft;已经公开的版本只能走对应的 channel repair,不能全量重建。
- 输入精确绑定原 annotated tag object、commit 和失败的 sealed `Release` run;云端 run 还必须与 tag 内的 run ID、attempt、requester 完全一致,commit 必须仍在 `main` 历史中。
- 目标只允许不存在 GitHub Release 或仍为 Draft;已经公开的版本不能全量重建:单个下游故障走对应的 channel repair,版本本身有问题则走受保护的全平台 withdrawal。
- `release-recovery` environment 必须限制为受保护分支、配置至少一名 required reviewer,并禁止自审;workflow 会通过 API 复核这些设置,未配置时 fail closed。
- 恢复复用正常的 contract、构建、Developer ID 签名、资产校验、immutable 发布、Homebrew、npm 和 OSS jobs,不存在 recovery 专用 publisher 或门禁跳过。
- 如果 GitHub Release 已在 recovery 中封存、后续 Homebrew/npm 校验发生瞬时失败,只重跑该 run 的 failed jobs;流水线仅在隐藏 run marker、tag object、commit 和 finalized artifact 字节全部精确一致时复用公开 Release。
成功的默认分支恢复 run 会成为后续 beta → stable 和 stable baseline 验证的可审计交付证据;历史临时分支恢复仍只接受 reviewed manifest 中的固定证据。
OSS 的 `latest.txt` / `beta.txt` 当前是镜像频道元数据;仓库内安装器仍从 GitHub/Gitee 解析版本,不能把 OSS pointer 当成已接入的安装通道。
云端 seal 后不要使用 GitHub 的 “Re-run failed jobs” 作为交付修复:annotated tag 永久绑定最初的 run attempt,普通 rerun 不会成为可接受的交付证据。GitHub Release 尚未公开时走上述 protected recovery;已经公开且仅 npm/OSS/Gitee 某一渠道失败时走对应 repair;版本内容本身有问题时走 withdrawal。
Homebrew 当前只属于本机预检/手工公式通道:预检会在当前 macOS 架构真实安装,但 Release workflow 不发布 tap,CI 生成的单主机公式也不应当作 Darwin 双架构正式交付。正式自动交付范围是 GitHub Release、npm、OSS,以及显式开启时的 Gitee fallback;Homebrew 双架构 tap 发布需另立需求。
OSS 的 `latest.txt` / `beta.txt` 是镜像频道元数据;当前仓库安装器仍主要从 GitHub/Gitee 解析版本。发布和撤回仍把 OSS 作为受控分发渠道处理,保证一旦外部消费者接入该 pointer,也不会继续解析到已撤回版本。
Release workflow 会生成 Darwin/Linux 双架构 Formula,并分别为 stable/beta 打开 Homebrew PR;tap 的默认分支仍以独立审核合入为交付边界。撤回 workflow 使用相同模板和回退版本 checksums 打开反向 PR;问题 GitHub Release 会先被移除以阻止新安装,永久墓碑和 workflow 日志承担审计/续跑依据。
## 平台治理前置
@@ -136,8 +180,14 @@ Homebrew 当前只属于本机预检/手工公式通道:预检会在当前 mac
- `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 的短暂窗口。
- tag ruleset 还必须覆盖 `withdrawn/v*`:只允许受保护的撤回 workflow 创建墓碑,禁止更新或删除墓碑;同时应允许 Release workflow 创建新的 `v*`,允许撤回 workflow 在全部渠道回退后删除精确的问题 `v*`。若组织级规则阻止这两个 workflow 的预期动作,发布或撤回会 fail closed,不能靠手工移动 tag 绕过。
- 配置 `RELEASE_GOVERNANCE_TOKEN` Actions secret,只授予目标仓库 `Administration: read`;内置 `GITHUB_TOKEN` 不具备 immutable-releases API 所需的仓库治理权限。每次本地预检和 tag workflow 都使用这一个身份进行 fail-closed 验证。
- 配置 `APPLE_CERTIFICATE_P12_BASE64`、`APPLE_CERTIFICATE_PASSWORD` 和具备发布权限的 `NPM_TOKEN`;撤回还要求该 npm 身份能够执行 `deprecate` 和修改 dist-tag。
- 配置 `OSS_ACCESS_KEY_ID`、`OSS_ACCESS_KEY_SECRET`、`OSS_ENDPOINT`、`OSS_BUCKET`,按需配置 `OSS_PREFIX`。撤回身份必须能够补齐安全版本资产、写 `latest.txt` / `beta.txt` 并删除问题版本前缀。
- 若启用 Gitee fallback,设置 `ENABLE_GITEE_UPLOAD_FALLBACK=true`,并配置 `GITEE_TOKEN`、`GITEE_USER`、`GITEE_REPO`;该身份必须能够创建和删除目标仓库的 Release 与 tag。
- 单独配置 `HOMEBREW_PR_TOKEN`,优先使用仅授权本仓库且具备 `Contents: write`、`Pull requests: write` 的 fine-grained PAT;若组织策略不允许该账号使用 fine-grained PAT,则回退到仅带 `public_repo` scope 的专用 classic PAT。治理预检和 tag contract 会验证 token 身份、classic scope,并用 `[skip ci]` 临时分支和 draft PR 完成真实写权限 canary,随后立即关闭 PR、删除分支;任何清理失败都会 fail closed。门禁也会拒绝与治理 token 复用。
- 创建 `release-recovery` environment,只允许受保护分支,设置 required reviewer、禁止自审并关闭管理员绕过。workflow 会读取 environment 的 required-reviewer、prevent-self-review 和 protected-branch 规则;规则缺失时紧急恢复会失败,正常 beta/stable tag 发布不受影响。
- 创建 `release-withdrawal` environment,只允许受保护分支,设置至少一名 required reviewer、禁止申请人自审并关闭管理员绕过。撤回 workflow 会通过 API 复核这些规则;任何一项缺失都会在触碰 npm、OSS、Gitee、Homebrew 或 GitHub Release 前失败。
- 仓库或组织的 Actions 策略必须允许 `Release` 与 `Withdraw release` workflow 的 `GITHUB_TOKEN` 获得各 job 声明的 `contents: write`。若上述发布凭证采用 environment secret,确认 `release-withdrawal` 审批完成后能够读取撤回所需的 npm、OSS、Gitee 和 Homebrew 凭证。
immutable releases,或任一 Code Admission context 缺失、未成功时,发布脚本会自动拒绝封 tag。tag ruleset 可能来自组织层,脚本不自动推断其最终作用范围;管理员确认不能省略,脚本约定也不能替代平台强制。
+61 -17
View File
@@ -31,6 +31,31 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
)
type consumeReadyWriter struct {
ready chan struct{}
once sync.Once
}
func newConsumeReadyWriter() *consumeReadyWriter {
return &consumeReadyWriter{ready: make(chan struct{})}
}
func (w *consumeReadyWriter) Write(p []byte) (int, error) {
if bytes.Contains(p, []byte("[event] ready ")) {
w.once.Do(func() { close(w.ready) })
}
return len(p), nil
}
func waitForConsumerReady(t *testing.T, ready <-chan struct{}) {
t.Helper()
select {
case <-ready:
case <-time.After(3 * time.Second):
t.Fatal("consumer did not report ready")
}
}
// TestIntegration_HelloPushdownFiltersAtBus verifies the Hello-time
// event_types pushdown contract (plan §4 unsung superpower): a consumer
// subscribing to "im.*" must NOT receive "approval.*" events even when
@@ -49,40 +74,50 @@ func TestIntegration_HelloPushdownFiltersAtBus(t *testing.T) {
defer func() { cancel(); <-runDone }()
var imBuf, approvalBuf bytes.Buffer
var wg sync.WaitGroup
wg.Add(2)
imReady := newConsumeReadyWriter()
approvalReady := newConsumeReadyWriter()
consumeCtx, cancelConsumers := context.WithTimeout(context.Background(), 5*time.Second)
defer cancelConsumers()
consumeDone := make(chan error, 2)
// Consumer A: im.* only
go func() {
defer wg.Done()
_ = Run(context.Background(), Config{
consumeDone <- Run(consumeCtx, Config{
WorkDir: dir,
IPCEndpoint: sock,
ClientID: "ding_test",
Stdout: &imBuf,
Stderr: io.Discard,
Stderr: imReady,
EventTypes: []string{"im.*"},
MaxEvents: 3, // 3 im events expected
})
}()
// Consumer B: approval.* only
go func() {
defer wg.Done()
_ = Run(context.Background(), Config{
consumeDone <- Run(consumeCtx, Config{
WorkDir: dir,
IPCEndpoint: sock,
ClientID: "ding_test",
Stdout: &approvalBuf,
Stderr: io.Discard,
Stderr: approvalReady,
EventTypes: []string{"approval.*"},
MaxEvents: 2, // 2 approval events expected
})
}()
// Give both consumers time to Hello + register.
time.Sleep(200 * time.Millisecond)
waitForConsumerReady(t, imReady.ready)
waitForConsumerReady(t, approvalReady.ready)
close(trigger)
wg.Wait()
for range 2 {
select {
case err := <-consumeDone:
if err != nil {
t.Fatalf("consume failed: %v", err)
}
case <-consumeCtx.Done():
t.Fatalf("consumers did not finish: %v", consumeCtx.Err())
}
}
// Verify consumer A got exactly the 3 im.* events.
imLines := nonEmptyLines(imBuf.String())
@@ -130,23 +165,32 @@ func TestIntegration_FilterRegexNarrowsFurther(t *testing.T) {
defer func() { cancel(); <-runDone }()
var buf bytes.Buffer
consumeDone := make(chan struct{})
ready := newConsumeReadyWriter()
consumeCtx, cancelConsumer := context.WithTimeout(context.Background(), 5*time.Second)
defer cancelConsumer()
consumeDone := make(chan error, 1)
go func() {
defer close(consumeDone)
_ = Run(context.Background(), Config{
consumeDone <- Run(consumeCtx, Config{
WorkDir: dir,
IPCEndpoint: sock,
ClientID: "ding_test",
Stdout: &buf,
Stderr: io.Discard,
Stderr: ready,
EventTypes: []string{"im.*"},
Filter: `\.at_v1$`, // only at_v1 events
MaxEvents: 1,
})
}()
time.Sleep(150 * time.Millisecond)
waitForConsumerReady(t, ready.ready)
close(trigger)
<-consumeDone
select {
case err := <-consumeDone:
if err != nil {
t.Fatalf("consume failed: %v", err)
}
case <-consumeCtx.Done():
t.Fatalf("consumer did not finish: %v", consumeCtx.Err())
}
lines := nonEmptyLines(buf.String())
if len(lines) != 1 {
+2 -2
View File
@@ -20,8 +20,8 @@
// 2. 渐进式退避(1s/2s/5s/10s/15s/30s/30s...,可自定义)
// 3. 默认 5 分钟超时,超时返回 jobId 让用户用 `... get --job-id <ID>` 续等
// 4. 状态机统一:PROCESSING/PENDING → SUCCESS → 拿 downloadUrl
// ↓
// FAILED → 返回 error
// ↓
// FAILED → 返回 error
// 5. 可选 PUT 下载:传入 OutputPath 时本包负责 HTTP GET 落盘
package asynctask
+3 -1
View File
@@ -112,7 +112,9 @@ func TestSubmit_ContextCancel(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
submitFn := func(ctx context.Context) (string, error) { return "job-x", nil }
queryFn := func(ctx context.Context, jobID string) (QueryResult, error) { return QueryResult{Status: StatusProcessing}, nil }
queryFn := func(ctx context.Context, jobID string) (QueryResult, error) {
return QueryResult{Status: StatusProcessing}, nil
}
_, err := Submit(ctx, submitFn, queryFn, Options{Backoff: testBackoff, Timeout: 1 * time.Second})
if !errors.Is(err, context.Canceled) {
t.Fatalf("expect ctx.Canceled, got %v", err)
+6 -3
View File
@@ -3,9 +3,11 @@
These repo-local entrypoints are the supported shell entrypoints for building, testing, packaging, and policy checks.
- `make build`: build the `dws` CLI from `cmd`
- `make test`: run `go test ./...`
- `make lint`: run formatting checks and required `golangci-lint`
- `make fmt`: format Go source files under `cmd/`, `internal/`, and `test/`
- `make test`: run every default package returned by `go list ./...`
- `make test-plan`: verify every default Go package belongs to exactly one CI test shard
- `make lint`: run the repository-wide format check, `go vet`, and required `staticcheck`
- `make format-check`: check every tracked or non-ignored untracked repository Go source file with `gofmt`
- `make fmt`: format every tracked or non-ignored untracked repository Go source file
- `make policy`: reuse the current `dws` binary and run the complete policy suite (`make build` first)
- `make package`: build all release artifacts locally
- `make release-pre VERSION=vX.Y.Z-beta.N`: validate a prerelease (`PUBLISH=1` pushes its tag)
@@ -15,6 +17,7 @@ Script groups:
- Root installers: `./scripts/install.sh`, `./scripts/install.ps1`, `./scripts/install-skills.sh`
- Product convenience installers: `./scripts/install-devapp.sh`, `./scripts/install-devapp.ps1`, `./scripts/install-event.sh`
- CI package plan: `./scripts/ci/test-packages.sh`
- Dev helpers: `./scripts/dev/build.sh`, `./scripts/dev/lint.sh`, `./scripts/dev/ci-local.sh`, `./scripts/dev/run-mock-e2e.sh`, `./scripts/dev/coverage.sh`
- Policy checks: `./scripts/policy/check-schema-command-registry.sh`, `./scripts/policy/check-generated-drift.sh`, `./scripts/policy/check-command-surface.sh`, `./scripts/policy/check-command-compatibility.sh --base-ref <main-ref> --stable-ref <latest-GA-tag>`, `./scripts/policy/check-schema-catalog.sh`, `./scripts/policy/check-schema-binary.sh`, `./scripts/policy/check-open-source-assets.sh`
- Policy runtime files: set `DWS_POLICY_TMPDIR` to override the default `.worktrees/policy-tmp` workspace
+121
View File
@@ -0,0 +1,121 @@
#!/bin/sh
set -eu
ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
MODULE="$(cd "$ROOT" && go list -m)"
usage() {
printf '%s\n' \
"usage: $0 list <app|generators|helpers|remaining|release-scripts>" \
" $0 verify" >&2
exit 2
}
list_shard() {
shard="$1"
cd "$ROOT"
case "$shard" in
app)
go list ./internal/app/...
;;
generators)
go list ./internal/generator/...
;;
helpers)
go list ./internal/helpers/...
;;
release-scripts)
go list ./test/scripts/...
;;
remaining)
all_packages="$(go list ./...)"
printf '%s\n' "$all_packages" | while IFS= read -r package; do
case "$package" in
"$MODULE/internal/app"|"$MODULE/internal/app/"*) ;;
"$MODULE/internal/generator"|"$MODULE/internal/generator/"*) ;;
"$MODULE/internal/helpers"|"$MODULE/internal/helpers/"*) ;;
"$MODULE/test/scripts"|"$MODULE/test/scripts/"*) ;;
*) printf '%s\n' "$package" ;;
esac
done
;;
*)
printf 'unknown test package shard: %s\n' "$shard" >&2
exit 2
;;
esac
}
verify_plan() {
workdir="$(mktemp -d "${TMPDIR:-/tmp}/dws-test-packages.XXXXXX")"
trap 'rm -rf "$workdir"' EXIT HUP INT TERM
expected="$workdir/expected"
assigned="$workdir/assigned"
unique="$workdir/unique"
duplicates="$workdir/duplicates"
missing="$workdir/missing"
unexpected="$workdir/unexpected"
all_packages="$workdir/all-packages"
cd "$ROOT"
go list ./... > "$all_packages"
LC_ALL=C sort -u "$all_packages" > "$expected"
: > "$assigned"
for shard in app generators helpers remaining release-scripts; do
shard_packages="$workdir/$shard"
unsorted_shard_packages="$workdir/$shard.unsorted"
list_shard "$shard" > "$unsorted_shard_packages"
LC_ALL=C sort "$unsorted_shard_packages" > "$shard_packages"
if [ ! -s "$shard_packages" ]; then
printf 'test package shard is empty: %s\n' "$shard" >&2
exit 1
fi
cat "$shard_packages" >> "$assigned"
done
LC_ALL=C sort "$assigned" -o "$assigned"
uniq -d "$assigned" > "$duplicates"
LC_ALL=C sort -u "$assigned" > "$unique"
comm -23 "$expected" "$unique" > "$missing"
comm -13 "$expected" "$unique" > "$unexpected"
failed=0
if [ -s "$duplicates" ]; then
printf '%s\n' 'test packages assigned to more than one shard:' >&2
sed 's/^/ /' "$duplicates" >&2
failed=1
fi
if [ -s "$missing" ]; then
printf '%s\n' 'default Go packages missing from the CI test plan:' >&2
sed 's/^/ /' "$missing" >&2
failed=1
fi
if [ -s "$unexpected" ]; then
printf '%s\n' 'CI test plan contains packages outside go list ./...:' >&2
sed 's/^/ /' "$unexpected" >&2
failed=1
fi
if [ "$failed" -ne 0 ]; then
exit 1
fi
package_count="$(wc -l < "$expected" | tr -d ' ')"
printf 'test package plan covers %s default packages exactly once\n' "$package_count"
}
case "${1:-}" in
list)
[ "$#" -eq 2 ] || usage
list_shard "$2"
;;
verify)
[ "$#" -eq 1 ] || usage
verify_plan
;;
*)
usage
;;
esac
+1 -1
View File
@@ -11,7 +11,7 @@ run() {
cd "$ROOT"
run ./scripts/dev/lint.sh
run go test ./...
run make test
COVERAGE_PROFILE="$(mktemp "${TMPDIR:-/tmp}/dws-coverage.XXXXXX")"
run ./scripts/dev/coverage.sh "$COVERAGE_PROFILE"
rm -f "$COVERAGE_PROFILE"
+5 -6
View File
@@ -1,13 +1,12 @@
#!/bin/sh
set -eu
ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
cd "$ROOT"
# ── Format check ──────────────────────────────────────────
unformatted="$(find cmd internal test -name '*.go' -print0 2>/dev/null | xargs -0r gofmt -l)"
if [ -n "$unformatted" ]; then
echo "$unformatted"
echo "Go files are not formatted. Run 'make fmt'." >&2
exit 1
fi
echo "Running gofmt check..."
make format-check
# ── go vet (built-in) ────────────────────────────────────
echo "Running go vet..."
+4 -4
View File
@@ -4,7 +4,7 @@ set -eu
ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
cd "$ROOT"
go test ./test/mock_mcp/... \
./test/integration/discovery/... \
./test/integration/extensions/... \
./test/integration/recovery/...
DWS_PACKAGE_VERSION="${DWS_PACKAGE_VERSION:-0.0.0-test}" \
go test -count=1 -timeout=5m \
./test/mock_mcp/... \
./test/integration/...
+203
View File
@@ -0,0 +1,203 @@
#!/bin/sh
set -eu
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
. "$SCRIPT_DIR/release-lib.sh"
ROOT="$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)"
CHANNEL=""
REQUESTED_CHANNEL=""
BUMP="patch"
usage() {
cat >&2 <<'EOF'
usage: next-release-version.sh --channel <beta|prerelease|stable> [options]
Options:
--bump <patch|minor|major> Core bump when starting a new beta line (default: patch)
--repo-root <path> Override repository root (primarily for tests)
Output:
release_version=<vX.Y.Z[-beta.N]>
from_beta=<vX.Y.Z-beta.N or empty>
channel=<prerelease|stable>
base=<latest allocated stable tag or empty>
Both ordinary release tags and refs/tags/withdrawn/v... tombstones reserve a
version permanently. The caller must create the returned release tag
atomically; this script only calculates the next candidate.
EOF
}
while [ "$#" -gt 0 ]; do
case "$1" in
--channel)
[ "$#" -ge 2 ] || { usage; exit 2; }
REQUESTED_CHANNEL="$2"
shift 2
;;
--bump)
[ "$#" -ge 2 ] || { usage; exit 2; }
BUMP="$2"
shift 2
;;
--repo-root)
[ "$#" -ge 2 ] || { usage; exit 2; }
ROOT="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
printf 'unknown argument: %s\n' "$1" >&2
usage
exit 2
;;
esac
done
case "$REQUESTED_CHANNEL" in
beta|prerelease) CHANNEL="prerelease" ;;
stable) CHANNEL="stable" ;;
*)
printf 'invalid release channel: %s (expected beta, prerelease, or stable)\n' "${REQUESTED_CHANNEL:-<empty>}" >&2
exit 2
;;
esac
case "$BUMP" in
patch|minor|major) ;;
*)
printf 'invalid version bump: %s (expected patch, minor, or major)\n' "$BUMP" >&2
exit 2
;;
esac
cd "$ROOT"
git rev-parse --is-inside-work-tree >/dev/null 2>&1 || {
printf 'not a Git worktree: %s\n' "$ROOT" >&2
exit 1
}
allocated_version_for_ref() {
_avfr_ref="$1"
case "$_avfr_ref" in
refs/tags/withdrawn/v*) printf '%s\n' "${_avfr_ref#refs/tags/withdrawn/}" ;;
refs/tags/v*) printf '%s\n' "${_avfr_ref#refs/tags/}" ;;
*) return 1 ;;
esac
}
stable_core_is_allocated() {
_scia_core="$1"
git show-ref --verify --quiet "refs/tags/$_scia_core" ||
git show-ref --verify --quiet "refs/tags/withdrawn/$_scia_core"
}
version_is_withdrawn() {
git show-ref --verify --quiet "refs/tags/withdrawn/$1"
}
bump_core() {
_bc_version="${1#v}"
_bc_major="${_bc_version%%.*}"
_bc_remainder="${_bc_version#*.}"
_bc_minor="${_bc_remainder%%.*}"
_bc_patch="${_bc_remainder#*.}"
case "$2" in
patch) _bc_patch=$((_bc_patch + 1)) ;;
minor)
_bc_minor=$((_bc_minor + 1))
_bc_patch=0
;;
major)
_bc_major=$((_bc_major + 1))
_bc_minor=0
_bc_patch=0
;;
esac
printf 'v%s.%s.%s\n' "$_bc_major" "$_bc_minor" "$_bc_patch"
}
tag_refs="$(git for-each-ref --format='%(refname)' refs/tags)"
latest_stable=""
for ref in $tag_refs; do
version="$(allocated_version_for_ref "$ref" 2>/dev/null || true)"
[ -n "$version" ] || continue
if release_is_stable_version "$version"; then
if [ -z "$latest_stable" ] || release_core_is_greater "$version" "$latest_stable"; then
latest_stable="$version"
fi
fi
done
highest_open_beta_core=""
for ref in $tag_refs; do
version="$(allocated_version_for_ref "$ref" 2>/dev/null || true)"
[ -n "$version" ] || continue
release_is_prerelease_version "$version" || continue
core="$(release_core_tag "$version")"
stable_core_is_allocated "$core" && continue
if [ -n "$latest_stable" ] && ! release_core_is_greater "$core" "$latest_stable"; then
continue
fi
if [ -z "$highest_open_beta_core" ] || release_core_is_greater "$core" "$highest_open_beta_core"; then
highest_open_beta_core="$core"
fi
done
release_version=""
from_beta=""
base="$latest_stable"
if [ "$CHANNEL" = "prerelease" ]; then
if [ -n "$highest_open_beta_core" ]; then
latest_beta=""
for ref in $tag_refs; do
version="$(allocated_version_for_ref "$ref" 2>/dev/null || true)"
[ -n "$version" ] || continue
release_is_prerelease_version "$version" || continue
[ "$(release_core_tag "$version")" = "$highest_open_beta_core" ] || continue
if [ -z "$latest_beta" ] || release_version_is_greater "$version" "$latest_beta"; then
latest_beta="$version"
fi
done
next_beta_number=$(( $(release_beta_number "$latest_beta") + 1 ))
release_version="$highest_open_beta_core-beta.$next_beta_number"
else
[ -n "$latest_stable" ] || {
printf 'cannot start a beta line without an allocated stable baseline\n' >&2
exit 1
}
next_core="$(bump_core "$latest_stable" "$BUMP")"
release_version="$next_core-beta.1"
fi
else
[ -n "$highest_open_beta_core" ] || {
printf 'cannot create a stable release without an open beta line newer than the latest allocated stable\n' >&2
exit 1
}
latest_beta=""
for ref in $tag_refs; do
version="$(allocated_version_for_ref "$ref" 2>/dev/null || true)"
[ -n "$version" ] || continue
release_is_prerelease_version "$version" || continue
[ "$(release_core_tag "$version")" = "$highest_open_beta_core" ] || continue
if [ -z "$latest_beta" ] || release_version_is_greater "$version" "$latest_beta"; then
latest_beta="$version"
fi
done
if version_is_withdrawn "$latest_beta" ||
! git show-ref --verify --quiet "refs/tags/$latest_beta"; then
printf 'latest beta %s is withdrawn; create the next beta before stable promotion\n' "$latest_beta" >&2
exit 1
fi
release_version="$highest_open_beta_core"
from_beta="$latest_beta"
fi
printf 'release_version=%s\n' "$release_version"
printf 'from_beta=%s\n' "$from_beta"
printf 'channel=%s\n' "$CHANNEL"
printf 'base=%s\n' "$base"
+49 -4
View File
@@ -120,12 +120,41 @@ git fetch --force --no-tags "$REMOTE" "+refs/tags/$VERSION:$recovery_ref"
}
tag_object="$(git rev-parse "$recovery_ref")"
commit="$(git rev-parse "$recovery_ref^{commit}")"
tag_message="$(git for-each-ref "$recovery_ref" --format='%(contents)')"
cloud_run_id="$(printf '%s\n' "$tag_message" | awk -F ': ' '$1 == "Release-Run" { print $2 }')"
cloud_run_attempt="$(printf '%s\n' "$tag_message" | awk -F ': ' '$1 == "Release-Run-Attempt" { print $2 }')"
cloud_actor="$(printf '%s\n' "$tag_message" | awk -F ': ' '$1 == "Requested-By" { print $2 }')"
cloud_actor_id="$(printf '%s\n' "$tag_message" | awk -F ': ' '$1 == "Requested-By-ID" { print $2 }')"
cloud_sealed_commit="$(printf '%s\n' "$tag_message" | awk -F ': ' '$1 == "Sealed-Commit" { print $2 }')"
cloud_marker_count=0
for cloud_value in "$cloud_run_id" "$cloud_run_attempt" "$cloud_actor" "$cloud_actor_id" "$cloud_sealed_commit"; do
[ -z "$cloud_value" ] || cloud_marker_count=$((cloud_marker_count + 1))
done
if [ "$cloud_marker_count" -ne 0 ] && [ "$cloud_marker_count" -ne 5 ]; then
printf '%s contains incomplete cloud release metadata\n' "$VERSION" >&2
exit 1
fi
if [ "$cloud_marker_count" -eq 5 ]; then
for cloud_number in "$cloud_run_id" "$cloud_run_attempt" "$cloud_actor_id"; do
printf '%s\n' "$cloud_number" | grep -Eq '^[1-9][0-9]*$' || {
printf '%s contains invalid cloud release identity\n' "$VERSION" >&2
exit 1
}
done
[ "$cloud_sealed_commit" = "$commit" ] || {
printf '%s cloud release metadata is not bound to %s\n' "$VERSION" "$commit" >&2
exit 1
}
fi
git merge-base --is-ancestor "$commit" "refs/remotes/$REMOTE/main" || {
printf '%s commit %s is not contained in %s/main\n' "$VERSION" "$commit" "$REMOTE" >&2
exit 1
}
if [ -z "$FAILED_RUN_ID" ]; then
if [ -z "$FAILED_RUN_ID" ] && [ "$cloud_marker_count" -eq 5 ]; then
FAILED_RUN_ID="$cloud_run_id"
FAILED_RUN_ATTEMPT="$cloud_run_attempt"
elif [ -z "$FAILED_RUN_ID" ]; then
candidate_runs="$(
gh api \
-H 'Accept: application/vnd.github+json' \
@@ -160,7 +189,7 @@ 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'
--jq '[.id, .run_attempt, .repository.full_name, .path, .event, .status, .conclusion, .head_branch, .head_sha, .actor.login, .actor.id] | @tsv'
)" || {
printf 'could not query Release run %s attempt %s\n' "$FAILED_RUN_ID" "$FAILED_RUN_ATTEMPT" >&2
exit 1
@@ -174,6 +203,7 @@ 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)"
attempt_actor_id="$(printf '%s\n' "$attempt_record" | cut -f11)"
case "$attempt_conclusion" in
failure|cancelled|timed_out|startup_failure|stale) ;;
*)
@@ -182,18 +212,33 @@ case "$attempt_conclusion" in
exit 1
;;
esac
if [ "$cloud_marker_count" -eq 5 ]; then
expected_attempt_event="workflow_dispatch"
expected_attempt_branch="main"
else
expected_attempt_event="push"
expected_attempt_branch="$VERSION"
fi
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_event" != "$expected_attempt_event" ] ||
[ "$attempt_status" != "completed" ] ||
[ "$attempt_branch" != "$VERSION" ] ||
[ "$attempt_branch" != "$expected_attempt_branch" ] ||
[ "$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
if [ "$cloud_marker_count" -eq 5 ] &&
{ [ "$cloud_run_id" != "$FAILED_RUN_ID" ] ||
[ "$cloud_run_attempt" != "$FAILED_RUN_ATTEMPT" ] ||
[ "$cloud_actor_id" != "$attempt_actor_id" ]; }; then
printf 'Release run %s attempt %s is not bound by the cloud seal for %s\n' \
"$FAILED_RUN_ID" "$FAILED_RUN_ATTEMPT" "$VERSION" >&2
exit 1
fi
printf 'Recovery target:\n'
printf ' version: %s\n' "$VERSION"
+52 -12
View File
@@ -91,7 +91,13 @@ if [ "$CONTEXT" = "local" ]; then
printf 'release tag already exists locally: %s\n' "$VERSION" >&2
exit 1
fi
remote_tag="$(git ls-remote --tags "$REMOTE" "refs/tags/$VERSION" "refs/tags/$VERSION^{}")" || {
if git rev-parse --verify --quiet "refs/tags/withdrawn/$VERSION" >/dev/null; then
printf 'release version was withdrawn and can never be reused: %s\n' "$VERSION" >&2
exit 1
fi
remote_tag="$(git ls-remote --tags "$REMOTE" \
"refs/tags/$VERSION" "refs/tags/$VERSION^{}" \
"refs/tags/withdrawn/$VERSION" "refs/tags/withdrawn/$VERSION^{}")" || {
printf 'could not query release tags from remote: %s\n' "$REMOTE" >&2
exit 1
}
@@ -100,6 +106,10 @@ if [ "$CONTEXT" = "local" ]; then
exit 1
}
else
if git rev-parse --verify --quiet "refs/tags/withdrawn/$VERSION" >/dev/null; then
printf 'release version was withdrawn and can never be reused: %s\n' "$VERSION" >&2
exit 1
fi
git rev-parse --verify --quiet "refs/tags/$VERSION^{commit}" >/dev/null || {
printf 'CI release tag is not available: %s\n' "$VERSION" >&2
exit 1
@@ -120,32 +130,58 @@ fi
previous_stable=""
previous_stable_commit=""
for tag in $(git tag --list 'v*' --sort=-version:refname); do
latest_allocated_stable=""
for tag_ref in $(git for-each-ref --format='%(refname)' refs/tags); do
case "$tag_ref" in
refs/tags/withdrawn/*) tag="${tag_ref#refs/tags/withdrawn/}" ;;
refs/tags/*) tag="${tag_ref#refs/tags/}" ;;
*) continue ;;
esac
[ "$tag" = "$VERSION" ] && continue
if release_is_stable_version "$tag"; then
previous_stable="$tag"
previous_stable_commit="$(git rev-parse "$tag^{commit}")"
break
release_is_stable_version "$tag" || continue
if [ -z "$latest_allocated_stable" ] ||
release_core_is_greater "$tag" "$latest_allocated_stable"; then
latest_allocated_stable="$tag"
fi
done
if [ -n "$previous_stable" ] && ! release_core_is_greater "$VERSION" "$previous_stable"; then
printf 'release version %s must be greater than latest stable %s\n' "$VERSION" "$previous_stable" >&2
if [ -n "$latest_allocated_stable" ] &&
! release_core_is_greater "$VERSION" "$latest_allocated_stable"; then
printf 'release version %s must be greater than latest allocated stable %s\n' \
"$VERSION" "$latest_allocated_stable" >&2
exit 1
fi
for tag in $(git tag --list 'v*' --sort=-version:refname); do
[ "$tag" = "$VERSION" ] && continue
release_is_stable_version "$tag" || continue
if git rev-parse --verify --quiet "refs/tags/withdrawn/$tag" >/dev/null; then
continue
fi
previous_stable="$tag"
previous_stable_commit="$(git rev-parse "$tag^{commit}")"
break
done
core_tag="$(release_core_tag "$VERSION")"
if [ "$CHANNEL" = "prerelease" ]; then
[ -z "$FROM_BETA" ] || { printf -- '--from-beta is only valid for stable releases\n' >&2; exit 1; }
if git rev-parse --verify --quiet "refs/tags/$core_tag" >/dev/null; then
if git rev-parse --verify --quiet "refs/tags/$core_tag" >/dev/null ||
git rev-parse --verify --quiet "refs/tags/withdrawn/$core_tag" >/dev/null; then
printf 'cannot publish prerelease after stable tag exists: %s\n' "$core_tag" >&2
exit 1
fi
previous_beta=""
for tag in $(git tag --list "$core_tag-beta.*" --sort=-version:refname); do
for tag_ref in $(git for-each-ref --format='%(refname)' \
"refs/tags/$core_tag-beta.*" "refs/tags/withdrawn/$core_tag-beta.*"); do
case "$tag_ref" in
refs/tags/withdrawn/*) tag="${tag_ref#refs/tags/withdrawn/}" ;;
refs/tags/*) tag="${tag_ref#refs/tags/}" ;;
*) continue ;;
esac
[ "$tag" = "$VERSION" ] && continue
if release_is_prerelease_version "$tag"; then
release_is_prerelease_version "$tag" || continue
if [ -z "$previous_beta" ] || release_version_is_greater "$tag" "$previous_beta"; then
previous_beta="$tag"
break
fi
done
beta_number="$(release_beta_number "$VERSION")"
@@ -178,6 +214,10 @@ else
printf 'stable version %s does not match beta baseline %s\n' "$VERSION" "$FROM_BETA" >&2
exit 1
}
if git rev-parse --verify --quiet "refs/tags/withdrawn/$FROM_BETA" >/dev/null; then
printf 'stable beta baseline was withdrawn: %s\n' "$FROM_BETA" >&2
exit 1
fi
git rev-parse --verify --quiet "refs/tags/$FROM_BETA^{commit}" >/dev/null || {
printf 'stable beta baseline is not available locally: %s\n' "$FROM_BETA" >&2
exit 1
+6 -2
View File
@@ -288,8 +288,12 @@ build_policy_binary() {
}
fetch_release_tags() {
git fetch --force "$REMOTE" '+refs/tags/v*:refs/tags/v*'
git fetch --force --no-tags "$OFFICIAL_TAGS_URL" '+refs/tags/v*:refs/tags/v*'
git fetch --force "$REMOTE" \
'+refs/tags/v*:refs/tags/v*' \
'+refs/tags/withdrawn/v*:refs/tags/withdrawn/v*'
git fetch --force --no-tags "$OFFICIAL_TAGS_URL" \
'+refs/tags/v*:refs/tags/v*' \
'+refs/tags/withdrawn/v*:refs/tags/withdrawn/v*'
}
printf '==> Refreshing %s/%s and release tags\n' "$REMOTE" "$BRANCH"
+1 -1
View File
@@ -82,7 +82,7 @@ if [ "$delivered_by_push" -ne 1 ]; then
if DWS_RELEASE_OFFICIAL_REPOSITORY="$REPOSITORY" \
DWS_RELEASE_GITHUB_TOKEN="$API_TOKEN" \
"$SCRIPT_DIR/verify-release-workflow-delivery.sh" "$TAG" "$EXPECTED_COMMIT" 2>/dev/null; then
printf 'Delivered stable baseline verified through protected default-branch recovery: %s -> %s\n' \
printf 'Delivered stable baseline verified through trusted default-branch delivery: %s -> %s\n' \
"$TAG" "$EXPECTED_COMMIT"
exit 0
fi
@@ -18,6 +18,9 @@ if [ "${1:-}" = "--channel-repair" ]; then
exit 2
;;
esac
elif [ "${1:-}" = "--npm-repair" ]; then
MODE="npm-repair"
shift
fi
TAG="${1:-}"
@@ -25,7 +28,7 @@ EXPECTED_COMMIT="${2:-}"
REPOSITORY="${DWS_RELEASE_OFFICIAL_REPOSITORY:-DingTalk-Real-AI/dingtalk-workspace-cli}"
[ -n "$TAG" ] && [ -n "$EXPECTED_COMMIT" ] || {
printf 'usage: verify-release-workflow-delivery.sh [--channel-repair <oss|gitee>] <tag> <commit>\n' >&2
printf 'usage: verify-release-workflow-delivery.sh [--channel-repair <oss|gitee> | --npm-repair] <tag> <commit>\n' >&2
exit 2
}
if ! release_is_stable_version "$TAG" && ! release_is_prerelease_version "$TAG"; then
@@ -81,6 +84,209 @@ for run in runs:
done
}
find_cloud_delivery_identity() {
tag_ref="$(
github_get "repos/$REPOSITORY/git/ref/tags/$TAG" \
| python3 -c 'import json,sys
ref=json.load(sys.stdin)
obj=ref.get("object", {})
if obj.get("type") == "tag" and obj.get("sha"):
print(obj["sha"])'
)" || return 1
[ -n "$tag_ref" ] || return 1
github_get "repos/$REPOSITORY/git/tags/$tag_ref" \
| python3 -c 'import json,re,sys
tag,commit=sys.argv[1:]
payload=json.load(sys.stdin)
if payload.get("tag") != tag or payload.get("object", {}).get("type") != "commit":
raise SystemExit(1)
if payload.get("object", {}).get("sha") != commit:
raise SystemExit(1)
fields={}
for line in payload.get("message", "").splitlines():
if ": " not in line:
continue
key,value=line.split(": ", 1)
if key in fields:
raise SystemExit(1)
fields[key]=value
required={
"Channel", "Release-Run", "Release-Run-Attempt", "Requested-By",
"Requested-By-ID", "Sealed-Commit", "Workflow-Commit",
"Allocation-Fingerprint",
}
if not required.issubset(fields):
raise SystemExit(1)
if fields["Sealed-Commit"] != commit or fields["Workflow-Commit"] != commit:
raise SystemExit(1)
if not re.fullmatch(r"[1-9][0-9]*", fields["Release-Run"]):
raise SystemExit(1)
if not re.fullmatch(r"[1-9][0-9]*", fields["Release-Run-Attempt"]):
raise SystemExit(1)
if not re.fullmatch(r"[1-9][0-9]*", fields["Requested-By-ID"]):
raise SystemExit(1)
if not fields["Requested-By"] or not re.fullmatch(r"[0-9a-f]{64}", fields["Allocation-Fingerprint"]):
raise SystemExit(1)
is_beta="-beta." in tag
if fields["Channel"] != ("prerelease" if is_beta else "stable"):
raise SystemExit(1)
from_beta=fields.get("From-Beta", "")
if is_beta:
if from_beta:
raise SystemExit(1)
else:
core=re.escape(tag)
if not re.fullmatch(core + r"-beta\.[1-9][0-9]*", from_beta):
raise SystemExit(1)
print("\t".join([
fields["Release-Run"],
fields["Release-Run-Attempt"],
fields["Requested-By"],
fields["Requested-By-ID"],
fields["Workflow-Commit"],
]))' "$TAG" "$EXPECTED_COMMIT"
}
verify_cloud_delivery() {
cloud_identity="$1"
cloud_run_id="$(printf '%s\n' "$cloud_identity" | cut -f1)"
cloud_run_attempt="$(printf '%s\n' "$cloud_identity" | cut -f2)"
cloud_actor="$(printf '%s\n' "$cloud_identity" | cut -f3)"
cloud_actor_id="$(printf '%s\n' "$cloud_identity" | cut -f4)"
cloud_workflow_sha="$(printf '%s\n' "$cloud_identity" | cut -f5)"
[ -n "$cloud_run_id" ] && [ -n "$cloud_run_attempt" ] &&
[ -n "$cloud_actor" ] && [ -n "$cloud_actor_id" ] &&
[ -n "$cloud_workflow_sha" ] || return 1
cloud_run_state="$(
github_get "repos/$REPOSITORY/actions/runs/$cloud_run_id/attempts/$cloud_run_attempt" \
| python3 -c 'import json,sys
r=json.load(sys.stdin)
print("\t".join(str(value) for value in (
r.get("id", ""),
r.get("run_attempt", ""),
r.get("repository", {}).get("full_name", ""),
r.get("path", ""),
r.get("event", ""),
r.get("status", ""),
r.get("conclusion", ""),
r.get("head_branch", ""),
r.get("head_sha", ""),
r.get("actor", {}).get("login", ""),
r.get("actor", {}).get("id", ""),
)))'
)" || return 1
expected_cloud_core="$(printf '%s\t%s\t%s\t.github/workflows/release.yml\tworkflow_dispatch\tcompleted\tsuccess\tmain\t%s' \
"$cloud_run_id" "$cloud_run_attempt" "$REPOSITORY" "$cloud_workflow_sha")"
[ "$(printf '%s\n' "$cloud_run_state" | cut -f1-9)" = "$expected_cloud_core" ] ||
return 1
[ -n "$(printf '%s\n' "$cloud_run_state" | cut -f10)" ] || return 1
[ "$(printf '%s\n' "$cloud_run_state" | cut -f11)" = "$cloud_actor_id" ] ||
return 1
[ "$cloud_workflow_sha" = "$EXPECTED_COMMIT" ] || return 1
jobs_dir="$(mktemp -d "${TMPDIR:-/tmp}/dws-release-cloud-jobs.XXXXXX")"
page=1
while :; do
jobs_page="$jobs_dir/jobs-$page.json"
if ! github_get "repos/$REPOSITORY/actions/runs/$cloud_run_id/attempts/$cloud_run_attempt/jobs?per_page=100&page=$page" \
>"$jobs_page"; then
rm -rf "$jobs_dir"
return 1
fi
page_count="$(
python3 -c 'import json,sys; print(len(json.load(open(sys.argv[1])).get("jobs", [])))' \
"$jobs_page"
)" || {
rm -rf "$jobs_dir"
return 1
}
[ "$page_count" -eq 100 ] || break
page=$((page + 1))
done
result=0
python3 - "$cloud_workflow_sha" "$jobs_dir"/jobs-*.json <<'PY' || result=$?
import json
import sys
workflow_sha, *pages = sys.argv[1:]
jobs = []
for page in pages:
with open(page, encoding="utf-8") as handle:
jobs.extend(json.load(handle).get("jobs", []))
required = (
"Plan next cloud release",
"Seal cloud release tag",
"release-contract",
"Build signed release artifacts",
"Verify Apple Developer ID signatures",
"Publish immutable GitHub Release",
"Publish npm and mirrors",
"Release delivery gate",
)
for name in required:
matches = [job for job in jobs if job.get("name") == name]
if len(matches) != 1:
raise SystemExit(1)
job = matches[0]
if (
job.get("head_sha") != workflow_sha
or job.get("status") != "completed"
or job.get("conclusion") != "success"
):
raise SystemExit(1)
seal = next(job for job in jobs if job.get("name") == "Seal cloud release tag")
steps = [
step for step in seal.get("steps", [])
if step.get("name") == "Create one immutable annotated release tag"
]
if (
len(steps) != 1
or steps[0].get("status") != "completed"
or steps[0].get("conclusion") != "success"
):
raise SystemExit(1)
PY
rm -rf "$jobs_dir"
return "$result"
}
verify_failed_cloud_delivery_identity() {
cloud_identity="$1"
cloud_run_id="$(printf '%s\n' "$cloud_identity" | cut -f1)"
cloud_run_attempt="$(printf '%s\n' "$cloud_identity" | cut -f2)"
cloud_actor="$(printf '%s\n' "$cloud_identity" | cut -f3)"
cloud_actor_id="$(printf '%s\n' "$cloud_identity" | cut -f4)"
cloud_workflow_sha="$(printf '%s\n' "$cloud_identity" | cut -f5)"
[ "$cloud_workflow_sha" = "$EXPECTED_COMMIT" ] || return 1
cloud_run_state="$(
github_get "repos/$REPOSITORY/actions/runs/$cloud_run_id/attempts/$cloud_run_attempt" \
| python3 -c 'import json,sys
r=json.load(sys.stdin)
print("\t".join(str(value) for value in (
r.get("id", ""),
r.get("run_attempt", ""),
r.get("repository", {}).get("full_name", ""),
r.get("path", ""),
r.get("event", ""),
r.get("status", ""),
r.get("conclusion", ""),
r.get("head_branch", ""),
r.get("head_sha", ""),
r.get("actor", {}).get("login", ""),
r.get("actor", {}).get("id", ""),
)))'
)" || return 1
expected_cloud_core="$(printf '%s\t%s\t%s\t.github/workflows/release.yml\tworkflow_dispatch\tcompleted\tfailure\tmain\t%s' \
"$cloud_run_id" "$cloud_run_attempt" "$REPOSITORY" "$cloud_workflow_sha")"
[ "$(printf '%s\n' "$cloud_run_state" | cut -f1-9)" = "$expected_cloud_core" ] &&
[ -n "$(printf '%s\n' "$cloud_run_state" | cut -f10)" ] &&
[ "$(printf '%s\n' "$cloud_run_state" | cut -f11)" = "$cloud_actor_id" ]
}
find_failed_push_delivery() {
matches=""
page=1
@@ -275,6 +481,112 @@ PY
return "$result"
}
verify_npm_repair_delivery() {
run_id="$1"
run_attempt="$2"
require_cloud_seal="$3"
jobs_dir="$(mktemp -d "${TMPDIR:-/tmp}/dws-release-npm-repair-jobs.XXXXXX")"
page=1
while :; do
jobs_page="$jobs_dir/jobs-$page.json"
if ! github_get "repos/$REPOSITORY/actions/runs/$run_id/attempts/$run_attempt/jobs?per_page=100&page=$page" \
>"$jobs_page"; then
rm -rf "$jobs_dir"
return 1
fi
if ! page_count="$(
python3 -c 'import json,sys; print(len(json.load(open(sys.argv[1])).get("jobs", [])))' \
"$jobs_page"
)"; then
rm -rf "$jobs_dir"
return 1
fi
[ "$page_count" -eq 100 ] || break
page=$((page + 1))
done
result=0
python3 - "$EXPECTED_COMMIT" "$run_id" "$run_attempt" "$TAG" "$require_cloud_seal" "$jobs_dir"/jobs-*.json <<'PY' || result=$?
import json
import sys
commit, run_id, run_attempt, tag, require_cloud_seal, *pages = sys.argv[1:]
jobs = []
for page in pages:
with open(page, encoding="utf-8") as handle:
jobs.extend(json.load(handle).get("jobs", []))
def fail(message):
print(
f"Release run {run_id} attempt {run_attempt} is not safe npm-repair "
f"authority for {tag}: {message}",
file=sys.stderr,
)
raise SystemExit(1)
def one_job(name):
matches = [job for job in jobs if job.get("name") == name]
if len(matches) != 1:
fail(f"expected exactly one job {name!r}, found {len(matches)}")
job = matches[0]
if (
job.get("head_sha") != commit
or job.get("status") != "completed"
or job.get("conclusion") != "success"
):
fail(f"required job {name!r} did not succeed at {commit}")
return job
for name in (
"release-contract",
"Build signed release artifacts",
"Verify Apple Developer ID signatures",
):
one_job(name)
if require_cloud_seal == "true":
one_job("Plan next cloud release")
seal = one_job("Seal cloud release tag")
seal_steps = [
step for step in seal.get("steps", [])
if step.get("name") == "Create one immutable annotated release tag"
]
if (
len(seal_steps) != 1
or seal_steps[0].get("status") != "completed"
or seal_steps[0].get("conclusion") != "success"
):
fail("cloud release seal step did not succeed")
elif require_cloud_seal != "false":
fail(f"invalid cloud seal requirement {require_cloud_seal!r}")
published = one_job("Publish immutable GitHub Release")
steps = [
step for step in published.get("steps", [])
if step.get("name") == "Require immutable published GitHub Release"
]
if (
len(steps) != 1
or steps[0].get("status") != "completed"
or steps[0].get("conclusion") != "success"
):
fail("immutable GitHub Release verification step did not succeed")
channels = [job for job in jobs if job.get("name") == "Publish npm and mirrors"]
if len(channels) != 1:
fail(f"expected exactly one npm publication job, found {len(channels)}")
channel = channels[0]
if (
channel.get("head_sha") != commit
or channel.get("status") != "completed"
or channel.get("conclusion") not in {"success", "failure"}
):
fail("npm publication job is not a completed success/failure at the release commit")
PY
rm -rf "$jobs_dir"
return "$result"
}
push_delivery="$(find_push_delivery || true)"
if [ -n "$push_delivery" ]; then
printf 'Release workflow delivery verified through exact-tag push run %s: %s -> %s\n' \
@@ -282,6 +594,15 @@ if [ -n "$push_delivery" ]; then
exit 0
fi
cloud_delivery_identity="$(find_cloud_delivery_identity || true)"
if [ -n "$cloud_delivery_identity" ] &&
verify_cloud_delivery "$cloud_delivery_identity"; then
cloud_delivery_run="$(printf '%s\n' "$cloud_delivery_identity" | cut -f1)"
printf 'Release workflow delivery verified through cloud release run %s: %s -> %s\n' \
"$cloud_delivery_run" "$TAG" "$EXPECTED_COMMIT"
exit 0
fi
if [ "$MODE" = "channel-repair" ]; then
failed_push_identity="$(find_failed_push_delivery || true)"
failed_push_delivery="$(printf '%s\n' "$failed_push_identity" | cut -f1)"
@@ -292,6 +613,38 @@ if [ "$MODE" = "channel-repair" ]; then
"$CHANNEL_REPAIR_TARGET" "$failed_push_delivery" "$failed_push_attempt" "$TAG" "$EXPECTED_COMMIT"
exit 0
fi
if [ -n "$cloud_delivery_identity" ] &&
verify_failed_cloud_delivery_identity "$cloud_delivery_identity"; then
failed_cloud_run="$(printf '%s\n' "$cloud_delivery_identity" | cut -f1)"
failed_cloud_attempt="$(printf '%s\n' "$cloud_delivery_identity" | cut -f2)"
if verify_channel_repair_delivery "$failed_cloud_run" "$failed_cloud_attempt"; then
printf 'Release %s channel-repair authority verified through failed cloud release run %s attempt %s: %s -> %s\n' \
"$CHANNEL_REPAIR_TARGET" "$failed_cloud_run" "$failed_cloud_attempt" "$TAG" "$EXPECTED_COMMIT"
exit 0
fi
fi
fi
if [ "$MODE" = "npm-repair" ]; then
failed_push_identity="$(find_failed_push_delivery || true)"
failed_push_run="$(printf '%s\n' "$failed_push_identity" | cut -f1)"
failed_push_attempt="$(printf '%s\n' "$failed_push_identity" | cut -f2)"
if [ -n "$failed_push_run" ] &&
verify_npm_repair_delivery "$failed_push_run" "$failed_push_attempt" false; then
printf 'Release npm-repair authority verified through failed exact-tag push run %s attempt %s: %s -> %s\n' \
"$failed_push_run" "$failed_push_attempt" "$TAG" "$EXPECTED_COMMIT"
exit 0
fi
if [ -n "$cloud_delivery_identity" ] &&
verify_failed_cloud_delivery_identity "$cloud_delivery_identity"; then
failed_cloud_run="$(printf '%s\n' "$cloud_delivery_identity" | cut -f1)"
failed_cloud_attempt="$(printf '%s\n' "$cloud_delivery_identity" | cut -f2)"
if verify_npm_repair_delivery "$failed_cloud_run" "$failed_cloud_attempt" true; then
printf 'Release npm-repair authority verified through failed cloud release run %s attempt %s: %s -> %s\n' \
"$failed_cloud_run" "$failed_cloud_attempt" "$TAG" "$EXPECTED_COMMIT"
exit 0
fi
fi
fi
find_recovery_identity() {
@@ -325,7 +678,7 @@ for run in runs:
recovery_identity="$(find_recovery_identity || true)"
[ -n "$recovery_identity" ] || {
printf 'Release workflow did not deliver %s at %s through a tag push or protected recovery\n' \
printf 'Release workflow did not deliver %s at %s through a tag push, cloud release, or protected recovery\n' \
"$TAG" "$EXPECTED_COMMIT" >&2
exit 1
}
+1186
View File
File diff suppressed because it is too large Load Diff
-4
View File
@@ -20,8 +20,6 @@ type docsFlagCase struct {
}
func TestDWSDocsCommandTreeCoverage(t *testing.T) {
t.Parallel()
docPaths, _, err := parseDWSDocsCompatibility()
if err != nil {
t.Fatalf("parseDWSDocsCompatibility() error = %v", err)
@@ -45,8 +43,6 @@ func TestDWSDocsCommandTreeCoverage(t *testing.T) {
}
func TestDWSDocsLocalFlagsCoverage(t *testing.T) {
t.Parallel()
docPaths, docFlags, err := parseDWSDocsCompatibility()
if err != nil {
t.Fatalf("parseDWSDocsCompatibility() error = %v", err)
-4
View File
@@ -14,8 +14,6 @@ import (
// and their availability depends on the test environment's fixture data.
func TestHiddenMCPHelpIsReachable(t *testing.T) {
t.Parallel()
cmd := app.NewRootCommand()
var out strings.Builder
cmd.SetOut(&out)
@@ -33,8 +31,6 @@ func TestHiddenMCPHelpIsReachable(t *testing.T) {
}
func TestSkillCommandIsRegistered(t *testing.T) {
t.Parallel()
root := app.NewRootCommand()
var found bool
+2
View File
@@ -11,6 +11,8 @@ import (
func TestMain(m *testing.M) {
// Set an empty catalog fixture so that EnvironmentLoader does not
// attempt live discovery (which would hang on unreachable MCP endpoints).
// Tests that construct app root commands must remain serial because root
// construction initializes process-wide helper dependencies.
absFixture, _ := filepath.Abs("testdata/empty_catalog.json")
os.Setenv(cli.CatalogFixtureEnv, absFixture)
@@ -8,8 +8,6 @@ import (
)
func TestRootHelpHidesRecoveredModules(t *testing.T) {
t.Parallel()
cmd := app.NewRootCommand()
var out strings.Builder
cmd.SetOut(&out)
+1
View File
@@ -24,6 +24,7 @@ func TestAIBehaviorPolicyProtectsEnforcementInputs(t *testing.T) {
workflow := string(data)
for _, want := range []string{
"filename.startsWith('scripts/ci/')",
"filename.startsWith('scripts/policy/')",
"filename === 'test/fixtures/cli-interface-baseline.txt'",
"previous_filename",
+4
View File
@@ -421,6 +421,10 @@ func TestChangelogPRFastPathWorkflowContract(t *testing.T) {
`COVERAGE_ENFORCE_OVERALL: "false"`,
`COVERAGE_OVERALL_TOLERANCE: "0"`,
`COVERAGE_ADDITIONAL_DIFF_PROFILE=coverage-shortcut.txt`,
"run: make test-plan",
"run: make format-check",
"./scripts/ci/test-packages.sh list",
`package_output="$(./scripts/ci/test-packages.sh list`,
} {
if !strings.Contains(admission, want) {
t.Errorf("Code Admission workflow missing contract %q", want)
+106
View File
@@ -0,0 +1,106 @@
package scripts_test
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func TestCITestPackagePlanCoversDefaultPackagesExactlyOnce(t *testing.T) {
root := testPackagePlanRoot(t)
output := runTestPackagePlan(t, root, "verify")
if !strings.Contains(output, "default packages exactly once") {
t.Fatalf("verify output = %q, want coverage summary", output)
}
}
func TestCITestPackagePlanRoutesPublicTestSuites(t *testing.T) {
root := testPackagePlanRoot(t)
remaining := strings.Fields(runTestPackagePlan(t, root, "list", "remaining"))
releaseScripts := strings.Fields(runTestPackagePlan(t, root, "list", "release-scripts"))
for _, suffix := range []string{
"/test/cli",
"/test/contract",
"/test/integration/extensions",
"/test/mock_mcp",
"/test/smoke",
"/test/unit",
} {
if !containsPackageSuffix(remaining, suffix) {
t.Errorf("remaining shard does not contain package ending in %q", suffix)
}
}
if containsPackageSuffix(remaining, "/test/scripts") {
t.Error("remaining shard unexpectedly contains /test/scripts")
}
if !containsPackageSuffix(releaseScripts, "/test/scripts") {
t.Error("release-scripts shard does not contain /test/scripts")
}
}
func TestCITestPackagePlanFailsClosedWhenGoListFails(t *testing.T) {
root := testPackagePlanRoot(t)
fakeBin := t.TempDir()
fakeGo := filepath.Join(fakeBin, "go")
err := os.WriteFile(fakeGo, []byte(`#!/bin/sh
if [ "$1" = "list" ] && [ "$2" = "-m" ]; then
printf '%s\n' 'github.com/DingTalk-Real-AI/dingtalk-workspace-cli'
exit 0
fi
printf '%s\n' 'injected go list failure' >&2
exit 42
`), 0o755)
if err != nil {
t.Fatalf("write fake go: %v", err)
}
script := filepath.Join(root, "scripts", "ci", "test-packages.sh")
for _, args := range [][]string{{"list", "remaining"}, {"verify"}} {
cmd := exec.Command("sh", append([]string{script}, args...)...)
cmd.Dir = root
cmd.Env = []string{
"PATH=" + fakeBin + string(os.PathListSeparator) + os.Getenv("PATH"),
"TMPDIR=" + t.TempDir(),
}
output, runErr := cmd.CombinedOutput()
if runErr == nil {
t.Fatalf("%s unexpectedly succeeded with failing go list:\n%s", strings.Join(args, " "), output)
}
if !strings.Contains(string(output), "injected go list failure") {
t.Fatalf("%s failure output = %q, want injected failure", strings.Join(args, " "), output)
}
}
}
func testPackagePlanRoot(t *testing.T) string {
t.Helper()
root, err := filepath.Abs(filepath.Join("..", ".."))
if err != nil {
t.Fatalf("resolve repository root: %v", err)
}
return root
}
func runTestPackagePlan(t *testing.T, root string, args ...string) string {
t.Helper()
script := filepath.Join(root, "scripts", "ci", "test-packages.sh")
cmd := exec.Command("sh", append([]string{script}, args...)...)
cmd.Dir = root
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("%s %s failed: %v\n%s", script, strings.Join(args, " "), err, output)
}
return string(output)
}
func containsPackageSuffix(packages []string, suffix string) bool {
for _, packagePath := range packages {
if strings.HasSuffix(packagePath, suffix) {
return true
}
}
return false
}
+111 -8
View File
@@ -758,7 +758,7 @@ func TestReleaseWorkflowGovernancePreflightCannotPublish(t *testing.T) {
"EXPECTED_REPOSITORY: DingTalk-Real-AI/dingtalk-workspace-cli",
`DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}`,
`test "$PREFLIGHT_COMMIT" = "$GITHUB_SHA"`,
`ref: ${{ inputs.governance_preflight_commit }}`,
`ref: ${{ needs.dispatch-contract.outputs.mode == 'create_release' && github.sha || inputs.governance_preflight_commit }}`,
"persist-credentials: false",
"governance preflight cannot be combined with npm repair",
} {
@@ -792,6 +792,9 @@ func TestReleaseWorkflowGovernancePreflightCannotPublish(t *testing.T) {
for _, required := range []string{
"needs: dispatch-contract",
"needs.dispatch-contract.outputs.mode == 'repair_npm'",
`ref: ` + "`tags/withdrawn/${version}`",
"was withdrawn and cannot be repaired",
"verify-release-workflow-delivery.sh --npm-repair",
} {
if !strings.Contains(repair, required) {
t.Errorf("npm repair dispatch contract is missing %q", required)
@@ -799,6 +802,88 @@ func TestReleaseWorkflowGovernancePreflightCannotPublish(t *testing.T) {
}
}
func TestReleaseWorkflowPlansAndSealsCurrentMainInTheCloud(t *testing.T) {
t.Parallel()
workflow := readReleaseWorkflow(t)
planStart := strings.Index(workflow, " release-plan:\n")
sealStart := strings.Index(workflow, " seal-release:\n")
if planStart == -1 || sealStart == -1 || planStart >= sealStart {
t.Fatal("cloud release plan and seal jobs are missing or out of order")
}
plan := workflow[planStart:sealStart]
seal := workflow[sealStart:]
for _, required := range []string{
"release_operation:",
"- none",
"- plan",
"- publish",
"release_channel:",
"release_bump:",
"release_confirmation:",
`release_flow + npm_repair + gitee_repair + oss_repair + governance + recovery`,
`echo "mode=plan_release"`,
`echo "mode=create_release"`,
`release_confirmation must be exactly: PUBLISH $RELEASE_CHANNEL`,
`needs.dispatch-contract.outputs.mode == 'plan_release'`,
`needs.governance-preflight.result == 'success'`,
"actions: read",
"contents: read",
`github.event.repository.default_branch`,
`GITHUB_REPOSITORY" = "$EXPECTED_REPOSITORY`,
`GITHUB_REF" = "refs/heads/$DEFAULT_BRANCH`,
`ref: ${{ github.sha }}`,
"persist-credentials: false",
`refs/remotes/origin/main)" = "$GITHUB_SHA`,
"next-release-version.sh",
"refs/tags/withdrawn/v",
"refs_fingerprint",
"Validate the candidate release contract before sealing",
"release-contract.sh",
"Require delivered previous stable baseline before sealing",
"Require delivered beta before sealing stable",
} {
if !strings.Contains(workflow, required) {
t.Errorf("cloud release contract is missing %q", required)
}
}
if strings.Contains(plan, "contents: write") {
t.Error("cloud release planning must remain read-only")
}
for _, required := range []string{
"name: Seal cloud release tag",
"contents: write",
"name: Create one immutable annotated release tag",
`branch.data.commit.sha !== commit`,
`actualFingerprint !== expectedFingerprint`,
`github.rest.git.createTag`,
`github.rest.git.createRef`,
`ref: ` + "`refs/tags/${version}`",
"`Release-Run: ${context.runId}`",
"`Requested-By: ${context.actor}`",
"`Requested-By-ID: ${context.payload.sender?.id || \"\"}`",
"`Sealed-Commit: ${commit}`",
"`Workflow-Commit: ${context.sha}`",
"`Allocation-Fingerprint: ${expectedFingerprint}`",
} {
if !strings.Contains(seal, required) {
t.Errorf("cloud release seal is missing %q", required)
}
}
for _, forbidden := range []string{
"actions/checkout",
"github.rest.git.updateRef",
"github.rest.git.deleteRef",
"git push",
"--force",
} {
if strings.Contains(seal, forbidden) {
t.Errorf("write-capable cloud seal must not contain %q", forbidden)
}
}
}
func TestReleaseWorkflowChannelRepairUsesSealedReleaseAuthority(t *testing.T) {
t.Parallel()
workflow := readReleaseWorkflow(t)
@@ -807,7 +892,11 @@ func TestReleaseWorkflowChannelRepairUsesSealedReleaseAuthority(t *testing.T) {
if start == -1 {
t.Fatal("release workflow is missing the channel repair job")
}
repair := workflow[start:]
end := strings.Index(workflow[start:], "\n release-plan:\n")
if end == -1 {
t.Fatal("release workflow channel repair job is missing its end marker")
}
repair := workflow[start : start+end]
tagAuthority := releaseWorkflowSection(
t,
repair,
@@ -826,7 +915,7 @@ func TestReleaseWorkflowChannelRepairUsesSealedReleaseAuthority(t *testing.T) {
"oss_repair=0",
`test -z "$REPAIR_GITEE_VERSION" || gitee_repair=1`,
`test -z "$REPAIR_OSS_VERSION" || oss_repair=1`,
"npm_repair + gitee_repair + oss_repair + governance + recovery",
"release_flow + npm_repair + gitee_repair + oss_repair + governance + recovery",
`echo "mode=repair_gitee" >> "$GITHUB_OUTPUT"`,
`echo "mode=repair_oss" >> "$GITHUB_OUTPUT"`,
} {
@@ -847,6 +936,8 @@ func TestReleaseWorkflowChannelRepairUsesSealedReleaseAuthority(t *testing.T) {
"persist-credentials: false",
"release_is_stable_version",
"release_is_prerelease_version",
`ref: ` + "`tags/withdrawn/${version}`",
"was withdrawn and cannot be repaired",
`ref: ` + "`tags/${version}`",
`["ahead", "identical"].includes(comparison.data.status)`,
"!release.data.immutable",
@@ -942,12 +1033,14 @@ func TestReleaseWorkflowRecoveryReusesGuardedJobs(t *testing.T) {
"protected_branches !== true",
"can_admins_bypass !== false",
`run.path !== ".github/workflows/release.yml"`,
`run.event !== "push"`,
`const expectedEvent = failedByCloud ? "workflow_dispatch" : "push"`,
`run.event !== expectedEvent`,
`tagFields.get("Release-Run") !== failedRunId`,
`"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_branch !== expectedBranch`,
`run.head_sha !== commit`,
`tagObject !== expectedTagObject`,
`["ahead", "identical"].includes(comparison.data.status)`,
@@ -960,7 +1053,7 @@ func TestReleaseWorkflowRecoveryReusesGuardedJobs(t *testing.T) {
`ref: sha`,
`path: tmp/trusted-release-tooling`,
`ref: ${{ github.sha }}`,
`step.name === "Require immutable published GitHub Release"`,
"verify-release-workflow-delivery.sh",
"Require a clean sealed source before GoReleaser",
`git status --porcelain --untracked-files=all`,
} {
@@ -1105,7 +1198,11 @@ func TestRecoverReleaseBindsOneFailedRunAttempt(t *testing.T) {
"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`,
`[.id, .run_attempt, .repository.full_name, .path, .event, .status, .conclusion, .head_branch, .head_sha, .actor.login, .actor.id] | @tsv`,
`Release-Run`,
`Release-Run-Attempt`,
`expected_attempt_event="workflow_dispatch"`,
`is not bound by the cloud seal`,
"actions/runs/%s/attempts/%s",
`-f "recover_failed_run_attempt=$FAILED_RUN_ATTEMPT"`,
} {
@@ -1129,7 +1226,7 @@ func TestReleaseWorkflowPublicationBypassesSkippedDispatchButStopsOnCancellation
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')) }}`,
condition: `if: ${{ !cancelled() && (github.event_name == 'push' || (needs.dispatch-contract.result == 'success' && needs.dispatch-contract.outputs.mode == 'recover_release' && needs.authorize-recovery.result == 'success') || (needs.dispatch-contract.result == 'success' && needs.dispatch-contract.outputs.mode == 'create_release' && needs.governance-preflight.result == 'success' && needs.release-plan.result == 'success' && needs.seal-release.result == 'success')) }}`,
},
{
name: "build",
@@ -1192,7 +1289,11 @@ func TestReleaseWorkflowDeliveryGateFailsClosed(t *testing.T) {
"- mirror-gitee-release",
"- repair-npm",
"- repair-channel",
"- release-plan",
"- seal-release",
`REPAIR_CHANNEL_RESULT: ${{ needs.repair-channel.result }}`,
`RELEASE_PLAN_RESULT: ${{ needs.release-plan.result }}`,
`SEAL_RELEASE_RESULT: ${{ needs.seal-release.result }}`,
"require_publication",
`require_result release-contract "$RELEASE_CONTRACT_RESULT" success`,
`require_result release "$RELEASE_RESULT" success`,
@@ -1200,6 +1301,8 @@ func TestReleaseWorkflowDeliveryGateFailsClosed(t *testing.T) {
`require_result publish-release "$PUBLISH_RELEASE_RESULT" success`,
`require_result publish-channels "$PUBLISH_CHANNELS_RESULT" success`,
"workflow_dispatch:recover_release",
"workflow_dispatch:create_release",
"workflow_dispatch:plan_release",
"workflow_dispatch:governance_preflight",
"workflow_dispatch:repair_npm",
"workflow_dispatch:repair_gitee",
+401
View File
@@ -141,6 +141,136 @@ func TestReleaseVersionOrdering(t *testing.T) {
}
}
func TestNextReleaseVersion(t *testing.T) {
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
if err != nil {
t.Fatalf("Abs(repo root) error = %v", err)
}
script := filepath.Join(sourceRoot, "scripts", "release", "next-release-version.sh")
newRepo := func(t *testing.T, tags ...string) string {
t.Helper()
root := t.TempDir()
mustRun(t, root, "git", "init", "-b", "main")
mustRun(t, root, "git", "config", "user.name", "Release Test")
mustRun(t, root, "git", "config", "user.email", "release-test@example.com")
mustWriteFile(t, filepath.Join(root, "seed.txt"), []byte("release allocator\n"), 0o644)
mustRun(t, root, "git", "add", "seed.txt")
mustRun(t, root, "git", "commit", "-m", "seed")
for _, tag := range tags {
mustRun(t, root, "git", "tag", "-a", tag, "-m", "Allocate "+tag)
}
return root
}
run := func(t *testing.T, root string, args ...string) (string, error) {
t.Helper()
commandArgs := append([]string{script, "--repo-root", root}, args...)
output, err := exec.Command("sh", commandArgs...).CombinedOutput()
return string(output), err
}
t.Run("continues highest open beta core", func(t *testing.T) {
root := newRepo(t,
"v1.0.52",
"v1.0.53-beta.1",
"v1.0.53-beta.2",
"v1.0.53-beta.3",
"v1.0.53-beta.4",
)
output, err := run(t, root, "--channel", "beta")
if err != nil {
t.Fatalf("next beta error = %v\noutput:\n%s", err, output)
}
want := "release_version=v1.0.53-beta.5\nfrom_beta=\nchannel=prerelease\nbase=v1.0.52\n"
if output != want {
t.Fatalf("next beta output:\ngot:\n%s\nwant:\n%s", output, want)
}
})
t.Run("withdrawn beta remains allocated", func(t *testing.T) {
root := newRepo(t,
"v1.0.52",
"v1.0.53-beta.4",
"withdrawn/v1.0.53-beta.5",
)
output, err := run(t, root, "--channel", "prerelease")
if err != nil {
t.Fatalf("next beta after withdrawal error = %v\noutput:\n%s", err, output)
}
if !strings.Contains(output, "release_version=v1.0.53-beta.6\n") {
t.Fatalf("withdrawn beta version was reused:\n%s", output)
}
})
t.Run("starts requested bumped beta core", func(t *testing.T) {
tests := []struct {
bump string
want string
}{
{bump: "patch", want: "v1.2.4-beta.1"},
{bump: "minor", want: "v1.3.0-beta.1"},
{bump: "major", want: "v2.0.0-beta.1"},
}
for _, test := range tests {
t.Run(test.bump, func(t *testing.T) {
root := newRepo(t, "v1.2.3")
output, err := run(t, root, "--channel", "beta", "--bump", test.bump)
if err != nil {
t.Fatalf("new beta line error = %v\noutput:\n%s", err, output)
}
if !strings.Contains(output, "release_version="+test.want+"\n") {
t.Fatalf("new beta line output:\n%s", output)
}
})
}
})
t.Run("promotes latest beta from highest open core", func(t *testing.T) {
root := newRepo(t,
"v1.0.0",
"v1.1.0-beta.3",
"v1.2.0-beta.1",
"v1.2.0-beta.2",
)
output, err := run(t, root, "--channel", "stable")
if err != nil {
t.Fatalf("stable allocation error = %v\noutput:\n%s", err, output)
}
want := "release_version=v1.2.0\nfrom_beta=v1.2.0-beta.2\nchannel=stable\nbase=v1.0.0\n"
if output != want {
t.Fatalf("stable allocation output:\ngot:\n%s\nwant:\n%s", output, want)
}
})
t.Run("withdrawn stable closes its core", func(t *testing.T) {
root := newRepo(t,
"v1.0.0",
"v1.0.1-beta.1",
"withdrawn/v1.0.1",
)
output, err := run(t, root, "--channel", "beta")
if err != nil {
t.Fatalf("beta after withdrawn stable error = %v\noutput:\n%s", err, output)
}
want := "release_version=v1.0.2-beta.1\nfrom_beta=\nchannel=prerelease\nbase=v1.0.1\n"
if output != want {
t.Fatalf("withdrawn stable allocation output:\ngot:\n%s\nwant:\n%s", output, want)
}
})
t.Run("withdrawn latest beta cannot be promoted", func(t *testing.T) {
root := newRepo(t,
"v1.0.0",
"v1.0.1-beta.1",
"withdrawn/v1.0.1-beta.2",
)
output, err := run(t, root, "--channel", "stable")
if err == nil || !strings.Contains(output, "latest beta v1.0.1-beta.2 is withdrawn") {
t.Fatalf("withdrawn beta promotion was not rejected: err=%v\noutput:\n%s", err, output)
}
})
}
func TestReleaseLibHelpersPreserveCallerVariables(t *testing.T) {
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
if err != nil {
@@ -674,6 +804,174 @@ esac
}
}
func TestReleaseWorkflowDeliveryAcceptsOnlyTagBoundCloudRelease(t *testing.T) {
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
if err != nil {
t.Fatalf("Abs(repo root) error = %v", err)
}
binDir := t.TempDir()
fakeCurl := filepath.Join(binDir, "curl")
mustWriteFile(t, fakeCurl, []byte(`#!/bin/sh
set -eu
for argument in "$@"; do endpoint="$argument"; done
case "$endpoint" in
*event=push*)
printf '{"workflow_runs":[]}\n'
;;
*/git/ref/tags/*)
printf '{"object":{"type":"tag","sha":"%s"}}\n' "$TAG_OBJECT"
;;
*/git/tags/*)
python3 - <<'PY'
import json
import os
message = "\n".join([
f"Release {os.environ['TAG']}",
"",
"Channel: prerelease",
"Release-Run: 42",
"Release-Run-Attempt: 1",
"Requested-By: release-user",
"Requested-By-ID: 1234",
f"Sealed-Commit: {os.environ['RELEASE_COMMIT']}",
f"Workflow-Commit: {os.environ['RELEASE_COMMIT']}",
f"Allocation-Fingerprint: {'d' * 64}",
])
print(json.dumps({
"tag": os.environ["TAG"],
"message": message,
"object": {"type": "commit", "sha": os.environ["RELEASE_COMMIT"]},
}))
PY
;;
*/actions/runs/42/attempts/1/jobs*)
python3 - <<'PY'
import json
import os
required = [
"Plan next cloud release",
"Seal cloud release tag",
"release-contract",
"Build signed release artifacts",
"Verify Apple Developer ID signatures",
"Publish immutable GitHub Release",
"Publish npm and mirrors",
"Release delivery gate",
]
if os.environ.get("MISSING_SEAL") == "1":
required.remove("Seal cloud release tag")
jobs = []
for name in required:
job = {
"name": name,
"status": "completed",
"conclusion": "success",
"head_sha": os.environ["RELEASE_COMMIT"],
"steps": [],
}
if name == "Seal cloud release tag":
job["steps"] = [{
"name": "Create one immutable annotated release tag",
"status": "completed",
"conclusion": "success",
}]
elif name == "Publish immutable GitHub Release":
job["steps"] = [{
"name": "Require immutable published GitHub Release",
"status": "completed",
"conclusion": "success",
}]
if os.environ.get("PUBLISH_RELEASE_FAILURE") == "1":
job["conclusion"] = "failure"
elif name == "Publish npm and mirrors":
job["conclusion"] = os.environ.get("CHANNEL_CONCLUSION", "success")
jobs.append(job)
print(json.dumps({"jobs": jobs}))
PY
;;
*/actions/runs/42/attempts/1)
python3 - <<'PY'
import json
import os
print(json.dumps({
"id": 42,
"run_attempt": 1,
"repository": {"full_name": "owner/repo"},
"path": ".github/workflows/release.yml",
"event": "workflow_dispatch",
"status": "completed",
"conclusion": os.environ.get("RUN_CONCLUSION", "success"),
"head_branch": "main",
"head_sha": os.environ["RELEASE_COMMIT"],
"actor": {
"login": os.environ.get("RUN_ACTOR", "release-user"),
"id": int(os.environ.get("RUN_ACTOR_ID", "1234")),
},
}))
PY
;;
*event=workflow_dispatch*)
printf '{"workflow_runs":[]}\n'
;;
*) exit 1 ;;
esac
`), 0o755)
script := filepath.Join(sourceRoot, "scripts", "release", "verify-release-workflow-delivery.sh")
tag := "v1.2.3-beta.1"
commit := strings.Repeat("a", 40)
tagObject := strings.Repeat("b", 40)
runWithArgs := func(arguments []string, overrides ...string) (string, error) {
cmd := exec.Command("sh", script, tag, commit)
cmd.Args = append([]string{"sh", script}, arguments...)
cmd.Env = append([]string{
"PATH=" + binDir + string(os.PathListSeparator) + os.Getenv("PATH"),
"HOME=" + t.TempDir(),
"DWS_RELEASE_OFFICIAL_REPOSITORY=owner/repo",
"TAG=" + tag,
"TAG_OBJECT=" + tagObject,
"RELEASE_COMMIT=" + commit,
}, overrides...)
output, err := cmd.CombinedOutput()
return string(output), err
}
run := func(overrides ...string) (string, error) {
return runWithArgs([]string{tag, commit}, overrides...)
}
if output, err := run(); err != nil || !strings.Contains(output, "cloud release run 42") {
t.Fatalf("tag-bound cloud delivery was rejected: err=%v\noutput:\n%s", err, output)
}
if output, err := run("RUN_ACTOR=renamed-release-user"); err != nil ||
!strings.Contains(output, "cloud release run 42") {
t.Fatalf("cloud delivery broke after a harmless login rename: err=%v\noutput:\n%s", err, output)
}
if output, err := run("RUN_ACTOR_ID=9999"); err == nil ||
!strings.Contains(output, "did not deliver") {
t.Fatalf("cloud delivery with the wrong stable actor ID passed: err=%v\noutput:\n%s", err, output)
}
if output, err := run("MISSING_SEAL=1"); err == nil ||
!strings.Contains(output, "did not deliver") {
t.Fatalf("cloud delivery without the seal job passed: err=%v\noutput:\n%s", err, output)
}
if output, err := runWithArgs(
[]string{"--npm-repair", tag, commit},
"RUN_CONCLUSION=failure",
"CHANNEL_CONCLUSION=failure",
); err != nil || !strings.Contains(output, "npm-repair authority verified") {
t.Fatalf("npm repair could not use a sealed immutable release after npm failure: err=%v\noutput:\n%s", err, output)
}
if output, err := runWithArgs(
[]string{"--npm-repair", tag, commit},
"RUN_CONCLUSION=failure",
"CHANNEL_CONCLUSION=failure",
"PUBLISH_RELEASE_FAILURE=1",
); err == nil || strings.Contains(output, "npm-repair authority verified") {
t.Fatalf("npm repair accepted a failed immutable GitHub publication: err=%v\noutput:\n%s", err, output)
}
}
func TestReleaseWorkflowDeliveryChannelRepairRequiresLatestAttemptCoreDelivery(t *testing.T) {
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
if err != nil {
@@ -1031,6 +1329,109 @@ func TestReleaseContractRejectsInvalidVersionChannelPairs(t *testing.T) {
}
}
func TestReleaseContractTreatsWithdrawnVersionsAsPermanentlyAllocated(t *testing.T) {
t.Run("continues after withdrawn beta", func(t *testing.T) {
r := newReleaseTestRepo(t)
section := "## [1.0.1-beta.3] - 2026-07-11\n\n### Changed\n\n- Replace the withdrawn beta.\n\n"
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(section)), 0o644)
r.commitAndPush(t, "prepare replacement beta")
mustRun(t, r.root, "git", "tag", "-a", "v1.0.1-beta.1", "-m", "Release v1.0.1-beta.1")
mustRun(t, r.root, "git", "tag", "-a", "withdrawn/v1.0.1-beta.2", "-m", "Withdraw v1.0.1-beta.2")
output, err := runReleaseScript(t, r.root, r.contract,
"--repo-root", r.root,
"--channel", "prerelease",
"--version", "v1.0.1-beta.3",
"--remote", "origin",
)
if err != nil {
t.Fatalf("replacement beta was rejected: err=%v\noutput:\n%s", err, output)
}
})
t.Run("never reuses exact withdrawn version", func(t *testing.T) {
r := newReleaseTestRepo(t)
section := "## [1.0.1-beta.1] - 2026-07-11\n\n### Changed\n\n- Must not reuse this version.\n\n"
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(section)), 0o644)
r.commitAndPush(t, "prepare withdrawn version")
mustRun(t, r.root, "git", "tag", "-a", "withdrawn/v1.0.1-beta.1", "-m", "Withdraw v1.0.1-beta.1")
output, err := runReleaseScript(t, r.root, r.contract,
"--repo-root", r.root,
"--channel", "prerelease",
"--version", "v1.0.1-beta.1",
"--remote", "origin",
)
if err == nil || !strings.Contains(output, "can never be reused") {
t.Fatalf("withdrawn version was reusable: err=%v\noutput:\n%s", err, output)
}
})
t.Run("withdrawn beta cannot be promoted", func(t *testing.T) {
r := newReleaseTestRepo(t)
r.seedBeta(t)
mustRun(t, r.root, "git", "tag", "-a", "withdrawn/v1.0.1-beta.1", "-m", "Withdraw v1.0.1-beta.1")
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(stableSection(), betaSection())), 0o644)
r.commitAndPush(t, "prepare blocked stable")
output, err := runReleaseScript(t, r.root, r.contract,
"--repo-root", r.root,
"--channel", "stable",
"--version", "v1.0.1",
"--from-beta", "v1.0.1-beta.1",
"--remote", "origin",
)
if err == nil || !strings.Contains(output, "beta baseline was withdrawn") {
t.Fatalf("withdrawn beta was promoted: err=%v\noutput:\n%s", err, output)
}
})
t.Run("withdrawn stable is a permanent version floor", func(t *testing.T) {
r := newReleaseTestRepo(t)
mustRun(t, r.root, "git", "tag", "-a", "withdrawn/v2.0.0", "-m", "Withdraw v2.0.0")
section := "## [1.1.0-beta.1] - 2026-07-11\n\n### Changed\n\n- Must remain above every allocated stable.\n\n"
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(section)), 0o644)
r.commitAndPush(t, "prepare invalid lower beta")
output, err := runReleaseScript(t, r.root, r.contract,
"--repo-root", r.root,
"--channel", "prerelease",
"--version", "v1.1.0-beta.1",
"--remote", "origin",
)
if err == nil || !strings.Contains(output, "must be greater than latest allocated stable v2.0.0") {
t.Fatalf("release below withdrawn stable floor passed: err=%v\noutput:\n%s", err, output)
}
})
t.Run("tombstone excludes stale ordinary tag from delivered baseline", func(t *testing.T) {
r := newReleaseTestRepo(t)
mustRun(t, r.root, "git", "tag", "-a", "v1.0.1", "-m", "Release v1.0.1")
mustRun(t, r.root, "git", "tag", "-a", "withdrawn/v1.0.1", "-m", "Withdraw v1.0.1")
section := "## [1.0.2-beta.1] - 2026-07-11\n\n### Changed\n\n- Continue after the withdrawn stable.\n\n"
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(section)), 0o644)
r.commitAndPush(t, "prepare beta after withdrawn stable")
metadata := filepath.Join(t.TempDir(), "metadata")
output, err := runReleaseScript(t, r.root, r.contract,
"--repo-root", r.root,
"--channel", "prerelease",
"--version", "v1.0.2-beta.1",
"--remote", "origin",
"--metadata-output", metadata,
)
if err != nil {
t.Fatalf("release after stale withdrawn tag failed: err=%v\noutput:\n%s", err, output)
}
assertFileContains(t, metadata, "previous_stable=v1.0.0")
if content, err := os.ReadFile(metadata); err != nil {
t.Fatalf("ReadFile(metadata) error = %v", err)
} else if strings.Contains(string(content), "previous_stable=v1.0.1") {
t.Fatalf("withdrawn stale tag remained the delivered baseline:\n%s", content)
}
})
}
func TestReleaseContractRejectsBadChangelogSections(t *testing.T) {
tests := []struct {
name string
-234
View File
@@ -1,234 +0,0 @@
#!/usr/bin/env bash
# run_all_tests.sh — Run all test suites in parallel and print a summary.
# Usage: bash test/scripts/run_all_tests.sh [--verbose] [--timeout <duration>] [--jobs <n>]
#
# Options:
# --verbose Print full test output for every suite (default: only on failure)
# --timeout <dur> Per-suite go test timeout (default: 120s)
# --jobs <n> Max parallel suites (default: number of CPU cores)
set -uo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
VERBOSE=false
TIMEOUT="120s"
MAX_JOBS="$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo 4)"
# ── Parse flags ──────────────────────────────────────────────────────────────
while [[ $# -gt 0 ]]; do
case "$1" in
--verbose) VERBOSE=true; shift ;;
--timeout) TIMEOUT="$2"; shift 2 ;;
--jobs) MAX_JOBS="$2"; shift 2 ;;
*) echo "Unknown option: $1" >&2; exit 1 ;;
esac
done
# ── Colour helpers ────────────────────────────────────────────────────────────
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
BOLD='\033[1m'
RESET='\033[0m'
# ── Test suite definitions ────────────────────────────────────────────────────
# Each entry: "label|package_path"
declare -a SUITES=(
"cli|./test/cli/..."
"contract|./test/contract/..."
"integration/extensions|./test/integration/extensions/..."
"integration/recovery|./test/integration/recovery/..."
"mock_mcp|./test/mock_mcp/..."
"scripts|./test/scripts/..."
)
# ── Temporary workspace ───────────────────────────────────────────────────────
LOG_DIR="$(mktemp -d)"
trap 'rm -rf "$LOG_DIR"' EXIT
# ── Worker: run one suite, write result files ─────────────────────────────────
# Result files written to $LOG_DIR/<safe_label>/{status,output}
run_suite_worker() {
local label="$1"
local pkg="$2"
local safe_label="${label//\//_}"
local suite_dir="$LOG_DIR/$safe_label"
mkdir -p "$suite_dir"
# Check whether the package directory actually exists
local pkg_dir="${pkg//\.\//}"
pkg_dir="${pkg_dir%/...}"
if [[ ! -d "$REPO_ROOT/$pkg_dir" ]]; then
echo "skip" > "$suite_dir/status"
echo "(directory not found)" > "$suite_dir/output"
return
fi
local exit_code=0
(
cd "$REPO_ROOT"
go test -timeout "$TIMEOUT" -count=1 "$pkg" 2>&1
) > "$suite_dir/output" 2>&1 || exit_code=$?
if [[ $exit_code -eq 0 ]]; then
echo "pass" > "$suite_dir/status"
elif grep -q "matched no packages\|no packages to test\|\[no test files\]" "$suite_dir/output" 2>/dev/null; then
echo "skip" > "$suite_dir/status"
echo "(no test files found)" > "$suite_dir/output"
else
echo "fail" > "$suite_dir/status"
fi
}
export -f run_suite_worker
export REPO_ROOT TIMEOUT LOG_DIR
# ── Main ──────────────────────────────────────────────────────────────────────
echo ""
printf "${BOLD}╔══════════════════════════════════════════════════════════════╗${RESET}\n"
printf "${BOLD}║ dws — Full Test Suite Runner ║${RESET}\n"
printf "${BOLD}╚══════════════════════════════════════════════════════════════╝${RESET}\n"
echo ""
printf "Repo root : %s\n" "$REPO_ROOT"
printf "Timeout : %s per suite\n" "$TIMEOUT"
printf "Parallel : %s jobs\n" "$MAX_JOBS"
printf "Verbose : %s\n" "$VERBOSE"
echo ""
printf "${BOLD}Launching %d suites in parallel…${RESET}\n" "${#SUITES[@]}"
echo ""
START_TIME=$(date +%s)
# ── Dispatch all suites concurrently with a job-slot semaphore ────────────────
declare -a PIDS=()
declare -a PID_LABELS=()
active_jobs=0
for suite in "${SUITES[@]}"; do
label="${suite%%|*}"
pkg="${suite##*|}"
printf " ${CYAN}START${RESET} %s\n" "$label"
run_suite_worker "$label" "$pkg" &
PIDS+=($!)
PID_LABELS+=("$label")
active_jobs=$((active_jobs + 1))
# Throttle: wait for one slot to free up when at capacity
if [[ $active_jobs -ge $MAX_JOBS ]]; then
wait "${PIDS[$((${#PIDS[@]} - MAX_JOBS))]}" 2>/dev/null || true
active_jobs=$((active_jobs - 1))
fi
done
# Wait for all remaining background jobs
for pid in "${PIDS[@]}"; do
wait "$pid" 2>/dev/null || true
done
END_TIME=$(date +%s)
ELAPSED=$((END_TIME - START_TIME))
# ── Collect results in original suite order ───────────────────────────────────
TOTAL=0
PASSED=0
FAILED=0
SKIPPED=0
declare -a FAILED_SUITES=()
declare -a PASSED_SUITES=()
declare -a SKIPPED_SUITES=()
echo ""
printf "${BOLD}Results:${RESET}\n"
echo ""
for suite in "${SUITES[@]}"; do
label="${suite%%|*}"
safe_label="${label//\//_}"
suite_dir="$LOG_DIR/$safe_label"
status_file="$suite_dir/status"
output_file="$suite_dir/output"
TOTAL=$((TOTAL + 1))
if [[ ! -f "$status_file" ]]; then
# Worker never wrote a status — treat as failure
printf " ${RED}FAIL${RESET} %s (worker did not complete)\n" "$label"
FAILED=$((FAILED + 1))
FAILED_SUITES+=("$label")
continue
fi
status="$(cat "$status_file")"
case "$status" in
pass)
PASSED=$((PASSED + 1))
PASSED_SUITES+=("$label")
printf " ${GREEN}PASS${RESET} %s\n" "$label"
if $VERBOSE; then
sed 's/^/ /' "$output_file"
fi
;;
skip)
SKIPPED=$((SKIPPED + 1))
SKIPPED_SUITES+=("$label")
printf " ${YELLOW}SKIP${RESET} %s %s\n" "$label" "$(cat "$output_file")"
;;
fail)
FAILED=$((FAILED + 1))
FAILED_SUITES+=("$label")
printf " ${RED}FAIL${RESET} %s\n" "$label"
echo " ── output ──────────────────────────────────────────────────────"
sed 's/^/ /' "$output_file"
echo " ────────────────────────────────────────────────────────────────"
;;
esac
done
# ── Summary ───────────────────────────────────────────────────────────────────
echo ""
printf "${BOLD}══════════════════════════════════════════════════════════════${RESET}\n"
printf "${BOLD} Summary (elapsed: %ds, parallel: %s jobs)${RESET}\n" "$ELAPSED" "$MAX_JOBS"
printf "${BOLD}══════════════════════════════════════════════════════════════${RESET}\n"
printf " Total : %d\n" "$TOTAL"
printf " ${GREEN}Passed : %d${RESET}\n" "$PASSED"
printf " ${RED}Failed : %d${RESET}\n" "$FAILED"
printf " ${YELLOW}Skipped : %d${RESET}\n" "$SKIPPED"
echo ""
if [[ ${#FAILED_SUITES[@]} -gt 0 ]]; then
printf "${RED}${BOLD}Failed suites:${RESET}\n"
for suite in "${FAILED_SUITES[@]}"; do
printf " ${RED}✗${RESET} %s\n" "$suite"
done
echo ""
fi
if [[ ${#SKIPPED_SUITES[@]} -gt 0 ]]; then
printf "${YELLOW}Skipped suites:${RESET}\n"
for suite in "${SKIPPED_SUITES[@]}"; do
printf " ${YELLOW}–${RESET} %s\n" "$suite"
done
echo ""
fi
if [[ ${#PASSED_SUITES[@]} -gt 0 ]]; then
printf "${GREEN}Passed suites:${RESET}\n"
for suite in "${PASSED_SUITES[@]}"; do
printf " ${GREEN}✓${RESET} %s\n" "$suite"
done
echo ""
fi
# ── Exit code ─────────────────────────────────────────────────────────────────
if [[ $FAILED -gt 0 ]]; then
printf "${RED}${BOLD}Result: FAILED${RESET}\n\n"
exit 1
else
printf "${GREEN}${BOLD}Result: ALL PASSED${RESET}\n\n"
exit 0
fi
+746
View File
@@ -0,0 +1,746 @@
package scripts_test
import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func TestWithdrawReleaseWorkflowIsProtectedAndFailClosed(t *testing.T) {
t.Parallel()
workflowPath, err := filepath.Abs(filepath.Join("..", "..", ".github", "workflows", "withdraw-release.yml"))
if err != nil {
t.Fatalf("Abs(withdraw workflow) error = %v", err)
}
content, err := os.ReadFile(workflowPath)
if err != nil {
t.Fatalf("ReadFile(%s) error = %v", workflowPath, err)
}
workflow := string(content)
for _, want := range []string{
"workflow_dispatch:",
"version:",
"reason:",
"confirmation:",
"group: dws-release-publication",
"cancel-in-progress: false",
"environment: release-withdrawal",
"prevent_self_review !== true",
"deployment_branch_policy?.protected_branches !== true",
"can_admins_bypass !== false",
`const expectedRepository = "DingTalk-Real-AI/dingtalk-workspace-cli"`,
"context.ref !== `refs/heads/${defaultBranch}`",
"branch.data.object.sha !== context.sha",
"contents: write",
"persist-credentials: false",
"NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}",
"DWS_GITEE_ENABLED:",
"HOMEBREW_PR_TOKEN: ${{ secrets.HOMEBREW_PR_TOKEN }}",
"./scripts/release/withdraw-release.sh",
"already-installed clients cannot be remotely downgraded.",
"this run remains failed until that PR is independently reviewed",
} {
if !strings.Contains(workflow, want) {
t.Errorf("withdraw workflow missing %q", want)
}
}
for _, forbidden := range []string{
"\n push:",
"\n schedule:",
"npm unpublish",
"cancel-in-progress: true",
} {
if strings.Contains(workflow, forbidden) {
t.Errorf("withdraw workflow contains forbidden trigger/action %q", forbidden)
}
}
}
func TestWithdrawReleaseScriptDeletesProblemReleaseLastAndUsesPermanentTombstone(t *testing.T) {
t.Parallel()
scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "withdraw-release.sh"))
if err != nil {
t.Fatalf("Abs(withdraw script) error = %v", err)
}
content, err := os.ReadFile(scriptPath)
if err != nil {
t.Fatalf("ReadFile(%s) error = %v", scriptPath, err)
}
script := string(content)
for _, want := range []string{
`TOMBSTONE="withdrawn/${VERSION}"`,
`github_api --method POST "repos/${OFFICIAL_REPOSITORY}/git/tags"`,
`github_api --method POST "repos/${OFFICIAL_REPOSITORY}/git/refs"`,
"existing tombstone $TOMBSTONE has different immutable withdrawal metadata",
`github_api --method PATCH`,
`npm deprecate "${PACKAGE_NAME}@${VERSION#v}"`,
`npm dist-tag add "${PACKAGE_NAME}@${ROLLBACK_VERSION#v}"`,
`"$OSSUTIL" rm -rf`,
`curl -fsS -X DELETE`,
`git push "$GITEE_GIT_REMOTE" ":refs/tags/${VERSION}"`,
`)" || return 1`,
`err "could not verify Gitee tag deletion for $VERSION"`,
`disabled_gitee_release_id="$(gitee_release_id "$VERSION")"`,
`DWS_TAP_PR_TITLE="revert: withdraw ${VERSION} and restore ${ROLLBACK_VERSION}"`,
"Homebrew rollback PR requires independent review and merge",
`"repos/${OFFICIAL_REPOSITORY}/releases/${TARGET_RELEASE_ID}"`,
`"repos/${OFFICIAL_REPOSITORY}/git/refs/tags/${VERSION}"`,
`github_expect_404 "repos/${OFFICIAL_REPOSITORY}/releases/tags/${VERSION}"`,
`github_expect_404 "repos/${OFFICIAL_REPOSITORY}/git/ref/tags/${VERSION}"`,
"Already-installed clients cannot be remotely downgraded",
} {
if !strings.Contains(script, want) {
t.Errorf("withdraw script missing %q", want)
}
}
for _, forbidden := range []string{
"npm unpublish",
`"repos/${OFFICIAL_REPOSITORY}/git/refs/tags/${TOMBSTONE}"`,
`--force "refs/tags/${TOMBSTONE}`,
`make_latest`,
} {
if strings.Contains(script, forbidden) {
t.Errorf("withdraw script contains evidence-destroying operation %q", forbidden)
}
}
tombstone := strings.LastIndex(script, "\ncreate_tombstone\n")
githubMutation := strings.LastIndex(script, "\nupdate_github_release\n")
npmMutation := strings.LastIndex(script, "\nupdate_npm_channel\n")
ossMutation := strings.LastIndex(script, "\nupdate_oss_channel ")
homebrewGate := strings.LastIndex(script, "\nupdate_homebrew\n")
githubDelete := strings.LastIndex(script, "\ndelete_github_release_and_tag\n")
if tombstone < 0 || githubMutation < 0 || npmMutation < 0 || ossMutation < 0 ||
homebrewGate < 0 || githubDelete < 0 {
t.Fatalf("could not locate withdrawal mutation sequence")
}
if !(tombstone < homebrewGate && homebrewGate < githubMutation &&
githubMutation < npmMutation && npmMutation < ossMutation && ossMutation < githubDelete) {
t.Fatalf("unsafe withdrawal order: tombstone=%d github-mark=%d npm=%d oss=%d homebrew=%d github-delete=%d",
tombstone, githubMutation, npmMutation, ossMutation, homebrewGate, githubDelete)
}
}
func TestWithdrawReleaseRejectsInvalidInputsBeforeMutation(t *testing.T) {
t.Parallel()
scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "withdraw-release.sh"))
if err != nil {
t.Fatalf("Abs(withdraw script) error = %v", err)
}
tests := []struct {
name string
version string
reason string
confirmation string
want string
}{
{
name: "invalid version",
version: "1.2.3",
reason: "critical startup regression",
confirmation: "WITHDRAW 1.2.3",
want: "version must be exactly",
},
{
name: "wrong confirmation",
version: "v1.2.3",
reason: "critical startup regression",
confirmation: "v1.2.3",
want: "confirmation must be exactly: WITHDRAW v1.2.3",
},
{
name: "multiline reason",
version: "v1.2.3",
reason: "critical\nregression",
confirmation: "WITHDRAW v1.2.3",
want: "reason must be a trimmed, printable single line",
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
cmd := exec.Command("bash", scriptPath, test.version, test.reason, test.confirmation)
output, err := cmd.CombinedOutput()
if err == nil {
t.Fatalf("withdraw-release.sh unexpectedly accepted invalid input:\n%s", string(output))
}
if !strings.Contains(string(output), test.want) {
t.Fatalf("withdraw-release.sh output missing %q:\n%s", test.want, string(output))
}
})
}
}
func TestWithdrawReleaseRollsBackConfiguredChannelsAndStopsForHomebrewReview(t *testing.T) {
root := t.TempDir()
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
if err != nil {
t.Fatalf("Abs(source root) error = %v", err)
}
for _, rel := range []string{
"scripts/release/withdraw-release.sh",
"scripts/release/release-lib.sh",
"build/homebrew-release.rb.tmpl",
} {
copyTestFile(t, filepath.Join(sourceRoot, rel), filepath.Join(root, rel), 0o755)
}
mustWriteFile(t, filepath.Join(root, "scripts", "release", "verify-delivery"), []byte(`#!/bin/sh
printf 'delivery %s %s\n' "$1" "$2" >> "$CALL_LOG"
exit 0
`), 0o755)
mustWriteFile(t, filepath.Join(root, "scripts", "release", "download-release-assets"), []byte(`#!/bin/sh
set -eu
version="$1"
dist="$2"
mkdir -p "$dist"
for asset in \
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
printf '%s %s\n' "$version" "$asset" > "$dist/$asset"
done
{
for asset in \
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
printf '%064d %s\n' 0 "$asset"
done
} > "$dist/checksums.txt"
printf 'download-assets %s\n' "$version" >> "$CALL_LOG"
`), 0o755)
mustWriteFile(t, filepath.Join(root, "scripts", "release", "verify-release-assets"), []byte(`#!/bin/sh
set -eu
test -f "$DWS_PACKAGE_DIST_DIR/checksums.txt"
printf 'verify-assets %s\n' "$1" >> "$CALL_LOG"
`), 0o755)
mustWriteFile(t, filepath.Join(root, "scripts", "release", "sync-gitee"), []byte(`#!/bin/sh
set -eu
git push "$GITEE_GIT_REMOTE" "refs/tags/$VERSION:refs/tags/$VERSION" >/dev/null
printf '%s\n' "$VERSION" > "$MOCK_STATE/gitee-release-$VERSION"
printf 'gitee-sync %s\n' "$VERSION" >> "$CALL_LOG"
`), 0o755)
mustWriteFile(t, filepath.Join(root, "scripts", "release", "publish-homebrew-formula.sh"), []byte(`#!/bin/sh
cp "$DWS_FORMULA_SOURCE" "$MOCK_STATE/homebrew-formula"
printf 'homebrew-pr %s %s\n' "$DWS_TAP_PR_TITLE" "$DWS_TAP_PR_BRANCH" >> "$CALL_LOG"
exit 0
`), 0o755)
stateDir := filepath.Join(root, "state")
fakeBin := filepath.Join(root, "bin")
if err := os.MkdirAll(stateDir, 0o755); err != nil {
t.Fatalf("MkdirAll(state) error = %v", err)
}
callLog := filepath.Join(stateDir, "calls.log")
writeWithdrawMocks(t, fakeBin)
mustWriteFile(t, filepath.Join(root, "Formula", "dingtalk-workspace-cli.rb"), []byte(`class DingtalkWorkspaceCli < Formula
version "1.0.51"
end
`), 0o644)
mustRun(t, root, "git", "init", "-b", "main")
mustRun(t, root, "git", "config", "user.name", "Withdrawal Test")
mustRun(t, root, "git", "config", "user.email", "withdrawal@example.com")
mustRun(t, root, "git", "add", ".")
mustRun(t, root, "git", "commit", "-m", "candidate")
mustRun(t, root, "git", "tag", "-a", "v1.0.51", "-m", "Release v1.0.51")
mustWriteFile(t, filepath.Join(root, "Formula", "dingtalk-workspace-cli.rb"), []byte(`class DingtalkWorkspaceCli < Formula
version "1.0.52"
end
`), 0o644)
mustRun(t, root, "git", "add", "Formula/dingtalk-workspace-cli.rb")
mustRun(t, root, "git", "commit", "-m", "target")
mustRun(t, root, "git", "tag", "-a", "v1.0.52", "-m", "Release v1.0.52")
targetCommit := strings.TrimSpace(mustOutput(t, root, "git", "rev-parse", "HEAD"))
targetTagObject := strings.TrimSpace(mustOutput(t, root, "git", "rev-parse", "refs/tags/v1.0.52"))
origin := filepath.Join(root, "origin.git")
gitee := filepath.Join(root, "gitee.git")
mustRun(t, root, "git", "init", "--bare", origin)
mustRun(t, root, "git", "init", "--bare", gitee)
mustRun(t, root, "git", "remote", "add", "origin", origin)
mustRun(t, root, "git", "push", "origin", "main", "v1.0.51", "v1.0.52")
mustRun(t, root, "git", "push", gitee, "v1.0.52")
writeJSONFile(t, filepath.Join(stateDir, "release-v1.0.51.json"), map[string]any{
"id": 51, "tag_name": "v1.0.51", "name": "v1.0.51", "body": "candidate",
"draft": false, "prerelease": false, "immutable": true,
})
writeJSONFile(t, filepath.Join(stateDir, "release-v1.0.52.json"), map[string]any{
"id": 52, "tag_name": "v1.0.52", "name": "v1.0.52", "body": "target",
"draft": false, "prerelease": false, "immutable": true,
})
mustWriteFile(t, filepath.Join(stateDir, "latest"), []byte("v1.0.52\n"), 0o644)
mustWriteFile(t, filepath.Join(stateDir, "npm-latest"), []byte("1.0.52\n"), 0o644)
mustWriteFile(t, filepath.Join(stateDir, "oss-latest"), []byte("v1.0.52\n"), 0o644)
mustWriteFile(t, filepath.Join(stateDir, "gitee-release-v1.0.52"), []byte("752\n"), 0o644)
mustWriteFile(t, filepath.Join(stateDir, "github-tag-v1.0.52"), []byte(targetTagObject+"\n"), 0o644)
scriptPath := filepath.Join(root, "scripts", "release", "withdraw-release.sh")
cmd := exec.Command("bash", scriptPath,
"v1.0.52",
"critical startup regression",
"WITHDRAW v1.0.52",
)
cmd.Dir = root
withdrawEnv := append(os.Environ(),
"PATH="+fakeBin+string(os.PathListSeparator)+os.Getenv("PATH"),
"MOCK_STATE="+stateDir,
"CALL_LOG="+callLog,
"GITHUB_ACTIONS=true",
"GITHUB_REPOSITORY=DingTalk-Real-AI/dingtalk-workspace-cli",
"GITHUB_REF_NAME=main",
"GITHUB_SHA="+targetCommit,
"GITHUB_RUN_ID=12345",
"GITHUB_ACTOR=release-operator",
"GITHUB_EVENT_DEFAULT_BRANCH=main",
"GITHUB_TOKEN=github-token",
"NODE_AUTH_TOKEN=npm-token",
"OSS_ACCESS_KEY_ID=oss-id",
"OSS_ACCESS_KEY_SECRET=oss-secret",
"OSS_ENDPOINT=https://oss-cn-hangzhou.aliyuncs.com",
"OSS_BUCKET=dws-test",
"OSSUTIL="+filepath.Join(fakeBin, "ossutil"),
"DWS_GITEE_ENABLED=true",
"GITEE_TOKEN=gitee-token",
"GITEE_USER=gitee-user",
"GITEE_REPO=DingTalk-Real-AI/dingtalk-workspace-cli",
"GITEE_GIT_REMOTE="+gitee,
"GITEE_PUBLIC_GIT_REMOTE="+gitee,
"HOMEBREW_PR_TOKEN=homebrew-token",
"DWS_DELIVERY_VERIFIER="+filepath.Join(root, "scripts", "release", "verify-delivery"),
"DWS_GITHUB_DOWNLOAD_HELPER="+filepath.Join(root, "scripts", "release", "download-release-assets"),
"DWS_ARTIFACT_VERIFY_HELPER="+filepath.Join(root, "scripts", "release", "verify-release-assets"),
"DWS_GITEE_SYNC_HELPER="+filepath.Join(root, "scripts", "release", "sync-gitee"),
"ORIGIN_GIT="+origin,
)
cmd.Env = withdrawEnv
output, err := cmd.CombinedOutput()
if err == nil {
t.Fatalf("withdrawal must remain failed while Homebrew PR is pending:\n%s", string(output))
}
if !strings.Contains(string(output), "Homebrew rollback PR requires independent review and merge") {
t.Fatalf("withdrawal did not report the Homebrew manual gate:\n%s", string(output))
}
assertFileEquals(t, filepath.Join(stateDir, "latest"), "v1.0.51")
assertFileEquals(t, filepath.Join(stateDir, "npm-latest"), "1.0.51")
assertFileContains(t, filepath.Join(stateDir, "npm-deprecated"), "WITHDRAWN v1.0.52")
assertFileEquals(t, filepath.Join(stateDir, "oss-latest"), "v1.0.51")
if _, err := os.Stat(filepath.Join(stateDir, "oss-removed")); err != nil {
t.Fatalf("OSS withdrawn prefix was not removed: %v", err)
}
if _, err := os.Stat(filepath.Join(stateDir, "gitee-release-v1.0.52")); !os.IsNotExist(err) {
t.Fatalf("Gitee release still exists, stat error = %v", err)
}
if refs := mustOutput(t, root, "git", "ls-remote", gitee, "refs/tags/v1.0.52"); strings.TrimSpace(refs) != "" {
t.Fatalf("Gitee still exposes withdrawn tag:\n%s", refs)
}
if _, err := os.Stat(filepath.Join(stateDir, "release-v1.0.52.json")); !os.IsNotExist(err) {
t.Fatalf("GitHub problem release still exists while Homebrew review is pending, stat error = %v", err)
}
if _, err := os.Stat(filepath.Join(stateDir, "github-tag-v1.0.52")); !os.IsNotExist(err) {
t.Fatalf("GitHub problem tag still exists while Homebrew review is pending, stat error = %v", err)
}
assertFileContains(t, filepath.Join(stateDir, "tombstone-message"), "Original-Commit: "+targetCommit)
assertFileContains(t, filepath.Join(stateDir, "tombstone-message"), "Original-Tag-Object: "+targetTagObject)
assertFileContains(t, filepath.Join(stateDir, "tombstone-message"), "Original-Release-ID: 52")
assertFileContains(t, filepath.Join(stateDir, "tombstone-message"), "Reason: critical startup regression")
assertFileContains(t, callLog, "homebrew-pr revert: withdraw v1.0.52 and restore v1.0.51")
calls, err := os.ReadFile(callLog)
if err != nil {
t.Fatalf("ReadFile(call log) error = %v", err)
}
logText := string(calls)
tombstoneIndex := strings.Index(logText, "tombstone-ref")
githubIndex := strings.Index(logText, "github-withdraw")
npmIndex := strings.Index(logText, "npm-deprecate")
ossIndex := strings.Index(logText, "oss-remove")
if tombstoneIndex < 0 || githubIndex < 0 || npmIndex < 0 || ossIndex < 0 {
t.Fatalf("missing mutation audit entries:\n%s", logText)
}
homebrewIndex := strings.Index(logText, "homebrew-pr")
if !(tombstoneIndex < homebrewIndex && homebrewIndex < githubIndex &&
githubIndex < npmIndex && npmIndex < ossIndex) {
t.Fatalf("tombstone was not durable before channel mutations:\n%s", logText)
}
deleteReleaseIndex := strings.Index(logText, "github-delete-release")
deleteTagIndex := strings.Index(logText, "github-delete-tag")
if deleteReleaseIndex < 0 || deleteTagIndex < 0 || homebrewIndex < 0 ||
!(homebrewIndex < ossIndex && ossIndex < deleteReleaseIndex && deleteReleaseIndex < deleteTagIndex) {
t.Fatalf("GitHub problem release was not removed before the Homebrew review pause:\n%s", logText)
}
mergedFormula, err := os.ReadFile(filepath.Join(stateDir, "homebrew-formula"))
if err != nil {
t.Fatalf("ReadFile(rendered Homebrew rollback) error = %v", err)
}
mustWriteFile(t, filepath.Join(root, "Formula", "dingtalk-workspace-cli.rb"), mergedFormula, 0o644)
mustRun(t, root, "git", "add", "Formula/dingtalk-workspace-cli.rb")
mustRun(t, root, "git", "commit", "-m", "merge Homebrew rollback")
mustRun(t, root, "git", "push", "origin", "main")
retryCommit := strings.TrimSpace(mustOutput(t, root, "git", "rev-parse", "HEAD"))
retryEnv := replaceTestEnv(withdrawEnv,
"GITHUB_SHA", retryCommit,
"GITHUB_RUN_ID", "12346",
)
retry := exec.Command("bash", scriptPath,
"v1.0.52",
"critical startup regression",
"WITHDRAW v1.0.52",
)
retry.Dir = root
retry.Env = retryEnv
retryOutput, retryErr := retry.CombinedOutput()
if retryErr != nil {
t.Fatalf("withdrawal retry after Homebrew merge error = %v\noutput:\n%s", retryErr, string(retryOutput))
}
for _, want := range []string{
"Resuming withdrawal for v1.0.52 from exact permanent tombstone metadata.",
"Permanent tombstone withdrawn/v1.0.52 already exists",
"GitHub Release v1.0.52 was already absent.",
"GitHub tag v1.0.52 was already absent.",
"Withdrawal completed for all configured distribution channels.",
"Already-installed clients cannot be remotely downgraded",
} {
if !strings.Contains(string(retryOutput), want) {
t.Fatalf("withdrawal retry output missing %q:\n%s", want, string(retryOutput))
}
}
assertFileEquals(t, filepath.Join(stateDir, "latest"), "v1.0.51")
if _, err := os.Stat(filepath.Join(stateDir, "release-v1.0.52.json")); !os.IsNotExist(err) {
t.Fatalf("GitHub problem release still exists, stat error = %v", err)
}
if _, err := os.Stat(filepath.Join(stateDir, "github-tag-v1.0.52")); !os.IsNotExist(err) {
t.Fatalf("GitHub problem tag still exists, stat error = %v", err)
}
if refs := mustOutput(t, root, "git", "ls-remote", origin, "refs/tags/v1.0.52"); strings.TrimSpace(refs) != "" {
t.Fatalf("GitHub origin still exposes withdrawn tag:\n%s", refs)
}
if _, err := os.Stat(filepath.Join(stateDir, "tombstone-ref")); err != nil {
t.Fatalf("permanent withdrawal tombstone is missing: %v", err)
}
assertFileContains(t, callLog, "github-delete-release")
assertFileContains(t, callLog, "github-delete-tag")
}
func copyTestFile(t *testing.T, source, destination string, mode os.FileMode) {
t.Helper()
content, err := os.ReadFile(source)
if err != nil {
t.Fatalf("ReadFile(%s) error = %v", source, err)
}
mustWriteFile(t, destination, content, mode)
}
func writeJSONFile(t *testing.T, path string, value any) {
t.Helper()
content, err := json.Marshal(value)
if err != nil {
t.Fatalf("Marshal(%s) error = %v", path, err)
}
mustWriteFile(t, path, append(content, '\n'), 0o644)
}
func assertFileEquals(t *testing.T, path, want string) {
t.Helper()
content, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile(%s) error = %v", path, err)
}
if got := strings.TrimSpace(string(content)); got != want {
t.Fatalf("%s = %q, want %q", path, got, want)
}
}
func assertFileContains(t *testing.T, path, want string) {
t.Helper()
content, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile(%s) error = %v", path, err)
}
if !strings.Contains(string(content), want) {
t.Fatalf("%s missing %q:\n%s", path, want, string(content))
}
}
func replaceTestEnv(env []string, replacements ...string) []string {
result := append([]string{}, env...)
for index := 0; index < len(replacements); index += 2 {
key := replacements[index]
value := replacements[index+1]
prefix := key + "="
replaced := false
for envIndex, entry := range result {
if strings.HasPrefix(entry, prefix) {
result[envIndex] = prefix + value
replaced = true
}
}
if !replaced {
result = append(result, prefix+value)
}
}
return result
}
func writeWithdrawMocks(t *testing.T, fakeBin string) {
t.Helper()
mustWriteFile(t, filepath.Join(fakeBin, "gh"), []byte(`#!/usr/bin/env python3
import json
import os
import pathlib
import subprocess
import sys
state = pathlib.Path(os.environ["MOCK_STATE"])
log = pathlib.Path(os.environ["CALL_LOG"])
args = sys.argv[1:]
def record(value):
with log.open("a", encoding="utf-8") as handle:
handle.write(value + "\n")
def value_after(flag, default=""):
try:
return args[args.index(flag) + 1]
except (ValueError, IndexError):
return default
def not_found():
print("gh: Not Found (HTTP 404)", file=sys.stderr)
raise SystemExit(1)
if args[:2] == ["release", "download"]:
output = pathlib.Path(value_after("--dir"))
output.mkdir(parents=True, exist_ok=True)
assets = [
"dws-darwin-amd64.tar.gz",
"dws-darwin-arm64.tar.gz",
"dws-linux-amd64.tar.gz",
"dws-linux-arm64.tar.gz",
"dws-skills.zip",
]
(output / "checksums.txt").write_text(
"".join(("a" * 64) + " " + asset + "\n" for asset in assets),
encoding="utf-8",
)
record("github-download-checksums")
raise SystemExit(0)
if not args or args[0] != "api":
raise SystemExit("unsupported gh invocation: " + " ".join(args))
method = value_after("--method", "GET")
skip = {"-H", "--method", "-f", "-F", "--input", "--jq"}
endpoint = ""
i = 1
while i < len(args):
if args[i] in skip:
i += 2
continue
if args[i].startswith("repos/"):
endpoint = args[i]
break
i += 1
jq = value_after("--jq")
if endpoint.endswith("/git/ref/heads/main"):
payload = {"object": {"sha": os.environ["GITHUB_SHA"]}}
elif "/releases/tags/" in endpoint:
version = endpoint.rsplit("/", 1)[1]
path = state / f"release-{version}.json"
if not path.exists():
not_found()
payload = json.loads(path.read_text(encoding="utf-8"))
elif endpoint.endswith("/releases/latest"):
payload = {"tag_name": (state / "latest").read_text(encoding="utf-8").strip()}
elif "/git/ref/tags/withdrawn/" in endpoint:
ref_path = state / "tombstone-ref"
if not ref_path.exists():
not_found()
payload = {"object": {"sha": ref_path.read_text(encoding="utf-8").strip()}}
elif endpoint.endswith("/git/ref/tags/v1.0.52") and method == "GET":
tag_path = state / "github-tag-v1.0.52"
if not tag_path.exists():
not_found()
payload = {"object": {"sha": tag_path.read_text(encoding="utf-8").strip()}}
elif "/git/tags/" in endpoint and method == "GET":
payload = {
"tag": "withdrawn/v1.0.52",
"message": (state / "tombstone-message").read_text(encoding="utf-8"),
"object": {
"type": "commit",
"sha": (state / "tombstone-target").read_text(encoding="utf-8").strip(),
},
}
elif endpoint.endswith("/git/tags") and method == "POST":
fields = {}
for index, arg in enumerate(args):
if arg == "-f":
key, value = args[index + 1].split("=", 1)
fields[key] = value
(state / "tombstone-message").write_text(fields["message"], encoding="utf-8")
(state / "tombstone-target").write_text(fields["object"] + "\n", encoding="utf-8")
payload = {"sha": "a" * 40}
record("tombstone-object")
elif endpoint.endswith("/git/refs") and method == "POST":
(state / "tombstone-ref").write_text("a" * 40 + "\n", encoding="utf-8")
payload = {"ref": "refs/tags/withdrawn/v1.0.52", "object": {"sha": "a" * 40}}
record("tombstone-ref")
elif endpoint.endswith("/releases/52") and method == "DELETE":
release_path = state / "release-v1.0.52.json"
if not release_path.exists():
not_found()
release_path.unlink()
(state / "latest").write_text("v1.0.51\n", encoding="utf-8")
payload = {}
record("github-delete-release")
elif endpoint.endswith("/git/refs/tags/v1.0.52") and method == "DELETE":
tag_path = state / "github-tag-v1.0.52"
if not tag_path.exists():
not_found()
tag_path.unlink()
subprocess.run(
["git", f"--git-dir={os.environ['ORIGIN_GIT']}", "update-ref", "-d", "refs/tags/v1.0.52"],
check=True,
)
payload = {}
record("github-delete-tag")
elif "/releases/" in endpoint and method == "PATCH":
release_id = endpoint.rsplit("/", 1)[1]
if release_id == "52":
path = state / "release-v1.0.52.json"
data = json.loads(path.read_text(encoding="utf-8"))
input_path = value_after("--input")
patch = json.loads(pathlib.Path(input_path).read_text(encoding="utf-8"))
data.update({"name": patch["name"], "body": patch["body"]})
path.write_text(json.dumps(data), encoding="utf-8")
record("github-withdraw")
payload = {}
else:
raise SystemExit("unsupported gh api endpoint: " + endpoint + " " + method)
if jq == ".object.sha":
print(payload["object"]["sha"])
elif jq == ".tag_name":
print(payload["tag_name"])
elif jq == ".sha":
print(payload["sha"])
else:
print(json.dumps(payload))
`), 0o755)
mustWriteFile(t, filepath.Join(fakeBin, "npm"), []byte(`#!/usr/bin/env python3
import os
import pathlib
import sys
state = pathlib.Path(os.environ["MOCK_STATE"])
log = pathlib.Path(os.environ["CALL_LOG"])
args = sys.argv[1:]
def record(value):
with log.open("a", encoding="utf-8") as handle:
handle.write(value + "\n")
if args[0] == "view":
spec = args[1]
field = args[2]
if field == "version":
print(spec.rsplit("@", 1)[1])
elif field == "deprecated":
path = state / "npm-deprecated"
if path.exists() and spec.endswith("@1.0.52"):
print(path.read_text(encoding="utf-8").strip())
elif field == "dist-tags.latest":
print((state / "npm-latest").read_text(encoding="utf-8").strip())
else:
raise SystemExit("unsupported npm view field: " + field)
elif args[0] == "deprecate":
(state / "npm-deprecated").write_text(args[2] + "\n", encoding="utf-8")
record("npm-deprecate")
elif args[:2] == ["dist-tag", "add"]:
version = args[2].rsplit("@", 1)[1]
(state / "npm-latest").write_text(version + "\n", encoding="utf-8")
record("npm-pointer")
else:
raise SystemExit("unsupported npm invocation: " + " ".join(args))
`), 0o755)
mustWriteFile(t, filepath.Join(fakeBin, "ossutil"), []byte(`#!/usr/bin/env python3
import os
import pathlib
import sys
state = pathlib.Path(os.environ["MOCK_STATE"])
log = pathlib.Path(os.environ["CALL_LOG"])
args = sys.argv[1:]
if args[0] == "cp":
source, target = args[-2:]
if source.startswith("oss://"):
if source.endswith("/latest.txt"):
stored = state / "oss-latest"
else:
stored = state / ("oss-object-" + source.rsplit("/", 1)[1])
pathlib.Path(target).write_bytes(stored.read_bytes())
else:
if target.endswith("/latest.txt"):
stored = state / "oss-latest"
else:
stored = state / ("oss-object-" + target.rsplit("/", 1)[1])
stored.write_bytes(pathlib.Path(source).read_bytes())
elif args[0] == "rm":
(state / "oss-removed").write_text("yes\n", encoding="utf-8")
with log.open("a", encoding="utf-8") as handle:
handle.write("oss-remove\n")
elif args[0] == "ls":
raise SystemExit(0)
else:
raise SystemExit("unsupported ossutil invocation: " + " ".join(args))
`), 0o755)
mustWriteFile(t, filepath.Join(fakeBin, "curl"), []byte(`#!/usr/bin/env python3
import json
import os
import pathlib
import sys
state = pathlib.Path(os.environ["MOCK_STATE"])
log = pathlib.Path(os.environ["CALL_LOG"])
args = sys.argv[1:]
url = args[-1]
version = "v1.0.51" if "v1.0.51" in url else "v1.0.52"
release = state / ("gitee-release-" + version)
if "-X" in args and args[args.index("-X") + 1] == "DELETE":
(state / "gitee-release-v1.0.52").unlink(missing_ok=True)
with log.open("a", encoding="utf-8") as handle:
handle.write("gitee-delete-release\n")
elif "/releases/tags/" in url:
output = pathlib.Path(args[args.index("-o") + 1])
if release.exists():
release_id = 751 if version == "v1.0.51" else 752
output.write_text(json.dumps({"id": release_id}), encoding="utf-8")
print("200", end="")
else:
output.write_text('{"message":"Not Found"}', encoding="utf-8")
print("404", end="")
else:
raise SystemExit(22)
`), 0o755)
}