Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d93925a892 | ||
|
|
faab9e0282 | ||
|
|
76d301268d | ||
|
|
7fddace8df | ||
|
|
076d77da8e |
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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 可能来自组织层,脚本不自动推断其最终作用范围;管理员确认不能省略,脚本约定也不能替代平台强制。
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Executable
+121
@@ -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
|
||||
@@ -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
@@ -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,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/...
|
||||
|
||||
Executable
+203
@@ -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"
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Executable
+1186
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user