feat(release): ship an install path that needs no code-signing certificate
Some checks failed
deploy-site / deploy (push) Failing after 4m9s
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.
This commit is contained in:
parent
a85ab799a3
commit
c35c6f3e95
13 changed files with 805 additions and 1 deletions
69
.forgejo/workflows/portable.yml
Normal file
69
.forgejo/workflows/portable.yml
Normal 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
|
||||
|
|
@ -33,6 +33,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
|
||||
|
|
|
|||
18
bucket/README.md
Normal file
18
bucket/README.md
Normal 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
38
bucket/d3ro-voice.json
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"version": "1.3.0",
|
||||
"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.0/D3RO-Voice-1.3.0-x64-portable.7z.001",
|
||||
"https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice/portable-1.3.0/D3RO-Voice-1.3.0-x64-portable.7z.002"
|
||||
],
|
||||
"hash": [
|
||||
"c4824bd625a2f0fd685f6fd3dd8521f4ced7d46954b59f0c10efcd432f82bd3f",
|
||||
"187f4917e53525e736df838b15323e840691d602eadcfd9024407498316932ce"
|
||||
]
|
||||
}
|
||||
},
|
||||
"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.0-x64-portable.7z.001",
|
||||
"https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice/portable-$version/D3RO-Voice-1.3.0-x64-portable.7z.002"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -141,6 +141,22 @@ node --env-file-if-exists=.env scripts/ci/set-forgejo-secrets.mjs --write
|
|||
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`).
|
||||
- 사용자 설치: Scoop 버킷(`bucket/`) 또는 `install-d3ro-voice.ps1`.
|
||||
|
||||
## `1.2.0` 릴리스 절차 (기록)
|
||||
|
||||
1. `release/product-version.json`의 version/build 값과 모든 버전 면을 `npm run version:check`로 대조한다.
|
||||
|
|
@ -178,6 +194,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
|
||||
|
|
|
|||
105
docs/deployment/unsigned-distribution.md
Normal file
105
docs/deployment/unsigned-distribution.md
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
# 서명 없이 배포하기 — 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 채널)
|
||||
|
||||
- 산출물: 95MiB 단위 **7z 분할 볼륨**(`D3RO-Voice-<v>x64-portable.7z.001/.002`) — LZMA2로
|
||||
688MB → 162MiB까지 줄어 각 볼륨이 Cloudflare 한도 안에 들어간다.
|
||||
- 게시 위치: `.../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` (피드에도 게시됨).
|
||||
볼륨을 내려받아 SHA-256 검증 → 결합 → 7-Zip으로 해제 → `%LOCALAPPDATA%\Programs\D3RO Voice`
|
||||
에 설치 + 시작 메뉴 바로가기. 관리자 권한 불필요, 7-Zip 필요.
|
||||
```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`, 설치 스크립트)만 갱신 허용.
|
||||
- 파일명에 `-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)
|
||||
|
||||
- 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`
|
||||
존재 확인 → 시작 메뉴 바로가기 생성. (검증 후 테스트 설치/바로가기는 제거)
|
||||
- 재게시 시도 시 동일 볼륨은 "이미 동일한 파일" 로 건너뛰는 것 확인(불변 가드 동작).
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`; updater feed와 분리. 2026-09-18 게시 + 실제 설치 검증. |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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/set-forgejo-secrets.mjs`, `scripts/ci/verify-windows-release-artifact.ps1` | `[!]` 2026-09-18 measured: the Forgejo repo has **zero** Actions secrets (`GET /repos/yunchan/d3ro-voice/actions/secrets` → `[]`), so `v1.2.0` (run 49) and `v1.3.0` (run 51) both failed at the signing guard and **no installer or update 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-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). |
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -26,6 +26,9 @@
|
|||
"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",
|
||||
|
|
|
|||
239
scripts/ci/build-portable.mjs
Normal file
239
scripts/ci/build-portable.mjs
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
// 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 매니페스트, 커밋 대상)
|
||||
//
|
||||
// 사용:
|
||||
// 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, 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)보다 여유를 둔 볼륨 크기 */
|
||||
const VOLUME_SIZE = '95m'
|
||||
const MAX_VOLUME_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',
|
||||
)
|
||||
|
||||
console.log(
|
||||
[
|
||||
'[portable] 완료',
|
||||
` 볼륨 : ${volumeEntries.length}개 / 합계 ${(totalBytes / 1048576).toFixed(1)}MiB`,
|
||||
...volumeEntries.map(
|
||||
(entry) => ` ${entry.name} (${(entry.size / 1048576).toFixed(1)}MiB)`,
|
||||
),
|
||||
` 인덱스 : ${join(releaseDir, 'portable.json')}`,
|
||||
` scoop : ${join(root, 'bucket', 'd3ro-voice.json')}`,
|
||||
` 게시 : node scripts/ci/publish-portable-release.mjs`,
|
||||
].join('\n'),
|
||||
)
|
||||
153
scripts/ci/publish-portable-release.mjs
Normal file
153
scripts/ci/publish-portable-release.mjs
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
// 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' })
|
||||
}
|
||||
|
||||
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',
|
||||
'',
|
||||
' 수동 설치(7-Zip 필요):',
|
||||
` irm ${FEED}/portable-latest/install-d3ro-voice.ps1 | iex`,
|
||||
'',
|
||||
' 참고: 이 채널은 서명이 없어 자동 업데이트 피드를 갱신하지 않습니다.',
|
||||
].join('\n'),
|
||||
)
|
||||
146
scripts/install/install-d3ro-voice.ps1
Normal file
146
scripts/install/install-d3ro-voice.ps1
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
# scripts/local/install-d3ro-voice.ps1
|
||||
# 서명 없이 D3RO Voice를 설치하는 수동 설치 스크립트.
|
||||
#
|
||||
# 왜 스크립트인가: canonical feed는 Cloudflare 뒤에 있어 업로드 본문이 100MiB를 넘으면
|
||||
# 거부된다. 사이드카(faster-whisper)를 포함한 앱은 95MiB 단위 7z 볼으로 나뉘어 있고,
|
||||
# 이 스크립트가 볼륨을 이어 붙여 해제한다. Scoop을 쓰면 Scoop이 같은 일을 자동으로 한다.
|
||||
#
|
||||
# 사용:
|
||||
# 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
|
||||
#
|
||||
# 요구 사항: Windows 10/11 x64, 7-Zip(없으면 Scoop 사용을 권장).
|
||||
# 관리자 권한 불필요 — %LOCALAPPDATA%\Programs 아래에 설치한다.
|
||||
|
||||
[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'),
|
||||
[string]$SevenZipPath = '',
|
||||
[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
|
||||
Write-Step "버전 $version, 볼륨 $($index.volumeCount)개 (합계 $([math]::Round($index.totalSize / 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 ($volume in $index.volumes) {
|
||||
$dest = Join-Path $workDir $volume.name
|
||||
$url = "$FeedBase/$($volume.name)"
|
||||
Write-Step "내려받기: $($volume.name) ($([math]::Round($volume.size / 1MB, 1)) MB)"
|
||||
Invoke-WebRequest -Uri $url -OutFile $dest -UseBasicParsing
|
||||
|
||||
$hash = Get-Sha256 $dest
|
||||
if ($hash -ne $volume.sha256) {
|
||||
throw "해시가 일치하지 않습니다: $($volume.name)`n 기대: $($volume.sha256)`n 실제: $hash"
|
||||
}
|
||||
}
|
||||
Write-Step '모든 볼륨의 SHA-256 검증 완료'
|
||||
|
||||
# 3. 볼륨 이어 붙이기
|
||||
$archive = Join-Path $workDir "$($index.archive)"
|
||||
$stream = [System.IO.File]::Create($archive)
|
||||
try {
|
||||
foreach ($volume in $index.volumes) {
|
||||
$part = [System.IO.File]::OpenRead((Join-Path $workDir $volume.name))
|
||||
try { $part.CopyTo($stream) } finally { $part.Dispose() }
|
||||
}
|
||||
} finally {
|
||||
$stream.Dispose()
|
||||
}
|
||||
Write-Step "아카이브 결합 완료: $([math]::Round((Get-Item $archive).Length / 1MB, 1)) MB"
|
||||
|
||||
# 4. 해제 (7-Zip 필요; 없으면 안내)
|
||||
$sevenZipCandidates = @()
|
||||
foreach ($base in @($env:ProgramFiles, ${env:ProgramFiles(x86)})) {
|
||||
if ($base) { $sevenZipCandidates += (Join-Path $base '7-Zip\7z.exe') }
|
||||
}
|
||||
$sevenZip = $sevenZipCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1
|
||||
|
||||
if ($SevenZipPath) { $sevenZip = $SevenZipPath }
|
||||
|
||||
if (-not $sevenZip) {
|
||||
# Windows PowerShell 5.1 호환 (?. 연산자는 PowerShell 7 전용)
|
||||
$sevenZipCommand = Get-Command 7z -ErrorAction SilentlyContinue
|
||||
if ($sevenZipCommand) { $sevenZip = $sevenZipCommand.Source }
|
||||
}
|
||||
|
||||
if (-not $sevenZip) {
|
||||
throw @'
|
||||
7-Zip을 찾을 수 없습니다. 두 가지 방법이 있습니다.
|
||||
1) Scoop 사용(권장, 7-Zip 자동 준비):
|
||||
scoop bucket add d3ro https://git.chanpaca.net/yunchan/d3ro-voice.git
|
||||
scoop install d3ro/d3ro-voice
|
||||
2) 7-Zip 설치 후 이 스크립트를 다시 실행: https://www.7-zip.org/
|
||||
'@
|
||||
}
|
||||
|
||||
$extractDir = Join-Path $workDir 'extract'
|
||||
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
|
||||
|
||||
Write-Step '압축 해제 중 (수백 MB, 시간이 걸릴 수 있습니다)'
|
||||
& $sevenZip x $archive "-o$extractDir" -y | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) { throw "압축 해제 실패 (7-Zip exit $LASTEXITCODE)" }
|
||||
|
||||
Copy-Item -Path (Join-Path $extractDir '*') -Destination $InstallDir -Recurse -Force
|
||||
|
||||
# 5. 시작 메뉴 바로가기
|
||||
$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 ''
|
||||
Write-Host "실행: `"$exe`"" -ForegroundColor Green
|
||||
Loading…
Add table
Add a link
Reference in a new issue