#!/usr/bin/env bash # Withdraw one already-published DWS release from every distribution channel # without pretending that installed clients can be downgraded. # # The withdrawal is deliberately resumable. A permanent annotated tombstone is # created before any channel mutation. Reruns accept only that exact tombstone; # the ref is never updated or deleted. set -euo pipefail SCRIPT_DIR="$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)" ROOT="$(CDPATH='' cd -- "$SCRIPT_DIR/../.." && pwd)" # shellcheck source=scripts/release/release-lib.sh . "$SCRIPT_DIR/release-lib.sh" VERSION="${1:-}" REASON="${2:-}" CONFIRMATION="${3:-}" OFFICIAL_REPOSITORY="${DWS_RELEASE_OFFICIAL_REPOSITORY:-DingTalk-Real-AI/dingtalk-workspace-cli}" PACKAGE_NAME="${DWS_NPM_PACKAGE:-dingtalk-workspace-cli}" DEFAULT_BRANCH="${DWS_DEFAULT_BRANCH:-${GITHUB_EVENT_DEFAULT_BRANCH:-}}" TOMBSTONE="withdrawn/${VERSION}" GITEE_API="${GITEE_API:-https://gitee.com/api/v5}" GITEE_REPO="${GITEE_REPO:-DingTalk-Real-AI/dingtalk-workspace-cli}" OSS_PREFIX="${OSS_PREFIX:-dws}" OSS_ENABLED="" DELIVERY_VERIFIER="${DWS_DELIVERY_VERIFIER:-$SCRIPT_DIR/verify-release-workflow-delivery.sh}" STABLE_DELIVERY_VERIFIER="${DWS_STABLE_DELIVERY_VERIFIER:-$SCRIPT_DIR/verify-delivered-stable.sh}" GITHUB_DOWNLOAD_HELPER="${DWS_GITHUB_DOWNLOAD_HELPER:-$SCRIPT_DIR/download-github-release-assets.sh}" ARTIFACT_VERIFY_HELPER="${DWS_ARTIFACT_VERIFY_HELPER:-$SCRIPT_DIR/verify-release-artifacts.sh}" GITEE_SYNC_HELPER="${DWS_GITEE_SYNC_HELPER:-$SCRIPT_DIR/sync-to-gitee.sh}" err() { printf 'error: %s\n' "$*" >&2 exit 1 } say() { printf '%s\n' "$*" } need_cmd() { command -v "$1" >/dev/null 2>&1 || err "missing required command: $1" } need_env() { local name="$1" value="$2" [ -n "$value" ] || err "missing required environment variable: $name" } sha256_text() { if command -v sha256sum >/dev/null 2>&1; then sha256sum | awk '{print $1}' else shasum -a 256 | awk '{print $1}' fi } github_api() { gh api -H 'X-GitHub-Api-Version: 2026-03-10' "$@" } github_optional() { local error_file status error_file="$(mktemp "${TMPDIR:-/tmp}/dws-withdraw-github-optional.XXXXXX")" set +e github_api "$@" 2>"$error_file" status=$? set -e if [ "$status" -eq 0 ]; then rm -f "$error_file" return 0 fi if grep -Eq '\(HTTP 404\)|HTTP 404([^0-9]|$)' "$error_file"; then rm -f "$error_file" return 4 fi cat "$error_file" >&2 rm -f "$error_file" return "$status" } github_expect_404() { local endpoint="$1" error_file error_file="$(mktemp "${TMPDIR:-/tmp}/dws-withdraw-github-404.XXXXXX")" if github_api "$endpoint" >/dev/null 2>"$error_file"; then rm -f "$error_file" err "GitHub resource still exists after deletion: $endpoint" fi if ! grep -Eq '\(HTTP 404\)|HTTP 404([^0-9]|$)' "$error_file"; then cat "$error_file" >&2 rm -f "$error_file" err "could not prove GitHub resource deletion: $endpoint" fi rm -f "$error_file" } github_delete_if_present() { local endpoint="$1" description="$2" error_file status error_file="$(mktemp "${TMPDIR:-/tmp}/dws-withdraw-github-delete.XXXXXX")" set +e github_api --method DELETE "$endpoint" >/dev/null 2>"$error_file" status=$? set -e if [ "$status" -eq 0 ]; then rm -f "$error_file" return 0 fi if grep -Eq '\(HTTP 404\)|HTTP 404([^0-9]|$)' "$error_file"; then rm -f "$error_file" say "$description was already absent." return 0 fi cat "$error_file" >&2 rm -f "$error_file" err "could not delete $description" } release_json() { github_api "repos/${OFFICIAL_REPOSITORY}/releases/tags/$1" } release_field() { local version="$1" expression="$2" release_json "$version" | python3 -c ' import json import sys data = json.load(sys.stdin) path = sys.argv[1].split(".") value = data for item in path: if item: value = value.get(item) if isinstance(value, dict) else None if value is True: print("true") elif value is False: print("false") elif value is not None: print(value) ' "$expression" } tag_commit() { git rev-parse --verify "$1^{commit}" 2>/dev/null } tag_object() { git rev-parse --verify "refs/tags/$1" 2>/dev/null } has_tombstone() { git show-ref --verify --quiet "refs/tags/withdrawn/$1" } expected_prerelease() { if [ "$1" = prerelease ]; then printf '%s\n' true else printf '%s\n' false fi } npm_exact_version() { npm view "${PACKAGE_NAME}@${1#v}" version --registry=https://registry.npmjs.org 2>/dev/null } npm_deprecation() { npm view "${PACKAGE_NAME}@${1#v}" deprecated --registry=https://registry.npmjs.org 2>/dev/null } release_is_eligible_rollback() { local candidate="$1" channel="$2" state commit deprecation immutable release_validate_version_channel "$channel" "$candidate" >/dev/null 2>&1 || return 1 has_tombstone "$candidate" && return 1 [ "$(git cat-file -t "$(tag_object "$candidate")" 2>/dev/null || true)" = tag ] || return 1 commit="$(tag_commit "$candidate" 2>/dev/null || true)" printf '%s\n' "$commit" | grep -Eq '^[0-9a-f]{40}$' || return 1 state="$( release_json "$candidate" 2>/dev/null | python3 -c 'import json,sys d=json.load(sys.stdin) print("%s\t%s\t%s" % ( str(bool(d.get("draft"))).lower(), str(bool(d.get("prerelease"))).lower(), "withdrawn" if "" end_marker="" original_body="$(release_field "$VERSION" body)" block="${begin_marker} ## Withdrawn This release has been withdrawn from distribution. - Reason: ${REASON} - Tombstone: \`${TOMBSTONE}\` Already-installed clients cannot be remotely downgraded. Install the current ${CHANNEL} channel or a later fixed version. ${end_marker}" notes="$( ORIGINAL_BODY="$original_body" WITHDRAW_BLOCK="$block" \ BEGIN_MARKER="$begin_marker" END_MARKER="$end_marker" python3 - <<'PY' import os body = os.environ["ORIGINAL_BODY"] block = os.environ["WITHDRAW_BLOCK"] begin = os.environ["BEGIN_MARKER"] end = os.environ["END_MARKER"] if begin in body and end in body and body.index(begin) < body.index(end): prefix, remainder = body.split(begin, 1) _, suffix = remainder.split(end, 1) print(prefix + block + suffix, end="") else: separator = "\n\n" if body else "" print(body + separator + block, end="") PY )" payload="$(mktemp "${TMPDIR:-/tmp}/dws-withdraw-release-json.XXXXXX")" NOTES="$notes" python3 - "$VERSION" >"$payload" <<'PY' import json import os import sys print(json.dumps({ "name": f"[WITHDRAWN] {sys.argv[1]}", "body": os.environ["NOTES"], })) PY github_api --method PATCH \ "repos/${OFFICIAL_REPOSITORY}/releases/${TARGET_RELEASE_ID}" \ --input "$payload" >/dev/null rm -f "$payload" state="$( release_json "$VERSION" | python3 -c 'import json,sys d=json.load(sys.stdin) print("%s\t%s\t%s" % ( d.get("name", ""), "marked" if "