d3ro-voice/scripts/ci/publish-portable-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

210 lines
No EOL
8.9 KiB
JavaScript

// scripts/ci/publish-portable-release.mjs
// 서명 없는 휴대용 배포본(7z 분할 볼륨) + Scoop 매니페스트 + 수동 설치 스크립트를
// Forgejo Generic Registry에 게시한다.
//
// 이 채널은 자동 업데이트 피드(latest.yml / update-policy.json)를 건드리지 않는다.
// 서명이 없어도 게시할 수 있으므로 인증서 발급 전에도 사용자가 설치할 수 있는 경로다.
//
// 경로:
// .../generic/d3ro-voice/portable-<version>/<륨>.7z.00N
// .../generic/d3ro-voice/portable-<version>/portable.json
// .../generic/d3ro-voice/portable-<version>/install-d3ro-voice.ps1
// .../generic/d3ro-voice/portable-latest/... (zip 부품 + install-d3ro-voice.ps1 + portable.json)
// .../generic/d3ro-voice/runtime-<version>/... (로컬 AI 런타임 부품 + runtime.json)
// .../generic/d3ro-voice/runtime-latest/runtime.json (인덱스만 — 부품 URL은 runtime-<version>)
//
// 버전 경로는 불변(다른 바이트면 중단, 부분 업로드는 이어 올림), *-latest 별칭은 버전 경로가
// 모두 완성된 뒤 더 오래된 버전으로 되돌리지 않을 때만 교체한다 — lib/portable-publish-policy.mjs.
// 별칭에는 클라이언트가 별칭 경로로 직접 받는 파일만 둔다(aliasNames). 7z 볼륨·런타임 부품은
// 인덱스가 버전 경로 URL로 가리키므로 별칭에 올리지 않는다. 설치 스크립트는 zip 부품을
// `$FeedBase/<부품>`(= portable-latest)에서 받으므로 zip 부품은 별칭에 둔다.
// 별칭 교체는 파일 단위 DELETE→PUT이고 인덱스가 마지막이다(패키지째 지우지 않는다).
//
// 사용:
// node scripts/ci/build-portable.mjs
// node --env-file-if-exists=.env scripts/ci/publish-portable-release.mjs [--check]
import credentialHelpers from '../lib/credentials.cjs'
import { createHash } from 'node:crypto'
import { existsSync, readFileSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { withFileDeletion } from './lib/forgejo-generic-file-delete.mjs'
import { createForgejoGenericRegistry } from './lib/forgejo-generic-registry.mjs'
import { publishPortablePackages } from './lib/portable-publish-policy.mjs'
const { forgejoAuthorization } = credentialHelpers
const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..')
const check = process.argv.includes('--check') || process.env.PORTABLE_PUBLISH_DRY_RUN === '1'
const FEED = 'https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice'
const version = JSON.parse(
readFileSync(join(root, 'release', 'product-version.json'), 'utf8'),
).version
const releaseDir = join(root, 'apps', 'desktop', 'release', version)
const index = JSON.parse(readFileSync(join(releaseDir, 'portable.json'), 'utf8'))
const installerPath = join(root, 'scripts', 'install', 'install-d3ro-voice.ps1')
if (index.version !== version) {
console.error(
`[portable] portable.json 버전(${index.version})이 product-version.json(${version})과 다릅니다. build-portable.mjs를 다시 실행하세요.`,
)
process.exit(1)
}
if (!existsSync(installerPath)) {
console.error(`[portable] 설치 스크립트가 없습니다: ${installerPath}`)
process.exit(1)
}
// 게시 전 해시 재검증 — 파일이 바뀌었는데 인덱스가 낡으면 불일치 배포가 된다.
// 순서: 볼륨 → zip 부품 → 설치 스크립트 → portable.json(인덱스가 마지막 = 커밋 지점)
const payloads = []
/** portable-latest 별칭에 둘 파일 (설치 스크립트가 별칭 경로에서 직접 받는 것) */
const portableAliasNames = []
for (const volume of index.volumes) {
const path = join(releaseDir, volume.name)
if (!existsSync(path)) {
console.error(`[portable] 볼륨이 없습니다: ${path}`)
process.exit(1)
}
const bytes = await readFile(path)
const sha256 = createHash('sha256').update(bytes).digest('hex')
if (sha256 !== volume.sha256) {
console.error(
`[portable] sha256 불일치 (${volume.name}): index=${volume.sha256} actual=${sha256}`,
)
process.exit(1)
}
payloads.push({ name: volume.name, bytes, contentType: 'application/octet-stream' })
}
// 수동 설치용 zip 분할 부품 (Windows 내장 Expand-Archive로 해제 — 7-Zip 불필요)
for (const part of index.zipParts ?? []) {
const partPath = join(releaseDir, part.name)
if (!existsSync(partPath)) {
console.error(`[portable] zip 부품이 없습니다: ${partPath}`)
process.exit(1)
}
const partBytes = await readFile(partPath)
const partSha = createHash('sha256').update(partBytes).digest('hex')
if (partSha !== part.sha256) {
console.error(`[portable] zip 부품 sha256 불일치 (${part.name})`)
process.exit(1)
}
payloads.push({ name: part.name, bytes: partBytes, contentType: 'application/octet-stream' })
portableAliasNames.push(part.name)
}
payloads.push({
name: 'install-d3ro-voice.ps1',
bytes: await readFile(installerPath),
contentType: 'text/plain',
})
payloads.push({
name: 'portable.json',
bytes: Buffer.from(`${JSON.stringify(index, null, 2)}\n`, 'utf8'),
contentType: 'application/json',
})
portableAliasNames.push('install-d3ro-voice.ps1', 'portable.json')
// ── 로컬 AI 런타임 번들 게시 (설치본에는 없고, 앱이 처음 필요할 때 내려받는다) ──
const runtimeDir = join(releaseDir, 'runtime')
const runtimePayloads = []
const runtimeIndexPath = join(runtimeDir, 'runtime.json')
if (existsSync(runtimeIndexPath)) {
const runtimeIndex = JSON.parse(readFileSync(runtimeIndexPath, 'utf8'))
for (const [component, entry] of Object.entries(runtimeIndex.components ?? {})) {
for (const part of entry.parts) {
const partPath = join(runtimeDir, part.name)
if (!existsSync(partPath)) {
console.error(`[portable] 런타임 부품이 없습니다: ${partPath}`)
process.exit(1)
}
const bytes = await readFile(partPath)
const sha = createHash('sha256').update(bytes).digest('hex')
if (sha !== part.sha256) {
console.error(`[portable] 런타임 부품 sha256 불일치: ${part.name}`)
process.exit(1)
}
runtimePayloads.push({ name: part.name, bytes, contentType: 'application/octet-stream' })
}
void component
}
runtimePayloads.push({
name: 'runtime.json',
bytes: Buffer.from(`${JSON.stringify(runtimeIndex, null, 2)}\n`, 'utf8'),
contentType: 'application/json',
})
}
const authorization = forgejoAuthorization()
async function forgejoFetch(url, init = {}) {
return fetch(url, {
...init,
headers: { Authorization: authorization, ...(init.headers ?? {}) },
})
}
const PACKAGE_API = 'https://git.chanpaca.net/api/v1/packages/yunchan/generic/d3ro-voice'
// 파일 단위 삭제를 더해 별칭을 패키지째 지우지 않고 바뀐 파일만 교체한다.
const registry = withFileDeletion(
createForgejoGenericRegistry({
feedUrl: FEED,
packageApiUrl: PACKAGE_API,
fetchImpl: forgejoFetch,
onUploaded: (url) => console.log(`[portable] uploaded ${url}`),
}),
{ fetchImpl: forgejoFetch },
)
// 정책(불변 버전 경로 · 별칭 롤백 방지)은 lib/portable-publish-policy.mjs 에 있다.
// 버전 경로(runtime-<v>, portable-<v>)를 모두 완성한 뒤에만 *-latest 별칭을 갱신한다.
try {
await publishPortablePackages({
version,
packages: [
// 런타임 부품은 runtime.json이 runtime-<version> URL로 가리킨다 → 별칭에는 인덱스만.
{
kind: 'runtime',
indexName: 'runtime.json',
payloads: runtimePayloads,
aliasNames: ['runtime.json'],
},
{ kind: 'portable', indexName: 'portable.json', payloads, aliasNames: portableAliasNames },
],
registry,
dryRun: check,
describeUrl: registry.fileUrl,
log: (message) => console.log(`[portable] ${message}`),
})
} catch (error) {
console.error(`[portable] ${error instanceof Error ? error.message : String(error)}`)
process.exit(1)
}
console.log(
[
'',
`[portable] 게시 ${check ? '(check 모드 — 실제 업로드 없음)' : '완료'}: ${version}`,
` 볼륨 : ${index.volumeCount}개 / 합계 ${(index.totalSize / 1048576).toFixed(1)}MiB`,
` 인덱스 : ${FEED}/portable-latest/portable.json`,
` 스크립트: ${FEED}/portable-latest/install-d3ro-voice.ps1`,
runtimePayloads.length
? ` 런타임 : ${FEED}/runtime-latest/runtime.json (runtime-${version} 에 ${runtimePayloads.length}개 파일)`
: ' 런타임 : (없음)',
'',
' 설치(Scoop, 권장):',
' scoop bucket add d3ro https://git.chanpaca.net/yunchan/d3ro-voice.git',
' scoop install d3ro/d3ro-voice',
'',
' 수동 설치(추가 도구 불필요):',
` irm ${FEED}/portable-latest/install-d3ro-voice.ps1 | iex`,
'',
' 참고: 이 채널은 서명이 없어 자동 업데이트 피드를 갱신하지 않습니다.',
].join('\n'),
)