Skip to content

Commit e99beb6

Browse files
authored
fix(release): verified, gated mirroring — the pipeline proves its own completeness (A4) (#201)
Root fixes for the 0.0.85 publish incident (one stuck gh upload → >1h hang → silently incomplete mirror → downstream install 404s): - publish-ecosystem: timeout-minutes 20 (was unbounded, 6h ceiling). - mirror_res.sh GH section gets the SAME protection its GitCode section always had: per-file timeout 300, post-upload download-200 verification, delete-and-reupload retry <=5 (the observed bad asset state cleared on re-upload). Both sections now skip assets already serving 200, so incident-recovery re-runs are seconds, not a full re-mirror. - Completeness gate verifies every asset on every ENABLED host and its non-zero exit is now BLOCKING in release.yml — the old '|| echo non-blocking' swallowed exactly this signal on 0.0.85. The index bump step is blocking for the same reason. - ci-fresh-install: new wait-index gate job — post-release runs poll xim-pkgindex's mcpp.lua for the released version (<=15min) instead of racing the bump-PR merge (0.0.85: failed 55s after merge). Verified: bash -n + YAML parse; positive run against the complete 0.0.85 mirror (16/16 assets 200 on both hosts, gate exit 0, skip path exercised); negative run with an injected missing asset exits 1 naming it. Design: .agents/docs/2026-07-08-root-cause-remediation-design.md A4.
1 parent 401c13f commit e99beb6

3 files changed

Lines changed: 80 additions & 11 deletions

File tree

.github/tools/mirror_res.sh

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -49,27 +49,52 @@ for a in "${ASSETS[@]}"; do
4949
gh release download "v$VER" -R "$SRC_REPO" -D "$DL" -p "$a" 2>/dev/null || { echo "[mirror] FAIL: missing $a in $SRC_REPO v$VER" >&2; exit 1; }
5050
done
5151

52-
# ── GitHub (gh --clobber, reliable) ───────────────────────────────
52+
# ── GitHub (per-file timeout + verify + delete-and-reupload retry) ──
53+
# A4 hardening: `gh release upload` was observed both HANGING (>1h on one
54+
# asset, 2026-07-08) and leaving a phantom asset that later 404s. Treat GH
55+
# exactly like GitCode below: bounded upload, verify the actual download,
56+
# retry with a delete first (the observed bad state cleared on re-upload).
57+
GH_ENABLED=0
5358
if [[ -n "${XLINGS_RES_TOKEN:-}" ]] || gh auth status >/dev/null 2>&1; then
59+
GH_ENABLED=1
5460
info "GitHub $GH_DST tag $VER"
5561
GH_TOKEN="${XLINGS_RES_TOKEN:-}" gh release view "$VER" -R "$GH_DST" >/dev/null 2>&1 \
5662
|| GH_TOKEN="${XLINGS_RES_TOKEN:-}" gh release create "$VER" -R "$GH_DST" --title "$VER" --notes "$PROJ $VER (mirror of $SRC_REPO)"
5763
for a in "${ASSETS[@]}"; do
58-
GH_TOKEN="${XLINGS_RES_TOKEN:-}" gh release upload "$VER" "$DL/$a" -R "$GH_DST" --clobber
64+
# Idempotent re-runs (incident recovery) skip assets already serving 200.
65+
if [[ "$(curl -fsSL -o /dev/null -w '%{http_code}' -L "https://github.com/${GH_DST}/releases/download/${VER}/${a}" 2>/dev/null)" == 200 ]]; then
66+
info "gh $a already mirrored (200), skipping"
67+
continue
68+
fi
69+
for try in 1 2 3 4 5; do
70+
GH_TOKEN="${XLINGS_RES_TOKEN:-}" timeout 300 gh release upload "$VER" "$DL/$a" -R "$GH_DST" --clobber || true
71+
if [[ "$(curl -fsSL -o /dev/null -w '%{http_code}' -L "https://github.com/${GH_DST}/releases/download/${VER}/${a}" 2>/dev/null)" == 200 ]]; then
72+
break
73+
fi
74+
echo "[mirror] gh $a not 200 after try $try; delete + reupload..."
75+
GH_TOKEN="${XLINGS_RES_TOKEN:-}" gh release delete-asset "$VER" "$a" -R "$GH_DST" -y 2>/dev/null || true
76+
sleep 4
77+
done
5978
done
6079
else
6180
info "no github auth; skipping github mirror"
6281
fi
6382

6483
# ── GitCode (gtc, per-file retry — multi-file upload can 502 and drop files) ──
84+
GTC_ENABLED=0
6585
if [[ -n "${GITCODE_TOKEN:-}" ]] && command -v gtc >/dev/null 2>&1; then
86+
GTC_ENABLED=1
6687
info "GitCode $GTC_DST tag $VER"
6788
gtc release create "$GTC_DST" --tag "$VER" --name "$VER" 2>/dev/null || true
6889
# Upload then verify the actual DOWNLOAD is 200 (gtc can report success yet
6990
# leave a phantom/missing asset — obs_callback flakiness), retry up to 5.
7091
for a in "${ASSETS[@]}"; do
92+
if [[ "$(curl -fsSL -o /dev/null -w '%{http_code}' -L "https://gitcode.com/${GTC_DST}/releases/download/${VER}/${a}" 2>/dev/null)" == 200 ]]; then
93+
info "gtc $a already mirrored (200), skipping"
94+
continue
95+
fi
7196
for try in 1 2 3 4 5; do
72-
gtc release upload "$GTC_DST" "$DL/$a" --tag "$VER" >/dev/null 2>&1 || true
97+
timeout 300 gtc release upload "$GTC_DST" "$DL/$a" --tag "$VER" >/dev/null 2>&1 || true
7398
if [[ "$(curl -fsSL -o /dev/null -w '%{http_code}' -L "https://gitcode.com/${GTC_DST}/releases/download/${VER}/${a}" 2>/dev/null)" == 200 ]]; then
7499
break
75100
fi
@@ -80,15 +105,21 @@ else
80105
info "no GITCODE_TOKEN/gtc; skipping gitcode mirror"
81106
fi
82107

83-
# ── Verify every platform on both hosts ───────────────────────────
108+
# ── Completeness gate: verify every asset on every ENABLED host ──
109+
# A4: this is the mirror's definition of done. The caller must treat a
110+
# non-zero exit as a hard failure (release.yml does since the 0.0.85
111+
# incident — the old `|| echo non-blocking` swallowed exactly this).
84112
info "verify:"
85113
rc=0
86-
for host in "github.com/$GH_DST" "gitcode.com/$GTC_DST"; do
114+
hosts=()
115+
[[ "${GH_ENABLED:-0}" == 1 ]] && hosts+=("github.com/$GH_DST")
116+
[[ "${GTC_ENABLED:-0}" == 1 ]] && hosts+=("gitcode.com/$GTC_DST")
117+
for host in "${hosts[@]}"; do
87118
for a in "${ASSETS[@]}"; do
88119
code=$(curl -fsSL -o /dev/null -w '%{http_code}' -L "https://${host}/releases/download/${VER}/${a}" 2>/dev/null || echo ERR)
89120
echo " $code https://${host}/releases/download/${VER}/${a}"
90-
[[ "$code" == 200 ]] || rc=1
121+
[[ "$code" == 200 ]] || { rc=1; echo "[mirror] FAIL: missing/unverified: https://${host}/releases/download/${VER}/${a}" >&2; }
91122
done
92123
done
93-
[[ $rc == 0 ]] && info "all platforms mirrored OK" || { echo "[mirror] WARN: some assets not 200" >&2; }
124+
[[ $rc == 0 ]] && info "all assets mirrored + verified on ${#hosts[@]} host(s)"
94125
exit $rc

.github/workflows/ci-fresh-install.yml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,36 @@ jobs:
3535
# ──────────────────────────────────────────────────────────────────
3636
# Linux: gcc@16.1.0, musl-gcc@15.1.0, llvm@20.1.7
3737
# ──────────────────────────────────────────────────────────────────
38+
# A4: post-release runs race the xim-pkgindex bump (a PR a maintainer
39+
# merges asynchronously) — the 0.0.85 fresh-install failed with
40+
# "version not found: available 0.0.84" 55s after the bump merged.
41+
# Convert the race into a bounded wait: poll the index's mcpp.lua until
42+
# it carries the released version (<=15 min), then let every job run.
43+
# Non-workflow_run triggers (manual, cron, PR) skip the wait.
44+
wait-index:
45+
runs-on: ubuntu-latest
46+
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
47+
timeout-minutes: 20
48+
steps:
49+
- name: Wait for xim-pkgindex to track the released mcpp
50+
if: ${{ github.event_name == 'workflow_run' }}
51+
run: |
52+
VER=$(curl -fsSL "https://api.github.com/repos/mcpp-community/mcpp/releases/latest" | python3 -c "import json,sys; print(json.load(sys.stdin)['tag_name'].lstrip('v'))")
53+
echo "released: $VER — waiting for index..."
54+
for i in $(seq 1 30); do
55+
if curl -fsSL "https://raw.githubusercontent.com/openxlings/xim-pkgindex/main/pkgs/m/mcpp.lua" | grep -q "\"$VER\""; then
56+
echo "index tracks $VER (after $((i*30))s)"; exit 0
57+
fi
58+
sleep 30
59+
done
60+
echo "::error::index never tracked $VER within 15min — merge the bump PR (openxlings/xim-pkgindex) and re-run"
61+
exit 1
62+
- name: No wait needed (manual/cron trigger)
63+
if: ${{ github.event_name != 'workflow_run' }}
64+
run: echo "not a post-release run; skipping index wait"
65+
3866
linux-fresh:
67+
needs: [wait-index]
3968
name: Linux fresh install
4069
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
4170
runs-on: ubuntu-24.04
@@ -142,6 +171,7 @@ jobs:
142171
# hosts. Runs the released mcpp inside distro containers.
143172
# ──────────────────────────────────────────────────────────────────
144173
linux-distro-matrix:
174+
needs: [wait-index]
145175
name: Linux distro (${{ matrix.distro }})
146176
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
147177
runs-on: ubuntu-24.04
@@ -228,6 +258,7 @@ jobs:
228258
# macOS: llvm@20.1.7
229259
# ──────────────────────────────────────────────────────────────────
230260
macos-fresh:
261+
needs: [wait-index]
231262
name: macOS fresh install
232263
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
233264
# macos-14: the support floor (mcpp ≥0.0.50 / xlings ≥0.4.50 ship
@@ -289,6 +320,7 @@ jobs:
289320
# Windows: llvm@20.1.7 + MSVC STL
290321
# ──────────────────────────────────────────────────────────────────
291322
windows-fresh:
323+
needs: [wait-index]
292324
name: Windows fresh install
293325
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
294326
runs-on: windows-latest

.github/workflows/release.yml

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -707,6 +707,9 @@ jobs:
707707
publish-ecosystem:
708708
needs: [build-release, build-linux-aarch64, build-macos, build-windows]
709709
runs-on: ubuntu-latest
710+
# A4 hardening: a single stuck upload once held this job >1h (6h default
711+
# ceiling). The mirror script now has per-file timeouts, so 20min is ample.
712+
timeout-minutes: 20
710713
env:
711714
XLINGS_RES_TOKEN: ${{ secrets.XLINGS_RES_TOKEN }}
712715
GITCODE_TOKEN: ${{ secrets.GITCODE_TOKEN }}
@@ -725,8 +728,10 @@ jobs:
725728
run: |
726729
chmod +x .github/tools/gtc .github/tools/mirror_res.sh
727730
export PATH="$PWD/.github/tools:$PATH"
728-
bash .github/tools/mirror_res.sh mcpp "${{ steps.version.outputs.version }}" \
729-
|| echo "binary mirror reported issues (non-blocking)"
731+
# A4: BLOCKING. Both mirror hosts serve users (GLOBAL + CN install
732+
# paths); an incomplete mirror must fail here, visibly, instead of
733+
# surfacing as a 404 in the first user's install (or fresh-install CI).
734+
bash .github/tools/mirror_res.sh mcpp "${{ steps.version.outputs.version }}"
730735
731736
- name: Open index bump PR (xim-pkgindex)
732737
if: ${{ env.XIM_PKGINDEX_TOKEN != '' }}
@@ -735,8 +740,9 @@ jobs:
735740
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
736741
run: |
737742
chmod +x .github/tools/bump_index.sh
738-
bash .github/tools/bump_index.sh mcpp "${{ steps.version.outputs.version }}" \
739-
|| echo "index bump reported issues (non-blocking)"
743+
# A4: BLOCKING — a missing bump PR means the index never learns the
744+
# release exists and every post-release install of the new version 404s.
745+
bash .github/tools/bump_index.sh mcpp "${{ steps.version.outputs.version }}"
740746
741747
# Post-release verification (ci-fresh-install) is triggered via its
742748
# `workflow_run: [release]` hook — a platform-generated event that is

0 commit comments

Comments
 (0)