fix(release): add fast guarded recovery path

This commit is contained in:
修雨
2026-07-17 16:13:15 +08:00
parent ec9ae33a43
commit fa558372d2
16 changed files with 1826 additions and 251 deletions
+39
View File
@@ -16,6 +16,7 @@ usage() {
cat >&2 <<'EOF'
usage: dws-release [version] [options]
dws-release config [--remote <name>]
dws-release recover <version> [--failed-run <run-id>] [--remote <name>]
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
@@ -34,6 +35,7 @@ Examples:
dws-release v1.2.3-beta.1 --publish
dws-release v1.2.3 --from-beta v1.2.3-beta.1
dws-release v1.2.3 --from-beta v1.2.3-beta.1 --publish
dws-release recover v1.2.3-beta.1
EOF
}
@@ -191,9 +193,46 @@ configure_remote() {
exit 0
}
recover_release() {
shift
recovery_version="${1:-}"
[ -n "$recovery_version" ] || die_usage 'recover requires a release version'
case "$recovery_version" in -h|--help) usage; exit 0 ;; esac
shift
recovery_failed_run=""
while [ "$#" -gt 0 ]; do
case "$1" in
--failed-run)
[ "$#" -ge 2 ] || die_usage '--failed-run requires a workflow run ID'
recovery_failed_run="$2"
shift 2
;;
--remote)
[ "$#" -ge 2 ] || die_usage '--remote requires a value'
REMOTE="$2"
shift 2
;;
-h|--help) usage; exit 0 ;;
*) die_usage "unknown recovery argument: $1" ;;
esac
done
cd "$ROOT"
require_remote
sync_main_if_safe
set -- "$recovery_version" --remote "$REMOTE"
if [ -n "$recovery_failed_run" ]; then
set -- "$@" --failed-run "$recovery_failed_run"
fi
exec "$SCRIPT_DIR/recover-release.sh" "$@"
}
if [ "${1:-}" = "config" ]; then
configure_remote "$@"
fi
if [ "${1:-}" = "recover" ]; then
recover_release "$@"
fi
[ "$#" -gt 0 ] || WIZARD=1
while [ "$#" -gt 0 ]; do
+153
View File
@@ -0,0 +1,153 @@
#!/bin/sh
set -eu
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
. "$SCRIPT_DIR/release-lib.sh"
ROOT="$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)"
VERSION="${1:-}"
REMOTE=""
FAILED_RUN_ID=""
EXPECTED_REPOSITORY="DingTalk-Real-AI/dingtalk-workspace-cli"
usage() {
cat >&2 <<'EOF'
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.
EOF
}
[ -n "$VERSION" ] || { usage; exit 2; }
shift
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 ;;
-h|--help) usage; exit 0 ;;
*) printf 'unknown recovery argument: %s\n' "$1" >&2; usage; exit 2 ;;
esac
done
channel="$(release_channel_for_version "$VERSION")" || exit 2
release_validate_version_channel "$channel" "$VERSION"
[ -n "$REMOTE" ] || { printf '%s\n' '--remote is required' >&2; exit 2; }
if [ -n "$FAILED_RUN_ID" ]; then
printf '%s\n' "$FAILED_RUN_ID" | grep -Eq '^[1-9][0-9]*$' || {
printf 'invalid failed Release run ID: %s\n' "$FAILED_RUN_ID" >&2
exit 2
}
fi
command -v gh >/dev/null 2>&1 || { printf '%s\n' 'gh is required for release recovery' >&2; exit 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
exit 1
}
[ -z "$(git status --porcelain --untracked-files=all)" ] || {
printf '%s\n' 'release recovery requires a clean main worktree' >&2
exit 1
}
push_url="$(git remote get-url --push "$REMOTE" 2>/dev/null)" || {
printf 'unknown release remote: %s\n' "$REMOTE" >&2
exit 1
}
case "$push_url" in
https://github.com/*) repository_path="${push_url#https://github.com/}" ;;
git@github.com:*) repository_path="${push_url#git@github.com:}" ;;
ssh://git@github.com/*) repository_path="${push_url#ssh://git@github.com/}" ;;
*) printf 'release recovery requires the official GitHub remote, got: %s\n' "$push_url" >&2; exit 1 ;;
esac
repository_path="${repository_path%/}"
repository_path="${repository_path%.git}"
[ "$repository_path" = "$EXPECTED_REPOSITORY" ] || {
printf 'release recovery is restricted to %s, got %s\n' "$EXPECTED_REPOSITORY" "$repository_path" >&2
exit 1
}
printf '==> Refreshing main and exact recovery tag %s\n' "$VERSION"
git fetch --force "$REMOTE" "+refs/heads/main:refs/remotes/$REMOTE/main"
recovery_ref="refs/dws-release-recovery/$VERSION"
cleanup() { git update-ref -d "$recovery_ref" >/dev/null 2>&1 || true; }
trap cleanup EXIT HUP INT TERM
git fetch --force --no-tags "$REMOTE" "+refs/tags/$VERSION:$recovery_ref"
[ "$(git cat-file -t "$recovery_ref")" = "tag" ] || {
printf '%s must be an annotated tag\n' "$VERSION" >&2
exit 1
}
tag_object="$(git rev-parse "$recovery_ref")"
commit="$(git rev-parse "$recovery_ref^{commit}")"
git merge-base --is-ancestor "$commit" "refs/remotes/$REMOTE/main" || {
printf '%s commit %s is not contained in %s/main\n' "$VERSION" "$commit" "$REMOTE" >&2
exit 1
}
if [ -z "$FAILED_RUN_ID" ]; then
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\" and (.conclusion as \\$c | [\"failure\", \"cancelled\", \"timed_out\", \"startup_failure\", \"stale\"] | index(\\$c))) | .id"
)" || {
printf 'could not query failed Release runs for %s\n' "$VERSION" >&2
exit 1
}
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
}
fi
printf 'Recovery target:\n'
printf ' version: %s\n' "$VERSION"
printf ' tag object: %s\n' "$tag_object"
printf ' commit: %s\n' "$commit"
printf ' failed run: https://github.com/%s/actions/runs/%s\n' "$EXPECTED_REPOSITORY" "$FAILED_RUN_ID"
[ -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
[ "$confirmation" = "$VERSION" ] || { printf '%s\n' 'release recovery cancelled' >&2; exit 1; }
nonce="${commit}-$(date +%s)-$$"
workflow_sha="$(git rev-parse "refs/remotes/$REMOTE/main")"
gh workflow run release.yml \
--repo "$EXPECTED_REPOSITORY" \
--ref main \
-f "recover_release_version=$VERSION" \
-f "recover_release_tag_object=$tag_object" \
-f "recover_release_commit=$commit" \
-f "recover_failed_run_id=$FAILED_RUN_ID" \
-f "recover_release_nonce=$nonce" \
-f "recover_release_confirmation=$VERSION"
expected_title="Release recovery $VERSION at $commit $nonce"
started_at="$(date +%s)"
run_id=""
while :; do
run_id="$(
gh api \
-H 'Accept: application/vnd.github+json' \
"repos/$EXPECTED_REPOSITORY/actions/workflows/release.yml/runs?event=workflow_dispatch&branch=main&per_page=100" \
--jq ".workflow_runs[] | select(.display_title == \"$expected_title\" and .head_sha == \"$workflow_sha\") | .id" \
| sed -n '1p'
)" || exit 1
[ -z "$run_id" ] || break
now="$(date +%s)"
[ $((now - started_at)) -lt 60 ] || {
printf 'recovery was dispatched but its run could not be located: %s\n' "$expected_title" >&2
exit 1
}
sleep 2
done
printf 'Protected recovery run: https://github.com/%s/actions/runs/%s\n' "$EXPECTED_REPOSITORY" "$run_id"
printf '%s\n' 'Approve the release-recovery environment when prompted; this command will follow the run.'
if ! gh run watch "$run_id" --repo "$EXPECTED_REPOSITORY" --exit-status; then
gh run view "$run_id" --repo "$EXPECTED_REPOSITORY" --log-failed >&2 || true
exit 1
fi
printf 'Release recovery completed: %s\n' "$VERSION"
+158 -68
View File
@@ -120,21 +120,6 @@ require_github_publication_authority() {
return 1
}
immutable_enabled="$(
gh api \
-H 'Accept: application/vnd.github+json' \
-H 'X-GitHub-Api-Version: 2026-03-10' \
"repos/$github_repository/immutable-releases" \
--jq '.enabled'
)" || {
printf 'immutable releases are not enabled for %s; enable them before allocating a tag\n' "$github_repository" >&2
return 1
}
[ "$immutable_enabled" = "true" ] || {
printf 'immutable releases are not enabled for %s\n' "$github_repository" >&2
return 1
}
active_runs="$(
gh api -H 'Accept: application/vnd.github+json' \
"repos/$github_repository/actions/workflows/release.yml/runs?per_page=100" \
@@ -195,18 +180,82 @@ require_github_publication_authority() {
}
done
beta_commit="$(git rev-parse "$FROM_BETA^{commit}")"
beta_runs="$(
gh api -H 'Accept: application/vnd.github+json' \
"repos/$github_repository/actions/workflows/release.yml/runs?branch=$FROM_BETA&event=push&status=completed&per_page=100" \
--jq '.workflow_runs[] | [.head_sha, .head_branch, .conclusion] | @tsv'
)" || return 1
printf '%s\n' "$beta_runs" | awk -F '\t' -v sha="$beta_commit" -v tag="$FROM_BETA" '
$1 == sha && $2 == tag && $3 == "success" { found = 1 }
END { exit(found ? 0 : 1) }
' || {
printf 'Release workflow did not succeed for %s at %s\n' "$FROM_BETA" "$beta_commit" >&2
DWS_RELEASE_OFFICIAL_REPOSITORY="$github_repository" \
"$SCRIPT_DIR/verify-release-workflow-delivery.sh" "$FROM_BETA" "$beta_commit" || return 1
fi
}
require_release_governance_ci() {
governance_repository="$(github_repository_from_url "$push_url" 2>/dev/null || true)"
if [ -z "$governance_repository" ]; then
[ "${DWS_RELEASE_ALLOW_NON_GITHUB_REMOTE:-0}" = "1" ] || {
printf 'release governance preflight requires a github.com publication remote\n' >&2
return 1
}
printf 'warning: Release governance CI preflight explicitly disabled for non-GitHub test remote\n' >&2
return 0
fi
"$SCRIPT_DIR/verify-release-governance-ci.sh" \
"$governance_repository" \
"$(git rev-parse HEAD)"
}
preflight_proof_path() {
proof_dir="$(git rev-parse --git-path dws-release-preflight)"
printf '%s/%s.proof\n' "$proof_dir" "$VERSION"
}
preflight_proof_is_reusable() {
proof_file="$1"
[ -f "$proof_file" ] || return 1
[ "$(wc -l < "$proof_file" | tr -d '[:space:]')" -eq 9 ] || return 1
[ "$(sed -n 's/^format=//p' "$proof_file")" = "1" ] || return 1
[ "$(sed -n 's/^channel=//p' "$proof_file")" = "$CHANNEL" ] || return 1
[ "$(sed -n 's/^version=//p' "$proof_file")" = "$VERSION" ] || return 1
[ "$(sed -n 's/^commit=//p' "$proof_file")" = "$(git rev-parse HEAD)" ] || return 1
[ "$(sed -n 's/^repository=//p' "$proof_file")" = "$push_identity" ] || return 1
[ "$(sed -n 's/^branch=//p' "$proof_file")" = "$BRANCH" ] || return 1
[ "$(sed -n 's/^from_beta=//p' "$proof_file")" = "$FROM_BETA" ] || return 1
[ "$(sed -n 's/^previous_stable=//p' "$proof_file")" = "$previous_stable" ] || return 1
proof_created_at="$(sed -n 's/^created_at=//p' "$proof_file")"
printf '%s\n' "$proof_created_at" | grep -Eq '^[0-9]+$' || return 1
proof_now="$(date +%s)"
[ "$proof_created_at" -le "$proof_now" ] || return 1
[ $((proof_now - proof_created_at)) -le 21600 ] || return 1
}
write_preflight_proof() {
proof_file="$1"
proof_dir="$(dirname "$proof_file")"
umask 077
mkdir -p "$proof_dir"
proof_tmp="${proof_file}.tmp.$$"
{
printf 'format=1\n'
printf 'channel=%s\n' "$CHANNEL"
printf 'version=%s\n' "$VERSION"
printf 'commit=%s\n' "$(git rev-parse HEAD)"
printf 'repository=%s\n' "$push_identity"
printf 'branch=%s\n' "$BRANCH"
printf 'from_beta=%s\n' "$FROM_BETA"
printf 'previous_stable=%s\n' "$previous_stable"
printf 'created_at=%s\n' "$(date +%s)"
} > "$proof_tmp"
mv "$proof_tmp" "$proof_file"
}
build_policy_binary() {
policy_tmp="$ROOT/tmp/go-build"
mkdir -p "$policy_tmp"
GOTMPDIR="$policy_tmp" make build
if [ "$(uname -s)" = "Darwin" ] && [ "${DWS_RELEASE_ALLOW_NON_GITHUB_REMOTE:-0}" != "1" ]; then
command -v codesign >/dev/null 2>&1 || {
printf '%s\n' 'codesign is required to prepare the macOS policy binary' >&2
return 1
}
codesign --force --sign - "$ROOT/dws"
codesign --verify --strict "$ROOT/dws"
fi
}
@@ -251,37 +300,45 @@ previous_stable="$(sed -n 's/^previous_stable=//p' "$metadata" | tail -1)"
printf '==> Verifying delivered stable baseline %s\n' "${previous_stable:-none}"
require_delivered_previous_stable
if [ "$PUBLISH" -eq 1 ]; then
printf '==> Verifying GitHub publication authority before local gates\n'
require_github_publication_authority
printf '==> Verifying GitHub publication authority before local gates\n'
require_github_publication_authority
proof_path="$(preflight_proof_path)"
ran_expensive_gates=0
if [ "$PUBLISH" -eq 1 ] && preflight_proof_is_reusable "$proof_path"; then
printf '==> Reusing exact preflight proof for %s at %s\n' "$VERSION" "$(git rev-parse HEAD)"
else
rm -f "$proof_path"
printf '==> Running repository test, build, and policy gates\n'
make test
# Installer tests intentionally exercise source builds. Rebuild the release
# binary before policy so policy never depends on a pre-existing ./dws.
build_policy_binary
make policy
if [ -n "$previous_stable" ]; then
printf '==> Comparing command tree with %s\n' "$previous_stable"
"$ROOT/scripts/policy/check-command-compatibility.sh" \
--base-ref "$REMOTE/$BRANCH" \
--stable-ref "$previous_stable"
fi
printf '==> Building local release artifacts for %s\n' "$VERSION"
make package VERSION="$VERSION"
printf '==> Verifying release artifact set and checksums\n'
"$SCRIPT_DIR/verify-release-artifacts.sh" "$VERSION"
printf '==> Verifying npm package installation\n'
"$SCRIPT_DIR/verify-package-managers.sh" --npm-only --expected-version "$VERSION"
if command -v brew >/dev/null 2>&1; then
printf '==> Verifying Homebrew package installation\n'
"$SCRIPT_DIR/verify-package-managers.sh" --brew-only --expected-version "$VERSION"
fi
ran_expensive_gates=1
fi
printf '==> Running repository test and policy gates\n'
make test
make policy
if [ -n "$previous_stable" ]; then
printf '==> Comparing command tree with %s\n' "$previous_stable"
"$ROOT/scripts/policy/check-command-compatibility.sh" \
--base-ref "$REMOTE/$BRANCH" \
--stable-ref "$previous_stable"
fi
printf '==> Building local release artifacts for %s\n' "$VERSION"
make package VERSION="$VERSION"
printf '==> Verifying release artifact set and checksums\n'
"$SCRIPT_DIR/verify-release-artifacts.sh" "$VERSION"
printf '==> Verifying npm package installation\n'
"$SCRIPT_DIR/verify-package-managers.sh" --npm-only --expected-version "$VERSION"
if command -v brew >/dev/null 2>&1; then
printf '==> Verifying Homebrew package installation\n'
"$SCRIPT_DIR/verify-package-managers.sh" --brew-only --expected-version "$VERSION"
fi
# Collect human confirmation before the final authority refresh. Nothing may
# block between that refresh and tag creation.
# Collect human confirmation before the final remote validations.
if [ "$PUBLISH" -eq 1 ] && [ "$YES" -ne 1 ]; then
if [ ! -t 0 ]; then
printf 'interactive confirmation is unavailable; pass --yes after reviewing the preflight\n' >&2
@@ -292,34 +349,65 @@ if [ "$PUBLISH" -eq 1 ] && [ "$YES" -ne 1 ]; then
[ "$confirmation" = "$VERSION" ] || { printf 'release cancelled\n' >&2; exit 1; }
fi
# Re-check after every local gate so tag creation cannot race with a modified
# source tree. dist/ is ignored and therefore does not make the tree dirty.
printf '==> Verifying the Release workflow publication identity\n'
require_release_governance_ci
# Refresh after every local gate and the remote governance check. dist/ is
# ignored and therefore does not make the tree dirty.
printf '==> Refreshing %s/%s before sealing the tag\n' "$REMOTE" "$BRANCH"
git fetch --force "$REMOTE" "+refs/heads/$BRANCH:refs/remotes/$REMOTE/$BRANCH"
fetch_release_tags
run_contract --metadata-output "$final_metadata"
final_previous_stable="$(sed -n 's/^previous_stable=//p' "$final_metadata" | tail -1)"
if [ "$final_previous_stable" != "$previous_stable" ]; then
[ -n "$final_previous_stable" ] || {
printf 'latest stable authority unexpectedly became empty\n' >&2
exit 1
}
previous_stable_before_refresh="$previous_stable"
previous_stable="$final_previous_stable"
printf '==> Revalidating delivered stable baseline %s\n' "$previous_stable"
require_delivered_previous_stable
if [ "$previous_stable" != "$previous_stable_before_refresh" ]; then
printf '==> Stable authority advanced from %s to %s; rechecking command compatibility\n' \
"${previous_stable:-none}" "${final_previous_stable:-none}"
[ -n "$final_previous_stable" ] || {
printf 'latest stable authority unexpectedly became empty\n' >&2
exit 1
}
"${previous_stable_before_refresh:-none}" "$previous_stable"
"$ROOT/scripts/policy/check-command-compatibility.sh" \
--base-ref "$REMOTE/$BRANCH" \
--stable-ref "$final_previous_stable"
--stable-ref "$previous_stable"
fi
if [ "$PUBLISH" -eq 1 ]; then
printf '==> Reconfirming GitHub publication authority on the settled baseline\n'
require_github_publication_authority
fi
# Delivery, compatibility, and publication checks above may take long enough
# for main or stable authority to move. This last refresh must be followed only
# by local proof/tag creation. The atomic push advertises main with the tag, so
# an already-advanced remote main rejects the whole transaction; a later main
# advance is safe because the sealed commit remains in protected main history.
printf '==> Settling final %s/%s and stable authority\n' "$REMOTE" "$BRANCH"
git fetch --force "$REMOTE" "+refs/heads/$BRANCH:refs/remotes/$REMOTE/$BRANCH"
fetch_release_tags
run_contract --metadata-output "$final_metadata"
settled_previous_stable="$(sed -n 's/^previous_stable=//p' "$final_metadata" | tail -1)"
[ "$settled_previous_stable" = "$previous_stable" ] || {
printf 'stable authority moved from %s to %s during final validation; rerun the release command\n' \
"$previous_stable" "$settled_previous_stable" >&2
exit 1
}
if [ "$ran_expensive_gates" -eq 1 ]; then
write_preflight_proof "$proof_path"
fi
if [ "$PUBLISH" -ne 1 ]; then
printf '\nPreflight passed. No tag was created.\n'
printf 'Publish with the same command plus --publish.\n'
printf 'Publish within six hours with the same command plus --publish to reuse this exact proof.\n'
exit 0
fi
printf '==> Reconfirming GitHub publication authority immediately before tag creation\n'
require_github_publication_authority
printf '==> Creating annotated tag %s\n' "$VERSION"
if [ "$CHANNEL" = "stable" ]; then
git tag -a "$VERSION" -m "Release $VERSION" -m 'Channel: stable' -m "From-Beta: $FROM_BETA"
@@ -327,7 +415,8 @@ else
git tag -a "$VERSION" -m "Release $VERSION" -m 'Channel: prerelease'
fi
if ! git push "$push_url" "refs/tags/$VERSION"; then
if ! git push --atomic "$push_url" \
"HEAD:refs/heads/$BRANCH" "refs/tags/$VERSION"; then
set +e
remote_refs="$(git ls-remote --tags "$push_url" "refs/tags/$VERSION" "refs/tags/$VERSION^{}")"
query_status=$?
@@ -351,3 +440,4 @@ if ! git push "$push_url" "refs/tags/$VERSION"; then
fi
printf 'Release tag pushed: %s -> %s. CI/CD now owns artifact publication.\n' "$VERSION" "$push_url"
rm -f "$proof_path"
@@ -79,6 +79,14 @@ printf '%s\n' "$stable_runs" | awk -F '\t' -v sha="$EXPECTED_COMMIT" -v tag="$TA
' && delivered_by_push=1 || delivered_by_push=0
if [ "$delivered_by_push" -ne 1 ]; then
if DWS_RELEASE_OFFICIAL_REPOSITORY="$REPOSITORY" \
DWS_RELEASE_GITHUB_TOKEN="$API_TOKEN" \
"$SCRIPT_DIR/verify-release-workflow-delivery.sh" "$TAG" "$EXPECTED_COMMIT" 2>/dev/null; then
printf 'Delivered stable baseline verified through protected default-branch recovery: %s -> %s\n' \
"$TAG" "$EXPECTED_COMMIT"
exit 0
fi
recovery_proof="$(
python3 - "$RECOVERY_MANIFEST" "$TAG" "$EXPECTED_COMMIT" <<'PY'
import json
@@ -3,15 +3,21 @@ set -eu
TAG="${1:-}"
EXPECTED_COMMIT="${2:-}"
EXPECTED_TAG_OBJECT="${3:-}"
REPOSITORY="${GITHUB_REPOSITORY:-}"
[ -n "$TAG" ] && [ -n "$EXPECTED_COMMIT" ] && [ -n "$REPOSITORY" ] || {
printf 'usage: GITHUB_REPOSITORY=owner/repo verify-github-tag-authority.sh <tag> <commit>\n' >&2
printf 'usage: GITHUB_REPOSITORY=owner/repo verify-github-tag-authority.sh <tag> <commit> [tag-object]\n' >&2
exit 2
}
command -v gh >/dev/null 2>&1 || { printf 'gh is required\n' >&2; exit 1; }
local_object="$(git rev-parse "refs/tags/$TAG")"
if [ -n "$EXPECTED_TAG_OBJECT" ] && [ "$local_object" != "$EXPECTED_TAG_OBJECT" ]; then
printf 'local tag object for %s changed (expected=%s actual=%s)\n' \
"$TAG" "$EXPECTED_TAG_OBJECT" "$local_object" >&2
exit 1
fi
remote_ref="$(
gh api -H 'Accept: application/vnd.github+json' \
"repos/$REPOSITORY/git/ref/tags/$TAG" \
+99
View File
@@ -0,0 +1,99 @@
#!/bin/sh
set -eu
REPOSITORY="${1:-}"
COMMIT="${2:-}"
WORKFLOW="release.yml"
DEFAULT_BRANCH="main"
TIMEOUT_SECONDS="${DWS_RELEASE_GOVERNANCE_TIMEOUT_SECONDS:-180}"
POLL_SECONDS="${DWS_RELEASE_GOVERNANCE_POLL_SECONDS:-2}"
usage() {
printf '%s\n' 'usage: verify-release-governance-ci.sh <owner/repository> <commit-sha>' >&2
}
[ -n "$REPOSITORY" ] && [ -n "$COMMIT" ] || { usage; exit 2; }
printf '%s\n' "$COMMIT" | grep -Eq '^[0-9a-f]{40}$' || {
printf 'invalid release commit: %s\n' "$COMMIT" >&2
exit 2
}
printf '%s\n' "$TIMEOUT_SECONDS" | grep -Eq '^[1-9][0-9]*$' || {
printf 'invalid governance timeout: %s\n' "$TIMEOUT_SECONDS" >&2
exit 2
}
printf '%s\n' "$POLL_SECONDS" | grep -Eq '^[1-9][0-9]*$' || {
printf 'invalid governance poll interval: %s\n' "$POLL_SECONDS" >&2
exit 2
}
command -v gh >/dev/null 2>&1 || {
printf '%s\n' 'gh is required to run the Release governance preflight' >&2
exit 1
}
nonce="${COMMIT}-$(date +%s)-$$"
expected_title="Release governance preflight $nonce"
printf '==> Dispatching Release governance preflight for %s\n' "$COMMIT"
gh workflow run "$WORKFLOW" \
--repo "$REPOSITORY" \
--ref "$DEFAULT_BRANCH" \
-f "governance_preflight_commit=$COMMIT" \
-f "governance_preflight_nonce=$nonce"
started_at="$(date +%s)"
run_id=""
while :; do
# nonce is restricted to a commit SHA, decimal timestamp, PID, and hyphens,
# so it is safe to embed in this jq string literal.
run_id="$(
gh api \
-H 'Accept: application/vnd.github+json' \
"repos/$REPOSITORY/actions/workflows/$WORKFLOW/runs?event=workflow_dispatch&branch=$DEFAULT_BRANCH&per_page=100" \
--jq ".workflow_runs[] | select(.display_title == \"$expected_title\" and .head_sha == \"$COMMIT\") | .id" \
| sed -n '1p'
)" || {
printf '%s\n' 'could not query the dispatched Release governance preflight' >&2
exit 1
}
[ -z "$run_id" ] || break
now="$(date +%s)"
if [ $((now - started_at)) -ge "$TIMEOUT_SECONDS" ]; then
printf 'timed out waiting for Release governance preflight: %s\n' "$expected_title" >&2
exit 1
fi
sleep "$POLL_SECONDS"
done
run_state=""
while :; do
run_state="$(
gh api \
-H 'Accept: application/vnd.github+json' \
"repos/$REPOSITORY/actions/runs/$run_id" \
--jq '[.display_title, .event, .status, .conclusion, .head_branch, .head_sha, .path, .repository.full_name] | @tsv'
)" || {
printf 'could not verify Release governance preflight run %s\n' "$run_id" >&2
exit 1
}
run_status="$(printf '%s\n' "$run_state" | cut -f3)"
[ "$run_status" != "completed" ] || break
now="$(date +%s)"
if [ $((now - started_at)) -ge "$TIMEOUT_SECONDS" ]; then
printf 'timed out waiting for Release governance preflight run %s\n' "$run_id" >&2
exit 1
fi
sleep "$POLL_SECONDS"
done
expected_state="$(printf '%s\tworkflow_dispatch\tcompleted\tsuccess\t%s\t%s\t.github/workflows/release.yml\t%s' \
"$expected_title" "$DEFAULT_BRANCH" "$COMMIT" "$REPOSITORY")"
[ "$run_state" = "$expected_state" ] || {
printf 'Release governance preflight run identity mismatch:\n expected: %s\n actual: %s\n' \
"$expected_state" "$run_state" >&2
gh run view "$run_id" --repo "$REPOSITORY" --log-failed >&2 || true
exit 1
}
printf 'Release governance preflight passed: https://github.com/%s/actions/runs/%s\n' \
"$REPOSITORY" "$run_id"
+156
View File
@@ -0,0 +1,156 @@
#!/bin/sh
set -eu
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
. "$SCRIPT_DIR/release-lib.sh"
TAG="${1:-}"
EXPECTED_COMMIT="${2:-}"
REPOSITORY="${DWS_RELEASE_OFFICIAL_REPOSITORY:-DingTalk-Real-AI/dingtalk-workspace-cli}"
[ -n "$TAG" ] && [ -n "$EXPECTED_COMMIT" ] || {
printf 'usage: verify-release-workflow-delivery.sh <tag> <commit>\n' >&2
exit 2
}
if ! release_is_stable_version "$TAG" && ! release_is_prerelease_version "$TAG"; then
printf 'invalid delivered release tag: %s\n' "$TAG" >&2
exit 2
fi
printf '%s\n' "$EXPECTED_COMMIT" | grep -Eq '^[0-9a-f]{40}$' || {
printf 'invalid delivered release commit: %s\n' "$EXPECTED_COMMIT" >&2
exit 2
}
command -v curl >/dev/null 2>&1 || { printf '%s\n' 'curl is required to verify release delivery' >&2; exit 1; }
command -v python3 >/dev/null 2>&1 || { printf '%s\n' 'python3 is required to verify release delivery' >&2; exit 1; }
API_TOKEN="${DWS_RELEASE_GITHUB_TOKEN:-}"
if [ -z "$API_TOKEN" ] && [ "${GITHUB_ACTIONS:-false}" != "true" ] && command -v gh >/dev/null 2>&1; then
API_TOKEN="$(gh auth token 2>/dev/null || true)"
fi
if [ -z "$API_TOKEN" ]; then API_TOKEN="${GITHUB_TOKEN:-}"; fi
github_get() {
if [ -n "$API_TOKEN" ]; then
curl -fsSL \
-H 'Accept: application/vnd.github+json' \
-H 'X-GitHub-Api-Version: 2026-03-10' \
-H "Authorization: Bearer $API_TOKEN" \
"https://api.github.com/$1" 2>/dev/null && return 0
fi
curl -fsSL \
-H 'Accept: application/vnd.github+json' \
-H 'X-GitHub-Api-Version: 2026-03-10' \
"https://api.github.com/$1"
}
find_push_delivery() {
page=1
while :; do
page_result="$(
github_get "repos/$REPOSITORY/actions/workflows/release.yml/runs?branch=$TAG&event=push&status=completed&per_page=100&page=$page" \
| python3 -c 'import json,sys
tag,commit=sys.argv[1:]
runs=json.load(sys.stdin).get("workflow_runs", [])
print(len(runs))
for run in runs:
if run.get("head_sha") == commit and run.get("head_branch") == tag and run.get("conclusion") == "success":
print(run.get("id", ""))
break' "$TAG" "$EXPECTED_COMMIT"
)" || return 1
page_count="$(printf '%s\n' "$page_result" | sed -n '1p')"
page_match="$(printf '%s\n' "$page_result" | sed -n '2p')"
if [ -n "$page_match" ]; then printf '%s\n' "$page_match"; return 0; fi
[ "$page_count" -eq 100 ] || return 1
page=$((page + 1))
done
}
push_delivery="$(find_push_delivery || true)"
if [ -n "$push_delivery" ]; then
printf 'Release workflow delivery verified through exact-tag push run %s: %s -> %s\n' \
"$push_delivery" "$TAG" "$EXPECTED_COMMIT"
exit 0
fi
find_recovery_identity() {
page=1
while :; do
page_result="$(
github_get "repos/$REPOSITORY/actions/workflows/release.yml/runs?branch=main&event=workflow_dispatch&status=completed&per_page=100&page=$page" \
| python3 -c 'import json,sys
tag,commit,repository=sys.argv[1:]
title=f"Release recovery {tag} at {commit}"
runs=json.load(sys.stdin).get("workflow_runs", [])
print(len(runs))
for run in runs:
display=run.get("display_title", "")
nonce=display[len(title) + 1:] if display.startswith(title + " ") else ""
if (__import__("re").fullmatch(__import__("re").escape(commit) + r"-[0-9]+-[0-9]+", nonce)
and run.get("event") == "workflow_dispatch"
and run.get("status") == "completed" and run.get("conclusion") == "success"
and run.get("head_branch") == "main" and run.get("path") == ".github/workflows/release.yml"
and run.get("repository", {}).get("full_name") == repository):
print("%s\t%s" % (run.get("id", ""), run.get("head_sha", "")))
break' "$TAG" "$EXPECTED_COMMIT" "$REPOSITORY"
)" || return 1
page_count="$(printf '%s\n' "$page_result" | sed -n '1p')"
page_match="$(printf '%s\n' "$page_result" | sed -n '2p')"
if [ -n "$page_match" ]; then printf '%s\n' "$page_match"; return 0; fi
[ "$page_count" -eq 100 ] || return 1
page=$((page + 1))
done
}
recovery_identity="$(find_recovery_identity || true)"
[ -n "$recovery_identity" ] || {
printf 'Release workflow did not deliver %s at %s through a tag push or protected recovery\n' \
"$TAG" "$EXPECTED_COMMIT" >&2
exit 1
}
recovery_run_id="$(printf '%s\n' "$recovery_identity" | cut -f1)"
recovery_workflow_sha="$(printf '%s\n' "$recovery_identity" | cut -f2)"
workflow_status="$({
github_get "repos/$REPOSITORY/compare/$recovery_workflow_sha...main" \
| python3 -c 'import json,sys; print(json.load(sys.stdin).get("status", ""))'
} || true)"
case "$workflow_status" in ahead|identical) ;; *)
printf 'protected recovery workflow %s is not contained in current main\n' \
"$recovery_workflow_sha" >&2
exit 1
esac
passed_jobs=""
page=1
while :; do
page_result="$(
github_get "repos/$REPOSITORY/actions/runs/$recovery_run_id/jobs?filter=all&per_page=100&page=$page" \
| python3 -c 'import json,sys
workflow_sha=sys.argv[1]
jobs=json.load(sys.stdin).get("jobs", [])
print(len(jobs))
for job in jobs:
if (job.get("status") == "completed" and job.get("conclusion") == "success"
and job.get("head_sha") == workflow_sha):
print(job.get("name", ""))' "$recovery_workflow_sha"
)" || exit 1
page_count="$(printf '%s\n' "$page_result" | sed -n '1p')"
page_jobs="$(printf '%s\n' "$page_result" | sed '1d')"
passed_jobs="$(printf '%s\n%s\n' "$passed_jobs" "$page_jobs" | sed '/^$/d')"
[ "$page_count" -eq 100 ] || break
page=$((page + 1))
done
for required_job in \
"Build signed release artifacts" \
"Verify Apple Developer ID signatures" \
"Publish immutable GitHub Release" \
"Publish npm and mirrors"; do
printf '%s\n' "$passed_jobs" | grep -Fqx "$required_job" || {
printf 'protected recovery run %s did not complete the shared release job graph for %s\n' \
"$recovery_run_id" "$TAG" >&2
exit 1
}
done
printf 'Release workflow delivery verified through protected recovery run %s: %s -> %s\n' \
"$recovery_run_id" "$TAG" "$EXPECTED_COMMIT"