d3ro-voice/scripts/ci/publish-updater-release.mjs
Yun Chan ba9ef9741e fix: red-team round 3 hardening across desktop, mobile, core and server
Batch of red-team r3 fixes that were in the working tree before the
2026-09-28 design overhaul, committed as one unit with their tests.

- desktop main: STT timeouts and sidecar, voice recording store, sync
  (credentials, audio, knowledge reindex, push gates), runtime
  provisioner, update policy, AltGr keybindings, voice-command policy,
  dictionary file codec/limits, meeting transcript condensing and a
  local recording ledger so interrupted-session recovery only closes
  meetings this device recorded (a phone's live meeting is left alone).
- mobile: login CSRF via implicit token callbacks rejected, account
  deletion/retention, durable queue retention, knowledge realtime
  without unfiltered DELETE, meeting re-record failure paths, cloud STT
  client, preferences store/resync.
- core: text chunking splits long unbroken transcripts to fit, template
  field policy, dictionary limits, meeting markdown inline handling.
- server: payple webhook policy and cancellation order scope, meeting
  document generation quota, team RPC null-role guard, unified LLM
  quota in-flight accounting, knowledge chunk vector index, meeting
  re-record failure paths (migrations 20260929*).
- ci: portable/runtime feed gates, update-policy schema, Forgejo file
  delete and alias planning.

Four older tests are updated to the new contracts rather than the old
behavior: token-pair auth callbacks are rejected, knowledge realtime no
longer subscribes to DELETE, long transcript lines are split, and
meeting recovery requires the local recording ledger for empty rows.
2026-09-28 20:45:52 +09:00

316 lines
No EOL
12 KiB
JavaScript

// 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'
import {
RUNTIME_MIN_VERSION_SOURCE,
WINDOWS_X64_TARGET,
assertRuntimeFeedCompatible,
parseRuntimeMinVersions,
} from './lib/runtime-feed-gate.mjs'
import { assertValidUpdatePolicy } from './lib/update-policy-schema.mjs'
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])
// electron-updater 설정 파일을 보장한다(없으면 자동 업데이트가 동작하지 않는다)
runNodeScript('scripts/ci/write-app-update-yml.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')
// 원격 정책은 오타 하나로 킬 스위치·staged rollout 이 조용히 꺼질 수 있다 — 올리기 전에 엄격히 검증한다.
if (existsSync(policyPath)) {
try {
assertValidUpdatePolicy(readFileSync(policyPath), 'release/update-policy.json')
} catch (error) {
console.error(`[updater] ${error instanceof Error ? error.message : String(error)}`)
process.exit(1)
}
}
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)
}
// 설치본에 app-update.yml이 없으면 electron-updater가 설정을 읽지 못해 자동 업데이트가 죽는다.
const packagedUpdateConfig = join(releaseDir, 'win-unpacked', 'resources', 'app-update.yml')
if (!existsSync(packagedUpdateConfig)) {
console.error('[updater] app-update.yml 누락 — write-app-update-yml.mjs를 먼저 실행하세요.')
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`]
// 앱이 요구하는 런타임(RUNTIME_MIN_VERSION)을 runtime-latest 가 아직 제공하지 못하면, 이 설치본으로
// 업데이트한 사용자는 로컬 STT 엔진을 쓸 수 없다. latest.yml 을 올리기 전에 fail-closed 로 확인한다.
try {
const runtime = await assertRuntimeFeedCompatible({
url: `${FEED}/runtime-latest/runtime.json`,
fetchImpl: (url, init) => fetch(url, init),
minVersions: parseRuntimeMinVersions(readFileSync(join(root, RUNTIME_MIN_VERSION_SOURCE), 'utf8')),
target: WINDOWS_X64_TARGET,
})
console.log(`[updater] runtime-latest ${runtime.version} 이 이 빌드의 최소 런타임 요구를 만족합니다.`)
} catch (error) {
console.error(`[updater] ${error instanceof Error ? error.message : String(error)}`)
process.exit(1)
}
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'),
)