Compare commits

..

No commits in common. "856e375f3e672172a11309f594b2f1148d476c76" and "5c11ee2fde767153cf1cd6e591660f85924c6a09" have entirely different histories.

6 changed files with 64 additions and 109 deletions

View file

@ -1,15 +1,15 @@
{
"version": "1.5.0",
"version": "1.3.5",
"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.5.0/D3RO-Voice-1.5.0-x64-portable.7z.001"
"https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice/portable-1.3.5/D3RO-Voice-1.3.5-x64-portable.7z.001"
],
"hash": [
"7e028887e1f2f9fc1204b53256e909fd3eda5a356ed86362c34b80424bf88c90"
"45ecac82d2665cd64dd4a6edcfed1fa7874dce7116608664e0ba795a1c1509e7"
]
}
},
@ -27,7 +27,7 @@
"architecture": {
"64bit": {
"url": [
"https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice/portable-$version/D3RO-Voice-1.5.0-x64-portable.7z.001"
"https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice/portable-$version/D3RO-Voice-1.3.5-x64-portable.7z.001"
]
}
}

View file

@ -6,7 +6,7 @@
| 항목 | 정본 | 현재 판정 |
|---|---|---|
| 제품 버전 | `release/product-version.json`: `1.5.0` | source SSOT 확정 |
| 제품 버전 | `release/product-version.json`: `1.3.0` | source SSOT 확정 |
| Android | versionCode `1030001` | production AAB 미생성 |
| iOS | build `1030001` | production archive 미검증 |
| Android upload key | alias `d3ro-upload-20260821`, cert SHA-256 `4F:AC:69:24:...:15:2B:54` | external PKCS12·user-only ACL·Credential Manager·private-key readback GREEN; CI secret·복구 백업·AAB signer 대조 대기 |
@ -16,10 +16,10 @@
| Firebase | Console `u/0`, `u/1` 모두 D3RO project 없음 | 사용자 승인 후 project·Android app 생성 필요 |
| AdMob | app `ca-app-pub-1039714767792854~6427959892`; banner `/9840591290`; rewarded `/2255790918` | SSOT 확정. `검토 필요`·`광고 게재 제한`·store 미연결·결제 프로필 미완료 |
| updater feed (canonical) | `https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice/latest` | Forgejo Generic Registry. GitLab project 1172은 legacy mirror |
| release notes | `CHANGELOG.md` `## [1.5.0]` + Play changelog `1050000.txt` (ko/en) | 태그 전 확정·검증 필수 |
| 직전 게시본 | Forgejo Generic Registry `1.5.0` (2026-09-23 게시, unsigned installer + runtime/portable) | `v1.2.0`·`v1.3.0` 파이프라인은 서명/사이드카 선행 조건에서 실패했고 `v1.4.0`은 게시되지 않았다. `1.3.2`·`1.3.7`·`1.5.0`은 무서명 로컬 게시 경로(GAP-REL-06)를 사용했다 |
| release notes | `CHANGELOG.md` `## [1.3.0]` + Play changelog `1030001.txt` (ko/en) | 태그 전 확정·검증 필수 |
| 직전 게시본 | Forgejo Release `v1.1.0` (2026-09-15 게시, unsigned installer) | `v1.2.0`·`v1.3.0` 파이프라인은 Forgejo 저장소 시크릿이 없어 실패 = 게시본 없음. 설치본이 있는 마지막 버전은 `1.1.0` |
live canonical feed(`git.chanpaca.net/.../d3ro-voice/latest`)의 `latest.yml`은 현재 `1.5.0`을 보고한다(2026-09-23 무서명 로컬 게시). `runtime-latest/runtime.json`과 `portable-latest/portable.json`도 `1.5.0`이다. 서명 인증서를 확보하면 이보다 높은 버전을 서명 게시해 대체한다.
live canonical feed(`git.chanpaca.net/.../d3ro-voice/latest`)의 `latest.yml`은 현재 `1.1.0`을 보고한다. 시크릿을 채우고 `v1.3.0` 파이프라인이 GREEN이 되면 그 값이 `1.3.0`으로 올라가고, 그때부터 기존 설치본이 자동 업데이트를 받는다.
## desktop 릴리스 파이프라인
@ -110,22 +110,6 @@ https://gitlab.twentyoz.kr:8443/api/v4/projects/1172/packages/generic/d3ro-voice
- **회수(rollback)**: `stagingPercentage`를 낮추거나 `killSwitch`를 켠다. 이미 배포된 버전은 되돌리지 않고 더 높은 patch로 forward-fix한다.
## `1.5.0` 릴리스 기록 (2026-09-23)
서명 인증서가 없는 상태라 CI 서명 파이프라인 대신 **무서명 로컬 게시**로 내보냈다.
1. `release/product-version.json`을 `1.5.0`(android/iOS `1050000`, 2026-09-23)으로 올리고 `npm run version:sync` → `npm run version:check` GREEN.
2. `CHANGELOG.md` `## [1.5.0] - 2026-09-23` 확정. `site/src/release.ts`와 `apps/web/src/lib/desktop-release.ts`는 `sync-version`이 자동 반영.
3. 게이트 GREEN: `version:check`, `release:metadata`, `security:secrets`, `check:design`, `lint`, `typecheck`, 데스크톱 빌드, `check:desktop-renderer`.
4. 커밋 후 `npm run release:tag` → annotated `v1.5.0` → `git push chanpaca main` + `git push chanpaca v1.5.0` (legacy mirror `origin`에도 함께).
5. 런타임을 먼저 게시: `npm run sidecar:build`(UIA 포함) → `npm run release:portable:build` → `npm run release:portable`. `runtime-latest/runtime.json`과 `portable-latest/portable.json`이 `1.5.0`을 보고하고, 로컬 해시와 원격 sha256이 일치함을 확인했다.
6. 업데이터 게시: `npm run release:updater -- --build --ack-unsigned`. 설치본 `D3RO-Voice-Setup-1.5.0-x64.exe` = 91.2MiB(95,612,108 bytes), `latest.yml` 버전 `1.5.0`, sha512가 로컬과 일치하며 canonical feed에서 익명 206을 반환한다.
7. 사이트: CI `deploy` 워크플로는 `CF_API_TOKEN` 시크릿이 없어 여전히 실패한다(GAP-REL-09b). 대신 인증된 로컬 wrangler로 `npm run build --prefix site` → release-identity 작성 → `npx wrangler pages deploy site/dist --project-name d3ro --branch main`을 실행했고, `https://d3ro.chanpaca.net/release-identity.json`이 commit `5c11ee2`, version `1.5.0`을 보고한다. 라이브 번들이 `D3RO-Voice-Setup-1.5.0-x64.exe`를 계산한다.
8. 게시 중 발견: `publish-portable-release.mjs`의 "동일 파일 건너뛰기"가 1MiB 초과 파일을 크기만으로 비교해, 크기가 우연히 같은 `runtime-latest` 볼륨 일부를 낡은 바이트로 남겼다. 파일 목록 API의 sha256으로 정확히 비교하고, 내용이 다르면 별칭 버전 전체를 지우고 다시 올리도록 수정했다(GAP-REL-11).
9. **남은 게이트**: 실제 설치본에서 자동 업데이트 E2E, Electron GUI에서 제안 오버레이·수락 삽입·비밀번호 차단 검증(GAP-INPUT-07), 모바일 production은 별개.
## `1.3.0` 릴리스 절차 (canonical = Forgejo Actions)
**선행 조건 — Forgejo 저장소 시크릿.** `.forgejo/workflows/release.yml`은 아래 4개가

View file

@ -2,7 +2,7 @@
> Status: ACTIVE
> Last full audit: 2026-09-13
> Last update: 2026-09-23 — **v1.5.0 릴리스.** CHANGELOG `[1.5.0]` 을 확정하고 버전 SSOT를 1.5.0(android/iOS 1050000)으로 올렸으며, `site/src/release.ts` 와 `apps/web/src/lib/desktop-release.ts` 다운로드 링크를 1.5.0으로 동기화했다. 이 릴리스는 입력 인텔리전스(`INPUT-01`~`INPUT-18`, 전부 데스크톱 `[~]`), 커스텀 인스트럭션 수정(AI-04/05), `LocalLLMService` 요청별 취소·상한을 포함한다. 인증서가 없어 무서명 업데이터 게시 예외(GAP-REL-06)를 유지한다. 게시 결과: canonical feed `latest.yml`=1.5.0(설치본 91.2MiB / 95,612,108 bytes, sha512 일치, 익명 206), `runtime-latest`/`portable-latest`=1.5.0(원격 sha256 검증), 사이트 `https://d3ro.chanpaca.net/release-identity.json`=commit `5c11ee2`/1.5.0. 게시 중 portable 별칭이 낡은 바이트와 새 바이트로 갈라지는 버그(GAP-REL-12)를 발견해 게시 스크립트를 수정했다.
> Last update: 2026-09-23 — **v1.5.0 릴리스.** CHANGELOG `[1.5.0]` 을 확정하고 버전 SSOT를 1.5.0(android/iOS 1050000)으로 올렸으며, `site/src/release.ts` 와 `apps/web/src/lib/desktop-release.ts` 다운로드 링크를 1.5.0으로 동기화했다. 이 릴리스는 입력 인텔리전스(`INPUT-01`~`INPUT-18`, 전부 데스크톱 `[~]`), 커스텀 인스트럭션 수정(AI-04/05), `LocalLLMService` 요청별 취소·상한을 포함한다. 인증서가 없어 무서명 업데이터 게시 예외(GAP-REL-06)를 유지한다.
>
> Previous update: 2026-09-22 — **Gemma/Ollama 폭주 방어 계약을 기록했다.** 19:14:12 부팅 워밍업이 `keep_alive: 30m`으로 `gemma4:e4b`를 19:44:12까지 VRAM 3,226,342,521 bytes / context 4096으로 강제 상주시킨 것이 관측됐으며, 같은 시점 Windows GPU Engine PID 표본에는 Ollama의 활성 compute가 없었다. 즉 당시 상태는 무한 추론이 아니라 강제 residency였다. 19:11:17~19:11:58의 자동 제안 연속 생성은 기존 900 ms·12/min·5 candidates·128 tokens·한 글자 재생성 정책이 허용한 burst였다. 현재 구현 계약은 부팅 warmup 제거, `keep_alive: 2m`, 제안 600 ms / 최소 5 s 간격 / 기본 6회·hard max 12회 per min / 3 candidates / 64 tokens / 12-char growth / 8 s timeout, 그리고 요청별 취소·상한·종료 정리다. 독립 표적 검증은 6 test files / 69 tests passed / 0 failed, 변경 코드·테스트 ESLint와 `git diff --check`도 exit 0이다. Raw Ollama에서는 cold bounded 요청이 client hard timeout 15.044 s에 취소된 뒤 `/api/ps`가 비었고 `/api/version`은 80 ms에 회복했다. 명시 warmup은 HTTP 200 / 16.639 s, 후속 warm 요청은 body `options.num_predict=1`, `keep_alive='2m'`로 553 ms HTTP 200 / `done:true` / `eval_count:1` / `done_reason:length`였고 `/api/ps` expiry는 약 119.9 s였다. **19:48:59 +09:00에는 새 generate/unload/kill/retry 없이 충분히 지난 뒤 한 번의 `/api/ps`가 HTTP 200 / 45.8 ms / `{models:[]}`였고 `/api/version`은 HTTP 200 / 7.3 ms / `0.32.13`이었다.** 이는 raw API 수준의 expiry 뒤 unload 확인일 뿐 앱 재시작·GUI·실제 타이핑 증거는 아니므로 상태는 `[~]`로 유지한다 (`11` GAP-LLM-04, GAP-INPUT-06).
>

View file

@ -22,15 +22,14 @@ Legend: `[ ]` open · `[~]` in progress · `[!]` blocked externally · `[x]` res
| ID | Area | Gap | Evidence | Suggested next step |
|---|---|---|---|---|
| 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. 2026-09-19: `1.3.7` (overlay fix) is published to the canonical updater feed (`latest.yml` = 1.3.7, 90.6MiB); the CI signing gate that blocks tag-driven publication is still unresolved, so this went out through the local updater path (GAP-REL-06). 2026-09-23: `1.5.0` published the same way (updater feed `latest.yml` = 1.5.0, installer 91.2MiB / 95,612,108 bytes, sha512 matches the local build and the feed serves it anonymously); the runtime and portable channels were republished first. |
| 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. 2026-09-19: `1.3.7` (overlay fix) is published to the canonical updater feed (`latest.yml` = 1.3.7, 90.6MiB); the CI signing gate that blocks tag-driven publication is still unresolved, so this went out through the local updater path (GAP-REL-06). |
| GAP-REL-08 | Release | 다운로드 센터가 **존재하지 않는 설치 파일**을 가리켰다. `apps/web/src/lib/desktop-release.ts`와 `site/src/release.ts`의 `DESKTOP_VERSION`이 `1.2.0`에 멈춰 있어 설치 URL이 `D3RO-Voice-Setup-1.2.0-x64.exe`였고, 그 경로는 피드에서 404다(실측: 1.2.0=404, 1.3.7=206). `version:sync`가 이 두 표면을 덮지 않아 계속 어긋났다. | `scripts/ci/sync-version.mjs`, `apps/web/src/lib/desktop-release.ts`, `site/src/release.ts` | `[x]` 2026-09-19: 두 다운로드 계약 파일을 `sync-version.mjs` 대상에 추가해 버전·릴리스일이 SSOT에서 자동 반영되도록 하고, 현재 값(1.3.7 / 2026-09-19)으로 정정했다. `version:check`·typecheck·site 빌드 GREEN. |
| GAP-REL-09 | Release | 랜딩 사이트가 **재배포되지 않는다**. `deploy` 워크플로가 main push마다 실패한다. 실측 원인(run#66 로그): `site/src/sections/Hero.tsx`가 타이머 ref를 `NodeJS.Timeout`으로 타이핑해 `@types/node` 네임스페이스가 필요했고, 배포 잡은 `npm ci --prefix site`만 하므로 조상 `node_modules`의 hoisted 타입이 없어 `tsc -b`가 `TS2503: Cannot find namespace 'NodeJS'`로 실패한다. 그래서 `https://d3ro.chanpaca.net/release-identity.json`이 404다(공개 버전 검증 불가). | `.forgejo/workflows/deploy-site.yml`, `site/src/sections/Hero.tsx` | `[x]` 2026-09-19: ref를 `ReturnType<typeof setTimeout>`으로 바꿔 hoisted 타입 의존을 제거했다(격리 `--typeRoots`로 CI 조건 재현 → 수정 전 TS2503, 수정 후 clean). 같은 수정을 push하자 `deploy` run#67이 사이트 빌드를 통과해 `dist/`를 만들었고, 실패는 다음 단계(Cloudflare)로 이동했다. |
| GAP-REL-09b | Release | `d3ro.chanpaca.net`이 404였던 직접 원인: 이 Cloudflare 계정에 Pages 프로젝트 `d3ro`/`d3ro-voice`가 **존재하지 않아** 커스텀 도메인 바인딩이 없었다(빈 본문 404, `cf-ray`만 반환). Pages 커스텀 도메인은 존 DNS CNAME(`d3ro → d3ro.pages.dev`)을 요구하는데 기존 `d3ro` 레코드가 남아 있어 `CNAME record not set`으로 pending에 머물렀고, 로컬 wrangler 자격증명에는 DNS 스코프가 없다(403 Authentication error). `deploy-site.yml`은 `CF_API_TOKEN` 시크릿이 없어 마지막 게시 단계에서도 `exit 1`이다. | `server/cloudflare-site-bridge/`, `.forgejo/workflows/deploy-site.yml`, `docs/map/02-infrastructure.md` | `[x]` 2026-09-19: Pages 프로젝트 `d3ro` 생성 + `site/dist` production 배포(`d3ro.pages.dev` 200, `release-identity.json` = commit `2407f5a` / 1.3.7) + 커스텀 도메인 연결. DNS 없이 도메인을 살리기 위해 Workers 라우트 브리지(`server/cloudflare-site-bridge`, `d3ro.chanpaca.net/*` → Pages 프록시, `npx wrangler deploy`)를 배포 → 라이브 확인: `/`·`/privacy/`·`/terms/`·`/delete-account/` 200, 라이브 번들이 설치 파일명을 `1.3.7`로 계산, `/download.html` → `/#download`. 남은 정리 2건: (1) 대시보드에 CNAME을 추가한 뒤 브리지 워커 삭제, (2) CI 자동 게시를 위해 `CF_API_TOKEN`(Pages/Workers Edit) + `CF_ACCOUNT_ID`=`8e83cc130e7329c160cf2b88d6b4c20a`를 Forgejo 시크릿에 등록. **2026-09-23:** CI `deploy`(deploy-site.yml)는 `CF_API_TOKEN` 부재로 run#71까지 여전히 실패한다. 로컬 인증 wrangler(pages:write)로 `npm run build --prefix site` → release-identity 작성 → `npx wrangler pages deploy site/dist --project-name d3ro --branch main`을 수동 배포했고, 라이브 `https://d3ro.chanpaca.net/release-identity.json`이 commit `5c11ee2` / version `1.5.0`을, 라이브 번들이 설치 파일명 `D3RO-Voice-Setup-1.5.0-x64.exe`를 보고한다. 자동 게시에는 여전히 시크릿 등록이 필요하다. |
| GAP-REL-09b | Release | `d3ro.chanpaca.net`이 404였던 직접 원인: 이 Cloudflare 계정에 Pages 프로젝트 `d3ro`/`d3ro-voice`가 **존재하지 않아** 커스텀 도메인 바인딩이 없었다(빈 본문 404, `cf-ray`만 반환). Pages 커스텀 도메인은 존 DNS CNAME(`d3ro → d3ro.pages.dev`)을 요구하는데 기존 `d3ro` 레코드가 남아 있어 `CNAME record not set`으로 pending에 머물렀고, 로컬 wrangler 자격증명에는 DNS 스코프가 없다(403 Authentication error). `deploy-site.yml`은 `CF_API_TOKEN` 시크릿이 없어 마지막 게시 단계에서도 `exit 1`이다. | `server/cloudflare-site-bridge/`, `.forgejo/workflows/deploy-site.yml`, `docs/map/02-infrastructure.md` | `[x]` 2026-09-19: Pages 프로젝트 `d3ro` 생성 + `site/dist` production 배포(`d3ro.pages.dev` 200, `release-identity.json` = commit `2407f5a` / 1.3.7) + 커스텀 도메인 연결. DNS 없이 도메인을 살리기 위해 Workers 라우트 브리지(`server/cloudflare-site-bridge`, `d3ro.chanpaca.net/*` → Pages 프록시, `npx wrangler deploy`)를 배포 → 라이브 확인: `/`·`/privacy/`·`/terms/`·`/delete-account/` 200, 라이브 번들이 설치 파일명을 `1.3.7`로 계산, `/download.html` → `/#download`. 남은 정리 2건: (1) 대시보드에 CNAME을 추가한 뒤 브리지 워커 삭제, (2) CI 자동 게시를 위해 `CF_API_TOKEN`(Pages/Workers Edit) + `CF_ACCOUNT_ID`=`8e83cc130e7329c160cf2b88d6b4c20a`를 Forgejo 시크릿에 등록. |
| GAP-REL-10 | Release | `release-windows`(태그 파이프라인)는 서명 가드에 도달하기 **전에** sidecar 단계에서 죽는다. 이 러너 컨텍스트에서는 `sidecar:setup`이 Python 3.11+를 찾지 못한다(`Python 3.11+ 를 찾을 수 없습니다`) → `sidecar:build` → `verify-sidecar-bundle.mjs` 연쇄 실패(실측: run#65 `v1.3.7`, run#61 `v1.3.6`). 같은 러너의 portable 잡은 `py -3.11 → Python 3.11.9`를 찾아 사이드카 빌드에 성공하므로, 워크플로/컨테이너 간 PATH 차이다. | `.forgejo/workflows/release.yml`, `apps/desktop/scripts/setup-sidecar.mjs` | `[!]` 2026-09-19: 러너에 Python 3.11+(`py` 런처 포함)를 보장하거나 워크플로에 `actions/setup-python` 단계를 추가한다. 그 전까지 서명 게시는 불가능하다(GAP-REL-02와 별개 선행 차단). |
| GAP-REL-11 | Release | portable 워크플로의 마지막 `actions/upload-artifact@v4` 단계가 Forgejo 러너에서 `GHESNotSupportedError`로 실패한다(증거 보존만 실패, 게시는 성공). | `.forgejo/workflows/portable.yml` | `[x]` 2026-09-19: `v1.3.7` portable 게시는 run#64에서 성공(7z 단일 볼륨 83.7MB + zip 2부, `portable-latest/portable.json`이 1.3.7 보고). 남은 조치: upload-artifact 단계를 제거하거나 v3/다른 보존 방식으로 바꿔 워크플로를 GREEN으로 만든다. |
| GAP-REL-12 | Release | Forgejo generic registry는 파일 단위 덮어쓰기를 거부(HTTP 409)하는데, portable 게시 스크립트의 "동일 파일 건너뛰기"가 1MiB 초과 파일을 **크기만** 비교했다. 그래서 새 runtime의 sidecar 볼륨 `.001`이 이전 볼륨과 정확히 같은 94,371,840 bytes였고, 내용이 다른데도 "동일"로 오판해 `runtime-latest`가 낡은 `.001` + 새 `.002`로 갈라졌다(다운로드 시 손상). | `scripts/ci/publish-portable-release.mjs` | `[x]` 2026-09-23: 파일 목록 API(`GET /api/v1/packages/{owner}/generic/{name}/{version}/files`)의 sha256으로 정확히 비교하고, 내용이 다른 파일이 하나라도 있으면 해당 별칭 버전 전체를 삭제 후 재업로드하도록 수정했다. 이미 발생한 `runtime-latest`는 버전을 지우고 재게시해 4개 파일 모두 로컬 해시와 일치함을 확인했다. |
| 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). **2026-09-19 정정**: CI 서명 게이트는 여전히 막혀 있지만, updater feed에는 `1.3.2`~`1.3.7`이 로컬 `release:updater` 경로로 게시되어 있다(GAP-REL-06). **2026-09-23 정정**: `1.4.0`은 게시되지 않았고(GAP-REL-06), `1.5.0`도 로컬 무서명 경로로 게시했다. CI 서명 게이트는 여전히 `WIN_CSC_*` 부재로 막혀 있다(GAP-REL-10의 Python 선행 문제와 별개). |
| GAP-REL-06 | Release | 서명 인증서가 없어 stable(`latest`) 채널에 **무서명** 설치본을 게시했다. electron-updater는 `app-update.yml`에 `publisherName`이 없으면 서명 검증을 건너뛰므로 설치는 동작하지만, SmartScreen 평판은 버전마다 0부터 시작한다. | `scripts/ci/publish-updater-release.mjs`, `release/update-policy.json`, `.forgejo/workflows/release.yml` | `[!]` 2026-09-18: `1.3.2`를 `--ack-unsigned`(명시적 승인 플래그)로 게시. **2026-09-19: `1.3.7`도 같은 경로로 게시**(`npm run release:updater -- --ack-unsigned`, 설치본 90.6MiB, `latest.yml`=1.3.7, 설치본 sha512가 피드 메타데이터와 일치). 인증서 확보 시 더 높은 버전으로 서명 게시하여 대체하고, 이 예외를 제거한다. **2026-09-23: `1.5.0`도 같은 경로로 게시**(`npm run release:updater -- --build --ack-unsigned`, 설치본 91.2MiB, `latest.yml`=1.5.0, sha512 검증). |
| 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). **2026-09-19 정정**: CI 서명 게이트는 여전히 막혀 있지만, updater feed에는 `1.3.2`~`1.3.7`이 로컬 `release:updater` 경로로 게시되어 있다(GAP-REL-06). |
| GAP-REL-06 | Release | 서명 인증서가 없어 stable(`latest`) 채널에 **무서명** 설치본을 게시했다. electron-updater는 `app-update.yml`에 `publisherName`이 없으면 서명 검증을 건너뛰므로 설치는 동작하지만, SmartScreen 평판은 버전마다 0부터 시작한다. | `scripts/ci/publish-updater-release.mjs`, `release/update-policy.json`, `.forgejo/workflows/release.yml` | `[!]` 2026-09-18: `1.3.2`를 `--ack-unsigned`(명시적 승인 플래그)로 게시. **2026-09-19: `1.3.7`도 같은 경로로 게시**(`npm run release:updater -- --ack-unsigned`, 설치본 90.6MiB, `latest.yml`=1.3.7, 설치본 sha512가 피드 메타데이터와 일치). 인증서 확보 시 더 높은 버전으로 서명 게시하여 대체하고, 이 예외를 제거한다. |
| GAP-REL-07 | Release | 패키징된 `better-sqlite3`가 호스트 Node ABI여서 `1.3.2` 설치본이 시작 즉시 죽었다(NODE_MODULE_VERSION 131 vs 130). 원인: 로컬 `npm install`이 네이티브 모듈을 Node용으로 재빌드했고 패키징이 재빌드를 건너었다. | `scripts/ci/verify-native-abi.mjs`, `scripts/ci/fix-native-abi.mjs`, `.gitlab-ci.yml`/`.forgejo`/`.github` 패키징 단계 | `[x]` 2026-09-18: 패키징 후 Electron ABI를 검증하고, 검증된 트리에서만 설치본을 생성(`--prepackaged`)한다. `1.3.3`은 설치본에서 추출한 바이너리로 재검증 GREEN. |
| 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. |

View file

@ -1,15 +1,5 @@
# D3RO-VOICE 프로젝트 현황
## v1.5.0 릴리스 완료 (2026-09-23) 🚀
- 버전 SSOT `1.5.0`(android/iOS `1050000`, releaseDate 2026-09-23). annotated 태그 `v1.5.0` 생성 후 `chanpaca`(canonical)와 `origin`(legacy mirror) main+tag push 완료.
- 게이트: `version:check` GREEN, `release:metadata` GREEN, `security:secrets` GREEN, `check:design` 0 violations, `lint` clean, `typecheck` clean, 데스크톱 빌드/`check:desktop-renderer`(7 pages) GREEN. 테스트는 native ABI 미스매치 구성에서 `366 failed | 1099 passed (1465)` — 실패 수는 기존 베이스라인과 동일(365 ABI + 1 stale sidecar 메시지, GAP-QA-02)로 신규 실패 0.
- 인증서가 없어 CI 서명 파이프라인 대신 무서명 로컬 게시: `sidecar:build`(UIA/`uiautomation`/`comtypes` 포함) → `release:portable:build` → `release:portable`(runtime-latest + portable-latest) → `release:updater --build --ack-unsigned`.
- 검증된 라이브 상태: canonical feed `latest.yml`=1.5.0(설치본 `D3RO-Voice-Setup-1.5.0-x64.exe` 95,612,108 bytes, sha512 일치, 익명 206), `runtime-latest/runtime.json`·`portable-latest/portable.json`=1.5.0(원격 sha256 = 로컬), 사이트 `d3ro.chanpaca.net/release-identity.json`=commit `5c11ee2`/1.5.0.
- 게시 중 portable 별칭(`runtime-latest` 등)의 "동일 파일 건너뛰기"가 1MiB 초과 파일을 크기만 비교해 낡은 `.001`과 새 `.002`가 섞이는 사고를 발견 → `publish-portable-release.mjs`를 파일 목록 API sha256 비교 + 별칭 버전 원자적 재게시로 수정(GAP-REL-12).
- 사이트 CI(`deploy-site.yml`)는 `CF_API_TOKEN` 시크릿 부재로 여전히 실패(GAP-REL-09b). 이번에는 인증된 로컬 wrangler로 수동 배포했다.
- 남은 검증: 실제 설치본 자동 업데이트 E2E, Electron GUI에서 제안 오버레이/수락 삽입/비밀번호 차단(GAP-INPUT-07).
## Local Flow Intelligence 10-pack — 문서/자동 증거 handoff (2026-09-22)
- 데스크톱 기존 InputTelemetry/Suggestion/UI 확장으로 Flow Radar, Edit Friction, App DNA, App Quality, Privacy Receipt, Smart Exclusion, Why This Suggestion, Memory Decay, Instant Recall, Shortcut Safety Audit을 구현했다. raw key events/key codes/content stream은 저장하지 않지만, 동의한 학습 텍스트는 `typing_samples`/`personal_phrases`에 별도 정책으로 남을 수 있다. `personal_phrases`는 나이 기반 자동 만료가 없고 개별 삭제·전체 삭제·동의 철회로 제거된다.

View file

@ -124,6 +124,7 @@ if (existsSync(runtimeIndexPath)) {
}
const authorization = forgejoAuthorization()
const bases = [`${FEED}/portable-${version}`, `${FEED}/portable-latest`]
async function forgejoFetch(url, init = {}) {
return fetch(url, {
@ -132,33 +133,53 @@ async function forgejoFetch(url, init = {}) {
})
}
const PACKAGE_API = 'https://git.chanpaca.net/api/v1/packages/yunchan/generic/d3ro-voice'
/**
* Forgejo generic registry는 HEAD를 405로 거부하고 파일 해시도 헤더로 주지 않는다.
* 패키지 버전의 파일 목록 API는 sha256을 주므로, 큰 볼륨을 내려받지 않고도 원격
* 바이트가 로컬과 같은지 정확히 판단할 수 있다.
* (Range GET의 크기만 비교하면 90MiB 볼륨에서 크기가 우연히 같을 때 다른 바이트를
* "동일"로 오판해 별칭이 일부만 새 바이트로 갱신된다 — 실측 사고.)
* 원격 파일이 로컬 바이트와 같은지 판단한다.
* Forgejo generic registry는 HEAD를 405로 거부하고 해시도 주지 않으므로,
* Range GET으로 크기를 본 뒤 1MiB 이하는 실제 바이트까지 비교한다.
* (크기만 비교하면 버전 문자열만 바뀐 latest.yml 같은 메타데이터를 놓친다 — 실측 사고.)
*/
async function remoteFileHashes(versionPath) {
const response = await forgejoFetch(`${PACKAGE_API}/${encodeURIComponent(versionPath)}/files`)
if (!response.ok) return new Map()
const files = await response.json()
return new Map(files.map((file) => [file.name, file.sha256]))
async function remoteIsIdentical(url, body, fetchImpl) {
const probe = await fetchImpl(url, { headers: { Range: 'bytes=0-0' } }).catch(() => null)
if (!probe?.ok) return false
const contentRange = probe.headers.get('content-range')
const remoteSize = contentRange ? Number(contentRange.split('/')[1]) : NaN
if (!Number.isFinite(remoteSize) || remoteSize !== body.length) return false
if (body.length > 1024 * 1024) return true
const full = await fetchImpl(url).catch(() => null)
if (!full?.ok) return false
const remoteBytes = Buffer.from(await full.arrayBuffer())
return remoteBytes.length === body.length && remoteBytes.equals(body)
}
async function deletePackageVersion(versionPath) {
const response = await forgejoFetch(`${PACKAGE_API}/${encodeURIComponent(versionPath)}`, {
method: 'DELETE',
})
if (!response.ok && response.status !== 404) {
console.error(`[portable] 버전 삭제 실패 (HTTP ${response.status}): ${versionPath}`)
async function upload(url, body, contentType) {
if (check) {
console.log(`[portable] (check) PUT ${url} (${body.length} bytes)`)
return
}
// 메타데이터는 크기가 아니라 내용까지 비교해야 한다.
// 크기만 보면 버전 문자열만 바뀐 latest.yml/json을 "동일"로 오판한다 — 실측 사고.
if (await remoteIsIdentical(url, body, forgejoFetch)) {
console.log(`[portable] 이미 동일한 파일이 있습니다(건너): ${url}`)
return
}
const existing = await forgejoFetch(url, { headers: { Range: 'bytes=0-0' } }).catch(() => null)
if (existing?.ok) {
// 볼륨/부품은 불변 자산이다 — 같은 버전 경로에 다른 바이트가 있으면 덮어쓰지 않고 중단한다.
const isImmutableAsset =
url.includes(`/portable-${version}/`) && (url.includes('.7z.') || url.includes('.zip.'))
if (isImmutableAsset) {
console.error(
`[portable] ${version} 자산에 다른 바이트가 이미 있습니다: ${url}\n` +
' 이미 게시된 버전은 어쓰지 않습니다(불변). 새 버전으로 게시하세요.',
)
process.exit(1)
}
// 메타데이터와 latest 별칭은 최신을 반영해야 하므로 지우고 쓴다.
await forgejoFetch(url, { method: 'DELETE' }).catch(() => null)
}
async function put(url, body, contentType) {
const response = await forgejoFetch(url, {
method: 'PUT',
headers: { 'Content-Type': contentType },
@ -174,57 +195,18 @@ async function put(url, body, contentType) {
console.log(`[portable] uploaded ${url}`)
}
/**
* 한 버전 경로를 원자적으로 게시한다.
* Forgejo generic registry는 파일 단위 덮어쓰기를 거부(409)하므로, 내용이 다른 파일이
* 하나라도 있으면 버전 전체를 지우고 모든 파일을 다시 올린다. 이렇게 해야
* `runtime-latest`/`portable-latest` 같은 별칭이 낡은 바이트와 새 바이트가 섞이지 않는다.
*/
async function publishBase(base, basePayloads) {
const versionPath = base.split('/').pop()
const items = basePayloads.map((payload) => ({
...payload,
sha256: createHash('sha256').update(payload.bytes).digest('hex'),
}))
if (check) {
for (const item of items) {
console.log(`[portable] (check) PUT ${base}/${item.name} (${item.bytes.length} bytes)`)
}
return
}
const remoteHashes = await remoteFileHashes(versionPath)
const differing = items.filter((item) => remoteHashes.get(item.name) !== item.sha256)
if (differing.length === 0) {
console.log(`[portable] 변경 없음(건너): ${base}`)
return
}
// 볼륨/부품은 불변 자산이다 — 같은 버전 경로에 다른 바이트가 있으면 덮어쓰지 않고 중단한다.
const isImmutableAsset =
base.includes(`/portable-${version}/`) &&
differing.some((item) => item.name.includes('.7z.') || item.name.includes('.zip.'))
if (isImmutableAsset) {
console.error(
`[portable] ${version} 자산에 다른 바이트가 이미 있습니다: ${base}\n` +
' 이미 게시된 버전은 덮어쓰지 않습니다(불변). 새 버전으로 게시하세요.',
)
process.exit(1)
}
if (remoteHashes.size > 0) {
await deletePackageVersion(versionPath)
}
for (const item of items) {
await put(`${base}/${encodeURIComponent(item.name)}`, item.bytes, item.contentType)
const runtimeBases = [`${FEED}/runtime-${version}`, `${FEED}/runtime-latest`]
for (const base of runtimeBases) {
for (const payload of runtimePayloads) {
await upload(`${base}/${encodeURIComponent(payload.name)}`, payload.bytes, payload.contentType)
}
}
await publishBase(`${FEED}/runtime-${version}`, runtimePayloads)
await publishBase(`${FEED}/runtime-latest`, runtimePayloads)
await publishBase(`${FEED}/portable-${version}`, payloads)
await publishBase(`${FEED}/portable-latest`, payloads)
for (const base of bases) {
for (const payload of payloads) {
await upload(`${base}/${encodeURIComponent(payload.name)}`, payload.bytes, payload.contentType)
}
}
console.log(
[