docs: record the 1.1.0 release and add the infrastructure map
Some checks failed
deploy-site / deploy (push) Failing after 40s
Some checks failed
deploy-site / deploy (push) Failing after 40s
Release notes for 1.1.0 were split between an Unreleased section and the version section, so the published notes would have omitted the update-feed and desktop changes. Everything shipping in this version now sits under one `## [1.1.0]` heading. `docs/map/` becomes the entry point for what infrastructure exists per platform and how far each feature is developed, with a documented update protocol so feature work and this map do not drift apart again. The release guide now states that installer binaries live in the update feed rather than the repository.
This commit is contained in:
parent
c2db1b2176
commit
c3ddd36c6f
29 changed files with 3207 additions and 23 deletions
126
docs/deployment/push-transport-without-firebase.md
Normal file
126
docs/deployment/push-transport-without-firebase.md
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
# Push transport without Firebase — analysis
|
||||
|
||||
> Status: decision note (2026-09-13)
|
||||
> Scope: `send-push` Edge Function, `server/supabase/migrations` push outbox, `apps/mobile-rn` notification stack
|
||||
> Question: we already pay for Supabase. Can it replace Firebase for notifications?
|
||||
|
||||
---
|
||||
|
||||
## 0. TL;DR
|
||||
|
||||
- **Supabase already is the push backend.** Tokens, devices, durable outbox, leases, retries, and delivery audit all live in Supabase (`push_tokens`, `devices`, `push_deliveries`, `push_dispatch_attempts`, `send-push`). Firebase is only the **last hop** to the device.
|
||||
- **Web and iOS are now Firebase-free on the server side.** Web Push (VAPID + RFC 8291 aes128gcm) and Apple APNs (`.p8` token) transports are implemented in `send-push` (`_shared/webpush.ts`, `_shared/apns.ts`, 10 unit tests). Client registration for those providers is the remaining work.
|
||||
- **Android cannot drop Firebase without changing transport.** Google Play Services only delivers push through FCM, and the FCM registration token is minted by the Firebase Messaging SDK, which requires `google-services.json` (a Firebase project). Supabase cannot substitute for that hop.
|
||||
- **The scheduled drain was missing and is now added.** Nothing in the repo triggered `send-push?mode=drain`, so transactionally enqueued notifications never left the outbox. A Cloudflare Cron Trigger now drains it every minute.
|
||||
- **Firebase-free Android is possible but is a different product decision:** UnifiedPush + a self-hosted distributor (e.g. ntfy). Third-party push services (OneSignal, Pusher) do **not** remove the Firebase dependency; they wrap FCM.
|
||||
|
||||
---
|
||||
|
||||
## 1. What exists today
|
||||
|
||||
| Layer | Owned by | Notes |
|
||||
|---|---|---|
|
||||
| Token + device registry | Supabase | `push_tokens`, `devices`; writes via `register_push_registration` RPC; RLS denies direct access |
|
||||
| Durable outbox + retry/lease | Supabase | `push_deliveries`, `push_dispatch_attempts`, `claim_due_push_dispatches`, `finalize_push_delivery` |
|
||||
| Dispatch orchestration | Supabase Edge `send-push` | Auth, event resolution, batching, stale-token handling |
|
||||
| **Transport** | **FCM** | `_shared/push-contract.ts` → `https://fcm.googleapis.com/v1/projects/<id>/messages:send`, OAuth2 via `FCM_SERVICE_ACCOUNT_JSON` |
|
||||
| Android client | Firebase Messaging SDK | `com.google.firebase:firebase-messaging`, `D3ROFirebaseMessagingService`, `google-services.json`; `FIREBASE_CONFIGURED` build flag |
|
||||
|
||||
Provider values accepted but unimplemented (`send-push/index.ts`):
|
||||
```
|
||||
const unsupported = deliveries.filter((delivery) => delivery.provider !== 'fcm')
|
||||
→ finalizeDelivery(..., 'permanent_failure', 'push_provider_not_supported')
|
||||
```
|
||||
|
||||
So: the only Firebase-specific pieces are (a) the FCM HTTP v1 send + service account, and (b) the Android client token source.
|
||||
|
||||
---
|
||||
|
||||
## 2. Platform-by-platform answer
|
||||
|
||||
### Desktop — no change needed
|
||||
In-app events use IPC/EventEmitter and Supabase Realtime. No push transport, no Firebase.
|
||||
|
||||
### Web — Firebase-free, implementable now
|
||||
- Standard **Web Push** with **VAPID** keys. The browser Push API + service worker handles delivery; the server signs with a VAPID private key and POSTs to the subscription endpoint.
|
||||
- Supabase plan: store `push_subscriptions` (endpoint + keys per browser), send from `send-push` with a VAPID JWT (ES256).
|
||||
- No Firebase, no Google account. APNs not involved.
|
||||
|
||||
### iOS — Firebase-free, implementable now
|
||||
- **APNs** directly: `.p8` key + Key ID + Team ID → JWT (ES256) → `https://api.push.apple.com/3/device/<token>`.
|
||||
- Needs an Apple Developer membership (already needed for App Store), **not** a Firebase project.
|
||||
- Supabase Edge Function can hold the `.p8` as a secret and sign the JWT with WebCrypto.
|
||||
|
||||
### Android — Firebase-free only via UnifiedPush
|
||||
- Google Play Services delivers notifications through FCM. The official `FirebaseMessaging` SDK mints the registration token, and it requires `google-services.json`. There is no supported way to obtain an FCM token without a Firebase project.
|
||||
- FCM HTTP v1 also requires an OAuth2 service account with the `firebase.messaging` scope, which means an FCM-enabled Google Cloud/Firebase project (the Firebase *console* is not strictly required, but the project is).
|
||||
- Firebase-free options:
|
||||
1. **UnifiedPush + self-hosted distributor (ntfy)** — open standard; the app registers with a distributor (`ntfy`, NextPush) which holds a persistent connection; the server POSTs to the distributor. No Google. Requires the user to have a distributor installed (or the app to bundle one) and adds a background-connection battery cost.
|
||||
2. **Own persistent WebSocket / foreground service** — free of Google but unreliable: Android Doze/App Standby kills sockets, so notifications cannot wake a killed app. Acceptable only for in-app live updates while running.
|
||||
3. **Third-party (OneSignal, Pusher Beams, Expo Push)** — still FCM/APNs underneath. Does not remove Firebase for Android.
|
||||
|
||||
---
|
||||
|
||||
## 3. What "Firebase" actually costs us today
|
||||
|
||||
- A Firebase project + `google-services.json` committed for the Android build (build blocks release if missing).
|
||||
- A service account JSON in Supabase secrets (`FCM_SERVICE_ACCOUNT_JSON`) and `FCM_PROJECT_ID`.
|
||||
- No Firebase database/auth/storage/analytics is used. It is a push-only dependency.
|
||||
|
||||
If the goal is "no Firebase at all", the decision reduces to: replace Android transport (UnifiedPush) or accept FCM on Android only.
|
||||
|
||||
---
|
||||
|
||||
## 4. Recommended path
|
||||
|
||||
1. **Keep Supabase as the SSOT/outbox** (already true). Do not move token/outbox logic.
|
||||
2. **[done] Implement `webpush` + `apns` transports** in `send-push` (`_shared/webpush.ts`, `_shared/apns.ts`).
|
||||
3. **For Android, offer two modes behind config:**
|
||||
- `fcm` (default): minimal Firebase project, push-only usage.
|
||||
- `unifiedpush` (opt-in): self-hosted ntfy distributor; documented UX and battery caveats.
|
||||
4. **Do not adopt OneSignal/Pusher** as a "Firebase replacement" — it does not remove the dependency.
|
||||
|
||||
### Effort sketch
|
||||
| Item | Effort | Risk |
|
||||
|---|---|---|
|
||||
| Web Push (VAPID) transport + tests | S–M | low — **done** |
|
||||
| APNs (JWT ES256 `.p8`) transport + tests | M | medium — **server done**, Apple key + client registration pending |
|
||||
| Cloudflare cron drain | S | low — **done** |
|
||||
| UnifiedPush Android (client lib + distributor + server transport) | L | high (UX, battery, distributor dependency) |
|
||||
| Minimal Firebase project for Android FCM | S (external action) | low |
|
||||
|
||||
---
|
||||
|
||||
## 5. Implemented (2026-09-13)
|
||||
|
||||
| Piece | Where | Notes |
|
||||
|---|---|---|
|
||||
| Web Push transport | `_shared/webpush.ts` | VAPID ES256 JWT + RFC 8291 `aes128gcm` encryption; strict config/subscription validation; 200/404/410/413 mapping |
|
||||
| APNs transport | `_shared/apns.ts` | Token-based `.p8` ES256 JWT (cached ~50 min); production/sandbox hosts; stale (`BadDeviceToken`/`Unregistered`) and 403 handling |
|
||||
| Provider routing | `send-push/index.ts` | `fcm` / `webpush` / `apns` dispatched with per-delivery stale → `stale`, payload → `permanent_failure`, else retryable; unknown providers stay `push_provider_not_supported` |
|
||||
| Cloudflare cron drain | `server/cloudflare-worker/src/push-drain.ts` + `scheduled()` + `wrangler.toml [triggers] crons` | POSTs `send-push?mode=drain&limit=…` with the service-role bearer every minute |
|
||||
| Tests | `_shared/webpush.test.ts`, `_shared/apns.test.ts`, `push-drain.test.ts`, `push-contract.test.ts` | 10 new tests incl. an RFC 8291 decrypt round-trip; CI runs the edge suite and the worker drain test |
|
||||
|
||||
### Required configuration
|
||||
| Secret / var | Consumer | Purpose |
|
||||
|---|---|---|
|
||||
| `WEBPUSH_VAPID_PUBLIC_KEY` / `WEBPUSH_VAPID_PRIVATE_KEY` / `WEBPUSH_SUBJECT` | Supabase Edge Function | VAPID signing; subject must be `mailto:` or `https:` |
|
||||
| `APNS_KEY_ID` / `APNS_TEAM_ID` / `APNS_PRIVATE_KEY` / `APNS_TOPIC` / `APNS_ENVIRONMENT` | Supabase Edge Function | APNs token auth; topic is the bundle id (`com.d3ro.voice`) |
|
||||
| `SUPABASE_URL` / `SUPABASE_SERVICE_ROLE_KEY` / `PUSH_DRAIN_BATCH_LIMIT` | Cloudflare Worker | Cron drain target + auth (service role key as `wrangler secret`) |
|
||||
|
||||
### Remaining work
|
||||
- **Client registration:** the mobile app currently registers only `fcm`. Web needs a service worker + `pushManager.subscribe` → store the JSON subscription as the registration id; iOS needs an APNs device token and `provider: 'apns'`.
|
||||
- **Android decision:** keep FCM or adopt UnifiedPush (see §2/§3).
|
||||
|
||||
---
|
||||
|
||||
## 6. Related
|
||||
|
||||
- `server/supabase/functions/_shared/push-contract.ts` — FCM transport + provider types
|
||||
- `server/supabase/functions/_shared/webpush.ts` — Web Push (VAPID + RFC 8291)
|
||||
- `server/supabase/functions/_shared/apns.ts` — APNs (.p8 token)
|
||||
- `server/supabase/functions/send-push/index.ts` — dispatch orchestration
|
||||
- `server/cloudflare-worker/src/push-drain.ts` — cron drain
|
||||
- `apps/mobile-rn/src/features/notifications/*` — client token/provider contract
|
||||
- `docs/map/11-gap-backlog.md` `GAP-PUSH-01` / `GAP-PUSH-02` / `EXT-FIREBASE-01`
|
||||
- `docs/v3/MOBILE_APP_COMPLETION_SSOT.md` `EXT-011`
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
# D3RO Voice 릴리스 가이드
|
||||
|
||||
기준일: 2026-08-29. 이 문서는 desktop GitLab 패키지·자동 업데이트와 mobile store release의 경계를 분리한다. 태그 생성이나 HTTP 200 하나만으로 배포 완료를 선언하지 않는다.
|
||||
기준일: 2026-09-16. 이 문서는 desktop GitLab 패키지·자동 업데이트와 mobile store release의 경계를 분리한다. 태그 생성이나 HTTP 200 하나만으로 배포 완료를 선언하지 않는다.
|
||||
|
||||
## 현재 release identity
|
||||
|
||||
|
|
@ -15,29 +15,44 @@
|
|||
| Windows Authenticode | external public-trust code-signing certificate | 현재 local `1.1.0` installer·unpacked app은 `NotSigned`; production PFX·CI secret·signed artifact GREEN 전까지 게시 금지 |
|
||||
| Firebase | Console `u/0`, `u/1` 모두 D3RO project 없음 | 사용자 승인 후 project·Android app 생성 필요 |
|
||||
| AdMob | app `ca-app-pub-1039714767792854~6427959892`; banner `/9840591290`; rewarded `/2255790918` | SSOT 확정. `검토 필요`·`광고 게재 제한`·store 미연결·결제 프로필 미완료 |
|
||||
| updater feed | `https://gitlab.twentyoz.kr:8443/api/v4/projects/1172/packages/generic/d3ro-voice/latest` | public `latest.yml`은 아직 `0.2.1-alpha`; `1.1.0` 미배포 |
|
||||
| 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.1.0]` | 태그 전 확정·검증 필수 |
|
||||
|
||||
source SSOT와 live updater metadata의 버전이 다르므로 아직 `1.1.0` 배포 완료가 아니다.
|
||||
|
||||
## GitLab desktop 파이프라인
|
||||
## desktop 릴리스 파이프라인
|
||||
|
||||
```text
|
||||
authoritative release commit
|
||||
→ version/check/test/build GREEN
|
||||
→ tag v1.1.0
|
||||
→ annotated tag v1.1.0
|
||||
→ package-windows (build-win-x64)
|
||||
→ package-macos (build-mac-arm64)
|
||||
→ publish-release (build-linux-x64)
|
||||
├─ packages/generic/d3ro-voice/1.1.0/ 버전별 보존
|
||||
├─ packages/generic/d3ro-voice/latest/ latest updater feed
|
||||
└─ GitLab Release + CHANGELOG release notes
|
||||
├─ publish-forgejo-release.mjs ← canonical
|
||||
│ ├─ Forgejo Generic Registry /d3ro-voice/1.1.0/ (버전별 보존)
|
||||
│ ├─ Forgejo Generic Registry /d3ro-voice/latest/ (updater feed + update-policy.json)
|
||||
│ └─ Forgejo Release + CHANGELOG notes + 자산 첨부
|
||||
└─ publish-gitlab-release.mjs ← legacy mirror (pre-Forgejo 설치본)
|
||||
├─ GitLab Generic Registry /d3ro-voice/1.1.0/
|
||||
├─ GitLab Generic Registry /d3ro-voice/latest/
|
||||
└─ GitLab Release
|
||||
```
|
||||
|
||||
- Forgejo·GitHub Actions도 동일한 `publish-forgejo-release.mjs`로 수렴한다. 어느
|
||||
빌더가 성공해도 canonical feed는 하나다.
|
||||
- `publish-forgejo-release.mjs`는 설치 자산을 먼저, `latest.yml`을 마지막에
|
||||
게시하고, 공개 URL에서 재검증한 뒤 Release 자산을 첨부한다.
|
||||
- 설치 바이너리는 저장소에 커밋하지 않는다. 배포 정본은 Forgejo feed이며,
|
||||
`site/public/releases/*/`, `apps/web/public/releases/*/`,
|
||||
`apps/api-server/wwwroot/releases/*/`는 build graph 밖이라 배포 산출물에
|
||||
포함되지 않는다. 사이트·웹 다운로드 센터는 로컬 경로가 아니라 feed URL을
|
||||
링크한다. (역사적 `1.0.0` 자산만 추적 상태로 남아 있다.)
|
||||
|
||||
- `scripts/ci/sync-version.mjs --check --tag v1.1.0`는 태그, `release/product-version.json`, package/lockfile, Android/iOS 버전 면의 일치를 fail-closed로 검증한다.
|
||||
- `scripts/ci/verify-release-metadata.mjs`는 배포 메타데이터와 CI/publisher 계약을 검증한다.
|
||||
- 같은 gate는 desktop license public key가 Ed25519이고 `release/product-version.json`의 `desktopLicensePublicKeyId`와 일치하는지 검증한다. `electron.vite.config.ts`는 이 파일을 직접 읽으므로 누락·손상된 키로는 build가 시작되지 않는다.
|
||||
- `scripts/ci/publish-gitlab-release.mjs`는 버전별 패키지를 먼저 올리고, `latest` 파일에서 설치 자산 참조를 검증한 후 update metadata를 마지막에 게시한다.
|
||||
- `scripts/ci/publish-forgejo-release.mjs`는 canonical이다. 버전별 패키지를 먼저 올리고, `latest`에서 설치 자산 참조를 검증한 뒤 `latest.yml`과 `update-policy.json`을 마지막에 게시하고 공개 URL에서 재검증한다. `scripts/ci/publish-gitlab-release.mjs`는 legacy mirror로 동일 자산을 GitLab에도 올린다.
|
||||
- Windows installer와 `latest.yml`은 필수다. macOS 산출물이 없는 Windows-only release를 의도했다면 그 판단을 release record에 남긴다.
|
||||
- `package-windows`는 external public-trust code-signing PFX를 protected file variable `WIN_CSC_PFX_FILE`로, 암호와 exact certificate subject를 protected `WIN_CSC_KEY_PASSWORD`, `WIN_CSC_EXPECTED_SIGNER_SUBJECT`로 받아야 한다. GitHub Release도 `WIN_CSC_LINK`, `WIN_CSC_KEY_PASSWORD`, `WIN_CSC_EXPECTED_SIGNER_SUBJECT`가 모두 없으면 실패한다.
|
||||
- `scripts/ci/verify-windows-release-artifact.ps1`는 installer와 unpacked app의 Authenticode `Valid`, exact signer subject, non-self-signed code-signing EKU, PE version, `latest.yml` path/size/SHA-512를 검증한다. 로컬 self-signed `Everything2EverythingDev`는 production 신뢰 인증서가 아니며 gate에서 명시적으로 거부한다.
|
||||
|
|
@ -45,7 +60,14 @@ authoritative release commit
|
|||
|
||||
## 자동 업데이트 계약
|
||||
|
||||
`apps/desktop/src/main/update-feed.ts`와 `apps/desktop/electron-builder.yml`은 버전 없는 같은 public Generic Package Registry URL을 가리켜야 한다.
|
||||
`apps/desktop/src/main/update-feed.ts`와 `apps/desktop/electron-builder.yml`은
|
||||
버전 없는 같은 canonical Forgejo URL을 가리켜야 한다.
|
||||
|
||||
```text
|
||||
https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice/latest
|
||||
```
|
||||
|
||||
legacy mirror(제거 예정, 런타임 참조 금지):
|
||||
|
||||
```text
|
||||
https://gitlab.twentyoz.kr:8443/api/v4/projects/1172/packages/generic/d3ro-voice/latest
|
||||
|
|
@ -53,7 +75,7 @@ https://gitlab.twentyoz.kr:8443/api/v4/projects/1172/packages/generic/d3ro-voice
|
|||
|
||||
아래 면이 하나라도 깨지면 release를 중단한다.
|
||||
|
||||
1. package registry의 무인증 public pull이 허용됐다.
|
||||
1. Forgejo Generic Registry의 무인증 public pull이 허용됐다 (`FORGEJO_TOKEN`으로 게시, 익명으로 읽기).
|
||||
2. `latest.yml`의 `version`이 태그와 일치한다.
|
||||
3. `latest.yml` URL/path가 같은 `latest` 경로의 실제 installer를 참조한다.
|
||||
4. installer 파일명에 공백이 없다: `D3RO-Voice-Setup-<version>-x64.exe`.
|
||||
|
|
@ -61,7 +83,28 @@ https://gitlab.twentyoz.kr:8443/api/v4/projects/1172/packages/generic/d3ro-voice
|
|||
6. installer와 `win-unpacked/D3RO Voice.exe` 모두 external public-trust certificate의 Authenticode `Valid`이고, signer subject가 protected CI identity와 정확히 일치한다.
|
||||
7. 이전 실제 설치본이 feed를 탐지하고, 다운로드·재시작·버전 상승을 끝까지 완료한다.
|
||||
|
||||
2026-08-29 live `latest.yml`의 버전은 `0.2.1-alpha`다. 이는 updater endpoint가 응답한다는 증거일 뿐 `1.1.0` 게시 증거가 아니다.
|
||||
2026-08-29 live `latest.yml`의 버전은 `0.2.1-alpha`다(legacy GitLab feed). 이는 updater endpoint가 응답한다는 증거일 뿐 `1.1.0` 게시 증거가 아니다.
|
||||
|
||||
## 업데이트 채널과 메이저/증분 정책
|
||||
|
||||
정책 정본은 `release/update-policy.json`이고, 런타임 로직은
|
||||
`apps/desktop/src/main/update-policy.ts`다. publisher가 이 파일을 feed 루트에
|
||||
게시하면 실행 중 앱이 내려받아 적용한다.
|
||||
|
||||
| 필드 | 의미 |
|
||||
|---|---|
|
||||
| `defaultChannel` / `channels` | `latest`(stable) · `beta` · `alpha`. `allowPrerelease`가 false면 stable 고객은 prerelease를 받지 않는다 |
|
||||
| `minimumSupportedVersion` | 이 미만 설치본은 업데이트가 **필수**(연기·건너뛰기 불가) |
|
||||
| `forceInstallBelow` | 이 미만은 다이얼로그 없이 즉시 설치. null이면 비활성 |
|
||||
| `fullInstallOnMajorChange` | major 승격 시 blockmap 차분 대신 전체 설치자 |
|
||||
| `fullInstallVersionGap` | 같은 major에서 minor 갭이 이 값 이상이면 전체 설치자 |
|
||||
| `stagingPercentage` | stable 업데이트를 노출할 사용자 비율(0~100). 강제 업데이트는 무시 |
|
||||
| `killSwitch` | true면 업데이트 확인 자체를 중단 |
|
||||
|
||||
- **증분(delta)**: 기본. `.blockmap`으로 변경 블록만 받는다. 첫 업데이트는 항상 full이다.
|
||||
- **전체(full)**: 현재 버전을 신뢰할 수 없거나, major 승격이거나, minor 갭이 임계 이상일 때 `disableDifferentialDownload`를 켠다.
|
||||
- **회수(rollback)**: `stagingPercentage`를 낮추거나 `killSwitch`를 켠다. 이미 배포된 버전은 되돌리지 않고 더 높은 patch로 forward-fix한다.
|
||||
|
||||
|
||||
## `1.1.0` 릴리스 절차
|
||||
|
||||
|
|
@ -70,14 +113,14 @@ https://gitlab.twentyoz.kr:8443/api/v4/projects/1172/packages/generic/d3ro-voice
|
|||
3. dirty/untracked 작업을 임의로 reset·clean하지 말고, release 범위만 검토 가능한 authoritative commit으로 보존한다.
|
||||
4. 같은 commit에서 lint, typecheck, test, build, release metadata·security·artifact gate를 전부 GREEN으로 만든다. Windows는 production Authenticode PFX를 주입한 CI build와 `verify-windows-release-artifact.ps1` GREEN이 필수다.
|
||||
5. desktop offline license를 제공한다면 external private key를 admin의 `ADMIN_LICENSE_PRIVATE_KEY` secret로 주입하고, 저장소 public key와 sign/verify roundtrip 및 발급 감사 로그를 확인한다.
|
||||
6. 이전 버전보다 높은 태그 `v1.1.0`을 생성해 push한다. 태그는 게이트를 시작하는 후속 단계지 검증을 대체하지 않는다.
|
||||
7. GitLab에서 package-windows, package-macos, publish-release와 의도한 mobile job 상태를 모두 확인한다. pending/stuck/skipped를 GREEN으로 기록하지 않는다.
|
||||
8. 버전별 package, GitLab Release note/asset, `latest.yml`, installer hash를 외부 public URL에서 다시 검증한다.
|
||||
6. 이전 버전보다 높은 annotated 태그 `v1.1.0`을 생성해 push한다. `npm run release:tag -- --dry-run`으로 검증한 뒤 `npm run release:tag`(GPG 사용 시 `-- --sign`)와 `git push origin v1.1.0`를 실행한다. 태그는 불변이며 게이트를 시작하는 후속 단계지 검증을 대체하지 않는다.
|
||||
7. GitLab에서 package-windows, package-macos, publish-release와 의도한 mobile job 상태를 모두 확인한다. publish-release가 Forgejo와 GitLab 양쪽에 게시했는지 로그로 확인한다. pending/stuck/skipped를 GREEN으로 기록하지 않는다.
|
||||
8. Forgejo Release note/asset, `latest.yml`, `update-policy.json`, installer hash를 외부 public URL에서 다시 검증한다.
|
||||
9. 이전 설치본에서 자동 업데이트 E2E를 실행하고 실행 중 버전·프로세스·사용자 데이터 보존을 확인한다.
|
||||
|
||||
## mobile release와의 경계
|
||||
|
||||
Desktop GitLab Release를 게시해도 Android production 출시가 자동으로 완료되지 않는다. Android는 다음을 별도로 증명한다.
|
||||
Desktop release를 게시해도 Android production 출시가 자동으로 완료되지 않는다. Android는 다음을 별도로 증명한다.
|
||||
|
||||
- 준비된 local upload/evidence key와 AdMob identity를 protected CI secret에 주입하고, 사용자 승인 후 생성한 production Firebase config와 함께 version `1.1.0`, versionCode `1010001` AAB 생성
|
||||
- package/config/upload signer/ABI/16 KB page size/signed provenance GREEN
|
||||
|
|
@ -100,9 +143,14 @@ Desktop GitLab 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/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
|
||||
- `scripts/ci/publish-gitlab-release.mjs` — registry·Release·updater feed publisher
|
||||
- `apps/desktop/src/main/update-feed.ts` — runtime updater URL SSOT
|
||||
- `scripts/ci/publish-gitlab-release.mjs` — legacy GitLab registry·Release mirror publisher
|
||||
- `apps/desktop/src/main/update-feed.ts` — runtime updater URL SSOT (canonical Forgejo + legacy mirror 상수)
|
||||
- `release/update-policy.json` — 채널·최소 지원 버전·강제 업데이트·full/delta·staged rollout·킬 스위치 SSOT
|
||||
- `apps/desktop/src/main/update-policy.ts` — 정책 파싱·결정 순수 로직
|
||||
- `scripts/ci/publish-forgejo-release.mjs` — canonical Forgejo registry·Release·feed publisher
|
||||
- `apps/desktop/electron-builder.yml` — builder publish URL·artifact contract
|
||||
- `docs/deployment/update-system-assessment.md` — 평가·2026 방법론·ADR
|
||||
- `docs/v3/play/04-release-checklist.md` — Play Console·AAB·closed test·production gate
|
||||
|
|
|
|||
214
docs/deployment/update-system-assessment.md
Normal file
214
docs/deployment/update-system-assessment.md
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
# D3RO Voice 업데이트 시스템 평가와 2026 방법론
|
||||
|
||||
기준일: 2026-09-13. 이 문서는 현재 자동 업데이트·릴리스 체계를 평가하고, 2026년
|
||||
9월 기준 업계 방법론에 맞춘 목표 아키텍처와 결정 사항을 기록한다. 운영 절차는
|
||||
[`release-guide.md`](./release-guide.md), 정책 정본은
|
||||
[`release/update-policy.json`](../../release/update-policy.json)이다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 결론
|
||||
|
||||
**기반은 좋다. 그러나 "업데이트 시스템"이라 부르기엔 미완성이다.**
|
||||
|
||||
현재 데스크톱은 `electron-updater` + GitLab Generic Package Registry feed로
|
||||
동작하며, 버전 SSOT·CHANGELOG gate·메타데이터 자가검증·서명 fail-closed까지
|
||||
갖춘 릴리스 파이프라인을 이미 보유한다. 이 부분은 업계 표준보다 앞서 있다.
|
||||
|
||||
반면 다음은 없다.
|
||||
|
||||
- **업데이트 채널**: stable/beta/alpha 구분이 없고 `latest.yml` 하나만 존재한다.
|
||||
- **메이저/증분 정책**: major 승격, 최소 지원 버전, 강제 업데이트, 버전 갭에 따른
|
||||
full 재설치 판단이 전혀 없다. 모든 업데이트가 동일하게 취급된다.
|
||||
- **단계적 롤아웃(staged rollout)과 킬 스위치**: 없다.
|
||||
- **배포 호스트 일관성**: 제품의 공개 배포 허브(admin·site 다운로드)는 Forgejo
|
||||
`git.chanpaca.net/yunchan/d3ro-voice`인데, 정작 updater feed는 GitLab
|
||||
(project 1172)을 가리킨다. 게다가 `verify-release-metadata.mjs`는 Forgejo
|
||||
릴리스 배포를 **금지**하고 있어 Forgejo로 릴리스할 수 없다.
|
||||
- **업데이트 서명**: `latest.yml` 메타데이터 서명(Ed25519)이 "future work"로만
|
||||
남아 있다. 코드 서명은 요구하지만 feed 자체 무결성은 TLS + SHA-512에만 의존한다.
|
||||
|
||||
**판정: `[~]` (부분).** 데스크톱 단일 채널·단일 호스트 업데이터로는 동작하지만,
|
||||
다중 채널·강제 업데이트·Forgejo 배포·릴리스 관리 방법론을 갖춘 "업그레이드 체계"는
|
||||
아직 아니다. 이 문서와 함께 추가된 코드가 그 격차를 메운다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 현재 구현 인벤토리
|
||||
|
||||
| 구성 | 위치 | 상태 |
|
||||
|---|---|---|
|
||||
| 업데이터 서비스 | `apps/desktop/src/main/services/UpdateService.ts` | 단일 feed, 4h 주기, 동의 다이얼로그, skip version |
|
||||
| feed SSOT | `apps/desktop/src/main/update-feed.ts` | GitLab project 1172 `latest` (버전 비고정) |
|
||||
| 빌더 publish | `apps/desktop/electron-builder.yml` | `provider: generic`, `detectUpdateChannel: false` |
|
||||
| 버전 SSOT | `release/product-version.json` | `1.1.0` / `1010001` |
|
||||
| 버전 동기화 | `scripts/ci/sync-version.mjs` | 태그·패키지·lockfile·Android/iOS/.NET 일치 fail-closed |
|
||||
| 메타데이터 검증 | `scripts/ci/verify-release-metadata.mjs` | feed·publisher·workflow 계약 self-test |
|
||||
| GitLab publisher | `scripts/ci/publish-gitlab-release.mjs` | 버전별 + `latest`, 설치자산 우선, public 재검증 |
|
||||
| Windows 산출물 gate | `scripts/ci/verify-windows-release-artifact.ps1` | Authenticode·PE version·SHA-512 |
|
||||
| CI | `.gitlab-ci.yml` `package-*` → `publish-release` | 태그 전용 |
|
||||
| Forgejo | `.forgejo/workflows/*` | **사이트 배포만**. 릴리스 배포 금지됨 |
|
||||
| 릴리스 노트 | `CHANGELOG.md` (Keep a Changelog) | publisher가 섹션 누락 시 실패 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 갭 상세
|
||||
|
||||
### 3.1 채널 부재
|
||||
`detectUpdateChannel: false`이고 `updater.channel` 설정이 없어 항상 `latest.yml`을
|
||||
읽는다. 베타/알파 사용자를 분리할 수 없고, prerelease를 안정 채널에 노출하지 않으려면
|
||||
매번 수동으로 feed를 조작해야 한다.
|
||||
|
||||
### 3.2 메이저/증분 판단 부재
|
||||
`_promptUserConsent`는 버전과 무관하게 동일한 3버튼 다이얼로그를 띄운다.
|
||||
`update-available` 이벤트에 `isMandatory` 필드가 선언돼 있지만 실제로 계산하지 않는다.
|
||||
major 승격(예: 1.x → 2.x)에서 사용자가 "나중에"를 눌러 구버전에 남을 수 있고,
|
||||
버전 갭이 큰 클라이언트가 differential patch를 시도하다 실패할 수 있다.
|
||||
|
||||
### 3.3 Forgejo 배포 금지
|
||||
`verify-release-metadata.mjs:147-148`은 Forgejo workflow에
|
||||
`sync-and-publish-forgejo-release`가 포함되면 실패시킨다. 이 스크립트가 버전을
|
||||
`1.0.0`으로 하드코딩한 legacy이기 때문이다. 결과적으로 Forgejo에는 릴리스가
|
||||
존재하지만(v1.0.0, 2026-08-20) 최신 체계에서는 갱신되지 않는다. 제품의 공개
|
||||
다운로드 페이지(`site/`, `apps/web/download`, `apps/admin/releases`)는 모두
|
||||
Forgejo를 정본으로 본다.
|
||||
|
||||
### 3.4 staged rollout / 킬 스위치 부재
|
||||
`stagingPercentage`를 쓰지 않아 새 버전이 전 사용자에게 즉시 노출된다. 잘못된
|
||||
릴리스 회수는 "더 높은 버전으로 forward-fix"만 가능하다.
|
||||
|
||||
### 3.5 메타데이터 서명 부재
|
||||
SHA-512는 TLS 종단 간 무결성만 보장한다. Feed 호스트가 침해되면 악성 설치자를
|
||||
서명 검증 이전에 내려줄 수 있다. 2026년 표준(Doyensec SafeUpdater, Sparkle 2.9
|
||||
signed feed, Tauri ed25519 강제)은 **매니페스트 서명**을 요구한다.
|
||||
|
||||
### 3.6 릴리스 gate 신뢰성 저하 (발견)
|
||||
`apps/desktop/package.json`의 `typecheck`는 `tsc --noEmit`인데 `tsconfig.json`이
|
||||
`files: []` + `references` 구조라 **아무 파일도 검사하지 않는다**. 실제로
|
||||
`tsc -p tsconfig.node.json --noEmit`을 돌리면 다수의 기존 오류가 나온다
|
||||
(`bootstrap.ts`, `support-handlers.ts` 등). 즉 "typecheck GREEN"은 데스크톱
|
||||
main/preload에 대해 아무 의미가 없고, 릴리스 게이트가 이 착시에 의존한다.
|
||||
(`npm run lint`와 vitest는 정상 동작.)
|
||||
|
||||
### 3.7 버전 번호 이력 불일치
|
||||
git 태그는 `v1.0.0`에서 멈춰 있고, live `latest.yml`은 `0.2.1-alpha`다. 제품 버전
|
||||
정본은 `1.1.0`이다. 태그→릴리스 파이프라인은 "같은 커밋에서 검증"을 요구하므로
|
||||
`1.1.0` 릴리스가 아직 시작되지 않았음을 보여준다.
|
||||
|
||||
---
|
||||
|
||||
## 4. 2026 방법론 (조사 요약)
|
||||
|
||||
### 4.1 프레임워크 선택
|
||||
|
||||
| 옵션 | 특징 | 판단 |
|
||||
|---|---|---|
|
||||
| **electron-updater + electron-builder v26/27+** | blockmap 차분, 채널, `stagingPercentage`, Windows 서명 검증, generic provider | **유지**. 이미 통합돼 있고 가장 성숙 |
|
||||
| Velopack | Squirrel 대체, JS/Electron 지원, delta | 빌드 프레임워크 교체 비용 큼 |
|
||||
| Tauri updater | ed25519 서명 강제, `latest.json` | Tauri 전용 |
|
||||
| Sparkle | macOS 전용, 서명 feed, critical update | 교차 플랫폼 아님 |
|
||||
| update-electron-app | 공식 Squirrel 경로 | 채널·staged rollout 없음 |
|
||||
|
||||
결론: **electron-updater를 유지하고 v27+ 보안 기본값(파일 단위 `files[]` 메타데이터,
|
||||
fail-closed 서명 검증, 명시적 publish) 방향으로 설정을 정렬**한다.
|
||||
|
||||
### 4.2 차분(delta) vs 전체(full)
|
||||
|
||||
- electron-updater는 캐시된 이전 설치자를 기준으로 `.blockmap`의 변경 블록만
|
||||
HTTP Range로 내려받는다. 첫 업데이트는 항상 full이다.
|
||||
- **full로 강제해야 하는 경우**: (a) 현재 버전을 신뢰할 수 없음/손상, (b) major
|
||||
승격(스키마·ABI 변경), (c) minor 갭이 임계 이상, (d) 서버가 Range 미지원.
|
||||
- 구현: `autoUpdater.disableDifferentialDownload`를 위 조건에서 켠다.
|
||||
|
||||
### 4.3 채널과 staged rollout
|
||||
|
||||
- 채널: `latest`(stable) / `beta` / `alpha`. 클라이언트 `updater.channel` +
|
||||
`allowPrerelease`로 선택. 빌드 측은 `generateUpdatesFilesForAllChannels`로
|
||||
채널별 메타데이터를 생성할 수 있다.
|
||||
- staged rollout: `latest.yml`의 `stagingPercentage` + 클라이언트의 영구 사용자
|
||||
해시. 회수는 **더 높은 버전**으로만 가능(같은 버전 재배포 불가).
|
||||
|
||||
### 4.4 릴리스 관리 방법론
|
||||
|
||||
- **SemVer**: breaking=MAJOR, 하위호환 기능=MINOR, 버그픽스=PATCH. 릴리스된 버전은
|
||||
불변(immutable). 잘못된 릴리스는 새 PATCH로 forward-fix.
|
||||
- **Conventional Commits** → `feat`=MINOR, `fix`=PATCH, `BREAKING CHANGE`/`!`=MAJOR.
|
||||
자동화 도구는 release-please(릴리스 PR 게이트) / semantic-release(완전 자동) /
|
||||
changesets(모노레포). 이 저장소는 SSOT+수동 승인 게이트를 이미 쓰므로
|
||||
**release-please식 게이트를 유지**하고 커밋 컨벤션만 도입한다.
|
||||
- **Trunk-based development**가 2026 기본. 릴리스 브랜치는 필요 시 JIT로 자르고
|
||||
태그 후 제거. 이 저장소는 `main` + 태그 릴리스로 이미 TBD에 가깝다.
|
||||
- **Git 태그**: annotated + 서명(`git tag -s`) + `v` 접두 semver. 보호 태그 규칙으로
|
||||
삭제·강제 이동을 막는다. 릴리스 태그는 절대 이동하지 않는다.
|
||||
- **CHANGELOG**: Keep a Changelog. `[Unreleased]`를 PR에서 유지하고 릴리스 시
|
||||
날짜 섹션으로 이동. YANKED 표기.
|
||||
|
||||
### 4.5 CI/CD
|
||||
|
||||
- 코드 품질 CI는 모든 push에서, 릴리스/배포 CI는 **태그에서만**.
|
||||
- 태그가 버전 SSOT·CHANGELOG·테스트·서명을 모두 통과해야 publish.
|
||||
- 자산을 먼저 업로드하고 **메타데이터(latest.yml)를 마지막에** 게시해 배포 중
|
||||
404를 막는다(현 publisher가 이미 구현).
|
||||
- 공개 URL에서 재검증(fail-closed).
|
||||
|
||||
출처: semver.org, conventionalcommits.org, keepachangelog.com,
|
||||
electron.build auto-update/code-signing/publish, forgejo.org packages/actions,
|
||||
trunkbaseddevelopment.com, sre.google release-engineering, Doyensec SafeUpdater.
|
||||
|
||||
---
|
||||
|
||||
## 5. 목표 아키텍처
|
||||
|
||||
```text
|
||||
┌───────────────────────────── release/update-policy.json (SSOT)
|
||||
│
|
||||
author commit → version:check → test → build (signed)
|
||||
│
|
||||
└→ tag vX.Y.Z (annotated, protected)
|
||||
│
|
||||
┌───────────────┼───────────────────────────┐
|
||||
│ │ │
|
||||
GitLab CI GitHub Actions Forgejo Actions
|
||||
(primary) (mirror) (release hub + feed)
|
||||
│ │ │
|
||||
└───────────────┴──────────────┬────────────┘
|
||||
▼
|
||||
publish-forgejo-release.mjs (canonical)
|
||||
publish-gitlab-release.mjs (legacy mirror)
|
||||
│
|
||||
┌────────────────────────┴────────────────────────┐
|
||||
▼ ▼
|
||||
Forgejo Generic Registry (canonical feed) GitLab Generic Registry (legacy)
|
||||
.../generic/d3ro-voice/<version|latest>/ .../generic/d3ro-voice/<version|latest>/
|
||||
│
|
||||
▼
|
||||
electron-updater → 채널(latest/beta/alpha) → 정책(mandatory/full-vs-delta)
|
||||
```
|
||||
|
||||
- **Canonical runtime feed**: Forgejo
|
||||
`https://git.chanpaca.net/api/packages/yunchan/generic/d3ro-voice/latest`.
|
||||
- **Legacy mirror**: GitLab project 1172 `latest`. 기존 `0.2.1-alpha` 설치본이
|
||||
GitLab을 계속 폴링하므로, 새 설치자가 Forgejo feed를 갖는 버전을 받을 때까지
|
||||
유지한다. 마이그레이션 완료 후 제거 가능.
|
||||
- **정책 SSOT**: `release/update-policy.json`. 빌드에 번들되는 기본값 + feed에서
|
||||
원격 오버라이드(선택). 서버에서 정책을 내려 강제 업데이트·킬 스위치를 즉시
|
||||
적용할 수 있다.
|
||||
|
||||
---
|
||||
|
||||
## 6. 결정 기록 (ADR 요약)
|
||||
|
||||
1. **ADR-UPD-01**: electron-updater를 유지한다. 빌드 프레임워크 교체는 비용 대비
|
||||
이득이 없다.
|
||||
2. **ADR-UPD-02**: Forgejo를 canonical 배포·feed 호스트로 한다. GitLab은 legacy
|
||||
mirror로 남긴다.
|
||||
3. **ADR-UPD-03**: 채널(latest/beta/alpha)과 최소 지원 버전·강제 업데이트·full
|
||||
재설치를 `release/update-policy.json`으로 정본화한다.
|
||||
4. **ADR-UPD-04**: 태그는 annotated `vX.Y.Z`이며 이동하지 않는다. 릴리스는
|
||||
forward-fix만 허용한다.
|
||||
5. **ADR-UPD-05**: Forgejo 배포 금지 조항을 제거하고, 버전 하드코딩 없는
|
||||
`publish-forgejo-release.mjs`로 대체한다.
|
||||
6. **ADR-UPD-06** (권고): 데스크톱 `typecheck`가 실제로 검사하도록
|
||||
`tsc -b` 또는 `tsc -p tsconfig.node.json`로 교체하고 기존 오류를 별도
|
||||
워크스트림에서 정리한다. 이 문서 범위에서는 릴리스 게이트가 착시에 의존하지
|
||||
않도록 `verify-release-metadata.mjs`에 최소한의 경고를 남긴다.
|
||||
Loading…
Add table
Add a link
Reference in a new issue