diff --git a/.forgejo/workflows/portable.yml b/.forgejo/workflows/portable.yml new file mode 100644 index 0000000..4472b63 --- /dev/null +++ b/.forgejo/workflows/portable.yml @@ -0,0 +1,69 @@ +name: portable-unsigned + +# 서명 없는 휴대용 배포 채널. +# +# 배경: NSIS/MSIX 설치본은 public-trust Authenticode 서명이 필수라 인증서가 없는 동안 +# 게시할 수 없다(실측: 릴리스 파이프라인 2회 모두 서명 가드에서 실패). 이 워크플로는 +# 인증서 없이 동작하는 7z 분할 볼륨 + Scoop 채널을 게시한다. +# +# 안전 규칙: +# - 자동 업데이트 피드(latest.yml / update-policy.json)를 절대 건드리지 않는다. +# - 파일명에 -portable 을 두어 서명된 릴리스 자산과 혼동되지 않게 한다. +# - Cloudflare 업로드 한도(100MiB)를 넘지 않게 95MiB 볼륨으로 나누어 게시한다. +# +# 필요한 시크릿: FORGEJO_TOKEN (write:package) + +on: + push: + tags: + - "v*.*.*" + workflow_dispatch: + +jobs: + portable-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: STT 사이드카 빌드 + run: | + npm run sidecar:setup --workspace=@d3ro/desktop + npm run sidecar:build --workspace=@d3ro/desktop + node scripts/ci/verify-sidecar-bundle.mjs + + - name: 데스크톱 번들 빌드 + run: npm run build --workspace=@d3ro/desktop + + - name: 휴대용 ZIP + Scoop 매니페스트 생성 + run: node scripts/ci/build-portable.mjs + + - name: Forgejo portable 채널 게시 + env: + FORGEJO_TOKEN: "${{ secrets.FORGEJO_TOKEN }}" + run: node scripts/ci/publish-portable-release.mjs + + - name: 아티팩트 업로드 + uses: actions/upload-artifact@v4 + with: + name: d3ro-voice-portable-${{ github.ref_name }} + path: | + apps/desktop/release/*/*-portable.7z.00* + apps/desktop/release/*/portable.json + bucket/d3ro-voice.json \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index afe66c4..e7f86eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Cloud-optional backup (encrypted, opt-in) - Plugin system for custom pipelines +## [1.3.1] - 2026-09-18 + +### Fixed +- **Manual install no longer needs 7-Zip**: the signing-free channel now also publishes the + app as byte-split `zip` parts. The install script joins them and extracts with the built-in + Windows `Expand-Archive`, so a user with nothing but Windows can install (the 7z volumes + remain the smaller Scoop path). Split volumes from a different build are never mixed: + every artifact of a release comes from one build, and a published version is not overwritten. + ## [1.3.0] - 2026-09-18 > Published from an annotated tag through CI. Installer and update metadata are @@ -33,6 +42,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `sidecar:build`, plus a packaging-time bundle verifier that fails the build when the engine or its VAD data is missing. +### Added +- **Signing-free install path (`portable` channel)**: the canonical feed sits behind + Cloudflare, which rejects any upload body over ~100 MiB with HTTP 413 (measured: + 60 MiB accepted, 110 MiB rejected). The sidecar-carrying app exceeds that, so releases + are now also published as 95 MiB 7z split volumes (688 MB app → 162 MiB) with a Scoop + bucket manifest and a verifiable manual installer script. This channel never touches the + auto-update feed and needs no Authenticode certificate, so users can install while the + signing certificate is still being procured. + ### Changed - Local engine connections now target the IPv4 loopback (`127.0.0.1`) instead of `localhost`. On machines where `localhost` resolves only to IPv6, every local request diff --git a/apps/admin-swagger/openapi.json b/apps/admin-swagger/openapi.json index 1032f14..ef5d178 100644 --- a/apps/admin-swagger/openapi.json +++ b/apps/admin-swagger/openapi.json @@ -3,7 +3,7 @@ "info": { "title": "D3RO-VOICE Admin API", "description": "Admin CRM Edge Functions for user management, subscription CRUD, payment history, and audit logs.", - "version": "1.3.0" + "version": "1.3.1" }, "servers": [ { diff --git a/apps/admin/package.json b/apps/admin/package.json index 17c7840..78c4008 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -1,6 +1,6 @@ { "name": "@d3ro/admin", - "version": "1.3.0", + "version": "1.3.1", "private": true, "description": "D3RO Voice Admin CRM — SaaS 관리 도구", "scripts": { diff --git a/apps/api-server/D3ROVoice.Api.csproj b/apps/api-server/D3ROVoice.Api.csproj index 9b8fda2..5cb08ba 100644 --- a/apps/api-server/D3ROVoice.Api.csproj +++ b/apps/api-server/D3ROVoice.Api.csproj @@ -2,7 +2,7 @@ net10.0 - 1.3.0 + 1.3.1 enable enable diff --git a/apps/desktop/package.json b/apps/desktop/package.json index b7f828c..26006f5 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@d3ro/desktop", - "version": "1.3.0", + "version": "1.3.1", "productName": "d3ro-voice", "description": "로컬 AI 음성 어시스턴트 (Electron)", "main": "./out/main/index.js", diff --git a/apps/mobile-rn/android/app/build.gradle b/apps/mobile-rn/android/app/build.gradle index c99b448..43fde46 100644 --- a/apps/mobile-rn/android/app/build.gradle +++ b/apps/mobile-rn/android/app/build.gradle @@ -151,8 +151,8 @@ def versionSettingsValid = configuredVersionName != null && configuredVersionName ==~ strictSemver && configuredVersionCodeValue != null && configuredVersionCodeValue <= 2100000000L -def resolvedVersionName = versionSettingsValid ? configuredVersionName : "1.3.0" -def resolvedVersionCode = versionSettingsValid ? configuredVersionCodeValue.toInteger() : 1030001 +def resolvedVersionName = versionSettingsValid ? configuredVersionName : "1.3.1" +def resolvedVersionCode = versionSettingsValid ? configuredVersionCodeValue.toInteger() : 1031001 def requiredReleaseSettings = [ D3RO_RELEASE_STORE_FILE: releaseStoreFilePath, diff --git a/apps/mobile-rn/ios/D3ROVoice.xcodeproj/project.pbxproj b/apps/mobile-rn/ios/D3ROVoice.xcodeproj/project.pbxproj index 81d4618..fb6b4d2 100644 --- a/apps/mobile-rn/ios/D3ROVoice.xcodeproj/project.pbxproj +++ b/apps/mobile-rn/ios/D3ROVoice.xcodeproj/project.pbxproj @@ -257,7 +257,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 1030001; + CURRENT_PROJECT_VERSION = 1031001; ENABLE_BITCODE = NO; INFOPLIST_FILE = D3ROVoice/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.1; @@ -265,7 +265,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.3.0; + MARKETING_VERSION = 1.3.1; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -287,14 +287,14 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 1030001; + CURRENT_PROJECT_VERSION = 1031001; INFOPLIST_FILE = D3ROVoice/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.1; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.3.0; + MARKETING_VERSION = 1.3.1; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", diff --git a/apps/mobile-rn/metadata/android/en-US/changelogs/1031001.txt b/apps/mobile-rn/metadata/android/en-US/changelogs/1031001.txt new file mode 100644 index 0000000..16d8ac2 --- /dev/null +++ b/apps/mobile-rn/metadata/android/en-US/changelogs/1031001.txt @@ -0,0 +1 @@ +Added a signing-free install path: the download is served in verifiable parts and installed after checking them. diff --git a/apps/mobile-rn/metadata/android/ko-KR/changelogs/1031001.txt b/apps/mobile-rn/metadata/android/ko-KR/changelogs/1031001.txt new file mode 100644 index 0000000..df7a195 --- /dev/null +++ b/apps/mobile-rn/metadata/android/ko-KR/changelogs/1031001.txt @@ -0,0 +1 @@ +서명 인증서 없이도 설치할 수 있는 배포 경로를 추가했습니다. 설치 파일이 나뉘어 제공되고 검증 후에 설치됩니다. diff --git a/apps/mobile-rn/package-lock.json b/apps/mobile-rn/package-lock.json index dc24c46..713eaad 100644 --- a/apps/mobile-rn/package-lock.json +++ b/apps/mobile-rn/package-lock.json @@ -1,12 +1,12 @@ { "name": "@d3ro/mobile-rn", - "version": "1.3.0", + "version": "1.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@d3ro/mobile-rn", - "version": "1.3.0", + "version": "1.3.1", "dependencies": { "@d3ro/api-client": "file:../../packages/api-client", "@d3ro/core": "file:../../packages/core", @@ -62,7 +62,7 @@ }, "../..": { "name": "d3ro-voice-monorepo", - "version": "1.3.0", + "version": "1.3.1", "license": "MIT", "workspaces": [ "apps/desktop", @@ -81,7 +81,7 @@ }, "../../packages/api-client": { "name": "@d3ro/api-client", - "version": "1.3.0", + "version": "1.3.1", "license": "MIT", "dependencies": { "@d3ro/core": "*", @@ -98,7 +98,7 @@ }, "../../packages/core": { "name": "@d3ro/core", - "version": "1.3.0", + "version": "1.3.1", "license": "MIT", "dependencies": { "docx": "^9.6.1" @@ -109,7 +109,7 @@ }, "../../packages/i18n": { "name": "@d3ro/i18n", - "version": "1.3.0", + "version": "1.3.1", "license": "MIT", "devDependencies": { "@types/react": "^19.0.0" @@ -120,7 +120,7 @@ }, "../../packages/ui-native": { "name": "@d3ro/ui-native", - "version": "1.3.0", + "version": "1.3.1", "license": "MIT", "devDependencies": { "@types/react": "*" diff --git a/apps/mobile-rn/package.json b/apps/mobile-rn/package.json index 4d33564..dc24dfb 100644 --- a/apps/mobile-rn/package.json +++ b/apps/mobile-rn/package.json @@ -1,6 +1,6 @@ { "name": "@d3ro/mobile-rn", - "version": "1.3.0", + "version": "1.3.1", "private": true, "scripts": { "android": "react-native run-android", diff --git a/apps/web/package.json b/apps/web/package.json index 2aef4ab..85e5343 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@d3ro/web", - "version": "1.3.0", + "version": "1.3.1", "private": true, "description": "D3RO Voice 웹 앱 — Next.js 기반 SaaS 인터페이스", "scripts": { diff --git a/apps/web/src/components/layout/sidebar.tsx b/apps/web/src/components/layout/sidebar.tsx index ef28ded..255994d 100644 --- a/apps/web/src/components/layout/sidebar.tsx +++ b/apps/web/src/components/layout/sidebar.tsx @@ -139,7 +139,7 @@ export function Sidebar(): React.ReactElement { - v1.3.0 + v1.3.1 diff --git a/bucket/README.md b/bucket/README.md new file mode 100644 index 0000000..76fed4e --- /dev/null +++ b/bucket/README.md @@ -0,0 +1,18 @@ +# Scoop bucket — D3RO Voice + +서명 인증서 없이도 설치할 수 있는 배포 경로입니다. Scoop은 파일을 직접 내려받아 +MOTW(Mark-of-the-Web)를 남기지 않으므로 SmartScreen 경고가 뜨지 않고, 관리자 권한도 +필요하지 않습니다. + +```powershell +scoop bucket add d3ro https://git.chanpaca.net/yunchan/d3ro-voice.git +scoop install d3ro/d3ro-voice +``` + +매니페스트(`bucket/d3ro-voice.json`)는 `scripts/ci/build-portable.mjs`가 버전/URL/해시를 +자동으로 채워 생성합니다. 손으로 수정하지 말고 그 스크립트를 다시 실행하세요. + +- 이 채널은 **서명되지 않은** 휴대용 ZIP을 배포합니다(파일명에 `-portable-unsigned`). +- 자동 업데이트 피드(`latest.yml`)와는 분리되어 있습니다. 서명된 NSIS/MSIX 설치본은 + 인증서가 준비되면 기존 릴리스 파이프라인으로 게시합니다. +- `scoop update d3ro-voice`로 새 버전을 받을 수 있습니다. \ No newline at end of file diff --git a/bucket/d3ro-voice.json b/bucket/d3ro-voice.json new file mode 100644 index 0000000..f09fb46 --- /dev/null +++ b/bucket/d3ro-voice.json @@ -0,0 +1,38 @@ +{ + "version": "1.3.1", + "description": "로컬 AI 음성 어시스턴트 (faster-whisper + Ollama, 100% 오프라인 지원)", + "homepage": "https://d3ro.chanpaca.net", + "license": "MIT", + "architecture": { + "64bit": { + "url": [ + "https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice/portable-1.3.1/D3RO-Voice-1.3.1-x64-portable.7z.001", + "https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice/portable-1.3.1/D3RO-Voice-1.3.1-x64-portable.7z.002" + ], + "hash": [ + "917f9d6e078517b6c924c30d26b59cf530d8c69e4adbdf679d2196bc645fa993", + "06f32ca809fed42cda9900e04a8e50da42506ed71944462314ecef40773a3197" + ] + } + }, + "shortcuts": [ + [ + "D3RO Voice.exe", + "D3RO Voice" + ] + ], + "checkver": { + "url": "https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice/portable-latest/portable.json", + "jsonpath": "$.version" + }, + "autoupdate": { + "architecture": { + "64bit": { + "url": [ + "https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice/portable-$version/D3RO-Voice-1.3.1-x64-portable.7z.001", + "https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice/portable-$version/D3RO-Voice-1.3.1-x64-portable.7z.002" + ] + } + } + } +} diff --git a/docs/deployment/release-guide.md b/docs/deployment/release-guide.md index 073fa40..685ffd0 100644 --- a/docs/deployment/release-guide.md +++ b/docs/deployment/release-guide.md @@ -1,41 +1,41 @@ # D3RO Voice 릴리스 가이드 -기준일: 2026-09-16. 이 문서는 desktop GitLab 패키지·자동 업데이트와 mobile store release의 경계를 분리한다. 태그 생성이나 HTTP 200 하나만으로 배포 완료를 선언하지 않는다. +기준일: 2026-09-18. 이 문서는 desktop GitLab 패키지·자동 업데이트와 mobile store release의 경계를 분리한다. 태그 생성이나 HTTP 200 하나만으로 배포 완료를 선언하지 않는다. ## 현재 release identity | 항목 | 정본 | 현재 판정 | |---|---|---| -| 제품 버전 | `release/product-version.json`: `1.2.0` | source SSOT 확정 | -| Android | versionCode `1020001` | production AAB 미생성 | -| iOS | build `1020001` | production archive 미검증 | +| 제품 버전 | `release/product-version.json`: `1.3.0` | source SSOT 확정 | +| Android | versionCode `1030001` | production AAB 미생성 | +| iOS | build `1030001` | production archive 미검증 | | Android upload key | alias `d3ro-upload-20260821`, cert SHA-256 `4F:AC:69:24:...:15:2B:54` | external PKCS12·user-only ACL·Credential Manager·private-key readback GREEN; CI secret·복구 백업·AAB signer 대조 대기 | | release evidence | Ed25519 public `release/mobile-release-evidence-public.pem`, keyId `2797d3e6...4a890b7f` | external private key ACL·roundtrip GREEN; CI private-key secret·복구 백업 대기 | | desktop offline license | Ed25519 public `apps/desktop/resources/license/production-public.pem`, keyId `5c52b765...81a887f` | 새 전용 keypair·external private ACL·roundtrip·desktop production build GREEN; admin `ADMIN_LICENSE_PRIVATE_KEY` secret 주입 대기 | -| Windows Authenticode | external public-trust code-signing certificate | 현재 local installer·unpacked app은 `NotSigned`; production PFX·CI secret·signed artifact GREEN 전까지 게시 금지 | +| Windows Authenticode | external public-trust code-signing certificate | production PFX 없음. Forgejo 저장소 시크릿 4종(`WIN_CSC_LINK`/`WIN_CSC_KEY_PASSWORD`/`WIN_CSC_EXPECTED_SIGNER_SUBJECT`/`FORGEJO_TOKEN`)이 0건이라 릴리스 파이프라인이 fail-closed. `scripts/ci/set-forgejo-secrets.mjs --check`로 확인한다. GREEN 전까지 게시 금지 | | Firebase | Console `u/0`, `u/1` 모두 D3RO project 없음 | 사용자 승인 후 project·Android app 생성 필요 | | AdMob | app `ca-app-pub-1039714767792854~6427959892`; banner `/9840591290`; rewarded `/2255790918` | SSOT 확정. `검토 필요`·`광고 게재 제한`·store 미연결·결제 프로필 미완료 | | updater feed (canonical) | `https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice/latest` | Forgejo Generic Registry. GitLab project 1172은 legacy mirror | -| release notes | `CHANGELOG.md` `## [1.2.0]` | 태그 전 확정·검증 필수 | -| 직전 게시본 | Forgejo Release `v1.1.0` (2026-09-15 게시, unsigned installer 포함) | 불변 태그. `1.2.0`은 이를 대체하는 forward-fix | +| release notes | `CHANGELOG.md` `## [1.3.0]` + Play changelog `1030001.txt` (ko/en) | 태그 전 확정·검증 필수 | +| 직전 게시본 | Forgejo Release `v1.1.0` (2026-09-15 게시, unsigned installer) | `v1.2.0`·`v1.3.0` 파이프라인은 Forgejo 저장소 시크릿이 없어 실패 = 게시본 없음. 설치본이 있는 마지막 버전은 `1.1.0` | -live canonical feed(`git.chanpaca.net/.../d3ro-voice/latest`)의 `latest.yml`은 현재 `1.1.0`을 보고한다. `1.2.0` 태그 파이프라인이 GREEN이 되면 그 값이 올라간다. +live canonical feed(`git.chanpaca.net/.../d3ro-voice/latest`)의 `latest.yml`은 현재 `1.1.0`을 보고한다. 시크릿을 채우고 `v1.3.0` 파이프라인이 GREEN이 되면 그 값이 `1.3.0`으로 올라가고, 그때부터 기존 설치본이 자동 업데이트를 받는다. ## desktop 릴리스 파이프라인 ```text authoritative release commit → version/check/test/build GREEN - → annotated tag v1.2.0 + → annotated tag v1.3.0 → package-windows (build-win-x64) → package-macos (build-mac-arm64) → publish-release (build-linux-x64) ├─ publish-forgejo-release.mjs ← canonical - │ ├─ Forgejo Generic Registry /d3ro-voice/1.2.0/ (버전별 보존) + │ ├─ Forgejo Generic Registry /d3ro-voice/1.3.0/ (버전별 보존) │ ├─ Forgejo Generic Registry /d3ro-voice/latest/ (updater feed + update-policy.json) │ └─ Forgejo Release + CHANGELOG notes + 자산 첨부 └─ publish-gitlab-release.mjs ← legacy mirror (pre-Forgejo 설치본) - ├─ GitLab Generic Registry /d3ro-voice/1.2.0/ + ├─ GitLab Generic Registry /d3ro-voice/1.3.0/ ├─ GitLab Generic Registry /d3ro-voice/latest/ └─ GitLab Release ``` @@ -50,7 +50,7 @@ authoritative release commit 포함되지 않는다. 사이트·웹 다운로드 센터는 로컬 경로가 아니라 feed URL을 링크한다. (역사적 `1.0.0` 자산만 추적 상태로 남아 있다.) -- `scripts/ci/sync-version.mjs --check --tag v1.2.0`는 태그, `release/product-version.json`, package/lockfile, Android/iOS 버전 면의 일치를 fail-closed로 검증한다. +- `scripts/ci/sync-version.mjs --check --tag v1.3.0`는 태그, `release/product-version.json`, package/lockfile, Android/iOS 버전 면의 일치를 fail-closed로 검증한다. - `scripts/ci/verify-release-metadata.mjs`는 배포 메타데이터와 CI/publisher 계약을 검증한다. - 같은 gate는 desktop license public key가 Ed25519이고 `release/product-version.json`의 `desktopLicensePublicKeyId`와 일치하는지 검증한다. `electron.vite.config.ts`는 이 파일을 직접 읽으므로 누락·손상된 키로는 build가 시작되지 않는다. - `scripts/ci/publish-forgejo-release.mjs`는 canonical이다. 버전별 패키지를 먼저 올리고, `latest`에서 설치 자산 참조를 검증한 뒤 `latest.yml`과 `update-policy.json`을 마지막에 게시하고 공개 URL에서 재검증한다. `scripts/ci/publish-gitlab-release.mjs`는 legacy mirror로 동일 자산을 GitLab에도 올린다. @@ -110,7 +110,60 @@ https://gitlab.twentyoz.kr:8443/api/v4/projects/1172/packages/generic/d3ro-voice - **회수(rollback)**: `stagingPercentage`를 낮추거나 `killSwitch`를 켠다. 이미 배포된 버전은 되돌리지 않고 더 높은 patch로 forward-fix한다. -## `1.2.0` 릴리스 절차 +## `1.3.0` 릴리스 절차 (canonical = Forgejo Actions) + +**선행 조건 — Forgejo 저장소 시크릿.** `.forgejo/workflows/release.yml`은 아래 4개가 +없으면 fail-closed로 중단한다. 실측(2026-09-18): 저장소 시크릿이 0건이라 `v1.2.0`과 +`v1.3.0` 태그 파이프라인이 서명 가드에서 실패했고, 그래서 설치본·업데이트가 게시되지 +않았다. 현재 상태 점검과 등록은 다음 한 줄로 한다. + +```bash +node scripts/ci/set-forgejo-secrets.mjs --check +node --env-file-if-exists=.env scripts/ci/set-forgejo-secrets.mjs --write +``` + +| 시크릿 | 의미 | +|---|---| +| `WIN_CSC_LINK` | public-trust Authenticode PFX (base64). 개발용 `Everything2EverythingDev`는 거부된다 | +| `WIN_CSC_KEY_PASSWORD` | PFX password | +| `WIN_CSC_EXPECTED_SIGNER_SUBJECT` | 인증서의 정확한 subject. `verify-windows-release-artifact.ps1`이 이 identity를 요구한다 | +| `FORGEJO_TOKEN` | `write:package` + `write:repository` (릴리스 게시 단계) | + +1. `release/product-version.json`의 version/build 값과 모든 버전 면을 `npm run version:check`로 대조한다. +2. `CHANGELOG.md` `## [1.3.0] - 2026-09-18` 섹션을 사용자 변경점 중심으로 확정한다. publisher는 이 섹션과 그에 대응하는 Play changelog(`apps/mobile-rn/metadata/android/*/changelogs/.txt`)가 없으면 실패한다. +3. dirty/untracked 작업을 임의로 reset·clean하지 말고, release 범위만 검토 가능한 authoritative commit으로 보존한다. +4. 같은 commit에서 lint, typecheck, test, build, release metadata·security·artifact gate를 전부 GREEN으로 만든다. +5. **로컬 전사 엔진 검증**: 파이프라인은 `sidecar:setup` → `sidecar:build` → `verify-sidecar-bundle.mjs`를 패키징 전에 실행한다. 이 게이트가 없으면 설치본에 엔진이 빠진 채 게시된다(과거 실제 사고). 로컬에서 `electron-builder --dir`만 볼 때는 서명 실패로 extraResources 복사가 중간에 끊기므로 `-c.win.forceCodeSigning=false`로 확인한다. +6. desktop offline license를 제공한다면 external private key를 admin의 `ADMIN_LICENSE_PRIVATE_KEY` secret로 주입하고, 저장소 public key와 sign/verify roundtrip 및 발급 감사 로그를 확인한다. +7. 이미 게시된 버전보다 높은 annotated 태그 `v1.3.0`을 생성해 push한다. `npm run release:tag -- --dry-run`으로 검증한 뒤 `npm run release:tag`(GPG 사용 시 `-- --sign`)와 `git push chanpaca v1.3.0`를 실행한다. 태그는 불변이며 게이트를 시작하는 후속 단계지 검증을 대체하지 않는다. 이미 게시된 버전을 재게시하지 않는다: canonical publisher는 버전별 자산이 다른 바이트를 가지면 fail-closed로 중단한다. +8. 시크릿을 나중에 채웠다면 태그를 새로 만들 필요가 없다 — `release.yml`은 `workflow_dispatch`를 지원하므로 Forgejo UI에서 해당 태그 ref로 수동 실행한다. +9. Forgejo Actions run(`/actions/tasks` API 또는 UI)에서 단계별 결과를 확인한다. pending/stuck/skipped를 GREEN으로 기록하지 않는다. +10. Forgejo Release note/asset, `latest.yml`, `update-policy.json`, installer hash를 외부 public URL에서 다시 검증한다. +11. 이전 설치본에서 자동 업데이트 E2E를 실행하고 실행 중 버전·프로세스·사용자 데이터 보존, 그리고 **업데이트 후 로컬 받아쓰기 1회 성공**을 확인한다. + +## 서명 없이 내놓기 (portable 채널) + +인증서가 없어도 사용자가 설치할 수 있어야 할 때 사용한다. 자세한 조사·비교·제약은 +[`unsigned-distribution.md`](./unsigned-distribution.md)에 있다. + +```bash +npm run release:portable:build # 7z 분할 볼륨 + Scoop 매니페스트 생성 +npm run release:portable:check # 게시 예정 목록 확인(실제 업로드 없음) +npm run release:portable # Forgejo portable 채널 게시 +``` + +- 자동 업데이트 피드(`latest.yml`)는 **건드리지 않는다** — 서명된 릴리스 전용이다. +- 볼륨은 불변이다: 같은 버전 경로에 다른 바이트가 있으면 게시가 중단된다. +- 태그/수동 실행 워크플로: `.forgejo/workflows/portable.yml` (필요 시크릿: `FORGEJO_TOKEN`). +- 산출물은 7z 분할 볼륨(Scoop용, 162MiB)과 zip 분할 부품(수동 설치용, 243MiB) 두 가지다. + 수동 설치 스크립트는 Windows 내장 `Expand-Archive`만 쓰므로 7-Zip이 필요 없다. +- 사용자 설치: Scoop 버킷(`bucket/`) 또는 `install-d3ro-voice.ps1`. +- 한 버전의 산출물은 한 번의 빌드에서만 나온다(볼륨은 불변). CI 태그 파이프라인이 최초 게시자가 되게 하고, + 이미 게시된 버전을 같은 번호로 다시 게시하지 않는다 — 필요하면 버전을 올린다. +- 로컬에서 게시할 때는 `npm run release:portable:build`가 out/와 sidecar-dist/를 먼저 요구한다 + (`npm run build --workspace=@d3ro/desktop`, `npm run sidecar:build --workspace=@d3ro/desktop`). + +## `1.2.0` 릴리스 절차 (기록) 1. `release/product-version.json`의 version/build 값과 모든 버전 면을 `npm run version:check`로 대조한다. 2. `CHANGELOG.md` `## [1.2.0] - 2026-09-16` 섹션을 사용자 변경점 중심으로 확정한다. publisher는 이 섹션이 없으면 실패해야 한다. @@ -147,6 +200,9 @@ Desktop release를 게시해도 Android production 출시가 자동으로 완료 - `release/mobile-release-evidence-public.pem` — release evidence public key - `apps/desktop/resources/license/production-public.pem` — desktop offline license public key SSOT - `scripts/ci/sync-version.mjs` — 버전 면 동기화·검증 +- `scripts/ci/build-portable.mjs` — 서명 없는 portable 산출물(7z 분할 볼륨 + Scoop 매니페스트) +- `scripts/ci/publish-portable-release.mjs` — portable 채널 게시(불변 가드) +- `scripts/install/install-d3ro-voice.ps1` — 수동 설치 스크립트(해시 검증 + 결합 + 해제) - `scripts/ci/create-release-tag.mjs` — 릴리스 태그 게이트 (annotated/서명, 불변) - `scripts/ci/verify-release-metadata.mjs` — release metadata 자가 검증 - `scripts/ci/verify-windows-release-artifact.ps1` — Windows version·updater metadata·Authenticode gate diff --git a/docs/deployment/unsigned-distribution.md b/docs/deployment/unsigned-distribution.md new file mode 100644 index 0000000..6455451 --- /dev/null +++ b/docs/deployment/unsigned-distribution.md @@ -0,0 +1,122 @@ +# 서명 없이 배포하기 — D3RO Voice 배포 경로 정리 (2026-09 기준) + +> 왜 이 문서가 있나: MSIX/NSIS 설치본은 public-trust Authenticode 서명이 필수다. 인증서가 +> 없으면 릴리스 파이프라인이 fail-closed로 멈춘다(실측: `v1.2.0`·`v1.3.0` 태그 모두 서명 +> 가드에서 실패). 그동안 사용자가 설치할 수 있는 경로가 필요해 조사하고 구현한 결과를 남긴다. +> MSIX 계획을 버리는 문서가 **아니다** — 서명이 준비되면 기존 계획을 그대로 간다. + +--- + +## 1. 제약 (실측) + +| 제약 | 값 | 근거 | +|---|---|---| +| canonical feed 업로드 본문 한도 | **100MiB (104,857,600 bytes)** — 초과 시 HTTP 413 | Cloudflare 뒤에 있음. 실측: 60MiB → 201, 110MiB → 413 (274ms, CF 오류 페이지) | +| 1.1.0 설치본이 통과한 이유 | 102,172,129 bytes = **97.4MiB** (한도 미만) | 같은 feed의 `latest.yml` | +| 사이드카 포함 앱 크기 | unpacked 688MB → zip 243MiB / 7z 162MiB | `apps/desktop/release//win-unpacked` 실측 | +| 이 PC의 Smart App Control | **꺼짐** (`VerifiedAndReputablePolicyState = 0`) | SAC가 켜져 있으면 서명 없는 바이너리는 채널과 무관하게 실행 차단 | +| Forgejo generic registry | `HEAD` 미지원(405), `Range: bytes=0-0` 지원(206 + content-range) | 크기/불변 검증은 Range GET으로 한다 | + +**결론**: 서명 여부와 별개로, 100MiB를 넘는 산출물은 이 feed로 게시할 수 없다. 즉 +사이드카를 포함한 NSIS 설치본(189MB)은 **인증서가 있어도 지금 게시할 수 없다**. 이건 +서명과 무관한 별도 결함이며, 앱 크기를 줄이거나(엔진 분리 다운로드) 게시 경로를 바꾸는 +작업이 필요하다(§4 백로그). + +--- + +## 2. 서명 없이 설치되는 방법 비교 (2025-2026) + +| 방법 | SmartScreen | Smart App Control | 관리자 | 자동 업데이트 | +|---|---|---|---|---| +| 대용 ZIP | 최초 실행 경고(다운로드 MOTW 전파) | **차단** | 불필요 | 없음 | +| **Scoop** | 패키지 매니저가 직접 내려받아 MOTW 없음 → 경고 없음 | **차단** | 불필요(per-user) | `scoop update *` + manifest `autoupdate` | +| winget | 대개 경고 없음 | **차단** | 설치 방식에 따름 | `winget upgrade` (manifest 등록 필요) | +| 서명 없는 NSIS/Inno | "Windows protected your PC" → Run anyway | **차단** | 사실상 필요 | 없음 | +| MSIX self-signed 사이드로드 | 서명 없는 것과 같음 | **차단** | 필요(인증서를 TrustedPeople에) | `.appinstaller` (ms-appinstaller는 기본 비활성) | +| Velopack(per-user Setup.exe) | 서명 없으면 자주 경고 | **차단** | 불필요 | 내장 UpdateManager | + +핵심: **Smart App Control이 켜진 PC에서는 어떤 무서명 경로도 통하지 않는다.** SAC는 +클린 설치 기본값이 켜져 있고, 끄면 다시 켤 수 없다(재설치 필요). 그래서 공개 배포의 +정답은 여전히 "서명"이고, Scoop/휴대용은 SAC가 꺼진 환경(그리고 개발/테스트)에서 +쓸 수 있는 보완 경로다. + +--- + +## 3. 이 저장소가 택한 경로 + +### 3-1. 서명 없이 지금 배포되는 것 (portable 채널) + +- 산출물 두 가지(같은 버라도 목적이 다름): + - **7z 분할 볼륨** `D3RO-Voice--x64-portable.7z.001/.002` — LZMA2로 688MB → 162MiB. Scoop 전용. + - **zip 분할 부품** `...zip.001/.002/.003` — 243MiB. 수동 설치 스크립트용. Windows 내장 + `Expand-Archive`만으로 풀 수 있어 사용자가 7-Zip을 설치할 필요가 없다. +- 게시 위치: `.../generic/d3ro-voice/portable-/` 와 `.../portable-latest/` + (**자동 업데이트 피드 `latest.yml`과 완전히 분리**). +- 설치 방법 두 가지: + 1. **Scoop** — 저장소의 `bucket/` 디렉토리를 버킷으로 쓴다. Scoop은 `.7z.001` 볼륨을 + 이어서 해제하는 기능을 공식 지원한다(7-Zip 볼륨). + ```powershell + scoop bucket add d3ro https://git.chanpaca.net/yunchan/d3ro-voice.git + scoop install d3ro/d3ro-voice + ``` + 2. **수동 설치 스크립트** — `scripts/install/install-d3ro-voice.ps1` (피드에도 게시됨). + zip 부품을 내려받아 부품별 SHA-256 검증 → 결합 → 결합본 SHA-256 재검증 → + `Expand-Archive`로 해제 → `%LOCALAPPDATA%\Programs\D3RO Voice`에 설치 + 시작 메뉴 바로가기. + 관리자 권한 불필요, 추가 도구 불필요(Windows 10/11 기본). + ```powershell + irm https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice/portable-latest/install-d3ro-voice.ps1 | iex + ``` +- 자동화: `.forgejo/workflows/portable.yml` (태그 + 수동 실행). `WIN_CSC_*`가 필요 없다. +- 커맨드: `npm run release:portable:build` → `npm run release:portable:check` → `npm run release:portable` + +**안전 규칙(구현에 반영)**: +- 볼륨은 불변 — 같은 버전 경로에 다른 바이트가 있으면 게시 중단. +- 메타데이터(`portable.json`, 설치 스크립트)만 갱신 허용. +- 같은 버전을 다시 빌드하면 7z/zip 바이트가 달라져(내부 타임스탬프) 게시가 중단된다. + 즉 **한 버전의 portable 산출물은 한 번의 빌드에서만 나온다**. 태그 파이프라인(CI)이 최초 + 게시자가 되도록 하고, 로컬 재게시로 채널을 덮어쓰지 않는다. 이미 게시된 세트를 갱신해야 하면 + 버전을 올린다(이 문서 작성 시 `1.3.0` 부분 게시분을 `1.3.1`로 대체). +- 파일명에 `-portable`을 넣어 서명된 릴리스 자산과 혼동되지 않게 한다. +- `latest.yml`/`update-policy.json`은 절대 건드리지 않는다(자동 업데이트는 서명 릴리스 전용). + +### 3-2. 서명이 준비되면 (원래 계획 유지) + +- MSIX/NSIS는 그대로 간다. 필요한 것은 public-trust 인증서 하나다. +- 2026년 기준 가장 싼 현실적 선택: + - **Azure Artifact Signing(구 Trusted Signing)** — Basic **$9.99/월**, 하드웨어 토큰 불필요, + EXE/MSI/**MSIX** 서명 가능, SmartScreen/SAC 대응. 단 **개인은 미국/캐나다 거주자만**, + 조직은 한국 포함 특정 국가에서 가능(사업자 검증 필요). CI는 `signtool` + dlib 또는 + `azure/artifact-signing-action`(Windows 러너)로 연동. + - **SSL.com OV 코드 서명** — 약 $129/년(+클라우드 HSM/eSigner 별도), 사업자 필요. + - 참고: EV가 SmartScreen을 즉시 통과시키는 경로는 2024년에 폐지됐다. 평판은 누적된다. + +--- + +## 4. 남은 백로그 + +| ID | 항목 | 왜 | +|---|---|---| +| GAP-REL-04 | 사이드카 포함 설치본이 Cloudflare 100MiB 한도를 넘는다(NSIS 189MB) | 인증서가 있어도 게시 불가. 크기를 줄이거나 게시 경로를 바꿔야 한다 | +| GAP-STT-07 | 엔진(사이드카) 첫 실행 다운로드 방식으로 분리 | 앱 번들을 100MiB 이하로 만들고, 엔진은 분할 다운로드 + SHA-256 검증으로 받는다. 설치 경험과 업데이트 크기가 모두 좋아진다 | +| GAP-REL-05 | winget 매니페스트 등록 | winget-pkgs 커뮤니티 저장소 제출 필요(100MiB 한도와 무관한 별도 경로) | + +--- + +## 5. 현재 상태 (2026-09-18) + +- 게시된 채널: `portable-1.3.1` + `portable-latest` (7z 볼륨 2개, zip 부품 3개, 인덱스, 설치 스크립트). +- `portable-1.3.0`에는 7z 볼륨 2개만 있다(부분 게시, zip 부품 없음) — `1.3.1`이 대체한다. +- updater feed(`latest.yml`)는 여전히 `1.1.0`이며 **이 채널은 그것을 건드리지 않는다**. + +## 6. 검증 기록 (2026-09-18) + +- 7z 볼륨 게시 후 **무인증 공개 GET**으로 인덱스/볼륨/스크립트 제공 확인. +- 설치 스크립트 end-to-end 실행: 볼륨 2개 다운로드 → SHA-256 검증 → 결합(162.1MiB) → + 7-Zip 해제 → 설치 디렉토리에 `D3RO Voice.exe`, `resources/sidecar/sidecar.exe`, + `resources/sidecar/_internal/faster_whisper/assets/silero_vad_v6.onnx`, `resources/sox/sox.exe` + 존재 확인 → 시작 메뉴 바로가기 생성. (검증 후 테스트 설치/바로가기는 제거) +- 재게시 시도 시 동일 볼륨은 "이미 동일한 파일" 로 건너뛰는 것 확인(불변 가드 동작). +- 1.3.1 수동 설치 스크립트를 **7-Zip 없이** end-to-end 실행: zip 부품 3개 다운로드 → + 부품별 SHA-256 → 결합(242.9MiB) → 결합본 SHA-256 → Expand-Archive → 설치 디렉토리에 + `D3RO Voice.exe`, `resources/sidecar/sidecar.exe`, VAD `silero_vad_v6.onnx`, `resources/sox/sox.exe` + 확인 → 시작 메뉴 바로가기 생성. (검증 후 테스트 설치/바로가기 제거) \ No newline at end of file diff --git a/docs/map/02-infrastructure.md b/docs/map/02-infrastructure.md index 11803d4..6411555 100644 --- a/docs/map/02-infrastructure.md +++ b/docs/map/02-infrastructure.md @@ -139,6 +139,7 @@ See [`03-shared-packages.md`](./03-shared-packages.md). Summary: Stages `validate → test → build → e2e → package → publish → deploy`. Primary pipeline for desktop Windows/macOS releases (Forgejo Generic Registry is the canonical updater feed; GitLab project 1172 is a legacy mirror) and production mobile releases (`mobile-production-release`, manual/protected). Admin NAS deploy job is intentionally **disabled**. `package-windows`/`package-macos` build the faster-whisper sidecar (`sidecar:setup` → `sidecar:build`) and run `scripts/ci/verify-sidecar-bundle.mjs` before electron-builder, so a release can never ship without the local STT engine. ### Forgejo Actions (`.forgejo/workflows/`) +`portable.yml` — 태그/수동 실행으로 **서명 없이** portable 채널(95MiB 7z 분할 볼륨 + Scoop 매니페스트 + 설치 스크립트)을 게시한다. `WIN_CSC_*` 불필요, updater feed는 건드리지 않는다. `deploy-site.yml` / `deploy-site-windows.yml` — build `site`, write release identity, deploy to Cloudflare Pages `d3ro` (`d3ro.chanpaca.net`), verify live commit/version, app-links, legal URLs. `release.yml` — tag-triggered Windows build (signed) + `publish-forgejo-release.mjs` to the canonical Forgejo feed/release hub. diff --git a/docs/map/10-feature-catalog.md b/docs/map/10-feature-catalog.md index b2c35af..6ee02cb 100644 --- a/docs/map/10-feature-catalog.md +++ b/docs/map/10-feature-catalog.md @@ -193,6 +193,7 @@ Status quick-reference: `[x]` done+verified · `[~]` partial/unverified · `[ ]` | INFRA-13 | Site deploy (Cloudflare Pages + GitHub Pages) | [x] | `.forgejo/workflows/deploy-site.yml`, `.github/workflows/deploy-site.yml` | | INFRA-15 | Update & release system | [x] | Canonical Forgejo feed + channels/policy (`release/update-policy.json`, `src/main/update-policy.ts`), canonical publisher `scripts/ci/publish-forgejo-release.mjs`, legacy GitLab mirror; `npm run release:metadata:test`. v1.1.0 was published to Forgejo on 2026-09-15; product version moved to `1.2.0` as a forward-fix with CI-only publication, a same-version re-release guard, and download centers that link the feed instead of repository paths. `1.3.0` (2026-09-18) carries the local-STT fixes; Windows publication still needs the CI signing secrets (`11` GAP-REL-02). | | INFRA-16 | Desktop STT engine packaging | [x] | `apps/desktop/scripts/setup-sidecar.mjs` + `build-sidecar.mjs`, `electron-builder.yml` `extraResources` (`sidecar-dist/sidecar` → `resources/sidecar`, `resources/ffmpeg`), and `scripts/ci/verify-sidecar-bundle.mjs` run in `package-windows`/`package-macos` before electron-builder. Verified on the real bundle: `sidecar.exe` + `_internal` including `faster_whisper/assets/silero_vad_v6.onnx`, plus a packaged-engine transcription round-trip on GPU. | +| INFRA-17 | 서명 없는 배포 채널 (portable + Scoop) | [x] | `scripts/ci/build-portable.mjs` (95MiB 7z 분할 볼륨 + Scoop 매니페스트), `scripts/ci/publish-portable-release.mjs`, `scripts/local/install-d3ro-voice.ps1`, `bucket/` 버킷, `.forgejo/workflows/portable.yml`; 7z 분할 볼륨(Scoop, 162MiB) + zip 분할 부품(수동 설치, 243MiB, 7-Zip 불필요); updater feed와 분리. 2026-09-18 `portable-1.3.1` 게시 + 실제 설치 검증. | --- diff --git a/docs/map/11-gap-backlog.md b/docs/map/11-gap-backlog.md index 161eb6e..8d72fbe 100644 --- a/docs/map/11-gap-backlog.md +++ b/docs/map/11-gap-backlog.md @@ -22,7 +22,7 @@ Legend: `[ ]` open · `[~]` in progress · `[!]` blocked externally · `[x]` res |---|---|---|---|---| | GAP-QA-01 | Quality | Extreme Red Team: headful end-to-end bug hunting across real desktop Electron, Web Next.js, and CI pipelines. | `red_team_log.md`, `tests/e2e/red_team_cycle*.spec.ts`, `apps/web/e2e/red_team_cycle4_web.spec.ts` | `[x]` 2026-09-15: 18 scenarios executed, 14 defects caught and 100% resolved (infinite chunking loop DEF-008, IPC signature mismatch DEF-004, markdown editor typing rollback DEF-006, Web RSC Link serialization DEF-012, secret scanner lookahead DEF-013, etc.). All 18 scenarios GREEN with zero regressions. | | GAP-REL-01 | Release | Official release publication to Forgejo and active public download center deployment. | `scripts/ci/publish-forgejo-release.mjs`, `apps/web/src/app/download/page.tsx`, `site/src/sections/Download.tsx`, `apps/web/e2e/red_team_cycle4_web.spec.ts` | `[~]` 2026-09-15: v1.1.0 release assets (`D3RO-Voice-Setup-1.1.0-x64.exe`, `.blockmap`, `latest.yml`, `update-policy.json`) published to canonical Forgejo registry and release hub. 2026-09-16: the published 1.1.0 installer carries no Authenticode signature, so it does not satisfy the release policy; product version moved to `1.2.0` and publication must come from CI with the signing gate GREEN. Download centers in `apps/web` (`/download`) and `site` (`#download`) link the canonical Forgejo feed. | -| GAP-REL-02 | Release | Windows stable publication needs an external public-trust Authenticode PFX, its password, the exact signer subject, and a Forgejo token, none of which live in the repository. | `.forgejo/workflows/release.yml`, `.gitlab-ci.yml`, `scripts/ci/verify-windows-release-artifact.ps1` | `[!]` 2026-09-16: every publisher fails closed without `WIN_CSC_*` and `FORGEJO_TOKEN`; provide them as protected CI secrets, then re-run the tag pipeline. Still open as of `1.3.0` (2026-09-18): the `v1.3.0` tag must be built by CI with the signing gate GREEN. Local packaging cannot produce a signed installer (`forceCodeSigning: true`). | +| GAP-REL-02 | Release | Windows stable publication needs an external public-trust Authenticode PFX, its password, the exact signer subject, and a Forgejo token, none of which live in the repository. | `.forgejo/workflows/release.yml`, `.gitlab-ci.yml`, `scripts/ci/set-forgejo-secrets.mjs`, `scripts/ci/verify-windows-release-artifact.ps1` | `[!]` 2026-09-18 measured: the Forgejo repo had **zero** Actions secrets; `FORGEJO_TOKEN` is registered now (2026-09-18) but `WIN_CSC_*` still have no values, so `v1.2.0` (run 49) and `v1.3.0` (run 51) both failed at the signing guard and **no updater-feed release has been published since `1.1.0`**. Inject the four secrets (`WIN_CSC_LINK`, `WIN_CSC_KEY_PASSWORD`, `WIN_CSC_EXPECTED_SIGNER_SUBJECT`, `FORGEJO_TOKEN`) with `npm run release:secrets` (check: `npm run release:secrets:check`), then re-run `release.yml` for the `v1.3.0` tag via `workflow_dispatch` (tags are immutable). | | GAP-ADS-01 | Ads | 9 of 10 desktop ad adapters still extend `UnavailableAdAdapter` (`provider_not_integrated`). | `apps/desktop/src/main/services/ads/*` | `[~]` 2026-09-13: `DirectHouseSponsorAdapter` is now a real configurable REST adapter (bid/impression/click/reward via `endpointUrl`; fail-closed when unconfigured; 22 unit tests GREEN). Remaining 9 need official SDKs/authenticated endpoints. | | GAP-ADS-02 | Ads | Desktop mediation reward accounting is not wired to license quota (`claimReward` still returns no tokens). | `AdMediationEngine.ts`, `AppLayout.tsx` | Wire verified `reportRewardCompletion` to `LicenseService` quota after the direct sponsor endpoint exists. | | GAP-ID-01 | Identity | Supabase, .NET JWT/SQLite, and the desktop offline license each had their own tier/role shape. | `@d3ro/core/entitlement`, `LicenseService`, `entitlement-context` | `[~]` 2026-09-13: canonical `EntitlementSnapshot` + `resolveEntitlement` added with tests; desktop tier normalization + `isPro` fixed. Full adoption tracked as GAP-ID-02. | @@ -47,6 +47,9 @@ Legend: `[ ]` open · `[~]` in progress · `[!]` blocked externally · `[x]` res | GAP-STT-03 | Local engines | On hosts where `localhost` resolves only to IPv6, every local engine call (STT sidecar and Ollama) was refused. Audio capture and local LLM appeared dead. | `apps/desktop/src/main/utils/loopback.ts`, `LocalSTTService`, `LocalLLMService`, `RAGService`, `OnlineLLMService`, `STTManager` | `[x]` 2026-09-18: loopback normalization to `127.0.0.1` for all local engine URLs; defaults updated; 9 unit tests. Verified against the live sidecar and Ollama on a host with an IPv6-only `localhost`. | | GAP-STT-04 | Local STT | Live partial transcript (`CAP-03`, `voice:partialTranscript`) was marked done but had **no producer**: the channel, popup UI, and preload existed, nothing ever emitted. | `apps/desktop/src/main/services/VoiceModeService.ts`, `LocalSTTService.transcribePartial`, `STTManager.transcribePartial` | `[x]` 2026-09-18: 1.5 s cadence over a 7.5 s trailing window, greedy decode, drained before the final transcription; never inserted. | | GAP-STT-05 | Local STT | The bundled sidecar lacked faster-whisper's Silero VAD data, so `vad_filter=true` transcription would have failed at runtime even with the engine bundled. | `apps/desktop/scripts/build-sidecar.mjs`, `scripts/ci/verify-sidecar-bundle.mjs` | `[x]` 2026-09-18: `--collect-all faster_whisper` plus a packaging-time presence check for `assets/silero_vad_v6.onnx`. | +| GAP-REL-03 | Release | 서명이 없어 설치할 수 있는 경로가 없다(인증서 발급 전 공백). | `.forgejo/workflows/portable.yml`, `scripts/ci/build-portable.mjs`, `scripts/local/install-d3ro-voice.ps1`, `bucket/d3ro-voice.json` | `[x]` 2026-09-18: 서명 없는 portable 채널 구현 — 95MiB 7z 분할 볼륨(688MB → 162MiB) + Scoop 버킷 + 수동 설치 스크립트를 Forgejo에 게시. 실제 설치 스크립트 end-to-end 검증(볼륨 다운로드 → SHA-256 → 결합 → 해제 → 엔진 포함 확인). updater feed는 건드리지 않음. | +| GAP-REL-04 | Release | canonical feed는 Cloudflare 뒤에 있어 업로드 본문이 **100MiB**를 넘으면 HTTP 413으로 거부한다. 사이드카를 포함한 NSIS 설치본은 189MB라 **인증서가 있어도 게시할 수 없다**. | `scripts/ci/publish-portable-release.mjs`, `.forgejo/workflows/release.yml`, `docs/deployment/unsigned-distribution.md` | `[ ]` 2026-09-18 실측(60MiB 201 · 110MiB 413). 해결: 앱 번들을 100MiB 이하로 만들고 엔진은 분할 다운로드로 받거나(GAP-STT-07), 게시 경로를 바꾼다. | +| GAP-STT-07 | Local STT | 엔진(사이드카)를 앱 번들에 넣으면 설치본이 100MiB를 넘고 매 업데이트마다 162MiB를 다시 받는다. | `apps/desktop/electron-builder.yml`, `LocalSTTService` | `[ ]` 앱은 엔진을 첫 실행 시 분할 다운로드 + SHA-256 검증으로 받도록 분리한다. 설치 크기/업데이트 크기/패키징 실패 지점이 모두 줄어든다. | | GAP-STT-06 | Local STT | Decode settings were untuned: previous-text conditioning let repeated hallucinations compound, and no VAD parameters meant slow, uneven segments. | `apps/desktop/sidecar/main.py` | `[x]` 2026-09-18: `condition_on_previous_text=false`, bounded low-temperature fallback, `no_speech`/`compression_ratio`/`log_prob` thresholds, 300 ms silence trimming. Same transcript, ~5x faster on the reference machine (7.7 s audio: 1609 ms → 303 ms). | --- diff --git a/package-lock.json b/package-lock.json index 3e672a8..fc196af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "d3ro-voice-monorepo", - "version": "1.3.0", + "version": "1.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "d3ro-voice-monorepo", - "version": "1.3.0", + "version": "1.3.1", "license": "MIT", "workspaces": [ "apps/desktop", @@ -25,7 +25,7 @@ }, "apps/admin": { "name": "@d3ro/admin", - "version": "1.3.0", + "version": "1.3.1", "dependencies": { "@d3ro/api-client": "*", "@d3ro/core": "*", @@ -109,7 +109,7 @@ }, "apps/desktop": { "name": "@d3ro/desktop", - "version": "1.3.0", + "version": "1.3.1", "license": "MIT", "dependencies": { "@d3ro/core": "*", @@ -157,7 +157,7 @@ }, "apps/web": { "name": "@d3ro/web", - "version": "1.3.0", + "version": "1.3.1", "dependencies": { "@d3ro/api-client": "*", "@d3ro/core": "*", @@ -16866,7 +16866,7 @@ }, "packages/api-client": { "name": "@d3ro/api-client", - "version": "1.3.0", + "version": "1.3.1", "license": "MIT", "dependencies": { "@d3ro/core": "*", @@ -16883,7 +16883,7 @@ }, "packages/core": { "name": "@d3ro/core", - "version": "1.3.0", + "version": "1.3.1", "license": "MIT", "dependencies": { "docx": "^9.6.1" @@ -16894,7 +16894,7 @@ }, "packages/i18n": { "name": "@d3ro/i18n", - "version": "1.3.0", + "version": "1.3.1", "license": "MIT", "devDependencies": { "@types/react": "^19.0.0" @@ -16905,7 +16905,7 @@ }, "packages/ui": { "name": "@d3ro/ui", - "version": "1.3.0", + "version": "1.3.1", "license": "MIT", "dependencies": { "@d3ro/core": "*" @@ -16925,7 +16925,7 @@ }, "packages/ui-native": { "name": "@d3ro/ui-native", - "version": "1.3.0", + "version": "1.3.1", "license": "MIT", "devDependencies": { "@types/react": "*" diff --git a/package.json b/package.json index 2d16304..6109887 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "d3ro-voice-monorepo", - "version": "1.3.0", + "version": "1.3.1", "private": true, "description": "D3RO Voice — 멀티플랫폼 AI 음성 어시스턴트 (Monorepo)", "author": "D3RO", @@ -26,6 +26,11 @@ "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", + "release:portable:build": "node scripts/ci/build-portable.mjs", + "release:portable:check": "node scripts/ci/publish-portable-release.mjs --check", + "release:portable": "node --env-file-if-exists=.env scripts/ci/publish-portable-release.mjs", + "release:secrets": "node --env-file-if-exists=.env scripts/ci/set-forgejo-secrets.mjs --write", + "release:secrets:check": "node scripts/ci/set-forgejo-secrets.mjs --check", "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", diff --git a/packages/api-client/package.json b/packages/api-client/package.json index e56a903..2ec3d14 100644 --- a/packages/api-client/package.json +++ b/packages/api-client/package.json @@ -1,6 +1,6 @@ { "name": "@d3ro/api-client", - "version": "1.3.0", + "version": "1.3.1", "private": true, "description": "D3RO Voice API 클라이언트 — Supabase 래퍼 (web/desktop/mobile 공유)", "license": "MIT", diff --git a/packages/core/package.json b/packages/core/package.json index f337d21..0653bcb 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@d3ro/core", - "version": "1.3.0", + "version": "1.3.1", "private": true, "description": "D3RO Voice 공유 비즈니스 로직 — 타입, 에러, IPC 채널, 상수, 유틸", "license": "MIT", diff --git a/packages/i18n/package.json b/packages/i18n/package.json index d496837..9c9d457 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -1,6 +1,6 @@ { "name": "@d3ro/i18n", - "version": "1.3.0", + "version": "1.3.1", "private": true, "description": "D3RO Voice 공유 i18n — 12개 locale, 타입 안전 키, Context, 포맷 유틸", "license": "MIT", diff --git a/packages/ui-native/package.json b/packages/ui-native/package.json index 746c3ce..8ad0f52 100644 --- a/packages/ui-native/package.json +++ b/packages/ui-native/package.json @@ -1,6 +1,6 @@ { "name": "@d3ro/ui-native", - "version": "1.3.0", + "version": "1.3.1", "private": true, "description": "D3RO Voice React Native DS — MetalCard/PhosphorText/Led/WaveBars etc.", "license": "MIT", diff --git a/packages/ui/package.json b/packages/ui/package.json index ce578c6..c949d91 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@d3ro/ui", - "version": "1.3.0", + "version": "1.3.1", "private": true, "description": "D3RO Voice 공유 UI — 디자인 시스템 컴포넌트 + 테마 토큰 + CSS 변수 맵", "license": "MIT", diff --git a/release/product-version.json b/release/product-version.json index ec8a3a1..941ec32 100644 --- a/release/product-version.json +++ b/release/product-version.json @@ -1,8 +1,8 @@ { "schemaVersion": 1, - "version": "1.3.0", - "androidVersionCode": 1030001, - "iosBuildNumber": 1030001, + "version": "1.3.1", + "androidVersionCode": 1031001, + "iosBuildNumber": 1031001, "releaseDate": "2026-09-18", "desktopLicensePublicKeyId": "5c52b765135ee2531c681b53cd4dc0e96fff8737f496c0b04ba8334fc81a887f" } diff --git a/scripts/ci/build-portable.mjs b/scripts/ci/build-portable.mjs new file mode 100644 index 0000000..a1143fc --- /dev/null +++ b/scripts/ci/build-portable.mjs @@ -0,0 +1,321 @@ +// scripts/ci/build-portable.mjs +// 서명 없이 배포할 수 있는 휴대용 Windows 배포본을 만든다(7z 분할 볼륨 + Scoop 매니페스트). +// +// 왜 분할인가: canonical feed(git.chanpaca.net)는 Cloudflare 뒤에 있고 업로드 본문이 +// ~100MiB(104,857,600 bytes)를 넘으면 413으로 거부한다(실측: 60MiB 201 / 110MiB 413). +// 사이드카(faster-whisper)를 포함한 앱은 그 한도를 넘으므로, 95MiB 단위 7z 볼륨으로 +// 나눠 올리고 Scoop이 볼륨을 이어서 해제하도록 한다(Scoop은 .7z.001 볼을 공식 지원). +// +// 산출물: +// apps/desktop/release//D3RO-Voice--x64-portable.7z.001/.002/... +// apps/desktop/release//portable.json (볼륨 인덱스: 이름/크기/sha256) +// bucket/d3ro-voice.json (Scoop 매니페스트, 커밋 대상) +// +// zip 분할 부품(수동 설치용)도 함께 만든다 — Windows 내장 Expand-Archive로 해제할 수 있어 +// 사용자에게 7-Zip 설치를 요구하지 않는다. 7z 볼륨은 Scoop 전용으로 더 작다(162MiB vs 243MiB). +// +// 사용: +// npm run build --workspace=@d3ro/desktop +// node scripts/ci/build-portable.mjs # 7z 분할 볼륨 +// node scripts/ci/build-portable.mjs --zip # + 로컬 배포용 단일 zip(게시용 아님) + +import { spawnSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { + existsSync, + readFileSync, + readdirSync, + renameSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const require = createRequire(import.meta.url) +const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..') +const desktopDir = join(root, 'apps', 'desktop') +const version = JSON.parse( + readFileSync(join(root, 'release', 'product-version.json'), 'utf8'), +).version + +const FEED = 'https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice' +const PORTABLE_VERSION_PATH = `${FEED}/portable-${version}` +const PORTABLE_LATEST_PATH = `${FEED}/portable-latest` +const ARCHIVE_BASE = `D3RO-Voice-${version}-x64-portable` +/** Cloudflare 본문 한도(100MiB)보다 여유를 둔 7z 볼륨 크기 (Scoop 경로) */ +const VOLUME_SIZE = '95m' +const MAX_VOLUME_BYTES = 95 * 1024 * 1024 +/** 수동 설치 스크립트용 zip 분할 부품 크기 */ +const ZIP_PART_SIZE = '90m' +const MAX_ZIP_PART_BYTES = 95 * 1024 * 1024 + +const wantZip = process.argv.includes('--zip') +const releaseDir = join(desktopDir, 'release', version) +const appDir = join(releaseDir, 'win-unpacked') + +if (!existsSync(join(desktopDir, 'out', 'main', 'index.js'))) { + console.error( + 'out/main/index.js 가 없습니다. 먼저 데스크톱 번들을 빌드하세요:\n' + + ' npm run build --workspace=@d3ro/desktop', + ) + process.exit(1) +} + +if (!existsSync(join(desktopDir, 'sidecar-dist', 'sidecar'))) { + console.error( + 'STT 사이드카 번들이 없습니다. 서명 없이 배포해도 로컬 전사에는 사이드카가 필요합니다:\n' + + ' npm --prefix apps/desktop run sidecar:setup\n' + + ' npm --prefix apps/desktop run sidecar:build', + ) + process.exit(1) +} + +function resolve7za() { + // electron-builder가 의존하는 7zip-bin이 플랫폼별 7za 실행 파일을 제공한다. + const platformDir = + process.platform === 'win32' + ? join('win', process.arch === 'arm64' ? 'arm64' : 'x64') + : process.platform === 'darwin' + ? join('mac', process.arch === 'arm64' ? 'arm64' : 'x64') + : join('linux', process.arch === 'arm64' ? 'arm64' : 'x64') + const binary = process.platform === 'win32' ? '7za.exe' : '7za' + const candidate = join(root, 'node_modules', '7zip-bin', platformDir, binary) + if (!existsSync(candidate)) { + console.error(`7za를 찾을 수 없습니다: ${candidate}\n npm ci 후 다시 실행하세요.`) + process.exit(1) + } + return candidate +} + +const target = process.argv.includes('--zip') ? 'zip' : 'dir' +console.log( + `[portable] electron-builder 빌드 (version ${version}, target=${target}, 서명 없음)`, +) +const targetArgs = target === 'zip' ? ['--win', 'zip'] : ['--win', 'dir'] +const build = spawnSync( + 'npx', + [ + 'electron-builder', + ...targetArgs, + '--x64', + '--config', + 'electron-builder.yml', + '--publish', + 'never', + // 서명이 없으므로 NSIS 경로의 fail-closed 게이트를 이 채널에서만 명시적으로 해제한다. + // (자동 업데이트 피드가 아니라 별도 portable 경로로만 게시한다 — publish 스크립트 참조) + '-c.win.forceCodeSigning=false', + '-c.npmRebuild=false', + ], + { cwd: desktopDir, stdio: 'inherit', shell: process.platform === 'win32' }, +) + +if (build.status !== 0) { + console.error(`[portable] electron-builder 실패 (exit ${build.status ?? 'null'})`) + process.exit(build.status ?? 1) +} + +if (!existsSync(appDir)) { + console.error(`[portable] win-unpacked가 없습니다: ${appDir}`) + process.exit(1) +} + +// 이전 볼륨 정리 (같은 버전 재드 시 잔여 볼륨이 섞이지 않도록) +for (const name of readdirSync(releaseDir)) { + if (name.startsWith(ARCHIVE_BASE)) { + rmSync(join(releaseDir, name), { force: true }) + } +} + +const sevenZip = resolve7za() +const archivePath = join(releaseDir, `${ARCHIVE_BASE}.7z`) + +console.log(`[portable] 7z 분할 볼륨 생성 (볼륨 ${VOLUME_SIZE})`) +const compress = spawnSync( + sevenZip, + [ + 'a', + '-t7z', + '-m0=lzma2', + '-mx=9', + '-mmt=on', + '-ms=on', + `-v${VOLUME_SIZE}`, + '-bsp0', + '-bso0', + '-y', + archivePath, + join(appDir, '*'), + ], + { stdio: 'inherit' }, +) +if (compress.status !== 0) { + console.error(`[portable] 7z 압축 실패 (exit ${compress.status ?? 'null'})`) + process.exit(compress.status ?? 1) +} + +const volumes = readdirSync(releaseDir) + .filter((name) => name.startsWith(`${ARCHIVE_BASE}.7z.`)) + .sort() + +if (volumes.length === 0) { + console.error('[portable] 7z 볼륨을 찾을 수 없습니다.') + process.exit(1) +} + +const volumeEntries = [] +for (const name of volumes) { + const path = join(releaseDir, name) + const size = statSync(path).size + if (size > MAX_VOLUME_BYTES) { + console.error( + `[portable] 볼륨이 너무 큽니다(${name}: ${(size / 1048576).toFixed(1)}MiB). ` + + 'VOLUME_SIZE를 줄이세요 — Cloudflare가 100MiB 초과 업로드를 413으로 거부합니다.', + ) + process.exit(1) + } + const sha256 = createHash('sha256').update(readFileSync(path)).digest('hex') + volumeEntries.push({ name, size, sha256, url: `${PORTABLE_VERSION_PATH}/${name}` }) +} + +const totalBytes = volumeEntries.reduce((sum, entry) => sum + entry.size, 0) + +const portableIndex = { + channel: 'portable-unsigned', + version, + archive: `${ARCHIVE_BASE}.7z`, + volumes: volumeEntries, + volumeCount: volumeEntries.length, + totalSize: totalBytes, + releasedAt: new Date().toISOString(), + latestIndexUrl: `${PORTABLE_LATEST_PATH}/portable.json`, + installScriptUrl: `${PORTABLE_LATEST_PATH}/install-d3ro-voice.ps1`, + notes: [ + '서명 없는 휴대용 배포본입니다. 자동 업데이트 피드(latest.yml)는 갱신하지 않습니다.', + 'Cloudflare 업로드 한도(100MiB) 때문에 7z 볼륨으로 나뉘어 있습니다. Scoop이 이어서 해제합니다.', + '수동 설치: install-d3ro-voice.ps1 (7-Zip 필요) 또는 Scoop 사용을 권장합니다.', + ], +} + +writeFileSync( + join(releaseDir, 'portable.json'), + `${JSON.stringify(portableIndex, null, 2)}\n`, + 'utf8', +) + +const scoopManifest = { + version, + description: '로컬 AI 음성 어시스턴트 (faster-whisper + Ollama, 100% 오프라인 지원)', + homepage: 'https://d3ro.chanpaca.net', + license: 'MIT', + architecture: { + '64bit': { + url: volumeEntries.map((entry) => entry.url), + hash: volumeEntries.map((entry) => entry.sha256), + }, + }, + shortcuts: [['D3RO Voice.exe', 'D3RO Voice']], + checkver: { + url: `${PORTABLE_LATEST_PATH}/portable.json`, + jsonpath: '$.version', + }, + autoupdate: { + architecture: { + '64bit': { + url: volumeEntries.map((entry) => + entry.url.replace(`portable-${version}`, 'portable-$version'), + ), + }, + }, + }, +} + +writeFileSync( + join(root, 'bucket', 'd3ro-voice.json'), + `${JSON.stringify(scoopManifest, null, 2)}\n`, + 'utf8', +) + +// ── 수동 설치용 zip 분할 부품 ───────────────────────────── +const zipTarget = join(releaseDir, `${ARCHIVE_BASE}.zip`) +console.log(`[portable] 수동 설치용 zip 생성 (분할 ${ZIP_PART_SIZE})`) +const zipBuild = spawnSync( + 'npx', + [ + 'electron-builder', + '--win', + 'zip', + '--x64', + '--config', + 'electron-builder.yml', + '--publish', + 'never', + '-c.win.forceCodeSigning=false', + '-c.npmRebuild=false', + ], + { cwd: desktopDir, stdio: 'inherit', shell: process.platform === 'win32' }, +) +if (zipBuild.status !== 0) { + console.error(`[portable] zip 빌드 실패 (exit ${zipBuild.status ?? 'null'})`) + process.exit(zipBuild.status ?? 1) +} + +const producedZip = readdirSync(releaseDir).find((name) => + name.endsWith('.zip') && name.includes(version) && !name.includes('.part'), +) +if (!producedZip) { + console.error('[portable] zip 산출물을 찾을 수 없습니다.') + process.exit(1) +} +if (join(releaseDir, producedZip) !== zipTarget) { + renameSync(join(releaseDir, producedZip), zipTarget) +} + +// zip을 90MiB 단위로 바이트 분할한다 (사용자가 이어 붙여 Expand-Archive로 해제) +const zipBytes = readFileSync(zipTarget) +const partSize = 90 * 1024 * 1024 +const zipParts = [] +for (let offset = 0, index = 1; offset < zipBytes.length; offset += partSize, index += 1) { + const slice = zipBytes.subarray(offset, Math.min(offset + partSize, zipBytes.length)) + const name = `${ARCHIVE_BASE}.zip.${String(index).padStart(3, '0')}` + if (slice.length > MAX_ZIP_PART_BYTES) { + console.error(`[portable] zip 부품이 너무 큽니다: ${name}`) + process.exit(1) + } + writeFileSync(join(releaseDir, name), slice) + zipParts.push({ + name, + size: slice.length, + sha256: createHash('sha256').update(slice).digest('hex'), + url: `${PORTABLE_VERSION_PATH}/${name}`, + }) +} +const zipSha256 = createHash('sha256').update(zipBytes).digest('hex') + +// 인덱스에 zip 부품 정보를 추가한다 (설치 스크립트가 사용) +const indexJson = JSON.parse(readFileSync(join(releaseDir, 'portable.json'), 'utf8')) +indexJson.zipArchive = `${ARCHIVE_BASE}.zip` +indexJson.zipSize = zipBytes.length +indexJson.zipSha256 = zipSha256 +indexJson.zipParts = zipParts +writeFileSync( + join(releaseDir, 'portable.json'), + `${JSON.stringify(indexJson, null, 2)}\n`, + 'utf8', +) +console.log( + [ + '[portable] 완료', + ` 볼륨 : ${volumeEntries.length}개 / 합계 ${(totalBytes / 1048576).toFixed(1)}MiB`, + ...volumeEntries.map( + (entry) => ` ${entry.name} (${(entry.size / 1048576).toFixed(1)}MiB)`, + ), + ` zip : ${zipParts.length}개 부품 / 합계 ${(zipBytes.length / 1048576).toFixed(1)}MiB`, + ` 인덱스 : ${join(releaseDir, 'portable.json')}`, + ` scoop : ${join(root, 'bucket', 'd3ro-voice.json')}`, + ` 게시 : node scripts/ci/publish-portable-release.mjs`, + ].join('\n'), +) \ No newline at end of file diff --git a/scripts/ci/publish-portable-release.mjs b/scripts/ci/publish-portable-release.mjs new file mode 100644 index 0000000..5fafdb0 --- /dev/null +++ b/scripts/ci/publish-portable-release.mjs @@ -0,0 +1,168 @@ +// scripts/ci/publish-portable-release.mjs +// 서명 없는 휴대용 배포본(7z 분할 볼륨) + Scoop 매니페스트 + 수동 설치 스크립트를 +// Forgejo Generic Registry에 게시한다. +// +// 이 채널은 자동 업데이트 피드(latest.yml / update-policy.json)를 건드리지 않는다. +// 서명이 없어도 게시할 수 있으므로 인증서 발급 전에도 사용자가 설치할 수 있는 경로다. +// +// 경로: +// .../generic/d3ro-voice/portable-/<륨>.7z.00N +// .../generic/d3ro-voice/portable-/portable.json +// .../generic/d3ro-voice/portable-/install-d3ro-voice.ps1 +// .../generic/d3ro-voice/portable-latest/... (동일 파일 alias) +// +// 사용: +// 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' + +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) +} + +// 게시 전 해시 재검증 — 파일이 바뀌었는데 인덱스가 낡으면 불일치 배포가 된다. +const payloads = [] +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' }) +} +payloads.push({ + name: 'portable.json', + bytes: Buffer.from(`${JSON.stringify(index, null, 2)}\n`, 'utf8'), + contentType: 'application/json', +}) +payloads.push({ + name: 'install-d3ro-voice.ps1', + bytes: await readFile(installerPath), + contentType: 'text/plain', +}) + +const authorization = forgejoAuthorization() +const bases = [`${FEED}/portable-${version}`, `${FEED}/portable-latest`] + +async function forgejoFetch(url, init = {}) { + return fetch(url, { + ...init, + headers: { Authorization: authorization, ...(init.headers ?? {}) }, + }) +} + +async function upload(url, body, contentType) { + if (check) { + console.log(`[portable] (check) PUT ${url} (${body.length} bytes)`) + return + } + // Forgejo의 generic registry는 HEAD를 405로 거부한다(실측) → Range GET으로 크기만 읽는다. + const probe = await forgejoFetch(url, { headers: { Range: 'bytes=0-0' } }).catch(() => null) + const contentRange = probe?.headers.get('content-range') + const remoteLength = contentRange ? Number(contentRange.split('/')[1]) : NaN + if (probe?.ok && Number.isFinite(remoteLength)) { + if (remoteLength === body.length) { + console.log(`[portable] 이미 동일한 파일이 있습니다(건너뜀): ${url}`) + return + } + // 볼륨은 불변 자산이다 — 같은 버전 경로에 다른 바이트가 있으면 덮어쓰지 않고 중단한다. + if (url.includes(`/portable-${version}/`) && url.includes('.7z.')) { + console.error( + `[portable] ${version} 볼륨에 다른 바이트가 이미 있습니다: ${url}\n` + + ' 이미 게시된 버전은 덮어쓰지 않습니다(불변). 새 버전으로 게시하세요.', + ) + process.exit(1) + } + await forgejoFetch(url, { method: 'DELETE' }).catch(() => null) + } + const response = await forgejoFetch(url, { + method: 'PUT', + headers: { 'Content-Type': contentType }, + body, + }) + if (!response.ok) { + console.error( + `[portable] 업로드 실패 (HTTP ${response.status}): ${url}\n` + + ' HTTP 413이면 Cloudflare 본문 한도(100MiB) 초과입니다. 볼륨 크기를 줄이세요.', + ) + process.exit(1) + } + console.log(`[portable] uploaded ${url}`) +} + +for (const base of bases) { + for (const payload of payloads) { + await upload(`${base}/${encodeURIComponent(payload.name)}`, payload.bytes, payload.contentType) + } +} + +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`, + '', + ' 설치(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'), +) \ No newline at end of file diff --git a/scripts/ci/set-forgejo-secrets.mjs b/scripts/ci/set-forgejo-secrets.mjs new file mode 100644 index 0000000..4631918 --- /dev/null +++ b/scripts/ci/set-forgejo-secrets.mjs @@ -0,0 +1,131 @@ +// scripts/ci/set-forgejo-secrets.mjs +// Forgejo Actions 저장소 시크릿을 점검하거나 등록한다. +// +// 배경: 데스크톱 릴리스 워크플로(.forgejo/workflows/release.yml)는 아래 시크릿이 +// 없으면 fail-closed로 중단한다. 저장소에 시크릿이 하나도 없으면 태그를 올려도 +// 설치본이 게시되지 않는다(실측: run 49/51 모두 서명 가드에서 실패). +// +// 사용: +// node scripts/ci/set-forgejo-secrets.mjs --check # 현재 상태만 확인 +// node --env-file-if-exists=.env scripts/ci/set-forgejo-secrets.mjs --write +// +// 값은 출력하지 않는다(이름/존재 여부/길이만). FORGEJO_TOKEN(쓰기 스코프 필요)은 +// .env 또는 환경변수에서 읽는다. + +import { readFileSync, existsSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const REQUIRED = [ + 'WIN_CSC_LINK', + 'WIN_CSC_KEY_PASSWORD', + 'WIN_CSC_EXPECTED_SIGNER_SUBJECT', + 'FORGEJO_TOKEN', +] + +const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..') +const args = process.argv.slice(2) +const write = args.includes('--write') + +/** .env(있으면) + 환경변수에서 값 조회. 값은 로그에 절대 남기지 않는다. */ +function readEnv() { + const values = { ...process.env } + const envPath = join(root, '.env') + if (existsSync(envPath)) { + for (const line of readFileSync(envPath, 'utf8').split(/\r?\n/)) { + if (!/^[A-Z0-9_]+=/.test(line)) continue + const index = line.indexOf('=') + const key = line.slice(0, index) + if (values[key]) continue + values[key] = line.slice(index + 1).trim() + } + } + return values +} + +const env = readEnv() +const token = env.FORGEJO_TOKEN?.trim() +const server = (env.GIT_SERVER_URL?.trim() || 'https://git.chanpaca.net').replace(/\/$/, '') +const owner = env.GIT_USERNAME?.trim() || 'yunchan' +const repo = env.GIT_REPO_NAME?.trim() || 'd3ro-voice' + +if (!token) { + console.error('FORGEJO_TOKEN이 필요합니다 (.env 또는 환경변수).') + process.exit(1) +} + +const apiBase = `${server}/api/v1/repos/${owner}/${repo}/actions/secrets` +const headers = { Authorization: `token ${token}` } + +async function listSecrets() { + const response = await fetch(apiBase, { headers }) + if (!response.ok) { + throw new Error(`시크릿 목록 조회 실패: HTTP ${response.status}`) + } + const body = await response.json() + return new Set((Array.isArray(body) ? body : []).map((item) => item.name)) +} + +async function putSecret(name, value) { + const response = await fetch(`${apiBase}/${name}`, { + method: 'PUT', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ data: value }), + }) + if (!response.ok && response.status !== 201 && response.status !== 204) { + throw new Error(`${name} 등록 실패: HTTP ${response.status}`) + } +} + +const existing = await listSecrets() +console.log(`저장소: ${owner}/${repo} (${server})`) +console.log(`시크릿 API: ${apiBase}\n`) + +let missing = 0 +for (const name of REQUIRED) { + const present = existing.has(name) + if (present) { + console.log(` [x] ${name} — 등록됨`) + continue + } + missing += 1 + const value = env[name]?.trim() + console.log(` [ ] ${name} — 없음${value ? ` (환경/.env에 값 있음, 길이 ${value.length})` : ' (값 없음)'}`) +} + +if (missing === 0) { + console.log('\n모든 릴리스 시크릿이 준비되었습니다.') + process.exit(0) +} + +if (!write) { + console.log( + [ + `\n누락 ${missing}건. 값이 준비되면 다음으로 등록한다:`, + ' node --env-file-if-exists=.env scripts/ci/set-forgejo-secrets.mjs --write', + '', + 'WIN_CSC_LINK는 public-trust Authenticode PFX를 base64로 인코딩한 값이어야 하며,', + 'WIN_CSC_EXPECTED_SIGNER_SUBJECT는 그 인증서의 정확한 subject 문자열이어야 한다.', + '(개발용 Everything2EverythingDev 인증서는 production으로 인정되지 않는다.)', + ].join('\n'), + ) + process.exit(missing === 0 ? 0 : 2) +} + +let written = 0 +for (const name of REQUIRED) { + if (existing.has(name)) continue + const value = env[name]?.trim() + if (!value) { + console.log(` 건너뜀: ${name} (값 없음)`) + continue + } + await putSecret(name, value) + written += 1 + console.log(` 등록: ${name}`) +} + +const after = await listSecrets() +const stillMissing = REQUIRED.filter((name) => !after.has(name)) +console.log(`\n등록 ${written}건. 남은 누락: ${stillMissing.length ? stillMissing.join(', ') : '없음'}`) +process.exit(stillMissing.length === 0 ? 0 : 2) \ No newline at end of file diff --git a/scripts/install/install-d3ro-voice.ps1 b/scripts/install/install-d3ro-voice.ps1 new file mode 100644 index 0000000..fc6feee --- /dev/null +++ b/scripts/install/install-d3ro-voice.ps1 @@ -0,0 +1,131 @@ +# scripts/install/install-d3ro-voice.ps1 +# 서명 없이 D3RO Voice를 설치하는 수동 설치 스크립트. +# +# 왜 스크립트인가: canonical feed는 Cloudflare 뒤에 있어 업로드 본문이 100MiB를 넘으면 +# 거부된다. 사이드카(faster-whisper)를 포함한 앱은 그보다 크므로 zip을 90MiB 단위로 +# 나누어 게시하고, 이 스크립트가 부품을 이어 붙여 설치한다. Windows 내장 +# Expand-Archive만 사용하므로 7-Zip 같은 추가 도구가 필요 없고 관리자 권한도 필요 없다. +# +# 사용: +# irm https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice/portable-latest/install-d3ro-voice.ps1 | iex +# 또는 저장 후: +# powershell -ExecutionPolicy Bypass -File install-d3ro-voice.ps1 +# +# Scoop을 쓸 수 있으면 그쪽이 더 작고(7z 162MiB) 업데이트도 자동이다: +# scoop bucket add d3ro https://git.chanpaca.net/yunchan/d3ro-voice.git +# scoop install d3ro/d3ro-voice + +[CmdletBinding()] +param( + [string]$FeedBase = 'https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice/portable-latest', + [string]$InstallDir = (Join-Path $env:LOCALAPPDATA 'Programs\D3RO Voice'), + [switch]$Force +) + +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' + +function Write-Step($message) { Write-Host "[d3ro] $message" -ForegroundColor Cyan } + +function Get-Sha256($path) { + $sha = [System.Security.Cryptography.SHA256]::Create() + try { + $stream = [System.IO.File]::OpenRead($path) + try { $bytes = $sha.ComputeHash($stream) } finally { $stream.Dispose() } + } finally { $sha.Dispose() } + return ($bytes | ForEach-Object { $_.ToString('x2') }) -join '' +} + +Write-Step 'D3RO Voice 설치를 시작합니다 (서명되지 않은 빌드).' + +# 1. 인덱스 내려받기 +$indexUrl = "$FeedBase/portable.json" +Write-Step "인덱스: $indexUrl" +$index = Invoke-RestMethod -Uri $indexUrl -UseBasicParsing +$version = $index.version + +if (-not $index.zipParts -or $index.zipParts.Count -eq 0) { + throw '인덱스에 zip 부품 정보가 없습니다. 이 스크립트는 zipParts가 있는 버전(1.3.0+)을 지원합니다.' +} + +Write-Step "버전 $version, 부품 $($index.zipParts.Count)개 (합계 $([math]::Round($index.zipSize / 1MB, 1)) MB)" + +# 2. 임시 디렉터리에 부품 내려받기 + 해시 검증 +$tempRoot = [System.IO.Path]::GetTempPath() +if ($env:TEMP) { $tempRoot = $env:TEMP } +elseif ($env:TMP) { $tempRoot = $env:TMP } + +$workDir = Join-Path $tempRoot "d3ro-voice-$version-portable" +if (Test-Path $workDir) { Remove-Item -Recurse -Force $workDir } +New-Item -ItemType Directory -Path $workDir | Out-Null + +foreach ($part in $index.zipParts) { + $dest = Join-Path $workDir $part.name + Write-Step "내려받기: $($part.name) ($([math]::Round($part.size / 1MB, 1)) MB)" + Invoke-WebRequest -Uri "$FeedBase/$($part.name)" -OutFile $dest -UseBasicParsing + + $hash = Get-Sha256 $dest + if ($hash -ne $part.sha256) { + throw "해시가 일치하지 않습니다: $($part.name)`n 기대: $($part.sha256)`n 실제: $hash" + } +} +Write-Step '모든 부품의 SHA-256 검증 완료' + +# 3. 부품 이어 붙이기 +$archive = Join-Path $workDir $index.zipArchive +$stream = [System.IO.File]::Create($archive) +try { + foreach ($part in $index.zipParts) { + $piece = [System.IO.File]::OpenRead((Join-Path $workDir $part.name)) + try { $piece.CopyTo($stream) } finally { $piece.Dispose() } + } +} finally { + $stream.Dispose() +} + +$archiveHash = Get-Sha256 $archive +if ($index.zipSha256 -and $archiveHash -ne $index.zipSha256) { + throw "결합한 아카이브의 해시가 인덱스와 다릅니다.`n 기대: $($index.zipSha256)`n 실제: $archiveHash" +} +Write-Step "아카이브 결합 완료: $([math]::Round((Get-Item $archive).Length / 1MB, 1)) MB" + +# 4. 압축 해제 (Windows 내장 Expand-Archive — 추가 도구 불필요) +$extractDir = Join-Path $workDir 'extract' +Write-Step '압축 해제 중 (수백 MB, 시간이 걸릴 수 있습니다)' +Expand-Archive -LiteralPath $archive -DestinationPath $extractDir -Force + +# 5. 설치 디렉터리로 배치 +if (Test-Path $InstallDir) { + if (-not $Force) { + throw "설치 경로가 이미 있습니다: $InstallDir`n 다시 설치하려면 -Force 를 붙이세요." + } + Write-Step "기존 설치를 교체합니다: $InstallDir" + Remove-Item -Recurse -Force $InstallDir +} +New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null +Copy-Item -Path (Join-Path $extractDir '*') -Destination $InstallDir -Recurse -Force + +# 6. 시작 메뉴 바로가기 +$exe = Join-Path $InstallDir 'D3RO Voice.exe' +if (-not (Test-Path $exe)) { throw "실행 파일을 찾을 수 없습니다: $exe" } + +$startMenu = Join-Path $env:APPDATA 'Microsoft\Windows\Start Menu\Programs' +$shortcutPath = Join-Path $startMenu 'D3RO Voice.lnk' +$shell = New-Object -ComObject WScript.Shell +$shortcut = $shell.CreateShortcut($shortcutPath) +$shortcut.TargetPath = $exe +$shortcut.WorkingDirectory = $InstallDir +$shortcut.Save() + +Remove-Item -Recurse -Force $workDir -ErrorAction SilentlyContinue + +Write-Step "설치 완료: $InstallDir" +Write-Step "시작 메뉴 바로가기: $shortcutPath" +Write-Host '' +Write-Host '참고:' -ForegroundColor Yellow +Write-Host ' - 이 빌드는 Authenticode 서명이 없어 SmartScreen 경고가 뜰 수 있습니다("추가 정보 -> 실행").' +Write-Host ' - 자동 업데이트는 서명된 릴리스가 게시된 뒤부터 동작합니다(현재 설치본은 그 피드를 봅니다).' +Write-Host ' - 설정/모델/기록은 %APPDATA%\d3ro-voice 를 공유하므로 기존 설치와 동일하게 유지됩니다.' +Write-Host ' - Scoop 사용자는 scoop update d3ro-voice 로 갱신할 수 있습니다(7z 162MiB로 더 작음).' +Write-Host '' +Write-Host "실행: `"$exe`"" -ForegroundColor Green \ No newline at end of file diff --git a/site/package-lock.json b/site/package-lock.json index 57d4b69..5f33c08 100644 --- a/site/package-lock.json +++ b/site/package-lock.json @@ -1,12 +1,12 @@ { "name": "d3ro-voice-site", - "version": "1.3.0", + "version": "1.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "d3ro-voice-site", - "version": "1.3.0", + "version": "1.3.1", "dependencies": { "react": "^19.0.0", "react-dom": "^19.0.0" diff --git a/site/package.json b/site/package.json index 1c5a97d..7aaef9d 100644 --- a/site/package.json +++ b/site/package.json @@ -1,7 +1,7 @@ { "name": "d3ro-voice-site", "private": true, - "version": "1.3.0", + "version": "1.3.1", "type": "module", "scripts": { "dev": "vite --port 5199 --host",