Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b50cc41c5 | ||
|
|
11e50662f9 | ||
|
|
29abdb6e79 | ||
|
|
6196e2565e | ||
|
|
609d56305e | ||
|
|
e69a1084a7 |
+381
-35
@@ -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
|
||||
@@ -51,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
|
||||
@@ -70,6 +74,7 @@ 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 }}
|
||||
@@ -81,22 +86,26 @@ jobs:
|
||||
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
|
||||
@@ -929,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 }}
|
||||
@@ -941,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
|
||||
@@ -951,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
|
||||
@@ -976,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
|
||||
@@ -993,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" \
|
||||
@@ -1007,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 \
|
||||
@@ -1014,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
|
||||
@@ -1032,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"
|
||||
|
||||
@@ -1662,6 +1818,7 @@ jobs:
|
||||
- publish-channels
|
||||
- mirror-gitee-release
|
||||
- repair-npm
|
||||
- repair-gitee
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 2
|
||||
permissions: {}
|
||||
@@ -1681,6 +1838,7 @@ jobs:
|
||||
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() {
|
||||
@@ -1710,6 +1868,7 @@ jobs:
|
||||
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)
|
||||
@@ -1717,6 +1876,7 @@ jobs:
|
||||
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)
|
||||
@@ -1730,6 +1890,7 @@ jobs:
|
||||
require_result publish-channels "$PUBLISH_CHANNELS_RESULT" skipped
|
||||
require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped
|
||||
require_result repair-npm "$REPAIR_NPM_RESULT" skipped
|
||||
require_result repair-gitee "$REPAIR_GITEE_RESULT" skipped
|
||||
;;
|
||||
workflow_dispatch:repair_npm)
|
||||
require_result dispatch-contract "$DISPATCH_RESULT" success
|
||||
@@ -1742,9 +1903,194 @@ jobs:
|
||||
require_result publish-release "$PUBLISH_RELEASE_RESULT" skipped
|
||||
require_result publish-channels "$PUBLISH_CHANNELS_RESULT" skipped
|
||||
require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped
|
||||
require_result repair-gitee "$REPAIR_GITEE_RESULT" skipped
|
||||
;;
|
||||
workflow_dispatch:repair_gitee)
|
||||
require_result dispatch-contract "$DISPATCH_RESULT" success
|
||||
require_result repair-gitee "$REPAIR_GITEE_RESULT" success
|
||||
require_result authorize-recovery "$AUTHORIZE_RECOVERY_RESULT" skipped
|
||||
require_result governance-preflight "$GOVERNANCE_PREFLIGHT_RESULT" skipped
|
||||
require_result release-contract "$RELEASE_CONTRACT_RESULT" skipped
|
||||
require_result release "$RELEASE_RESULT" skipped
|
||||
require_result verify-darwin-signatures "$DARWIN_SIGNATURE_RESULT" skipped
|
||||
require_result publish-release "$PUBLISH_RELEASE_RESULT" skipped
|
||||
require_result publish-channels "$PUBLISH_CHANNELS_RESULT" skipped
|
||||
require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped
|
||||
require_result repair-npm "$REPAIR_NPM_RESULT" skipped
|
||||
;;
|
||||
*)
|
||||
echo "unsupported release mode: event=$EVENT_NAME mode=$DISPATCH_MODE" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
# Mirror an existing public immutable release's assets to Gitee on demand. The
|
||||
# push-triggered mirror-gitee-release job depends on the same run's finalized
|
||||
# dist artifact, so a tag that was already published (including one delivered
|
||||
# by a recovery dispatch) cannot reuse it. This dispatch path re-derives the
|
||||
# asset set from the immutable GitHub Release itself and mirrors it. The
|
||||
# release tag and asset checksums remain the sealed authority boundary.
|
||||
repair-gitee:
|
||||
name: Mirror an existing immutable release to Gitee
|
||||
needs: dispatch-contract
|
||||
if: ${{ !cancelled() && needs.dispatch-contract.result == 'success' && needs.dispatch-contract.outputs.mode == 'repair_gitee' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && github.repository == 'DingTalk-Real-AI/dingtalk-workspace-cli' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
steps:
|
||||
- name: Check out trusted release tooling
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
path: tooling
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Validate repair version
|
||||
working-directory: tooling
|
||||
env:
|
||||
VERSION: ${{ inputs.repair_gitee_version }}
|
||||
run: |
|
||||
set -eu
|
||||
. ./scripts/release/release-lib.sh
|
||||
if ! release_is_stable_version "$VERSION" && ! release_is_prerelease_version "$VERSION"; then
|
||||
echo "Invalid release version: $VERSION" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
- name: Verify immutable release authority
|
||||
id: authority
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
VERSION: ${{ inputs.repair_gitee_version }}
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const version = process.env.VERSION;
|
||||
const ref = await github.rest.git.getRef({
|
||||
owner,
|
||||
repo,
|
||||
ref: `tags/${version}`,
|
||||
});
|
||||
if (ref.data.object.type !== "tag") {
|
||||
core.setFailed(`${version} is not an annotated tag`);
|
||||
return;
|
||||
}
|
||||
const tag = await github.rest.git.getTag({
|
||||
owner,
|
||||
repo,
|
||||
tag_sha: ref.data.object.sha,
|
||||
});
|
||||
if (tag.data.tag !== version || tag.data.object.type !== "commit") {
|
||||
core.setFailed(`${version} does not resolve to one annotated commit tag`);
|
||||
return;
|
||||
}
|
||||
const commitSha = tag.data.object.sha;
|
||||
const defaultBranch = context.payload.repository.default_branch;
|
||||
const branch = await github.rest.repos.getBranch({
|
||||
owner,
|
||||
repo,
|
||||
branch: defaultBranch,
|
||||
});
|
||||
const comparison = await github.rest.repos.compareCommitsWithBasehead({
|
||||
owner,
|
||||
repo,
|
||||
basehead: `${commitSha}...${branch.data.commit.sha}`,
|
||||
});
|
||||
if (!["ahead", "identical"].includes(comparison.data.status)) {
|
||||
core.setFailed(`${version} is not contained in ${defaultBranch}`);
|
||||
return;
|
||||
}
|
||||
const release = await github.rest.repos.getReleaseByTag({
|
||||
owner,
|
||||
repo,
|
||||
tag: version,
|
||||
});
|
||||
if (
|
||||
release.data.tag_name !== version ||
|
||||
release.data.draft ||
|
||||
!release.data.immutable
|
||||
) {
|
||||
core.setFailed(`${version} is not a public immutable GitHub Release`);
|
||||
return;
|
||||
}
|
||||
const expectedPrerelease = version.includes("-beta.");
|
||||
if (release.data.prerelease !== expectedPrerelease) {
|
||||
core.setFailed(`${version} prerelease metadata does not match its version`);
|
||||
return;
|
||||
}
|
||||
const expectedAssets = new Set([
|
||||
"dws-darwin-amd64.tar.gz",
|
||||
"dws-darwin-arm64.tar.gz",
|
||||
"dws-linux-amd64.tar.gz",
|
||||
"dws-linux-arm64.tar.gz",
|
||||
"dws-windows-amd64.zip",
|
||||
"dws-windows-arm64.zip",
|
||||
"dws-skills.zip",
|
||||
"checksums.txt",
|
||||
]);
|
||||
const assetNames = release.data.assets.map((asset) => asset.name);
|
||||
if (
|
||||
assetNames.length !== expectedAssets.size ||
|
||||
assetNames.some((name) => !expectedAssets.has(name)) ||
|
||||
new Set(assetNames).size !== expectedAssets.size
|
||||
) {
|
||||
core.setFailed(`${version} does not contain the exact release asset set`);
|
||||
return;
|
||||
}
|
||||
core.setOutput("commit_sha", commitSha);
|
||||
core.setOutput("tag_object", ref.data.object.sha);
|
||||
|
||||
- name: Fetch and verify sealed release tag
|
||||
working-directory: tooling
|
||||
env:
|
||||
VERSION: ${{ inputs.repair_gitee_version }}
|
||||
RELEASE_COMMIT: ${{ steps.authority.outputs.commit_sha }}
|
||||
RELEASE_TAG_OBJECT: ${{ steps.authority.outputs.tag_object }}
|
||||
run: |
|
||||
set -eu
|
||||
git fetch --force --no-tags \
|
||||
https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git \
|
||||
'+refs/tags/v*:refs/tags/v*'
|
||||
./scripts/release/verify-github-tag-authority.sh \
|
||||
"$VERSION" "$RELEASE_COMMIT" "$RELEASE_TAG_OBJECT"
|
||||
|
||||
- name: Require successful Release workflow delivery
|
||||
working-directory: tooling
|
||||
env:
|
||||
VERSION: ${{ inputs.repair_gitee_version }}
|
||||
RELEASE_COMMIT: ${{ steps.authority.outputs.commit_sha }}
|
||||
DWS_RELEASE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
./scripts/release/verify-release-workflow-delivery.sh \
|
||||
"$VERSION" "$RELEASE_COMMIT"
|
||||
|
||||
- name: Download and verify immutable GitHub Release assets
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ inputs.repair_gitee_version }}
|
||||
run: |
|
||||
set -eu
|
||||
mkdir -p dist
|
||||
gh release download "$VERSION" \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--dir dist \
|
||||
--pattern 'dws-*' \
|
||||
--pattern 'checksums.txt' \
|
||||
--clobber
|
||||
DWS_PACKAGE_DIST_DIR="$GITHUB_WORKSPACE/dist" \
|
||||
"$GITHUB_WORKSPACE/tooling/scripts/release/verify-release-artifacts.sh" "$VERSION"
|
||||
|
||||
- name: Mirror release to Gitee (China)
|
||||
timeout-minutes: 100
|
||||
working-directory: tooling
|
||||
run: |
|
||||
"$GITHUB_WORKSPACE/tooling/scripts/release/sync-to-gitee.sh"
|
||||
env:
|
||||
VERSION: ${{ inputs.repair_gitee_version }}
|
||||
DIST_DIR: ${{ github.workspace }}/dist
|
||||
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
|
||||
GITEE_USER: ${{ secrets.GITEE_USER }}
|
||||
GITEE_REPO: ${{ secrets.GITEE_REPO }}
|
||||
DWS_REQUIRE_GITEE: "1"
|
||||
|
||||
@@ -13,6 +13,7 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Tag-push GitHub Release publication** — Draft publication now locks one GitHub Release database ID, verifies its exact tag, channel, notes, recovery marker, asset set, and uploaded bytes, then publishes and rechecks that same ID as immutable. Recovery runs use the trusted default-branch release helpers instead of the sealed tag's historical scripts, fixing the Draft-only `GET /releases/tags/{tag}` 404 without allowing the release identity to drift during recovery.
|
||||
- **Release preflight reliability** — source-mode installer tests now use isolated temporary checkouts and HOME directories instead of overwriting and deleting the real repository `dws` binary, release preflight explicitly rebuilds before policy checks, and the full-suite runner gives the growing script package a non-flaky five-minute per-suite budget.
|
||||
|
||||
## [1.0.53-beta.4] - 2026-07-17
|
||||
|
||||
@@ -377,9 +377,11 @@ func TestCrossPlatformCoverageDaemonMethodEdges(t *testing.T) {
|
||||
d.conns.Store("not-a-conn", struct{}{})
|
||||
d.conns.Store(&queryConn{}, struct{}{})
|
||||
d.consumerWG.Add(1)
|
||||
d.shutdown()
|
||||
acceptDone := make(chan struct{})
|
||||
close(acceptDone)
|
||||
d.shutdown(acceptDone)
|
||||
d.consumerWG.Done()
|
||||
d.shutdown()
|
||||
d.shutdown(acceptDone)
|
||||
|
||||
pr, pw, err := os.Pipe()
|
||||
if err != nil {
|
||||
|
||||
@@ -246,10 +246,11 @@ func Run(ctx context.Context, cfg Config) error {
|
||||
}
|
||||
|
||||
// 7. Graceful shutdown — cancel runCtx first so all background
|
||||
// goroutines wake up, then close listener / drain consumers.
|
||||
// goroutines wake up, then stop accepting connections before draining
|
||||
// consumers. The accept-loop barrier is required before WaitGroup.Wait:
|
||||
// sync.WaitGroup forbids a positive Add racing with Wait.
|
||||
cancelRun()
|
||||
d.shutdown()
|
||||
<-acceptDone
|
||||
d.shutdown(acceptDone)
|
||||
<-idleDone
|
||||
<-dropWarnDone
|
||||
|
||||
@@ -291,6 +292,10 @@ func (d *daemon) acceptLoop(ctx context.Context) {
|
||||
d.log.Warn("bus: accept error", "err", err)
|
||||
continue
|
||||
}
|
||||
// Track the connection before publishing the handler goroutine. Once
|
||||
// acceptLoop returns, shutdown can therefore close every accepted
|
||||
// connection before waiting for handlers to drain.
|
||||
d.conns.Store(conn, struct{}{})
|
||||
d.consumerWG.Add(1)
|
||||
go func() {
|
||||
defer d.consumerWG.Done()
|
||||
@@ -303,7 +308,6 @@ func (d *daemon) acceptLoop(ctx context.Context) {
|
||||
// Hello → register with Hub → spawn writer goroutine → read until EOF/Bye.
|
||||
// Always Unregisters and Closes on exit (plan invariant #5).
|
||||
func (d *daemon) handleConnection(ctx context.Context, conn net.Conn) {
|
||||
d.conns.Store(conn, struct{}{})
|
||||
defer func() {
|
||||
d.conns.Delete(conn)
|
||||
conn.Close()
|
||||
@@ -478,9 +482,10 @@ func (d *daemon) triggerShutdown(reason string) {
|
||||
// 1. mark shuttingDown so acceptLoop exits cleanly
|
||||
// 2. broadcast Bye to all consumers
|
||||
// 3. close listener (interrupts pending Accept)
|
||||
// 4. wait for all per-connection goroutines to drain
|
||||
// 5. lock + meta cleanup via Run's defers
|
||||
func (d *daemon) shutdown() {
|
||||
// 4. wait for acceptLoop to return so no future consumerWG.Add can occur
|
||||
// 5. close all accepted connections and wait for handlers to drain
|
||||
// 6. lock + meta cleanup via Run's defers
|
||||
func (d *daemon) shutdown(acceptDone <-chan struct{}) {
|
||||
d.shutdownMu.Lock()
|
||||
defer d.shutdownMu.Unlock()
|
||||
if !d.shuttingDown.CompareAndSwap(false, true) {
|
||||
@@ -488,6 +493,7 @@ func (d *daemon) shutdown() {
|
||||
}
|
||||
d.hub.Broadcast(transport.Bye{Type: transport.FrameTypeBye, Reason: "shutdown"})
|
||||
_ = d.listener.Close()
|
||||
<-acceptDone
|
||||
// Force-close all open IPC connections so any reader goroutine blocked
|
||||
// on Read() returns with a network error and exits cleanly. Without
|
||||
// this the consumerWG never drains and Run hangs forever.
|
||||
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||
TAG="${1:-}"
|
||||
DEST_DIR="${2:-}"
|
||||
REPOSITORY="${GITHUB_REPOSITORY:-}"
|
||||
RELEASE_ID="${DWS_GITHUB_RELEASE_ID:-}"
|
||||
|
||||
[ -n "$TAG" ] && [ -n "$DEST_DIR" ] && [ -n "$REPOSITORY" ] || {
|
||||
printf 'usage: GITHUB_REPOSITORY=owner/repo [DWS_GITHUB_RELEASE_ID=id] download-github-release-assets.sh <tag> <destination>\n' >&2
|
||||
exit 2
|
||||
}
|
||||
[ -n "$RELEASE_ID" ] || {
|
||||
printf 'DWS_GITHUB_RELEASE_ID is required for an exact release download\n' >&2
|
||||
exit 2
|
||||
}
|
||||
printf '%s\n' "$RELEASE_ID" | grep -Eq '^[1-9][0-9]*$' || {
|
||||
printf 'invalid GitHub Release ID: %s\n' "$RELEASE_ID" >&2
|
||||
exit 2
|
||||
}
|
||||
command -v gh >/dev/null 2>&1 || { printf 'gh is required\n' >&2; exit 1; }
|
||||
|
||||
DWS_GITHUB_RELEASE_ID="$RELEASE_ID" \
|
||||
"$SCRIPT_DIR/verify-github-release-assets.sh" "$TAG"
|
||||
|
||||
tmp="$(mktemp -d "${TMPDIR:-/tmp}/dws-github-release-download.XXXXXX")"
|
||||
trap 'rm -rf "$tmp"' EXIT HUP INT TERM
|
||||
tab="$(printf '\t')"
|
||||
cat > "$tmp/expected" <<'EOF'
|
||||
checksums.txt
|
||||
dws-darwin-amd64.tar.gz
|
||||
dws-darwin-arm64.tar.gz
|
||||
dws-linux-amd64.tar.gz
|
||||
dws-linux-arm64.tar.gz
|
||||
dws-skills.zip
|
||||
dws-windows-amd64.zip
|
||||
dws-windows-arm64.zip
|
||||
EOF
|
||||
LC_ALL=C sort "$tmp/expected" -o "$tmp/expected"
|
||||
gh api -H 'Accept: application/vnd.github+json' \
|
||||
"repos/$REPOSITORY/releases/$RELEASE_ID" \
|
||||
--jq '.assets[] | [.id, .name] | @tsv' > "$tmp/assets.tsv"
|
||||
cut -f 2 "$tmp/assets.tsv" | LC_ALL=C sort > "$tmp/actual"
|
||||
if ! diff -u "$tmp/expected" "$tmp/actual"; then
|
||||
printf 'GitHub Release %s must contain exactly the supported assets\n' "$TAG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
while IFS="$tab" read -r asset_id asset_name; do
|
||||
printf '%s\n' "$asset_id" | grep -Eq '^[1-9][0-9]*$' || {
|
||||
printf 'invalid GitHub Release asset ID: %s\n' "$asset_id" >&2
|
||||
exit 1
|
||||
}
|
||||
case "$asset_name" in
|
||||
checksums.txt | \
|
||||
dws-darwin-amd64.tar.gz | dws-darwin-arm64.tar.gz | \
|
||||
dws-linux-amd64.tar.gz | dws-linux-arm64.tar.gz | \
|
||||
dws-windows-amd64.zip | dws-windows-arm64.zip | \
|
||||
dws-skills.zip) ;;
|
||||
*)
|
||||
printf 'unsupported GitHub Release asset name: %s\n' "$asset_name" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
gh api -H 'Accept: application/octet-stream' \
|
||||
"repos/$REPOSITORY/releases/assets/$asset_id" > "$tmp/$asset_name"
|
||||
done < "$tmp/assets.tsv"
|
||||
|
||||
mkdir -p "$DEST_DIR"
|
||||
for asset_name in \
|
||||
checksums.txt \
|
||||
dws-darwin-amd64.tar.gz \
|
||||
dws-darwin-arm64.tar.gz \
|
||||
dws-linux-amd64.tar.gz \
|
||||
dws-linux-arm64.tar.gz \
|
||||
dws-windows-amd64.zip \
|
||||
dws-windows-arm64.zip \
|
||||
dws-skills.zip; do
|
||||
[ -f "$tmp/$asset_name" ] || {
|
||||
printf 'GitHub Release asset was not downloaded: %s\n' "$asset_name" >&2
|
||||
exit 1
|
||||
}
|
||||
mv -f "$tmp/$asset_name" "$DEST_DIR/$asset_name"
|
||||
done
|
||||
|
||||
printf 'GitHub Release assets downloaded and verified: %s\n' "$TAG"
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -799,6 +879,94 @@ 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"))
|
||||
@@ -890,11 +1058,7 @@ func TestReleaseWorkflowPublicationBypassesSkippedDispatchButStopsOnCancellation
|
||||
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:]
|
||||
gate := releaseWorkflowSection(t, workflow, " release-delivery-gate:\n", "\n repair-gitee:\n")
|
||||
|
||||
for _, required := range []string{
|
||||
"name: Release delivery gate",
|
||||
@@ -909,6 +1073,8 @@ func TestReleaseWorkflowDeliveryGateFailsClosed(t *testing.T) {
|
||||
"- 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`,
|
||||
@@ -918,6 +1084,9 @@ func TestReleaseWorkflowDeliveryGateFailsClosed(t *testing.T) {
|
||||
"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) {
|
||||
@@ -1090,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")
|
||||
}
|
||||
|
||||
@@ -259,7 +259,25 @@ func TestReleaseGitHubAssetSetIsExact(t *testing.T) {
|
||||
}
|
||||
binDir := t.TempDir()
|
||||
fakeGH := filepath.Join(binDir, "gh")
|
||||
mustWriteFile(t, fakeGH, []byte("#!/bin/sh\nset -eu\nprintf '%s\\n' \"$GH_ASSETS\"\n"), 0o755)
|
||||
mustWriteFile(t, fakeGH, []byte(`#!/bin/sh
|
||||
set -eu
|
||||
case "${1:-}" in
|
||||
release)
|
||||
[ "${2:-}" = "view" ] || exit 2
|
||||
[ "${GH_DRAFT:-false}" = "true" ] || exit 1
|
||||
printf '123\n'
|
||||
;;
|
||||
api)
|
||||
printf '%s\n' "$*" >> "$GH_LOG"
|
||||
case "$*" in
|
||||
*tag_name*) printf '%s\n' "$GH_RELEASE_TAG" ;;
|
||||
*assets*name*) printf '%s\n' "$GH_ASSETS" ;;
|
||||
*) exit 2 ;;
|
||||
esac
|
||||
;;
|
||||
*) exit 2 ;;
|
||||
esac
|
||||
`), 0o755)
|
||||
exact := strings.Join([]string{
|
||||
"dws-windows-arm64.zip",
|
||||
"dws-linux-amd64.tar.gz",
|
||||
@@ -271,23 +289,218 @@ func TestReleaseGitHubAssetSetIsExact(t *testing.T) {
|
||||
"dws-darwin-arm64.tar.gz",
|
||||
}, "\n")
|
||||
script := filepath.Join(sourceRoot, "scripts", "release", "verify-github-release-assets.sh")
|
||||
run := func(assets string) (string, error) {
|
||||
run := func(assets string, draft bool, releaseTag, releaseID string) (string, string, error) {
|
||||
draftValue := "false"
|
||||
if draft {
|
||||
draftValue = "true"
|
||||
}
|
||||
logPath := filepath.Join(t.TempDir(), "gh.log")
|
||||
cmd := exec.Command("sh", script, "v1.2.3")
|
||||
cmd.Env = []string{
|
||||
"PATH=" + binDir + string(os.PathListSeparator) + os.Getenv("PATH"),
|
||||
"HOME=" + t.TempDir(),
|
||||
"GITHUB_REPOSITORY=owner/repo",
|
||||
"GH_ASSETS=" + assets,
|
||||
"GH_DRAFT=" + draftValue,
|
||||
"GH_LOG=" + logPath,
|
||||
"GH_RELEASE_TAG=" + releaseTag,
|
||||
"DWS_GITHUB_RELEASE_ID=" + releaseID,
|
||||
}
|
||||
output, err := cmd.CombinedOutput()
|
||||
return string(output), err
|
||||
log, readErr := os.ReadFile(logPath)
|
||||
if readErr != nil && !os.IsNotExist(readErr) {
|
||||
t.Fatalf("ReadFile(%s) error = %v", logPath, readErr)
|
||||
}
|
||||
return string(output), string(log), err
|
||||
}
|
||||
if output, err := run(exact); err != nil {
|
||||
if output, _, err := run(exact, false, "v1.2.3", ""); err != nil {
|
||||
t.Fatalf("exact GitHub assets rejected: %v\n%s", err, output)
|
||||
}
|
||||
if output, err := run(exact + "\nmalware.exe"); err == nil || !strings.Contains(output, "exactly the supported assets") {
|
||||
if output, log, err := run(exact, true, "v1.2.3", ""); err != nil {
|
||||
t.Fatalf("exact Draft GitHub assets rejected: %v\n%s", err, output)
|
||||
} else if !strings.Contains(log, "repos/owner/repo/releases/123") ||
|
||||
strings.Contains(log, "repos/owner/repo/releases/tags/v1.2.3") {
|
||||
t.Fatalf("Draft verification did not use the release ID endpoint:\n%s", log)
|
||||
}
|
||||
if output, _, err := run(exact+"\nmalware.exe", false, "v1.2.3", ""); err == nil || !strings.Contains(output, "exactly the supported assets") {
|
||||
t.Fatalf("extra GitHub asset was not rejected: err=%v\n%s", err, output)
|
||||
}
|
||||
if output, _, err := run(exact, true, "v9.9.9", ""); err == nil || !strings.Contains(output, "ID/tag mismatch") {
|
||||
t.Fatalf("wrong Draft release ID target was not rejected: err=%v\n%s", err, output)
|
||||
}
|
||||
if output, _, err := run(exact, false, "v1.2.3", "invalid"); err == nil || !strings.Contains(output, "invalid GitHub Release ID") {
|
||||
t.Fatalf("invalid explicit release ID was not rejected: err=%v\n%s", err, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseGitHubAssetsDownloadByExactReleaseID(t *testing.T) {
|
||||
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(repo root) error = %v", err)
|
||||
}
|
||||
binDir := t.TempDir()
|
||||
fakeGH := filepath.Join(binDir, "gh")
|
||||
mustWriteFile(t, fakeGH, []byte(`#!/bin/sh
|
||||
set -eu
|
||||
[ "${1:-}" = "api" ] || exit 2
|
||||
shift
|
||||
|
||||
accept=""
|
||||
endpoint=""
|
||||
query=""
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
-H)
|
||||
accept="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--jq)
|
||||
query="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
repos/*)
|
||||
endpoint="$1"
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
printf '%s|%s|%s\n' "$accept" "$endpoint" "$query" >> "$GH_LOG"
|
||||
|
||||
case "$accept" in
|
||||
*application/octet-stream*)
|
||||
case "$endpoint" in
|
||||
repos/owner/repo/releases/assets/*) ;;
|
||||
*) exit 31 ;;
|
||||
esac
|
||||
asset_id="${endpoint##*/}"
|
||||
asset_name="$(
|
||||
awk -F ' ' -v id="$asset_id" '
|
||||
$1 == id { print $2; found = 1 }
|
||||
END { if (!found) exit 1 }
|
||||
' "$GH_ASSET_ROWS"
|
||||
)" || exit 32
|
||||
printf 'asset-bytes:%s:%s\n' "$asset_id" "$asset_name"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
[ "$endpoint" = "repos/owner/repo/releases/$GH_EXPECTED_RELEASE_ID" ] || exit 33
|
||||
case "$query" in
|
||||
'.tag_name')
|
||||
printf '%s\n' "$GH_RELEASE_TAG"
|
||||
;;
|
||||
'.assets[].name')
|
||||
cut -f 2 "$GH_ASSET_ROWS"
|
||||
;;
|
||||
'.assets[] | [.id, .name] | @tsv')
|
||||
cat "$GH_ASSET_ROWS"
|
||||
;;
|
||||
*)
|
||||
exit 34
|
||||
;;
|
||||
esac
|
||||
`), 0o755)
|
||||
|
||||
type releaseAsset struct {
|
||||
id string
|
||||
name string
|
||||
}
|
||||
assets := []releaseAsset{
|
||||
{id: "1008", name: "dws-windows-arm64.zip"},
|
||||
{id: "1003", name: "dws-darwin-arm64.tar.gz"},
|
||||
{id: "1001", name: "checksums.txt"},
|
||||
{id: "1005", name: "dws-linux-arm64.tar.gz"},
|
||||
{id: "1002", name: "dws-darwin-amd64.tar.gz"},
|
||||
{id: "1007", name: "dws-windows-amd64.zip"},
|
||||
{id: "1004", name: "dws-linux-amd64.tar.gz"},
|
||||
{id: "1006", name: "dws-skills.zip"},
|
||||
}
|
||||
rowsFor := func(items []releaseAsset) string {
|
||||
var rows []string
|
||||
for _, asset := range items {
|
||||
rows = append(rows, asset.id+"\t"+asset.name)
|
||||
}
|
||||
return strings.Join(rows, "\n") + "\n"
|
||||
}
|
||||
script := filepath.Join(sourceRoot, "scripts", "release", "download-github-release-assets.sh")
|
||||
type runResult struct {
|
||||
output string
|
||||
log string
|
||||
dest string
|
||||
err error
|
||||
}
|
||||
run := func(items []releaseAsset, releaseTag, releaseID string) runResult {
|
||||
runDir := t.TempDir()
|
||||
rowsPath := filepath.Join(runDir, "assets.tsv")
|
||||
logPath := filepath.Join(runDir, "gh.log")
|
||||
dest := filepath.Join(runDir, "download")
|
||||
mustWriteFile(t, rowsPath, []byte(rowsFor(items)), 0o644)
|
||||
cmd := exec.Command("sh", script, "v1.2.3", dest)
|
||||
cmd.Env = []string{
|
||||
"PATH=" + binDir + string(os.PathListSeparator) + os.Getenv("PATH"),
|
||||
"HOME=" + t.TempDir(),
|
||||
"GITHUB_REPOSITORY=owner/repo",
|
||||
"DWS_GITHUB_RELEASE_ID=" + releaseID,
|
||||
"GH_ASSET_ROWS=" + rowsPath,
|
||||
"GH_EXPECTED_RELEASE_ID=456",
|
||||
"GH_LOG=" + logPath,
|
||||
"GH_RELEASE_TAG=" + releaseTag,
|
||||
}
|
||||
output, runErr := cmd.CombinedOutput()
|
||||
log, readErr := os.ReadFile(logPath)
|
||||
if readErr != nil && !os.IsNotExist(readErr) {
|
||||
t.Fatalf("ReadFile(%s) error = %v", logPath, readErr)
|
||||
}
|
||||
return runResult{
|
||||
output: string(output),
|
||||
log: string(log),
|
||||
dest: dest,
|
||||
err: runErr,
|
||||
}
|
||||
}
|
||||
|
||||
success := run(assets, "v1.2.3", "456")
|
||||
if success.err != nil {
|
||||
t.Fatalf("exact GitHub Release download was rejected: %v\n%s", success.err, success.output)
|
||||
}
|
||||
if strings.Contains(success.log, "releases/tags/") {
|
||||
t.Fatalf("download fell back to a tag endpoint:\n%s", success.log)
|
||||
}
|
||||
if got := strings.Count(success.log, "application/octet-stream"); got != len(assets) {
|
||||
t.Fatalf("asset download count = %d, want %d\nlog:\n%s", got, len(assets), success.log)
|
||||
}
|
||||
for _, asset := range assets {
|
||||
wantEndpoint := "repos/owner/repo/releases/assets/" + asset.id
|
||||
if !strings.Contains(success.log, wantEndpoint) {
|
||||
t.Errorf("asset %s was not downloaded by ID %s\nlog:\n%s", asset.name, asset.id, success.log)
|
||||
}
|
||||
data, readErr := os.ReadFile(filepath.Join(success.dest, asset.name))
|
||||
if readErr != nil {
|
||||
t.Errorf("ReadFile(%s) error = %v", asset.name, readErr)
|
||||
continue
|
||||
}
|
||||
want := fmt.Sprintf("asset-bytes:%s:%s\n", asset.id, asset.name)
|
||||
if string(data) != want {
|
||||
t.Errorf("%s bytes = %q, want %q", asset.name, data, want)
|
||||
}
|
||||
}
|
||||
|
||||
extra := append(append([]releaseAsset{}, assets...), releaseAsset{id: "1009", name: "malware.exe"})
|
||||
if result := run(extra, "v1.2.3", "456"); result.err == nil || !strings.Contains(result.output, "exactly the supported assets") {
|
||||
t.Fatalf("extra GitHub Release asset was not rejected: err=%v\n%s", result.err, result.output)
|
||||
}
|
||||
if result := run(assets[:len(assets)-1], "v1.2.3", "456"); result.err == nil || !strings.Contains(result.output, "exactly the supported assets") {
|
||||
t.Fatalf("missing GitHub Release asset was not rejected: err=%v\n%s", result.err, result.output)
|
||||
}
|
||||
if result := run(assets, "v9.9.9", "456"); result.err == nil || !strings.Contains(result.output, "ID/tag mismatch") {
|
||||
t.Fatalf("wrong GitHub Release tag was not rejected: err=%v\n%s", result.err, result.output)
|
||||
}
|
||||
if result := run(assets, "v1.2.3", "invalid"); result.err == nil || !strings.Contains(result.output, "invalid GitHub Release ID") {
|
||||
t.Fatalf("invalid GitHub Release ID was not rejected: err=%v\n%s", result.err, result.output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseDeliveredStableRequiresSuccessfulPublicDelivery(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user