chore: adiciona .gitignore e commit.command

This commit is contained in:
João Henrique
2026-08-18 08:25:29 -04:00
parent 68958fde00
commit 8fca456ceb
215 changed files with 65752 additions and 0 deletions
+577
View File
@@ -0,0 +1,577 @@
# Hex HotKey Semantics
## Overview
Hex uses a **threshold-based recording system** that behaves differently depending on whether your hotkey is **modifier-only** (e.g., Option) or **a regular hotkey** (e.g., Cmd+A).
The key insight: **Modifier-only hotkeys need protection from accidental triggers** (like quick Option taps for special characters), while regular hotkeys are inherently intentional.
---
## Quick Reference
### Modifier-Only Hotkeys (e.g., Option, Option+Command)
**Timeline: Press Option → START recording**
**Before 0.3s (< 0.3s):**
- Release → DISCARD (silent)
- Click → DISCARD (silent)
- Press A → DISCARD (silent)
- Add Shift → DISCARD (silent)
- All actions trigger silent discard
**After 0.3s (≥ 0.3s):**
- Release → STOP (transcribe)
- Click → NOP (ignore, keep recording)
- Press A → NOP (ignore, keep recording)
- Add Shift → NOP (ignore, keep recording)
- ESC → CANCEL (only way to stop)
**Key points:**
- **< 0.3s**: Everything except ESC triggers **silent discard** (no sound)
- **≥ 0.3s**: Only **ESC cancels** (with sound), everything else is **ignored**
- Recording continues until you release the modifier or press ESC
---
### Regular Hotkeys (e.g., Cmd+A, Option+K)
**Timeline: Press Cmd+A → START recording**
**Before 0.2s (< minimumKeyTime):**
- Release → DISCARD (silent)
- Click → DISCARD (silent)
**Between 0.2s - 1.0s:**
- Press different key (e.g., Cmd+B) → STOP (with sound)
- Add modifier (e.g., Shift) → STOP (with sound)
**After 1.0s (> 1.0s):**
- Press different key → NOP (ignore, keep recording)
- Add modifier → NOP (ignore, keep recording)
- Allows typing while recording
**Any time:**
- Release → STOP (transcribe if long enough)
- ESC → CANCEL
**Key points:**
- **< minimumKeyTime** (default 0.2s): **Silent discard**
- **0.2s - 1.0s**: Different key/modifier triggers **stop** (with sound)
- **> 1.0s**: Everything is **ignored** (allows typing while recording)
- Release or ESC always stops
---
## Constants & Thresholds
```swift
// For modifier-only hotkeys (system safety)
modifierOnlyMinimumDuration = 0.3s
// For all hotkeys (user-configurable)
minimumKeyTime = 0.2s (default)
// Other thresholds
doubleTapThreshold = 0.3s // Max time between taps
pressAndHoldCancelThreshold = 1.0s // For regular hotkeys only
```
### Effective Thresholds
The **actual threshold** used depends on the hotkey type:
```swift
// Modifier-only (e.g., Option)
effectiveThreshold = max(minimumKeyTime, 0.3s)
// User sets 0.1s → uses 0.3s
// User sets 0.5s → uses 0.5s
// Regular (e.g., Cmd+A)
effectiveThreshold = minimumKeyTime
// User sets 0.1s → uses 0.1s
// User sets 0.5s → uses 0.5s
```
---
## Recording Decision Matrix
When you **release** the hotkey, should we transcribe the recording?
**Modifier-only (Option):**
- Duration < 0.3s → Discard (silent)
- Duration ≥ 0.3s → Transcribe
**Regular (Cmd+A):**
- Duration < 0.2s (or < minimumKeyTime) → Discard (silent)
- Duration ≥ 0.2s (or ≥ minimumKeyTime) → Transcribe
*Note: minimumKeyTime can be adjusted by user, but modifier-only always enforces 0.3s minimum*
---
## Detailed Behaviors
### 1. Modifier-Only: Option
#### Scenario A: Quick tap (< 0.3s)
```
User: Hold Option (0.1s) → Release
↓
START ────→ DISCARD (silent)
Result: No transcription, no sound
Why: Likely accidental (Option+Click, Option+A for special chars)
```
#### Scenario B: Hold and click (< 0.3s)
```
User: Hold Option (0.25s) → Click mouse
↓
START ────→ DISCARD (silent)
Result: No transcription, no sound, click passes through
Why: Option+Click is for duplicating items in macOS
```
#### Scenario C: Hold and press A (< 0.3s)
```
User: Hold Option (0.2s) → Press A
↓
START ────→ DISCARD (silent)
Result: No transcription, Option+A passes through to macOS
Why: Option+A might be for special character "å"
```
#### Scenario D: Hold longer (≥ 0.3s)
```
User: Hold Option (0.5s) → Release
↓
START ───────────→ TRANSCRIBE
Result: Audio transcribed and pasted
```
#### Scenario E: Hold, then click (≥ 0.3s)
```
User: Hold Option (0.5s) → Click mouse
↓
START ───────────→ (ignored, keeps recording)
Result: Recording continues, click passes through
Why: After 0.3s, we assume intentional recording
```
#### Scenario F: Hold, then add Shift (≥ 0.3s)
```
User: Hold Option (0.5s) → Add Shift
↓
START ───────────→ (ignored, keeps recording)
Result: Recording continues, Option+Shift passes through
Why: User might be pressing Shift for capital letters while speaking
```
#### Scenario G: ESC cancels anytime
```
User: Hold Option (any duration) → Press ESC
↓
START ───────────→ CANCEL (with sound)
Result: Recording cancelled, cancel sound plays
Why: ESC is explicit "I want to cancel" gesture
```
---
### 2. Regular Hotkey: Cmd+A
#### Scenario A: Quick tap (< 0.2s)
```
User: Hold Cmd+A (0.1s) → Release
↓
START ────→ DISCARD (silent)
```
#### Scenario B: Press different key within 1s
```
User: Hold Cmd+A (0.5s) → Press Cmd+B
↓
START ───────────→ STOP (with sound)
Result: Recording stopped, audio discarded
Why: User is likely using other Cmd shortcuts
```
#### Scenario C: Press different key after 1s
```
User: Hold Cmd+A (1.5s) → Press Cmd+B
↓
START ───────────────────→ (ignored, keeps recording)
Result: Recording continues, Cmd+B passes through
Why: After 1s, assume user is transcribing while typing
```
#### Scenario D: Add modifier (< 1s)
```
User: Hold Cmd+A (0.5s) → Add Shift (Cmd+Shift+A)
↓
START ───────────→ STOP (with sound)
Result: Recording stopped
Why: Cmd+Shift+A is likely a different command
```
---
### 3. Multi-Modifier: Option+Command
**Behaves like single modifier** (uses 0.3s threshold):
```
User: Hold Option+Command (0.25s) → Add Shift
↓
START ────→ DISCARD (silent)
User: Hold Option+Command (0.5s) → Add Shift
↓
START ───────────→ (ignored, keeps recording)
```
**Partial release = full release:**
```
User: Hold Option+Command → Release Command (keep Option)
↓
START ───────────→ STOP
Result: Recording stopped and transcribed (if ≥ 0.3s)
Why: Releasing any part of the hotkey = release
```
---
## Double-Tap Lock
**Quick double-tap locks recording on** (hands-free mode)
### Timeline
```
0.0s: Tap hotkey ──────────→ START
0.1s: Release ─────────────→ STOP
0.2s: Tap again ───────────→ START
0.3s: Release ─────────────→ LOCK! (Δt = 0.2s < 0.3s)
Now recording is locked on:
- Release doesn't stop it
- Tap hotkey again to stop
- ESC also stops
5.0s: Tap hotkey ──────────→ STOP
```
### Sequence
1. **Tap 1** (t=0.0s) → START recording
2. **Release** (t=0.1s) → STOP (normal behavior)
3. **Tap 2** (t=0.2s, within 0.3s window) → START recording again
4. **Release** (t=0.3s, Δt=0.2s < 0.3s) → **LOCK!** (hands-free mode)
5. Recording continues until:
- Tap hotkey again → STOP
- Press ESC → STOP
### Rules
1. **Timing window**: 2nd tap must be within **0.3s** of 1st release
2. **Lock engages**: On **2nd release**, not 2nd press
3. **Exit lock**: Tap hotkey again OR press ESC
4. **Too slow**: If 2nd tap > 0.3s after 1st release, treated as new recording
---
## Output Types
- **`.discard`** (silent)
- Stop recording
- Discard audio
- Pass keys through to macOS
- **`.stop`** (with stop sound)
- Stop recording
- Transcribe if duration ≥ threshold
- **`.cancel`** (with cancel sound)
- Stop recording
- Discard audio
- Play cancel sound
---
## Key Interception
**When does Hex intercept (block) key events from reaching other apps?**
**Key Interception Rules:**
- **Press modifier-only hotkey (Option)** → No (passes through)
- **Press regular hotkey (Cmd+A)** → Yes (blocked)
- **`.discard` output** → No (passes through) ← CRITICAL!
- **`.cancel` output** → Yes (blocked)
- **Mouse clicks** → Never intercepted (always pass through)
**Example:**
```
User: Press Option (0.2s) → Press A
↓
START → DISCARD (passes through)
Result: Hex discards recording silently
macOS sees Option+A
Special character dialog appears ✅
```
---
## Dirty State
**Prevents re-triggering until full release**
### What triggers dirty?
**Modifier-only (Option):**
- Add extra modifier within 0.3s → dirty
- Press any key within 0.3s → dirty
- Click mouse within 0.3s → dirty
**Regular (Cmd+A):**
- Press different key within 1s → dirty
- Change modifiers within 1s → dirty
### Dirty behavior
```
User: Hold Option (0.1s) → Add Shift → Release Shift → Press Option again
↓
START → DISCARD (dirty=true) → (ignored) → (ignored)
User must release EVERYTHING (∅) to clear dirty
→ Release all keys → Now Option works again
```
### State Flow
**CLEAN** → [trigger dirty condition] → **DIRTY** → [full release (all keys)] → **CLEAN**
- **CLEAN**: Accepts hotkey input
- **DIRTY**: Ignores all input until full release (all keys released)
---
## Decision Tree
**Processing order:**
1. **Is ESC pressed?**
- YES → CANCEL (exit)
2. **Are we dirty?**
- YES → Ignore input (unless full release)
3. **Does chord match hotkey exactly?**
- NO → Check if recording active
- Recording active → Handle based on elapsed time
- Not recording → Ignore input
- YES → Continue to step 4
4. **Is recording active?**
- NO → START recording
- YES → Check hotkey type and elapsed time
5. **If recording active:**
- **Modifier-only?**
- YES → Check elapsed < max(0.3s, minimumKeyTime)
- YES → DISCARD or (ignore if ≥0.3s)
- NO → (ignore)
- **Regular hotkey?**
- Check elapsed < 1s
- YES → Check elapsed < minimumKeyTime
- YES → DISCARD
- NO → STOP
- NO → (ignore)
6. **Final action:**
- START or STOP (depending on current state)
---
## State Machine
**States:**
- **IDLE**
- Transition: chord matches hotkey → PRESS & HOLD
- **PRESS & HOLD** (recording)
- On release (normal):
- Check elapsed time
- If < 0.3s (modifier-only) or < minimumKeyTime (regular) → DISCARD
- If ≥ threshold → Check last tap timing
- If Δt < 0.3s → LOCK
- Otherwise → STOP → IDLE
- On other input:
- Check elapsed time
- If < 0.3s → DISCARD → IDLE
- If ≥ 0.3s → (ignore, keep recording)
- **LOCK** (hands-free recording)
- Transition: Tap hotkey again OR press ESC → STOP → IDLE
---
## Examples with Full Explanations
### Example 1: Quick Option tap for special character
```
Goal: Type "å" (Option+A)
Timeline:
t=0.0s Press Option → START recording
t=0.15s Press A → DISCARD (< 0.3s)
Option+A passes to macOS
macOS sees: Option+A
Result: Special character dialog appears ✅
Hex: Silent discard, no transcription
```
**Why this works:**
- Recording starts immediately (responsive)
- But discarded if < 0.3s (safety)
- Keys pass through (Option+A reaches macOS)
---
### Example 2: Intentional voice recording with Option
```
Goal: Record a voice note
Timeline:
t=0.0s Press Option → START recording
t=0.5s Still holding... (recording audio)
t=2.0s Release Option → STOP, TRANSCRIBE
Result: Audio transcribed and pasted ✅
```
---
### Example 3: Option-click to duplicate
```
Goal: Duplicate file in Finder
Timeline:
t=0.0s Press Option → START recording
t=0.2s Click file → DISCARD (< 0.3s)
Click passes through
Finder sees: Option+Click
Result: File duplicated ✅
Hex: Silent discard
```
---
### Example 4: Recording while typing
```
Goal: Dictate code comments while typing
Timeline:
t=0.0s Press Option → START recording
t=0.5s Still talking... (recording)
t=2.0s Press Cmd+Tab → IGNORED (> 0.3s)
Cmd+Tab passes through
t=3.0s Type some code → IGNORED
t=5.0s Release Option → STOP, TRANSCRIBE
Result: Audio transcribed ✅
Cmd+Tab worked ✅
Typing worked ✅
```
**Why:** After 0.3s, Hex assumes you're intentionally recording and ignores other input (except ESC).
---
### Example 5: Accidental recording cancellation
```
Goal: Cancel accidental recording
Timeline:
t=0.0s Press Option → START recording
t=1.0s "Oh no, accident!"
t=1.5s Press ESC → CANCEL (with sound)
Result: Recording cancelled ✅
Cancel sound plays
```
---
## Summary Table
**Modifier-only (Option):**
- **Time < 0.3s:**
- Release → Discard
- Click → Discard
- Press key → Discard
- Add modifier → Discard
- **Time ≥ 0.3s:**
- Release → Transcribe
- Click → Ignore (keep recording)
- Press key → Ignore (keep recording)
- Add modifier → Ignore (keep recording)
- ESC → Cancel
**Regular (Cmd+A):**
- **Time < minimumKeyTime:**
- Release → Discard
- **Time: minimumKeyTime - 1s:**
- Other key → Stop
- Add modifier → Stop
- **Time > 1s:**
- Other key → Ignore (keep recording)
- Add modifier → Ignore (keep recording)
- **Any time:**
- Release → Transcribe (if long enough)
- ESC → Cancel
---
## Implementation Files
- **Core Logic**: `HexCore/Sources/HexCore/Logic/HotKeyProcessor.swift`
- **Recording Decision**: `HexCore/Sources/HexCore/Logic/RecordingDecision.swift`
- **Feature Integration**: `Hex/Features/Transcription/TranscriptionFeature.swift`
- **Tests**: `HexCore/Tests/HexCoreTests/HotKeyProcessorTests.swift`
---
**Document Version:** 2.0
**Last Updated:** 2025-11-14
**Total Tests:** 46 passing ✅
+71
View File
@@ -0,0 +1,71 @@
# Parakeet Short-Audio Padding Plan
## Problem Statement
- FluidAudio's Parakeet TDT v3 decoder refuses to run (or produces unstable text) when the input WAV is shorter than its internal chunk size.
- Hex often emits <250 ms clips when users tap-to-talk, so we must “pack” (pad) the PCM before `AsrManager.transcribe(_:)` is called.
- WhisperKit already zero-pads to 30 s internally, but Parakeet expects callers to hand it an audio segment that spans an entire chunk.
## Goals (Parakeet-first)
1. Detect when a captured recording is below Parakeet’s minimum duration (TBD) and ensure the on-disk file we hand to FluidAudio meets or exceeds that window.
2. Keep padding transparent to users: no audible artifacts, no extra latency, and logs that explain when/why padding ran (`HexLog.parakeet`).
3. Limit scope to Parakeet for now; Whisper will keep using its existing flow unless we discover regressions.
## Constraints & Research Tasks
- [ ] Confirm Parakeet’s required sample count / duration. FluidAudio docs hint that v3 expects whole “chunk_duration” intervals (default 1.5 s @ 16 kHz mono). Need an authoritative value from:
- `FluidAudio/Documentation/Models/ASR/LastChunkHandling.md`
- Hugging Face card for `parakeet-tdt-0.6b-v3-coreml`
- Any sample scripts in `FluidAudio` repo (look for `chunk_duration`, `min_samples`, or padding helpers).
- [ ] Determine whether Parakeet tolerates zero-padding vs. repeating tail samples. Preference is zero-padding unless docs warn about VAD slip.
- [ ] Verify whether padding must retain the original WAV container (RIFF header). If `AsrManager` only reads PCM floats, we can rewrite the whole file; otherwise we may need to append silence samples and fix the header lengths.
## Current Pipeline Touchpoints
1. `Hex/Clients/RecordingClient.swift:667` sets up `AVAudioRecorder` at 16 kHz mono float and writes to `recording.wav` in `FileManager.default.temporaryDirectory`.
2. `Hex/Features/Transcription/TranscriptionFeature.swift:320` calls `recording.stopRecording()` and passes the resulting URL into `TranscriptionClient.transcribe`.
3. `Hex/Clients/TranscriptionClient.swift:242` routes Parakeet variants straight to `ParakeetClient.transcribe(_:)` with no preprocessing.
4. `Hex/Clients/ParakeetClient.swift:118` hands the URL to `AsrManager.transcribe(url)` and returns the text.
These spots give us two obvious injection points: (a) mutate the WAV immediately after `recording.stopRecording()` returns, or (b) intercept inside `ParakeetClient` before calling FluidAudio.
## Implementation Plan
### Phase 1 – Instrumentation & Guardrails
1. Add a lightweight helper (e.g., `ShortClipInspector`) that loads the WAV header, validates sample rate/channels, and returns total duration.
2. Insert the inspector inside `TranscriptionClient` right before the Parakeet branch so we can log current clip lengths (`HexLog.parakeet.debug("clip=0.18s")`).
3. Ship this logging first (behind a debug flag if needed) to confirm real-world distributions before we enable padding.
### Phase 2 – Padding Helper (Parakeet Only)
1. Create a new utility in `Hex/Audio/ShortClipPadder.swift` (or similar) that:
- Accepts a source `URL`, desired minimum samples, and output `URL` (can be the same file if we rewrite safely).
- Reads PCM floats via `AVAudioFile` or `ExtAudioFile`, counts samples, and if below threshold, appends zeros until `minSamples` is met.
- Rewrites the WAV header chunk sizes so the file stays valid.
2. Unit test this helper in `HexCoreTests` with fixtures (<50 ms clip, >threshold clip) to guarantee correct math and metadata.
3. Make the minimum configurable (env var or `HexSettings`) so we can tweak without hard-coding; default to whatever FluidAudio recommends.
### Phase 3 – Integration Path
1. Inside `TranscriptionClient.transcribe`, when `isParakeet(model)` is true:
- Inspect clip duration.
- If below threshold, call the padder and produce a temporary padded file (e.g., `recording_padded.wav`).
- Pass the padded URL to `parakeet.transcribe` and clean up temp files afterward.
2. Alternatively (if we want to contain logic), embed the padding call in `ParakeetClient.transcribe` so every backend use (future features/tests) benefits without touching the rest of the app flow.
3. Ensure padding happens off the main actor to avoid UI stalls—`TranscriptionClient` already runs inside an async task, so heavy I/O should stay there.
### Phase 4 – Telemetry & Recovery UX
1. Emit structured logs when padding occurs, including original duration and pad length, so we can grep Console for “Parakeet padded clip”.
2. If FluidAudio still rejects the clip after padding, surface a user-friendly error and optionally auto-retry with a slightly longer pad.
3. Consider capturing anonymized counts (number of padded clips per session) for future tuning once analytics hooks exist.
## Validation Strategy
- **Unit tests:**
- `ShortClipPadderTests` verifying sample count math, header rewrites, and idempotent behavior when clip already meets threshold.
- **Integration smoke test:**
- CLI harness that feeds a synthetic 150 ms WAV through `ParakeetClient` with padding enabled; ensure the decoder returns text (or at least does not throw).
- **Manual QA:**
- Tap hotkey quickly on macOS 15 (Intel + Apple Silicon). Confirm transcripts appear instead of “clip too short” errors, and latency increase is negligible (<5 ms for padding).
## Open Questions
1. Does `AsrManager.transcribe` stream audio internally (making padding unnecessary if we tweak its chunk config) or does it expect fully sized files only?
2. Should we preserve raw, unpadded recordings for history exports while only padding the temporary file handed to Parakeet?
3. Do we need to bump the `RecordingDecisionEngine` thresholds so we still discard accidental tap-noise clips, or is padding enough?
4. Could insanely short clips (e.g., <10 ms) produce audible pops once padded? If so, we may need a pre-smoothing step (fade in/out) before zero-fill.
> Next action: gather the concrete min-duration requirement from FluidAudio docs and pick an injection point (TranscriptionClient vs. ParakeetClient) so we can spike the padding helper.
+302
View File
@@ -0,0 +1,302 @@
# Hex Release Pipeline - GitHub Actions Plan
## Context
Need to set up automated release pipeline for macOS app distribution via:
- **Sparkle** (in-app updates via S3)
- **Homebrew Cask** (requires ZIP artifact)
- **GitHub Releases** (visibility/download page)
## Current State
### What Works ✅
- `scripts/tool.py` - Python script, works locally
- `tools/release.ts` - TypeScript/Effect version, works locally
- Both use local notarytool keychain profile (`AC_PASSWORD`)
- Both upload to S3, generate Sparkle appcast
### What's Broken ❌
- `.github/workflows/release.yml` - Incomplete, has TODO stubs
- No ZIP creation (Homebrew needs this)
- No GitHub release integration
## Standard Approaches (2025)
### Option A: Pure GitHub Actions YAML
**How most projects do it:**
```yaml
- uses: apple-actions/import-codesign-certs@v2
- run: xcodebuild ...
- uses: lando/notarize-action@v2
- run: create-dmg ...
- uses: actions/create-release@v1
```
**Pros:** Standard, lots of examples, community actions
**Cons:** Verbose YAML, hard to test locally, logic spread across steps
### Option B: Call Local Script (What You Have)
**Use existing `release.ts` from workflow:**
```yaml
- uses: oven-sh/setup-bun@v2
- run: bun run tools/release.ts --bucket hex-updates
```
**Pros:** Test locally, type-safe, reusable, Effect composability
**Cons:** Need env var auth fallback for CI
### Option C: Hybrid
Use actions for signing/notarization, script for build/upload
## Recommended: Option B (Enhanced)
Why? You already built a complete, working TypeScript tool. Just need CI auth.
### Architecture
```
GitHub Actions Workflow
├─ Setup (checkout, certs, Bun)
├─ Call: bun run tools/release.ts
│ ├─ Detects CI via env
│ ├─ Uses APPLE_ID/PASSWORD instead of keychain
│ ├─ Builds, signs, notarizes
│ ├─ Creates DMG + ZIP
│ ├─ Uploads to S3 (Sparkle)
│ └─ Returns artifacts paths
└─ Create GitHub Release (upload DMG + ZIP)
```
### Required Changes
#### 1. Modify `release.ts`
**Add CI detection:**
```typescript
const getNotarizeArgs = () => {
if (process.env.GITHUB_ACTIONS) {
return [
"--apple-id", process.env.APPLE_ID!,
"--password", process.env.APPLE_ID_PASSWORD!,
"--team-id", process.env.TEAM_ID!
]
}
return ["--keychain-profile", "AC_PASSWORD"]
}
```
**Add ZIP creation:**
```typescript
// After DMG creation
yield* runCommandCheck("ditto", "-c", "-k", "--keepParent",
appBundle, join(updatesDir, `Hex-${newVersion}.zip`))
```
#### 2. Update Workflow
Replace `.github/workflows/release.yml` with:
```yaml
name: Release
on:
workflow_dispatch:
inputs:
version_bump:
type: choice
options: [patch, minor, major]
default: patch
jobs:
release:
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- name: Setup Xcode
run: sudo xcode-select -s /Applications/Xcode_16.2.app
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: cd tools && bun install
- name: Import signing certificate
env:
MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }}
MACOS_CERTIFICATE_PWD: ${{ secrets.MACOS_CERTIFICATE_PWD }}
run: |
# Decode and import cert to default keychain
echo "$MACOS_CERTIFICATE" | base64 --decode > /tmp/cert.p12
security import /tmp/cert.p12 -P "$MACOS_CERTIFICATE_PWD" -A
security set-key-partition-list -S apple-tool:,apple: -s -k "" login.keychain-db
rm /tmp/cert.p12
- name: Build and release
run: bun run tools/release.ts --bucket hex-updates
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
TEAM_ID: ${{ secrets.TEAM_ID }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
- name: Create GitHub Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION=$(cat Hex/Info.plist | grep -A1 CFBundleShortVersionString | tail -1 | sed 's/.*<string>\(.*\)<\/string>.*/\1/')
# release.ts already tags + creates the release with the aggregated changelog.
# Keep this step only if we ever skip that behavior in CI.
gh release create "v$VERSION" \
--title "Hex v$VERSION" \
--notes-file build/release-notes-$VERSION.md \
updates/Hex-$VERSION.dmg \
updates/Hex-$VERSION.zip
```
### Required Secrets (9)
Set via: `gh secret set SECRET_NAME`
```bash
# Apple Developer
APPLE_ID # your@email.com
APPLE_ID_PASSWORD # xxxx-xxxx-xxxx-xxxx (app-specific)
TEAM_ID # QC99C9JE59
DEVELOPMENT_TEAM # QC99C9JE59 (same as TEAM_ID)
# Code Signing
MACOS_CERTIFICATE # base64 encoded .p12
MACOS_CERTIFICATE_PWD # .p12 password
# S3 / Sparkle
AWS_ACCESS_KEY_ID # S3 access
AWS_SECRET_ACCESS_KEY # S3 secret
# Auto-provided
GITHUB_TOKEN # Auto-available in actions
```
#### How to Generate
**App-specific password:**
```bash
# 1. Go to appleid.apple.com
# 2. Sign in > Security > App-Specific Passwords
# 3. Generate for "Hex GitHub Actions"
# 4. Copy xxxx-xxxx-xxxx-xxxx
```
**Certificate base64:**
```bash
# Export from Keychain Access as .p12
base64 -i DeveloperID.p12 | pbcopy
# Paste into secret
```
## Comparison: What Others Do
### Electron Apps (Sparkle, DMG)
- **VSCode**: Custom Azure Pipelines + scripts
- **Obsidian**: Electron-builder in Actions
- **Raycast**: Similar to our Option B (script from workflow)
### Native macOS Apps
- **Bartender**: Manual releases
- **Alfred**: Private infrastructure
- **Rectangle**: GitHub Actions with notarize action
**Trend:** Moving toward script-based (easier to test/debug locally)
## Homebrew Cask Setup
After first release:
```bash
# 1. Create cask
brew create --cask hex
# 2. Test
brew install --cask hex
# 3. Submit PR to homebrew/cask
# OR create personal tap: homebrew-hex
```
**Cask needs:**
- ZIP artifact (not DMG) - universal convention
- GitHub release URL
- SHA256 (auto-calculated by brew)
## Testing Strategy
### Local Testing
```bash
# Full release (dry-run flag needed?)
bun run tools/release.ts --bucket hex-updates-test
# Test specific steps
bun run tools/release.ts --skip-upload
```
### CI Testing
```bash
# Trigger workflow
gh workflow run release.yml
# Monitor
gh run list
gh run view <run-id>
```
## Migration Path
1. ✅ Modify `release.ts` - add CI auth
2. ✅ Add ZIP creation to `release.ts`
3. ✅ Replace workflow YAML
4. ✅ Set all 9 secrets
5. ✅ Test release to staging S3 bucket
6. ✅ First real release
7. ✅ Submit Homebrew cask
## ✅ IMPLEMENTED (Tag-Based Approach)
### Version Management
**Solved:** Git tags determine version
- Push `v0.2.12` → builds version `0.2.12`
- Auto-increments build number
- Updates all version files
### Configuration
**Solved:** Effect Config system
- Auto-detects CI vs local environment
- Uses keychain locally, env vars in CI
- Type-safe configuration
### Artifacts
**Solved:** Creates both DMG + ZIP
- DMG for direct download / Sparkle
- ZIP for Homebrew cask
- Both notarized and signed
### Changelog Automation
**Solved:** Changesets CLI drives SemVer + release notes
- Contributors run `bunx changeset` to capture a patch/minor/major summary
- Release tool enforces pending changesets, runs `changeset version`, and copies the aggregated `CHANGELOG.md` into `Hex/Resources/changelog.md`
- GitHub releases reuse the generated changelog section via `--notes-file`, so Sparkle, the in-app sheet, and GitHub stay identical
## Remaining Questions
1. **S3 bucket:** Staging bucket for pre-release testing?
2. **Rollback:** Failed notarization handling?
3. **Certificate expiry:** Track and rotate Developer ID cert
## Completed Tasks
- [x] Update `release.ts` with Effect Config
- [x] Add CI detection (Apple creds optional)
- [x] Add ZIP creation for Homebrew
- [x] Rewrite workflow for tag-based releases
- [x] Document release process
- [ ] Configure secrets (manual step)
- [ ] First release test
- [ ] Submit Homebrew cask
+154
View File
@@ -0,0 +1,154 @@
# Hex Release Process
## Overview
Releases are triggered by pushing git tags. The system automatically:
- Builds & signs the app
- Notarizes with Apple
- Creates DMG + ZIP artifacts
- Uploads to S3 for Sparkle updates
- Creates GitHub release
## Quick Start
```bash
# Create and push a tag
git tag v0.2.12
git push origin v0.2.12
# GitHub Actions will automatically:
# 1. Build Hex v0.2.12
# 2. Notarize with Apple
# 3. Upload to S3 (Sparkle)
# 4. Create GitHub release with DMG + ZIP
```
## Architecture
**Tag-based versioning:**
- Tag `v0.2.12` → builds version `0.2.12`
- Updates `Info.plist` and `project.pbxproj`
- Auto-increments build number
**Effect Config system:**
```typescript
// Reads from environment variables
BUCKET=hex-updates // Default
VERSION=v0.2.12 // From git tag
APPLE_ID=your@email.com // CI only
APPLE_ID_PASSWORD=xxxx-xxxx // CI only
AWS_ACCESS_KEY_ID=... // Required
AWS_SECRET_ACCESS_KEY=... // Required
```
**Local vs CI:**
- **Local**: Uses keychain profile `AC_PASSWORD`
- **CI**: Uses `APPLE_ID` / `APPLE_ID_PASSWORD` env vars
## Local Testing
```bash
# Setup keychain profile (one-time)
xcrun notarytool store-credentials "AC_PASSWORD"
# Test release locally (doesn't upload)
cd tools
VERSION=v0.2.12-test \
AWS_ACCESS_KEY_ID=... \
AWS_SECRET_ACCESS_KEY=... \
bun run release.ts
```
## Required Secrets
Set via: `gh secret set SECRET_NAME`
### Apple (Notarization)
```bash
APPLE_ID # your@email.com
APPLE_ID_PASSWORD # App-specific password from appleid.apple.com
TEAM_ID # QC99C9JE59
```
### Code Signing
```bash
MACOS_CERTIFICATE # base64 -i cert.p12 | pbcopy
MACOS_CERTIFICATE_PWD # Certificate password
```
### AWS (S3 / Sparkle)
```bash
AWS_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY
```
## Artifacts
Each release creates:
- `Hex-{version}.dmg` - Signed, notarized DMG
- `Hex-{version}.zip` - For Homebrew cask
- `hex-latest.dmg` - Always points to latest
- `appcast.xml` - Sparkle update feed
## Homebrew Cask
After first release, update `hex.rb`:
```bash
# Get SHA256
curl -L https://github.com/kitlangton/Hex/releases/download/v0.2.12/Hex-v0.2.12.zip -o Hex.zip
shasum -a 256 Hex.zip
# Update hex.rb with version and SHA
```
Submit to:
- **Personal tap**: `homebrew-hex` (easier)
- **Official cask**: PR to `homebrew/homebrew-cask`
## Critical Constraints
### CFBundleVersion Requirements
**NEVER manually edit CFBundleVersion or reuse build numbers.** The release pipeline automatically increments CFBundleVersion with each release to ensure Sparkle can properly generate the appcast feed.
- `updates/` directory must only contain DMGs with strictly increasing CFBundleVersion values
- Duplicate build numbers will block appcast generation and break updates for existing users
- The release script preserves the last 3 DMGs in `updates/` for delta generation
- Older versions are automatically moved to `updates/old_updates/`
If you accidentally create a release with a duplicate CFBundleVersion:
1. Delete the problematic DMG from `updates/`
2. Move any other old DMGs to `updates/old_updates/`
3. Regenerate appcast: `./bin/generate_appcast --maximum-deltas 3 updates`
4. Re-upload cleaned artifacts to S3
## Troubleshooting
### Notarization fails
- Check Apple ID credentials
- Verify app-specific password
- Ensure `TEAM_ID` is correct
### S3 upload fails
- Verify AWS credentials
- Check bucket permissions
- Ensure bucket exists
### Build fails
- Check Xcode version (16.2)
- Verify code signing setup
- Check certificate validity
### Sparkle updates not appearing
- Verify appcast.xml lists versions in descending CFBundleVersion order
- Check that CFBundleVersion values are unique and strictly increasing
- Ensure no duplicate build numbers exist in updates/
- Test feed URL: https://hex-updates.s3.amazonaws.com/appcast.xml
## Files
- `tools/release.ts` - Main release script (Effect)
- `.github/workflows/release.yml` - CI workflow
- `bin/generate_appcast` - Sparkle appcast generator
- `hex.rb` - Homebrew cask formula