Compare commits

...

3 commits
v1.3.0 ... main

Author SHA1 Message Date
Yun Chan
0411f389d9 feat(release): make the signing-free install work with nothing but Windows
Some checks failed
deploy-site / deploy (push) Failing after 3m26s
The manual install path still needed 7-Zip, which the target machine does not
have, so "installable without a certificate" was not yet true.

The channel now also publishes the app as byte-split zip parts, and the install
script joins them and extracts with the built-in Windows Expand-Archive after
verifying every part and the joined archive. Version 1.3.1 republishes the
channel from a single build, because a version's artifacts can only match one
build and published volumes are never overwritten.
2026-09-18 12:03:18 +09:00
Yun Chan
c35c6f3e95 feat(release): ship an install path that needs no code-signing certificate
Some checks failed
deploy-site / deploy (push) Failing after 4m9s
Installers could not be published at all: the signing certificate does not
exist yet, and the release pipelines stop at their signing guard. Users had no
way to install a fixed build, so the product was effectively stuck behind a
certificate that takes weeks to obtain.

There is also a second, independent blocker: the download feed sits behind
Cloudflare, which rejects any upload body over about 100 MiB, and the app with
its speech engine exceeds that even when signed.

A portable channel now publishes what can actually be delivered today: the app
compressed into 95 MiB 7z volumes (162 MiB total instead of 243 MiB), a Scoop
bucket for a normal install and uninstall experience, and a verifiable manual
installer script. It is deliberately separate from the auto-update feed, needs
no certificate, and refuses to overwrite an already published version.
2026-09-18 11:03:34 +09:00
Yun Chan
a85ab799a3 docs(release): record why tagged releases publish nothing
Some checks failed
deploy-site / deploy (push) Failing after 31s
Two tagged release pipelines failed and no installer has been published since
1.1.0. The cause is not in the build: the release repository has no Actions
secrets at all, so every run stops at the signing guard.

A helper now reports which release secrets are missing and registers them once
the signing material exists, and the release guide documents the required
values, the Forgejo-side check, and how to re-run a pipeline for an existing
tag without recreating it.
2026-09-18 04:37:05 +09:00
35 changed files with 1140 additions and 56 deletions

View file

@ -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

View file

@ -13,6 +13,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Cloud-optional backup (encrypted, opt-in) - Cloud-optional backup (encrypted, opt-in)
- Plugin system for custom pipelines - 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 ## [1.3.0] - 2026-09-18
> Published from an annotated tag through CI. Installer and update metadata are > 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 `sidecar:build`, plus a packaging-time bundle verifier that fails the build when the
engine or its VAD data is missing. 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 ### Changed
- Local engine connections now target the IPv4 loopback (`127.0.0.1`) instead of - 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 `localhost`. On machines where `localhost` resolves only to IPv6, every local request

View file

@ -3,7 +3,7 @@
"info": { "info": {
"title": "D3RO-VOICE Admin API", "title": "D3RO-VOICE Admin API",
"description": "Admin CRM Edge Functions for user management, subscription CRUD, payment history, and audit logs.", "description": "Admin CRM Edge Functions for user management, subscription CRUD, payment history, and audit logs.",
"version": "1.3.0" "version": "1.3.1"
}, },
"servers": [ "servers": [
{ {

View file

@ -1,6 +1,6 @@
{ {
"name": "@d3ro/admin", "name": "@d3ro/admin",
"version": "1.3.0", "version": "1.3.1",
"private": true, "private": true,
"description": "D3RO Voice Admin CRM — SaaS 관리 도구", "description": "D3RO Voice Admin CRM — SaaS 관리 도구",
"scripts": { "scripts": {

View file

@ -2,7 +2,7 @@
<PropertyGroup> <PropertyGroup>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Version>1.3.0</Version> <Version>1.3.1</Version>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>

View file

@ -1,6 +1,6 @@
{ {
"name": "@d3ro/desktop", "name": "@d3ro/desktop",
"version": "1.3.0", "version": "1.3.1",
"productName": "d3ro-voice", "productName": "d3ro-voice",
"description": "로컬 AI 음성 어시스턴트 (Electron)", "description": "로컬 AI 음성 어시스턴트 (Electron)",
"main": "./out/main/index.js", "main": "./out/main/index.js",

View file

@ -151,8 +151,8 @@ def versionSettingsValid = configuredVersionName != null &&
configuredVersionName ==~ strictSemver && configuredVersionName ==~ strictSemver &&
configuredVersionCodeValue != null && configuredVersionCodeValue != null &&
configuredVersionCodeValue <= 2100000000L configuredVersionCodeValue <= 2100000000L
def resolvedVersionName = versionSettingsValid ? configuredVersionName : "1.3.0" def resolvedVersionName = versionSettingsValid ? configuredVersionName : "1.3.1"
def resolvedVersionCode = versionSettingsValid ? configuredVersionCodeValue.toInteger() : 1030001 def resolvedVersionCode = versionSettingsValid ? configuredVersionCodeValue.toInteger() : 1031001
def requiredReleaseSettings = [ def requiredReleaseSettings = [
D3RO_RELEASE_STORE_FILE: releaseStoreFilePath, D3RO_RELEASE_STORE_FILE: releaseStoreFilePath,

View file

@ -257,7 +257,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 1030001; CURRENT_PROJECT_VERSION = 1031001;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = D3ROVoice/Info.plist; INFOPLIST_FILE = D3ROVoice/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1; IPHONEOS_DEPLOYMENT_TARGET = 15.1;
@ -265,7 +265,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.3.0; MARKETING_VERSION = 1.3.1;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = (
"$(inherited)", "$(inherited)",
"-ObjC", "-ObjC",
@ -287,14 +287,14 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 1030001; CURRENT_PROJECT_VERSION = 1031001;
INFOPLIST_FILE = D3ROVoice/Info.plist; INFOPLIST_FILE = D3ROVoice/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1; IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.3.0; MARKETING_VERSION = 1.3.1;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = (
"$(inherited)", "$(inherited)",
"-ObjC", "-ObjC",

View file

@ -0,0 +1 @@
Added a signing-free install path: the download is served in verifiable parts and installed after checking them.

View file

@ -0,0 +1 @@
서명 인증서 없이도 설치할 수 있는 배포 경로를 추가했습니다. 설치 파일이 나뉘어 제공되고 검증 후에 설치됩니다.

View file

@ -1,12 +1,12 @@
{ {
"name": "@d3ro/mobile-rn", "name": "@d3ro/mobile-rn",
"version": "1.3.0", "version": "1.3.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@d3ro/mobile-rn", "name": "@d3ro/mobile-rn",
"version": "1.3.0", "version": "1.3.1",
"dependencies": { "dependencies": {
"@d3ro/api-client": "file:../../packages/api-client", "@d3ro/api-client": "file:../../packages/api-client",
"@d3ro/core": "file:../../packages/core", "@d3ro/core": "file:../../packages/core",
@ -62,7 +62,7 @@
}, },
"../..": { "../..": {
"name": "d3ro-voice-monorepo", "name": "d3ro-voice-monorepo",
"version": "1.3.0", "version": "1.3.1",
"license": "MIT", "license": "MIT",
"workspaces": [ "workspaces": [
"apps/desktop", "apps/desktop",
@ -81,7 +81,7 @@
}, },
"../../packages/api-client": { "../../packages/api-client": {
"name": "@d3ro/api-client", "name": "@d3ro/api-client",
"version": "1.3.0", "version": "1.3.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@d3ro/core": "*", "@d3ro/core": "*",
@ -98,7 +98,7 @@
}, },
"../../packages/core": { "../../packages/core": {
"name": "@d3ro/core", "name": "@d3ro/core",
"version": "1.3.0", "version": "1.3.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"docx": "^9.6.1" "docx": "^9.6.1"
@ -109,7 +109,7 @@
}, },
"../../packages/i18n": { "../../packages/i18n": {
"name": "@d3ro/i18n", "name": "@d3ro/i18n",
"version": "1.3.0", "version": "1.3.1",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@types/react": "^19.0.0" "@types/react": "^19.0.0"
@ -120,7 +120,7 @@
}, },
"../../packages/ui-native": { "../../packages/ui-native": {
"name": "@d3ro/ui-native", "name": "@d3ro/ui-native",
"version": "1.3.0", "version": "1.3.1",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@types/react": "*" "@types/react": "*"

View file

@ -1,6 +1,6 @@
{ {
"name": "@d3ro/mobile-rn", "name": "@d3ro/mobile-rn",
"version": "1.3.0", "version": "1.3.1",
"private": true, "private": true,
"scripts": { "scripts": {
"android": "react-native run-android", "android": "react-native run-android",

View file

@ -1,6 +1,6 @@
{ {
"name": "@d3ro/web", "name": "@d3ro/web",
"version": "1.3.0", "version": "1.3.1",
"private": true, "private": true,
"description": "D3RO Voice 웹 앱 — Next.js 기반 SaaS 인터페이스", "description": "D3RO Voice 웹 앱 — Next.js 기반 SaaS 인터페이스",
"scripts": { "scripts": {

View file

@ -139,7 +139,7 @@ export function Sidebar(): React.ReactElement {
</PhosphorText> </PhosphorText>
</Box> </Box>
<Box sx={{ fontSize: '10px', fontFamily: 'ui-monospace, monospace', color: d3roPalette.text.muted }}> <Box sx={{ fontSize: '10px', fontFamily: 'ui-monospace, monospace', color: d3roPalette.text.muted }}>
v1.3.0 v1.3.1
</Box> </Box>
</Box> </Box>

18
bucket/README.md Normal file
View file

@ -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`로 새 버전을 받을 수 있습니다.

38
bucket/d3ro-voice.json Normal file
View file

@ -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"
]
}
}
}
}

View file

@ -1,41 +1,41 @@
# D3RO Voice 릴리스 가이드 # 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 identity
| 항목 | 정본 | 현재 판정 | | 항목 | 정본 | 현재 판정 |
|---|---|---| |---|---|---|
| 제품 버전 | `release/product-version.json`: `1.2.0` | source SSOT 확정 | | 제품 버전 | `release/product-version.json`: `1.3.0` | source SSOT 확정 |
| Android | versionCode `1020001` | production AAB 미생성 | | Android | versionCode `1030001` | production AAB 미생성 |
| iOS | build `1020001` | production archive 미검증 | | 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 대조 대기 | | 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·복구 백업 대기 | | 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 주입 대기 | | 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 생성 필요 | | Firebase | Console `u/0`, `u/1` 모두 D3RO project 없음 | 사용자 승인 후 project·Android app 생성 필요 |
| AdMob | app `ca-app-pub-1039714767792854~6427959892`; banner `/9840591290`; rewarded `/2255790918` | SSOT 확정. `검토 필요`·`광고 게재 제한`·store 미연결·결제 프로필 미완료 | | 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 | | 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]` | 태그 전 확정·검증 필수 | | release notes | `CHANGELOG.md` `## [1.3.0]` + Play changelog `1030001.txt` (ko/en) | 태그 전 확정·검증 필수 |
| 직전 게시본 | Forgejo Release `v1.1.0` (2026-09-15 게시, unsigned installer 포함) | 불변 태그. `1.2.0`은 이를 대체하는 forward-fix | | 직전 게시본 | 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 릴리스 파이프라인 ## desktop 릴리스 파이프라인
```text ```text
authoritative release commit authoritative release commit
→ version/check/test/build GREEN → version/check/test/build GREEN
→ annotated tag v1.2.0 → annotated tag v1.3.0
→ package-windows (build-win-x64) → package-windows (build-win-x64)
→ package-macos (build-mac-arm64) → package-macos (build-mac-arm64)
→ publish-release (build-linux-x64) → publish-release (build-linux-x64)
├─ publish-forgejo-release.mjs ← canonical ├─ 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 Generic Registry /d3ro-voice/latest/ (updater feed + update-policy.json)
│ └─ Forgejo Release + CHANGELOG notes + 자산 첨부 │ └─ Forgejo Release + CHANGELOG notes + 자산 첨부
└─ publish-gitlab-release.mjs ← legacy mirror (pre-Forgejo 설치본) └─ 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 Generic Registry /d3ro-voice/latest/
└─ GitLab Release └─ GitLab Release
``` ```
@ -50,7 +50,7 @@ authoritative release commit
포함되지 않는다. 사이트·웹 다운로드 센터는 로컬 경로가 아니라 feed URL을 포함되지 않는다. 사이트·웹 다운로드 센터는 로컬 경로가 아니라 feed URL을
링크한다. (역사적 `1.0.0` 자산만 추적 상태로 남아 있다.) 링크한다. (역사적 `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 계약을 검증한다. - `scripts/ci/verify-release-metadata.mjs`는 배포 메타데이터와 CI/publisher 계약을 검증한다.
- 같은 gate는 desktop license public key가 Ed25519이고 `release/product-version.json``desktopLicensePublicKeyId`와 일치하는지 검증한다. `electron.vite.config.ts`는 이 파일을 직접 읽으므로 누락·손상된 키로는 build가 시작되지 않는다. - 같은 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에도 올린다. - `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한다. - **회수(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/<versionCode>.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`로 대조한다. 1. `release/product-version.json`의 version/build 값과 모든 버전 면을 `npm run version:check`로 대조한다.
2. `CHANGELOG.md` `## [1.2.0] - 2026-09-16` 섹션을 사용자 변경점 중심으로 확정한다. publisher는 이 섹션이 없으면 실패해야 한다. 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 - `release/mobile-release-evidence-public.pem` — release evidence public key
- `apps/desktop/resources/license/production-public.pem` — desktop offline license public key SSOT - `apps/desktop/resources/license/production-public.pem` — desktop offline license public key SSOT
- `scripts/ci/sync-version.mjs` — 버전 면 동기화·검증 - `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/create-release-tag.mjs` — 릴리스 태그 게이트 (annotated/서명, 불변)
- `scripts/ci/verify-release-metadata.mjs` — release metadata 자가 검증 - `scripts/ci/verify-release-metadata.mjs` — release metadata 자가 검증
- `scripts/ci/verify-windows-release-artifact.ps1` — Windows version·updater metadata·Authenticode gate - `scripts/ci/verify-windows-release-artifact.ps1` — Windows version·updater metadata·Authenticode gate

View file

@ -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/<v>/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-<v>-x64-portable.7z.001/.002` — LZMA2로 688MB → 162MiB. Scoop 전용.
- **zip 분할 부품** `...zip.001/.002/.003` — 243MiB. 수동 설치 스크립트용. Windows 내장
`Expand-Archive`만으로 풀 수 있어 사용자가 7-Zip을 설치할 필요가 없다.
- 게시 위치: `.../generic/d3ro-voice/portable-<version>/``.../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`
확인 → 시작 메뉴 바로가기 생성. (검증 후 테스트 설치/바로가기 제거)

View file

@ -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. 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/`) ### 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. `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. `release.yml` — tag-triggered Windows build (signed) + `publish-forgejo-release.mjs` to the canonical Forgejo feed/release hub.

View file

@ -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-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-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-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` 게시 + 실제 설치 검증. |
--- ---

View file

@ -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-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-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-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-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. | | 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-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-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-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). | | 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). |
--- ---

20
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "d3ro-voice-monorepo", "name": "d3ro-voice-monorepo",
"version": "1.3.0", "version": "1.3.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "d3ro-voice-monorepo", "name": "d3ro-voice-monorepo",
"version": "1.3.0", "version": "1.3.1",
"license": "MIT", "license": "MIT",
"workspaces": [ "workspaces": [
"apps/desktop", "apps/desktop",
@ -25,7 +25,7 @@
}, },
"apps/admin": { "apps/admin": {
"name": "@d3ro/admin", "name": "@d3ro/admin",
"version": "1.3.0", "version": "1.3.1",
"dependencies": { "dependencies": {
"@d3ro/api-client": "*", "@d3ro/api-client": "*",
"@d3ro/core": "*", "@d3ro/core": "*",
@ -109,7 +109,7 @@
}, },
"apps/desktop": { "apps/desktop": {
"name": "@d3ro/desktop", "name": "@d3ro/desktop",
"version": "1.3.0", "version": "1.3.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@d3ro/core": "*", "@d3ro/core": "*",
@ -157,7 +157,7 @@
}, },
"apps/web": { "apps/web": {
"name": "@d3ro/web", "name": "@d3ro/web",
"version": "1.3.0", "version": "1.3.1",
"dependencies": { "dependencies": {
"@d3ro/api-client": "*", "@d3ro/api-client": "*",
"@d3ro/core": "*", "@d3ro/core": "*",
@ -16866,7 +16866,7 @@
}, },
"packages/api-client": { "packages/api-client": {
"name": "@d3ro/api-client", "name": "@d3ro/api-client",
"version": "1.3.0", "version": "1.3.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@d3ro/core": "*", "@d3ro/core": "*",
@ -16883,7 +16883,7 @@
}, },
"packages/core": { "packages/core": {
"name": "@d3ro/core", "name": "@d3ro/core",
"version": "1.3.0", "version": "1.3.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"docx": "^9.6.1" "docx": "^9.6.1"
@ -16894,7 +16894,7 @@
}, },
"packages/i18n": { "packages/i18n": {
"name": "@d3ro/i18n", "name": "@d3ro/i18n",
"version": "1.3.0", "version": "1.3.1",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@types/react": "^19.0.0" "@types/react": "^19.0.0"
@ -16905,7 +16905,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@d3ro/ui", "name": "@d3ro/ui",
"version": "1.3.0", "version": "1.3.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@d3ro/core": "*" "@d3ro/core": "*"
@ -16925,7 +16925,7 @@
}, },
"packages/ui-native": { "packages/ui-native": {
"name": "@d3ro/ui-native", "name": "@d3ro/ui-native",
"version": "1.3.0", "version": "1.3.1",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@types/react": "*" "@types/react": "*"

View file

@ -1,6 +1,6 @@
{ {
"name": "d3ro-voice-monorepo", "name": "d3ro-voice-monorepo",
"version": "1.3.0", "version": "1.3.1",
"private": true, "private": true,
"description": "D3RO Voice — 멀티플랫폼 AI 음성 어시스턴트 (Monorepo)", "description": "D3RO Voice — 멀티플랫폼 AI 음성 어시스턴트 (Monorepo)",
"author": "D3RO", "author": "D3RO",
@ -26,6 +26,11 @@
"release:forgejo:local": "node --env-file-if-exists=.env scripts/ci/publish-forgejo-release.mjs", "release:forgejo:local": "node --env-file-if-exists=.env scripts/ci/publish-forgejo-release.mjs",
"release:forgejo:check": "node scripts/ci/publish-forgejo-release.mjs --check", "release:forgejo:check": "node scripts/ci/publish-forgejo-release.mjs --check",
"release:tag": "node scripts/ci/create-release-tag.mjs", "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": "node scripts/ci/check-no-hardcoded-secrets.mjs",
"security:secrets:test": "node scripts/ci/check-no-hardcoded-secrets.mjs --self-test", "security:secrets:test": "node scripts/ci/check-no-hardcoded-secrets.mjs --self-test",
"check:design": "node scripts/ci/check-design-tokens.mjs", "check:design": "node scripts/ci/check-design-tokens.mjs",

View file

@ -1,6 +1,6 @@
{ {
"name": "@d3ro/api-client", "name": "@d3ro/api-client",
"version": "1.3.0", "version": "1.3.1",
"private": true, "private": true,
"description": "D3RO Voice API 클라이언트 — Supabase 래퍼 (web/desktop/mobile 공유)", "description": "D3RO Voice API 클라이언트 — Supabase 래퍼 (web/desktop/mobile 공유)",
"license": "MIT", "license": "MIT",

View file

@ -1,6 +1,6 @@
{ {
"name": "@d3ro/core", "name": "@d3ro/core",
"version": "1.3.0", "version": "1.3.1",
"private": true, "private": true,
"description": "D3RO Voice 공유 비즈니스 로직 — 타입, 에러, IPC 채널, 상수, 유틸", "description": "D3RO Voice 공유 비즈니스 로직 — 타입, 에러, IPC 채널, 상수, 유틸",
"license": "MIT", "license": "MIT",

View file

@ -1,6 +1,6 @@
{ {
"name": "@d3ro/i18n", "name": "@d3ro/i18n",
"version": "1.3.0", "version": "1.3.1",
"private": true, "private": true,
"description": "D3RO Voice 공유 i18n — 12개 locale, 타입 안전 키, Context, 포맷 유틸", "description": "D3RO Voice 공유 i18n — 12개 locale, 타입 안전 키, Context, 포맷 유틸",
"license": "MIT", "license": "MIT",

View file

@ -1,6 +1,6 @@
{ {
"name": "@d3ro/ui-native", "name": "@d3ro/ui-native",
"version": "1.3.0", "version": "1.3.1",
"private": true, "private": true,
"description": "D3RO Voice React Native DS — MetalCard/PhosphorText/Led/WaveBars etc.", "description": "D3RO Voice React Native DS — MetalCard/PhosphorText/Led/WaveBars etc.",
"license": "MIT", "license": "MIT",

View file

@ -1,6 +1,6 @@
{ {
"name": "@d3ro/ui", "name": "@d3ro/ui",
"version": "1.3.0", "version": "1.3.1",
"private": true, "private": true,
"description": "D3RO Voice 공유 UI — 디자인 시스템 컴포넌트 + 테마 토큰 + CSS 변수 맵", "description": "D3RO Voice 공유 UI — 디자인 시스템 컴포넌트 + 테마 토큰 + CSS 변수 맵",
"license": "MIT", "license": "MIT",

View file

@ -1,8 +1,8 @@
{ {
"schemaVersion": 1, "schemaVersion": 1,
"version": "1.3.0", "version": "1.3.1",
"androidVersionCode": 1030001, "androidVersionCode": 1031001,
"iosBuildNumber": 1030001, "iosBuildNumber": 1031001,
"releaseDate": "2026-09-18", "releaseDate": "2026-09-18",
"desktopLicensePublicKeyId": "5c52b765135ee2531c681b53cd4dc0e96fff8737f496c0b04ba8334fc81a887f" "desktopLicensePublicKeyId": "5c52b765135ee2531c681b53cd4dc0e96fff8737f496c0b04ba8334fc81a887f"
} }

View file

@ -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/<version>/D3RO-Voice-<version>-x64-portable.7z.001/.002/...
// apps/desktop/release/<version>/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'),
)

View file

@ -0,0 +1,168 @@
// scripts/ci/publish-portable-release.mjs
// 서명 없는 휴대용 배포본(7z 분할 볼륨) + Scoop 매니페스트 + 수동 설치 스크립트를
// Forgejo Generic Registry에 게시한다.
//
// 이 채널은 자동 업데이트 피드(latest.yml / update-policy.json)를 건드리지 않는다.
// 서명이 없어도 게시할 수 있으므로 인증서 발급 전에도 사용자가 설치할 수 있는 경로다.
//
// 경로:
// .../generic/d3ro-voice/portable-<version>/<륨>.7z.00N
// .../generic/d3ro-voice/portable-<version>/portable.json
// .../generic/d3ro-voice/portable-<version>/install-d3ro-voice.ps1
// .../generic/d3ro-voice/portable-latest/... (동일 파일 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'),
)

View file

@ -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)

View file

@ -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

View file

@ -1,12 +1,12 @@
{ {
"name": "d3ro-voice-site", "name": "d3ro-voice-site",
"version": "1.3.0", "version": "1.3.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "d3ro-voice-site", "name": "d3ro-voice-site",
"version": "1.3.0", "version": "1.3.1",
"dependencies": { "dependencies": {
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0" "react-dom": "^19.0.0"

View file

@ -1,7 +1,7 @@
{ {
"name": "d3ro-voice-site", "name": "d3ro-voice-site",
"private": true, "private": true,
"version": "1.3.0", "version": "1.3.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --port 5199 --host", "dev": "vite --port 5199 --host",