From 24f7e7c52a4c7f38673431b64ac913b270e0ac5a Mon Sep 17 00:00:00 2001 From: Bill Date: Sun, 20 Sep 2026 20:27:05 +0800 Subject: [PATCH] fix(release): atomic update-channel publish upload the payload first and latest.yml last, prune the previous version only afterwards, reuse an existing release, add --channel-only; sync- changelog uses a function replacement so $-sequences in notes survive --- scripts/release.cjs | 143 ++++++++++++++++++++++++++++++------- scripts/sync-changelog.cjs | 4 +- 2 files changed, 121 insertions(+), 26 deletions(-) diff --git a/scripts/release.cjs b/scripts/release.cjs index 409c1c9..f192852 100644 --- a/scripts/release.cjs +++ b/scripts/release.cjs @@ -1,6 +1,7 @@ // OpenTerminal release publisher -// Usage: node scripts/release.cjs [--skip-github] [--skip-gitea] +// Usage: node scripts/release.cjs [--skip-github] [--skip-gitea] [--channel-only] // node scripts/release.cjs 1.0.2 +// node scripts/release.cjs 1.0.2 --channel-only # update channel only (repair) // Reads release notes from RELEASE_NOTES.md (repo root, gitignored). // Credentials from .env (repo root, gitignored): GIT_TOKEN, GH_TOKEN. // If HTTPS_PROXY / HTTP_PROXY (env or .env) is set, traffic goes through it @@ -11,14 +12,18 @@ const path = require('node:path') const ROOT = path.join(__dirname, '..') const V = process.argv[2] if (!V || !/^\d+\.\d+\.\d+$/.test(V)) { - console.error('usage: node scripts/release.cjs [--skip-github] [--skip-gitea]') + console.error('usage: node scripts/release.cjs [--skip-github] [--skip-gitea] [--channel-only]') process.exit(1) } const SKIP_GH = process.argv.includes('--skip-github') const SKIP_GITEA = process.argv.includes('--skip-gitea') +const CHANNEL_ONLY = process.argv.includes('--channel-only') const env = Object.fromEntries( fs.readFileSync(path.join(ROOT, '.env'), 'utf8').split('\n') + // Trim before the comment check: an indented `# comment = x` is a comment, + // not an env entry. + .map((l) => l.trim()) .filter((l) => l.includes('=') && !l.startsWith('#')) .map((l) => { const i = l.indexOf('='); return [l.slice(0, i).trim(), l.slice(i + 1).trim()] }) ) @@ -31,10 +36,12 @@ const files = [ for (const f of files) { if (!fs.existsSync(f)) { console.error('missing build artifact:', f); process.exit(1) } } -// Guard rails: the artifacts must belong to the version being published, and -// the tag must not exist yet — otherwise a re-run silently republishes a -// *different* binary under an already-released version number, and installed -// clients never see it (their version check compares numbers, not hashes). +// Guard rails: the artifacts must belong to the version being published — +// otherwise a re-run silently republishes a *different* binary under an +// already-released version number, and installed clients never see it (their +// version check compares numbers, not hashes). Re-running the same version is +// supported (gitea() reuses an existing release), so this is a version check, +// not a tag check. const pkgVersion = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version if (pkgVersion !== V) { console.error(`package.json is ${pkgVersion} but you asked to publish ${V}`) @@ -62,7 +69,8 @@ if (ymlVersion !== V) { // the agent is passed explicitly on GitHub requests. const proxy = process.env.HTTPS_PROXY || process.env.https_proxy || env.HTTPS_PROXY || env.PROXY const proxyAgent = proxy ? new (require('undici').ProxyAgent)(proxy) : undefined -if (proxy) console.log('proxy for GitHub only:', proxy) +// Never log the proxy URL verbatim: it may embed `user:password@`. +if (proxy) console.log('proxy for GitHub only:', proxy.replace(/\/\/[^/@]*@/, '//')) async function upload(url, file, token, authScheme, viaProxy = false) { const name = path.basename(file) @@ -88,15 +96,81 @@ async function upload(url, file, token, authScheme, viaProxy = false) { async function gitea() { console.log('=== Gitea release ===') const base = 'https://git.codingplan.site/api/v1/repos/admin/OpenTerminal' - const resp = await fetch(`${base}/releases`, { - method: 'POST', - headers: { Authorization: `token ${env.GIT_TOKEN}`, 'Content-Type': 'application/json' }, - body: JSON.stringify({ tag_name: `v${V}`, name: `OpenTerminal v${V}`, body: notes, draft: false, prerelease: false }) - }) - if (!resp.ok) { throw new Error(`Gitea release create failed: ${resp.status} ${(await resp.text()).slice(0, 300)}`) } - const rel = await resp.json() - console.log('release created, id =', rel.id) - for (const f of files) await upload(`${base}/releases/${rel.id}/assets`, f, env.GIT_TOKEN, 'token') + const auth = { Authorization: `token ${env.GIT_TOKEN}` } + // Idempotent: a re-run (e.g. `--channel-only` after a half-finished publish) + // must not die on the release it created last time — reuse it, and skip assets + // that are already attached. + const existing = await fetch(`${base}/releases/tags/v${V}`, { headers: auth }) + let rel + if (existing.ok) { + rel = await existing.json() + console.log('release already exists, reusing id =', rel.id) + } else { + const resp = await fetch(`${base}/releases`, { + method: 'POST', + headers: { ...auth, 'Content-Type': 'application/json' }, + body: JSON.stringify({ tag_name: `v${V}`, name: `OpenTerminal v${V}`, body: notes, draft: false, prerelease: false }) + }) + if (resp.ok) { + rel = await resp.json() + console.log('release created, id =', rel.id) + } else { + const detail = (await resp.text()).slice(0, 300) + // 409 = the tag already carries a release, created by an earlier partial run. + const again = resp.status === 409 ? await fetch(`${base}/releases/tags/v${V}`, { headers: auth }) : undefined + if (!again || !again.ok) throw new Error(`Gitea release create failed: ${resp.status} ${detail}`) + rel = await again.json() + console.log('release already exists, reusing id =', rel.id) + } + } + const listed = await fetch(`${base}/releases/${rel.id}/assets`, { headers: auth }) + const attached = new Set(listed.ok ? (await listed.json()).map((a) => a.name) : []) + for (const f of files) { + const name = path.basename(f) + if (attached.has(name)) { console.log(` asset ${name}: already attached, skipped`); continue } + await upload(`${base}/releases/${rel.id}/assets`, f, env.GIT_TOKEN, 'token') + } +} + +/** Version the update channel serves right now (read from its latest.yml), or + undefined when the channel is empty/unreachable. */ +async function channelVersion(base) { + try { + const resp = await fetch(`${base}/latest.yml`) + if (!resp.ok) return undefined + return (await resp.text()).match(/^version:\s*(\S+)/m)?.[1] + } catch { + return undefined + } +} + +/** Size of a channel file, or -1 when it is not there. */ +async function channelFileSize(url) { + try { + const resp = await fetch(url, { method: 'HEAD' }) + return resp.ok ? Number(resp.headers.get('content-length') ?? -1) : -1 + } catch { + return -1 + } +} + +/** Drop the previous version's binaries, once the new latest.yml is live. The old + files keep serving until then, so nothing is ever removed up front. Best + effort: leftovers cost disk, never correctness — latest.yml names the files + clients fetch. */ +async function pruneChannel(base, previous) { + if (!previous || previous === V) return + for (const name of [`OpenTerminal-${previous}-setup.exe`, `OpenTerminal-${previous}-setup.exe.blockmap`]) { + try { + const resp = await fetch(`${base}/${encodeURIComponent(name)}`, { + method: 'DELETE', + headers: { Authorization: `token ${env.GIT_TOKEN}` } + }) + if (resp.ok) console.log(` removed previous ${name}`) + } catch (e) { + console.log(` previous ${name} not removed: ${e.message}`) + } + } } async function giteaChannel() { @@ -110,30 +184,41 @@ async function giteaChannel() { // releases API 404s), but the generic package is publicly readable. [path.join(ROOT, 'RELEASE_NOTES.md'), 'release-notes.md'] ] - // Validate before touching the channel: a missing file after the DELETE would - // leave the update channel empty (clients then fall back to GitHub, which is - // unreachable in China for most users). + // Validate before touching the channel: uploading a payload-less latest.yml + // would leave the update channel broken (clients then fall back to GitHub, + // which is unreachable in China for most users). for (const [f] of channelFiles) { if (!fs.existsSync(f)) throw new Error(`missing channel file: ${f}`) } // latest.yml goes last: it is what makes clients start downloading, so the - // payload must already be in place. + // payload must already be in place. Nothing is deleted first — the previous + // version keeps serving until every new file has landed, so an interrupted PUT + // (ECONNRESET) can no longer leave the channel empty and unrepairable. const isLatest = ([f]) => path.basename(f) === 'latest.yml' const ordered = [...channelFiles.filter((e) => !isLatest(e)), ...channelFiles.filter(isLatest)] - // Replace the version only once every file is known to be present on disk. - const del = await fetch(base, { method: 'DELETE', headers: { Authorization: `token ${env.GIT_TOKEN}` } }) - console.log(' delete old stable:', del.status) + const previous = await channelVersion(base) for (const [f, name] of ordered) { const stat = fs.statSync(f) - const resp = await fetch(`${base}/${encodeURIComponent(name)}`, { + const url = `${base}/${encodeURIComponent(name)}` + const resp = await fetch(url, { method: 'PUT', headers: { Authorization: `token ${env.GIT_TOKEN}`, 'Content-Type': 'application/octet-stream', 'Content-Length': String(stat.size) }, body: fs.createReadStream(f), duplex: 'half' }) console.log(` put ${name}: HTTP ${resp.status}`) - if (!resp.ok) throw new Error(`channel put failed (${resp.status}) for ${name}: ${(await resp.text()).slice(0, 200)}`) + if (!resp.ok) { + const detail = (await resp.text()).slice(0, 200) + // Re-publishing the same version can hit an already-stored file; accept it + // only when the stored copy has exactly the same size. + if ((resp.status === 409 || resp.status === 422) && (await channelFileSize(url)) === stat.size) { + console.log(` ${name} is already published with the same size, kept`) + continue + } + throw new Error(`channel put failed (${resp.status}) for ${name}: ${detail}`) + } } + await pruneChannel(base, previous) } async function github() { @@ -159,6 +244,14 @@ async function github() { } async function main() { + // --channel-only: the release and its assets already exist (or are not needed), + // so republish just the update channel — the repair path for a channel upload + // that died halfway. + if (CHANNEL_ONLY) { + await giteaChannel() + console.log('done (channel only)') + return + } if (!SKIP_GITEA) { await gitea(); await giteaChannel() } if (!SKIP_GH) await github() console.log('done') diff --git a/scripts/sync-changelog.cjs b/scripts/sync-changelog.cjs index 90e746b..93b8ea0 100644 --- a/scripts/sync-changelog.cjs +++ b/scripts/sync-changelog.cjs @@ -52,8 +52,10 @@ const sync = ({ notes, changelog, header, required }) => { } const section = `## v${pkgVersion} - ${new Date().toISOString().slice(0, 10)}\n\n${body}\n` + // Function replacement, not a string: a notes body containing `$&`, `$1`, `` $` `` + // or `$'` would otherwise be expanded by String.replace and corrupt the merge. const updated = existing - ? existing.replace(new RegExp(`^(${header}\\n\\n)`), `$1${section}\n`) + ? existing.replace(new RegExp(`^(${header}\\n\\n)`), (_m, head) => `${head}${section}\n`) : `${header}\n\n${section}` fs.writeFileSync(changelogPath, updated, 'utf8') console.log(`${changelog}: added v${pkgVersion} (${body.split('\n').length} lines)`)