Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ed5de2374 |
+34
-308
@@ -35,10 +35,6 @@ 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
|
||||
@@ -76,7 +72,6 @@ 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 }}
|
||||
RECOVER_NONCE: ${{ inputs.recover_release_nonce }}
|
||||
RECOVER_CONFIRMATION: ${{ inputs.recover_release_confirmation }}
|
||||
run: |
|
||||
@@ -88,7 +83,6 @@ jobs:
|
||||
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 || {
|
||||
@@ -106,7 +100,6 @@ 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
|
||||
@@ -127,10 +120,6 @@ 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
|
||||
@@ -301,7 +290,7 @@ jobs:
|
||||
|
||||
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')) }}
|
||||
if: ${{ always() && (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:
|
||||
@@ -328,7 +317,6 @@ 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;
|
||||
@@ -337,7 +325,6 @@ 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" ||
|
||||
@@ -350,7 +337,6 @@ 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;
|
||||
@@ -363,10 +349,6 @@ 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}` });
|
||||
@@ -401,19 +383,13 @@ jobs:
|
||||
core.setFailed(`${version} commit is not contained in ${defaultBranch}`);
|
||||
return;
|
||||
}
|
||||
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 failedRun = await github.rest.actions.getWorkflowRun({
|
||||
owner,
|
||||
repo,
|
||||
run_id: Number(failedRunId),
|
||||
});
|
||||
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" ||
|
||||
@@ -422,9 +398,7 @@ jobs:
|
||||
run.head_branch !== version ||
|
||||
run.head_sha !== commit
|
||||
) {
|
||||
core.setFailed(
|
||||
`run ${failedRunId} attempt ${failedRunAttempt} is not the failed exact-tag Release attempt for ${version}`,
|
||||
);
|
||||
core.setFailed(`run ${failedRunId} is not the failed exact-tag Release run for ${version}`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -814,7 +788,7 @@ jobs:
|
||||
|
||||
verify-darwin-signatures:
|
||||
name: Verify Apple Developer ID signatures
|
||||
if: ${{ !cancelled() && needs.release-contract.result == 'success' && needs.release.result == 'success' }}
|
||||
if: ${{ !cancelled() && needs.release.result == 'success' }}
|
||||
needs: [release-contract, release]
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 10
|
||||
@@ -929,7 +903,6 @@ 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 }}
|
||||
@@ -942,36 +915,9 @@ jobs:
|
||||
prerelease_flag="--prerelease"
|
||||
fi
|
||||
|
||||
release_id=""
|
||||
if gh release view "$RELEASE_VERSION" >/dev/null 2>&1; then
|
||||
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
|
||||
)"
|
||||
is_draft="$(gh release view "$RELEASE_VERSION" --json isDraft --jq .isDraft)"
|
||||
is_prerelease="$(gh release view "$RELEASE_VERSION" --json isPrerelease --jq .isPrerelease)"
|
||||
if [ "$is_prerelease" != "$expected_prerelease" ]; then
|
||||
echo "Existing release channel does not match $RELEASE_CHANNEL." >&2
|
||||
exit 1
|
||||
@@ -979,18 +925,14 @@ jobs:
|
||||
if [ "$is_draft" = "false" ]; then
|
||||
is_immutable="$(
|
||||
gh api -H 'X-GitHub-Api-Version: 2026-03-10' \
|
||||
"repos/$GITHUB_REPOSITORY/releases/$release_id" \
|
||||
"repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_VERSION" \
|
||||
--jq .immutable
|
||||
)"
|
||||
if [ "$is_immutable" != "true" ]; then
|
||||
echo "Published release is mutable; refusing to reuse its assets." >&2
|
||||
exit 1
|
||||
fi
|
||||
remote_body="$(
|
||||
gh api -H 'X-GitHub-Api-Version: 2026-03-10' \
|
||||
"repos/$GITHUB_REPOSITORY/releases/$release_id" \
|
||||
--jq .body
|
||||
)"
|
||||
remote_body="$(gh release view "$RELEASE_VERSION" --json body --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
|
||||
@@ -1008,9 +950,11 @@ jobs:
|
||||
fi
|
||||
published_dir="$RUNNER_TEMP/published-release"
|
||||
mkdir -p "$published_dir"
|
||||
DWS_GITHUB_RELEASE_ID="$release_id" \
|
||||
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/download-github-release-assets.sh" \
|
||||
"$RELEASE_VERSION" "$published_dir"
|
||||
gh release download "$RELEASE_VERSION" \
|
||||
--dir "$published_dir" \
|
||||
--pattern 'dws-*' \
|
||||
--pattern 'checksums.txt' \
|
||||
--clobber
|
||||
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
|
||||
@@ -1023,10 +967,12 @@ 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" \
|
||||
@@ -1035,63 +981,6 @@ 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 \
|
||||
@@ -1099,24 +988,15 @@ jobs:
|
||||
dist/dws-skills.zip \
|
||||
dist/checksums.txt \
|
||||
--clobber
|
||||
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"
|
||||
./scripts/release/verify-github-release-assets.sh "$RELEASE_VERSION"
|
||||
sealed_upload="$RUNNER_TEMP/sealed-upload"
|
||||
rm -rf "$sealed_upload"
|
||||
mkdir -p "$sealed_upload"
|
||||
DWS_GITHUB_RELEASE_ID="$release_id" \
|
||||
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/download-github-release-assets.sh" \
|
||||
"$RELEASE_VERSION" "$sealed_upload"
|
||||
gh release download "$RELEASE_VERSION" \
|
||||
--dir "$sealed_upload" \
|
||||
--pattern 'dws-*' \
|
||||
--pattern 'checksums.txt' \
|
||||
--clobber
|
||||
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
|
||||
@@ -1126,76 +1006,23 @@ jobs:
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
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"
|
||||
gh release edit "$RELEASE_VERSION" --draft=false
|
||||
|
||||
- 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
|
||||
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
|
||||
}
|
||||
release_state="$(
|
||||
immutable="$(
|
||||
gh api -H 'X-GitHub-Api-Version: 2026-03-10' \
|
||||
"repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID" \
|
||||
--jq '[.tag_name, .draft, .prerelease, .immutable] | @tsv'
|
||||
"repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_VERSION" \
|
||||
--jq .immutable
|
||||
)"
|
||||
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
|
||||
test "$immutable" = true || {
|
||||
echo "GitHub Release is not immutable; refusing downstream publication." >&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
|
||||
./scripts/release/verify-github-release-assets.sh "$RELEASE_VERSION"
|
||||
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/verify-github-tag-authority.sh" \
|
||||
"$RELEASE_VERSION" "$RELEASE_COMMIT" "$RELEASE_TAG_OBJECT"
|
||||
|
||||
@@ -1459,7 +1286,7 @@ jobs:
|
||||
|
||||
mirror-gitee-release:
|
||||
name: Mirror immutable release to Gitee
|
||||
if: ${{ !cancelled() && vars.ENABLE_GITEE_UPLOAD_FALLBACK == 'true' && needs.release-contract.result == 'success' && needs.release.result == 'success' && needs.publish-channels.result == 'success' }}
|
||||
if: ${{ vars.ENABLE_GITEE_UPLOAD_FALLBACK == 'true' && !cancelled() && 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
|
||||
@@ -1794,104 +1621,3 @@ 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
|
||||
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 }}
|
||||
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_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_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
|
||||
;;
|
||||
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
|
||||
;;
|
||||
*)
|
||||
echo "unsupported release mode: event=$EVENT_NAME mode=$DISPATCH_MODE" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
+1
-1
@@ -13,8 +13,8 @@ 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.
|
||||
- **Tag-push publication after the recovery refactor** — the guarded-recovery jobs are skipped on ordinary tag pushes, and the downstream build/sign/publish jobs relied on an implicit `success()` that treats those skipped ancestors as a chain failure, so pushing an annotated release tag validated the contract but silently skipped every publish job. The `release`, `verify-darwin-signatures`, `publish-release`, `publish-channels`, and `mirror-gitee-release` gates now use an explicit `!cancelled()` status check alongside their existing `result == 'success'` conditions, restoring direct tag-push delivery while keeping protected recovery as the failure break-glass.
|
||||
|
||||
## [1.0.53-beta.4] - 2026-07-17
|
||||
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||
TAG="${1:-}"
|
||||
DEST_DIR="${2:-}"
|
||||
REPOSITORY="${GITHUB_REPOSITORY:-}"
|
||||
RELEASE_ID="${DWS_GITHUB_RELEASE_ID:-}"
|
||||
|
||||
[ -n "$TAG" ] && [ -n "$DEST_DIR" ] && [ -n "$REPOSITORY" ] || {
|
||||
printf 'usage: GITHUB_REPOSITORY=owner/repo [DWS_GITHUB_RELEASE_ID=id] download-github-release-assets.sh <tag> <destination>\n' >&2
|
||||
exit 2
|
||||
}
|
||||
[ -n "$RELEASE_ID" ] || {
|
||||
printf 'DWS_GITHUB_RELEASE_ID is required for an exact release download\n' >&2
|
||||
exit 2
|
||||
}
|
||||
printf '%s\n' "$RELEASE_ID" | grep -Eq '^[1-9][0-9]*$' || {
|
||||
printf 'invalid GitHub Release ID: %s\n' "$RELEASE_ID" >&2
|
||||
exit 2
|
||||
}
|
||||
command -v gh >/dev/null 2>&1 || { printf 'gh is required\n' >&2; exit 1; }
|
||||
|
||||
DWS_GITHUB_RELEASE_ID="$RELEASE_ID" \
|
||||
"$SCRIPT_DIR/verify-github-release-assets.sh" "$TAG"
|
||||
|
||||
tmp="$(mktemp -d "${TMPDIR:-/tmp}/dws-github-release-download.XXXXXX")"
|
||||
trap 'rm -rf "$tmp"' EXIT HUP INT TERM
|
||||
tab="$(printf '\t')"
|
||||
cat > "$tmp/expected" <<'EOF'
|
||||
checksums.txt
|
||||
dws-darwin-amd64.tar.gz
|
||||
dws-darwin-arm64.tar.gz
|
||||
dws-linux-amd64.tar.gz
|
||||
dws-linux-arm64.tar.gz
|
||||
dws-skills.zip
|
||||
dws-windows-amd64.zip
|
||||
dws-windows-arm64.zip
|
||||
EOF
|
||||
LC_ALL=C sort "$tmp/expected" -o "$tmp/expected"
|
||||
gh api -H 'Accept: application/vnd.github+json' \
|
||||
"repos/$REPOSITORY/releases/$RELEASE_ID" \
|
||||
--jq '.assets[] | [.id, .name] | @tsv' > "$tmp/assets.tsv"
|
||||
cut -f 2 "$tmp/assets.tsv" | LC_ALL=C sort > "$tmp/actual"
|
||||
if ! diff -u "$tmp/expected" "$tmp/actual"; then
|
||||
printf 'GitHub Release %s must contain exactly the supported assets\n' "$TAG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
while IFS="$tab" read -r asset_id asset_name; do
|
||||
printf '%s\n' "$asset_id" | grep -Eq '^[1-9][0-9]*$' || {
|
||||
printf 'invalid GitHub Release asset ID: %s\n' "$asset_id" >&2
|
||||
exit 1
|
||||
}
|
||||
case "$asset_name" in
|
||||
checksums.txt | \
|
||||
dws-darwin-amd64.tar.gz | dws-darwin-arm64.tar.gz | \
|
||||
dws-linux-amd64.tar.gz | dws-linux-arm64.tar.gz | \
|
||||
dws-windows-amd64.zip | dws-windows-arm64.zip | \
|
||||
dws-skills.zip) ;;
|
||||
*)
|
||||
printf 'unsupported GitHub Release asset name: %s\n' "$asset_name" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
gh api -H 'Accept: application/octet-stream' \
|
||||
"repos/$REPOSITORY/releases/assets/$asset_id" > "$tmp/$asset_name"
|
||||
done < "$tmp/assets.tsv"
|
||||
|
||||
mkdir -p "$DEST_DIR"
|
||||
for asset_name in \
|
||||
checksums.txt \
|
||||
dws-darwin-amd64.tar.gz \
|
||||
dws-darwin-arm64.tar.gz \
|
||||
dws-linux-amd64.tar.gz \
|
||||
dws-linux-arm64.tar.gz \
|
||||
dws-windows-amd64.zip \
|
||||
dws-windows-arm64.zip \
|
||||
dws-skills.zip; do
|
||||
[ -f "$tmp/$asset_name" ] || {
|
||||
printf 'GitHub Release asset was not downloaded: %s\n' "$asset_name" >&2
|
||||
exit 1
|
||||
}
|
||||
mv -f "$tmp/$asset_name" "$DEST_DIR/$asset_name"
|
||||
done
|
||||
|
||||
printf 'GitHub Release assets downloaded and verified: %s\n' "$TAG"
|
||||
@@ -16,7 +16,7 @@ usage() {
|
||||
cat >&2 <<'EOF'
|
||||
usage: dws-release [version] [options]
|
||||
dws-release config [--remote <name>]
|
||||
dws-release recover <version> [--failed-run <run-id>] [--failed-attempt <attempt>] [--remote <name>]
|
||||
dws-release recover <version> [--failed-run <run-id>] [--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,7 +200,6 @@ 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)
|
||||
@@ -208,11 +207,6 @@ 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"
|
||||
@@ -230,9 +224,6 @@ recover_release() {
|
||||
if [ -n "$recovery_failed_run" ]; then
|
||||
set -- "$@" --failed-run "$recovery_failed_run"
|
||||
fi
|
||||
if [ -n "$recovery_failed_attempt" ]; then
|
||||
set -- "$@" --failed-attempt "$recovery_failed_attempt"
|
||||
fi
|
||||
exec "$SCRIPT_DIR/recover-release.sh" "$@"
|
||||
}
|
||||
|
||||
|
||||
@@ -8,12 +8,11 @@ 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>] [--failed-attempt <attempt>]
|
||||
usage: recover-release.sh <version> --remote <name> [--failed-run <run-id>]
|
||||
|
||||
Recovers one failed existing release tag through the protected default-branch
|
||||
Release workflow. It never creates, moves, or deletes a tag.
|
||||
@@ -26,7 +25,6 @@ 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
|
||||
@@ -41,46 +39,8 @@ 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
|
||||
@@ -126,81 +86,27 @@ git merge-base --is-ancestor "$commit" "refs/remotes/$REMOTE/main" || {
|
||||
}
|
||||
|
||||
if [ -z "$FAILED_RUN_ID" ]; then
|
||||
candidate_runs="$(
|
||||
failed_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\") | .id"
|
||||
--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"
|
||||
)" || {
|
||||
printf 'could not query Release runs for %s\n' "$VERSION" >&2
|
||||
printf 'could not query failed Release runs for %s\n' "$VERSION" >&2
|
||||
exit 1
|
||||
}
|
||||
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
|
||||
FAILED_RUN_ID="$(printf '%s\n' "$failed_runs" | sed -n '1p')"
|
||||
[ -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/attempts/%s\n' \
|
||||
"$EXPECTED_REPOSITORY" "$FAILED_RUN_ID" "$FAILED_RUN_ATTEMPT"
|
||||
printf ' failed run: https://github.com/%s/actions/runs/%s\n' "$EXPECTED_REPOSITORY" "$FAILED_RUN_ID"
|
||||
[ -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
|
||||
@@ -215,7 +121,6 @@ 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,32 +3,13 @@ set -eu
|
||||
|
||||
TAG="${1:-}"
|
||||
REPOSITORY="${GITHUB_REPOSITORY:-}"
|
||||
RELEASE_ID="${DWS_GITHUB_RELEASE_ID:-}"
|
||||
|
||||
[ -n "$TAG" ] && [ -n "$REPOSITORY" ] || {
|
||||
printf 'usage: GITHUB_REPOSITORY=owner/repo [DWS_GITHUB_RELEASE_ID=id] verify-github-release-assets.sh <tag>\n' >&2
|
||||
printf 'usage: GITHUB_REPOSITORY=owner/repo 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'
|
||||
@@ -43,17 +24,8 @@ 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' \
|
||||
"$release_endpoint" \
|
||||
"repos/$REPOSITORY/releases/tags/$TAG" \
|
||||
--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
|
||||
|
||||
@@ -733,7 +733,6 @@ 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)`,
|
||||
@@ -746,9 +745,6 @@ 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`,
|
||||
@@ -799,221 +795,6 @@ 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)
|
||||
start := strings.Index(workflow, " release-delivery-gate:\n")
|
||||
if start == -1 {
|
||||
t.Fatal("release workflow is missing the terminal delivery gate")
|
||||
}
|
||||
gate := workflow[start:]
|
||||
|
||||
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",
|
||||
"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",
|
||||
"unsupported release mode",
|
||||
} {
|
||||
if !strings.Contains(gate, required) {
|
||||
t.Errorf("release delivery gate is missing %q", required)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseWorkflowUploadsPostProcessedDarwinAssets(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -1178,7 +959,7 @@ func TestReleaseWorkflowUsesAppleCodesignBeforePublication(t *testing.T) {
|
||||
}
|
||||
|
||||
codesign := strings.Index(workflow[verifyJob:publishJob], "codesign --verify --strict --verbose=4")
|
||||
publish := strings.Index(workflow[publishJob:], "-F draft=false")
|
||||
publish := strings.Index(workflow[publishJob:], `gh release edit "$RELEASE_VERSION" --draft=false`)
|
||||
if codesign == -1 || publish == -1 {
|
||||
t.Fatal("macOS codesign verification and explicit Draft publication are required")
|
||||
}
|
||||
|
||||
@@ -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", "--failed-attempt", "2")
|
||||
output, err := f.run(t, nil, "recover", "v1.0.1-beta.1", "--failed-run", "12345")
|
||||
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\t--failed-attempt\t2\n"
|
||||
want := "recover\tv1.0.1-beta.1\t--remote\torigin\t--failed-run\t12345\n"
|
||||
if got := f.callLog(t); got != want {
|
||||
t.Fatalf("recovery delegate calls = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
@@ -259,25 +259,7 @@ func TestReleaseGitHubAssetSetIsExact(t *testing.T) {
|
||||
}
|
||||
binDir := t.TempDir()
|
||||
fakeGH := filepath.Join(binDir, "gh")
|
||||
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)
|
||||
mustWriteFile(t, fakeGH, []byte("#!/bin/sh\nset -eu\nprintf '%s\\n' \"$GH_ASSETS\"\n"), 0o755)
|
||||
exact := strings.Join([]string{
|
||||
"dws-windows-arm64.zip",
|
||||
"dws-linux-amd64.tar.gz",
|
||||
@@ -289,218 +271,23 @@ esac
|
||||
"dws-darwin-arm64.tar.gz",
|
||||
}, "\n")
|
||||
script := filepath.Join(sourceRoot, "scripts", "release", "verify-github-release-assets.sh")
|
||||
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")
|
||||
run := func(assets string) (string, error) {
|
||||
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()
|
||||
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
|
||||
return string(output), err
|
||||
}
|
||||
if output, _, err := run(exact, false, "v1.2.3", ""); err != nil {
|
||||
if output, err := run(exact); err != nil {
|
||||
t.Fatalf("exact GitHub assets rejected: %v\n%s", err, output)
|
||||
}
|
||||
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") {
|
||||
if output, err := run(exact + "\nmalware.exe"); err == nil || !strings.Contains(output, "exactly the supported assets") {
|
||||
t.Fatalf("extra GitHub asset was not rejected: err=%v\n%s", err, output)
|
||||
}
|
||||
if output, _, err := run(exact, true, "v9.9.9", ""); err == nil || !strings.Contains(output, "ID/tag mismatch") {
|
||||
t.Fatalf("wrong Draft release ID target was not rejected: err=%v\n%s", err, output)
|
||||
}
|
||||
if output, _, err := run(exact, false, "v1.2.3", "invalid"); err == nil || !strings.Contains(output, "invalid GitHub Release ID") {
|
||||
t.Fatalf("invalid explicit release ID was not rejected: err=%v\n%s", err, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseGitHubAssetsDownloadByExactReleaseID(t *testing.T) {
|
||||
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(repo root) error = %v", err)
|
||||
}
|
||||
binDir := t.TempDir()
|
||||
fakeGH := filepath.Join(binDir, "gh")
|
||||
mustWriteFile(t, fakeGH, []byte(`#!/bin/sh
|
||||
set -eu
|
||||
[ "${1:-}" = "api" ] || exit 2
|
||||
shift
|
||||
|
||||
accept=""
|
||||
endpoint=""
|
||||
query=""
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
-H)
|
||||
accept="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--jq)
|
||||
query="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
repos/*)
|
||||
endpoint="$1"
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
printf '%s|%s|%s\n' "$accept" "$endpoint" "$query" >> "$GH_LOG"
|
||||
|
||||
case "$accept" in
|
||||
*application/octet-stream*)
|
||||
case "$endpoint" in
|
||||
repos/owner/repo/releases/assets/*) ;;
|
||||
*) exit 31 ;;
|
||||
esac
|
||||
asset_id="${endpoint##*/}"
|
||||
asset_name="$(
|
||||
awk -F ' ' -v id="$asset_id" '
|
||||
$1 == id { print $2; found = 1 }
|
||||
END { if (!found) exit 1 }
|
||||
' "$GH_ASSET_ROWS"
|
||||
)" || exit 32
|
||||
printf 'asset-bytes:%s:%s\n' "$asset_id" "$asset_name"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
[ "$endpoint" = "repos/owner/repo/releases/$GH_EXPECTED_RELEASE_ID" ] || exit 33
|
||||
case "$query" in
|
||||
'.tag_name')
|
||||
printf '%s\n' "$GH_RELEASE_TAG"
|
||||
;;
|
||||
'.assets[].name')
|
||||
cut -f 2 "$GH_ASSET_ROWS"
|
||||
;;
|
||||
'.assets[] | [.id, .name] | @tsv')
|
||||
cat "$GH_ASSET_ROWS"
|
||||
;;
|
||||
*)
|
||||
exit 34
|
||||
;;
|
||||
esac
|
||||
`), 0o755)
|
||||
|
||||
type releaseAsset struct {
|
||||
id string
|
||||
name string
|
||||
}
|
||||
assets := []releaseAsset{
|
||||
{id: "1008", name: "dws-windows-arm64.zip"},
|
||||
{id: "1003", name: "dws-darwin-arm64.tar.gz"},
|
||||
{id: "1001", name: "checksums.txt"},
|
||||
{id: "1005", name: "dws-linux-arm64.tar.gz"},
|
||||
{id: "1002", name: "dws-darwin-amd64.tar.gz"},
|
||||
{id: "1007", name: "dws-windows-amd64.zip"},
|
||||
{id: "1004", name: "dws-linux-amd64.tar.gz"},
|
||||
{id: "1006", name: "dws-skills.zip"},
|
||||
}
|
||||
rowsFor := func(items []releaseAsset) string {
|
||||
var rows []string
|
||||
for _, asset := range items {
|
||||
rows = append(rows, asset.id+"\t"+asset.name)
|
||||
}
|
||||
return strings.Join(rows, "\n") + "\n"
|
||||
}
|
||||
script := filepath.Join(sourceRoot, "scripts", "release", "download-github-release-assets.sh")
|
||||
type runResult struct {
|
||||
output string
|
||||
log string
|
||||
dest string
|
||||
err error
|
||||
}
|
||||
run := func(items []releaseAsset, releaseTag, releaseID string) runResult {
|
||||
runDir := t.TempDir()
|
||||
rowsPath := filepath.Join(runDir, "assets.tsv")
|
||||
logPath := filepath.Join(runDir, "gh.log")
|
||||
dest := filepath.Join(runDir, "download")
|
||||
mustWriteFile(t, rowsPath, []byte(rowsFor(items)), 0o644)
|
||||
cmd := exec.Command("sh", script, "v1.2.3", dest)
|
||||
cmd.Env = []string{
|
||||
"PATH=" + binDir + string(os.PathListSeparator) + os.Getenv("PATH"),
|
||||
"HOME=" + t.TempDir(),
|
||||
"GITHUB_REPOSITORY=owner/repo",
|
||||
"DWS_GITHUB_RELEASE_ID=" + releaseID,
|
||||
"GH_ASSET_ROWS=" + rowsPath,
|
||||
"GH_EXPECTED_RELEASE_ID=456",
|
||||
"GH_LOG=" + logPath,
|
||||
"GH_RELEASE_TAG=" + releaseTag,
|
||||
}
|
||||
output, runErr := cmd.CombinedOutput()
|
||||
log, readErr := os.ReadFile(logPath)
|
||||
if readErr != nil && !os.IsNotExist(readErr) {
|
||||
t.Fatalf("ReadFile(%s) error = %v", logPath, readErr)
|
||||
}
|
||||
return runResult{
|
||||
output: string(output),
|
||||
log: string(log),
|
||||
dest: dest,
|
||||
err: runErr,
|
||||
}
|
||||
}
|
||||
|
||||
success := run(assets, "v1.2.3", "456")
|
||||
if success.err != nil {
|
||||
t.Fatalf("exact GitHub Release download was rejected: %v\n%s", success.err, success.output)
|
||||
}
|
||||
if strings.Contains(success.log, "releases/tags/") {
|
||||
t.Fatalf("download fell back to a tag endpoint:\n%s", success.log)
|
||||
}
|
||||
if got := strings.Count(success.log, "application/octet-stream"); got != len(assets) {
|
||||
t.Fatalf("asset download count = %d, want %d\nlog:\n%s", got, len(assets), success.log)
|
||||
}
|
||||
for _, asset := range assets {
|
||||
wantEndpoint := "repos/owner/repo/releases/assets/" + asset.id
|
||||
if !strings.Contains(success.log, wantEndpoint) {
|
||||
t.Errorf("asset %s was not downloaded by ID %s\nlog:\n%s", asset.name, asset.id, success.log)
|
||||
}
|
||||
data, readErr := os.ReadFile(filepath.Join(success.dest, asset.name))
|
||||
if readErr != nil {
|
||||
t.Errorf("ReadFile(%s) error = %v", asset.name, readErr)
|
||||
continue
|
||||
}
|
||||
want := fmt.Sprintf("asset-bytes:%s:%s\n", asset.id, asset.name)
|
||||
if string(data) != want {
|
||||
t.Errorf("%s bytes = %q, want %q", asset.name, data, want)
|
||||
}
|
||||
}
|
||||
|
||||
extra := append(append([]releaseAsset{}, assets...), releaseAsset{id: "1009", name: "malware.exe"})
|
||||
if result := run(extra, "v1.2.3", "456"); result.err == nil || !strings.Contains(result.output, "exactly the supported assets") {
|
||||
t.Fatalf("extra GitHub Release asset was not rejected: err=%v\n%s", result.err, result.output)
|
||||
}
|
||||
if result := run(assets[:len(assets)-1], "v1.2.3", "456"); result.err == nil || !strings.Contains(result.output, "exactly the supported assets") {
|
||||
t.Fatalf("missing GitHub Release asset was not rejected: err=%v\n%s", result.err, result.output)
|
||||
}
|
||||
if result := run(assets, "v9.9.9", "456"); result.err == nil || !strings.Contains(result.output, "ID/tag mismatch") {
|
||||
t.Fatalf("wrong GitHub Release tag was not rejected: err=%v\n%s", result.err, result.output)
|
||||
}
|
||||
if result := run(assets, "v1.2.3", "invalid"); result.err == nil || !strings.Contains(result.output, "invalid GitHub Release ID") {
|
||||
t.Fatalf("invalid GitHub Release ID was not rejected: err=%v\n%s", result.err, result.output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseDeliveredStableRequiresSuccessfulPublicDelivery(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user