451 lines
15 KiB
Bash
Executable File
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)."
|