@@ -62,88 +62,96 @@ probe() { # host_path asset → 0 iff the object serves bytes
6262 [[ " $code " == 200 || " $code " == 206 ]]
6363}
6464
65- # Wait for an asset to serve, with real propagation patience (fresh uploads
66- # take ~20-60s to reach the download CDN). ~2min ceiling, 5s cadence.
67- wait_asset () { # url → 0 iff 200/206 within the window
68- for _ in $( seq 1 24) ; do
69- probe " $1 " && return 0
70- sleep 5
65+ # Shared-deadline batch verify: ONE ~2min propagation clock sweeps ALL
66+ # pending assets (per-asset serial 120s waits stacked up to 16min in the
67+ # v0.0.90 incident). Echoes the still-missing set; empty output = all good.
68+ verify_batch () { # base_url asset... → prints assets still not serving
69+ local base=" $1 " ; shift
70+ local deadline=$(( SECONDS + 150 ))
71+ local pending=(" $@ " )
72+ while (( ${# pending[@]} )) && (( SECONDS < deadline)) ; do
73+ local still=()
74+ for a in " ${pending[@]} " ; do
75+ probe " ${base} /${a} " || still+=(" $a " )
76+ done
77+ pending=(" ${still[@]} " )
78+ (( ${# pending[@]} )) && sleep 5
7179 done
72- return 1
80+ (( ${# pending[@]} )) && echo " ${pending[*]} "
81+ return 0
7382}
7483
75- # ── GitHub ──────────────────────────────────────────────────────────
76- # A4 hardening: `gh release upload` was observed both HANGING (>1h on one
77- # asset, 2026-07-08) and leaving a phantom asset that later 404s (0.0.75/76).
78- # Shape (v0 .0.89 lesson): upload ALL assets first, then verify with patience —
79- # propagation overlaps instead of serializing per asset. NEVER delete on a
80- # verify timeout: --clobber re-upload already replaces, and the eager
81- # 404→delete loop repeatedly deleted GOOD uploads whose propagation was
82- # merely slower than the wait window (0.0.86 incident, recurred at 18s in
83- # v0.0.89 — 11 delete+reupload cycles across 8 assets).
84- GH_ENABLED=0
85- if [[ -n " ${XLINGS_RES_TOKEN :- } " ]] || gh auth status > /dev/null 2>&1 ; then
86- GH_ENABLED=1
87- info " GitHub $GH_DST tag $VER "
88- GH_TOKEN =" ${XLINGS_RES_TOKEN :- } " gh release view " $VER " -R " $GH_DST " > /dev/null 2>&1 \
89- || GH_TOKEN= " ${XLINGS_RES_TOKEN :- } " gh release create " $VER " -R " $GH_DST " --title " $VER " --notes " $PROJ $VER (mirror of $SRC_REPO ) "
84+ # One host's mirror: upload everything not yet serving, then batch-verify
85+ # with propagation patience, up to 3 rounds.
86+ #
87+ # Hard-won rules (0 .0.86 / 0.0.89 / 0.0.90 postmortems):
88+ # - NEVER delete on a verify timeout — the eager 404→ delete loop repeatedly
89+ # deleted GOOD uploads whose propagation was merely slow.
90+ # - NEVER kill a slow-but-progressing upload: the outer `timeout` MUST
91+ # exceed gtc's inner PUT timeout (600s). v0.0.90 wrapped uploads in
92+ # `timeout 300`, so every cross-border PUT >5min was SIGKILLed at 60%%
93+ # and restarted from byte zero — the 20min job ceiling fell to this.
94+ # - gtc's exit code lies both ways (obs_callback flakiness); the download
95+ # probe is the only source of truth.
96+ mirror_host () { # kind(gh|gtc) base_url
97+ local kind =" $1 " base= " $2 " try a
98+ local pending failed
9099 for try in 1 2 3; do
91- # Phase 1: upload everything not yet serving (idempotent re-runs skip).
92100 pending=()
93101 for a in " ${ASSETS[@]} " ; do
94- if probe " https://github.com/ ${GH_DST} /releases/download/ ${VER } /${a} " ; then
95- [[ $try == 1 ]] && info " gh $a already mirrored, skipping"
102+ if probe " ${base } /${a} " ; then
103+ [[ $try == 1 ]] && info " $kind $a already mirrored, skipping"
96104 continue
97105 fi
98106 pending+=(" $a " )
99- GH_TOKEN=" ${XLINGS_RES_TOKEN:- } " timeout 300 gh release upload " $VER " " $DL /$a " -R " $GH_DST " --clobber || true
100- done
101- [[ ${# pending[@]} == 0 ]] && break
102- # Phase 2: verify the batch with propagation patience.
103- failed=()
104- for a in " ${pending[@]} " ; do
105- wait_asset " https://github.com/${GH_DST} /releases/download/${VER} /${a} " \
106- || failed+=(" $a " )
107+ if [[ " $kind " == gh ]]; then
108+ GH_TOKEN=" ${XLINGS_RES_TOKEN:- } " timeout 900 \
109+ gh release upload " $VER " " $DL /$a " -R " $GH_DST " --clobber || true
110+ else
111+ timeout 900 gtc release upload " $GTC_DST " " $DL /$a " --tag " $VER " \
112+ > /dev/null 2>&1 || true
113+ fi
107114 done
108- [[ ${# failed[@]} == 0 ]] && break
109- echo " [mirror] gh not serving after patience (try $try ): ${failed[*]} — re-uploading (no delete)"
115+ [[ ${# pending[@]} == 0 ]] && return 0
116+ failed=$( verify_batch " $base " " ${pending[@]} " )
117+ [[ -z " $failed " ]] && return 0
118+ echo " [mirror] $kind not serving after patience (try $try ): $failed — re-uploading (no delete)"
110119 done
120+ return 0 # the completeness gate below is the real pass/fail
121+ }
122+
123+ # ── Both hosts IN PARALLEL: they are fully independent, and the gitcode
124+ # leg is cross-border-slow — serializing them doubled wall time for nothing.
125+ GH_ENABLED=0
126+ if [[ -n " ${XLINGS_RES_TOKEN:- } " ]] || gh auth status > /dev/null 2>&1 ; then
127+ GH_ENABLED=1
128+ info " GitHub $GH_DST tag $VER "
129+ GH_TOKEN=" ${XLINGS_RES_TOKEN:- } " gh release view " $VER " -R " $GH_DST " > /dev/null 2>&1 \
130+ || GH_TOKEN=" ${XLINGS_RES_TOKEN:- } " gh release create " $VER " -R " $GH_DST " --title " $VER " --notes " $PROJ $VER (mirror of $SRC_REPO )"
111131else
112132 info " no github auth; skipping github mirror"
113133fi
114-
115- # ── GitCode (gtc — multi-file upload can 502 and drop files) ─────────
116- # Same two-phase shape. gtc can report errors on uploads that actually
117- # succeeded (obs_callback flakiness) — the probe, not gtc's exit code, is
118- # the source of truth.
119134GTC_ENABLED=0
120135if [[ -n " ${GITCODE_TOKEN:- } " ]] && command -v gtc > /dev/null 2>&1 ; then
121136 GTC_ENABLED=1
122137 info " GitCode $GTC_DST tag $VER "
123138 gtc release create " $GTC_DST " --tag " $VER " --name " $VER " 2> /dev/null || true
124- for try in 1 2 3; do
125- pending=()
126- for a in " ${ASSETS[@]} " ; do
127- if probe " https://gitcode.com/${GTC_DST} /releases/download/${VER} /${a} " ; then
128- [[ $try == 1 ]] && info " gtc $a already mirrored, skipping"
129- continue
130- fi
131- pending+=(" $a " )
132- timeout 300 gtc release upload " $GTC_DST " " $DL /$a " --tag " $VER " > /dev/null 2>&1 || true
133- done
134- [[ ${# pending[@]} == 0 ]] && break
135- failed=()
136- for a in " ${pending[@]} " ; do
137- wait_asset " https://gitcode.com/${GTC_DST} /releases/download/${VER} /${a} " \
138- || failed+=(" $a " )
139- done
140- [[ ${# failed[@]} == 0 ]] && break
141- echo " [mirror] gtc not serving after patience (try $try ): ${failed[*]} — re-uploading"
142- done
143139else
144140 info " no GITCODE_TOKEN/gtc; skipping gitcode mirror"
145141fi
146142
143+ GH_PID=" " ; GTC_PID=" "
144+ if [[ " $GH_ENABLED " == 1 ]]; then
145+ mirror_host gh " https://github.com/${GH_DST} /releases/download/${VER} " &
146+ GH_PID=$!
147+ fi
148+ if [[ " $GTC_ENABLED " == 1 ]]; then
149+ mirror_host gtc " https://gitcode.com/${GTC_DST} /releases/download/${VER} " &
150+ GTC_PID=$!
151+ fi
152+ [[ -n " $GH_PID " ]] && wait " $GH_PID "
153+ [[ -n " $GTC_PID " ]] && wait " $GTC_PID "
154+
147155# ── Completeness gate: verify every asset on every ENABLED host ──
148156# A4: this is the mirror's definition of done. The caller must treat a
149157# non-zero exit as a hard failure (release.yml does since the 0.0.85
0 commit comments