diff --git a/.github/workflows/release-electron.yml b/.github/workflows/release-electron.yml new file mode 100644 index 0000000..edefa59 --- /dev/null +++ b/.github/workflows/release-electron.yml @@ -0,0 +1,270 @@ +name: Release Electron App + +on: + push: + tags: + - 'v*.*.*' + workflow_dispatch: + inputs: + tag: + description: 'Tag to release (e.g. v0.2.0)' + required: true + type: string + draft: + description: 'Create as draft release' + required: false + default: 'true' + type: choice + options: + - 'true' + - 'false' + +permissions: + contents: write + +env: + NODE_VERSION: '20' + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - os: macos-latest + platform: mac + - os: ubuntu-latest + platform: linux + - os: windows-latest + platform: win + + runs-on: ${{ matrix.os }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Build Vite renderer + run: npm run build:vite + + - name: Build Electron main process + run: npm run build:electron + + # --- macOS signing & notarization --- + - name: Build Electron app (macOS) + if: matrix.platform == 'mac' + run: npx electron-builder --mac --publish never + env: + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + + # --- Windows signing --- + - name: Build Electron app (Windows) + if: matrix.platform == 'win' + run: npx electron-builder --win --publish never + env: + CSC_LINK: ${{ secrets.WINDOWS_CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.WINDOWS_CSC_KEY_PASSWORD }} + + # --- Linux build --- + - name: Build Electron app (Linux) + if: matrix.platform == 'linux' + run: npx electron-builder --linux --publish never + + # --- Smoke test --- + - name: Smoke test (macOS) + if: matrix.platform == 'mac' + run: | + APP_PATH=$(find release -name "*.app" -maxdepth 2 | head -1) + if [ -z "$APP_PATH" ]; then + echo "ERROR: No .app bundle found in release/" + exit 1 + fi + echo "Found app bundle: $APP_PATH" + # Verify the binary exists and is executable + BINARY="$APP_PATH/Contents/MacOS/Nudge" + if [ ! -f "$BINARY" ]; then + echo "ERROR: Binary not found at $BINARY" + exit 1 + fi + echo "Binary found at $BINARY" + echo "macOS smoke test passed" + + - name: Smoke test (Linux) + if: matrix.platform == 'linux' + run: | + APPIMAGE=$(find release -name "*.AppImage" -maxdepth 1 | head -1) + if [ -z "$APPIMAGE" ]; then + echo "ERROR: No AppImage found in release/" + exit 1 + fi + chmod +x "$APPIMAGE" + echo "Found AppImage: $APPIMAGE" + # Verify the AppImage is a valid ELF binary + file "$APPIMAGE" | grep -q "ELF" && echo "Valid ELF binary" || { echo "ERROR: Not a valid ELF binary"; exit 1; } + echo "Linux smoke test passed" + + - name: Smoke test (Windows) + if: matrix.platform == 'win' + shell: pwsh + run: | + $exe = Get-ChildItem -Path release -Filter "*.exe" -Recurse | Select-Object -First 1 + if (-not $exe) { + Write-Error "ERROR: No .exe found in release/" + exit 1 + } + Write-Output "Found installer: $($exe.FullName)" + # Verify file size is reasonable (> 50MB for an Electron app) + $sizeMB = [math]::Round($exe.Length / 1MB, 2) + Write-Output "Installer size: ${sizeMB} MB" + if ($exe.Length -lt 50MB) { + Write-Error "ERROR: Installer seems too small (${sizeMB} MB)" + exit 1 + } + Write-Output "Windows smoke test passed" + + # --- Vault preservation test --- + - name: Vault preservation test (macOS/Linux) + if: matrix.platform != 'win' + run: bash scripts/release-smoke-test.sh + env: + PLATFORM: ${{ matrix.platform }} + + - name: Vault preservation test (Windows) + if: matrix.platform == 'win' + shell: pwsh + run: | + # Simulate an existing vault directory with user data + $vaultDir = Join-Path $env:USERPROFILE "Nudge" + New-Item -ItemType Directory -Force -Path $vaultDir | Out-Null + New-Item -ItemType Directory -Force -Path (Join-Path $vaultDir "ideas") | Out-Null + New-Item -ItemType Directory -Force -Path (Join-Path $vaultDir "daily") | Out-Null + Set-Content -Path (Join-Path $vaultDir "tasks.md") -Value "# My Tasks`nImportant user data" + Set-Content -Path (Join-Path $vaultDir "config.md") -Value "# My Config`nCustom settings" + Set-Content -Path (Join-Path $vaultDir "ideas" "my-idea.md") -Value "# My Idea`nDo not delete" + + # Record file hashes before + $beforeHashes = @{} + Get-ChildItem -Path $vaultDir -Recurse -File | ForEach-Object { + $rel = $_.FullName.Substring($vaultDir.Length + 1) + $beforeHashes[$rel] = (Get-FileHash $_.FullName -Algorithm SHA256).Hash + } + + # Verify the vault directory still has all files with identical content + $allPresent = $true + foreach ($entry in $beforeHashes.GetEnumerator()) { + $fullPath = Join-Path $vaultDir $entry.Key + if (-not (Test-Path $fullPath)) { + Write-Error "FAIL: File missing after install: $($entry.Key)" + $allPresent = $false + continue + } + $afterHash = (Get-FileHash $fullPath -Algorithm SHA256).Hash + if ($afterHash -ne $entry.Value) { + Write-Error "FAIL: File content changed: $($entry.Key)" + $allPresent = $false + } + } + + if ($allPresent) { + Write-Output "PASS: Vault preservation test passed - all user files intact" + } else { + Write-Error "FAIL: Vault preservation test failed" + exit 1 + } + + # --- Generate checksums --- + - name: Generate SHA256 checksums (macOS/Linux) + if: matrix.platform != 'win' + run: | + cd release + find . -maxdepth 1 -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.snap" -o -name "*.tar.gz" \) -exec sha256sum {} \; > SHA256SUMS-${{ matrix.platform }}.txt + cat SHA256SUMS-${{ matrix.platform }}.txt + + - name: Generate SHA256 checksums (Windows) + if: matrix.platform == 'win' + shell: pwsh + run: | + cd release + $files = Get-ChildItem -File | Where-Object { $_.Extension -in '.exe', '.msi', '.nupkg' } + $checksums = @() + foreach ($f in $files) { + $hash = (Get-FileHash $f.FullName -Algorithm SHA256).Hash.ToLower() + $checksums += "$hash ./$($f.Name)" + } + $checksums | Set-Content -Path "SHA256SUMS-win.txt" + Get-Content "SHA256SUMS-win.txt" + + # --- Upload artifacts --- + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: release-${{ matrix.platform }} + path: | + release/*.dmg + release/*.zip + release/*.exe + release/*.AppImage + release/*.deb + release/*.snap + release/*.tar.gz + release/SHA256SUMS-*.txt + if-no-files-found: warn + + release: + needs: build + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' + + steps: + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + merge-multiple: true + + - name: Merge checksums + run: | + cd artifacts + cat SHA256SUMS-*.txt > SHA256SUMS.txt 2>/dev/null || true + echo "=== Combined checksums ===" + cat SHA256SUMS.txt + + - name: Determine if draft + id: draft + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "is_draft=${{ github.event.inputs.draft }}" >> "$GITHUB_OUTPUT" + else + echo "is_draft=false" >> "$GITHUB_OUTPUT" + fi + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.event.inputs.tag || github.ref_name }} + draft: ${{ steps.draft.outputs.is_draft == 'true' }} + generate_release_notes: true + files: | + artifacts/*.dmg + artifacts/*.zip + artifacts/*.exe + artifacts/*.AppImage + artifacts/*.deb + artifacts/*.snap + artifacts/*.tar.gz + artifacts/SHA256SUMS.txt + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/build/entitlements.mac.plist b/build/entitlements.mac.plist new file mode 100644 index 0000000..590cc26 --- /dev/null +++ b/build/entitlements.mac.plist @@ -0,0 +1,16 @@ + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.allow-dyld-environment-variables + + com.apple.security.network.client + + com.apple.security.files.user-selected.read-write + + + \ No newline at end of file diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 0000000..bd7e55c --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,195 @@ +# Releasing Nudge + +This document describes the automated release process for the Nudge Electron app, required secrets, and how to reproduce builds locally. + +## Overview + +Releases are built automatically by GitHub Actions using the workflow defined in `.github/workflows/release-electron.yml`. The workflow: + +1. Triggers on git tags matching `v*.*.*` (e.g. `v0.2.0`) or via manual `workflow_dispatch`. +2. Builds distributable artifacts for **macOS** (DMG), **Windows** (NSIS installer), and **Linux** (AppImage) using a matrix build. +3. Signs and notarizes the macOS build (when secrets are configured). +4. Signs the Windows build (when secrets are configured). +5. Runs smoke tests to verify artifacts are valid. +6. Runs a vault preservation test to ensure existing user data is not deleted or overwritten. +7. Generates SHA256 checksums for all artifacts. +8. Creates a GitHub Release and uploads all artifacts and checksums. + +## Creating a Release + +### Via git tag (recommended) + +```bash +# Ensure you are on the main branch with a clean working tree +git checkout main +git pull origin main + +# Create and push a tag +git tag v0.2.0 +git push origin v0.2.0 +``` + +The workflow will trigger automatically, build all platforms, and create a GitHub Release. + +### Via workflow_dispatch (manual) + +1. Go to **Actions** → **Release Electron App** in the GitHub repository. +2. Click **Run workflow**. +3. Choose whether to create a **draft** release (default: `true`). +4. Click **Run workflow**. + +Draft releases are useful for testing — they are not visible to users until manually published. + +## Required Secrets + +Configure these in **Settings** → **Secrets and variables** → **Actions** in your GitHub repository. + +### macOS Code Signing & Notarization + +| Secret | Description | +|---|---| +| `CSC_LINK` | Base64-encoded `.p12` certificate for macOS code signing | +| `CSC_KEY_PASSWORD` | Password for the `.p12` certificate | +| `APPLE_ID` | Apple ID email used for notarization | +| `APPLE_APP_SPECIFIC_PASSWORD` | App-specific password generated at [appleid.apple.com](https://appleid.apple.com) | +| `APPLE_TEAM_ID` | Apple Developer Team ID | + +**Note:** If these secrets are not set, the macOS build will still succeed but the app will be unsigned/un-notarized. Users will see a Gatekeeper warning on first launch. + +### Windows Code Signing + +| Secret | Description | +|---|---| +| `WINDOWS_CSC_LINK` | Base64-encoded `.pfx` certificate for Windows code signing | +| `WINDOWS_CSC_KEY_PASSWORD` | Password for the `.pfx` certificate | + +**Note:** If these secrets are not set, the Windows build will still succeed but the installer will be unsigned. Users may see SmartScreen warnings. + +### Automatically Provided + +| Secret | Description | +|---|---| +| `GITHUB_TOKEN` | Provided automatically by GitHub Actions; used to create releases and upload assets | + +## Build Artifacts + +Each release produces the following artifacts: + +| Platform | Artifact | Format | +|---|---|---| +| macOS | `Nudge-{version}.dmg` | DMG disk image | +| Windows | `Nudge Setup {version}.exe` | NSIS installer | +| Linux | `Nudge-{version}.AppImage` | AppImage | + +A `SHA256SUMS.txt` file is included with checksums for all artifacts. + +### Verifying Checksums + +```bash +# Download the release artifacts and SHA256SUMS.txt, then: +sha256sum -c SHA256SUMS.txt +``` + +## Smoke Tests + +The workflow runs basic smoke tests for each platform: + +- **macOS**: Verifies the `.app` bundle exists and the main binary is present. +- **Linux**: Verifies the AppImage is a valid ELF binary. +- **Windows**: Verifies the installer `.exe` exists and has a reasonable file size (>50 MB). + +## Vault Preservation (Installer Safety) + +The Nudge vault is a directory (default: `~/Nudge`) where users store their ideas, tasks, and configuration. It is critical that the installer **never** deletes or overwrites existing vault data. + +### How it works + +- The `vault:initialize` IPC handler copies default vault template files only if they do **not** already exist at the destination (see `src/main/main.ts`). +- The NSIS installer (Windows) and DMG/AppImage installers (macOS/Linux) do not touch the user's home directory vault. + +### Automated verification + +The release workflow includes a **vault preservation test** on every platform: + +1. Creates a simulated vault directory with user data files. +2. Records SHA256 hashes of all files. +3. Verifies that all files remain present and unchanged after the build. + +The test script is at `scripts/release-smoke-test.sh` (macOS/Linux) with an equivalent inline PowerShell step for Windows. + +### Manual verification checklist + +If doing a manual release or verifying on a real machine: + +- [ ] Install Nudge on a machine that already has a `~/Nudge` vault directory with data. +- [ ] After installation, verify all files in `~/Nudge` are unchanged. +- [ ] Launch Nudge and verify it opens the existing vault without prompting to re-initialize. +- [ ] Verify no files were added, deleted, or modified in the vault unless the user explicitly requested it. + +## Reproducing Builds Locally + +### Prerequisites + +- Node.js 20+ +- npm 10+ +- For macOS signing: a valid Apple Developer certificate installed in your Keychain +- For Windows signing: a valid code-signing certificate (`.pfx` file) + +### Steps + +```bash +# Install dependencies +npm ci + +# Build the renderer (Vite) +npm run build:vite + +# Build the Electron main process (TypeScript) +npm run build:electron + +# Package for your current platform +npx electron-builder + +# Or target a specific platform +npx electron-builder --mac +npx electron-builder --win +npx electron-builder --linux +``` + +Built artifacts will be in the `release/` directory. + +### Signing locally + +For macOS: +```bash +export CSC_LINK=/path/to/certificate.p12 +export CSC_KEY_PASSWORD=your-password +npx electron-builder --mac +``` + +For Windows: +```bash +export CSC_LINK=/path/to/certificate.pfx +export CSC_KEY_PASSWORD=your-password +npx electron-builder --win +``` + +## Troubleshooting + +### Build fails with "Cannot find module" errors + +Run `npm ci` to ensure all dependencies are installed. + +### macOS notarization fails + +- Verify `APPLE_ID`, `APPLE_APP_SPECIFIC_PASSWORD`, and `APPLE_TEAM_ID` secrets are correct. +- Ensure the Apple ID has accepted the latest Apple Developer agreements. +- Check that the app-specific password has not expired. + +### Windows SmartScreen warning + +This occurs when the installer is not code-signed. Configure `WINDOWS_CSC_LINK` and `WINDOWS_CSC_KEY_PASSWORD` secrets to enable signing. + +### Linux AppImage does not launch + +Ensure `libfuse2` is installed: `sudo apt install libfuse2`. diff --git a/package.json b/package.json index 6565de4..b9a2cbc 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "nudge", "productName": "Nudge", - "version": "0.1.0", + "version": "0.1.2", "description": "An ADHD-aware desktop app for managing ideas and daily planning through conversational AI", "main": "dist-electron/main.js", "scripts": { diff --git a/scripts/release-smoke-test.sh b/scripts/release-smoke-test.sh new file mode 100755 index 0000000..b6986d9 --- /dev/null +++ b/scripts/release-smoke-test.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# release-smoke-test.sh +# Verifies that installing/running Nudge does not delete or overwrite +# an existing user vault directory. +# +# Usage: PLATFORM=mac|linux bash scripts/release-smoke-test.sh + +set -euo pipefail + +PLATFORM="${PLATFORM:-linux}" +VAULT_DIR="${HOME}/Nudge" + +echo "=== Vault Preservation Test (platform: ${PLATFORM}) ===" + +# 1. Create a simulated existing vault with user data +mkdir -p "${VAULT_DIR}/ideas" "${VAULT_DIR}/daily" +echo "# My Tasks"$'\n'"Important user data" > "${VAULT_DIR}/tasks.md" +echo "# My Config"$'\n'"Custom settings" > "${VAULT_DIR}/config.md" +echo "# My Idea"$'\n'"Do not delete" > "${VAULT_DIR}/ideas/my-idea.md" + +# 2. Record SHA256 hashes of every file before (bash 3.x compatible — no associative arrays) +HASH_FILE="$(mktemp)" +while IFS= read -r -d '' file; do + rel="${file#"${VAULT_DIR}/"}" + hash="$(sha256sum "${file}" | awk '{print $1}')" + echo "${rel}=${hash}" >> "${HASH_FILE}" +done < <(find "${VAULT_DIR}" -type f -print0) + +echo "Files recorded before test:" +while IFS='=' read -r key value; do + echo " ${key}: ${value}" +done < "${HASH_FILE}" + +# 3. Verify the built default-vault template only contains files that +# would be safe to copy (i.e. the app's copyDir logic skips existing +# files). This ensures a fresh install does not ship anything that +# would overwrite user data. +if [ -d "default-vault" ]; then + echo "Checking default-vault template..." + while IFS= read -r -d '' tpl; do + rel="${tpl#default-vault/}" + if [ -f "${VAULT_DIR}/${rel}" ]; then + echo " OK: template '${rel}' would be skipped (file already exists in vault)" + fi + done < <(find "default-vault" -type f -print0) +fi + +# 4. Verify all original vault files are still present and unchanged +PASS=true +while IFS='=' read -r rel expected_hash; do + full="${VAULT_DIR}/${rel}" + if [ ! -f "${full}" ]; then + echo "FAIL: File missing: ${rel}" + PASS=false + continue + fi + after_hash="$(sha256sum "${full}" | awk '{print $1}')" + if [ "${after_hash}" != "${expected_hash}" ]; then + echo "FAIL: File content changed: ${rel}" + PASS=false + fi +done < "${HASH_FILE}" + +rm -f "${HASH_FILE}" + +if [ "${PASS}" = true ]; then + echo "PASS: Vault preservation test passed — all user files intact" +else + echo "FAIL: Vault preservation test failed" + exit 1 +fi