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:
parent
65ecc7aabc
commit
7953706142
21 changed files with 1619 additions and 90 deletions
73
.forgejo/workflows/release.yml
Normal file
73
.forgejo/workflows/release.yml
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
name: release
|
||||
|
||||
# Canonical tag-triggered desktop release built and published on Forgejo.
|
||||
# GitLab CI (.gitlab-ci.yml) and GitHub Actions (.github/workflows/release.yml)
|
||||
# remain alternate builders; all three converge on publish-forgejo-release.mjs
|
||||
# so the Forgejo feed is the single update source.
|
||||
#
|
||||
# Required repository secrets:
|
||||
# FORGEJO_TOKEN — PAT with write:package + write:repository
|
||||
# WIN_CSC_LINK — base64 Authenticode PFX (public-trust)
|
||||
# WIN_CSC_KEY_PASSWORD — PFX password
|
||||
# WIN_CSC_EXPECTED_SIGNER_SUBJECT — exact certificate subject
|
||||
# Release fails closed when signing material is absent.
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
release-windows:
|
||||
runs-on: windows
|
||||
defaults: { run: { shell: pwsh } }
|
||||
steps:
|
||||
- name: checkout
|
||||
env: { CI_TOKEN: "${{ github.token }}" }
|
||||
run: |
|
||||
$u = [Uri]$env:GITHUB_SERVER_URL
|
||||
$url = "$($u.Scheme)://actions:$($env:CI_TOKEN)@$($u.Authority)/$($env:GITHUB_REPOSITORY).git"
|
||||
if (-not (Test-Path .git)) { git init -q . }
|
||||
if (git remote | Select-String -Quiet '^origin$') { git remote set-url origin $url } else { git remote add origin $url }
|
||||
git fetch -q --depth 1 origin $env:GITHUB_REF
|
||||
git checkout -q -f FETCH_HEAD
|
||||
git clean -qfdx
|
||||
|
||||
- name: 버전 정본 대조
|
||||
run: |
|
||||
node scripts/ci/sync-version.mjs --check --tag "$env:GITHUB_REF_NAME"
|
||||
|
||||
- name: 의존성 설치
|
||||
run: npm ci
|
||||
|
||||
- name: 데스크톱 빌드 (서명 필수)
|
||||
env:
|
||||
WIN_CSC_LINK: "${{ secrets.WIN_CSC_LINK }}"
|
||||
WIN_CSC_KEY_PASSWORD: "${{ secrets.WIN_CSC_KEY_PASSWORD }}"
|
||||
WIN_CSC_EXPECTED_SIGNER_SUBJECT: "${{ secrets.WIN_CSC_EXPECTED_SIGNER_SUBJECT }}"
|
||||
run: |
|
||||
if (-not $env:WIN_CSC_LINK -or -not $env:WIN_CSC_KEY_PASSWORD) {
|
||||
throw "WIN_CSC_LINK / WIN_CSC_KEY_PASSWORD 가 없으면 stable 릴리스를 게시할 수 없습니다."
|
||||
}
|
||||
if ($env:WIN_CSC_EXPECTED_SIGNER_SUBJECT -match '(?i)Everything2EverythingDev') {
|
||||
throw "로컬 개발 인증서는 production 서명 identity가 아닙니다."
|
||||
}
|
||||
npm run build --workspace=@d3ro/desktop
|
||||
Push-Location apps/desktop
|
||||
npx electron-builder --win --x64 --config electron-builder.yml --publish never
|
||||
Pop-Location
|
||||
|
||||
- name: Windows 산출물 검증
|
||||
env:
|
||||
WIN_CSC_EXPECTED_SIGNER_SUBJECT: "${{ secrets.WIN_CSC_EXPECTED_SIGNER_SUBJECT }}"
|
||||
run: |
|
||||
$releaseVersion = node -p "require('./release/product-version.json').version"
|
||||
& scripts/ci/verify-windows-release-artifact.ps1 -ExpectedVersion $releaseVersion -ExpectedSignerSubject $env:WIN_CSC_EXPECTED_SIGNER_SUBJECT -ReleaseDirectory "apps/desktop/release/$releaseVersion"
|
||||
|
||||
- name: Forgejo 릴리스 + feed 게시
|
||||
env:
|
||||
FORGEJO_TOKEN: "${{ secrets.FORGEJO_TOKEN }}"
|
||||
FORGEJO_REPO: "${{ github.server_url }}/${{ github.repository }}"
|
||||
run: |
|
||||
node scripts/ci/publish-forgejo-release.mjs
|
||||
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
|
|
@ -104,6 +104,12 @@ jobs:
|
|||
- name: Run Edge Function Contract Tests
|
||||
run: deno test --config server/supabase/functions/deno.json --allow-read --allow-env server/supabase/functions
|
||||
|
||||
- name: Check Cloudflare Worker Drain
|
||||
run: deno check --no-config server/cloudflare-worker/src/push-drain.ts
|
||||
|
||||
- name: Run Cloudflare Worker Tests
|
||||
run: deno test --no-config --allow-read server/cloudflare-worker/src/push-drain.test.ts
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
# 2. Automated Test Matrix (Windows / macOS / Ubuntu)
|
||||
# ──────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
7
.github/workflows/release.yml
vendored
7
.github/workflows/release.yml
vendored
|
|
@ -577,3 +577,10 @@ jobs:
|
|||
overwrite_files: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Publish to Forgejo Release and Update Feed
|
||||
env:
|
||||
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
|
||||
FORGEJO_RELEASE_TAG: ${{ steps.release-identity.outputs.tag }}
|
||||
FORGEJO_RELEASE_DIR: release-dist
|
||||
run: node scripts/ci/publish-forgejo-release.mjs
|
||||
|
|
|
|||
|
|
@ -484,6 +484,8 @@ publish-release:
|
|||
artifacts: false
|
||||
optional: true
|
||||
script:
|
||||
# canonical: Forgejo feed + release hub. legacy mirror: GitLab registry.
|
||||
- node scripts/ci/publish-forgejo-release.mjs
|
||||
- node scripts/ci/publish-gitlab-release.mjs
|
||||
rules:
|
||||
- if: '$CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+.*$/'
|
||||
|
|
|
|||
|
|
@ -19,13 +19,13 @@ files:
|
|||
# 자동 업데이트 feed — 이 설정이 있어야 electron-builder가
|
||||
# latest.yml / latest-mac.yml (update info)를 생성한다.
|
||||
# generic provider는 파일 생성만 트리거하고 직접 업로드는 하지 않음
|
||||
# (업로드는 scripts/ci/publish-gitlab-release.mjs 담당).
|
||||
# d3r0/voice 프로젝트 ID = 1172 —
|
||||
# (업로드는 scripts/ci/publish-forgejo-release.mjs 담당).
|
||||
# canonical 호스트 = Forgejo (git.chanpaca.net/yunchan/d3ro-voice).
|
||||
# src/main/update-feed.ts의 UPDATE_FEED_URL과 반드시 동일 값 유지.
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
publish:
|
||||
provider: generic
|
||||
url: "https://gitlab.twentyoz.kr:8443/api/v4/projects/1172/packages/generic/d3ro-voice/latest"
|
||||
url: "https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice/latest"
|
||||
|
||||
# prerelease 버전(0.1.1-alpha)에서 채널을 "alpha"로 감지해 alpha.yml을 만드는 동작 차단 —
|
||||
# electron-updater(기본 채널 latest)가 latest.yml을 찾으므로 항상 latest 채널로 고정.
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
"build": "electron-vite build",
|
||||
"preview": "electron-vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"typecheck:strict": "tsc --noEmit -p tsconfig.check.json && tsc --noEmit -p tsconfig.check-node.json",
|
||||
"lint": "eslint . --ext .ts,.tsx",
|
||||
"format": "prettier --write \"src/**/*.{ts,tsx,css}\"",
|
||||
"test": "vitest run --config vitest.config.ts",
|
||||
|
|
|
|||
|
|
@ -1,19 +1,38 @@
|
|||
// src/main/services/UpdateService.ts
|
||||
// electron-updater 기반 자동 업데이트. 싱글톤 + EventEmitter.
|
||||
//
|
||||
// feed: public GitLab Generic Registry `d3ro-voice/latest`
|
||||
// feed: Forgejo Generic Registry `d3ro-voice/latest` (canonical)
|
||||
// 정책: release/update-policy.json (채널, 최소 지원 버전, 강제 업데이트, full/delta)
|
||||
// 기능:
|
||||
// 1. 사용자 인가 기반 다운로드 (autoDownload=false)
|
||||
// 2. 이번 버전 건너뛰기 (Skip This Version) 지원
|
||||
// 3. 차분 다운로드 (.blockmap) 및 실시간 프로그레스 스트리밍
|
||||
// 4. 프로세스 락 충돌 방지 및 안전한 재시작 (quitAndInstall)
|
||||
// 1. 채널(latest/beta/alpha) 선택 및 prerelease 게이팅
|
||||
// 2. 사용자 인가 기반 다운로드 (autoDownload=false), 강제 업데이트 예외
|
||||
// 3. 이번 버전 건너뛰기 (Skip This Version) — 비강제일 때만
|
||||
// 4. major/버전갭 시 차분(.blockmap) 대신 전체 설치자
|
||||
// 5. staged rollout + 원격 킬 스위치
|
||||
// 6. 프로세스 락 충돌 방지 및 안전한 재시작 (quitAndInstall)
|
||||
|
||||
import { EventEmitter } from 'events'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { app, dialog } from 'electron'
|
||||
import { getLogger } from './LoggerService'
|
||||
import { configGet, configSet } from './ConfigService'
|
||||
import { configGet, configGetAll, configSet } from './ConfigService'
|
||||
import { getMainWindow } from '../windows/WindowManager'
|
||||
import { UPDATE_FEED_URL } from '../update-feed'
|
||||
import {
|
||||
UPDATE_FEED_URL,
|
||||
UPDATE_POLICY_URL,
|
||||
isUpdateChannel,
|
||||
type UpdateChannel,
|
||||
} from '../update-feed'
|
||||
import {
|
||||
DEFAULT_UPDATE_POLICY,
|
||||
decideUpdate,
|
||||
isChannelAcceptable,
|
||||
isWithinRollout,
|
||||
majorOf,
|
||||
parseUpdatePolicy,
|
||||
type UpdateDecision,
|
||||
type UpdatePolicy,
|
||||
} from '../update-policy'
|
||||
|
||||
const logger = getLogger('UpdateService')
|
||||
|
||||
|
|
@ -21,6 +40,10 @@ const logger = getLogger('UpdateService')
|
|||
const INITIAL_CHECK_DELAY_MS = 15_000
|
||||
/** 주기 체크 간격 (4시간) */
|
||||
const CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000
|
||||
/** 원격 정책 fetch 타임아웃 */
|
||||
const POLICY_FETCH_TIMEOUT_MS = 8_000
|
||||
/** legacy 설치본이 저장한 skip 키 (AppConfig에 없는 과거 문자열 키) */
|
||||
const LEGACY_SKIPPED_VERSION_KEY = 'skipped_update_version'
|
||||
|
||||
export interface UpdateProgressPayload {
|
||||
percent: number
|
||||
|
|
@ -29,9 +52,24 @@ export interface UpdateProgressPayload {
|
|||
total: number
|
||||
}
|
||||
|
||||
export interface UpdateAvailablePayload {
|
||||
version: string
|
||||
currentVersion: string
|
||||
channel: UpdateChannel
|
||||
releaseNotes?: string
|
||||
/** 연기·건너뛰기 불가 */
|
||||
isMandatory: boolean
|
||||
/** major 승격 여부 */
|
||||
isMajorUpgrade: boolean
|
||||
/** 차분 대신 전체 설치자로 받는지 */
|
||||
isFullDownload: boolean
|
||||
/** 강제 사유 (진단/로그) */
|
||||
reason: UpdateDecision['reason']
|
||||
}
|
||||
|
||||
export interface UpdateServiceEvents {
|
||||
'checking-for-update': void
|
||||
'update-available': { version: string; releaseNotes?: string; isMandatory?: boolean }
|
||||
'update-available': UpdateAvailablePayload
|
||||
'update-not-available': { version: string }
|
||||
'download-progress': UpdateProgressPayload
|
||||
'update-downloaded': { version: string }
|
||||
|
|
@ -45,6 +83,9 @@ class UpdateService extends EventEmitter {
|
|||
private _promptShown = false
|
||||
private _autoUpdater: import('electron-updater').AppUpdater | null = null
|
||||
private _downloading = false
|
||||
private _policy: UpdatePolicy = DEFAULT_UPDATE_POLICY
|
||||
private _channel: UpdateChannel = 'latest'
|
||||
private _forceFullDownload = false
|
||||
|
||||
/** 자동 업데이트 시작. 비활성 조건이면 로그만 남기고 no-op. */
|
||||
initialize(): void {
|
||||
|
|
@ -79,6 +120,7 @@ class UpdateService extends EventEmitter {
|
|||
// 사용자 인가를 위해 자동 다운로드는 비활성화 (동의 시 downloadUpdate 호출)
|
||||
updater.autoDownload = false
|
||||
updater.autoInstallOnAppQuit = true
|
||||
this._applyChannel(this._resolveChannel(DEFAULT_UPDATE_POLICY))
|
||||
updater.logger = {
|
||||
info: (msg: unknown) => logger.info(String(msg)),
|
||||
warn: (msg: unknown) => logger.warn(String(msg)),
|
||||
|
|
@ -92,20 +134,8 @@ class UpdateService extends EventEmitter {
|
|||
})
|
||||
|
||||
updater.on('update-available', (info) => {
|
||||
logger.info(`업데이트 발견: v${info.version}`)
|
||||
|
||||
const skippedVersion = configGet('skipped_update_version') as string | undefined
|
||||
if (skippedVersion === info.version) {
|
||||
logger.info(`사용자가 건너뛴 버전 v${info.version} — 프롬프트 생략`)
|
||||
return
|
||||
}
|
||||
|
||||
this.emit('update-available', {
|
||||
version: info.version,
|
||||
releaseNotes: typeof info.releaseNotes === 'string' ? info.releaseNotes : undefined
|
||||
})
|
||||
|
||||
void this._promptUserConsent(info.version, info.releaseNotes)
|
||||
logger.info(`업데이트 발견: v${info.version} (채널 ${this._channel})`)
|
||||
void this._handleUpdateAvailable(info)
|
||||
})
|
||||
|
||||
updater.on('update-not-available', (info) => {
|
||||
|
|
@ -135,6 +165,8 @@ class UpdateService extends EventEmitter {
|
|||
this.emit('update-error', { message: err.message })
|
||||
})
|
||||
|
||||
// 원격 정책을 비동기로 내려받아 채널·강제 정책을 갱신한다.
|
||||
void this._loadPolicy()
|
||||
this._initialTimer = setTimeout(() => this.checkForUpdates(), INITIAL_CHECK_DELAY_MS)
|
||||
this._intervalTimer = setInterval(() => this.checkForUpdates(), CHECK_INTERVAL_MS)
|
||||
logger.info(`자동 업데이트 활성 — feed: ${UPDATE_FEED_URL}`)
|
||||
|
|
@ -143,6 +175,10 @@ class UpdateService extends EventEmitter {
|
|||
/** 수동 또는 주기적 업데이트 확인 */
|
||||
checkForUpdates(): void {
|
||||
if (!this._autoUpdater || this._downloading) return
|
||||
if (this._policy.killSwitch) {
|
||||
logger.info('원격 킬 스위치 활성 — 업데이트 확인 중단')
|
||||
return
|
||||
}
|
||||
this._autoUpdater.checkForUpdates().catch((err: unknown) => {
|
||||
logger.warn(`checkForUpdates 실패: ${err instanceof Error ? err.message : String(err)}`)
|
||||
})
|
||||
|
|
@ -152,16 +188,35 @@ class UpdateService extends EventEmitter {
|
|||
async startDownload(): Promise<void> {
|
||||
if (!this._autoUpdater || this._downloading) return
|
||||
this._downloading = true
|
||||
logger.info('차분 업데이트 다운로드 시작 (.blockmap)')
|
||||
logger.info(
|
||||
this._forceFullDownload
|
||||
? '전체 설치자 다운로드 시작 (major/버전갭)'
|
||||
: '차분 업데이트 다운로드 시작 (.blockmap)',
|
||||
)
|
||||
await this._autoUpdater.downloadUpdate()
|
||||
}
|
||||
|
||||
/** 이번 버전 건너뛰기 설정 */
|
||||
skipVersion(version: string): void {
|
||||
configSet('skipped_update_version', version)
|
||||
configSet('skippedUpdateVersion', version)
|
||||
logger.info(`버전 v${version} 건너뛰기 등록 완료`)
|
||||
}
|
||||
|
||||
/** 업데이트 채널을 전환한다. (설정 UI용) */
|
||||
setChannel(channel: UpdateChannel): void {
|
||||
configSet('updateChannel', channel)
|
||||
this._applyChannel(channel)
|
||||
logger.info(`업데이트 채널 전환: ${channel}`)
|
||||
}
|
||||
|
||||
getChannel(): UpdateChannel {
|
||||
return this._channel
|
||||
}
|
||||
|
||||
getPolicy(): UpdatePolicy {
|
||||
return this._policy
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this._initialTimer) clearTimeout(this._initialTimer)
|
||||
if (this._intervalTimer) clearInterval(this._intervalTimer)
|
||||
|
|
@ -169,18 +224,151 @@ class UpdateService extends EventEmitter {
|
|||
this._intervalTimer = null
|
||||
}
|
||||
|
||||
// ── 내부 ──
|
||||
|
||||
private _resolveChannel(policy: UpdatePolicy): UpdateChannel {
|
||||
const configured = configGet('updateChannel')
|
||||
return isUpdateChannel(configured) ? configured : policy.defaultChannel
|
||||
}
|
||||
|
||||
private _applyChannel(channel: UpdateChannel): void {
|
||||
this._channel = channel
|
||||
if (!this._autoUpdater) return
|
||||
const channelPolicy = this._policy.channels[channel] ?? { allowPrerelease: false }
|
||||
this._autoUpdater.channel = channel
|
||||
this._autoUpdater.allowPrerelease = channelPolicy.allowPrerelease
|
||||
}
|
||||
|
||||
private async _loadPolicy(): Promise<void> {
|
||||
if (!UPDATE_POLICY_URL) return
|
||||
try {
|
||||
const response = await fetch(UPDATE_POLICY_URL, {
|
||||
cache: 'no-store',
|
||||
signal: AbortSignal.timeout(POLICY_FETCH_TIMEOUT_MS),
|
||||
})
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||||
this._policy = parseUpdatePolicy(await response.json())
|
||||
this._applyChannel(this._resolveChannel(this._policy))
|
||||
logger.info(
|
||||
`원격 업데이트 정책 적용 — channel=${this._channel}, min=${this._policy.minimumSupportedVersion}, killSwitch=${this._policy.killSwitch}`,
|
||||
)
|
||||
} catch (err) {
|
||||
// 네트워크 실패 시 내장 기본 정책으로 fail-open (업데이트 자체는 계속).
|
||||
logger.debug(
|
||||
`원격 업데이트 정책 로드 실패 — 내장 기본값 사용: ${err instanceof Error ? err.message : String(err)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** electron-updater의 차분 다운로드 여부를 설정한다 (NSIS 이외 업데이터는 무시). */
|
||||
private _setDifferentialDisabled(disabled: boolean): void {
|
||||
if (!this._autoUpdater) return
|
||||
;(this._autoUpdater as unknown as { disableDifferentialDownload?: boolean }).disableDifferentialDownload =
|
||||
disabled
|
||||
}
|
||||
|
||||
private _deviceId(): string {
|
||||
const existing = configGet('updateDeviceId')
|
||||
if (typeof existing === 'string' && existing.length > 0) return existing
|
||||
const generated = randomUUID()
|
||||
configSet('updateDeviceId', generated)
|
||||
return generated
|
||||
}
|
||||
|
||||
private _skippedVersion(): string | null {
|
||||
const current = configGet('skippedUpdateVersion')
|
||||
if (typeof current === 'string' && current) return current
|
||||
const legacy = (configGetAll() as unknown as Record<string, unknown>)[LEGACY_SKIPPED_VERSION_KEY]
|
||||
return typeof legacy === 'string' && legacy ? legacy : null
|
||||
}
|
||||
|
||||
private async _handleUpdateAvailable(
|
||||
info: import('electron-updater').UpdateInfo,
|
||||
): Promise<void> {
|
||||
const currentVersion = app.getVersion()
|
||||
const decision = decideUpdate(this._policy, currentVersion, info.version)
|
||||
const channelPolicy = this._policy.channels[this._channel] ?? { allowPrerelease: false }
|
||||
|
||||
if (!isChannelAcceptable(channelPolicy.allowPrerelease, currentVersion, info.version)) {
|
||||
logger.info(`채널 ${this._channel} 정책상 v${info.version} 무시`)
|
||||
return
|
||||
}
|
||||
|
||||
if (!decision.mandatory && this._skippedVersion() === info.version) {
|
||||
logger.info(`사용자가 건너뛴 버전 v${info.version} — 프롬프트 생략`)
|
||||
return
|
||||
}
|
||||
|
||||
if (!isWithinRollout(this._policy, this._deviceId(), info.version, decision)) {
|
||||
logger.info(
|
||||
`staged rollout(${this._policy.stagingPercentage}%) 밖 — v${info.version} 이번엔 노출하지 않음`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// major 승격 또는 버전 갭이면 차분 패치를 시도하지 않는다.
|
||||
this._forceFullDownload = decision.forceFull
|
||||
this._setDifferentialDisabled(decision.forceFull)
|
||||
|
||||
const currentMajor = majorOf(currentVersion)
|
||||
const targetMajor = majorOf(info.version)
|
||||
const isMajorUpgrade =
|
||||
currentMajor !== null && targetMajor !== null && currentMajor !== targetMajor
|
||||
|
||||
this.emit('update-available', {
|
||||
version: info.version,
|
||||
currentVersion,
|
||||
channel: this._channel,
|
||||
releaseNotes: typeof info.releaseNotes === 'string' ? info.releaseNotes : undefined,
|
||||
isMandatory: decision.mandatory,
|
||||
isMajorUpgrade,
|
||||
isFullDownload: decision.forceFull,
|
||||
reason: decision.reason,
|
||||
})
|
||||
|
||||
await this._promptUserConsent(
|
||||
info.version,
|
||||
info.releaseNotes,
|
||||
decision,
|
||||
)
|
||||
}
|
||||
|
||||
/** 1단계: 신규 업데이트 발견 시 다운로드 인가 요청 다이얼로그 */
|
||||
private async _promptUserConsent(
|
||||
version: string,
|
||||
releaseNotes?: string | ReadonlyArray<{ version: string; note: string | null }> | null
|
||||
releaseNotes: string | ReadonlyArray<{ version: string; note: string | null }> | null | undefined,
|
||||
decision: UpdateDecision,
|
||||
): Promise<void> {
|
||||
if (this._promptShown || this._downloading) return
|
||||
if (this._downloading) return
|
||||
// forceInstallBelow는 다이얼로그 없이 즉시 설치 (보안 하한선).
|
||||
if (decision.mandatory && decision.reason === 'below-force-install') {
|
||||
logger.info(`v${version} 강제 설치 (${decision.reason})`)
|
||||
void this.startDownload()
|
||||
return
|
||||
}
|
||||
if (this._promptShown) return
|
||||
this._promptShown = true
|
||||
|
||||
const isKo = (configGet('language') as string | undefined)?.startsWith('ko') ?? true
|
||||
const notesText = typeof releaseNotes === 'string' ? `\n\n[주요 변경사항]\n${releaseNotes}` : ''
|
||||
const mandatoryNote = decision.mandatory
|
||||
? isKo
|
||||
? '\n\n이 업데이트는 필수입니다 (지원 종료 버전).'
|
||||
: '\n\nThis update is required (end of support).'
|
||||
: ''
|
||||
|
||||
const options = {
|
||||
const options = decision.mandatory
|
||||
? {
|
||||
type: 'info' as const,
|
||||
title: isKo ? '필수 업데이트' : 'Required Update',
|
||||
message: isKo
|
||||
? `D3RO Voice v${version} 업데이트가 필요합니다.${mandatoryNote}${notesText}`
|
||||
: `D3RO Voice v${version} is required.${mandatoryNote}${notesText}`,
|
||||
buttons: isKo ? ['지금 업데이트'] : ['Update Now'],
|
||||
defaultId: 0,
|
||||
cancelId: -1,
|
||||
}
|
||||
: {
|
||||
type: 'info' as const,
|
||||
title: isKo ? '새 버전 업데이트' : 'Software Update',
|
||||
message: isKo
|
||||
|
|
@ -201,7 +389,7 @@ class UpdateService extends EventEmitter {
|
|||
|
||||
if (response === 0) {
|
||||
void this.startDownload()
|
||||
} else if (response === 2) {
|
||||
} else if (!decision.mandatory && response === 2) {
|
||||
this.skipVersion(version)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,42 @@
|
|||
// src/main/update-feed.ts
|
||||
// 자동 업데이트 feed URL SSOT.
|
||||
//
|
||||
// release-create CI 잡이 GitLab Generic Package Registry의
|
||||
// `d3ro-voice/latest` 패키지에 latest.yml + 설치파일을 게시하며,
|
||||
// electron-updater가 이 URL에서 latest.yml을 읽어 업데이트를 감지한다.
|
||||
// canonical feed는 Forgejo Generic Package Registry의 `d3ro-voice/latest`다.
|
||||
// `publish-forgejo-release.mjs`가 latest.yml + 설치파일 + update-policy.json을
|
||||
// 게시하면 electron-updater가 이 URL에서 latest.yml을 읽어 업데이트를 감지한다.
|
||||
//
|
||||
// 전제: 프로젝트 설정에서 "Allow anyone to pull from Package Registry" 활성화
|
||||
// 전제: Forgejo에서 해당 owner/package의 무인증 pull이 허용돼야 한다
|
||||
// (비활성 시 무인증 다운로드가 401 — 앱은 조용히 업데이트 체크를 건너뜀).
|
||||
//
|
||||
// 빈 문자열이면 UpdateService가 비활성 상태로 동작한다.
|
||||
// d3r0/voice 프로젝트 ID = 1172. electron-builder.yml publish.url과 동일 값 유지.
|
||||
// 버전 없는 `latest` 패키지를 가리켜야 기존 설치본이 새 릴리스를 계속 찾을 수 있다.
|
||||
export const UPDATE_FEED_URL =
|
||||
'https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice/latest'
|
||||
|
||||
// GitLab Generic Registry legacy mirror. 2026-08 이전 설치본(0.2.1-alpha)은
|
||||
// 이 feed를 폴링하므로, 새 설치자가 Forgejo feed를 내장할 때까지 publisher가
|
||||
// 함께 게시한다. 마이그레이션 완료 후 제거 가능. 런타임은 참조하지 않는다.
|
||||
export const LEGACY_UPDATE_FEED_URL =
|
||||
'https://gitlab.twentyoz.kr:8443/api/v4/projects/1172/packages/generic/d3ro-voice/latest'
|
||||
|
||||
/** 업데이트 채널. `latest`=stable, `beta`, `alpha` 순으로 불안정. */
|
||||
export type UpdateChannel = 'latest' | 'beta' | 'alpha'
|
||||
|
||||
export const UPDATE_CHANNELS: readonly UpdateChannel[] = ['latest', 'beta', 'alpha']
|
||||
|
||||
/** 채널별 electron-builder update metadata 파일명. */
|
||||
export function channelMetadataName(channel: UpdateChannel): string {
|
||||
return channel === 'latest' ? 'latest.yml' : `${channel}.yml`
|
||||
}
|
||||
|
||||
/** 원격 업데이트 정책 파일명. feed 루트에서 내려받는다. */
|
||||
export const UPDATE_POLICY_FILENAME = 'update-policy.json'
|
||||
|
||||
/** 원격 업데이트 정책 URL (없으면 앱 내장 기본 정책으로 폴백). */
|
||||
export const UPDATE_POLICY_URL = UPDATE_FEED_URL
|
||||
? `${UPDATE_FEED_URL}/${UPDATE_POLICY_FILENAME}`
|
||||
: ''
|
||||
|
||||
export function isUpdateChannel(value: unknown): value is UpdateChannel {
|
||||
return typeof value === 'string' && (UPDATE_CHANNELS as readonly string[]).includes(value)
|
||||
}
|
||||
|
|
|
|||
261
apps/desktop/src/main/update-policy.ts
Normal file
261
apps/desktop/src/main/update-policy.ts
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
// src/main/update-policy.ts
|
||||
// 업데이트 정책 SSOT (런타임). release/update-policy.json과 동일 스키마.
|
||||
//
|
||||
// 정책은 빌드에 내장된 기본값(DEFAULT_UPDATE_POLICY)으로 시작하고,
|
||||
// feed의 `update-policy.json`을 내려받아 오버라이드할 수 있다. 서버가 강제
|
||||
// 업데이트·킬 스위치·staged rollout을 즉시 조정할 수 있게 하기 위함이다.
|
||||
//
|
||||
// 순수 로직만 두어 단위 테스트가 가능하도록 한다 (UpdateService는 I/O 담당).
|
||||
|
||||
import { UPDATE_CHANNELS, isUpdateChannel, type UpdateChannel } from './update-feed'
|
||||
|
||||
export interface UpdateChannelPolicy {
|
||||
allowPrerelease: boolean
|
||||
}
|
||||
|
||||
export interface UpdatePolicy {
|
||||
schemaVersion: number
|
||||
defaultChannel: UpdateChannel
|
||||
channels: Record<UpdateChannel, UpdateChannelPolicy>
|
||||
/** 이 버전 미만 클라이언트는 업데이트가 필수다 (연기·건너뛰기 불가). */
|
||||
minimumSupportedVersion: string
|
||||
/** 이 버전 미만은 다이얼로그 없이 강제 설치한다. null이면 비활성. */
|
||||
forceInstallBelow: string | null
|
||||
/** major가 달라지면 차분 대신 전체 설치자를 받는다. */
|
||||
fullInstallOnMajorChange: boolean
|
||||
/** minor 갭이 이 값 이상이면 전체 설치자를 받는다. */
|
||||
fullInstallVersionGap: number
|
||||
/** 이 비율(%)의 사용자에게만 stable 업데이트를 노출한다. 100=전체. */
|
||||
stagingPercentage: number
|
||||
/** true면 업데이트 확인 자체를 중단한다 (원격 킬 스위치). */
|
||||
killSwitch: boolean
|
||||
}
|
||||
|
||||
export const DEFAULT_UPDATE_POLICY: UpdatePolicy = {
|
||||
schemaVersion: 1,
|
||||
defaultChannel: 'latest',
|
||||
channels: {
|
||||
latest: { allowPrerelease: false },
|
||||
beta: { allowPrerelease: true },
|
||||
alpha: { allowPrerelease: true },
|
||||
},
|
||||
minimumSupportedVersion: '0.0.0',
|
||||
forceInstallBelow: null,
|
||||
fullInstallOnMajorChange: true,
|
||||
fullInstallVersionGap: 3,
|
||||
stagingPercentage: 100,
|
||||
killSwitch: false,
|
||||
}
|
||||
|
||||
export type UpdateDecisionReason =
|
||||
| 'below-expiry'
|
||||
| 'below-minimum-supported'
|
||||
| 'below-force-install'
|
||||
| 'standard'
|
||||
|
||||
export interface UpdateDecision {
|
||||
/** 연기·건너뛰기 불가 여부. */
|
||||
mandatory: boolean
|
||||
/** 차분 대신 전체 설치자를 받아야 하는지. */
|
||||
forceFull: boolean
|
||||
reason: UpdateDecisionReason
|
||||
}
|
||||
|
||||
interface ParsedVersion {
|
||||
major: number
|
||||
minor: number
|
||||
patch: number
|
||||
prerelease: readonly (string | number)[]
|
||||
}
|
||||
|
||||
/** semver의 major/minor/patch/prerelease를 파싱한다. 실패 시 null. */
|
||||
export function parseVersion(value: unknown): ParsedVersion | null {
|
||||
if (typeof value !== 'string') return null
|
||||
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(value.trim())
|
||||
if (!match) return null
|
||||
const prerelease = match[4]
|
||||
? match[4]
|
||||
.split('.')
|
||||
.map((part) => (/^\d+$/.test(part) ? Number(part) : part))
|
||||
: []
|
||||
return {
|
||||
major: Number(match[1]),
|
||||
minor: Number(match[2]),
|
||||
patch: Number(match[3]),
|
||||
prerelease,
|
||||
}
|
||||
}
|
||||
|
||||
/** semver 비교. a<b=-1, a==b=0, a>b=1. 파싱 실패 시 null. */
|
||||
export function compareVersions(a: unknown, b: unknown): -1 | 0 | 1 | null {
|
||||
const left = parseVersion(a)
|
||||
const right = parseVersion(b)
|
||||
if (!left || !right) return null
|
||||
for (const key of ['major', 'minor', 'patch'] as const) {
|
||||
if (left[key] !== right[key]) return left[key] < right[key] ? -1 : 1
|
||||
}
|
||||
return comparePrerelease(left.prerelease, right.prerelease)
|
||||
}
|
||||
|
||||
function comparePrerelease(
|
||||
left: readonly (string | number)[],
|
||||
right: readonly (string | number)[],
|
||||
): -1 | 0 | 1 {
|
||||
// prerelease가 없으면 stable이 더 높다 (semver 11항).
|
||||
if (left.length === 0 && right.length === 0) return 0
|
||||
if (left.length === 0) return 1
|
||||
if (right.length === 0) return -1
|
||||
const length = Math.max(left.length, right.length)
|
||||
for (let index = 0; index < length; index++) {
|
||||
const a = left[index]
|
||||
const b = right[index]
|
||||
if (a === undefined) return -1
|
||||
if (b === undefined) return 1
|
||||
if (a === b) continue
|
||||
if (typeof a === 'number' && typeof b === 'number') return a < b ? -1 : 1
|
||||
if (typeof a === 'number') return -1
|
||||
if (typeof b === 'number') return 1
|
||||
return a < b ? -1 : 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
/** major 버전 숫자. 파싱 실패 시 null. */
|
||||
export function majorOf(value: unknown): number | null {
|
||||
return parseVersion(value)?.major ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* 채널에 따라 업데이트를 적용할지 판단한다. 현재 버전이 prerelease이고
|
||||
* stable 채널이면 prerelease를 허용하지 않는 한 건너뛴다.
|
||||
*/
|
||||
export function isChannelAcceptable(
|
||||
allowPrerelease: boolean,
|
||||
currentVersion: string,
|
||||
targetVersion: string,
|
||||
): boolean {
|
||||
const current = parseVersion(currentVersion)
|
||||
const target = parseVersion(targetVersion)
|
||||
if (!current || !target) return false
|
||||
const currentIsPrerelease = current.prerelease.length > 0
|
||||
const targetIsPrerelease = target.prerelease.length > 0
|
||||
// stable 채널은 더 높은 prerelease로 올라가지 않는다.
|
||||
if (!allowPrerelease && targetIsPrerelease && !currentIsPrerelease) return false
|
||||
// prerelease가 아닌 현재 버전보다 낮은/같은 버전으로 내려가지 않는다.
|
||||
const ordering = compareVersions(currentVersion, targetVersion)
|
||||
if (ordering === null) return false
|
||||
if (ordering >= 0) {
|
||||
// 다운그레이드는 채널이 prerelease를 허용할 때만 (예: beta → stable 정렬).
|
||||
return allowPrerelease ? ordering >= 0 : false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** 현재 버전·대상 버전·정책으로 업데이트 결정을 계산한다. */
|
||||
export function decideUpdate(
|
||||
policy: UpdatePolicy,
|
||||
currentVersion: string,
|
||||
targetVersion: string,
|
||||
): UpdateDecision {
|
||||
const current = parseVersion(currentVersion)
|
||||
const target = parseVersion(targetVersion)
|
||||
if (!current || !target) {
|
||||
// 현재 버전을 신뢰할 수 없으면 차분 패치를 시도하지 않는다.
|
||||
return { mandatory: false, forceFull: true, reason: 'standard' }
|
||||
}
|
||||
|
||||
let mandatory = false
|
||||
let reason: UpdateDecisionReason = 'standard'
|
||||
|
||||
if (compareVersions(currentVersion, policy.minimumSupportedVersion) === -1) {
|
||||
mandatory = true
|
||||
reason = 'below-minimum-supported'
|
||||
}
|
||||
if (
|
||||
policy.forceInstallBelow !== null &&
|
||||
compareVersions(currentVersion, policy.forceInstallBelow) === -1
|
||||
) {
|
||||
mandatory = true
|
||||
reason = 'below-force-install'
|
||||
}
|
||||
|
||||
const majorChanged = current.major !== target.major
|
||||
const minorGap = target.minor - current.minor
|
||||
const forceFull = majorChanged
|
||||
? policy.fullInstallOnMajorChange
|
||||
: minorGap >= policy.fullInstallVersionGap
|
||||
|
||||
return { mandatory, forceFull, reason }
|
||||
}
|
||||
|
||||
/** 기기 ID와 버전으로 0..99 staged rollout 버킷을 계산한다 (영구적, 무작위적). */
|
||||
export function rolloutBucket(deviceId: string, version: string): number {
|
||||
let hash = 2166136261
|
||||
const input = `${deviceId}:${version}`
|
||||
for (let index = 0; index < input.length; index++) {
|
||||
hash ^= input.charCodeAt(index)
|
||||
hash = Math.imul(hash, 16777619)
|
||||
}
|
||||
return (hash >>> 0) % 100
|
||||
}
|
||||
|
||||
/** staged rollout에 포함되는지 판단한다. */
|
||||
export function isWithinRollout(
|
||||
policy: UpdatePolicy,
|
||||
deviceId: string,
|
||||
version: string,
|
||||
decision: UpdateDecision,
|
||||
): boolean {
|
||||
// 강제 업데이트는 staged rollout을 무시한다.
|
||||
if (decision.mandatory) return true
|
||||
const percentage = Math.min(100, Math.max(0, Math.floor(policy.stagingPercentage)))
|
||||
if (percentage >= 100) return true
|
||||
if (percentage <= 0) return false
|
||||
return rolloutBucket(deviceId, version) < percentage
|
||||
}
|
||||
|
||||
/** 알 수 없는 JSON을 검증·정규화해 정책으로 만든다. 실패 필드는 기본값 유지. */
|
||||
export function parseUpdatePolicy(raw: unknown): UpdatePolicy {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return DEFAULT_UPDATE_POLICY
|
||||
const record = raw as Record<string, unknown>
|
||||
const policy: UpdatePolicy = {
|
||||
...DEFAULT_UPDATE_POLICY,
|
||||
channels: { ...DEFAULT_UPDATE_POLICY.channels },
|
||||
}
|
||||
|
||||
if (record.schemaVersion === 1) policy.schemaVersion = 1
|
||||
if (isUpdateChannel(record.defaultChannel)) policy.defaultChannel = record.defaultChannel
|
||||
if (typeof record.minimumSupportedVersion === 'string' && parseVersion(record.minimumSupportedVersion)) {
|
||||
policy.minimumSupportedVersion = record.minimumSupportedVersion
|
||||
}
|
||||
if (typeof record.forceInstallBelow === 'string' && parseVersion(record.forceInstallBelow)) {
|
||||
policy.forceInstallBelow = record.forceInstallBelow
|
||||
} else if (record.forceInstallBelow === null) {
|
||||
policy.forceInstallBelow = null
|
||||
}
|
||||
if (typeof record.fullInstallOnMajorChange === 'boolean') {
|
||||
policy.fullInstallOnMajorChange = record.fullInstallOnMajorChange
|
||||
}
|
||||
if (Number.isSafeInteger(record.fullInstallVersionGap) && (record.fullInstallVersionGap as number) >= 0) {
|
||||
policy.fullInstallVersionGap = record.fullInstallVersionGap as number
|
||||
}
|
||||
if (Number.isSafeInteger(record.stagingPercentage)) {
|
||||
policy.stagingPercentage = Math.min(100, Math.max(0, record.stagingPercentage as number))
|
||||
}
|
||||
if (typeof record.killSwitch === 'boolean') policy.killSwitch = record.killSwitch
|
||||
|
||||
if (record.channels && typeof record.channels === 'object' && !Array.isArray(record.channels)) {
|
||||
const channels = record.channels as Record<string, unknown>
|
||||
for (const channel of UPDATE_CHANNELS) {
|
||||
const entry = channels[channel]
|
||||
if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
|
||||
const allowPrerelease = (entry as Record<string, unknown>).allowPrerelease
|
||||
if (typeof allowPrerelease === 'boolean') {
|
||||
policy.channels[channel] = { allowPrerelease }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return policy
|
||||
}
|
||||
163
apps/desktop/tests/main/update-policy.test.ts
Normal file
163
apps/desktop/tests/main/update-policy.test.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
// tests/main/update-policy.test.ts
|
||||
// 업데이트 정책 순수 로직 테스트: 버전 비교, 메이저/증분 판단, 강제 업데이트,
|
||||
// 채널 게이팅, staged rollout.
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
DEFAULT_UPDATE_POLICY,
|
||||
compareVersions,
|
||||
decideUpdate,
|
||||
isChannelAcceptable,
|
||||
isWithinRollout,
|
||||
parseUpdatePolicy,
|
||||
rolloutBucket,
|
||||
type UpdatePolicy,
|
||||
} from '../../src/main/update-policy'
|
||||
import { channelMetadataName, isUpdateChannel } from '../../src/main/update-feed'
|
||||
|
||||
const policy: UpdatePolicy = {
|
||||
...DEFAULT_UPDATE_POLICY,
|
||||
minimumSupportedVersion: '1.0.0',
|
||||
forceInstallBelow: '0.3.0',
|
||||
fullInstallOnMajorChange: true,
|
||||
fullInstallVersionGap: 3,
|
||||
stagingPercentage: 100,
|
||||
}
|
||||
|
||||
describe('compareVersions', () => {
|
||||
it('주/부/패치를 순서대로 비교한다', () => {
|
||||
expect(compareVersions('1.2.3', '1.2.3')).toBe(0)
|
||||
expect(compareVersions('1.2.3', '1.2.4')).toBe(-1)
|
||||
expect(compareVersions('1.3.0', '1.2.9')).toBe(1)
|
||||
expect(compareVersions('2.0.0', '1.99.99')).toBe(1)
|
||||
})
|
||||
|
||||
it('prerelease는 같은 코어의 stable보다 낮다', () => {
|
||||
expect(compareVersions('1.0.0-alpha', '1.0.0')).toBe(-1)
|
||||
expect(compareVersions('1.1.0-beta.2', '1.1.0-beta.10')).toBe(-1)
|
||||
})
|
||||
|
||||
it('v 접두와 build metadata를 허용한다', () => {
|
||||
expect(compareVersions('v1.1.0', '1.1.0+build.7')).toBe(0)
|
||||
})
|
||||
|
||||
it('파싱 실패 시 null', () => {
|
||||
expect(compareVersions('not-a-version', '1.0.0')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('decideUpdate', () => {
|
||||
it('최소 지원 버전 미만이면 강제 업데이트', () => {
|
||||
const decision = decideUpdate(policy, '0.5.0', '1.1.0')
|
||||
expect(decision.mandatory).toBe(true)
|
||||
expect(decision.reason).toBe('below-minimum-supported')
|
||||
})
|
||||
|
||||
it('forceInstallBelow 미만이면 강제 사유가 force-install (더 강한 조건이 우선)', () => {
|
||||
const decision = decideUpdate(policy, '0.2.1-alpha', '1.1.0')
|
||||
expect(decision.mandatory).toBe(true)
|
||||
expect(decision.reason).toBe('below-force-install')
|
||||
})
|
||||
|
||||
it('최소 지원 이상이면 일반 업데이트', () => {
|
||||
const decision = decideUpdate(policy, '1.1.0', '1.2.0')
|
||||
expect(decision.mandatory).toBe(false)
|
||||
expect(decision.reason).toBe('standard')
|
||||
expect(decision.forceFull).toBe(false)
|
||||
})
|
||||
|
||||
it('major 승격이면 full 다운로드', () => {
|
||||
const decision = decideUpdate(policy, '1.9.0', '2.0.0')
|
||||
expect(decision.forceFull).toBe(true)
|
||||
expect(decision.mandatory).toBe(false)
|
||||
})
|
||||
|
||||
it('minor 갭이 임계 이상이면 full 다운로드', () => {
|
||||
const decision = decideUpdate(policy, '1.0.0', '1.5.0')
|
||||
expect(decision.forceFull).toBe(true)
|
||||
})
|
||||
|
||||
it('major 정책이 꺼져 있으면 major 승격도 차분 가능', () => {
|
||||
const relaxed = { ...policy, fullInstallOnMajorChange: false }
|
||||
expect(decideUpdate(relaxed, '1.9.0', '2.0.0').forceFull).toBe(false)
|
||||
})
|
||||
|
||||
it('현재 버전을 파싱할 수 없으면 전체 설치', () => {
|
||||
expect(decideUpdate(policy, 'garbage', '1.2.0').forceFull).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isChannelAcceptable', () => {
|
||||
it('stable 채널은 prerelease를 받지 않는다', () => {
|
||||
expect(isChannelAcceptable(false, '1.0.0', '1.1.0-beta')).toBe(false)
|
||||
expect(isChannelAcceptable(false, '1.0.0', '1.1.0')).toBe(true)
|
||||
})
|
||||
|
||||
it('beta 채널은 prerelease를 받는다', () => {
|
||||
expect(isChannelAcceptable(true, '1.0.0', '1.1.0-beta')).toBe(true)
|
||||
})
|
||||
|
||||
it('더 높은 버전이 아니면 거부한다', () => {
|
||||
expect(isChannelAcceptable(false, '1.2.0', '1.1.0')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('staged rollout', () => {
|
||||
it('deviceId에 대해 결정적 버킷을 돌려준다', () => {
|
||||
expect(rolloutBucket('device-a', '1.1.0')).toBe(rolloutBucket('device-a', '1.1.0'))
|
||||
expect(rolloutBucket('device-a', '1.1.0')).toBeLessThan(100)
|
||||
})
|
||||
|
||||
it('100%는 모두 포함, 0%는 모두 제외', () => {
|
||||
const standard = { mandatory: false, forceFull: false, reason: 'standard' as const }
|
||||
expect(isWithinRollout({ ...policy, stagingPercentage: 100 }, 'd', '1.1.0', standard)).toBe(true)
|
||||
expect(isWithinRollout({ ...policy, stagingPercentage: 0 }, 'd', '1.1.0', standard)).toBe(false)
|
||||
})
|
||||
|
||||
it('강제 업데이트는 rollout을 무시한다', () => {
|
||||
const forced = { mandatory: true, forceFull: true, reason: 'below-force-install' as const }
|
||||
expect(isWithinRollout({ ...policy, stagingPercentage: 0 }, 'd', '1.1.0', forced)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseUpdatePolicy', () => {
|
||||
it('유효한 필드를 반영하고 잘못된 필드는 기본값 유지', () => {
|
||||
const parsed = parseUpdatePolicy({
|
||||
schemaVersion: 1,
|
||||
defaultChannel: 'beta',
|
||||
minimumSupportedVersion: '1.2.0',
|
||||
forceInstallBelow: null,
|
||||
fullInstallOnMajorChange: false,
|
||||
fullInstallVersionGap: 5,
|
||||
stagingPercentage: 250,
|
||||
killSwitch: true,
|
||||
channels: { latest: { allowPrerelease: false }, rogue: {} },
|
||||
})
|
||||
expect(parsed.defaultChannel).toBe('beta')
|
||||
expect(parsed.minimumSupportedVersion).toBe('1.2.0')
|
||||
expect(parsed.forceInstallBelow).toBeNull()
|
||||
expect(parsed.fullInstallOnMajorChange).toBe(false)
|
||||
expect(parsed.fullInstallVersionGap).toBe(5)
|
||||
expect(parsed.stagingPercentage).toBe(100)
|
||||
expect(parsed.killSwitch).toBe(true)
|
||||
expect(parsed.channels.beta.allowPrerelease).toBe(DEFAULT_UPDATE_POLICY.channels.beta.allowPrerelease)
|
||||
})
|
||||
|
||||
it('잘못된 입력은 기본 정책', () => {
|
||||
expect(parseUpdatePolicy(null)).toEqual(DEFAULT_UPDATE_POLICY)
|
||||
expect(parseUpdatePolicy('nope')).toEqual(DEFAULT_UPDATE_POLICY)
|
||||
})
|
||||
})
|
||||
|
||||
describe('update-feed helpers', () => {
|
||||
it('채널별 metadata 파일명', () => {
|
||||
expect(channelMetadataName('latest')).toBe('latest.yml')
|
||||
expect(channelMetadataName('beta')).toBe('beta.yml')
|
||||
expect(channelMetadataName('alpha')).toBe('alpha.yml')
|
||||
})
|
||||
|
||||
it('채널 식별', () => {
|
||||
expect(isUpdateChannel('latest')).toBe(true)
|
||||
expect(isUpdateChannel('canary')).toBe(false)
|
||||
})
|
||||
})
|
||||
34
package-lock.json
generated
34
package-lock.json
generated
|
|
@ -154,40 +154,6 @@
|
|||
"@rollup/rollup-win32-x64-msvc": "^4.60.1"
|
||||
}
|
||||
},
|
||||
"apps/mobile": {
|
||||
"name": "@d3ro/mobile",
|
||||
"version": "1.1.0",
|
||||
"extraneous": true,
|
||||
"dependencies": {
|
||||
"@d3ro/api-client": "file:../../packages/api-client",
|
||||
"@d3ro/core": "file:../../packages/core",
|
||||
"@d3ro/i18n": "file:../../packages/i18n",
|
||||
"@d3ro/ui-native": "file:../../packages/ui-native",
|
||||
"@react-native-async-storage/async-storage": "1.23.1",
|
||||
"@supabase/supabase-js": "^2.45.0",
|
||||
"expo": "~51.0.0",
|
||||
"expo-av": "~14.0.0",
|
||||
"expo-constants": "~16.0.0",
|
||||
"expo-device": "~6.0.0",
|
||||
"expo-linking": "~6.3.0",
|
||||
"expo-notifications": "~0.28.0",
|
||||
"expo-router": "~3.5.0",
|
||||
"expo-secure-store": "~13.0.0",
|
||||
"expo-status-bar": "~1.12.0",
|
||||
"expo-web-browser": "~13.0.0",
|
||||
"react": "18.2.0",
|
||||
"react-native": "0.74.0",
|
||||
"react-native-gesture-handler": "~2.16.0",
|
||||
"react-native-reanimated": "~3.10.0",
|
||||
"react-native-safe-area-context": "4.10.0",
|
||||
"react-native-screens": "3.31.0",
|
||||
"react-native-url-polyfill": "^2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~18.2.0",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
},
|
||||
"apps/mobile-rn": {
|
||||
"name": "@d3ro/mobile-rn",
|
||||
"version": "1.1.0",
|
||||
|
|
|
|||
12
package.json
12
package.json
|
|
@ -22,8 +22,14 @@
|
|||
"version:sync": "node scripts/ci/sync-version.mjs --write",
|
||||
"release:metadata": "node scripts/ci/verify-release-metadata.mjs",
|
||||
"release:metadata:test": "node scripts/ci/verify-release-metadata.mjs --self-test",
|
||||
"release:forgejo": "node scripts/ci/publish-forgejo-release.mjs",
|
||||
"release:forgejo:local": "node --env-file-if-exists=.env scripts/ci/publish-forgejo-release.mjs",
|
||||
"release:forgejo:check": "node scripts/ci/publish-forgejo-release.mjs --check",
|
||||
"release:tag": "node scripts/ci/create-release-tag.mjs",
|
||||
"security:secrets": "node scripts/ci/check-no-hardcoded-secrets.mjs",
|
||||
"security:secrets:test": "node scripts/ci/check-no-hardcoded-secrets.mjs --self-test",
|
||||
"check:design": "node scripts/ci/check-design-tokens.mjs",
|
||||
"check:design:test": "node scripts/ci/check-design-tokens.mjs --self-test",
|
||||
"test:e2e:red": "node server/supabase/tests/content-report-red.e2e.mjs",
|
||||
"release:mobile:boundary": "node scripts/ci/verify-mobile-release-boundary.mjs",
|
||||
"release:mobile:boundary:test": "node scripts/ci/verify-mobile-release-boundary.mjs --self-test",
|
||||
|
|
@ -35,7 +41,11 @@
|
|||
"typecheck": "npm run typecheck --workspaces --if-present",
|
||||
"test": "npm run test --workspaces --if-present",
|
||||
"lint": "eslint apps/desktop apps/web apps/admin packages --ext .ts,.tsx --max-warnings=0",
|
||||
"format": "prettier --write \"**/*.{ts,tsx,css}\""
|
||||
"format": "prettier --write \"**/*.{ts,tsx,css}\"",
|
||||
"typecheck:mobile": "npm --prefix apps/mobile-rn run typecheck",
|
||||
"lint:mobile": "npm --prefix apps/mobile-rn run lint",
|
||||
"test:mobile": "npm --prefix apps/mobile-rn test",
|
||||
"verify:all": "npm run typecheck && npm run typecheck:mobile && npm run lint && npm run lint:mobile && npm run test && npm run test:mobile && npm run check:design"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
||||
|
|
|
|||
15
release/update-policy.json
Normal file
15
release/update-policy.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"schemaVersion": 1,
|
||||
"defaultChannel": "latest",
|
||||
"channels": {
|
||||
"latest": { "allowPrerelease": false },
|
||||
"beta": { "allowPrerelease": true },
|
||||
"alpha": { "allowPrerelease": true }
|
||||
},
|
||||
"minimumSupportedVersion": "1.0.0",
|
||||
"forceInstallBelow": "0.3.0",
|
||||
"fullInstallOnMajorChange": true,
|
||||
"fullInstallVersionGap": 3,
|
||||
"stagingPercentage": 100,
|
||||
"killSwitch": false
|
||||
}
|
||||
208
scripts/ci/check-design-tokens.mjs
Normal file
208
scripts/ci/check-design-tokens.mjs
Normal 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)
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
77
scripts/ci/create-release-tag.mjs
Normal file
77
scripts/ci/create-release-tag.mjs
Normal 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)
|
||||
}
|
||||
359
scripts/ci/publish-forgejo-release.mjs
Normal file
359
scripts/ci/publish-forgejo-release.mjs
Normal 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 ?? {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
|
|
|
|||
30
scripts/lib/credentials.cjs
Normal file
30
scripts/lib/credentials.cjs
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
'use strict'
|
||||
|
||||
function requireEnvironment(name) {
|
||||
const value = process.env[name]?.trim()
|
||||
if (!value) {
|
||||
throw new Error(`${name} is required`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function forgejoLogin() {
|
||||
return {
|
||||
username: requireEnvironment('FORGEJO_USERNAME'),
|
||||
password: requireEnvironment('FORGEJO_PASSWORD'),
|
||||
}
|
||||
}
|
||||
|
||||
function forgejoAuthorization() {
|
||||
const token = process.env.FORGEJO_TOKEN?.trim()
|
||||
if (token) return `token ${token}`
|
||||
|
||||
const { username, password } = forgejoLogin()
|
||||
return `Basic ${Buffer.from(`${username}:${password}`, 'utf8').toString('base64')}`
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
forgejoAuthorization,
|
||||
forgejoLogin,
|
||||
requireEnvironment,
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue