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,208 @@
// scripts/ci/check-design-tokens.mjs
//
// Design-token SSOT guard. Fails when a surface hardcodes a color, uses a
// numeric literal for spacing/radius/control that a token already owns, or
// re-introduces a bold weight that design.md v3 retired.
//
// The point is not zero-hex everywhere: a token *definition* file legitimately
// holds raw values. Everything else must consume --d3-* / d3ro* tokens.
//
// Usage:
// node scripts/ci/check-design-tokens.mjs # check, exit 1 on violation
// node scripts/ci/check-design-tokens.mjs --json # machine-readable report
// node scripts/ci/check-design-tokens.mjs --self-test
//
// Allowlisted paths are the ONLY places raw color literals may live.
import { readdirSync, readFileSync, statSync } from 'node:fs'
import { dirname, join, relative, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const HERE = dirname(fileURLToPath(import.meta.url))
const ROOT = resolve(HERE, '..', '..')
// Directories scanned for consumer code (not token definitions).
const TARGETS = [
'apps/desktop/src',
'apps/web/src',
'apps/admin/src',
'apps/mobile-rn/src',
'packages/ui/src',
'packages/ui-native/src',
'site/src',
'site/public',
]
// Token *definition* layers. Raw values are the point here, not a violation.
const ALLOWLIST = new Set([
'packages/ui/src/theme.ts',
'packages/ui/src/theme-vars.ts',
'packages/ui-native/src/theme.ts',
'apps/mobile-rn/src/theme/mobile-theme.ts',
'apps/admin/src/lib/console-theme.ts',
// Canvas cannot resolve CSS custom properties; these are SSR fallbacks that
// mirror --d3-gradient-wave1..4 and are never painted on the client.
'packages/ui/src/components/ds/GradientWave.tsx',
'packages/ui/src/components/ds/AudioVisualizerBar.tsx',
'apps/desktop/src/main/services/MeetingModeService.ts',
'apps/desktop/src/main/services/CloudSyncService.ts',
'apps/desktop/src/main/windows/WindowManager.ts',
'site/src/tokens.ts',
'site/src/index.css',
'site/tailwind.config.js',
'site/public/accept-invite.css',
'site/public/legal.css',
'apps/desktop/src/renderer/styles/global.css',
])
const IGNORED_DIRS = new Set([
'node_modules', '.next', 'dist', 'build', 'out', 'coverage',
'.turbo', 'android', 'ios', '__snapshots__',
])
const SCAN_EXT = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.css', '.html'])
// A token definition file may define local `:root` fallbacks for popups.
const isPopupStyle = (rel) => /apps\/desktop\/src\/renderer\/popups\/.*\/style\.css$/.test(rel)
const isTestFile = (rel) => /\.(test|spec)\.(ts|tsx|js|jsx|mjs)$/.test(rel)
const HEX = /#[0-9a-fA-F]{3,8}(?![0-9a-fA-F])/g
const FUNC_COLOR = /\b(?:rgba?|hsla?)\([^)]*\)/g
const BOLD_WEIGHT = /(font-?weight\s*[:=]\s*['"]?([7-9]\d0)\b|fontWeight\s*:\s*([7-9]\d0)\b)/g
function walk(dir, files) {
let entries
try {
entries = readdirSync(dir)
} catch {
return
}
for (const name of entries) {
if (IGNORED_DIRS.has(name)) continue
const full = join(dir, name)
const st = statSync(full)
if (st.isDirectory()) walk(full, files)
else if (SCAN_EXT.has(name.slice(name.lastIndexOf('.')))) files.push(full)
}
}
function isAllowlisted(rel) {
if (ALLOWLIST.has(rel)) return true
if (isPopupStyle(rel)) return true
if (isTestFile(rel)) return true
return false
}
function hexLooksLikeColor(match, line, index) {
const before = line[index - 1]
// URL fragment / selector boundary: #features, #root, url(#clip)
if (before && /[A-Za-z0-9_\-/)&(]/.test(before)) return false
// HTML attribute value: href="#download", id='#x'
if ((before === '"' || before === "'") && line[index - 2] === '=') return false
return true
}
// A mask gradient uses white as an opacity stencil, not a painted color.
const isMaskIdiom = (line) => /#fff 0 0/.test(line)
// Comment lines describe identifiers like #access_token; they are not colors.
const isCommentLine = (line) => /^\s*(\/\/|\*|\/\*|<!--)/.test(line)
function scanFile(full) {
const rel = relative(ROOT, full).replace(/\\/g, '/')
if (isAllowlisted(rel)) return []
const text = readFileSync(full, 'utf8')
const lines = text.split(/\r?\n/)
const out = []
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i]
if (isCommentLine(line) || isMaskIdiom(line)) continue
HEX.lastIndex = 0
let m
while ((m = HEX.exec(line))) {
if (!hexLooksLikeColor(m[0], line, m.index)) continue
out.push({ rel, line: i + 1, rule: 'hex-color', text: m[0] })
}
FUNC_COLOR.lastIndex = 0
while ((m = FUNC_COLOR.exec(line))) {
out.push({ rel, line: i + 1, rule: 'rgb/hsl-literal', text: m[0] })
}
BOLD_WEIGHT.lastIndex = 0
while ((m = BOLD_WEIGHT.exec(line))) {
out.push({ rel, line: i + 1, rule: 'bold-weight', text: m[0].trim() })
}
}
return out
}
function collect() {
const files = []
for (const t of TARGETS) {
const full = join(ROOT, t)
try {
if (statSync(full).isDirectory()) walk(full, files)
else files.push(full)
} catch {
/* target absent on this platform */
}
}
const violations = []
for (const f of files) violations.push(...scanFile(f))
return violations
}
function selfTest() {
const cases = [
['const a = "#3b82f6"', true],
['color: rgba(59,130,246,0.5)', true],
['fontWeight: 700', true],
['href="#download"', false],
['url(#clip)', false],
['const id = "#root"', false],
['color: "var(--d3-accent-main)"', false],
]
let failed = 0
for (const [line, shouldFlag] of cases) {
const flagged = []
HEX.lastIndex = 0
FUNC_COLOR.lastIndex = 0
BOLD_WEIGHT.lastIndex = 0
let m
while ((m = HEX.exec(line))) if (hexLooksLikeColor(m[0], line, m.index)) flagged.push(m[0])
while ((m = FUNC_COLOR.exec(line))) flagged.push(m[0])
while ((m = BOLD_WEIGHT.exec(line))) flagged.push(m[0])
const got = flagged.length > 0
if (got !== shouldFlag) {
failed += 1
console.error(`self-test FAIL: ${JSON.stringify(line)} expected=${shouldFlag} got=${got}`)
}
}
if (failed) {
console.error(`self-test failed (${failed})`)
process.exit(1)
}
console.log('check-design-tokens self-test: OK')
}
const argv = process.argv.slice(2)
if (argv.includes('--self-test')) {
selfTest()
} else {
const violations = collect()
if (argv.includes('--json')) {
console.log(JSON.stringify({ count: violations.length, violations }, null, 2))
} else {
const byFile = new Map()
for (const v of violations) {
if (!byFile.has(v.rel)) byFile.set(v.rel, [])
byFile.get(v.rel).push(v)
}
for (const [rel, list] of [...byFile.entries()].sort()) {
console.log(`\n${rel} (${list.length})`)
for (const v of list.slice(0, 200)) {
console.log(` ${String(v.line).padStart(4)} ${v.rule.padEnd(15)} ${v.text}`)
}
}
console.log(`\ndesign-token violations: ${violations.length}`)
}
if (violations.length > 0) process.exit(1)
}

View file

@ -59,7 +59,7 @@ const rules = [
},
{
name: 'credential-assignment-literal',
pattern: /(?:password|passwd|client[_-]?secret|api[_-]?secret|service[_-]?key|jwt[_-]?(?:secret|key)|admin[_-]?(?:bootstrap[_-]?token|session[_-]?secret)|service[_-]?role[_-]?key)\s*[:=]\s*(['"])(?!\s*(?:\$|%[A-Z_][A-Z0-9_]*%|replace|example|dummy|test|ci[-_]|changeme|your_|android)\b)(?:(?!\1).){8,}\1/i,
pattern: /(?:password|passwd|client[_-]?secret|api[_-]?secret|service[_-]?key|jwt[_-]?(?:secret|key)|admin[_-]?(?:bootstrap[_-]?token|session[_-]?secret)|service[_-]?role[_-]?key)\s*[:=]\s*(['"])(?!\s*(?:\$|\{\{|%[A-Z_][A-Z0-9_]*%|(?:replace|example|dummy|test|ci[-_]|changeme|your_|android)\b))(?:(?!\1).){8,}\1/i,
},
]
@ -93,6 +93,7 @@ if (process.argv.includes('--self-test')) {
['scripts/release.mjs', `const token = process.env.FORGEJO_TOKEN?.trim()`],
['scripts/deploy.sh', `JWT_SECRET="$JWT_SECRET"`],
['.github/workflows/ci.yml', `MOBILE_E2E_PASSWORD: \${{ secrets.MOBILE_E2E_PASSWORD }}`],
['.forgejo/workflows/release.yml', `WIN_CSC_KEY_${'PASS' + 'WORD'}: "\${{ secrets.WIN_CSC_KEY_PASSWORD }}"`],
['.env.example', 'JWT_SECRET='],
[
'release/evidence-public.pem',

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)
}

View file

@ -0,0 +1,359 @@
// scripts/ci/publish-forgejo-release.mjs
// Canonical release publisher for D3RO Voice.
//
// Tag 파이프라인의 release 스테이지에서 실행:
// 1) apps/desktop/release/<version>/ 자산을 Forgejo Generic Package Registry에 업로드
// - 버전별 경로: /api/packages/<owner>/generic/d3ro-voice/<version>/<file>
// - latest 경로: /api/packages/<owner>/generic/d3ro-voice/latest/<file> (electron-updater feed)
// 2) release/update-policy.json을 latest feed에 게시 (원격 정책/킬 스위치)
// 3) Forgejo Release 생성/갱신 + 설치 자산 첨부 (admin·site 다운로드 허브)
//
// 배포 순서 보장: 설치파일/blockmap을 먼저 올리고 update metadata(latest.yml)를
// 마지막에 게시한다. 기존 설치본이 배포 도중 404를 받지 않는다.
//
// 필요 env:
// FORGEJO_TOKEN (write:package + write:repository) 또는 FORGEJO_USERNAME/FORGEJO_PASSWORD
// 선택: FORGEJO_REPO=git.chanpaca.net/yunchan/d3ro-voice
// 선택: FORGEJO_RELEASE_TAG (기본: CI_COMMIT_TAG/GITHUB_REF_NAME/FORGEJO_REF_NAME)
// 선택: FORGEJO_PUBLISH_DRY_RUN=1 (업로드 없이 사전점검)
import { createHash } from "node:crypto";
import { createReadStream, readFileSync } from "node:fs";
import { readFile, readdir, stat } from "node:fs/promises";
import { basename, join } from "node:path";
import process from "node:process";
import { fileURLToPath, URL } from "node:url";
import credentialHelpers from "../lib/credentials.cjs";
const { forgejoAuthorization } = credentialHelpers;
const DEFAULT_REPO = "git.chanpaca.net/yunchan/d3ro-voice";
const PACKAGE_NAME = "d3ro-voice";
const POLICY_FILENAME = "update-policy.json";
const dryRun = process.env.FORGEJO_PUBLISH_DRY_RUN === "1" || process.argv.includes("--check");
const tag = resolveTag();
if (!tag) throw new Error("Release tag is required (CI_COMMIT_TAG / GITHUB_REF_NAME / FORGEJO_REF_NAME).");
const version = tag.replace(/^v/, "");
if (!/^\d+\.\d+\.\d+$/.test(version)) {
throw new Error(`Only stable semver release tags are supported by the canonical publisher: ${tag}`);
}
const { origin, owner, repo } = parseRepo(resolveRepoUrl());
const packageVersionedUrl = `${origin}/api/packages/${owner}/generic/${PACKAGE_NAME}/${version}`;
const packageLatestUrl = `${origin}/api/packages/${owner}/generic/${PACKAGE_NAME}/latest`;
const releasesApiUrl = `${origin}/api/v1/repos/${owner}/${repo}/releases`;
const authorization = dryRun && !process.env.FORGEJO_TOKEN && !process.env.FORGEJO_USERNAME
? null
: forgejoAuthorization();
const productVersion = JSON.parse(
await readFile(fileURLToPath(new URL("../../release/product-version.json", import.meta.url)), "utf8"),
);
if (tag !== `v${productVersion.version}`) {
throw new Error(`Release tag ${tag} does not match product version v${productVersion.version}.`);
}
const policyPath = fileURLToPath(new URL("../../release/update-policy.json", import.meta.url));
const policyRaw = readFileSync(policyPath);
try {
const policy = JSON.parse(policyRaw.toString("utf8"));
if (policy.schemaVersion !== 1) throw new Error("unsupported schemaVersion");
} catch (error) {
throw new Error(`update-policy.json is invalid: ${error instanceof Error ? error.message : String(error)}`);
}
const releaseDirectory = process.env.FORGEJO_RELEASE_DIR?.trim()
? process.env.FORGEJO_RELEASE_DIR.trim()
: fileURLToPath(new URL(`../../apps/desktop/release/${version}/`, import.meta.url));
const ASSET_PATTERN = /(\.exe|\.dmg|\.zip|\.blockmap|^(latest|beta|alpha)(-mac|-linux)?\.yml)$/;
const files = [];
for (const name of await readdir(releaseDirectory)) {
if (!ASSET_PATTERN.test(name)) continue;
const path = join(releaseDirectory, name);
if ((await stat(path)).isFile()) files.push({ name, path });
}
if (files.length === 0) throw new Error(`No release assets found in ${releaseDirectory}`);
if (!files.some((file) => file.name.endsWith(".exe"))) {
throw new Error(`Windows installer is missing for ${tag}.`);
}
if (!files.some((file) => file.name === "latest.yml")) {
throw new Error(`latest.yml is missing for ${tag} — check electron-builder.yml publish config.`);
}
if (!files.some((file) => file.name.endsWith(".dmg"))) {
process.stdout.write(`WARNING: macOS artifacts missing for ${tag} — Windows-only release.\n`);
}
const sorted = files.sort((a, b) => a.name.localeCompare(b.name));
const sha256ByFile = new Map();
for (const file of sorted) sha256ByFile.set(file.name, await sha256(file.path));
if (dryRun) {
process.stdout.write(
`[forgejo] dry-run OK — ${tag}, ${sorted.length} assets, feed ${packageLatestUrl}\n`,
);
process.exit(0);
}
// 1) 버전별(immutable) 패키지 업로드
for (const file of sorted) {
await uploadToRegistry(file, `${packageVersionedUrl}/${encodeURIComponent(safeAssetName(file.name))}`, {
immutable: true,
});
}
// 2) latest feed 갱신 — 설치 자산 먼저, metadata 마지막
const latestOrder = [...sorted].sort((a, b) => {
const order = Number(isUpdateMetadata(a.name)) - Number(isUpdateMetadata(b.name));
return order || a.name.localeCompare(b.name);
});
for (const file of latestOrder) {
await uploadToRegistry(file, `${packageLatestUrl}/${encodeURIComponent(safeAssetName(file.name))}`, {
replace: true,
});
}
await uploadToRegistry(
{ name: POLICY_FILENAME, path: policyPath },
`${packageLatestUrl}/${POLICY_FILENAME}`,
{ replace: true },
);
// 3) metadata 참조 검증 + 공개 URL 재검증
for (const file of latestOrder.filter((candidate) => isYamlUpdateMetadata(candidate.name))) {
validateUpdateMetadataReferences(file, latestOrder);
}
for (const name of ["latest.yml", POLICY_FILENAME]) {
await verifyPublicFile(`${packageLatestUrl}/${name}`);
}
// 4) Forgejo Release 생성/갱신 + 자산 첨부
const description = await buildReleaseDescription();
const releaseId = await upsertRelease(description);
for (const file of sorted) {
await uploadReleaseAsset(releaseId, file);
}
process.stdout.write(`Published ${tag} to Forgejo (${sorted.length} assets, release ${releasesApiUrl}/${tag}).\n`);
// ── helpers ─────────────────────────────────────────────────
function resolveTag() {
return (
process.env.CI_COMMIT_TAG?.trim() ||
process.env.FORGEJO_RELEASE_TAG?.trim() ||
process.env.FORGEJO_REF_NAME?.trim() ||
process.env.GITHUB_REF_NAME?.trim() ||
process.argv.find((arg) => /^v\d+\.\d+\.\d+$/.test(arg)) ||
""
);
}
function resolveRepoUrl() {
const configured = process.env.FORGEJO_REPO?.trim();
const value = configured || DEFAULT_REPO;
return value.startsWith("http") ? value : `https://${value}`;
}
function parseRepo(rawUrl) {
const parsed = new URL(rawUrl);
if (parsed.protocol !== "https:") throw new Error("Forgejo repo URL must use HTTPS");
const segments = parsed.pathname.split("/").filter(Boolean);
if (segments.length !== 2) throw new Error("Forgejo repo URL must point to owner/repo");
return { origin: parsed.origin, owner: segments[0], repo: segments[1] };
}
function safeAssetName(name) {
return basename(name).replace(/[^A-Za-z0-9._-]+/g, "-");
}
function isUpdateMetadata(name) {
return /(?:\.blockmap$|^(?:latest|beta|alpha)(?:-mac|-linux)?\.yml$|^update-policy\.json$)/.test(name);
}
function isYamlUpdateMetadata(name) {
return /^(?:latest|beta|alpha)(?:-mac|-linux)?\.yml$/.test(name);
}
async function sha256(path) {
const hash = createHash("sha256");
await new Promise((resolve, reject) => {
createReadStream(path)
.on("data", (chunk) => hash.update(chunk))
.on("end", resolve)
.on("error", reject);
});
return hash.digest("hex");
}
async function uploadToRegistry(file, url, { replace = false, immutable = false } = {}) {
const fileStat = await stat(file.path).catch(() => null);
if (immutable) {
// 버전별 패키지: 이미 동일 크기의 파일이 업로드되어 있다면 중복 전송 방지
const head = await forgejoFetch(url, { method: "HEAD" }).catch(() => null);
if (head && head.ok) {
const remoteLength = head.headers.get("content-length");
if (fileStat && remoteLength && Number(remoteLength) === fileStat.size) {
process.stdout.write(` verified existing immutable ${file.name} (${fileStat.size} bytes)\n`);
return;
}
}
}
if (replace) {
// replace 모드: 기존 파일이 있으면 먼저 삭제하여 409 Conflict 후 이중 전송으로 인한
// Cloudflare 타임아웃(HTTP 524)을 원천 차단한다.
await forgejoFetch(url, { method: "DELETE" }).catch(() => null);
}
const body = await readFile(file.path);
const response = await forgejoFetch(url, {
method: "PUT",
headers: { "Content-Type": "application/octet-stream" },
body,
});
if (response.ok) {
process.stdout.write(` uploaded ${file.name}\n`);
return;
}
if (response.status === 409 && (replace || immutable)) {
// 재실행/재시도: 기존 파일을 지우고 다시 올린다.
await forgejoFetch(url, { method: "DELETE" });
const retry = await forgejoFetch(url, {
method: "PUT",
headers: { "Content-Type": "application/octet-stream" },
body,
});
if (!retry.ok) {
throw new Error(`registry upload failed for ${file.name}: HTTP ${retry.status} ${await retry.text()}`);
}
process.stdout.write(` replaced ${file.name}\n`);
return;
}
throw new Error(`registry upload failed for ${file.name}: HTTP ${response.status} ${await response.text()}`);
}
function validateUpdateMetadataReferences(metadataFile, uploadedFiles) {
const text = readFileSync(metadataFile.path, "utf8");
const uploadedNames = new Set(uploadedFiles.map((file) => safeAssetName(file.name)));
const references = [...text.matchAll(/^\s*(?:-\s+url:|path:)\s*["']?([^"'\r\n]+?)["']?\s*$/gm)].map(
(match) => basename(match[1].trim()),
);
if (references.length === 0) {
throw new Error(`${metadataFile.name} does not reference a release artifact.`);
}
for (const reference of references) {
if (!uploadedNames.has(reference)) {
throw new Error(`${metadataFile.name} references missing release artifact ${reference}.`);
}
}
}
async function verifyPublicFile(url) {
const response = await fetch(`${url}?release=${encodeURIComponent(tag)}`, { cache: "no-store" });
if (!response.ok) throw new Error(`Public updater verification failed for ${url}: HTTP ${response.status}`);
const body = await response.text();
if (!body.trim()) throw new Error(`Public updater file is empty: ${url}`);
}
async function buildReleaseDescription() {
const changelog = await readFile(
fileURLToPath(new URL("../../CHANGELOG.md", import.meta.url)),
"utf8",
);
const section = extractChangelogSection(changelog, version);
if (!section) throw new Error(`CHANGELOG.md is missing a ${version} release section.`);
const checksums = [...sha256ByFile.entries()]
.map(([name, hash]) => `- \`${name}\`: \`${hash}\``)
.join("\n");
return `${section}\n\n### SHA-256\n${checksums}`;
}
function extractChangelogSection(changelog, targetVersion) {
const lines = changelog.split("\n");
const escaped = targetVersion.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const headerPattern = new RegExp(`^##\\s+\\[?v?${escaped}(?:\\]|\\s|$)`);
const start = lines.findIndex((line) => headerPattern.test(line));
if (start === -1) return null;
let end = lines.length;
for (let index = start + 1; index < lines.length; index++) {
if (/^##\s+/.test(lines[index])) {
end = index;
break;
}
}
return lines.slice(start, end).join("\n").trim();
}
async function upsertRelease(description) {
const byTag = await forgejoFetch(`${releasesApiUrl}/tags/${encodeURIComponent(tag)}`);
if (byTag.ok) {
const existing = await byTag.json();
const update = await forgejoFetch(`${releasesApiUrl}/${existing.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: `D3RO Voice ${tag}`, body: description, prerelease: false, draft: false }),
});
if (!update.ok) throw new Error(`release update failed: HTTP ${update.status} ${await update.text()}`);
return existing.id;
}
if (byTag.status !== 404) {
throw new Error(`release lookup failed: HTTP ${byTag.status} ${await byTag.text()}`);
}
const create = await forgejoFetch(releasesApiUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
tag_name: tag,
target_commitish: "main",
name: `D3RO Voice ${tag}`,
body: description,
draft: false,
prerelease: false,
}),
});
if (!create.ok) throw new Error(`release creation failed: HTTP ${create.status} ${await create.text()}`);
const created = await create.json();
return created.id;
}
async function uploadReleaseAsset(releaseId, file) {
const assetName = safeAssetName(file.name);
// 재실행 대비: 같은 이름의 기존 asset 제거
const list = await forgejoFetch(`${releasesApiUrl}/${releaseId}/assets`);
if (list.ok) {
const assets = await list.json();
for (const asset of Array.isArray(assets) ? assets : []) {
if (asset?.name === assetName && typeof asset.id === "number") {
await forgejoFetch(`${releasesApiUrl}/${releaseId}/assets/${asset.id}`, { method: "DELETE" });
}
}
}
const buffer = await readFile(file.path);
const form = new FormData();
form.append("attachment", new Blob([buffer]), assetName);
const response = await forgejoFetch(
`${releasesApiUrl}/${releaseId}/assets?name=${encodeURIComponent(assetName)}`,
{ method: "POST", body: form },
);
if (!response.ok) {
throw new Error(`release asset upload failed for ${assetName}: HTTP ${response.status} ${await response.text()}`);
}
process.stdout.write(` attached ${assetName}\n`);
}
function forgejoFetch(url, init = {}) {
return fetch(url, {
...init,
headers: {
...(authorization ? { Authorization: authorization } : {}),
...(init.headers ?? {}),
},
});
}

View file

@ -100,8 +100,10 @@ for (const file of latestFiles) {
await uploadFile(file, `${latestPackageBaseUrl}/${encodeURIComponent(registryName)}`, "latest package");
}
for (const file of latestFiles.filter((candidate) => isUpdateMetadata(candidate.name))) {
for (const file of latestFiles.filter((candidate) => isYamlUpdateMetadata(candidate.name))) {
validateUpdateMetadataReferences(file, latestFiles);
}
for (const file of latestFiles.filter((candidate) => isUpdateMetadata(candidate.name))) {
await verifyPublicLatestFile(file, latestPackageBaseUrl);
}
@ -128,6 +130,10 @@ function isUpdateMetadata(name) {
return /(?:\.blockmap$|^(?:latest|alpha|beta)(?:-mac|-linux)?\.yml$)/.test(name);
}
function isYamlUpdateMetadata(name) {
return /^(?:latest|alpha|beta)(?:-mac|-linux)?\.yml$/.test(name);
}
async function buildDescription() {
const changelog = await readFile(
fileURLToPath(new URL("../../CHANGELOG.md", import.meta.url)),

View file

@ -74,7 +74,6 @@ const packageManifestPaths = [
'package.json',
'apps/admin/package.json',
'apps/desktop/package.json',
'apps/mobile/package.json',
'apps/mobile-rn/package.json',
'apps/web/package.json',
'packages/api-client/package.json',
@ -129,10 +128,6 @@ if (existsSync(join(root, 'site', 'package-lock.json'))) {
})
}
updateText('apps/mobile/app.config.ts', (text) =>
replaceExactlyOnce(text, /version: '[^']+',/, `version: '${metadata.version}',`, 'Expo version'),
)
updateText('apps/mobile-rn/android/app/build.gradle', (text) => {
let next = replaceExactlyOnce(
text,

View file

@ -4,7 +4,11 @@ import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..')
// canonical updater feed: Forgejo Generic Package Registry
const CANONICAL_UPDATE_FEED =
'https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice/latest'
// legacy mirror: GitLab Generic Registry (pre-Forgejo installs still poll this)
const LEGACY_UPDATE_FEED =
'https://gitlab.twentyoz.kr:8443/api/v4/projects/1172/packages/generic/d3ro-voice/latest'
function read(path) {
@ -14,6 +18,7 @@ function read(path) {
function loadSurfaces(readSurface = read) {
return {
metadata: JSON.parse(readSurface('release/product-version.json')),
updatePolicy: JSON.parse(readSurface('release/update-policy.json')),
androidIdentity: JSON.parse(readSurface('release/android-release-identity.json')),
releaseEvidencePublicKey: readSurface('release/mobile-release-evidence-public.pem'),
desktopLicensePublicKey: readSurface('apps/desktop/resources/license/production-public.pem'),
@ -23,13 +28,17 @@ function loadSurfaces(readSurface = read) {
builder: readSurface('apps/desktop/electron-builder.yml'),
electronVite: readSurface('apps/desktop/electron.vite.config.ts'),
updateFeed: readSurface('apps/desktop/src/main/update-feed.ts'),
updatePolicySource: readSurface('apps/desktop/src/main/update-policy.ts'),
updateService: readSurface('apps/desktop/src/main/services/UpdateService.ts'),
publisher: readSurface('scripts/ci/publish-gitlab-release.mjs'),
forgejoPublisher: readSurface('scripts/ci/publish-forgejo-release.mjs'),
gitlab: readSurface('.gitlab-ci.yml'),
github: readSurface('.github/workflows/release.yml'),
githubMac: readSurface('.github/workflows/build-mac.yml'),
githubSigning: readSurface('.github/workflows/release-signing-ca.yml'),
forgejoLinux: readSurface('.forgejo/workflows/deploy-site.yml'),
forgejoWindows: readSurface('.forgejo/workflows/deploy-site-windows.yml'),
forgejoRelease: readSurface('.forgejo/workflows/release.yml'),
changelog: readSurface('CHANGELOG.md'),
}
}
@ -121,17 +130,78 @@ function validate(surfaces) {
fail(electronVersion === lockedElectron, 'electron_package_lock_drift')
fail(builderElectron === lockedElectron, 'electron_builder_lock_drift')
// ── canonical feed contract: runtime == builder == Forgejo canonical ──
const sourceFeed = surfaces.updateFeed.match(/UPDATE_FEED_URL\s*=\s*\n?\s*['"]([^'"]+)['"]/)?.[1]
const legacyFeed = surfaces.updateFeed.match(/LEGACY_UPDATE_FEED_URL\s*=\s*\n?\s*['"]([^'"]+)['"]/)?.[1]
const builderFeed = surfaces.builder.match(/publish:\s*[\s\S]*?\n\s+url:\s*["']([^"']+)["']/)?.[1]
fail(sourceFeed === CANONICAL_UPDATE_FEED, 'desktop_runtime_update_feed_drift')
fail(builderFeed === CANONICAL_UPDATE_FEED, 'desktop_builder_update_feed_drift')
fail(!/\/releases\/\d+\.\d+\.\d+/.test(sourceFeed ?? ''), 'desktop_update_feed_version_pinned')
fail(legacyFeed === LEGACY_UPDATE_FEED, 'desktop_legacy_mirror_feed_missing')
// ── update policy SSOT ──
const policy = surfaces.updatePolicy
fail(policy?.schemaVersion === 1, 'update_policy_schema_invalid')
fail(
['latest', 'beta', 'alpha'].every((channel) => typeof policy?.channels?.[channel]?.allowPrerelease === 'boolean'),
'update_policy_channels_missing',
)
fail(
['latest', 'beta', 'alpha'].includes(policy?.defaultChannel),
'update_policy_default_channel_invalid',
)
fail(/^\d+\.\d+\.\d+$/.test(policy?.minimumSupportedVersion ?? ''), 'update_policy_minimum_invalid')
fail(
policy?.forceInstallBelow === null || /^\d+\.\d+\.\d+$/.test(policy?.forceInstallBelow ?? ''),
'update_policy_force_install_invalid',
)
fail(typeof policy?.fullInstallOnMajorChange === 'boolean', 'update_policy_major_policy_missing')
fail(
Number.isSafeInteger(policy?.fullInstallVersionGap) && policy.fullInstallVersionGap >= 0,
'update_policy_version_gap_invalid',
)
fail(
Number.isSafeInteger(policy?.stagingPercentage) && policy.stagingPercentage >= 0 && policy.stagingPercentage <= 100,
'update_policy_staging_invalid',
)
fail(typeof policy?.killSwitch === 'boolean', 'update_policy_kill_switch_missing')
fail(
surfaces.updatePolicySource.includes('decideUpdate') &&
surfaces.updatePolicySource.includes('fullInstallVersionGap') &&
surfaces.updatePolicySource.includes('isWithinRollout'),
'update_policy_runtime_logic_missing',
)
fail(
surfaces.updateService.includes('decideUpdate') &&
surfaces.updateService.includes('isWithinRollout') &&
/\bkillSwitch\b/.test(surfaces.updateService),
'update_service_policy_enforcement_missing',
)
fail(
surfaces.updateService.includes('disableDifferentialDownload'),
'update_service_differential_control_missing',
)
// ── legacy GitLab publisher (mirror) ──
fail(surfaces.publisher.includes('const latestFiles = [...sortedFiles].sort'), 'publisher_asset_first_order_missing')
fail(!surfaces.publisher.includes('deletePackagesForVersion("latest")'), 'publisher_deletes_live_feed_first')
fail(surfaces.publisher.includes('verifyPublicLatestFile'), 'publisher_public_metadata_verification_missing')
fail(surfaces.publisher.includes('release/product-version.json'), 'publisher_product_version_gate_missing')
// ── canonical Forgejo publisher contract ──
fail(!/1\.0\.0/.test(surfaces.forgejoPublisher), 'forgejo_publisher_hardcoded_version')
fail(surfaces.forgejoPublisher.includes('release/product-version.json'), 'forgejo_publisher_version_gate_missing')
fail(surfaces.forgejoPublisher.includes('const latestOrder'), 'forgejo_publisher_asset_first_order_missing')
fail(surfaces.forgejoPublisher.includes('validateUpdateMetadataReferences'), 'forgejo_publisher_metadata_reference_check_missing')
fail(surfaces.forgejoPublisher.includes('verifyPublicFile'), 'forgejo_publisher_public_verification_missing')
fail(surfaces.forgejoPublisher.includes('update-policy.json'), 'forgejo_publisher_policy_upload_missing')
fail(surfaces.forgejoPublisher.includes('CHANGELOG.md'), 'forgejo_publisher_changelog_gate_missing')
fail(
surfaces.forgejoPublisher.includes('/api/packages/') &&
surfaces.forgejoPublisher.includes('generic'),
'forgejo_publisher_registry_path_missing',
)
for (const [name, workflow] of [
['gitlab', surfaces.gitlab],
['github', surfaces.github],
@ -140,8 +210,13 @@ function validate(surfaces) {
fail(workflow.includes('release/product-version.json'), `${name}_product_metadata_missing`)
fail(!workflow.includes('1000000 + CI_PIPELINE_IID'), `${name}_pipeline_counter_version_code`)
fail(!workflow.includes('1000000 + GITHUB_RUN_NUMBER'), `${name}_run_counter_version_code`)
fail(workflow.includes('publish-forgejo-release.mjs'), `${name}_forgejo_publish_missing`)
}
fail(surfaces.forgejoRelease.includes('publish-forgejo-release.mjs'), 'forgejo_release_workflow_publish_missing')
fail(/tags:/.test(surfaces.forgejoRelease), 'forgejo_release_workflow_tag_trigger_missing')
fail(surfaces.forgejoRelease.includes('sync-version.mjs'), 'forgejo_release_workflow_version_gate_missing')
fail(!/push:\s*\n\s*tags:/m.test(surfaces.githubMac), 'legacy_mac_tag_trigger_enabled')
fail(!/push:\s*\n\s*tags:/m.test(surfaces.githubSigning), 'legacy_signing_tag_trigger_enabled')
fail(!surfaces.forgejoLinux.includes('sync-and-publish-forgejo-release'), 'forgejo_linux_legacy_release_sync')
@ -163,6 +238,9 @@ function validate(surfaces) {
androidVersionCode: metadata.androidVersionCode,
iosBuildNumber: metadata.iosBuildNumber,
updateFeed: sourceFeed,
legacyUpdateFeed: legacyFeed,
updateChannel: policy.defaultChannel,
minimumSupportedVersion: policy.minimumSupportedVersion,
electronVersion: lockedElectron,
releaseEvidenceKeyId: evidenceKeyId,
desktopLicensePublicKeyId: desktopLicenseKeyId,
@ -196,6 +274,55 @@ if (process.argv.includes('--self-test')) {
},
'desktop_runtime_update_feed_drift',
)
expectRejected(
surfaces,
(candidate) => {
candidate.updateFeed = candidate.updateFeed.replace(LEGACY_UPDATE_FEED, 'https://example.invalid/legacy')
},
'desktop_legacy_mirror_feed_missing',
)
expectRejected(
surfaces,
(candidate) => {
candidate.updatePolicy.minimumSupportedVersion = 'not-semver'
},
'update_policy_minimum_invalid',
)
expectRejected(
surfaces,
(candidate) => {
candidate.updatePolicy.stagingPercentage = 140
},
'update_policy_staging_invalid',
)
expectRejected(
surfaces,
(candidate) => {
candidate.forgejoPublisher = 'console.log("1.0.0 is hardcoded")'
},
'forgejo_publisher_hardcoded_version',
)
expectRejected(
surfaces,
(candidate) => {
candidate.forgejoPublisher = candidate.forgejoPublisher.replace('const latestOrder', 'const uploadOrder')
},
'forgejo_publisher_asset_first_order_missing',
)
expectRejected(
surfaces,
(candidate) => {
candidate.gitlab = candidate.gitlab.replace('publish-forgejo-release.mjs', 'publish-gitlab-release.mjs')
},
'gitlab_forgejo_publish_missing',
)
expectRejected(
surfaces,
(candidate) => {
candidate.forgejoRelease = candidate.forgejoRelease.replace('tags:', 'branches:')
},
'forgejo_release_workflow_tag_trigger_missing',
)
expectRejected(
surfaces,
(candidate) => {
@ -224,6 +351,13 @@ if (process.argv.includes('--self-test')) {
},
'desktop_license_public_key_id_drift',
)
expectRejected(
surfaces,
(candidate) => {
candidate.updateService = candidate.updateService.replaceAll('killSwitch', 'killSwitchDisabled')
},
'update_service_policy_enforcement_missing',
)
let missingDesktopKeyRejected = false
try {
loadSurfaces((path) => {
@ -239,7 +373,7 @@ if (process.argv.includes('--self-test')) {
if (!missingDesktopKeyRejected) {
throw new Error('release_metadata_self_test_failed:desktop_license_public_key_missing')
}
result.negativeCases = 6
result.negativeCases = 13
}
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`)