Compare commits

...
Author SHA1 Message Date
修雨 6b50cc41c5 ci(release): add dispatch repair-gitee job to mirror an existing release
The push-triggered mirror-gitee-release job consumes the same run's
finalized-release-dist artifact, so it cannot mirror a tag that was
already published — including one delivered by a recovery dispatch such
as v1.0.53-beta.3. Add a workflow_dispatch repair-gitee job (input
mirror_gitee_version) that re-derives the asset set from the immutable
GitHub Release, verifies it byte-for-byte via checksums, and runs
sync-to-gitee.sh. Guarded to the official repo + default branch and
gated by the existing Gitee secrets.
2026-07-19 21:51:35 +08:00
修雨 11e50662f9 Merge pull request #683 from DingTalk-Real-AI/codex/fix-event-bus-shutdown-race
fix(event): serialize bus shutdown with accept loop
2026-07-19 21:39:53 +08:00
修雨 29abdb6e79 fix(event): serialize bus shutdown with accept loop
Wait for the accept loop to stop before waiting for connection handlers, and track accepted connections before publishing handlers. This removes the WaitGroup Add/Wait race caught by PR #667 CI and follows up the event bus introduced in #589.
2026-07-19 21:21:16 +08:00
修雨 6196e2565e Merge pull request #678 from DingTalk-Real-AI/fix/release-draft-asset-verify
fix(release): bind draft publication to release ID
2026-07-19 19:52:09 +08:00
修雨 609d56305e fix(release): bind draft publication to release ID 2026-07-19 12:22:44 +08:00
修雨 e69a1084a7 Merge pull request #675 from DingTalk-Real-AI/codex/fix-release-skip-propagation
fix(release): prevent skipped publication false greens
2026-07-18 12:11:37 +08:00
修雨 978ee6e636 fix(release): fail closed on skipped publication 2026-07-18 11:34:34 +08:00
11 changed files with 1287 additions and 73 deletions
+520 -47
View File
@@ -1,5 +1,5 @@
name: Release
run-name: ${{ github.event_name == 'workflow_dispatch' && inputs.governance_preflight_nonce != '' && format('Release governance preflight {0}', inputs.governance_preflight_nonce) || github.event_name == 'workflow_dispatch' && inputs.recover_release_version != '' && format('Release recovery {0} at {1} {2}', inputs.recover_release_version, inputs.recover_release_commit, inputs.recover_release_nonce) || github.event_name == 'workflow_dispatch' && inputs.repair_npm_version != '' && format('Release npm repair {0}', inputs.repair_npm_version) || github.workflow }}
run-name: ${{ github.event_name == 'workflow_dispatch' && inputs.governance_preflight_nonce != '' && format('Release governance preflight {0}', inputs.governance_preflight_nonce) || github.event_name == 'workflow_dispatch' && inputs.recover_release_version != '' && format('Release recovery {0} at {1} {2}', inputs.recover_release_version, inputs.recover_release_commit, inputs.recover_release_nonce) || github.event_name == 'workflow_dispatch' && inputs.repair_npm_version != '' && format('Release npm repair {0}', inputs.repair_npm_version) || github.event_name == 'workflow_dispatch' && inputs.repair_gitee_version != '' && format('Release Gitee repair {0}', inputs.repair_gitee_version) || github.workflow }}
on:
push:
@@ -11,6 +11,10 @@ on:
description: "Recover npm for a public immutable release without moving latest/beta"
required: false
type: string
repair_gitee_version:
description: "Mirror an existing public immutable release's assets to Gitee"
required: false
type: string
governance_preflight_commit:
description: "Verify release governance for this exact default-branch commit without publishing"
required: false
@@ -35,6 +39,10 @@ on:
description: "Failed exact-tag Release workflow run ID"
required: false
type: string
recover_failed_run_attempt:
description: "Failed attempt number within the exact-tag Release workflow run"
required: false
type: string
recover_release_nonce:
description: "Unique identifier for this protected recovery request"
required: false
@@ -47,9 +55,9 @@ on:
permissions:
contents: read
# Queue governance checks, tag releases, protected recoveries, and npm repairs
# behind one publication lock. The local entrypoint also refuses to tag while
# a Release run is active.
# Queue governance checks, tag releases, protected recoveries, and channel
# repairs behind one publication lock. The local entrypoint also refuses to tag
# while a Release run is active.
concurrency:
group: dws-release-publication
queue: max
@@ -66,31 +74,38 @@ jobs:
id: mode
env:
REPAIR_NPM_VERSION: ${{ inputs.repair_npm_version }}
REPAIR_GITEE_VERSION: ${{ inputs.repair_gitee_version }}
GOVERNANCE_COMMIT: ${{ inputs.governance_preflight_commit }}
GOVERNANCE_NONCE: ${{ inputs.governance_preflight_nonce }}
RECOVER_VERSION: ${{ inputs.recover_release_version }}
RECOVER_TAG_OBJECT: ${{ inputs.recover_release_tag_object }}
RECOVER_COMMIT: ${{ inputs.recover_release_commit }}
RECOVER_FAILED_RUN_ID: ${{ inputs.recover_failed_run_id }}
RECOVER_FAILED_RUN_ATTEMPT: ${{ inputs.recover_failed_run_attempt }}
RECOVER_NONCE: ${{ inputs.recover_release_nonce }}
RECOVER_CONFIRMATION: ${{ inputs.recover_release_confirmation }}
run: |
set -eu
repair=0
npm_repair=0
gitee_repair=0
governance=0
recovery=0
test -z "$REPAIR_NPM_VERSION" || repair=1
test -z "$REPAIR_NPM_VERSION" || npm_repair=1
test -z "$REPAIR_GITEE_VERSION" || gitee_repair=1
if test -n "$GOVERNANCE_COMMIT" || test -n "$GOVERNANCE_NONCE"; then governance=1; fi
if test -n "$RECOVER_VERSION" || test -n "$RECOVER_TAG_OBJECT" || \
test -n "$RECOVER_COMMIT" || test -n "$RECOVER_FAILED_RUN_ID" || \
test -n "$RECOVER_FAILED_RUN_ATTEMPT" || \
test -n "$RECOVER_NONCE" || \
test -n "$RECOVER_CONFIRMATION"; then recovery=1; fi
test $((repair + governance + recovery)) -eq 1 || {
test $((npm_repair + gitee_repair + governance + recovery)) -eq 1 || {
echo "workflow_dispatch must select exactly one release mode" >&2
exit 1
}
if test "$repair" -eq 1; then
if test "$npm_repair" -eq 1; then
echo "mode=repair_npm" >> "$GITHUB_OUTPUT"
elif test "$gitee_repair" -eq 1; then
echo "mode=repair_gitee" >> "$GITHUB_OUTPUT"
elif test "$governance" -eq 1; then
if test -z "$GOVERNANCE_COMMIT" || test -z "$GOVERNANCE_NONCE"; then
echo "governance preflight requires both commit and nonce" >&2
@@ -100,6 +115,7 @@ jobs:
else
if test -z "$RECOVER_VERSION" || test -z "$RECOVER_TAG_OBJECT" || \
test -z "$RECOVER_COMMIT" || test -z "$RECOVER_FAILED_RUN_ID" || \
test -z "$RECOVER_FAILED_RUN_ATTEMPT" || \
test -z "$RECOVER_NONCE" || test -z "$RECOVER_CONFIRMATION"; then
echo "release recovery requires every recovery input" >&2
exit 1
@@ -120,6 +136,10 @@ jobs:
echo "recover_failed_run_id must be a positive run ID" >&2
exit 1
}
printf '%s\n' "$RECOVER_FAILED_RUN_ATTEMPT" | grep -Eq '^[1-9][0-9]*$' || {
echo "recover_failed_run_attempt must be a positive attempt number" >&2
exit 1
}
printf '%s\n' "$RECOVER_NONCE" | grep -Eq "^${RECOVER_COMMIT}-[0-9]+-[0-9]+$" || {
echo "recover_release_nonce must be bound to the release commit" >&2
exit 1
@@ -290,7 +310,7 @@ jobs:
release-contract:
needs: [dispatch-contract, authorize-recovery]
if: ${{ always() && (github.event_name == 'push' || (needs.dispatch-contract.result == 'success' && needs.dispatch-contract.outputs.mode == 'recover_release' && needs.authorize-recovery.result == 'success')) }}
if: ${{ !cancelled() && (github.event_name == 'push' || (needs.dispatch-contract.result == 'success' && needs.dispatch-contract.outputs.mode == 'recover_release' && needs.authorize-recovery.result == 'success')) }}
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
@@ -317,6 +337,7 @@ jobs:
RECOVER_TAG_OBJECT: ${{ inputs.recover_release_tag_object }}
RECOVER_COMMIT: ${{ inputs.recover_release_commit }}
RECOVER_FAILED_RUN_ID: ${{ inputs.recover_failed_run_id }}
RECOVER_FAILED_RUN_ATTEMPT: ${{ inputs.recover_failed_run_attempt }}
with:
script: |
const { owner, repo } = context.repo;
@@ -325,6 +346,7 @@ jobs:
let commit = context.sha;
let expectedTagObject = "";
let failedRunId = "";
let failedRunAttempt = "";
if (isRecovery) {
if (
context.payload.repository.full_name !== "DingTalk-Real-AI/dingtalk-workspace-cli" ||
@@ -337,6 +359,7 @@ jobs:
commit = process.env.RECOVER_COMMIT;
expectedTagObject = process.env.RECOVER_TAG_OBJECT;
failedRunId = process.env.RECOVER_FAILED_RUN_ID;
failedRunAttempt = process.env.RECOVER_FAILED_RUN_ATTEMPT;
if (!/^v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-beta\.[1-9]\d*)?$/.test(version)) {
core.setFailed(`invalid recovery release version: ${version}`);
return;
@@ -349,6 +372,10 @@ jobs:
core.setFailed("recover_failed_run_id must be a positive workflow run ID");
return;
}
if (!/^[1-9]\d*$/.test(failedRunAttempt)) {
core.setFailed("recover_failed_run_attempt must be a positive run attempt");
return;
}
}
const ref = await github.rest.git.getRef({ owner, repo, ref: `tags/${version}` });
@@ -383,13 +410,19 @@ jobs:
core.setFailed(`${version} commit is not contained in ${defaultBranch}`);
return;
}
const failedRun = await github.rest.actions.getWorkflowRun({
owner,
repo,
run_id: Number(failedRunId),
});
const failedRun = await github.request(
"GET /repos/{owner}/{repo}/actions/runs/{run_id}/attempts/{attempt_number}",
{
owner,
repo,
run_id: Number(failedRunId),
attempt_number: Number(failedRunAttempt),
},
);
const run = failedRun.data;
if (
run.id !== Number(failedRunId) ||
run.run_attempt !== Number(failedRunAttempt) ||
run.repository.full_name !== `${owner}/${repo}` ||
run.path !== ".github/workflows/release.yml" ||
run.event !== "push" ||
@@ -398,7 +431,9 @@ jobs:
run.head_branch !== version ||
run.head_sha !== commit
) {
core.setFailed(`run ${failedRunId} is not the failed exact-tag Release run for ${version}`);
core.setFailed(
`run ${failedRunId} attempt ${failedRunAttempt} is not the failed exact-tag Release attempt for ${version}`,
);
return;
}
try {
@@ -622,7 +657,7 @@ jobs:
release:
name: Build signed release artifacts
if: ${{ needs.release-contract.result == 'success' }}
if: ${{ !cancelled() && needs.release-contract.result == 'success' }}
needs: [release-contract]
runs-on: ubuntu-latest
timeout-minutes: 60
@@ -788,7 +823,7 @@ jobs:
verify-darwin-signatures:
name: Verify Apple Developer ID signatures
if: ${{ needs.release.result == 'success' }}
if: ${{ !cancelled() && needs.release-contract.result == 'success' && needs.release.result == 'success' }}
needs: [release-contract, release]
runs-on: macos-latest
timeout-minutes: 10
@@ -816,7 +851,7 @@ jobs:
publish-release:
name: Publish immutable GitHub Release
if: ${{ needs.release-contract.result == 'success' && needs.release.result == 'success' && needs.verify-darwin-signatures.result == 'success' }}
if: ${{ !cancelled() && needs.release-contract.result == 'success' && needs.release.result == 'success' && needs.verify-darwin-signatures.result == 'success' }}
needs: [release-contract, release, verify-darwin-signatures]
runs-on: ubuntu-latest
timeout-minutes: 30
@@ -903,6 +938,7 @@ jobs:
"$RELEASE_VERSION" "$RELEASE_COMMIT" "$RELEASE_TAG_OBJECT"
- name: Publish or reuse immutable GitHub Release
id: publish
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_CHANNEL: ${{ needs.release-contract.outputs.channel }}
@@ -915,9 +951,36 @@ jobs:
prerelease_flag="--prerelease"
fi
release_id=""
if gh release view "$RELEASE_VERSION" >/dev/null 2>&1; then
is_draft="$(gh release view "$RELEASE_VERSION" --json isDraft --jq .isDraft)"
is_prerelease="$(gh release view "$RELEASE_VERSION" --json isPrerelease --jq .isPrerelease)"
release_id="$(
gh release view "$RELEASE_VERSION" \
--json databaseId \
--jq .databaseId
)"
printf '%s\n' "$release_id" | grep -Eq '^[1-9][0-9]*$' || {
echo "Existing GitHub Release has an invalid database ID: $release_id" >&2
exit 1
}
resolved_tag="$(
gh api -H 'X-GitHub-Api-Version: 2026-03-10' \
"repos/$GITHUB_REPOSITORY/releases/$release_id" \
--jq .tag_name
)"
test "$resolved_tag" = "$RELEASE_VERSION" || {
echo "GitHub Release ID $release_id targets $resolved_tag, not $RELEASE_VERSION." >&2
exit 1
}
is_draft="$(
gh api -H 'X-GitHub-Api-Version: 2026-03-10' \
"repos/$GITHUB_REPOSITORY/releases/$release_id" \
--jq .draft
)"
is_prerelease="$(
gh api -H 'X-GitHub-Api-Version: 2026-03-10' \
"repos/$GITHUB_REPOSITORY/releases/$release_id" \
--jq .prerelease
)"
if [ "$is_prerelease" != "$expected_prerelease" ]; then
echo "Existing release channel does not match $RELEASE_CHANNEL." >&2
exit 1
@@ -925,14 +988,18 @@ jobs:
if [ "$is_draft" = "false" ]; then
is_immutable="$(
gh api -H 'X-GitHub-Api-Version: 2026-03-10' \
"repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_VERSION" \
"repos/$GITHUB_REPOSITORY/releases/$release_id" \
--jq .immutable
)"
if [ "$is_immutable" != "true" ]; then
echo "Published release is mutable; refusing to reuse its assets." >&2
exit 1
fi
remote_body="$(gh release view "$RELEASE_VERSION" --json body --jq .body)"
remote_body="$(
gh api -H 'X-GitHub-Api-Version: 2026-03-10' \
"repos/$GITHUB_REPOSITORY/releases/$release_id" \
--jq .body
)"
local_body="$(cat "$RUNNER_TEMP/release-notes.md")"
if [ "$remote_body" != "$local_body" ]; then
echo "Published release notes differ from the sealed CHANGELOG." >&2
@@ -950,11 +1017,9 @@ jobs:
fi
published_dir="$RUNNER_TEMP/published-release"
mkdir -p "$published_dir"
gh release download "$RELEASE_VERSION" \
--dir "$published_dir" \
--pattern 'dws-*' \
--pattern 'checksums.txt' \
--clobber
DWS_GITHUB_RELEASE_ID="$release_id" \
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/download-github-release-assets.sh" \
"$RELEASE_VERSION" "$published_dir"
DWS_PACKAGE_DIST_DIR="$published_dir" \
./scripts/release/verify-release-artifacts.sh "$RELEASE_VERSION"
for asset in "$published_dir"/dws-* "$published_dir"/checksums.txt; do
@@ -967,12 +1032,10 @@ jobs:
DWS_PACKAGE_DIST_DIR="$GITHUB_WORKSPACE/dist" \
DWS_PACKAGE_SOURCE_ROOT="$GITHUB_WORKSPACE" \
./scripts/release/stage-npm-package.sh "$RELEASE_VERSION"
echo "release_id=$release_id" >> "$GITHUB_OUTPUT"
echo "Published GitHub Release is immutable and verified; reusing its exact assets."
exit 0
fi
gh release edit "$RELEASE_VERSION" \
--title "$RELEASE_VERSION" \
--notes-file "$RUNNER_TEMP/release-notes.md"
else
# shellcheck disable=SC2086
gh release create "$RELEASE_VERSION" \
@@ -981,6 +1044,63 @@ jobs:
--title "$RELEASE_VERSION" \
--notes-file "$RUNNER_TEMP/release-notes.md" \
$prerelease_flag
release_id="$(
gh release view "$RELEASE_VERSION" \
--json databaseId \
--jq .databaseId
)"
fi
printf '%s\n' "$release_id" | grep -Eq '^[1-9][0-9]*$' || {
echo "Draft GitHub Release has an invalid database ID: $release_id" >&2
exit 1
}
resolved_tag="$(
gh api -H 'X-GitHub-Api-Version: 2026-03-10' \
"repos/$GITHUB_REPOSITORY/releases/$release_id" \
--jq .tag_name
)"
test "$resolved_tag" = "$RELEASE_VERSION" || {
echo "Draft GitHub Release ID $release_id targets $resolved_tag, not $RELEASE_VERSION." >&2
exit 1
}
gh api --method PATCH \
-H 'X-GitHub-Api-Version: 2026-03-10' \
"repos/$GITHUB_REPOSITORY/releases/$release_id" \
-f tag_name="$RELEASE_VERSION" \
-f target_commitish="$RELEASE_COMMIT" \
-f name="$RELEASE_VERSION" \
-f body="$(cat "$RUNNER_TEMP/release-notes.md")" \
-F draft=true \
-F prerelease="$expected_prerelease" \
>/dev/null
draft_state="$(
gh api -H 'X-GitHub-Api-Version: 2026-03-10' \
"repos/$GITHUB_REPOSITORY/releases/$release_id" \
--jq '[.tag_name, .draft, .prerelease] | @tsv'
)"
test "$draft_state" = "$(printf '%s\ttrue\t%s' "$RELEASE_VERSION" "$expected_prerelease")" || {
echo "Draft GitHub Release identity or channel changed before upload." >&2
exit 1
}
draft_body="$(
gh api -H 'X-GitHub-Api-Version: 2026-03-10' \
"repos/$GITHUB_REPOSITORY/releases/$release_id" \
--jq .body
)"
local_body="$(cat "$RUNNER_TEMP/release-notes.md")"
test "$draft_body" = "$local_body" || {
echo "Draft GitHub Release notes differ from the sealed CHANGELOG." >&2
exit 1
}
if [ "$IS_RECOVERY" = "true" ]; then
recovery_marker="<!-- dws-release-recovery run=$GITHUB_RUN_ID tag-object=$RELEASE_TAG_OBJECT commit=$RELEASE_COMMIT -->"
case "$draft_body" in
*"$recovery_marker"*) ;;
*)
echo "Draft GitHub Release is not bound to this exact recovery run." >&2
exit 1
;;
esac
fi
gh release upload "$RELEASE_VERSION" \
dist/dws-*.tar.gz \
@@ -988,15 +1108,24 @@ jobs:
dist/dws-skills.zip \
dist/checksums.txt \
--clobber
./scripts/release/verify-github-release-assets.sh "$RELEASE_VERSION"
uploaded_release_id="$(
gh release view "$RELEASE_VERSION" \
--json databaseId \
--jq .databaseId
)"
test "$uploaded_release_id" = "$release_id" || {
echo "GitHub Release identity changed during upload: $release_id -> $uploaded_release_id" >&2
exit 1
}
DWS_GITHUB_RELEASE_ID="$release_id" \
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-github-release-assets.sh" \
"$RELEASE_VERSION"
sealed_upload="$RUNNER_TEMP/sealed-upload"
rm -rf "$sealed_upload"
mkdir -p "$sealed_upload"
gh release download "$RELEASE_VERSION" \
--dir "$sealed_upload" \
--pattern 'dws-*' \
--pattern 'checksums.txt' \
--clobber
DWS_GITHUB_RELEASE_ID="$release_id" \
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/download-github-release-assets.sh" \
"$RELEASE_VERSION" "$sealed_upload"
DWS_PACKAGE_DIST_DIR="$sealed_upload" \
./scripts/release/verify-release-artifacts.sh "$RELEASE_VERSION"
for remote_asset in "$sealed_upload"/dws-* "$sealed_upload"/checksums.txt; do
@@ -1006,23 +1135,76 @@ jobs:
exit 1
}
done
gh release edit "$RELEASE_VERSION" --draft=false
gh api --method PATCH \
-H 'X-GitHub-Api-Version: 2026-03-10' \
"repos/$GITHUB_REPOSITORY/releases/$release_id" \
-f name="$RELEASE_VERSION" \
-f body="$(cat "$RUNNER_TEMP/release-notes.md")" \
-F prerelease="$expected_prerelease" \
-F draft=false \
>/dev/null
echo "release_id=$release_id" >> "$GITHUB_OUTPUT"
- name: Require immutable published GitHub Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_ID: ${{ steps.publish.outputs.release_id }}
RELEASE_CHANNEL: ${{ needs.release-contract.outputs.channel }}
run: |
set -eu
immutable="$(
gh api -H 'X-GitHub-Api-Version: 2026-03-10' \
"repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_VERSION" \
--jq .immutable
)"
test "$immutable" = true || {
echo "GitHub Release is not immutable; refusing downstream publication." >&2
expected_prerelease=false
if [ "$RELEASE_CHANNEL" = "prerelease" ]; then expected_prerelease=true; fi
printf '%s\n' "$RELEASE_ID" | grep -Eq '^[1-9][0-9]*$' || {
echo "Published GitHub Release has an invalid database ID: $RELEASE_ID" >&2
exit 1
}
./scripts/release/verify-github-release-assets.sh "$RELEASE_VERSION"
release_state="$(
gh api -H 'X-GitHub-Api-Version: 2026-03-10' \
"repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID" \
--jq '[.tag_name, .draft, .prerelease, .immutable] | @tsv'
)"
test "$release_state" = "$(printf '%s\tfalse\t%s\ttrue' "$RELEASE_VERSION" "$expected_prerelease")" || {
echo "GitHub Release ID $RELEASE_ID is not the exact public immutable $RELEASE_VERSION release." >&2
exit 1
}
immutable_body="$(
gh api -H 'X-GitHub-Api-Version: 2026-03-10' \
"repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID" \
--jq .body
)"
local_body="$(cat "$RUNNER_TEMP/release-notes.md")"
test "$immutable_body" = "$local_body" || {
echo "Immutable GitHub Release notes differ from the sealed CHANGELOG." >&2
exit 1
}
if [ "$IS_RECOVERY" = "true" ]; then
recovery_marker="<!-- dws-release-recovery run=$GITHUB_RUN_ID tag-object=$RELEASE_TAG_OBJECT commit=$RELEASE_COMMIT -->"
case "$immutable_body" in
*"$recovery_marker"*) ;;
*)
echo "Immutable GitHub Release is not bound to this exact recovery run." >&2
exit 1
;;
esac
fi
DWS_GITHUB_RELEASE_ID="$RELEASE_ID" \
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-github-release-assets.sh" \
"$RELEASE_VERSION"
immutable_dir="$RUNNER_TEMP/immutable-release"
rm -rf "$immutable_dir"
mkdir -p "$immutable_dir"
DWS_GITHUB_RELEASE_ID="$RELEASE_ID" \
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/download-github-release-assets.sh" \
"$RELEASE_VERSION" "$immutable_dir"
DWS_PACKAGE_DIST_DIR="$immutable_dir" \
./scripts/release/verify-release-artifacts.sh "$RELEASE_VERSION"
for immutable_asset in "$immutable_dir"/dws-* "$immutable_dir"/checksums.txt; do
sealed_asset="dist/$(basename "$immutable_asset")"
cmp -s "$sealed_asset" "$immutable_asset" || {
echo "Immutable GitHub asset differs from this run's sealed bytes: $(basename "$immutable_asset")" >&2
exit 1
}
done
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-github-tag-authority.sh" \
"$RELEASE_VERSION" "$RELEASE_COMMIT" "$RELEASE_TAG_OBJECT"
@@ -1055,7 +1237,7 @@ jobs:
publish-channels:
name: Publish npm and mirrors
if: ${{ needs.release-contract.result == 'success' && needs.publish-release.result == 'success' }}
if: ${{ !cancelled() && needs.release-contract.result == 'success' && needs.publish-release.result == 'success' }}
needs: [release-contract, publish-release]
runs-on: ubuntu-latest
timeout-minutes: 30
@@ -1286,7 +1468,7 @@ jobs:
mirror-gitee-release:
name: Mirror immutable release to Gitee
if: ${{ vars.ENABLE_GITEE_UPLOAD_FALLBACK == 'true' && needs.release-contract.result == 'success' && needs.release.result == 'success' && needs.publish-channels.result == 'success' }}
if: ${{ !cancelled() && vars.ENABLE_GITEE_UPLOAD_FALLBACK == 'true' && needs.release-contract.result == 'success' && needs.release.result == 'success' && needs.publish-channels.result == 'success' }}
needs: [release-contract, release, publish-channels]
runs-on: ubuntu-latest
timeout-minutes: 120
@@ -1621,3 +1803,294 @@ jobs:
run: npm publish "$RUNNER_TEMP/dingtalk-workspace-cli-repair.tgz" --access public --tag backfill --ignore-scripts --registry=https://registry.npmjs.org
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
release-delivery-gate:
name: Release delivery gate
if: ${{ !cancelled() }}
needs:
- dispatch-contract
- authorize-recovery
- governance-preflight
- release-contract
- release
- verify-darwin-signatures
- publish-release
- publish-channels
- mirror-gitee-release
- repair-npm
- repair-gitee
runs-on: ubuntu-latest
timeout-minutes: 2
permissions: {}
steps:
- name: Require the selected release mode to finish
env:
EVENT_NAME: ${{ github.event_name }}
DISPATCH_MODE: ${{ needs.dispatch-contract.outputs.mode }}
GITEE_FALLBACK_ENABLED: ${{ vars.ENABLE_GITEE_UPLOAD_FALLBACK }}
DISPATCH_RESULT: ${{ needs.dispatch-contract.result }}
AUTHORIZE_RECOVERY_RESULT: ${{ needs.authorize-recovery.result }}
GOVERNANCE_PREFLIGHT_RESULT: ${{ needs.governance-preflight.result }}
RELEASE_CONTRACT_RESULT: ${{ needs.release-contract.result }}
RELEASE_RESULT: ${{ needs.release.result }}
DARWIN_SIGNATURE_RESULT: ${{ needs.verify-darwin-signatures.result }}
PUBLISH_RELEASE_RESULT: ${{ needs.publish-release.result }}
PUBLISH_CHANNELS_RESULT: ${{ needs.publish-channels.result }}
MIRROR_GITEE_RESULT: ${{ needs.mirror-gitee-release.result }}
REPAIR_NPM_RESULT: ${{ needs.repair-npm.result }}
REPAIR_GITEE_RESULT: ${{ needs.repair-gitee.result }}
run: |
set -eu
require_result() {
job="$1"
actual="$2"
expected="$3"
test "$actual" = "$expected" || {
echo "$job result is $actual, expected $expected" >&2
exit 1
}
}
require_publication() {
require_result release-contract "$RELEASE_CONTRACT_RESULT" success
require_result release "$RELEASE_RESULT" success
require_result verify-darwin-signatures "$DARWIN_SIGNATURE_RESULT" success
require_result publish-release "$PUBLISH_RELEASE_RESULT" success
require_result publish-channels "$PUBLISH_CHANNELS_RESULT" success
if test "$GITEE_FALLBACK_ENABLED" = true; then
require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" success
else
require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped
fi
}
case "$EVENT_NAME:$DISPATCH_MODE" in
push:)
require_result dispatch-contract "$DISPATCH_RESULT" skipped
require_result authorize-recovery "$AUTHORIZE_RECOVERY_RESULT" skipped
require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" skipped
require_result repair-npm "$REPAIR_NPM_RESULT" skipped
require_result repair-gitee "$REPAIR_GITEE_RESULT" skipped
require_publication
;;
workflow_dispatch:recover_release)
require_result dispatch-contract "$DISPATCH_RESULT" success
require_result authorize-recovery "$AUTHORIZE_RECOVERY_RESULT" success
require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" skipped
require_result repair-npm "$REPAIR_NPM_RESULT" skipped
require_result repair-gitee "$REPAIR_GITEE_RESULT" skipped
require_publication
;;
workflow_dispatch:governance_preflight)
require_result dispatch-contract "$DISPATCH_RESULT" success
require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" success
require_result authorize-recovery "$AUTHORIZE_RECOVERY_RESULT" skipped
require_result release-contract "$RELEASE_CONTRACT_RESULT" skipped
require_result release "$RELEASE_RESULT" skipped
require_result verify-darwin-signatures "$DARWIN_SIGNATURE_RESULT" skipped
require_result publish-release "$PUBLISH_RELEASE_RESULT" skipped
require_result publish-channels "$PUBLISH_CHANNELS_RESULT" skipped
require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped
require_result repair-npm "$REPAIR_NPM_RESULT" skipped
require_result repair-gitee "$REPAIR_GITEE_RESULT" skipped
;;
workflow_dispatch:repair_npm)
require_result dispatch-contract "$DISPATCH_RESULT" success
require_result repair-npm "$REPAIR_NPM_RESULT" success
require_result authorize-recovery "$AUTHORIZE_RECOVERY_RESULT" skipped
require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" skipped
require_result release-contract "$RELEASE_CONTRACT_RESULT" skipped
require_result release "$RELEASE_RESULT" skipped
require_result verify-darwin-signatures "$DARWIN_SIGNATURE_RESULT" skipped
require_result publish-release "$PUBLISH_RELEASE_RESULT" skipped
require_result publish-channels "$PUBLISH_CHANNELS_RESULT" skipped
require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped
require_result repair-gitee "$REPAIR_GITEE_RESULT" skipped
;;
workflow_dispatch:repair_gitee)
require_result dispatch-contract "$DISPATCH_RESULT" success
require_result repair-gitee "$REPAIR_GITEE_RESULT" success
require_result authorize-recovery "$AUTHORIZE_RECOVERY_RESULT" skipped
require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" skipped
require_result release-contract "$RELEASE_CONTRACT_RESULT" skipped
require_result release "$RELEASE_RESULT" skipped
require_result verify-darwin-signatures "$DARWIN_SIGNATURE_RESULT" skipped
require_result publish-release "$PUBLISH_RELEASE_RESULT" skipped
require_result publish-channels "$PUBLISH_CHANNELS_RESULT" skipped
require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped
require_result repair-npm "$REPAIR_NPM_RESULT" skipped
;;
*)
echo "unsupported release mode: event=$EVENT_NAME mode=$DISPATCH_MODE" >&2
exit 1
;;
esac
# Mirror an existing public immutable release's assets to Gitee on demand. The
# push-triggered mirror-gitee-release job depends on the same run's finalized
# dist artifact, so a tag that was already published (including one delivered
# by a recovery dispatch) cannot reuse it. This dispatch path re-derives the
# asset set from the immutable GitHub Release itself and mirrors it. The
# release tag and asset checksums remain the sealed authority boundary.
repair-gitee:
name: Mirror an existing immutable release to Gitee
needs: dispatch-contract
if: ${{ !cancelled() && needs.dispatch-contract.result == 'success' && needs.dispatch-contract.outputs.mode == 'repair_gitee' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && github.repository == 'DingTalk-Real-AI/dingtalk-workspace-cli' }}
runs-on: ubuntu-latest
timeout-minutes: 120
permissions:
actions: read
contents: read
steps:
- name: Check out trusted release tooling
uses: actions/checkout@v4
with:
ref: ${{ github.sha }}
path: tooling
persist-credentials: false
fetch-depth: 0
- name: Validate repair version
working-directory: tooling
env:
VERSION: ${{ inputs.repair_gitee_version }}
run: |
set -eu
. ./scripts/release/release-lib.sh
if ! release_is_stable_version "$VERSION" && ! release_is_prerelease_version "$VERSION"; then
echo "Invalid release version: $VERSION" >&2
exit 2
fi
- name: Verify immutable release authority
id: authority
uses: actions/github-script@v7
env:
VERSION: ${{ inputs.repair_gitee_version }}
with:
script: |
const { owner, repo } = context.repo;
const version = process.env.VERSION;
const ref = await github.rest.git.getRef({
owner,
repo,
ref: `tags/${version}`,
});
if (ref.data.object.type !== "tag") {
core.setFailed(`${version} is not an annotated tag`);
return;
}
const tag = await github.rest.git.getTag({
owner,
repo,
tag_sha: ref.data.object.sha,
});
if (tag.data.tag !== version || tag.data.object.type !== "commit") {
core.setFailed(`${version} does not resolve to one annotated commit tag`);
return;
}
const commitSha = tag.data.object.sha;
const defaultBranch = context.payload.repository.default_branch;
const branch = await github.rest.repos.getBranch({
owner,
repo,
branch: defaultBranch,
});
const comparison = await github.rest.repos.compareCommitsWithBasehead({
owner,
repo,
basehead: `${commitSha}...${branch.data.commit.sha}`,
});
if (!["ahead", "identical"].includes(comparison.data.status)) {
core.setFailed(`${version} is not contained in ${defaultBranch}`);
return;
}
const release = await github.rest.repos.getReleaseByTag({
owner,
repo,
tag: version,
});
if (
release.data.tag_name !== version ||
release.data.draft ||
!release.data.immutable
) {
core.setFailed(`${version} is not a public immutable GitHub Release`);
return;
}
const expectedPrerelease = version.includes("-beta.");
if (release.data.prerelease !== expectedPrerelease) {
core.setFailed(`${version} prerelease metadata does not match its version`);
return;
}
const expectedAssets = new Set([
"dws-darwin-amd64.tar.gz",
"dws-darwin-arm64.tar.gz",
"dws-linux-amd64.tar.gz",
"dws-linux-arm64.tar.gz",
"dws-windows-amd64.zip",
"dws-windows-arm64.zip",
"dws-skills.zip",
"checksums.txt",
]);
const assetNames = release.data.assets.map((asset) => asset.name);
if (
assetNames.length !== expectedAssets.size ||
assetNames.some((name) => !expectedAssets.has(name)) ||
new Set(assetNames).size !== expectedAssets.size
) {
core.setFailed(`${version} does not contain the exact release asset set`);
return;
}
core.setOutput("commit_sha", commitSha);
core.setOutput("tag_object", ref.data.object.sha);
- name: Fetch and verify sealed release tag
working-directory: tooling
env:
VERSION: ${{ inputs.repair_gitee_version }}
RELEASE_COMMIT: ${{ steps.authority.outputs.commit_sha }}
RELEASE_TAG_OBJECT: ${{ steps.authority.outputs.tag_object }}
run: |
set -eu
git fetch --force --no-tags \
https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git \
'+refs/tags/v*:refs/tags/v*'
./scripts/release/verify-github-tag-authority.sh \
"$VERSION" "$RELEASE_COMMIT" "$RELEASE_TAG_OBJECT"
- name: Require successful Release workflow delivery
working-directory: tooling
env:
VERSION: ${{ inputs.repair_gitee_version }}
RELEASE_COMMIT: ${{ steps.authority.outputs.commit_sha }}
DWS_RELEASE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
./scripts/release/verify-release-workflow-delivery.sh \
"$VERSION" "$RELEASE_COMMIT"
- name: Download and verify immutable GitHub Release assets
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ inputs.repair_gitee_version }}
run: |
set -eu
mkdir -p dist
gh release download "$VERSION" \
--repo "$GITHUB_REPOSITORY" \
--dir dist \
--pattern 'dws-*' \
--pattern 'checksums.txt' \
--clobber
DWS_PACKAGE_DIST_DIR="$GITHUB_WORKSPACE/dist" \
"$GITHUB_WORKSPACE/tooling/scripts/release/verify-release-artifacts.sh" "$VERSION"
- name: Mirror release to Gitee (China)
timeout-minutes: 100
working-directory: tooling
run: |
"$GITHUB_WORKSPACE/tooling/scripts/release/sync-to-gitee.sh"
env:
VERSION: ${{ inputs.repair_gitee_version }}
DIST_DIR: ${{ github.workspace }}/dist
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
GITEE_USER: ${{ secrets.GITEE_USER }}
GITEE_REPO: ${{ secrets.GITEE_REPO }}
DWS_REQUIRE_GITEE: "1"
+1
View File
@@ -13,6 +13,7 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
### Fixed
- **Tag-push GitHub Release publication** — Draft publication now locks one GitHub Release database ID, verifies its exact tag, channel, notes, recovery marker, asset set, and uploaded bytes, then publishes and rechecks that same ID as immutable. Recovery runs use the trusted default-branch release helpers instead of the sealed tag's historical scripts, fixing the Draft-only `GET /releases/tags/{tag}` 404 without allowing the release identity to drift during recovery.
- **Release preflight reliability** — source-mode installer tests now use isolated temporary checkouts and HOME directories instead of overwriting and deleting the real repository `dws` binary, release preflight explicitly rebuilds before policy checks, and the full-suite runner gives the growing script package a non-flaky five-minute per-suite budget.
## [1.0.53-beta.4] - 2026-07-17
+4 -2
View File
@@ -377,9 +377,11 @@ func TestCrossPlatformCoverageDaemonMethodEdges(t *testing.T) {
d.conns.Store("not-a-conn", struct{}{})
d.conns.Store(&queryConn{}, struct{}{})
d.consumerWG.Add(1)
d.shutdown()
acceptDone := make(chan struct{})
close(acceptDone)
d.shutdown(acceptDone)
d.consumerWG.Done()
d.shutdown()
d.shutdown(acceptDone)
pr, pw, err := os.Pipe()
if err != nil {
+13 -7
View File
@@ -246,10 +246,11 @@ func Run(ctx context.Context, cfg Config) error {
}
// 7. Graceful shutdown — cancel runCtx first so all background
// goroutines wake up, then close listener / drain consumers.
// goroutines wake up, then stop accepting connections before draining
// consumers. The accept-loop barrier is required before WaitGroup.Wait:
// sync.WaitGroup forbids a positive Add racing with Wait.
cancelRun()
d.shutdown()
<-acceptDone
d.shutdown(acceptDone)
<-idleDone
<-dropWarnDone
@@ -291,6 +292,10 @@ func (d *daemon) acceptLoop(ctx context.Context) {
d.log.Warn("bus: accept error", "err", err)
continue
}
// Track the connection before publishing the handler goroutine. Once
// acceptLoop returns, shutdown can therefore close every accepted
// connection before waiting for handlers to drain.
d.conns.Store(conn, struct{}{})
d.consumerWG.Add(1)
go func() {
defer d.consumerWG.Done()
@@ -303,7 +308,6 @@ func (d *daemon) acceptLoop(ctx context.Context) {
// Hello → register with Hub → spawn writer goroutine → read until EOF/Bye.
// Always Unregisters and Closes on exit (plan invariant #5).
func (d *daemon) handleConnection(ctx context.Context, conn net.Conn) {
d.conns.Store(conn, struct{}{})
defer func() {
d.conns.Delete(conn)
conn.Close()
@@ -478,9 +482,10 @@ func (d *daemon) triggerShutdown(reason string) {
// 1. mark shuttingDown so acceptLoop exits cleanly
// 2. broadcast Bye to all consumers
// 3. close listener (interrupts pending Accept)
// 4. wait for all per-connection goroutines to drain
// 5. lock + meta cleanup via Run's defers
func (d *daemon) shutdown() {
// 4. wait for acceptLoop to return so no future consumerWG.Add can occur
// 5. close all accepted connections and wait for handlers to drain
// 6. lock + meta cleanup via Run's defers
func (d *daemon) shutdown(acceptDone <-chan struct{}) {
d.shutdownMu.Lock()
defer d.shutdownMu.Unlock()
if !d.shuttingDown.CompareAndSwap(false, true) {
@@ -488,6 +493,7 @@ func (d *daemon) shutdown() {
}
d.hub.Broadcast(transport.Bye{Type: transport.FrameTypeBye, Reason: "shutdown"})
_ = d.listener.Close()
<-acceptDone
// Force-close all open IPC connections so any reader goroutine blocked
// on Read() returns with a network error and exits cleanly. Without
// this the consumerWG never drains and Run hangs forever.
+87
View File
@@ -0,0 +1,87 @@
#!/bin/sh
set -eu
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
TAG="${1:-}"
DEST_DIR="${2:-}"
REPOSITORY="${GITHUB_REPOSITORY:-}"
RELEASE_ID="${DWS_GITHUB_RELEASE_ID:-}"
[ -n "$TAG" ] && [ -n "$DEST_DIR" ] && [ -n "$REPOSITORY" ] || {
printf 'usage: GITHUB_REPOSITORY=owner/repo [DWS_GITHUB_RELEASE_ID=id] download-github-release-assets.sh <tag> <destination>\n' >&2
exit 2
}
[ -n "$RELEASE_ID" ] || {
printf 'DWS_GITHUB_RELEASE_ID is required for an exact release download\n' >&2
exit 2
}
printf '%s\n' "$RELEASE_ID" | grep -Eq '^[1-9][0-9]*$' || {
printf 'invalid GitHub Release ID: %s\n' "$RELEASE_ID" >&2
exit 2
}
command -v gh >/dev/null 2>&1 || { printf 'gh is required\n' >&2; exit 1; }
DWS_GITHUB_RELEASE_ID="$RELEASE_ID" \
"$SCRIPT_DIR/verify-github-release-assets.sh" "$TAG"
tmp="$(mktemp -d "${TMPDIR:-/tmp}/dws-github-release-download.XXXXXX")"
trap 'rm -rf "$tmp"' EXIT HUP INT TERM
tab="$(printf '\t')"
cat > "$tmp/expected" <<'EOF'
checksums.txt
dws-darwin-amd64.tar.gz
dws-darwin-arm64.tar.gz
dws-linux-amd64.tar.gz
dws-linux-arm64.tar.gz
dws-skills.zip
dws-windows-amd64.zip
dws-windows-arm64.zip
EOF
LC_ALL=C sort "$tmp/expected" -o "$tmp/expected"
gh api -H 'Accept: application/vnd.github+json' \
"repos/$REPOSITORY/releases/$RELEASE_ID" \
--jq '.assets[] | [.id, .name] | @tsv' > "$tmp/assets.tsv"
cut -f 2 "$tmp/assets.tsv" | LC_ALL=C sort > "$tmp/actual"
if ! diff -u "$tmp/expected" "$tmp/actual"; then
printf 'GitHub Release %s must contain exactly the supported assets\n' "$TAG" >&2
exit 1
fi
while IFS="$tab" read -r asset_id asset_name; do
printf '%s\n' "$asset_id" | grep -Eq '^[1-9][0-9]*$' || {
printf 'invalid GitHub Release asset ID: %s\n' "$asset_id" >&2
exit 1
}
case "$asset_name" in
checksums.txt | \
dws-darwin-amd64.tar.gz | dws-darwin-arm64.tar.gz | \
dws-linux-amd64.tar.gz | dws-linux-arm64.tar.gz | \
dws-windows-amd64.zip | dws-windows-arm64.zip | \
dws-skills.zip) ;;
*)
printf 'unsupported GitHub Release asset name: %s\n' "$asset_name" >&2
exit 1
;;
esac
gh api -H 'Accept: application/octet-stream' \
"repos/$REPOSITORY/releases/assets/$asset_id" > "$tmp/$asset_name"
done < "$tmp/assets.tsv"
mkdir -p "$DEST_DIR"
for asset_name in \
checksums.txt \
dws-darwin-amd64.tar.gz \
dws-darwin-arm64.tar.gz \
dws-linux-amd64.tar.gz \
dws-linux-arm64.tar.gz \
dws-windows-amd64.zip \
dws-windows-arm64.zip \
dws-skills.zip; do
[ -f "$tmp/$asset_name" ] || {
printf 'GitHub Release asset was not downloaded: %s\n' "$asset_name" >&2
exit 1
}
mv -f "$tmp/$asset_name" "$DEST_DIR/$asset_name"
done
printf 'GitHub Release assets downloaded and verified: %s\n' "$TAG"
+10 -1
View File
@@ -16,7 +16,7 @@ usage() {
cat >&2 <<'EOF'
usage: dws-release [version] [options]
dws-release config [--remote <name>]
dws-release recover <version> [--failed-run <run-id>] [--remote <name>]
dws-release recover <version> [--failed-run <run-id>] [--failed-attempt <attempt>] [--remote <name>]
With no arguments, starts an interactive release guide. For a version that has
no CHANGELOG section yet, prepares the template and stops. Otherwise, runs the
@@ -200,6 +200,7 @@ recover_release() {
case "$recovery_version" in -h|--help) usage; exit 0 ;; esac
shift
recovery_failed_run=""
recovery_failed_attempt=""
while [ "$#" -gt 0 ]; do
case "$1" in
--failed-run)
@@ -207,6 +208,11 @@ recover_release() {
recovery_failed_run="$2"
shift 2
;;
--failed-attempt)
[ "$#" -ge 2 ] || die_usage '--failed-attempt requires a run attempt'
recovery_failed_attempt="$2"
shift 2
;;
--remote)
[ "$#" -ge 2 ] || die_usage '--remote requires a value'
REMOTE="$2"
@@ -224,6 +230,9 @@ recover_release() {
if [ -n "$recovery_failed_run" ]; then
set -- "$@" --failed-run "$recovery_failed_run"
fi
if [ -n "$recovery_failed_attempt" ]; then
set -- "$@" --failed-attempt "$recovery_failed_attempt"
fi
exec "$SCRIPT_DIR/recover-release.sh" "$@"
}
+101 -6
View File
@@ -8,11 +8,12 @@ ROOT="$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)"
VERSION="${1:-}"
REMOTE=""
FAILED_RUN_ID=""
FAILED_RUN_ATTEMPT=""
EXPECTED_REPOSITORY="DingTalk-Real-AI/dingtalk-workspace-cli"
usage() {
cat >&2 <<'EOF'
usage: recover-release.sh <version> --remote <name> [--failed-run <run-id>]
usage: recover-release.sh <version> --remote <name> [--failed-run <run-id>] [--failed-attempt <attempt>]
Recovers one failed existing release tag through the protected default-branch
Release workflow. It never creates, moves, or deletes a tag.
@@ -25,6 +26,7 @@ while [ "$#" -gt 0 ]; do
case "$1" in
--remote) [ "$#" -ge 2 ] || { usage; exit 2; }; REMOTE="$2"; shift 2 ;;
--failed-run) [ "$#" -ge 2 ] || { usage; exit 2; }; FAILED_RUN_ID="$2"; shift 2 ;;
--failed-attempt) [ "$#" -ge 2 ] || { usage; exit 2; }; FAILED_RUN_ATTEMPT="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) printf 'unknown recovery argument: %s\n' "$1" >&2; usage; exit 2 ;;
esac
@@ -39,8 +41,46 @@ if [ -n "$FAILED_RUN_ID" ]; then
exit 2
}
fi
if [ -n "$FAILED_RUN_ATTEMPT" ]; then
[ -n "$FAILED_RUN_ID" ] || {
printf '%s\n' '--failed-attempt requires --failed-run' >&2
exit 2
}
printf '%s\n' "$FAILED_RUN_ATTEMPT" | grep -Eq '^[1-9][0-9]*$' || {
printf 'invalid failed Release run attempt: %s\n' "$FAILED_RUN_ATTEMPT" >&2
exit 2
}
fi
command -v gh >/dev/null 2>&1 || { printf '%s\n' 'gh is required for release recovery' >&2; exit 1; }
find_latest_failed_attempt() {
find_run_id="$1"
find_latest="$(
gh api \
-H 'Accept: application/vnd.github+json' \
"repos/$EXPECTED_REPOSITORY/actions/runs/$find_run_id" \
--jq .run_attempt
)" || return 1
printf '%s\n' "$find_latest" | grep -Eq '^[1-9][0-9]*$' || return 1
find_attempt="$find_latest"
while [ "$find_attempt" -ge 1 ]; do
find_conclusion="$(
gh api \
-H 'Accept: application/vnd.github+json' \
"repos/$EXPECTED_REPOSITORY/actions/runs/$find_run_id/attempts/$find_attempt" \
--jq .conclusion
)" || return 1
case "$find_conclusion" in
failure|cancelled|timed_out|startup_failure|stale)
printf '%s\n' "$find_attempt"
return 0
;;
esac
find_attempt=$((find_attempt - 1))
done
return 1
}
cd "$ROOT"
[ "$(git symbolic-ref --quiet --short HEAD 2>/dev/null || true)" = "main" ] || {
printf '%s\n' 'release recovery must run from the main worktree' >&2
@@ -86,27 +126,81 @@ git merge-base --is-ancestor "$commit" "refs/remotes/$REMOTE/main" || {
}
if [ -z "$FAILED_RUN_ID" ]; then
failed_runs="$(
candidate_runs="$(
gh api \
-H 'Accept: application/vnd.github+json' \
"repos/$EXPECTED_REPOSITORY/actions/workflows/release.yml/runs?branch=$VERSION&event=push&status=completed&per_page=100" \
--jq ".workflow_runs[] | select(.head_sha == \"$commit\" and .head_branch == \"$VERSION\" and (.conclusion as \\$c | [\"failure\", \"cancelled\", \"timed_out\", \"startup_failure\", \"stale\"] | index(\\$c))) | .id"
--jq ".workflow_runs[] | select(.head_sha == \"$commit\" and .head_branch == \"$VERSION\") | .id"
)" || {
printf 'could not query failed Release runs for %s\n' "$VERSION" >&2
printf 'could not query Release runs for %s\n' "$VERSION" >&2
exit 1
}
FAILED_RUN_ID="$(printf '%s\n' "$failed_runs" | sed -n '1p')"
for candidate_run_id in $candidate_runs; do
if candidate_attempt="$(find_latest_failed_attempt "$candidate_run_id")"; then
FAILED_RUN_ID="$candidate_run_id"
FAILED_RUN_ATTEMPT="$candidate_attempt"
break
fi
done
[ -n "$FAILED_RUN_ID" ] || {
printf 'no failed exact-tag Release run found for %s at %s\n' "$VERSION" "$commit" >&2
exit 1
}
elif [ -z "$FAILED_RUN_ATTEMPT" ]; then
FAILED_RUN_ATTEMPT="$(find_latest_failed_attempt "$FAILED_RUN_ID")" || {
printf 'Release run %s has no failed attempt\n' "$FAILED_RUN_ID" >&2
exit 1
}
fi
printf '%s\n' "$FAILED_RUN_ATTEMPT" | grep -Eq '^[1-9][0-9]*$' || {
printf 'invalid failed Release run attempt: %s\n' "$FAILED_RUN_ATTEMPT" >&2
exit 1
}
attempt_record="$(
gh api \
-H 'Accept: application/vnd.github+json' \
"repos/$EXPECTED_REPOSITORY/actions/runs/$FAILED_RUN_ID/attempts/$FAILED_RUN_ATTEMPT" \
--jq '[.id, .run_attempt, .repository.full_name, .path, .event, .status, .conclusion, .head_branch, .head_sha] | @tsv'
)" || {
printf 'could not query Release run %s attempt %s\n' "$FAILED_RUN_ID" "$FAILED_RUN_ATTEMPT" >&2
exit 1
}
attempt_id="$(printf '%s\n' "$attempt_record" | cut -f1)"
attempt_number="$(printf '%s\n' "$attempt_record" | cut -f2)"
attempt_repository="$(printf '%s\n' "$attempt_record" | cut -f3)"
attempt_path="$(printf '%s\n' "$attempt_record" | cut -f4)"
attempt_event="$(printf '%s\n' "$attempt_record" | cut -f5)"
attempt_status="$(printf '%s\n' "$attempt_record" | cut -f6)"
attempt_conclusion="$(printf '%s\n' "$attempt_record" | cut -f7)"
attempt_branch="$(printf '%s\n' "$attempt_record" | cut -f8)"
attempt_commit="$(printf '%s\n' "$attempt_record" | cut -f9)"
case "$attempt_conclusion" in
failure|cancelled|timed_out|startup_failure|stale) ;;
*)
printf 'Release run %s attempt %s is not failed: %s\n' \
"$FAILED_RUN_ID" "$FAILED_RUN_ATTEMPT" "$attempt_conclusion" >&2
exit 1
;;
esac
if [ "$attempt_id" != "$FAILED_RUN_ID" ] ||
[ "$attempt_number" != "$FAILED_RUN_ATTEMPT" ] ||
[ "$attempt_repository" != "$EXPECTED_REPOSITORY" ] ||
[ "$attempt_path" != ".github/workflows/release.yml" ] ||
[ "$attempt_event" != "push" ] ||
[ "$attempt_status" != "completed" ] ||
[ "$attempt_branch" != "$VERSION" ] ||
[ "$attempt_commit" != "$commit" ]; then
printf 'Release run %s attempt %s does not match %s at %s\n' \
"$FAILED_RUN_ID" "$FAILED_RUN_ATTEMPT" "$VERSION" "$commit" >&2
exit 1
fi
printf 'Recovery target:\n'
printf ' version: %s\n' "$VERSION"
printf ' tag object: %s\n' "$tag_object"
printf ' commit: %s\n' "$commit"
printf ' failed run: https://github.com/%s/actions/runs/%s\n' "$EXPECTED_REPOSITORY" "$FAILED_RUN_ID"
printf ' failed run: https://github.com/%s/actions/runs/%s/attempts/%s\n' \
"$EXPECTED_REPOSITORY" "$FAILED_RUN_ID" "$FAILED_RUN_ATTEMPT"
[ -t 0 ] || { printf '%s\n' 'interactive recovery confirmation is required' >&2; exit 1; }
printf 'Type %s to request protected recovery: ' "$VERSION"
IFS= read -r confirmation
@@ -121,6 +215,7 @@ gh workflow run release.yml \
-f "recover_release_tag_object=$tag_object" \
-f "recover_release_commit=$commit" \
-f "recover_failed_run_id=$FAILED_RUN_ID" \
-f "recover_failed_run_attempt=$FAILED_RUN_ATTEMPT" \
-f "recover_release_nonce=$nonce" \
-f "recover_release_confirmation=$VERSION"
@@ -3,13 +3,32 @@ set -eu
TAG="${1:-}"
REPOSITORY="${GITHUB_REPOSITORY:-}"
RELEASE_ID="${DWS_GITHUB_RELEASE_ID:-}"
[ -n "$TAG" ] && [ -n "$REPOSITORY" ] || {
printf 'usage: GITHUB_REPOSITORY=owner/repo verify-github-release-assets.sh <tag>\n' >&2
printf 'usage: GITHUB_REPOSITORY=owner/repo [DWS_GITHUB_RELEASE_ID=id] verify-github-release-assets.sh <tag>\n' >&2
exit 2
}
command -v gh >/dev/null 2>&1 || { printf 'gh is required\n' >&2; exit 1; }
if [ -z "$RELEASE_ID" ]; then
RELEASE_ID="$(
gh release view "$TAG" \
--repo "$REPOSITORY" \
--json databaseId,isDraft \
--jq 'select(.isDraft == true) | .databaseId' 2>/dev/null || true
)"
fi
if [ -n "$RELEASE_ID" ]; then
printf '%s\n' "$RELEASE_ID" | grep -Eq '^[1-9][0-9]*$' || {
printf 'invalid GitHub Release ID: %s\n' "$RELEASE_ID" >&2
exit 2
}
release_endpoint="repos/$REPOSITORY/releases/$RELEASE_ID"
else
release_endpoint="repos/$REPOSITORY/releases/tags/$TAG"
fi
tmp="$(mktemp -d "${TMPDIR:-/tmp}/dws-github-assets.XXXXXX")"
trap 'rm -rf "$tmp"' EXIT HUP INT TERM
cat > "$tmp/expected" <<'EOF'
@@ -24,8 +43,17 @@ dws-windows-arm64.zip
EOF
LC_ALL=C sort "$tmp/expected" -o "$tmp/expected"
resolved_tag="$(
gh api -H 'Accept: application/vnd.github+json' \
"$release_endpoint" \
--jq '.tag_name'
)"
[ "$resolved_tag" = "$TAG" ] || {
printf 'GitHub Release ID/tag mismatch: expected %s, got %s\n' "$TAG" "$resolved_tag" >&2
exit 1
}
gh api -H 'Accept: application/vnd.github+json' \
"repos/$REPOSITORY/releases/tags/$TAG" \
"$release_endpoint" \
--jq '.assets[].name' | LC_ALL=C sort > "$tmp/actual"
if ! diff -u "$tmp/expected" "$tmp/actual"; then
printf 'GitHub Release %s must contain exactly the supported assets\n' "$TAG" >&2
+301 -1
View File
@@ -724,6 +724,86 @@ func TestReleaseWorkflowGovernancePreflightCannotPublish(t *testing.T) {
}
}
func TestReleaseWorkflowGiteeRepairUsesSealedReleaseAuthority(t *testing.T) {
t.Parallel()
workflow := readReleaseWorkflow(t)
dispatch := releaseWorkflowSection(t, workflow, " dispatch-contract:\n", "\n authorize-recovery:\n")
start := strings.Index(workflow, " repair-gitee:\n")
if start == -1 {
t.Fatal("release workflow is missing the Gitee repair job")
}
repair := workflow[start:]
for _, required := range []string{
"repair_gitee_version:",
`format('Release Gitee repair {0}', inputs.repair_gitee_version)`,
`REPAIR_GITEE_VERSION: ${{ inputs.repair_gitee_version }}`,
"gitee_repair=0",
`test -z "$REPAIR_GITEE_VERSION" || gitee_repair=1`,
"npm_repair + gitee_repair + governance + recovery",
`echo "mode=repair_gitee" >> "$GITHUB_OUTPUT"`,
} {
if !strings.Contains(dispatch, required) && !strings.Contains(workflow, required) {
t.Errorf("Gitee repair dispatch contract is missing %q", required)
}
}
for _, required := range []string{
"needs: dispatch-contract",
`if: ${{ !cancelled() && needs.dispatch-contract.result == 'success' && needs.dispatch-contract.outputs.mode == 'repair_gitee'`,
`github.ref == format('refs/heads/{0}', github.event.repository.default_branch)`,
`github.repository == 'DingTalk-Real-AI/dingtalk-workspace-cli'`,
"actions: read",
`ref: ${{ github.sha }}`,
"path: tooling",
"persist-credentials: false",
"release_is_stable_version",
"release_is_prerelease_version",
`ref: ` + "`tags/${version}`",
`["ahead", "identical"].includes(comparison.data.status)`,
"!release.data.immutable",
`release.data.prerelease !== expectedPrerelease`,
"assetNames.length !== expectedAssets.size",
"new Set(assetNames).size !== expectedAssets.size",
`core.setOutput("tag_object", ref.data.object.sha)`,
"verify-github-tag-authority.sh",
"verify-release-workflow-delivery.sh",
`DWS_RELEASE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}`,
"verify-release-artifacts.sh",
`--repo "$GITHUB_REPOSITORY"`,
`DWS_REQUIRE_GITEE: "1"`,
"working-directory: tooling\n run: |\n " +
`"$GITHUB_WORKSPACE/tooling/scripts/release/sync-to-gitee.sh"`,
} {
if !strings.Contains(repair, required) {
t.Errorf("Gitee repair authority is missing %q", required)
}
}
for _, asset := range []string{
"dws-darwin-amd64.tar.gz",
"dws-darwin-arm64.tar.gz",
"dws-linux-amd64.tar.gz",
"dws-linux-arm64.tar.gz",
"dws-windows-amd64.zip",
"dws-windows-arm64.zip",
"dws-skills.zip",
"checksums.txt",
} {
if strings.Count(repair, `"`+asset+`"`) != 1 {
t.Errorf("Gitee repair must require exactly one %s asset declaration", asset)
}
}
if strings.Contains(repair, "contents: write") {
t.Error("Gitee repair must not grant contents write permission")
}
if strings.Contains(repair, "ref: ${{ github.event.repository.default_branch }}") {
t.Error("Gitee repair must not check out a floating default branch")
}
if got := strings.Count(repair, "working-directory: tooling"); got < 4 {
t.Errorf("Gitee repair trusted tooling working-directory count = %d, want at least 4", got)
}
}
func TestReleaseWorkflowRecoveryReusesGuardedJobs(t *testing.T) {
t.Parallel()
workflow := readReleaseWorkflow(t)
@@ -733,6 +813,7 @@ func TestReleaseWorkflowRecoveryReusesGuardedJobs(t *testing.T) {
"recover_release_tag_object:",
"recover_release_commit:",
"recover_failed_run_id:",
"recover_failed_run_attempt:",
"recover_release_nonce:",
"recover_release_confirmation:",
`format('Release recovery {0} at {1} {2}', inputs.recover_release_version, inputs.recover_release_commit, inputs.recover_release_nonce)`,
@@ -745,6 +826,9 @@ func TestReleaseWorkflowRecoveryReusesGuardedJobs(t *testing.T) {
"can_admins_bypass !== false",
`run.path !== ".github/workflows/release.yml"`,
`run.event !== "push"`,
`"GET /repos/{owner}/{repo}/actions/runs/{run_id}/attempts/{attempt_number}"`,
"attempt_number: Number(failedRunAttempt)",
"run.run_attempt !== Number(failedRunAttempt)",
`["failure", "cancelled", "timed_out", "startup_failure", "stale"].includes(run.conclusion)`,
`run.head_branch !== version`,
`run.head_sha !== commit`,
@@ -795,6 +879,222 @@ func TestReleaseWorkflowRecoveryReusesGuardedJobs(t *testing.T) {
}
}
func TestReleaseWorkflowDraftLifecycleUsesOneReleaseID(t *testing.T) {
t.Parallel()
workflow := readReleaseWorkflow(t)
publishJob := releaseWorkflowSection(t, workflow, " publish-release:\n", "\n publish-channels:\n")
publishStep := releaseWorkflowSection(
t,
publishJob,
" - name: Publish or reuse immutable GitHub Release\n",
"\n - name: Require immutable published GitHub Release\n",
)
for _, required := range []string{
"id: publish",
"--json databaseId",
`"repos/$GITHUB_REPOSITORY/releases/$release_id"`,
`uploaded_release_id="$(`,
`test "$uploaded_release_id" = "$release_id"`,
"Draft GitHub Release ID $release_id targets",
"Draft GitHub Release notes differ from the sealed CHANGELOG.",
"Draft GitHub Release is not bound to this exact recovery run.",
`DWS_GITHUB_RELEASE_ID="$release_id"`,
`tmp/trusted-release-tooling/scripts/release/verify-github-release-assets.sh`,
`tmp/trusted-release-tooling/scripts/release/download-github-release-assets.sh`,
`cmp -s "$local_asset" "$remote_asset"`,
"-F draft=false",
`echo "release_id=$release_id" >> "$GITHUB_OUTPUT"`,
} {
if !strings.Contains(publishStep, required) {
t.Errorf("Draft release lifecycle is missing %q", required)
}
}
for _, forbidden := range []string{
`gh release download "$RELEASE_VERSION"`,
`gh release edit "$RELEASE_VERSION" --draft=false`,
`"repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_VERSION"`,
} {
if strings.Contains(publishStep, forbidden) {
t.Errorf("Draft release lifecycle must not switch back from the locked release ID via %q", forbidden)
}
}
tagGuard := strings.Index(publishStep, "Draft GitHub Release ID $release_id targets")
draftPatch := strings.Index(publishStep, "-F draft=true")
bodyVerify := strings.Index(publishStep, "Draft GitHub Release notes differ from the sealed CHANGELOG.")
markerVerify := strings.Index(publishStep, "Draft GitHub Release is not bound to this exact recovery run.")
upload := strings.Index(publishStep, `gh release upload "$RELEASE_VERSION"`)
idRecheck := strings.Index(publishStep, `test "$uploaded_release_id" = "$release_id"`)
verify := strings.Index(publishStep, "tmp/trusted-release-tooling/scripts/release/verify-github-release-assets.sh")
download := strings.LastIndex(publishStep, "tmp/trusted-release-tooling/scripts/release/download-github-release-assets.sh")
byteCompare := strings.Index(publishStep, `cmp -s "$local_asset" "$remote_asset"`)
publish := strings.Index(publishStep, "-F draft=false")
if tagGuard == -1 || draftPatch == -1 || bodyVerify == -1 || markerVerify == -1 ||
upload == -1 || idRecheck == -1 || verify == -1 || download == -1 || byteCompare == -1 || publish == -1 ||
!(tagGuard < draftPatch && draftPatch < bodyVerify && bodyVerify < markerVerify && markerVerify < upload &&
upload < idRecheck && idRecheck < verify && verify < download && download < byteCompare && byteCompare < publish) {
t.Fatal("Draft must retain one release ID through upload, exact verification, download, byte comparison, and publication")
}
terminalStep := publishJob[strings.Index(publishJob, " - name: Require immutable published GitHub Release\n"):]
for _, required := range []string{
`RELEASE_ID: ${{ steps.publish.outputs.release_id }}`,
`RELEASE_CHANNEL: ${{ needs.release-contract.outputs.channel }}`,
`"repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID"`,
`DWS_GITHUB_RELEASE_ID="$RELEASE_ID"`,
`tmp/trusted-release-tooling/scripts/release/verify-github-release-assets.sh`,
`tmp/trusted-release-tooling/scripts/release/download-github-release-assets.sh`,
`[.tag_name, .draft, .prerelease, .immutable] | @tsv`,
`printf '%s\tfalse\t%s\ttrue' "$RELEASE_VERSION" "$expected_prerelease"`,
"Immutable GitHub Release notes differ from the sealed CHANGELOG.",
"Immutable GitHub Release is not bound to this exact recovery run.",
`DWS_PACKAGE_DIST_DIR="$immutable_dir"`,
`cmp -s "$sealed_asset" "$immutable_asset"`,
} {
if !strings.Contains(terminalStep, required) {
t.Errorf("terminal immutable release gate is missing %q", required)
}
}
immutableState := strings.Index(terminalStep, `[.tag_name, .draft, .prerelease, .immutable] | @tsv`)
immutableBody := strings.Index(terminalStep, "Immutable GitHub Release notes differ from the sealed CHANGELOG.")
immutableDownload := strings.Index(terminalStep, "tmp/trusted-release-tooling/scripts/release/download-github-release-assets.sh")
immutableVerify := strings.Index(terminalStep, `DWS_PACKAGE_DIST_DIR="$immutable_dir"`)
immutableCompare := strings.Index(terminalStep, `cmp -s "$sealed_asset" "$immutable_asset"`)
if immutableState == -1 || immutableBody == -1 || immutableDownload == -1 || immutableVerify == -1 || immutableCompare == -1 ||
!(immutableState < immutableBody && immutableBody < immutableDownload && immutableDownload < immutableVerify && immutableVerify < immutableCompare) {
t.Fatal("terminal gate must reverify immutable notes and exact sealed bytes on the locked release ID")
}
}
func TestRecoverReleaseBindsOneFailedRunAttempt(t *testing.T) {
t.Parallel()
scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "recover-release.sh"))
if err != nil {
t.Fatalf("Abs(recover-release.sh) error = %v", err)
}
data, err := os.ReadFile(scriptPath)
if err != nil {
t.Fatalf("ReadFile(%s) error = %v", scriptPath, err)
}
script := string(data)
for _, required := range []string{
"--failed-attempt <attempt>",
"--failed-attempt requires --failed-run",
"find_latest_failed_attempt",
`while [ "$find_attempt" -ge 1 ]`,
"actions/runs/$find_run_id/attempts/$find_attempt",
`select(.head_sha == \"$commit\" and .head_branch == \"$VERSION\")`,
"Release run %s has no failed attempt",
`[.id, .run_attempt, .repository.full_name, .path, .event, .status, .conclusion, .head_branch, .head_sha] | @tsv`,
"actions/runs/%s/attempts/%s",
`-f "recover_failed_run_attempt=$FAILED_RUN_ATTEMPT"`,
} {
if !strings.Contains(script, required) {
t.Errorf("release recovery script is missing attempt binding %q", required)
}
}
}
func TestReleaseWorkflowPublicationBypassesSkippedDispatchButStopsOnCancellation(t *testing.T) {
t.Parallel()
workflow := readReleaseWorkflow(t)
tests := []struct {
name string
start string
end string
condition string
}{
{
name: "release contract",
start: " release-contract:\n",
end: "\n release:\n",
condition: `if: ${{ !cancelled() && (github.event_name == 'push' || (needs.dispatch-contract.result == 'success' && needs.dispatch-contract.outputs.mode == 'recover_release' && needs.authorize-recovery.result == 'success')) }}`,
},
{
name: "build",
start: " release:\n",
end: "\n verify-darwin-signatures:\n",
condition: `if: ${{ !cancelled() && needs.release-contract.result == 'success' }}`,
},
{
name: "Darwin verification",
start: " verify-darwin-signatures:\n",
end: "\n publish-release:\n",
condition: `if: ${{ !cancelled() && needs.release-contract.result == 'success' && needs.release.result == 'success' }}`,
},
{
name: "GitHub publication",
start: " publish-release:\n",
end: "\n publish-channels:\n",
condition: `if: ${{ !cancelled() && needs.release-contract.result == 'success' && needs.release.result == 'success' && needs.verify-darwin-signatures.result == 'success' }}`,
},
{
name: "channel publication",
start: " publish-channels:\n",
end: "\n mirror-gitee-release:\n",
condition: `if: ${{ !cancelled() && needs.release-contract.result == 'success' && needs.publish-release.result == 'success' }}`,
},
{
name: "Gitee mirror",
start: " mirror-gitee-release:\n",
end: "\n repair-npm:\n",
condition: `if: ${{ !cancelled() && vars.ENABLE_GITEE_UPLOAD_FALLBACK == 'true' && needs.release-contract.result == 'success' && needs.release.result == 'success' && needs.publish-channels.result == 'success' }}`,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
section := releaseWorkflowSection(t, workflow, test.start, test.end)
if !strings.Contains(section, test.condition) {
t.Errorf("%s must override skipped dispatch ancestors while preserving cancellation and dependency gates", test.name)
}
})
}
}
func TestReleaseWorkflowDeliveryGateFailsClosed(t *testing.T) {
t.Parallel()
workflow := readReleaseWorkflow(t)
gate := releaseWorkflowSection(t, workflow, " release-delivery-gate:\n", "\n repair-gitee:\n")
for _, required := range []string{
"name: Release delivery gate",
`if: ${{ !cancelled() }}`,
"- dispatch-contract",
"- authorize-recovery",
"- governance-preflight",
"- release-contract",
"- release",
"- verify-darwin-signatures",
"- publish-release",
"- publish-channels",
"- mirror-gitee-release",
"- repair-npm",
"- repair-gitee",
`REPAIR_GITEE_RESULT: ${{ needs.repair-gitee.result }}`,
"require_publication",
`require_result release-contract "$RELEASE_CONTRACT_RESULT" success`,
`require_result release "$RELEASE_RESULT" success`,
`require_result verify-darwin-signatures "$DARWIN_SIGNATURE_RESULT" success`,
`require_result publish-release "$PUBLISH_RELEASE_RESULT" success`,
`require_result publish-channels "$PUBLISH_CHANNELS_RESULT" success`,
"workflow_dispatch:recover_release",
"workflow_dispatch:governance_preflight",
"workflow_dispatch:repair_npm",
"workflow_dispatch:repair_gitee",
`require_result repair-gitee "$REPAIR_GITEE_RESULT" success`,
`require_result repair-gitee "$REPAIR_GITEE_RESULT" skipped`,
"unsupported release mode",
} {
if !strings.Contains(gate, required) {
t.Errorf("release delivery gate is missing %q", required)
}
}
}
func TestReleaseWorkflowUploadsPostProcessedDarwinAssets(t *testing.T) {
t.Parallel()
@@ -959,7 +1259,7 @@ func TestReleaseWorkflowUsesAppleCodesignBeforePublication(t *testing.T) {
}
codesign := strings.Index(workflow[verifyJob:publishJob], "codesign --verify --strict --verbose=4")
publish := strings.Index(workflow[publishJob:], `gh release edit "$RELEASE_VERSION" --draft=false`)
publish := strings.Index(workflow[publishJob:], "-F draft=false")
if codesign == -1 || publish == -1 {
t.Fatal("macOS codesign verification and explicit Draft publication are required")
}
+2 -2
View File
@@ -38,11 +38,11 @@ func newReleaseEntryFixture(t *testing.T) *releaseEntryFixture {
func TestDWSReleaseEntryRoutesProtectedRecovery(t *testing.T) {
f := newReleaseEntryFixture(t)
f.configureRemote(t, "origin")
output, err := f.run(t, nil, "recover", "v1.0.1-beta.1", "--failed-run", "12345")
output, err := f.run(t, nil, "recover", "v1.0.1-beta.1", "--failed-run", "12345", "--failed-attempt", "2")
if err != nil {
t.Fatalf("dws-release recovery route error = %v\noutput:\n%s", err, output)
}
want := "recover\tv1.0.1-beta.1\t--remote\torigin\t--failed-run\t12345\n"
want := "recover\tv1.0.1-beta.1\t--remote\torigin\t--failed-run\t12345\t--failed-attempt\t2\n"
if got := f.callLog(t); got != want {
t.Fatalf("recovery delegate calls = %q, want %q", got, want)
}
+218 -5
View File
@@ -259,7 +259,25 @@ func TestReleaseGitHubAssetSetIsExact(t *testing.T) {
}
binDir := t.TempDir()
fakeGH := filepath.Join(binDir, "gh")
mustWriteFile(t, fakeGH, []byte("#!/bin/sh\nset -eu\nprintf '%s\\n' \"$GH_ASSETS\"\n"), 0o755)
mustWriteFile(t, fakeGH, []byte(`#!/bin/sh
set -eu
case "${1:-}" in
release)
[ "${2:-}" = "view" ] || exit 2
[ "${GH_DRAFT:-false}" = "true" ] || exit 1
printf '123\n'
;;
api)
printf '%s\n' "$*" >> "$GH_LOG"
case "$*" in
*tag_name*) printf '%s\n' "$GH_RELEASE_TAG" ;;
*assets*name*) printf '%s\n' "$GH_ASSETS" ;;
*) exit 2 ;;
esac
;;
*) exit 2 ;;
esac
`), 0o755)
exact := strings.Join([]string{
"dws-windows-arm64.zip",
"dws-linux-amd64.tar.gz",
@@ -271,23 +289,218 @@ func TestReleaseGitHubAssetSetIsExact(t *testing.T) {
"dws-darwin-arm64.tar.gz",
}, "\n")
script := filepath.Join(sourceRoot, "scripts", "release", "verify-github-release-assets.sh")
run := func(assets string) (string, error) {
run := func(assets string, draft bool, releaseTag, releaseID string) (string, string, error) {
draftValue := "false"
if draft {
draftValue = "true"
}
logPath := filepath.Join(t.TempDir(), "gh.log")
cmd := exec.Command("sh", script, "v1.2.3")
cmd.Env = []string{
"PATH=" + binDir + string(os.PathListSeparator) + os.Getenv("PATH"),
"HOME=" + t.TempDir(),
"GITHUB_REPOSITORY=owner/repo",
"GH_ASSETS=" + assets,
"GH_DRAFT=" + draftValue,
"GH_LOG=" + logPath,
"GH_RELEASE_TAG=" + releaseTag,
"DWS_GITHUB_RELEASE_ID=" + releaseID,
}
output, err := cmd.CombinedOutput()
return string(output), err
log, readErr := os.ReadFile(logPath)
if readErr != nil && !os.IsNotExist(readErr) {
t.Fatalf("ReadFile(%s) error = %v", logPath, readErr)
}
return string(output), string(log), err
}
if output, err := run(exact); err != nil {
if output, _, err := run(exact, false, "v1.2.3", ""); err != nil {
t.Fatalf("exact GitHub assets rejected: %v\n%s", err, output)
}
if output, err := run(exact + "\nmalware.exe"); err == nil || !strings.Contains(output, "exactly the supported assets") {
if output, log, err := run(exact, true, "v1.2.3", ""); err != nil {
t.Fatalf("exact Draft GitHub assets rejected: %v\n%s", err, output)
} else if !strings.Contains(log, "repos/owner/repo/releases/123") ||
strings.Contains(log, "repos/owner/repo/releases/tags/v1.2.3") {
t.Fatalf("Draft verification did not use the release ID endpoint:\n%s", log)
}
if output, _, err := run(exact+"\nmalware.exe", false, "v1.2.3", ""); err == nil || !strings.Contains(output, "exactly the supported assets") {
t.Fatalf("extra GitHub asset was not rejected: err=%v\n%s", err, output)
}
if output, _, err := run(exact, true, "v9.9.9", ""); err == nil || !strings.Contains(output, "ID/tag mismatch") {
t.Fatalf("wrong Draft release ID target was not rejected: err=%v\n%s", err, output)
}
if output, _, err := run(exact, false, "v1.2.3", "invalid"); err == nil || !strings.Contains(output, "invalid GitHub Release ID") {
t.Fatalf("invalid explicit release ID was not rejected: err=%v\n%s", err, output)
}
}
func TestReleaseGitHubAssetsDownloadByExactReleaseID(t *testing.T) {
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
if err != nil {
t.Fatalf("Abs(repo root) error = %v", err)
}
binDir := t.TempDir()
fakeGH := filepath.Join(binDir, "gh")
mustWriteFile(t, fakeGH, []byte(`#!/bin/sh
set -eu
[ "${1:-}" = "api" ] || exit 2
shift
accept=""
endpoint=""
query=""
while [ "$#" -gt 0 ]; do
case "$1" in
-H)
accept="${2:-}"
shift 2
;;
--jq)
query="${2:-}"
shift 2
;;
repos/*)
endpoint="$1"
shift
;;
*)
shift
;;
esac
done
printf '%s|%s|%s\n' "$accept" "$endpoint" "$query" >> "$GH_LOG"
case "$accept" in
*application/octet-stream*)
case "$endpoint" in
repos/owner/repo/releases/assets/*) ;;
*) exit 31 ;;
esac
asset_id="${endpoint##*/}"
asset_name="$(
awk -F ' ' -v id="$asset_id" '
$1 == id { print $2; found = 1 }
END { if (!found) exit 1 }
' "$GH_ASSET_ROWS"
)" || exit 32
printf 'asset-bytes:%s:%s\n' "$asset_id" "$asset_name"
exit 0
;;
esac
[ "$endpoint" = "repos/owner/repo/releases/$GH_EXPECTED_RELEASE_ID" ] || exit 33
case "$query" in
'.tag_name')
printf '%s\n' "$GH_RELEASE_TAG"
;;
'.assets[].name')
cut -f 2 "$GH_ASSET_ROWS"
;;
'.assets[] | [.id, .name] | @tsv')
cat "$GH_ASSET_ROWS"
;;
*)
exit 34
;;
esac
`), 0o755)
type releaseAsset struct {
id string
name string
}
assets := []releaseAsset{
{id: "1008", name: "dws-windows-arm64.zip"},
{id: "1003", name: "dws-darwin-arm64.tar.gz"},
{id: "1001", name: "checksums.txt"},
{id: "1005", name: "dws-linux-arm64.tar.gz"},
{id: "1002", name: "dws-darwin-amd64.tar.gz"},
{id: "1007", name: "dws-windows-amd64.zip"},
{id: "1004", name: "dws-linux-amd64.tar.gz"},
{id: "1006", name: "dws-skills.zip"},
}
rowsFor := func(items []releaseAsset) string {
var rows []string
for _, asset := range items {
rows = append(rows, asset.id+"\t"+asset.name)
}
return strings.Join(rows, "\n") + "\n"
}
script := filepath.Join(sourceRoot, "scripts", "release", "download-github-release-assets.sh")
type runResult struct {
output string
log string
dest string
err error
}
run := func(items []releaseAsset, releaseTag, releaseID string) runResult {
runDir := t.TempDir()
rowsPath := filepath.Join(runDir, "assets.tsv")
logPath := filepath.Join(runDir, "gh.log")
dest := filepath.Join(runDir, "download")
mustWriteFile(t, rowsPath, []byte(rowsFor(items)), 0o644)
cmd := exec.Command("sh", script, "v1.2.3", dest)
cmd.Env = []string{
"PATH=" + binDir + string(os.PathListSeparator) + os.Getenv("PATH"),
"HOME=" + t.TempDir(),
"GITHUB_REPOSITORY=owner/repo",
"DWS_GITHUB_RELEASE_ID=" + releaseID,
"GH_ASSET_ROWS=" + rowsPath,
"GH_EXPECTED_RELEASE_ID=456",
"GH_LOG=" + logPath,
"GH_RELEASE_TAG=" + releaseTag,
}
output, runErr := cmd.CombinedOutput()
log, readErr := os.ReadFile(logPath)
if readErr != nil && !os.IsNotExist(readErr) {
t.Fatalf("ReadFile(%s) error = %v", logPath, readErr)
}
return runResult{
output: string(output),
log: string(log),
dest: dest,
err: runErr,
}
}
success := run(assets, "v1.2.3", "456")
if success.err != nil {
t.Fatalf("exact GitHub Release download was rejected: %v\n%s", success.err, success.output)
}
if strings.Contains(success.log, "releases/tags/") {
t.Fatalf("download fell back to a tag endpoint:\n%s", success.log)
}
if got := strings.Count(success.log, "application/octet-stream"); got != len(assets) {
t.Fatalf("asset download count = %d, want %d\nlog:\n%s", got, len(assets), success.log)
}
for _, asset := range assets {
wantEndpoint := "repos/owner/repo/releases/assets/" + asset.id
if !strings.Contains(success.log, wantEndpoint) {
t.Errorf("asset %s was not downloaded by ID %s\nlog:\n%s", asset.name, asset.id, success.log)
}
data, readErr := os.ReadFile(filepath.Join(success.dest, asset.name))
if readErr != nil {
t.Errorf("ReadFile(%s) error = %v", asset.name, readErr)
continue
}
want := fmt.Sprintf("asset-bytes:%s:%s\n", asset.id, asset.name)
if string(data) != want {
t.Errorf("%s bytes = %q, want %q", asset.name, data, want)
}
}
extra := append(append([]releaseAsset{}, assets...), releaseAsset{id: "1009", name: "malware.exe"})
if result := run(extra, "v1.2.3", "456"); result.err == nil || !strings.Contains(result.output, "exactly the supported assets") {
t.Fatalf("extra GitHub Release asset was not rejected: err=%v\n%s", result.err, result.output)
}
if result := run(assets[:len(assets)-1], "v1.2.3", "456"); result.err == nil || !strings.Contains(result.output, "exactly the supported assets") {
t.Fatalf("missing GitHub Release asset was not rejected: err=%v\n%s", result.err, result.output)
}
if result := run(assets, "v9.9.9", "456"); result.err == nil || !strings.Contains(result.output, "ID/tag mismatch") {
t.Fatalf("wrong GitHub Release tag was not rejected: err=%v\n%s", result.err, result.output)
}
if result := run(assets, "v1.2.3", "invalid"); result.err == nil || !strings.Contains(result.output, "invalid GitHub Release ID") {
t.Fatalf("invalid GitHub Release ID was not rejected: err=%v\n%s", result.err, result.output)
}
}
func TestReleaseDeliveredStableRequiresSuccessfulPublicDelivery(t *testing.T) {