Files
dingtalk-workspace-cli/scripts/release/reconcile-gitee-assets.sh

451 lines
15 KiB
Bash
Executable File

#!/usr/bin/env bash
# Reconcile the complete DWS release asset set with one Gitee release.
set -euo pipefail
DIST_DIR="${DIST_DIR:-dist}"
GITEE_API="${GITEE_API:-https://gitee.com/api/v5}"
GITEE_CURL_CONNECT_TIMEOUT="${GITEE_CURL_CONNECT_TIMEOUT:-15}"
GITEE_CURL_MAX_TIME="${GITEE_CURL_MAX_TIME:-120}"
GITEE_LIST_MAX_TIME="${GITEE_LIST_MAX_TIME:-20}"
# Attachment-list reads are safe to retry and are used both before an upload
# and to detect a committed upload whose HTTP response was lost. Keep retrying
# long enough to bridge a short Gitee TLS/API outage without ever blindly
# replaying the upload itself.
GITEE_LIST_RETRIES="${GITEE_LIST_RETRIES:-24}"
GITEE_LIST_RETRY_DELAY="${GITEE_LIST_RETRY_DELAY:-20}"
GITEE_LIST_RETRY_WINDOW_SECONDS="${GITEE_LIST_RETRY_WINDOW_SECONDS:-420}"
GITEE_VERIFY_MAX_TIME="${GITEE_VERIFY_MAX_TIME:-60}"
GITEE_MUTATION_MAX_TIME="${GITEE_MUTATION_MAX_TIME:-20}"
# Gitee does not return a response until an attachment upload is committed.
# From GitHub-hosted runners, a near-10 MiB DWS binary can legitimately take
# more than five minutes, so keep the transfer deadline above that observed
# floor while the per-asset and overall deadlines retain hard upper bounds.
GITEE_UPLOAD_MAX_TIME="${GITEE_UPLOAD_MAX_TIME:-1200}"
# The per-asset deadline guarantees one complete slow upload plus one complete
# attachment-list recovery on each side of that upload. The second upload
# attempt is allowed only for zero-byte failures before HTTP begins; a first
# attempt that consumes the full transfer deadline intentionally exhausts the
# retry budget instead of holding a runner for another full slow upload.
GITEE_UPLOAD_RETRIES="${GITEE_UPLOAD_RETRIES:-2}"
GITEE_UPLOAD_RETRY_DELAY="${GITEE_UPLOAD_RETRY_DELAY:-5}"
GITEE_EXISTING_VERIFY_ATTEMPTS="${GITEE_EXISTING_VERIFY_ATTEMPTS:-1}"
GITEE_POST_UPLOAD_VERIFY_ATTEMPTS="${GITEE_POST_UPLOAD_VERIFY_ATTEMPTS:-2}"
GITEE_VERIFY_RETRY_DELAY="${GITEE_VERIFY_RETRY_DELAY:-5}"
GITEE_ASSET_TIMEOUT_SECONDS="${GITEE_ASSET_TIMEOUT_SECONDS:-2220}"
GITEE_OVERALL_TIMEOUT_SECONDS="${GITEE_OVERALL_TIMEOUT_SECONDS:-18300}"
err() {
printf 'error: %s\n' "$*" >&2
exit 1
}
require_positive_integer() {
local name="$1" value="$2"
case "$value" in
''|*[!0-9]*) err "${name} must be a positive integer: ${value}" ;;
esac
[ "$value" -gt 0 ] || err "${name} must be greater than zero"
}
require_nonnegative_integer() {
local name="$1" value="$2"
case "$value" in
''|*[!0-9]*) err "${name} must be a non-negative integer: ${value}" ;;
esac
}
for setting in \
GITEE_CURL_CONNECT_TIMEOUT \
GITEE_CURL_MAX_TIME \
GITEE_LIST_MAX_TIME \
GITEE_LIST_RETRIES \
GITEE_LIST_RETRY_WINDOW_SECONDS \
GITEE_VERIFY_MAX_TIME \
GITEE_MUTATION_MAX_TIME \
GITEE_UPLOAD_MAX_TIME \
GITEE_UPLOAD_RETRIES \
GITEE_EXISTING_VERIFY_ATTEMPTS \
GITEE_POST_UPLOAD_VERIFY_ATTEMPTS \
GITEE_ASSET_TIMEOUT_SECONDS \
GITEE_OVERALL_TIMEOUT_SECONDS; do
require_positive_integer "$setting" "${!setting}"
done
for setting in \
GITEE_LIST_RETRY_DELAY \
GITEE_UPLOAD_RETRY_DELAY \
GITEE_VERIFY_RETRY_DELAY; do
require_nonnegative_integer "$setting" "${!setting}"
done
[ -n "${GITEE_TOKEN:-}" ] || err "GITEE_TOKEN is required"
[ -n "${GITEE_REPO:-}" ] || err "GITEE_REPO is required"
[ -n "${GITEE_RELEASE_ID:-}" ] || err "GITEE_RELEASE_ID is required"
[ -d "$DIST_DIR" ] || err "dist dir not found: ${DIST_DIR}"
OWNER="${GITEE_REPO%%/*}"
NAME="${GITEE_REPO##*/}"
base="${GITEE_API}/repos/${OWNER}/${NAME}"
release_id="$GITEE_RELEASE_ID"
now_seconds() {
date +%s
}
overall_deadline=$(( $(now_seconds) + GITEE_OVERALL_TIMEOUT_SECONDS ))
active_deadline="$overall_deadline"
deadline_remaining() {
local remaining=$(( active_deadline - $(now_seconds) ))
[ "$remaining" -gt 0 ] || return 1
printf '%s\n' "$remaining"
}
bounded_max_time() {
local configured="$1" remaining
remaining="$(deadline_remaining)" || return 1
if [ "$configured" -lt "$remaining" ]; then
printf '%s\n' "$configured"
else
printf '%s\n' "$remaining"
fi
}
start_asset_deadline() {
local now candidate
now="$(now_seconds)"
[ "$now" -lt "$overall_deadline" ] || return 1
candidate=$(( now + GITEE_ASSET_TIMEOUT_SECONDS ))
if [ "$candidate" -lt "$overall_deadline" ]; then
active_deadline="$candidate"
else
active_deadline="$overall_deadline"
fi
}
sleep_within_deadline() {
local seconds="$1" remaining
[ "$seconds" -gt 0 ] || return 0
remaining="$(deadline_remaining)" || return 1
[ "$seconds" -lt "$remaining" ] || return 1
sleep "$seconds"
}
required_assets=(
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
)
missing_assets=()
for name in "${required_assets[@]}"; do
[ -f "${DIST_DIR}/${name}" ] || missing_assets+=("$name")
done
if [ "${#missing_assets[@]}" -gt 0 ]; then
err "required release assets are missing from ${DIST_DIR}: ${missing_assets[*]}"
fi
api_get() {
local configured_max_time="$1" max_time
shift
max_time="$(bounded_max_time "$configured_max_time")" || return 1
curl -fsSL --connect-timeout "$GITEE_CURL_CONNECT_TIMEOUT" \
--max-time "$max_time" "$@"
}
list_assets_once() {
local max_time="$1" payload
payload="$(api_get "$max_time" \
-H "Authorization: token ${GITEE_TOKEN}" \
"${base}/releases/${release_id}/attach_files")" || return 1
printf '%s\n' "$payload" | python3 -c 'import json,sys
data=json.load(sys.stdin)
if isinstance(data,list):
rows=data
elif isinstance(data,dict) and "attach_files" in data:
rows=data["attach_files"]
else:
raise ValueError("attachment list response must be a list or contain attach_files")
if not isinstance(rows,list):
raise ValueError("attach_files must be a list")
for asset in rows:
if not isinstance(asset,dict):
raise ValueError("each attachment must be an object")
name=asset.get("name","")
asset_id=asset.get("id","")
url=asset.get("browser_download_url","")
valid_id=(isinstance(asset_id,int) and not isinstance(asset_id,bool) and asset_id > 0) or (isinstance(asset_id,str) and asset_id.isdigit() and int(asset_id) > 0)
if not isinstance(name,str) or not name or not valid_id or not isinstance(url,str) or not url:
raise ValueError("attachment identity and download URL are required")
print("%s\t%s\t%s" % (name, asset_id, url))'
}
list_assets() {
local attempt=1 candidate now retry_deadline remaining max_time delay
now="$(now_seconds)"
retry_deadline=$(( now + GITEE_LIST_RETRY_WINDOW_SECONDS ))
if [ "$retry_deadline" -gt "$active_deadline" ]; then
retry_deadline="$active_deadline"
fi
while [ "$attempt" -le "$GITEE_LIST_RETRIES" ]; do
now="$(now_seconds)"
remaining=$(( retry_deadline - now ))
[ "$remaining" -gt 0 ] || break
max_time="$GITEE_LIST_MAX_TIME"
if [ "$max_time" -gt "$remaining" ]; then
max_time="$remaining"
fi
# Publish one complete parse atomically. A malformed response can make the
# parser emit valid leading rows before it fails; leaking those rows into a
# later successful retry would manufacture duplicates and could delete a
# correct attachment.
if candidate="$(list_assets_once "$max_time")"; then
now="$(now_seconds)"
if [ "$now" -lt "$retry_deadline" ]; then
printf '%s\n' "$candidate"
return 0
fi
break
fi
if [ "$attempt" -ge "$GITEE_LIST_RETRIES" ]; then
break
fi
attempt=$((attempt + 1))
now="$(now_seconds)"
remaining=$(( retry_deadline - now ))
[ "$remaining" -gt 0 ] || break
delay="$GITEE_LIST_RETRY_DELAY"
if [ "$delay" -gt 0 ]; then
# Do not turn a configured backoff into a burst of zero-delay requests
# during the final wall-clock second. Explicit delay=0 remains available
# to fast unit tests and tightly controlled callers.
[ "$remaining" -gt 1 ] || break
if [ "$delay" -ge "$remaining" ]; then
delay=$(( remaining - 1 ))
fi
fi
echo " ⚠ Gitee attachment list attempt $((attempt - 1))/${GITEE_LIST_RETRIES} failed; retrying in ${delay}s" >&2
sleep_within_deadline "$delay" || return 1
done
return 1
}
sha256_of() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum ${1:+"$1"} | awk '{print $1}'
else
shasum -a 256 ${1:+"$1"} | awk '{print $1}'
fi
}
asset_ids() {
local assets_map="$1" name="$2"
printf '%s\n' "$assets_map" | awk -F'\t' -v n="$name" '$1 == n {print $2}'
}
asset_url() {
local assets_map="$1" name="$2"
printf '%s\n' "$assets_map" | awk -F'\t' -v n="$name" '$1 == n {print $3; exit}'
}
asset_count() {
local assets_map="$1" name="$2"
printf '%s\n' "$assets_map" | awk -F'\t' -v n="$name" '$1 == n {count++} END {print count + 0}'
}
verify_asset_once() {
local file="$1" name local_sha assets_map count url remote_sha
name="$(basename "$file")"
local_sha="$(sha256_of "$file")"
if ! assets_map="$(list_assets)"; then
return 1
fi
count="$(asset_count "$assets_map" "$name")"
[ "$count" -eq 1 ] || return 1
url="$(asset_url "$assets_map" "$name")"
[ -n "$url" ] || return 1
if ! remote_sha="$(api_get "$GITEE_VERIFY_MAX_TIME" "$url" | sha256_of)"; then
return 1
fi
[ "$remote_sha" = "$local_sha" ]
}
verify_asset_with_retries() {
local file="$1" attempts="$2" attempt=1
while [ "$attempt" -le "$attempts" ]; do
if verify_asset_once "$file"; then
return 0
fi
attempt=$((attempt + 1))
if [ "$attempt" -le "$attempts" ]; then
sleep_within_deadline "$GITEE_VERIFY_RETRY_DELAY" || return 1
fi
done
return 1
}
delete_asset() {
local asset_id="$1" max_time
max_time="$(bounded_max_time "$GITEE_MUTATION_MAX_TIME")" || return 1
curl -fsS --connect-timeout "$GITEE_CURL_CONNECT_TIMEOUT" \
--max-time "$max_time" \
-H "Authorization: token ${GITEE_TOKEN}" \
-X DELETE "${base}/releases/${release_id}/attach_files/${asset_id}" \
>/dev/null
}
delete_named_assets() {
local name="$1" assets_map ids asset_id
if ! assets_map="$(list_assets)"; then
return 1
fi
ids="$(asset_ids "$assets_map" "$name")"
while IFS= read -r asset_id; do
[ -n "$asset_id" ] || continue
delete_asset "$asset_id" || return 1
done <<<"$ids"
return 0
}
gitee_attach() {
local file="$1" name attempt response status max_time transfer_log metrics http_code size_upload safe_to_retry
name="$(basename "$file")"
attempt=1
while [ "$attempt" -le "$GITEE_UPLOAD_RETRIES" ]; do
status=0
max_time="$(bounded_max_time "$GITEE_UPLOAD_MAX_TIME")" || return 1
transfer_log="$(mktemp)" || return 1
if metrics="$(curl -fsS --connect-timeout "$GITEE_CURL_CONNECT_TIMEOUT" \
--max-time "$max_time" \
-X POST "${base}/releases/${release_id}/attach_files" \
-H "Authorization: token ${GITEE_TOKEN}" \
-H "Expect:" \
-F "file=@${file}" \
-o /dev/null \
-w '%{http_code}\t%{size_upload}' \
2>"$transfer_log")"; then
status=0
else
status=$?
fi
response="$(<"$transfer_log")"
rm -f "$transfer_log"
http_code="${metrics%%$'\t'*}"
if [ "$metrics" = "$http_code" ]; then
size_upload=""
else
size_upload="${metrics#*$'\t'}"
fi
safe_to_retry=0
if [ "$status" -ne 0 ] && [ "$http_code" = "000" ] && \
awk -v value="$size_upload" 'BEGIN { exit !((value + 0) == 0 && value ~ /^[0-9]+([.][0-9]+)?$/) }'; then
# A request that never reached HTTP and uploaded zero bytes cannot have
# committed an attachment. TLS/DNS/connect failures are therefore the
# only transport failures safe to replay automatically.
safe_to_retry=1
fi
# Gitee can commit an upload but let the HTTP response time out. Probe the
# release before retrying so a lost response does not create duplicates.
if verify_asset_with_retries "$file" "$GITEE_POST_UPLOAD_VERIFY_ATTEMPTS"; then
if [ "$status" -ne 0 ]; then
echo " ✓ ${name} appeared with the expected SHA after a lost upload response"
fi
return 0
fi
echo " ⚠ upload attempt ${attempt}/${GITEE_UPLOAD_RETRIES} failed for ${name}: $(printf '%s' "$response" | head -c 240)" >&2
if [ "$safe_to_retry" -ne 1 ]; then
echo " ⚠ ${name} upload outcome is ambiguous (HTTP ${http_code:-unknown}, uploaded ${size_upload:-unknown} bytes); refusing to replay POST" >&2
return 1
fi
attempt=$((attempt + 1))
if [ "$attempt" -le "$GITEE_UPLOAD_RETRIES" ]; then
# Remove any partial, stale, or duplicate attachment before the one
# explicit retry. There is deliberately no nested curl retry layer.
delete_named_assets "$name" || return 1
sleep_within_deadline "$GITEE_UPLOAD_RETRY_DELAY" || return 1
fi
done
return 1
}
uploaded=0
replaced=0
skipped=0
failed=0
index=0
total="${#required_assets[@]}"
for name in "${required_assets[@]}"; do
index=$((index + 1))
file="${DIST_DIR}/${name}"
if ! start_asset_deadline; then
err "overall Gitee reconciliation deadline exhausted before ${name}"
fi
if ! assets_map="$(list_assets)"; then
if deadline_remaining >/dev/null; then
echo " ❌ could not list Gitee assets before reconciling ${name}" >&2
else
echo " ❌ ${name} exceeded its Gitee reconciliation deadline" >&2
fi
failed=$((failed + 1))
continue
fi
count="$(asset_count "$assets_map" "$name")"
existed="$count"
if [ "$count" -eq 1 ] && verify_asset_with_retries "$file" "$GITEE_EXISTING_VERIFY_ATTEMPTS"; then
echo " ✓ [${index}/${total}] ${name} already correct on Gitee — skip"
skipped=$((skipped + 1))
continue
fi
if [ "$count" -gt 0 ]; then
echo " ↻ [${index}/${total}] ${name} is stale or duplicated (${count} copies) — replacing"
if ! delete_named_assets "$name"; then
echo " ❌ failed to delete stale Gitee attachment(s) for ${name}" >&2
failed=$((failed + 1))
continue
fi
else
echo " ⬆ [${index}/${total}] ${name} (new)"
fi
if gitee_attach "$file"; then
if [ "$existed" -eq 0 ]; then
uploaded=$((uploaded + 1))
else
replaced=$((replaced + 1))
fi
else
if deadline_remaining >/dev/null; then
echo " ❌ upload failed for ${name}" >&2
else
echo " ❌ upload failed for ${name}: per-asset or overall deadline exhausted" >&2
fi
failed=$((failed + 1))
fi
done
active_deadline="$overall_deadline"
deadline_remaining >/dev/null || err "overall Gitee reconciliation deadline exhausted before final verification"
if ! final_assets_map="$(list_assets)"; then
err "could not list Gitee assets for final verification"
fi
for name in "${required_assets[@]}"; do
count="$(asset_count "$final_assets_map" "$name")"
if [ "$count" -ne 1 ]; then
echo " ❌ final verification expected exactly one ${name}, found ${count}" >&2
failed=$((failed + 1))
fi
done
[ "$failed" -eq 0 ] || err "Gitee release reconciliation finished with ${failed} failure(s)"
echo "✅ Gitee release assets: uploaded ${uploaded}, replaced ${replaced}, skipped ${skipped} (all ${total} verified)."