// scripts/ci/publish-updater-release.mjs // 자동 업데이트 채널(latest)에 디스크톱 설치본을 게시한다. // // 전제: 설치본이 Cloudflare 업로드 한도(100MiB) 아래여야 한다. 그래서 로컬 AI // 런타임(사이드카/ffmpeg)은 설치본에 넣지 않고, 앱이 처음 필요할 때 // `runtime-latest`에서 내려받는다(RuntimeProvisioner, `npm run release:portable`가 게시). // // 정책 예외(명시): // - 이 채널은 일반적으로 Authenticode 서명을 요구한다. 서명 인증서가 준비되기 전까지 // 업데이트를 전달할 수 없어, **무서명 빌드를 명시적 승인(--ack-unsigned)으로만** 게시한다. // - 검증되지 않은 서명을 조용히 게시하지 않는다: 승인 플래그가 없으면 즉시 실패한다. // // 사용: // npm run build --workspace=@d3ro/desktop // node scripts/ci/publish-updater-release.mjs --build --ack-unsigned // node scripts/ci/publish-updater-release.mjs --check # 게시 예정만 확인 import credentialHelpers from '../lib/credentials.cjs' import { spawnSync } from 'node:child_process' import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs' import { readFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' const { forgejoAuthorization } = credentialHelpers const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..') const desktopDir = join(root, 'apps', 'desktop') const args = process.argv.slice(2) const check = args.includes('--check') const ackUnsigned = args.includes('--ack-unsigned') const build = args.includes('--build') const FEED = 'https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice' /** Cloudflare 업로드 본문 한도 (실측: 110MiB → 413) */ const MAX_UPLOAD_BYTES = 100 * 1024 * 1024 const version = JSON.parse( readFileSync(join(root, 'release', 'product-version.json'), 'utf8'), ).version const releaseDir = join(desktopDir, 'release', version) if (build) { const appDir = join(releaseDir, 'win-unpacked') // 1) 먼저 unpacked 트리빌드한다. console.log('[updater] electron-builder --dir (앱 전용 — 런타임 제외)') const result = spawnSync( 'npx', [ 'electron-builder', '--win', 'dir', '--x64', '--config', 'electron-builder.yml', '--publish', 'never', '-c.win.forceCodeSigning=false', '-c.npmRebuild=false', ], { cwd: desktopDir, stdio: 'inherit', shell: process.platform === 'win32' }, ) if (result.status !== 0) { console.error(`[updater] 빌드 실패 (exit ${result.status ?? 'null'})`) process.exit(result.status ?? 1) } // 2) 네이티브 모듈 ABI 보장 + 검증 (호스트 Node ABI가 섞이면 이 시작조차 못 한다) runNodeScript('scripts/ci/fix-native-abi.mjs', ['--dir', appDir]) runNodeScript('scripts/ci/verify-native-abi.mjs', ['--dir', appDir]) // 3) 검증된 트리에서 설치본 생성 (--prepackaged = 재빌드 없이 그대로 패키징) console.log('[updater] electron-builder --prepackaged (NSIS x64)') const packageResult = spawnSync( 'npx', [ 'electron-builder', '--prepackaged', appDir, '--win', 'nsis', '--x64', '--config', 'electron-builder.yml', '--publish', 'never', '-c.win.forceCodeSigning=false', '-c.npmRebuild=false', ], { cwd: desktopDir, stdio: 'inherit', shell: process.platform === 'win32' }, ) if (packageResult.status !== 0) { console.error(`[updater] 설치본 생성 실패 (exit ${packageResult.status ?? 'null'})`) process.exit(packageResult.status ?? 1) } } /** 이 스크립트와 같은 Node로 CI 스크립트를 실행한다 */ function runNodeScript(relativePath, scriptArgs) { console.log(`[updater] $ node ${relativePath} ${scriptArgs.join(' ')}`) const result = spawnSync(process.execPath, [join(root, relativePath), ...scriptArgs], { cwd: root, stdio: 'inherit', }) if (result.status !== 0) { console.error(`[updater] ${relativePath} 실패 (exit ${result.status ?? 'null'})`) process.exit(result.status ?? 1) } } const metadataPath = join(releaseDir, 'latest.yml') if (!existsSync(metadataPath)) { console.error( `[updater] latest.yml이 없습니다: ${metadataPath}\n --build로 먼저 빌드하세요.`, ) process.exit(1) } const candidates = readdirSync(releaseDir).filter( (name) => /\.exe$/.test(name) && !/__uninstaller|apponly/i.test(name), ) const installer = candidates.find((name) => name.includes('Setup')) ?? candidates[0] if (!installer) { console.error(`[updater] 설치본을 찾을 수 없습니다: ${releaseDir}`) process.exit(1) } const installerPath = join(releaseDir, installer) const blockmapPath = `${installerPath}.blockmap` const policyPath = join(root, 'release', 'update-policy.json') const payloads = [ { name: installer, path: installerPath, type: 'application/octet-stream' }, { name: `${installer}.blockmap`, path: blockmapPath, type: 'application/octet-stream' }, { name: 'latest.yml', path: metadataPath, type: 'text/yaml' }, { name: 'update-policy.json', path: policyPath, type: 'application/json' }, ].filter((payload) => existsSync(payload.path)) const installerSize = statSync(installerPath).size console.log( [ `[updater] 버전 ${version}`, ` 설치본 : ${installer} (${(installerSize / 1048576).toFixed(1)}MiB)`, ` 한도 : ${(MAX_UPLOAD_BYTES / 1048576).toFixed(0)}MiB (Cloudflare 업로드 본문 한도)`, ].join('\n'), ) if (installerSize > MAX_UPLOAD_BYTES) { console.error( [ '[updater] 설치본이 업로드 한도를 넘습니다 — 게시할 수 없습니다.', ' 런타임(사이드카/ffmpeg)을 설치본에 다시 넣지 않았는지 확인하세요:', ' `apps/desktop/electron-builder.yml`의 extraResources / files / asarUnpack.', ].join('\n'), ) process.exit(1) } const metadata = readFileSync(metadataPath, 'utf8') if (!metadata.includes(`version: ${version}`)) { console.error('[updater] latest.yml의 버전이 product-version.json과 다릅니다.') process.exit(1) } const targets = [`${FEED}/${version}`, `${FEED}/latest`] if (check) { console.log('[updater] (check) 게시 예정:') for (const target of targets) { for (const payload of payloads) { console.log(` PUT ${target}/${payload.name} (${statSync(payload.path).size} bytes)`) } } process.exit(0) } if (!ackUnsigned) { console.error( [ '[updater] 무서명 빌드를 stable 채널에 게시하려면 명시적 승인이 필요합니다.', ' 서명 인증서가 준비되면 이 플래그 없이 게시하세요(권장).', ' 승인: --ack-unsigned', ].join('\n'), ) process.exit(1) } const authorization = forgejoAuthorization() /** * 원격 파일이 로컬 바이트와 같은지 판단한다. * Forgejo generic registry는 HEAD를 405로 거부하고 해시도 주지 않으므로, * Range GET으로 크기를 본 뒤 1MiB 이하는 실제 바이트까지 비교한다. * (크기만 비교하면 버전 문자열만 바뀐 latest.yml 같은 메타데이터를 놓친다 — 실측 사고.) */ async function remoteIsIdentical(url, body, fetchImpl) { const probe = await fetchImpl(url, { headers: { Range: 'bytes=0-0' } }).catch(() => null) if (!probe?.ok) return false const contentRange = probe.headers.get('content-range') const remoteSize = contentRange ? Number(contentRange.split('/')[1]) : NaN if (!Number.isFinite(remoteSize) || remoteSize !== body.length) return false if (body.length > 1024 * 1024) return true const full = await fetchImpl(url).catch(() => null) if (!full?.ok) return false const remoteBytes = Buffer.from(await full.arrayBuffer()) return remoteBytes.length === body.length && remoteBytes.equals(body) } async function forgejoFetch(url, init = {}) { return fetch(url, { ...init, headers: { Authorization: authorization, ...(init.headers ?? {}) }, }) } console.log( [ '[updater] 경고: 무서명 설치본을 stable(latest) 채널에 게시합니다.', ' - electron-updater는 app-update.yml에 publisherName이 없으면 서명 검증을 건너뛰므로', ' 설치 자체는 정상 동작합니다.', ' - 인증서가 준비되면 이 버전보다 높은 버전으로 서명 게시하여 대체하세요.', ].join('\n'), ) for (const target of targets) { for (const payload of payloads) { const url = `${target}/${encodeURIComponent(payload.name)}` const body = await readFile(payload.path) // Forgejo generic registry는 HEAD를 405로 거부하고 해시도 주지 않는다. // 크기만 비교하면 버전 문자열만 바뀐 latest.yml을 "동일"로 오판한다 — 내용까지 비교한다. if (await remoteIsIdentical(url, body, forgejoFetch)) { console.log(`[updater] 이미 동일한 파일이 있습니다(건너): ${url}`) continue } // 메타데이터는 최신을 가리켜야 하므로 기존 파일을 지우고 쓴다(PUT은 409를 돌려준다). const existing = await forgejoFetch(url, { headers: { Range: 'bytes=0-0' } }).catch(() => null) if (existing?.ok) { await forgejoFetch(url, { method: 'DELETE' }).catch(() => null) } const response = await forgejoFetch(url, { method: 'PUT', headers: { 'Content-Type': payload.type }, body, }) if (!response.ok) { const hint = response.status === 409 ? ' (409: 같은 경로에 다른 내용이 이미 있음 — 게시된 버전을 덮어쓰지 않습니다)' : response.status === 413 ? ' (413: Cloudflare 업로드 한도 초과 — 런타임 분리 확인)' : '' console.error( `[updater] 업로드 실패 (HTTP ${response.status}): ${target}/${payload.name}${hint}`, ) process.exit(1) } console.log(`[updater] uploaded ${target}/${payload.name}`) } } console.log( [ '', `[updater] 게시 완료: ${version}`, ` 피드 : ${FEED}/latest`, ` 메타 : ${FEED}/latest/latest.yml`, ' 기존 설치본(canonical feed 사용)은 다음 업데이트 확인 때 이 버전을 받습니다.', ' legacy GitLab mirror를 보는 1.0.x 이하 설치는 1회 수동 설치가 필요합니다.', ].join('\n'), )