1216 lines
43 KiB
Bash
Executable File
1216 lines
43 KiB
Bash
Executable File
#!/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 "<!-- dws-release-withdrawn" in (d.get("body") or "") else "active",
|
|
))' 2>/dev/null || true
|
|
)"
|
|
[ "$state" = "$(printf 'false\t%s\tactive' "$(expected_prerelease "$channel")")" ] || return 1
|
|
immutable="$(release_field "$candidate" immutable 2>/dev/null || true)"
|
|
[ "$(npm_exact_version "$candidate" || true)" = "${candidate#v}" ] || return 1
|
|
if ! deprecation="$(npm_deprecation "$candidate")"; then
|
|
return 1
|
|
fi
|
|
[ -z "$deprecation" ] || return 1
|
|
if [ "$immutable" = true ]; then
|
|
DWS_RELEASE_OFFICIAL_REPOSITORY="$OFFICIAL_REPOSITORY" \
|
|
DWS_RELEASE_GITHUB_TOKEN="${GITHUB_TOKEN:-}" \
|
|
"$DELIVERY_VERIFIER" "$candidate" "$commit" >/dev/null 2>&1
|
|
return
|
|
fi
|
|
[ "$channel" = stable ] || return 1
|
|
DWS_RELEASE_OFFICIAL_REPOSITORY="$OFFICIAL_REPOSITORY" \
|
|
DWS_RELEASE_GITHUB_TOKEN="${GITHUB_TOKEN:-}" \
|
|
"$STABLE_DELIVERY_VERIFIER" "$candidate" "$commit" >/dev/null 2>&1
|
|
}
|
|
|
|
find_rollback_version() {
|
|
local channel="$1" candidate
|
|
while IFS= read -r candidate; do
|
|
[ -n "$candidate" ] || continue
|
|
[ "$candidate" != "$VERSION" ] || continue
|
|
release_version_is_greater "$VERSION" "$candidate" || continue
|
|
if release_is_eligible_rollback "$candidate" "$channel"; then
|
|
printf '%s\n' "$candidate"
|
|
return 0
|
|
fi
|
|
done < <(
|
|
# shellcheck disable=SC2154 # Defined by sourced release-lib.sh.
|
|
if [ "$channel" = stable ]; then
|
|
git tag --list 'v*' | grep -E "$release_stable_pattern" | sort -Vr
|
|
else
|
|
git tag --list 'v*' | grep -E "$release_prerelease_pattern" | sort -Vr
|
|
fi
|
|
)
|
|
return 1
|
|
}
|
|
|
|
require_safe_channel_pointer() {
|
|
local version="$1" channel="$2"
|
|
release_validate_version_channel "$channel" "$version" >/dev/null 2>&1 ||
|
|
err "channel pointer contains an invalid $channel version: $version"
|
|
if [ "$version" = "$VERSION" ]; then
|
|
return 0
|
|
fi
|
|
release_is_eligible_rollback "$version" "$channel" ||
|
|
err "channel pointer $version is not a delivered, non-withdrawn $channel release"
|
|
}
|
|
|
|
validate_inputs() {
|
|
release_channel_for_version "$VERSION" >/dev/null ||
|
|
err "version must be exactly vX.Y.Z or vX.Y.Z-beta.N"
|
|
[ "$CONFIRMATION" = "WITHDRAW $VERSION" ] ||
|
|
err "confirmation must be exactly: WITHDRAW $VERSION"
|
|
python3 - "$REASON" <<'PY' || err "reason must be a trimmed, printable single line between 8 and 300 characters"
|
|
import sys
|
|
import unicodedata
|
|
|
|
reason = sys.argv[1]
|
|
if not 8 <= len(reason) <= 300 or reason != reason.strip():
|
|
raise SystemExit(1)
|
|
if any(ch in "\r\n" or unicodedata.category(ch).startswith("C") for ch in reason):
|
|
raise SystemExit(1)
|
|
PY
|
|
}
|
|
|
|
validate_context() {
|
|
need_env GITHUB_REPOSITORY "${GITHUB_REPOSITORY:-}"
|
|
need_env GITHUB_REF_NAME "${GITHUB_REF_NAME:-}"
|
|
need_env GITHUB_SHA "${GITHUB_SHA:-}"
|
|
need_env GITHUB_RUN_ID "${GITHUB_RUN_ID:-}"
|
|
need_env GITHUB_ACTOR "${GITHUB_ACTOR:-}"
|
|
need_env DWS_DEFAULT_BRANCH "$DEFAULT_BRANCH"
|
|
[ "${GITHUB_ACTIONS:-}" = true ] || err "withdrawal may run only inside GitHub Actions"
|
|
[ "$GITHUB_REPOSITORY" = "$OFFICIAL_REPOSITORY" ] ||
|
|
err "withdrawal is restricted to $OFFICIAL_REPOSITORY"
|
|
[ "$GITHUB_REF_NAME" = "$DEFAULT_BRANCH" ] ||
|
|
err "withdrawal must run from the default branch $DEFAULT_BRANCH"
|
|
printf '%s\n' "$GITHUB_SHA" | grep -Eq '^[0-9a-f]{40}$' ||
|
|
err "GITHUB_SHA must be a full lowercase commit SHA"
|
|
[ "$(git rev-parse HEAD)" = "$GITHUB_SHA" ] ||
|
|
err "checked out withdrawal tooling is not the dispatched commit"
|
|
|
|
local default_head
|
|
default_head="$(
|
|
github_api "repos/${OFFICIAL_REPOSITORY}/git/ref/heads/${DEFAULT_BRANCH}" --jq .object.sha
|
|
)"
|
|
[ "$default_head" = "$GITHUB_SHA" ] ||
|
|
err "default branch advanced to $default_head; re-dispatch withdrawal from the new head"
|
|
}
|
|
|
|
ensure_ossutil() {
|
|
OSSUTIL="${OSSUTIL:-ossutil}"
|
|
if command -v "$OSSUTIL" >/dev/null 2>&1; then
|
|
return
|
|
fi
|
|
|
|
local os arch expected archive extract found actual install_dir
|
|
case "$(uname -s)" in
|
|
Linux) os=linux ;;
|
|
Darwin) os=mac ;;
|
|
*) err "unsupported ossutil operating system: $(uname -s)" ;;
|
|
esac
|
|
arch="$(uname -m)"
|
|
case "$arch" in
|
|
x86_64|amd64) arch=amd64 ;;
|
|
aarch64|arm64) arch=arm64 ;;
|
|
esac
|
|
case "${os}-${arch}" in
|
|
linux-amd64) expected=3ae4d9fc85a7a6e9f5654d1599766f1a3a42a3692870887b5ae9338d582ef65a ;;
|
|
linux-arm64) expected=f6c95ba0c2d2ef30290af686ce4d706c701f4734ce8090bee4288a77e3f1d764 ;;
|
|
mac-amd64) expected=8437fdd3ef1a3eb12310f61fcf1c00a5bff5cdab47b4fea815527472e7cf896c ;;
|
|
mac-arm64) expected=058fd048f321f8c80def8b748030531646eefe3a82837bf16b581ba7d9c84ac7 ;;
|
|
*) err "unsupported ossutil architecture: ${os}-${arch}" ;;
|
|
esac
|
|
|
|
archive="$(mktemp "${TMPDIR:-/tmp}/ossutil-2.3.0.XXXXXX.zip")"
|
|
extract="$(mktemp -d "${TMPDIR:-/tmp}/ossutil-2.3.0.XXXXXX")"
|
|
curl -fsSL \
|
|
"https://gosspublic.alicdn.com/ossutil/v2/2.3.0/ossutil-2.3.0-${os}-${arch}.zip" \
|
|
-o "$archive"
|
|
if command -v sha256sum >/dev/null 2>&1; then
|
|
actual="$(sha256sum "$archive" | awk '{print $1}')"
|
|
else
|
|
actual="$(shasum -a 256 "$archive" | awk '{print $1}')"
|
|
fi
|
|
[ "$actual" = "$expected" ] || {
|
|
rm -rf "$archive" "$extract"
|
|
err "ossutil archive checksum mismatch for ${os}-${arch}"
|
|
}
|
|
unzip -qo "$archive" -d "$extract"
|
|
found="$(find "$extract" -name ossutil -type f | head -1)"
|
|
[ -n "$found" ] || {
|
|
rm -rf "$archive" "$extract"
|
|
err "verified ossutil archive does not contain the executable"
|
|
}
|
|
install_dir="$(mktemp -d "${TMPDIR:-/tmp}/dws-withdraw-ossutil.XXXXXX")"
|
|
cp "$found" "$install_dir/ossutil"
|
|
chmod 700 "$install_dir/ossutil"
|
|
rm -rf "$archive" "$extract"
|
|
OSSUTIL="$install_dir/ossutil"
|
|
WITHDRAW_OSSUTIL_DIR="$install_dir"
|
|
}
|
|
|
|
oss_cp() {
|
|
"$OSSUTIL" cp -f --endpoint "$OSS_ENDPOINT" "$1" "$2"
|
|
}
|
|
|
|
oss_get_pointer() {
|
|
local pointer_name="$1" output="$2"
|
|
oss_cp "oss://${OSS_BUCKET}/${OSS_PREFIX}/${pointer_name}" "$output"
|
|
}
|
|
|
|
read_oss_pointer() {
|
|
local pointer_name="$1" output
|
|
output="$(mktemp "${TMPDIR:-/tmp}/dws-withdraw-oss-pointer.XXXXXX")"
|
|
if ! oss_get_pointer "$pointer_name" "$output" >/dev/null; then
|
|
rm -f "$output"
|
|
err "could not read required OSS channel pointer ${pointer_name}"
|
|
fi
|
|
tr -d '[:space:]' <"$output"
|
|
rm -f "$output"
|
|
}
|
|
|
|
write_oss_pointer() {
|
|
local pointer_name="$1" version="$2" pointer verify
|
|
pointer="$(mktemp "${TMPDIR:-/tmp}/dws-withdraw-oss-pointer.XXXXXX")"
|
|
verify="$(mktemp "${TMPDIR:-/tmp}/dws-withdraw-oss-verify.XXXXXX")"
|
|
printf '%s\n' "$version" >"$pointer"
|
|
oss_cp "$pointer" "oss://${OSS_BUCKET}/${OSS_PREFIX}/${pointer_name}" >/dev/null
|
|
oss_get_pointer "$pointer_name" "$verify" >/dev/null
|
|
[ "$(tr -d '[:space:]' <"$verify")" = "$version" ] || {
|
|
rm -f "$pointer" "$verify"
|
|
err "OSS ${pointer_name} did not move to $version"
|
|
}
|
|
rm -f "$pointer" "$verify"
|
|
}
|
|
|
|
ensure_oss_rollback() {
|
|
local asset downloaded remote release_id
|
|
WITHDRAW_OSS_ROLLBACK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/dws-withdraw-oss-rollback.XXXXXX")"
|
|
release_id="$(release_field "$ROLLBACK_VERSION" id)"
|
|
DWS_GITHUB_RELEASE_ID="$release_id" \
|
|
"$GITHUB_DOWNLOAD_HELPER" "$ROLLBACK_VERSION" "$WITHDRAW_OSS_ROLLBACK_DIR"
|
|
DWS_PACKAGE_DIST_DIR="$WITHDRAW_OSS_ROLLBACK_DIR" \
|
|
"$ARTIFACT_VERIFY_HELPER" "$ROLLBACK_VERSION"
|
|
|
|
for asset 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
|
|
remote="oss://${OSS_BUCKET}/${OSS_PREFIX}/download/${ROLLBACK_VERSION}/${asset}"
|
|
oss_cp "$WITHDRAW_OSS_ROLLBACK_DIR/$asset" "$remote" >/dev/null
|
|
downloaded="$WITHDRAW_OSS_ROLLBACK_DIR/verified-$asset"
|
|
oss_cp "$remote" "$downloaded" >/dev/null
|
|
cmp -s "$WITHDRAW_OSS_ROLLBACK_DIR/$asset" "$downloaded" ||
|
|
err "OSS rollback asset verification failed: $asset"
|
|
done
|
|
rm -rf "$WITHDRAW_OSS_ROLLBACK_DIR"
|
|
WITHDRAW_OSS_ROLLBACK_DIR=""
|
|
say "OSS rollback release $ROLLBACK_VERSION is complete before moving the channel pointer."
|
|
}
|
|
|
|
prepare_tombstone_message() {
|
|
local reason_sha="$1" output="$2"
|
|
{
|
|
printf 'Withdrawn DWS release %s\n\n' "$VERSION"
|
|
printf 'Version: %s\n' "$VERSION"
|
|
printf 'Original-Tag-Object: %s\n' "$TARGET_TAG_OBJECT"
|
|
printf 'Original-Commit: %s\n' "$TARGET_COMMIT"
|
|
printf 'Original-Release-ID: %s\n' "$TARGET_RELEASE_ID"
|
|
printf 'Channel: %s\n' "$CHANNEL"
|
|
printf 'OSS-Mirror: %s\n' "$TARGET_OSS_MODE"
|
|
printf 'Reason-SHA256: %s\n' "$reason_sha"
|
|
printf 'Reason: %s\n' "$REASON"
|
|
printf 'Requested-By: %s\n' "$GITHUB_ACTOR"
|
|
printf 'Request-Run: %s\n' "$GITHUB_RUN_ID"
|
|
} >"$output"
|
|
}
|
|
|
|
verify_existing_tombstone() {
|
|
local reason_sha="$1" ref_sha tombstone_json parsed status
|
|
local actual_target original_tag_object original_commit original_release_id
|
|
local tombstone_channel tombstone_version tombstone_oss_mode tombstone_reason_sha tombstone_reason
|
|
set +e
|
|
ref_sha="$(
|
|
github_optional \
|
|
"repos/${OFFICIAL_REPOSITORY}/git/ref/tags/${TOMBSTONE}" \
|
|
--jq .object.sha
|
|
)"
|
|
status=$?
|
|
set -e
|
|
case "$status" in
|
|
0) ;;
|
|
4) return 1 ;;
|
|
*) err "could not query permanent tombstone $TOMBSTONE" ;;
|
|
esac
|
|
printf '%s\n' "$ref_sha" | grep -Eq '^[0-9a-f]{40}$' ||
|
|
err "existing tombstone $TOMBSTONE has an invalid tag object"
|
|
tombstone_json="$(
|
|
github_api "repos/${OFFICIAL_REPOSITORY}/git/tags/${ref_sha}"
|
|
)" || err "existing tombstone $TOMBSTONE is not an annotated tag"
|
|
parsed="$(
|
|
printf '%s' "$tombstone_json" |
|
|
python3 -c '
|
|
import json
|
|
import re
|
|
import sys
|
|
|
|
expected_tag = sys.argv[1]
|
|
payload = json.load(sys.stdin)
|
|
obj = payload.get("object", {})
|
|
if (
|
|
payload.get("tag") != expected_tag
|
|
or obj.get("type") != "commit"
|
|
or not re.fullmatch(r"[0-9a-f]{40}", obj.get("sha", ""))
|
|
):
|
|
raise SystemExit(1)
|
|
fields = {}
|
|
for line in payload.get("message", "").splitlines():
|
|
if ": " not in line:
|
|
continue
|
|
key, value = line.split(": ", 1)
|
|
if key in fields:
|
|
raise SystemExit(1)
|
|
fields[key] = value
|
|
required = (
|
|
"Version",
|
|
"Original-Tag-Object",
|
|
"Original-Commit",
|
|
"Original-Release-ID",
|
|
"Channel",
|
|
"Reason-SHA256",
|
|
"Reason",
|
|
"Requested-By",
|
|
"Request-Run",
|
|
)
|
|
if any(not fields.get(key) for key in required):
|
|
raise SystemExit(1)
|
|
if not re.fullmatch(r"[0-9a-f]{40}", fields["Original-Tag-Object"]):
|
|
raise SystemExit(1)
|
|
if not re.fullmatch(r"[0-9a-f]{40}", fields["Original-Commit"]):
|
|
raise SystemExit(1)
|
|
if not re.fullmatch(r"[1-9][0-9]*", fields["Original-Release-ID"]):
|
|
raise SystemExit(1)
|
|
if not re.fullmatch(r"[0-9a-f]{64}", fields["Reason-SHA256"]):
|
|
raise SystemExit(1)
|
|
oss_mode = fields.get("OSS-Mirror", "enabled")
|
|
if oss_mode not in {"enabled", "deferred"}:
|
|
raise SystemExit(1)
|
|
print("\t".join((
|
|
obj["sha"],
|
|
fields["Original-Tag-Object"],
|
|
fields["Original-Commit"],
|
|
fields["Original-Release-ID"],
|
|
fields["Channel"],
|
|
fields["Version"],
|
|
oss_mode,
|
|
fields["Reason-SHA256"],
|
|
fields["Reason"],
|
|
)))' "$TOMBSTONE"
|
|
)" || err "existing tombstone $TOMBSTONE has invalid immutable withdrawal metadata"
|
|
actual_target="$(printf '%s\n' "$parsed" | cut -f1)"
|
|
original_tag_object="$(printf '%s\n' "$parsed" | cut -f2)"
|
|
original_commit="$(printf '%s\n' "$parsed" | cut -f3)"
|
|
original_release_id="$(printf '%s\n' "$parsed" | cut -f4)"
|
|
tombstone_channel="$(printf '%s\n' "$parsed" | cut -f5)"
|
|
tombstone_version="$(printf '%s\n' "$parsed" | cut -f6)"
|
|
tombstone_oss_mode="$(printf '%s\n' "$parsed" | cut -f7)"
|
|
tombstone_reason_sha="$(printf '%s\n' "$parsed" | cut -f8)"
|
|
tombstone_reason="$(printf '%s\n' "$parsed" | cut -f9-)"
|
|
[ "$actual_target" = "$original_commit" ] &&
|
|
[ "$tombstone_version" = "$VERSION" ] &&
|
|
[ "$tombstone_channel" = "$CHANNEL" ] &&
|
|
[ "$tombstone_oss_mode" = "${TARGET_OSS_MODE:-$tombstone_oss_mode}" ] &&
|
|
[ "$tombstone_reason_sha" = "$reason_sha" ] &&
|
|
[ "$tombstone_reason" = "$REASON" ] ||
|
|
err "existing tombstone $TOMBSTONE has different immutable withdrawal metadata"
|
|
if [ -n "${TARGET_TAG_OBJECT:-}" ] && [ "$TARGET_TAG_OBJECT" != "$original_tag_object" ]; then
|
|
err "existing tombstone $TOMBSTONE records a different original tag object"
|
|
fi
|
|
if [ -n "${TARGET_COMMIT:-}" ] && [ "$TARGET_COMMIT" != "$original_commit" ]; then
|
|
err "existing tombstone $TOMBSTONE records a different original commit"
|
|
fi
|
|
if [ -n "${TARGET_RELEASE_ID:-}" ] && [ "$TARGET_RELEASE_ID" != "$original_release_id" ]; then
|
|
err "existing tombstone $TOMBSTONE records a different original release"
|
|
fi
|
|
TARGET_TAG_OBJECT="$original_tag_object"
|
|
TARGET_COMMIT="$original_commit"
|
|
TARGET_RELEASE_ID="$original_release_id"
|
|
TARGET_OSS_MODE="$tombstone_oss_mode"
|
|
}
|
|
|
|
create_tombstone() {
|
|
local message_file message reason_sha tag_sha ref_sha create_ref_error
|
|
reason_sha="$(printf '%s' "$REASON" | sha256_text)"
|
|
message_file="$(mktemp "${TMPDIR:-/tmp}/dws-withdraw-tombstone.XXXXXX")"
|
|
prepare_tombstone_message "$reason_sha" "$message_file"
|
|
message="$(cat "$message_file")"
|
|
rm -f "$message_file"
|
|
|
|
if verify_existing_tombstone "$reason_sha"; then
|
|
say "Permanent tombstone $TOMBSTONE already exists with the exact withdrawal metadata."
|
|
return
|
|
fi
|
|
|
|
tag_sha="$(
|
|
github_api --method POST "repos/${OFFICIAL_REPOSITORY}/git/tags" \
|
|
-f tag="$TOMBSTONE" \
|
|
-f message="$message" \
|
|
-f object="$TARGET_COMMIT" \
|
|
-f type=commit \
|
|
--jq .sha
|
|
)"
|
|
printf '%s\n' "$tag_sha" | grep -Eq '^[0-9a-f]{40}$' ||
|
|
err "GitHub returned an invalid tombstone tag object"
|
|
|
|
create_ref_error="$(mktemp "${TMPDIR:-/tmp}/dws-withdraw-ref-error.XXXXXX")"
|
|
if ! github_api --method POST "repos/${OFFICIAL_REPOSITORY}/git/refs" \
|
|
-f ref="refs/tags/${TOMBSTONE}" \
|
|
-f sha="$tag_sha" >/dev/null 2>"$create_ref_error"; then
|
|
if verify_existing_tombstone "$reason_sha"; then
|
|
say "Permanent tombstone $TOMBSTONE was created concurrently with identical metadata."
|
|
rm -f "$create_ref_error"
|
|
return
|
|
fi
|
|
cat "$create_ref_error" >&2
|
|
rm -f "$create_ref_error"
|
|
err "could not create permanent tombstone $TOMBSTONE"
|
|
fi
|
|
rm -f "$create_ref_error"
|
|
verify_existing_tombstone "$reason_sha" ||
|
|
err "permanent tombstone $TOMBSTONE could not be verified"
|
|
say "Created permanent tombstone $TOMBSTONE. This ref must never be moved or deleted."
|
|
}
|
|
|
|
update_github_release() {
|
|
local original_body begin_marker end_marker block notes payload state
|
|
if [ "${TARGET_RELEASE_EXISTS:-false}" != true ]; then
|
|
say "GitHub Release $VERSION is already absent; permanent tombstone metadata authorizes resumption."
|
|
return
|
|
fi
|
|
begin_marker="<!-- dws-release-withdrawn-begin version=${VERSION} -->"
|
|
end_marker="<!-- dws-release-withdrawn-end version=${VERSION} -->"
|
|
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 "<!-- dws-release-withdrawn-begin " in (d.get("body") or "") else "unmarked",
|
|
str(bool(d.get("immutable"))).lower(),
|
|
))'
|
|
)"
|
|
[ "$state" = "$(printf '[WITHDRAWN] %s\tmarked\ttrue' "$VERSION")" ] ||
|
|
err "GitHub Release was not durably marked withdrawn"
|
|
}
|
|
|
|
update_npm_channel() {
|
|
local deprecation_message delivered_deprecation delivered_pointer current_pointer
|
|
deprecation_message="WITHDRAWN ${VERSION}: ${REASON}. Use ${ROLLBACK_VERSION} or a later fixed version."
|
|
npm deprecate "${PACKAGE_NAME}@${VERSION#v}" "$deprecation_message" \
|
|
--registry=https://registry.npmjs.org
|
|
if ! delivered_deprecation="$(npm_deprecation "$VERSION")"; then
|
|
err "could not verify npm deprecation for ${VERSION#v}"
|
|
fi
|
|
[ "$delivered_deprecation" = "$deprecation_message" ] ||
|
|
err "npm did not retain the exact withdrawal deprecation for ${VERSION#v}"
|
|
|
|
current_pointer="$(
|
|
npm view "$PACKAGE_NAME" "dist-tags.${NPM_TAG}" --registry=https://registry.npmjs.org
|
|
)"
|
|
require_safe_channel_pointer "v$current_pointer" "$CHANNEL"
|
|
if [ "$current_pointer" = "${VERSION#v}" ]; then
|
|
npm dist-tag add "${PACKAGE_NAME}@${ROLLBACK_VERSION#v}" "$NPM_TAG" \
|
|
--registry=https://registry.npmjs.org
|
|
fi
|
|
delivered_pointer="$(
|
|
npm view "$PACKAGE_NAME" "dist-tags.${NPM_TAG}" --registry=https://registry.npmjs.org
|
|
)"
|
|
[ "$delivered_pointer" != "${VERSION#v}" ] ||
|
|
err "npm $NPM_TAG still points to withdrawn ${VERSION#v}"
|
|
require_safe_channel_pointer "v$delivered_pointer" "$CHANNEL"
|
|
say "npm ${NPM_TAG} now resolves to v${delivered_pointer}; ${VERSION#v} is deprecated."
|
|
}
|
|
|
|
update_oss_channel() {
|
|
local pointer_name="$1" current listing base
|
|
current="$(read_oss_pointer "$pointer_name")"
|
|
require_safe_channel_pointer "$current" "$CHANNEL"
|
|
if [ "$current" = "$VERSION" ]; then
|
|
write_oss_pointer "$pointer_name" "$ROLLBACK_VERSION"
|
|
current="$ROLLBACK_VERSION"
|
|
fi
|
|
require_safe_channel_pointer "$current" "$CHANNEL"
|
|
|
|
base="oss://${OSS_BUCKET}/${OSS_PREFIX}/download/${VERSION}/"
|
|
"$OSSUTIL" rm -rf --endpoint "$OSS_ENDPOINT" "$base" >/dev/null
|
|
listing="$("$OSSUTIL" ls --endpoint "$OSS_ENDPOINT" "$base")"
|
|
if printf '%s\n' "$listing" | grep -Fq "${OSS_PREFIX}/download/${VERSION}/"; then
|
|
err "OSS still exposes objects beneath ${OSS_PREFIX}/download/${VERSION}/"
|
|
fi
|
|
say "OSS ${pointer_name} resolves to $current; the withdrawn version prefix was removed."
|
|
}
|
|
|
|
gitee_release_id() {
|
|
local lookup_version="$1" response status release_id
|
|
local -a auth_args
|
|
auth_args=()
|
|
if [ -n "${GITEE_TOKEN:-}" ]; then
|
|
auth_args=(-H "Authorization: token ${GITEE_TOKEN}")
|
|
fi
|
|
response="$(mktemp "${TMPDIR:-/tmp}/dws-withdraw-gitee-release.XXXXXX")"
|
|
if ! status="$(
|
|
curl -sS --connect-timeout 15 --max-time 60 \
|
|
-o "$response" -w '%{http_code}' \
|
|
"${auth_args[@]}" \
|
|
"${GITEE_API}/repos/${GITEE_REPO}/releases/tags/${lookup_version}"
|
|
)"; then
|
|
rm -f "$response"
|
|
printf 'could not query Gitee release %s\n' "$lookup_version" >&2
|
|
return 1
|
|
fi
|
|
case "$status" in
|
|
200)
|
|
release_id="$(
|
|
python3 -c 'import json,sys
|
|
d=json.load(sys.stdin)
|
|
value=d.get("id")
|
|
if isinstance(value, int) and value > 0:
|
|
print(value)
|
|
' <"$response" 2>/dev/null || true
|
|
)"
|
|
rm -f "$response"
|
|
[ -n "$release_id" ] || {
|
|
printf 'Gitee returned invalid release metadata for %s\n' "$lookup_version" >&2
|
|
return 1
|
|
}
|
|
printf '%s\n' "$release_id"
|
|
;;
|
|
404)
|
|
rm -f "$response"
|
|
return 4
|
|
;;
|
|
*)
|
|
rm -f "$response"
|
|
printf 'Gitee release lookup for %s returned HTTP %s\n' "$lookup_version" "$status" >&2
|
|
return 1
|
|
;;
|
|
esac
|
|
}
|
|
|
|
gitee_remote_tag_commit() {
|
|
local version="${1:-$VERSION}" refs
|
|
refs="$(
|
|
git ls-remote "$GITEE_PUBLIC_GIT_REMOTE" \
|
|
"refs/tags/${version}" "refs/tags/${version}^{}"
|
|
)" || return 1
|
|
printf '%s\n' "$refs" | awk -v tag="refs/tags/${version}" '
|
|
$2 == tag "^{}" { peeled=$1 }
|
|
$2 == tag { direct=$1 }
|
|
END {
|
|
if (peeled != "") print peeled
|
|
else if (direct != "") print direct
|
|
}
|
|
'
|
|
}
|
|
|
|
ensure_gitee_rollback() {
|
|
local dist rollback_commit rollback_remote_commit rollback_release_id release_id lookup_status
|
|
WITHDRAW_GITEE_ROLLBACK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/dws-withdraw-gitee-rollback.XXXXXX")"
|
|
dist="$WITHDRAW_GITEE_ROLLBACK_DIR"
|
|
rollback_release_id="$(release_field "$ROLLBACK_VERSION" id)"
|
|
DWS_GITHUB_RELEASE_ID="$rollback_release_id" \
|
|
"$GITHUB_DOWNLOAD_HELPER" "$ROLLBACK_VERSION" "$dist"
|
|
DWS_PACKAGE_DIST_DIR="$dist" "$ARTIFACT_VERIFY_HELPER" "$ROLLBACK_VERSION"
|
|
VERSION="$ROLLBACK_VERSION" \
|
|
DIST_DIR="$dist" \
|
|
GITEE_TOKEN="$GITEE_TOKEN" \
|
|
GITEE_USER="$GITEE_USER" \
|
|
GITEE_REPO="$GITEE_REPO" \
|
|
GITEE_GIT_REMOTE="$GITEE_GIT_REMOTE" \
|
|
GITEE_PUBLIC_GIT_REMOTE="$GITEE_PUBLIC_GIT_REMOTE" \
|
|
DWS_REQUIRE_GITEE=1 \
|
|
"$GITEE_SYNC_HELPER"
|
|
rm -rf "$dist"
|
|
WITHDRAW_GITEE_ROLLBACK_DIR=""
|
|
|
|
rollback_commit="$(tag_commit "$ROLLBACK_VERSION")"
|
|
rollback_remote_commit="$(gitee_remote_tag_commit "$ROLLBACK_VERSION")" ||
|
|
err "could not verify Gitee rollback tag $ROLLBACK_VERSION"
|
|
[ "$rollback_remote_commit" = "$rollback_commit" ] ||
|
|
err "Gitee rollback tag $ROLLBACK_VERSION is not aligned to $rollback_commit"
|
|
set +e
|
|
release_id="$(gitee_release_id "$ROLLBACK_VERSION")"
|
|
lookup_status=$?
|
|
set -e
|
|
[ "$lookup_status" -eq 0 ] && [ -n "$release_id" ] ||
|
|
err "Gitee rollback release $ROLLBACK_VERSION is not available"
|
|
say "Gitee rollback release $ROLLBACK_VERSION is complete before withdrawing $VERSION."
|
|
}
|
|
|
|
withdraw_gitee() {
|
|
local remote_commit release_id lookup_status tmp askpass remaining_commit remaining_status
|
|
remote_commit="$(gitee_remote_tag_commit)" ||
|
|
err "could not inspect the Gitee tag before withdrawal"
|
|
set +e
|
|
release_id="$(gitee_release_id "$VERSION")"
|
|
lookup_status=$?
|
|
set -e
|
|
case "$lookup_status" in
|
|
0) ;;
|
|
4) release_id="" ;;
|
|
*) err "could not determine whether Gitee release $VERSION exists" ;;
|
|
esac
|
|
if [ -n "$remote_commit" ]; then
|
|
[ "$remote_commit" = "$TARGET_COMMIT" ] ||
|
|
err "Gitee tag $VERSION points to $remote_commit, not the GitHub release commit"
|
|
fi
|
|
if [ -n "$release_id" ]; then
|
|
curl -fsS -X DELETE \
|
|
-H "Authorization: token ${GITEE_TOKEN}" \
|
|
"${GITEE_API}/repos/${GITEE_REPO}/releases/${release_id}" >/dev/null
|
|
set +e
|
|
gitee_release_id "$VERSION" >/dev/null
|
|
lookup_status=$?
|
|
set -e
|
|
case "$lookup_status" in
|
|
4) ;;
|
|
0) err "Gitee release $VERSION still exists after deletion" ;;
|
|
*) err "could not verify deletion of Gitee release $VERSION" ;;
|
|
esac
|
|
fi
|
|
if [ -z "$remote_commit" ]; then
|
|
say "Gitee release and tag $VERSION are already absent."
|
|
return
|
|
fi
|
|
|
|
tmp="$(mktemp -d "${TMPDIR:-/tmp}/dws-withdraw-gitee.XXXXXX")"
|
|
askpass="$tmp/askpass.sh"
|
|
{
|
|
printf '%s\n' '#!/bin/sh'
|
|
# shellcheck disable=SC2016 # These variables belong to the generated script.
|
|
printf '%s\n' 'case "$1" in'
|
|
# shellcheck disable=SC2016 # These variables belong to the generated script.
|
|
printf '%s\n' ' *Username*) printf "%s\n" "$DWS_GITEE_USER" ;;'
|
|
# shellcheck disable=SC2016 # These variables belong to the generated script.
|
|
printf '%s\n' ' *) printf "%s\n" "$DWS_GITEE_TOKEN" ;;'
|
|
printf '%s\n' 'esac'
|
|
} >"$askpass"
|
|
chmod 700 "$askpass"
|
|
DWS_GITEE_USER="$GITEE_USER" \
|
|
DWS_GITEE_TOKEN="$GITEE_TOKEN" \
|
|
GIT_ASKPASS="$askpass" \
|
|
GIT_TERMINAL_PROMPT=0 \
|
|
git push "$GITEE_GIT_REMOTE" ":refs/tags/${VERSION}" >/dev/null
|
|
rm -rf "$tmp"
|
|
set +e
|
|
remaining_commit="$(gitee_remote_tag_commit)"
|
|
remaining_status=$?
|
|
set -e
|
|
[ "$remaining_status" -eq 0 ] ||
|
|
err "could not verify Gitee tag deletion for $VERSION"
|
|
[ -z "$remaining_commit" ] ||
|
|
err "Gitee tag $VERSION still exists after deletion"
|
|
say "Removed the withdrawn release and tag from the Gitee distribution mirror."
|
|
}
|
|
|
|
checksum_for() {
|
|
local file="$1" asset="$2"
|
|
awk -v wanted="$asset" '
|
|
$2 == wanted && $1 ~ /^[0-9a-f]{64}$/ { print $1 }
|
|
' "$file"
|
|
}
|
|
|
|
render_homebrew_rollback_formula() {
|
|
local output="$1" checksum_file="$2" release_version="${3:-$ROLLBACK_VERSION}"
|
|
local class_name description keg_only caveat semver base
|
|
local darwin_amd64 darwin_arm64 linux_amd64 linux_arm64 skills
|
|
semver="${release_version#v}"
|
|
base="https://github.com/${OFFICIAL_REPOSITORY}/releases/download/${release_version}"
|
|
darwin_amd64="$(checksum_for "$checksum_file" dws-darwin-amd64.tar.gz)"
|
|
darwin_arm64="$(checksum_for "$checksum_file" dws-darwin-arm64.tar.gz)"
|
|
linux_amd64="$(checksum_for "$checksum_file" dws-linux-amd64.tar.gz)"
|
|
linux_arm64="$(checksum_for "$checksum_file" dws-linux-arm64.tar.gz)"
|
|
skills="$(checksum_for "$checksum_file" dws-skills.zip)"
|
|
for digest in "$darwin_amd64" "$darwin_arm64" "$linux_amd64" "$linux_arm64" "$skills"; do
|
|
printf '%s\n' "$digest" | grep -Eq '^[0-9a-f]{64}$' ||
|
|
err "rollback release checksums are incomplete"
|
|
done
|
|
|
|
class_name=DingtalkWorkspaceCli
|
|
description='Automate DingTalk workspace tasks from the terminal'
|
|
keg_only=''
|
|
caveat=''
|
|
if [ "$CHANNEL" = prerelease ]; then
|
|
class_name=DingtalkWorkspaceCliBeta
|
|
description="${description} (beta channel)"
|
|
keg_only=' keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"'
|
|
# shellcheck disable=SC2016 # Preserve Homebrew's Ruby interpolation.
|
|
caveat=' This beta is keg-only. Add #{opt_bin} to PATH to use its `dws` binary.'
|
|
fi
|
|
|
|
sed \
|
|
-e "s|__CLASS_NAME__|$class_name|g" \
|
|
-e "s|__DESCRIPTION__|$description|g" \
|
|
-e "s|__VERSION__|$semver|g" \
|
|
-e "s|__DARWIN_AMD64_URL__|$base/dws-darwin-amd64.tar.gz|g" \
|
|
-e "s|__DARWIN_AMD64_SHA256__|$darwin_amd64|g" \
|
|
-e "s|__DARWIN_ARM64_URL__|$base/dws-darwin-arm64.tar.gz|g" \
|
|
-e "s|__DARWIN_ARM64_SHA256__|$darwin_arm64|g" \
|
|
-e "s|__LINUX_AMD64_URL__|$base/dws-linux-amd64.tar.gz|g" \
|
|
-e "s|__LINUX_AMD64_SHA256__|$linux_amd64|g" \
|
|
-e "s|__LINUX_ARM64_URL__|$base/dws-linux-arm64.tar.gz|g" \
|
|
-e "s|__LINUX_ARM64_SHA256__|$linux_arm64|g" \
|
|
-e "s|__SKILLS_URL__|$base/dws-skills.zip|g" \
|
|
-e "s|__SKILLS_SHA256__|$skills|g" \
|
|
-e "s|__KEG_ONLY_LINE__|$keg_only|g" \
|
|
-e "s|__CHANNEL_CAVEAT__|$caveat|g" \
|
|
"$ROOT/build/homebrew-release.rb.tmpl" >"$output"
|
|
}
|
|
|
|
formula_version() {
|
|
awk '$1 == "version" { gsub(/"/, "", $2); print "v" $2; exit }' "$1"
|
|
}
|
|
|
|
update_homebrew() {
|
|
local formula_path source_path current desired checksum_dir checksum_file
|
|
formula_path=Formula/dingtalk-workspace-cli.rb
|
|
source_path="$(mktemp "${TMPDIR:-/tmp}/dws-withdraw-formula.XXXXXX")"
|
|
if [ "$CHANNEL" = prerelease ]; then
|
|
formula_path=Formula/dingtalk-workspace-cli-beta.rb
|
|
fi
|
|
[ -f "$ROOT/$formula_path" ] ||
|
|
err "tracked Homebrew channel formula is missing: $formula_path"
|
|
current="$(formula_version "$ROOT/$formula_path")"
|
|
desired="$ROLLBACK_VERSION"
|
|
if [ "$current" != "$VERSION" ]; then
|
|
require_safe_channel_pointer "$current" "$CHANNEL"
|
|
desired="$current"
|
|
fi
|
|
|
|
checksum_dir="$(mktemp -d "${TMPDIR:-/tmp}/dws-withdraw-checksums.XXXXXX")"
|
|
gh release download "$desired" \
|
|
--repo "$OFFICIAL_REPOSITORY" \
|
|
--pattern checksums.txt \
|
|
--dir "$checksum_dir"
|
|
checksum_file="$checksum_dir/checksums.txt"
|
|
[ -s "$checksum_file" ] || err "rollback release checksums.txt is missing"
|
|
render_homebrew_rollback_formula "$source_path" "$checksum_file" "$desired"
|
|
ruby -c "$source_path" >/dev/null || err "rendered rollback Homebrew formula is invalid"
|
|
|
|
if [ "$current" != "$VERSION" ]; then
|
|
cmp -s "$ROOT/$formula_path" "$source_path" ||
|
|
err "tracked Homebrew formula for $current does not exactly match its published checksums"
|
|
rm -rf "$checksum_dir"
|
|
rm -f "$source_path"
|
|
say "Homebrew ${formula_path} is fully verified at $current and avoids $VERSION."
|
|
return
|
|
fi
|
|
|
|
DWS_FORMULA_SOURCE="$source_path" \
|
|
DWS_TAP_FORMULA_PATH="$formula_path" \
|
|
DWS_TAP_REPO_URL="${DWS_TAP_REPO_URL:-https://github.com/${OFFICIAL_REPOSITORY}.git}" \
|
|
DWS_TAP_GITHUB_TOKEN="$HOMEBREW_PR_TOKEN" \
|
|
DWS_TAP_PR_REPOSITORY="$OFFICIAL_REPOSITORY" \
|
|
DWS_TAP_PR_BRANCH="automation/withdraw-${VERSION//./-}" \
|
|
DWS_TAP_PR_TITLE="revert: withdraw ${VERSION} and restore ${ROLLBACK_VERSION}" \
|
|
DWS_TAP_COMMIT_MESSAGE="revert: restore formula to ${ROLLBACK_VERSION}" \
|
|
"$SCRIPT_DIR/publish-homebrew-formula.sh"
|
|
rm -rf "$checksum_dir"
|
|
rm -f "$source_path"
|
|
HOMEBREW_ROLLBACK_PENDING=true
|
|
say "Homebrew rollback PR requires independent review and merge; this run will finish the other channel withdrawals and then remain failed."
|
|
}
|
|
|
|
delete_github_release_and_tag() {
|
|
local latest_before latest_after reason_sha
|
|
latest_before="$(
|
|
github_api "repos/${OFFICIAL_REPOSITORY}/releases/latest" --jq .tag_name
|
|
)"
|
|
|
|
github_delete_if_present \
|
|
"repos/${OFFICIAL_REPOSITORY}/releases/${TARGET_RELEASE_ID}" \
|
|
"GitHub Release $VERSION"
|
|
github_expect_404 "repos/${OFFICIAL_REPOSITORY}/releases/tags/${VERSION}"
|
|
|
|
github_delete_if_present \
|
|
"repos/${OFFICIAL_REPOSITORY}/git/refs/tags/${VERSION}" \
|
|
"GitHub tag $VERSION"
|
|
github_expect_404 "repos/${OFFICIAL_REPOSITORY}/git/ref/tags/${VERSION}"
|
|
|
|
reason_sha="$(printf '%s' "$REASON" | sha256_text)"
|
|
verify_existing_tombstone "$reason_sha" ||
|
|
err "permanent tombstone $TOMBSTONE disappeared during final GitHub cleanup"
|
|
|
|
latest_after="$(
|
|
github_api "repos/${OFFICIAL_REPOSITORY}/releases/latest" --jq .tag_name
|
|
)"
|
|
if [ "$CHANNEL" = stable ] && [ "$latest_before" = "$VERSION" ]; then
|
|
[ "$latest_after" = "$ROLLBACK_VERSION" ] ||
|
|
err "GitHub latest resolved to $latest_after, expected rollback $ROLLBACK_VERSION"
|
|
else
|
|
[ "$latest_after" = "$latest_before" ] ||
|
|
err "GitHub latest changed unexpectedly from $latest_before to $latest_after"
|
|
release_is_eligible_rollback "$latest_after" stable ||
|
|
err "GitHub latest does not resolve to a safe stable release: $latest_after"
|
|
fi
|
|
say "Deleted GitHub Release and original tag $VERSION after package and mirror rollback; permanent tombstone $TOMBSTONE remains."
|
|
}
|
|
|
|
cleanup() {
|
|
if [ -n "${WITHDRAW_OSSUTIL_DIR:-}" ]; then
|
|
rm -rf "$WITHDRAW_OSSUTIL_DIR"
|
|
fi
|
|
if [ -n "${WITHDRAW_OSS_ROLLBACK_DIR:-}" ]; then
|
|
rm -rf "$WITHDRAW_OSS_ROLLBACK_DIR"
|
|
fi
|
|
if [ -n "${WITHDRAW_GITEE_ROLLBACK_DIR:-}" ]; then
|
|
rm -rf "$WITHDRAW_GITEE_ROLLBACK_DIR"
|
|
fi
|
|
}
|
|
trap cleanup EXIT HUP INT TERM
|
|
|
|
validate_inputs
|
|
for command in git gh npm curl python3 awk sed grep sort unzip ruby cmp cut; do
|
|
need_cmd "$command"
|
|
done
|
|
need_env GITHUB_TOKEN "${GITHUB_TOKEN:-}"
|
|
need_env NODE_AUTH_TOKEN "${NODE_AUTH_TOKEN:-}"
|
|
|
|
validate_context
|
|
git fetch --force --tags origin
|
|
|
|
CHANNEL="$(release_channel_for_version "$VERSION")"
|
|
TARGET_TAG_OBJECT=""
|
|
TARGET_COMMIT=""
|
|
TARGET_RELEASE_ID=""
|
|
TARGET_TAG_EXISTS=false
|
|
TARGET_RELEASE_EXISTS=false
|
|
TARGET_OSS_MODE=""
|
|
|
|
set +e
|
|
remote_tag_object="$(
|
|
github_optional \
|
|
"repos/${OFFICIAL_REPOSITORY}/git/ref/tags/${VERSION}" \
|
|
--jq .object.sha
|
|
)"
|
|
remote_tag_status=$?
|
|
set -e
|
|
case "$remote_tag_status" in
|
|
0)
|
|
TARGET_TAG_EXISTS=true
|
|
TARGET_TAG_OBJECT="$(tag_object "$VERSION" || true)"
|
|
TARGET_COMMIT="$(tag_commit "$VERSION" || true)"
|
|
[ "$TARGET_TAG_OBJECT" = "$remote_tag_object" ] ||
|
|
err "local $VERSION tag object differs from the authoritative GitHub ref"
|
|
[ "$(git cat-file -t "$TARGET_TAG_OBJECT" 2>/dev/null || true)" = tag ] ||
|
|
err "$VERSION must be an annotated GitHub release tag"
|
|
printf '%s\n' "$TARGET_COMMIT" | grep -Eq '^[0-9a-f]{40}$' ||
|
|
err "could not resolve the release commit for $VERSION"
|
|
TARGET_OSS_MODE="$("$SCRIPT_DIR/release-tag-oss-mode.sh" "$VERSION")" ||
|
|
err "could not resolve immutable OSS policy for $VERSION"
|
|
;;
|
|
4) ;;
|
|
*) err "could not inspect the authoritative GitHub tag $VERSION" ;;
|
|
esac
|
|
|
|
set +e
|
|
target_json="$(
|
|
github_optional "repos/${OFFICIAL_REPOSITORY}/releases/tags/$VERSION"
|
|
)"
|
|
target_release_status=$?
|
|
set -e
|
|
case "$target_release_status" in
|
|
0)
|
|
TARGET_RELEASE_EXISTS=true
|
|
target_state="$(
|
|
printf '%s' "$target_json" |
|
|
python3 -c 'import json,sys
|
|
d=json.load(sys.stdin)
|
|
print("%s\t%s\t%s\t%s\t%s" % (
|
|
d.get("tag_name", ""),
|
|
str(bool(d.get("draft"))).lower(),
|
|
str(bool(d.get("prerelease"))).lower(),
|
|
str(bool(d.get("immutable"))).lower(),
|
|
d.get("id", ""),
|
|
))'
|
|
)"
|
|
expected_state="$(printf '%s\tfalse\t%s\ttrue' "$VERSION" "$(expected_prerelease "$CHANNEL")")"
|
|
case "$target_state" in
|
|
"$expected_state"$'\t'*) ;;
|
|
*) err "$VERSION is not the exact public immutable GitHub Release for its channel" ;;
|
|
esac
|
|
TARGET_RELEASE_ID="${target_state##*$'\t'}"
|
|
printf '%s\n' "$TARGET_RELEASE_ID" | grep -Eq '^[1-9][0-9]*$' ||
|
|
err "GitHub Release has an invalid database ID"
|
|
;;
|
|
4) ;;
|
|
*) err "could not inspect the authoritative GitHub Release $VERSION" ;;
|
|
esac
|
|
|
|
if [ "$TARGET_TAG_EXISTS" != true ] || [ "$TARGET_RELEASE_EXISTS" != true ]; then
|
|
reason_sha="$(printf '%s' "$REASON" | sha256_text)"
|
|
verify_existing_tombstone "$reason_sha" ||
|
|
err "$VERSION is partially absent without the exact permanent withdrawal tombstone"
|
|
say "Resuming withdrawal for $VERSION from exact permanent tombstone metadata."
|
|
fi
|
|
|
|
case "$TARGET_OSS_MODE" in
|
|
enabled)
|
|
OSS_ENABLED=true
|
|
need_env OSS_ACCESS_KEY_ID "${OSS_ACCESS_KEY_ID:-}"
|
|
need_env OSS_ACCESS_KEY_SECRET "${OSS_ACCESS_KEY_SECRET:-}"
|
|
need_env OSS_ENDPOINT "${OSS_ENDPOINT:-}"
|
|
need_env OSS_BUCKET "${OSS_BUCKET:-}"
|
|
case "$OSS_PREFIX" in
|
|
''|/*|*'..'*|*'//'*) err "OSS_PREFIX must be a non-empty safe relative prefix" ;;
|
|
*[!A-Za-z0-9._/-]*) err "OSS_PREFIX contains unsupported characters" ;;
|
|
esac
|
|
;;
|
|
deferred) OSS_ENABLED=false ;;
|
|
*) err "could not resolve immutable OSS policy for $VERSION" ;;
|
|
esac
|
|
|
|
[ "$(npm_exact_version "$VERSION" || true)" = "${VERSION#v}" ] ||
|
|
err "npm exact version ${PACKAGE_NAME}@${VERSION#v} is not published"
|
|
if [ "$TARGET_TAG_EXISTS" = true ] && [ "$TARGET_RELEASE_EXISTS" = true ]; then
|
|
DWS_RELEASE_OFFICIAL_REPOSITORY="$OFFICIAL_REPOSITORY" \
|
|
DWS_RELEASE_GITHUB_TOKEN="$GITHUB_TOKEN" \
|
|
"$DELIVERY_VERIFIER" "$VERSION" "$TARGET_COMMIT" >/dev/null ||
|
|
err "$VERSION has no complete Release workflow delivery proof"
|
|
fi
|
|
|
|
ROLLBACK_VERSION="$(find_rollback_version "$CHANNEL" || true)"
|
|
[ -n "$ROLLBACK_VERSION" ] ||
|
|
err "no earlier delivered, non-withdrawn $CHANNEL release is available for rollback"
|
|
say "Withdrawal plan: $VERSION ($CHANNEL) -> $ROLLBACK_VERSION"
|
|
|
|
if [ "$CHANNEL" = stable ]; then
|
|
NPM_TAG=latest
|
|
else
|
|
NPM_TAG=beta
|
|
fi
|
|
NPM_POINTER_BEFORE="$(
|
|
npm view "$PACKAGE_NAME" "dist-tags.${NPM_TAG}" --registry=https://registry.npmjs.org
|
|
)"
|
|
[ -n "$NPM_POINTER_BEFORE" ] || err "npm $NPM_TAG is empty"
|
|
require_safe_channel_pointer "v$NPM_POINTER_BEFORE" "$CHANNEL"
|
|
|
|
if [ "$OSS_ENABLED" = true ]; then
|
|
ensure_ossutil
|
|
if [ -z "${OSS_REGION:-}" ]; then
|
|
OSS_REGION="$(
|
|
printf '%s' "$OSS_ENDPOINT" |
|
|
sed -n 's#^\(https\{0,1\}://\)\{0,1\}oss-\([a-z0-9-]*[a-z0-9]\)\.aliyuncs\.com.*#\2#p' |
|
|
sed 's#-internal$##'
|
|
)"
|
|
fi
|
|
[ -n "$OSS_REGION" ] || err "could not derive OSS_REGION; set it explicitly"
|
|
export OSS_REGION
|
|
if [ "$CHANNEL" = stable ]; then
|
|
OSS_POINTER_NAME=latest.txt
|
|
else
|
|
OSS_POINTER_NAME=beta.txt
|
|
fi
|
|
OSS_POINTER_BEFORE="$(read_oss_pointer "$OSS_POINTER_NAME")"
|
|
require_safe_channel_pointer "$OSS_POINTER_BEFORE" "$CHANNEL"
|
|
else
|
|
OSS_POINTER_NAME=""
|
|
say "OSS mirroring is disabled; no OSS rollback is required."
|
|
fi
|
|
|
|
GITEE_ENABLED="${DWS_GITEE_ENABLED:-false}"
|
|
case "$GITEE_ENABLED" in
|
|
true)
|
|
need_env GITEE_TOKEN "${GITEE_TOKEN:-}"
|
|
need_env GITEE_USER "${GITEE_USER:-}"
|
|
need_env GITEE_REPO "$GITEE_REPO"
|
|
GITEE_GIT_REMOTE="${GITEE_GIT_REMOTE:-https://gitee.com/${GITEE_REPO}.git}"
|
|
GITEE_PUBLIC_GIT_REMOTE="${GITEE_PUBLIC_GIT_REMOTE:-https://gitee.com/${GITEE_REPO}.git}"
|
|
;;
|
|
false)
|
|
GITEE_PUBLIC_GIT_REMOTE="${GITEE_PUBLIC_GIT_REMOTE:-https://gitee.com/${GITEE_REPO}.git}"
|
|
if ! disabled_gitee_commit="$(gitee_remote_tag_commit)"; then
|
|
err "could not prove that disabled Gitee distribution does not expose $VERSION"
|
|
fi
|
|
if [ -n "$disabled_gitee_commit" ]; then
|
|
err "Gitee still exposes $VERSION but automated mirror withdrawal is disabled"
|
|
fi
|
|
set +e
|
|
disabled_gitee_release_id="$(gitee_release_id "$VERSION")"
|
|
disabled_gitee_release_status=$?
|
|
set -e
|
|
case "$disabled_gitee_release_status" in
|
|
4) ;;
|
|
0)
|
|
err "Gitee Release $VERSION still exists (id $disabled_gitee_release_id) but automated mirror withdrawal is disabled"
|
|
;;
|
|
*) err "could not prove that disabled Gitee distribution has no Release for $VERSION" ;;
|
|
esac
|
|
;;
|
|
*) err "DWS_GITEE_ENABLED must be true or false" ;;
|
|
esac
|
|
|
|
need_env HOMEBREW_PR_TOKEN "${HOMEBREW_PR_TOKEN:-}"
|
|
[ "$HOMEBREW_PR_TOKEN" != "$GITHUB_TOKEN" ] ||
|
|
err "HOMEBREW_PR_TOKEN must be a separate least-privilege identity"
|
|
|
|
HOMEBREW_ROLLBACK_PENDING=false
|
|
create_tombstone
|
|
update_homebrew
|
|
update_github_release
|
|
update_npm_channel
|
|
if [ "$OSS_ENABLED" = true ]; then
|
|
ensure_oss_rollback
|
|
update_oss_channel "$OSS_POINTER_NAME"
|
|
else
|
|
say "OSS did not contain $VERSION because mirroring was disabled; no mirror mutation was needed."
|
|
fi
|
|
if [ "$GITEE_ENABLED" = true ]; then
|
|
ensure_gitee_rollback
|
|
withdraw_gitee
|
|
else
|
|
say "Gitee did not contain $VERSION; no mirror mutation was needed."
|
|
fi
|
|
delete_github_release_and_tag
|
|
|
|
if [ "$HOMEBREW_ROLLBACK_PENDING" = true ]; then
|
|
err "Homebrew rollback PR requires independent review and merge; rerun this workflow after merge"
|
|
fi
|
|
|
|
say "Withdrawal completed for all configured distribution channels."
|
|
say "Already-installed clients cannot be remotely downgraded; users must install $ROLLBACK_VERSION or a later fixed version."
|