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",