From 18db26c265676399c960e65c2625c2df056318a4 Mon Sep 17 00:00:00 2001 From: Joshua King Date: Fri, 5 Jun 2026 12:44:38 -0400 Subject: [PATCH] ci: retry transient network errors when publishing the release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The publish step died with "fetch failed: ECONNRESET" mid-run, leaving a half-created release (no version.txt asset → the Pi got 404s). Wrap the Gitea API calls in a small retry (rfetch) so a flaky connection doesn't leave the rolling release incomplete. Co-Authored-By: Claude Opus 4.8 --- .gitea/scripts/publish-release.mjs | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/.gitea/scripts/publish-release.mjs b/.gitea/scripts/publish-release.mjs index fb97add..66ebf10 100644 --- a/.gitea/scripts/publish-release.mjs +++ b/.gitea/scripts/publish-release.mjs @@ -16,6 +16,21 @@ const VERSION = SHA.slice(0, 8); const API = `${SERVER}/api/v1/repos/${REPO}`; const H = { Authorization: `token ${TOKEN}` }; +// The runner's network to Gitea is flaky (ECONNRESET mid-publish leaves a +// half-created release). Retry transient fetch failures so the multi-step +// publish is atomic-enough in practice. +const rfetch = async (url, opts = {}, tries = 4) => { + for (let i = 1; ; i++) { + try { + return await fetch(url, opts); + } catch (e) { + if (i >= tries) throw e; + console.log(`fetch ${url} failed (${e.cause?.code || e.message}); retry ${i}/${tries - 1}`); + await new Promise((r) => setTimeout(r, 1000 * i)); + } + } +}; + const ok = async (r) => { if (!r.ok) throw new Error(`${r.status} ${r.url}\n${await r.text()}`); const t = await r.text(); @@ -31,14 +46,14 @@ const files = { }; // Roll the release forward to this commit: delete the old release + tag. -const existing = await fetch(`${API}/releases/tags/${TAG}`, { headers: H }); +const existing = await rfetch(`${API}/releases/tags/${TAG}`, { headers: H }); if (existing.ok) { const rel = await existing.json(); - await fetch(`${API}/releases/${rel.id}`, { method: 'DELETE', headers: H }); + await rfetch(`${API}/releases/${rel.id}`, { method: 'DELETE', headers: H }); } -await fetch(`${API}/tags/${TAG}`, { method: 'DELETE', headers: H }); // ignore if absent +await rfetch(`${API}/tags/${TAG}`, { method: 'DELETE', headers: H }); // ignore if absent -const rel = await ok(await fetch(`${API}/releases`, { +const rel = await ok(await rfetch(`${API}/releases`, { method: 'POST', headers: { ...H, 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -53,7 +68,7 @@ const rel = await ok(await fetch(`${API}/releases`, { for (const [name, buf] of Object.entries(files)) { const fd = new FormData(); fd.append('attachment', new Blob([buf]), name); - await ok(await fetch(`${API}/releases/${rel.id}/assets?name=${encodeURIComponent(name)}`, { + await ok(await rfetch(`${API}/releases/${rel.id}/assets?name=${encodeURIComponent(name)}`, { method: 'POST', headers: H, body: fd, })); console.log(`uploaded ${name}`);