feat(release): publish desktop updates from a tag through one feed

Desktop clients had two competing update sources: the runtime pointed at a
legacy GitLab registry while the Forgejo packages were filled in by
hardcoded, version-pinned scripts. Operators could not tell which feed was
authoritative, and no release could be reproduced from a tag.

Auto-update now reads a single canonical Forgejo registry feed, updated by
a version-agnostic publisher that runs from the tag on Forgejo, GitLab, and
GitHub CI alike. Channel, minimum supported version, forced install,
full-versus-delta thresholds, staged rollout, and a remote kill switch come
from one policy file the client fetches alongside the feed. Tag creation is
gated on a clean tree, matching version surfaces, and a changelog section.
This commit is contained in:
Yun Chan 2026-09-16 23:23:00 +09:00
parent 65ecc7aabc
commit 7953706142
21 changed files with 1619 additions and 90 deletions

View file

@ -0,0 +1,77 @@
// scripts/ci/create-release-tag.mjs
// 릴리스 태그 생성 게이트. 버전 SSOT·CHANGELOG·작업트리 상태를 검증한 뒤
// annotated(기본) 또는 GPG 서명(--sign) 태그를 만든다.
//
// Usage:
// node scripts/ci/create-release-tag.mjs --dry-run
// node scripts/ci/create-release-tag.mjs
// node scripts/ci/create-release-tag.mjs --sign
//
// 태그는 절대 이동·삭제하지 않는다. 잘못된 릴리스는 더 높은 patch로 forward-fix한다.
// 생성 후 `git push origin vX.Y.Z`로 push한다.
import { readFileSync } from 'node:fs'
import { spawnSync } from 'node:child_process'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..')
const args = process.argv.slice(2)
const dryRun = args.includes('--dry-run')
const sign = args.includes('--sign') || args.includes('-s')
const metadata = JSON.parse(readFileSync(join(root, 'release', 'product-version.json'), 'utf8'))
if (!/^\d+\.\d+\.\d+$/.test(metadata.version)) {
fail(`Only stable semver can be tagged: ${metadata.version}`)
}
const tag = `v${metadata.version}`
const changelog = readFileSync(join(root, 'CHANGELOG.md'), 'utf8')
const header = `## [${metadata.version}] - ${metadata.releaseDate}`
if (!changelog.includes(header)) {
fail(`CHANGELOG.md is missing the release section: "${header}"`)
}
const status = git(['status', '--porcelain'])
if (status.stdout.trim() && !args.includes('--allow-dirty')) {
fail('Working tree is dirty. Commit release surfaces first, or pass --allow-dirty for a local dry tag.')
}
const existing = git(['tag', '--list', tag]).stdout.trim()
if (existing) {
fail(`Tag ${tag} already exists. Releases are immutable — lift the version and re-tag.`)
}
const head = git(['rev-parse', 'HEAD']).stdout.trim()
const message = `Release ${tag}`
const tagArgs = sign
? ['tag', '-s', tag, '-m', message]
: ['tag', '-a', tag, '-m', message]
if (dryRun) {
process.stdout.write(
`[tag] dry-run — would create ${sign ? 'signed' : 'annotated'} ${tag} at ${head}\n` +
`[tag] next: git push origin ${tag}\n`,
)
process.exit(0)
}
const result = spawnSync('git', tagArgs, { cwd: root, stdio: 'inherit' })
if (result.status !== 0) {
fail(`git ${tagArgs.join(' ')} failed (exit ${result.status})`)
}
process.stdout.write(`[tag] created ${tag}. Push with: git push origin ${tag}\n`)
function git(commandArgs) {
const result = spawnSync('git', commandArgs, { cwd: root, encoding: 'utf8' })
if (result.status !== 0) {
fail(`git ${commandArgs.join(' ')} failed: ${result.stderr?.trim() || `exit ${result.status}`}`)
}
return result
}
function fail(message) {
process.stderr.write(`[tag] ${message}\n`)
process.exit(1)
}