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
This commit is contained in:
1 parent
c0342db1e8
commit
24f7e7c52a
2 files changed
+121
-26
No files matched your search
+118
-25
@@ -1,6 +1,7 @@
|
||||
// OpenTerminal release publisher
|
||||
// Usage: node scripts/release.cjs <version> [--skip-github] [--skip-gitea]
|
||||
// Usage: node scripts/release.cjs <version> [--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 <x.y.z> [--skip-github] [--skip-gitea]')
|
||||
console.error('usage: node scripts/release.cjs <x.y.z> [--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')
|
||||
|
||||
@@ -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)`)
|
||||
|
||||
Reference in new issue
Block a user