diff --git a/.github/workflows/publish-vscode.yml b/.github/workflows/publish-vscode.yml deleted file mode 100644 index c673c7c..0000000 --- a/.github/workflows/publish-vscode.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: Publish VSCode Extension - -on: - push: - tags: - - 'vscode-ext/v*' - -jobs: - publish: - runs-on: macos-latest - steps: - - uses: actions/checkout@v4 - - - uses: pnpm/action-setup@v4 - with: - version: 10 - - - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: pnpm - cache-dependency-path: | - lib/pnpm-lock.yaml - vscode-ext/pnpm-lock.yaml - - # Install and build frontend for VSCode - - name: Install frontend dependencies - run: cd lib && pnpm install - - - name: Run tests - run: cd lib && pnpm test - - - name: Build frontend for VSCode - run: cd vscode-ext && pnpm build:frontend - - # Install and build extension - - name: Install extension dependencies - run: cd vscode-ext && pnpm install - - - name: Build extension - run: cd vscode-ext && pnpm build - - # Package - - name: Package extension - run: cd vscode-ext && npx vsce package --no-dependencies - - # Publish to VS Code Marketplace - - name: Publish to VS Code Marketplace - run: cd vscode-ext && npx vsce publish --no-dependencies - env: - VSCE_PAT: ${{ secrets.VSCE_PAT }} - - # Publish to OpenVSX - - name: Publish to OpenVSX - run: cd vscode-ext && npx ovsx publish --no-dependencies - env: - OVSX_PAT: ${{ secrets.OVSX_PAT }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..047805e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,153 @@ +name: Release + +on: + push: + tags: + - 'v*' + +jobs: + build-standalone: + name: Build Standalone (${{ matrix.target }}) + strategy: + matrix: + include: + - platform: ubuntu-22.04 + target: x86_64-unknown-linux-gnu + artifact-name: standalone-linux-x64 + - platform: macos-latest + target: aarch64-apple-darwin + artifact-name: standalone-mac-aarch64 + - platform: macos-latest + target: x86_64-apple-darwin + artifact-name: standalone-mac-x86_64 + - platform: windows-latest + target: x86_64-pc-windows-msvc + artifact-name: standalone-win-x64 + runs-on: ${{ matrix.platform }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Install workspace dependencies + run: pnpm install --frozen-lockfile + + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Rust cache + uses: swatinem/rust-cache@v2 + with: + workspaces: standalone/src-tauri + + - name: Install system dependencies (Linux) + if: matrix.platform == 'ubuntu-22.04' + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf + + - name: Build Tauri app + uses: tauri-apps/tauri-action@v0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + projectPath: standalone + tauriScript: pnpm tauri + args: --target ${{ matrix.target }} + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact-name }} + path: | + standalone/src-tauri/target/${{ matrix.target }}/release/bundle/**/*.exe + standalone/src-tauri/target/${{ matrix.target }}/release/bundle/**/*.msi + standalone/src-tauri/target/${{ matrix.target }}/release/bundle/**/*.dmg + standalone/src-tauri/target/${{ matrix.target }}/release/bundle/**/*.app + standalone/src-tauri/target/${{ matrix.target }}/release/bundle/**/*.app.tar.gz + standalone/src-tauri/target/${{ matrix.target }}/release/bundle/**/*.app.tar.gz.sig + standalone/src-tauri/target/${{ matrix.target }}/release/bundle/**/*.AppImage + standalone/src-tauri/target/${{ matrix.target }}/release/bundle/**/*.AppImage.tar.gz + standalone/src-tauri/target/${{ matrix.target }}/release/bundle/**/*.AppImage.tar.gz.sig + standalone/src-tauri/target/${{ matrix.target }}/release/bundle/**/*.deb + standalone/src-tauri/target/${{ matrix.target }}/release/bundle/**/*.nsis.zip + standalone/src-tauri/target/${{ matrix.target }}/release/bundle/**/*.nsis.zip.sig + standalone/src-tauri/target/${{ matrix.target }}/release/bundle/nsis/** + + build-vscode: + name: Build VSCode Extension + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Install workspace dependencies + run: pnpm install --frozen-lockfile + + - name: Test lib + run: pnpm --filter mouseterm-lib test + + - name: Build frontend for VSCode + run: pnpm --filter mouseterm build:frontend + + - name: Build extension + run: pnpm --filter mouseterm build + + - name: Package extension + run: cd vscode-ext && npx vsce package --no-dependencies + + - name: Upload .vsix + uses: actions/upload-artifact@v4 + with: + name: vscode-extension + path: vscode-ext/*.vsix + + publish-vscode: + name: Publish VSCode Extension + needs: + - build-standalone + - build-vscode + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Install workspace dependencies + run: pnpm install --frozen-lockfile + + - name: Download .vsix + uses: actions/download-artifact@v4 + with: + name: vscode-extension + path: vscode-ext + + - name: Publish to VS Code Marketplace + run: cd vscode-ext && npx vsce publish --packagePath *.vsix --no-dependencies + env: + VSCE_PAT: ${{ secrets.VSCE_PAT }} + + - name: Publish to OpenVSX + run: cd vscode-ext && npx ovsx publish --packagePath *.vsix --no-dependencies + env: + OVSX_PAT: ${{ secrets.OVSX_PAT }} diff --git a/.gitignore b/.gitignore index fd6dd3b..9018399 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,9 @@ vscode-ext/media/ vscode-ext/node_modules/ vscode-ext/*.vsix +# Release signing work directory (created by scripts/sign-and-deploy.sh) +release-signed/ + # Tauri / Standalone standalone/src-tauri/target/ standalone/src-tauri/binaries/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..fbc9986 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,9 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/). + +## [0.1.0] - 2026-04-09 + +- Initial release to test publishing. diff --git a/docs/specs/deploy.md b/docs/specs/deploy.md new file mode 100644 index 0000000..d6bc706 --- /dev/null +++ b/docs/specs/deploy.md @@ -0,0 +1,304 @@ +# Deploy Spec + +## What we ship + +Every release produces three artifact groups under one version and changelog: + +| Artifact | Format | Destination | +|----------|--------|-------------| +| VSCode extension | `.vsix` | VS Code Marketplace + OpenVSX | +| Standalone (Windows) | NSIS `.exe` installer | GitHub Release + Tauri updater | +| Standalone (macOS) | `.dmg` (install) + `.tar.gz` (update) | GitHub Release + Tauri updater | +| Standalone (Linux) | AppImage + `.deb` | GitHub Release + Tauri updater | + +## Versioning + +A single version number (`X.Y.Z`) applies to all artifacts. The version lives in three places that must stay in sync: + +- `standalone/src-tauri/tauri.conf.json` → `version` +- `vscode-ext/package.json` → `version` +- `lib/package.json` → `version` (if applicable) + +A release is triggered by pushing a tag: `v0.1.0`. This is intentionally a single tag (not separate `vscode-ext/v*` and `standalone/v*` tags) because we want one changelog entry for both. + +## Two-stage pipeline + +Code signing for Windows requires a physical USB hardware key (EV cert via PIV). macOS signing uses a local Developer ID cert. Both must happen locally. So: + +``` +Stage 1: CI (GitHub Actions) + → Build unsigned Tauri apps (win, mac, linux) + → Build + publish VSCode extension + → Upload unsigned Tauri artifacts + +Stage 2: Local (sign-and-deploy.sh) + → Download CI artifacts + → Sign macOS (codesign + notarize) + → Sign Windows (jsign + PIV hardware key) + → Generate Tauri update manifest with signatures + → Upload signed artifacts to GitHub Release +``` + +## Stage 1: CI workflow + +Triggered by tag push `v*`. Three parallel jobs: + +### Job: `build-standalone` (matrix) + +Runs on `ubuntu-22.04` (linux), `macos-latest` (mac), and `windows-latest` (win). Uses `tauri-apps/tauri-action@v0`. + +```yaml +strategy: + matrix: + include: + - platform: ubuntu-22.04 + target: x86_64-unknown-linux-gnu + - platform: macos-latest + target: aarch64-apple-darwin + - platform: macos-latest + target: x86_64-apple-darwin + - platform: windows-latest + target: x86_64-pc-windows-msvc +``` + +Each matrix leg: +1. Checkout, setup Node 22, pnpm 10, Rust stable +2. Install workspace dependencies once from the repo root with `pnpm install --frozen-lockfile` +3. Install system deps (Linux: libgtk, libwebkit, etc.) +4. Build via `tauri-action` — but **skip signing** (no `APPLE_SIGNING_IDENTITY`, no `TAURI_SIGNING_PRIVATE_KEY`) +5. Upload artifacts (installers + bundles) via `actions/upload-artifact` + +**Note:** We do NOT use `tauri-action`'s built-in GitHub Release creation. We create the release locally after signing. + +### Job: `build-vscode` + +Runs on `ubuntu-latest`: +1. Checkout, setup Node 22, pnpm 10 +2. `pnpm install --frozen-lockfile` at the repo root +3. `pnpm --filter mouseterm-lib test` +4. `pnpm --filter mouseterm build:frontend && pnpm --filter mouseterm build` +5. `npx vsce package --no-dependencies` +6. Upload `.vsix` as artifact + +### Job: `publish-vscode` + +Runs after `build-vscode` succeeds: +1. Download `.vsix` artifact +2. `npx vsce publish --packagePath *.vsix --no-dependencies` +3. `npx ovsx publish --packagePath *.vsix --no-dependencies` + +This runs in CI because VSCode Marketplace publishing uses PAT tokens (no hardware key needed). + +**Migration note:** This replaces the existing `.github/workflows/publish-vscode.yml`, which was triggered by `vscode-ext/v*` tags and has never been run. That workflow should be deleted when the unified release workflow is created. Fixes from the old workflow: use `ubuntu-latest` instead of `macos-latest`, upgrade to Node 22, and unify under the `v*` tag convention. + +## Stage 2: Local script + +`scripts/sign-and-deploy.sh` — modeled on the Type The Rhythm script. + +### Prerequisites + +```bash +brew install gh jsign +gh auth login +xcode-select --install +tauri signer generate # one-time: creates update signing keypair +``` + +### Signing identity + +| Platform | Tool | Identity | +|----------|------|----------| +| macOS | codesign + notarytool | Developer ID Application: DiffPlug LLC (LXW8WAGWYX) | +| Windows | jsign | PIV hardware key, alias AUTHENTICATION, TSA http://ts.ssl.com | + +### Two signing layers + +There are two independent signing layers. OS signing proves the executable is from DiffPlug; Tauri signing proves the update bundle hasn't been tampered with in transit. Both are required — they protect different things at different points in time. + +| Layer | What it signs | Who verifies | What happens without it | +|-------|--------------|--------------|------------------------| +| OS (codesign / jsign) | The executable (`.app` / `.exe`) | The OS, on launch | Gatekeeper / SmartScreen warnings | +| Tauri updater (ed25519) | The update bundle (`.tar.gz` / `.nsis.zip`) | The running app, on update | Updater rejects the download | + +**Order matters:** OS-sign the inner executable first, then package it into the update bundle, then Tauri-sign the bundle. The `.sig` file is generated from the final bundle that already contains the OS-signed binary. + +``` +codesign/jsign the executable + → package into update bundle (.tar.gz / .nsis.zip) + → Tauri-sign the bundle → produces .sig file + → upload bundle + .sig to GitHub Release +``` + +### Flow + +``` +./scripts/sign-and-deploy.sh all 0.1.0 +``` + +1. **Wait for CI** — find the workflow run for tag `v0.1.0`, poll until complete +2. **Download artifacts** — `gh run download` into `release-signed/` +3. **Sign macOS** (OS layer) + - Fix any framework symlink issues (artifact downloads flatten symlinks) + - `codesign --force --deep --sign "$IDENTITY" --entitlements ... --options runtime` + - Notarize via `xcrun notarytool submit --wait` + - `xcrun stapler staple` + - Re-package signed `.app` into `.dmg` (for direct download) and `.tar.gz` (for updater) +4. **Sign Windows** (OS layer) + - Sign the inner exe: `jsign --storetype PIV --storepass "$PIN" --alias AUTHENTICATION --tsaurl http://ts.ssl.com --tsmode RFC3161 MouseTerm.exe` + - Rebuild the NSIS installer around the signed exe + - Sign the installer exe: `jsign ... MouseTerm-windows-x64.exe` +5. **Sign update bundles** (Tauri layer) + - Tauri-sign each update bundle (the `.tar.gz` and `.nsis.zip` from steps 3-4) using `TAURI_SIGNING_PRIVATE_KEY` + - This produces a `.sig` file per bundle + - Build the update manifest JSON (see below) with the `.sig` contents inline +6. **Create GitHub Release** + - `gh release create v0.1.0 --title "v0.1.0" --notes-file CHANGELOG.md` + - Upload: signed installers (`.dmg`, `.exe`, `.AppImage`, `.deb`) + update bundles (`.tar.gz`, `.nsis.zip`) + `.sig` files + `latest.json` manifest +7. **Verify** — spot-check signatures, confirm release assets are correct + +### Resuming after failure + +```bash +./scripts/sign-and-deploy.sh resume 0.1.0 # re-download + sign + release +./scripts/sign-and-deploy.sh sign-mac # re-sign macOS only +./scripts/sign-and-deploy.sh sign-win # re-sign Windows only +./scripts/sign-and-deploy.sh release 0.1.0 # re-create GitHub Release only +``` + +## Artifact filenames + +All release assets use **stable filenames** (no version in the name). This allows hotlinking directly from mouseterm.com via GitHub's `/latest/download/` redirect, which always resolves to the most recent release. + +| Asset | Filename | Purpose | +|-------|----------|---------| +| Windows installer | `MouseTerm-windows-x64.exe` | Direct download | +| Windows update bundle | `MouseTerm-windows-x64.nsis.zip` | Tauri updater | +| macOS installer (ARM) | `MouseTerm-macos-aarch64.dmg` | Direct download | +| macOS update bundle (ARM) | `MouseTerm-macos-aarch64.tar.gz` | Tauri updater | +| macOS installer (Intel) | `MouseTerm-macos-x86_64.dmg` | Direct download | +| macOS update bundle (Intel) | `MouseTerm-macos-x86_64.tar.gz` | Tauri updater | +| Linux AppImage | `MouseTerm-linux-x86_64.AppImage` | Direct download | +| Linux update bundle | `MouseTerm-linux-x86_64.AppImage.tar.gz` | Tauri updater | +| Linux deb | `MouseTerm-linux-x86_64.deb` | Direct download | +| Update manifest | `latest.json` | Tauri updater endpoint | + +### Download hotlinks + +The mouseterm.com download page can link directly to the latest release with no server-side logic: + +``` +https://github.com/diffplug/mouseterm/releases/latest/download/MouseTerm-windows-x64.exe +https://github.com/diffplug/mouseterm/releases/latest/download/MouseTerm-macos-aarch64.dmg +https://github.com/diffplug/mouseterm/releases/latest/download/MouseTerm-macos-x86_64.dmg +https://github.com/diffplug/mouseterm/releases/latest/download/MouseTerm-linux-x86_64.AppImage +``` + +These can later be migrated to `mouseterm.com/download/...` URLs backed by Cloudflare R2 (for analytics) without changing anything in the app — only the website links and the updater endpoint URL in `tauri.conf.json` would change. + +## Tauri auto-updater + +### Configuration + +In `standalone/src-tauri/tauri.conf.json`: + +```json +{ + "bundle": { + "createUpdaterArtifacts": true + }, + "plugins": { + "updater": { + "pubkey": "", + "endpoints": [ + "https://mouseterm.com/standalone-latest.json" + ] + } + } +} +``` + +And in the Rust app bootstrap (`standalone/src-tauri/src/lib.rs`), the updater plugin is registered with: + +```rust +.plugin(tauri_plugin_updater::Builder::new().build()) +``` + +`standalone/src-tauri/Cargo.toml` must include `tauri-plugin-updater = "2"` so the configured updater endpoint is actually active at runtime. + +### Update manifest (`standalone-latest.json`) + +Generated by the local script after signing. The script copies it to `website/public/standalone-latest.json` so it's served from `mouseterm.com/standalone-latest.json` via Cloudflare Pages. This gives us request analytics on update checks. The manifest is also uploaded to the GitHub Release as a backup. + +```json +{ + "version": "0.1.0", + "notes": "Release notes here", + "pub_date": "2026-03-25T12:00:00Z", + "platforms": { + "windows-x86_64": { + "url": "https://github.com/diffplug/mouseterm/releases/download/v0.1.0/MouseTerm-windows-x64.nsis.zip", + "signature": "" + }, + "darwin-aarch64": { + "url": "https://github.com/diffplug/mouseterm/releases/download/v0.1.0/MouseTerm-macos-aarch64.tar.gz", + "signature": "" + }, + "darwin-x86_64": { + "url": "https://github.com/diffplug/mouseterm/releases/download/v0.1.0/MouseTerm-macos-x86_64.tar.gz", + "signature": "" + }, + "linux-x86_64": { + "url": "https://github.com/diffplug/mouseterm/releases/download/v0.1.0/MouseTerm-linux-x86_64.AppImage.tar.gz", + "signature": "" + } + } +} +``` + +Note: the update manifest URLs include the version in the *path* (`/v0.1.0/`) but the *filenames* are stable. The manifest itself is served from `mouseterm.com/standalone-latest.json` — Cloudflare Pages analytics tracks every update check. + +## Release checklist + +Human-driven steps, in order: + +1. **Update dependencies page** — run `node website/scripts/generate-deps.js` and review the diff in `website/src/data/dependencies.json`. Commit if changed. +2. **Finalize changelog** — promote the `[Unreleased]` section in `CHANGELOG.md` to `[X.Y.Z]` with today's date. Write release notes covering both standalone and VSCode changes. +3. **Bump versions** — update `version` in all three places: + - `standalone/src-tauri/tauri.conf.json` + - `vscode-ext/package.json` + - `lib/package.json` +4. **Commit and tag** — `git commit -m "Release vX.Y.Z"` then `git tag vX.Y.Z`. +5. **Push** — `git push && git push origin vX.Y.Z`. This triggers CI (Stage 1). +6. **Wait for CI** — monitor the workflow run. VSCode extension publishes automatically. +7. **Run local signing** — `./scripts/sign-and-deploy.sh all X.Y.Z`. Plug in the PIV USB key first. The script will: + - Download unsigned CI artifacts + - Sign macOS (will prompt for `APPLE_SIGN_PASS` if not set) + - Sign Windows (will prompt for `EV_SIGN_PIN` if not set) + - Generate Tauri update manifest and copy to `website/public/standalone-latest.json` + - Create the GitHub Release with all signed assets +8. **Deploy website** — commit the updated `website/public/standalone-latest.json` and deploy mouseterm.com so the updater endpoint is live. +9. **Verify the release** + - Check GitHub Release assets are correct + - On a Mac: download the `.dmg`, open it, confirm no Gatekeeper warnings + - On Windows: download the `.exe` installer, confirm no SmartScreen warnings + - Confirm Tauri auto-updater picks up the new version (test from a previous version) + - Confirm VSCode extension is live on Marketplace and OpenVSX + +## Changelog + +A single `CHANGELOG.md` at the repo root, following [Keep a Changelog](https://keepachangelog.com/) format. The `[Unreleased]` section is promoted to `[X.Y.Z]` at release time. The release notes include both standalone and VSCode changes in one entry. + +## Environment / secrets + +| Secret | Where | Purpose | +|--------|-------|---------| +| `VSCE_PAT` | GitHub Actions secret | VS Code Marketplace publish | +| `OVSX_PAT` | GitHub Actions secret | OpenVSX publish | +| `GITHUB_TOKEN` | GitHub Actions (automatic) | Artifact upload | +| `APPLE_SIGNING_IDENTITY` | Local keychain | macOS codesign | +| `APPLE_ID` | Local env / prompted | Notarization | +| `APPLE_SIGN_PASS` | Local env / prompted | Notarization password | +| `APPLE_TEAM_ID` | Local env / hardcoded | Notarization | +| `EV_SIGN_PIN` | Local env / prompted | Windows PIV signing | +| `TAURI_SIGNING_PRIVATE_KEY` | Local env | Tauri update signatures | +| `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | Local env / prompted | Tauri update key password | diff --git a/scripts/sign-and-deploy.sh b/scripts/sign-and-deploy.sh new file mode 100755 index 0000000..22df070 --- /dev/null +++ b/scripts/sign-and-deploy.sh @@ -0,0 +1,713 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ============================================================================= +# Local Code Signing and GitHub Release Script +# ============================================================================= +# Downloads unsigned CI artifacts, signs macOS and Windows binaries locally, +# generates Tauri update manifest, and creates a GitHub Release. +# +# Usage: ./scripts/sign-and-deploy.sh all +# Example: ./scripts/sign-and-deploy.sh all 0.1.0 +# ============================================================================= + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +WORK_DIR="$REPO_ROOT/release-signed" + +# ============================================================================= +# Configuration +# ============================================================================= + +# macOS Signing Identity +MACOS_IDENTITY="Developer ID Application: DiffPlug LLC (LXW8WAGWYX)" +MACOS_TEAM_ID="LXW8WAGWYX" +APPLE_ID="edgar.twigg@gmail.com" + +# Windows Signing (jsign with PIV) +JSIGN_ALIAS="AUTHENTICATION" +TSA_URL="http://ts.ssl.com" + +# GitHub repo +GITHUB_REPO="diffplug/mouseterm" + +# Stable filenames for release assets +FNAME_WIN_EXE="MouseTerm-windows-x64.exe" +FNAME_WIN_UPDATE="MouseTerm-windows-x64.nsis.zip" +FNAME_MAC_ARM_DMG="MouseTerm-macos-aarch64.dmg" +FNAME_MAC_ARM_UPDATE="MouseTerm-macos-aarch64.tar.gz" +FNAME_MAC_INTEL_DMG="MouseTerm-macos-x86_64.dmg" +FNAME_MAC_INTEL_UPDATE="MouseTerm-macos-x86_64.tar.gz" +FNAME_LINUX_APPIMAGE="MouseTerm-linux-x86_64.AppImage" +FNAME_LINUX_UPDATE="MouseTerm-linux-x86_64.AppImage.tar.gz" +FNAME_LINUX_DEB="MouseTerm-linux-x86_64.deb" +FNAME_MANIFEST="latest.json" + +# ============================================================================= +# Helper Functions +# ============================================================================= + +log() { echo "[$(date '+%H:%M:%S')] $*"; } +error() { echo "[ERROR] $*" >&2; exit 1; } +warn() { echo "[WARN] $*" >&2; } + +prompt_secret() { + local varname="$1" + local prompt="$2" + if [[ -z "${!varname:-}" ]]; then + read -rsp "$prompt: " "$varname" + echo + export "$varname" + fi +} + +prompt_secret_multiline() { + local varname="$1" + local prompt="$2" + local sentinel="__EOF_${varname}__" + if [[ -z "${!varname:-}" ]]; then + cat >&2 </dev/null || error "Required command not found: $1. Install with: $2" +} + +check_git_clean() { + log "Checking git status..." + + rm -rf "$WORK_DIR" + + if ! git -C "$REPO_ROOT" diff --quiet || ! git -C "$REPO_ROOT" diff --cached --quiet; then + error "Local changes detected. Commit or stash changes before deploying." + fi + + if [[ -n "$(git -C "$REPO_ROOT" ls-files --others --exclude-standard)" ]]; then + error "Untracked files detected. Commit or remove them before deploying." + fi + + local upstream + upstream=$(git -C "$REPO_ROOT" rev-parse --abbrev-ref --symbolic-full-name "@{u}" 2>/dev/null) || true + + if [[ -n "$upstream" ]]; then + local ahead + ahead=$(git -C "$REPO_ROOT" rev-list --count "$upstream..HEAD") + if [[ "$ahead" -gt 0 ]]; then + error "You have $ahead unpushed commit(s). Push changes before deploying." + fi + else + warn "No upstream branch set. Cannot verify commits are pushed." + fi + + log "Git status clean." +} + +find_nsis_script() { + find "$WORK_DIR/standalone-win-x64" \ + \( -name "*.nsi" -o -name "*.nsh" \) \ + -print \ + | head -1 +} + +rebuild_windows_installer() { + local signed_exe="$1" + local installer_path="$2" + + check_command makensis "Install NSIS (makensis) and re-download artifacts" + + local script_path + script_path=$(find_nsis_script) + [[ -n "$script_path" ]] || error "NSIS script not found in downloaded artifacts; include bundle/nsis staging files before rebuilding the installer." + + local script_dir + script_dir="$(cd "$(dirname "$script_path")" && pwd)" + local bundle_root + bundle_root="$(cd "$script_dir/.." && pwd)" + + local staged_exe + staged_exe=$(find "$bundle_root" -name "MouseTerm.exe" -not -path "$signed_exe" | head -1) + [[ -n "$staged_exe" ]] || error "Could not find staged MouseTerm.exe for NSIS rebuild" + + cp "$signed_exe" "$staged_exe" + + local installer_name + installer_name="$(basename "$installer_path")" + + rm -f "$installer_path" + log "Rebuilding NSIS installer: $installer_name" + ( + cd "$script_dir" + makensis -NOCD -X"OutFile $installer_name" "$(basename "$script_path")" + ) + + [[ -f "$installer_path" ]] || error "NSIS rebuild did not produce $installer_path" +} + +resolve_tag_sha() { + local tag="$1" + local tag_sha + + tag_sha=$(git -C "$REPO_ROOT" rev-list -n 1 "$tag^{commit}" 2>/dev/null) \ + || error "Tag $tag not found locally. Fetch tags or create it first." + + [[ -n "$tag_sha" ]] || error "Could not resolve commit for tag $tag" + printf '%s\n' "$tag_sha" +} + +find_release_run_id() { + local tag="$1" + local tag_sha="$2" + + gh run list \ + --repo "$GITHUB_REPO" \ + --workflow release.yml \ + --event push \ + --commit "$tag_sha" \ + --limit 5 \ + --json databaseId,displayTitle,headSha \ + --jq ".[] | select(.displayTitle == \"$tag\" or .headSha == \"$tag_sha\") | .databaseId" \ + | head -1 +} + +# ============================================================================= +# Download CI Artifacts +# ============================================================================= + +download_artifacts() { + local version="$1" + local tag="v$version" + local tag_sha + tag_sha=$(resolve_tag_sha "$tag") + + log "Finding workflow run for tag $tag ($tag_sha)..." + + check_command gh "brew install gh && gh auth login" + + local run_id="" + local attempts=0 + local max_attempts=60 # 5 minutes of retries + + while [[ -z "$run_id" ]] && [[ $attempts -lt $max_attempts ]]; do + run_id=$(find_release_run_id "$tag" "$tag_sha") + + if [[ -z "$run_id" ]]; then + attempts=$((attempts + 1)) + log "Workflow not found yet, waiting... (attempt $attempts/$max_attempts)" + sleep 5 + fi + done + + [[ -z "$run_id" ]] && error "Could not find workflow run for tag $tag" + + log "Found workflow run: $run_id" + log "Waiting for workflow to complete (this may take several minutes)..." + + gh run watch "$run_id" --repo "$GITHUB_REPO" --exit-status \ + || error "Workflow failed. Check: https://github.com/$GITHUB_REPO/actions/runs/$run_id" + + log "Workflow completed successfully!" + + rm -rf "$WORK_DIR" + mkdir -p "$WORK_DIR" + + log "Downloading artifacts..." + gh run download "$run_id" \ + --repo "$GITHUB_REPO" \ + --dir "$WORK_DIR" + + log "Artifacts downloaded to $WORK_DIR" + ls -la "$WORK_DIR" +} + +resume_download() { + local version="$1" + local tag="v$version" + local tag_sha + tag_sha=$(resolve_tag_sha "$tag") + + log "Finding completed workflow run for tag $tag ($tag_sha)..." + + check_command gh "brew install gh && gh auth login" + + local run_id="" + run_id=$(find_release_run_id "$tag" "$tag_sha") + + [[ -z "$run_id" ]] && error "Could not find workflow run for tag $tag" + + local conclusion + conclusion=$(gh run view "$run_id" --repo "$GITHUB_REPO" --json conclusion --jq '.conclusion') + if [[ "$conclusion" != "success" ]]; then + error "Workflow run $run_id has conclusion '$conclusion' (expected 'success'). Check: https://github.com/$GITHUB_REPO/actions/runs/$run_id" + fi + + log "Found completed workflow run: $run_id" + + rm -rf "$WORK_DIR" + mkdir -p "$WORK_DIR" + + log "Downloading artifacts..." + gh run download "$run_id" \ + --repo "$GITHUB_REPO" \ + --dir "$WORK_DIR" + + log "Artifacts downloaded to $WORK_DIR" + ls -la "$WORK_DIR" +} + +# ============================================================================= +# Sign macOS App Bundles +# ============================================================================= + +sign_macos_app() { + local app_path="$1" + local arch_label="$2" + + log "Signing macOS app ($arch_label): $app_path" + + [[ -d "$app_path" ]] || error "macOS app not found at $app_path" + + # Verify signing identity is available + security find-identity -v -p codesigning | grep -q "$MACOS_IDENTITY" \ + || error "Signing identity not found: $MACOS_IDENTITY" + + # Sign with hardened runtime + codesign --force --deep --sign "$MACOS_IDENTITY" \ + --options runtime \ + --timestamp \ + "$app_path" + + # Verify + codesign --verify --deep --strict --verbose=2 "$app_path" \ + || error "Signature verification failed for $app_path" + + log "macOS signing complete ($arch_label)" +} + +sign_macos() { + log "Starting macOS code signing..." + + # Find and sign both arch builds + local aarch64_app + aarch64_app=$(find "$WORK_DIR/standalone-mac-aarch64" -name "*.app" -type d | head -1) + local x86_64_app + x86_64_app=$(find "$WORK_DIR/standalone-mac-x86_64" -name "*.app" -type d | head -1) + + [[ -n "$aarch64_app" ]] && sign_macos_app "$aarch64_app" "aarch64" + [[ -n "$x86_64_app" ]] && sign_macos_app "$x86_64_app" "x86_64" + + log "All macOS signing complete" +} + +# ============================================================================= +# Notarize macOS Apps +# ============================================================================= + +notarize_macos_app() { + local app_path="$1" + local arch_label="$2" + + log "Notarizing macOS app ($arch_label)..." + + local zip_path="$WORK_DIR/notarize-${arch_label}.zip" + + ditto -c -k --keepParent "$app_path" "$zip_path" + + xcrun notarytool submit "$zip_path" \ + --apple-id "$APPLE_ID" \ + --team-id "$MACOS_TEAM_ID" \ + --password "$APPLE_SIGN_PASS" \ + --wait \ + --timeout 30m + + rm -f "$zip_path" + + xcrun stapler staple "$app_path" + xcrun stapler validate "$app_path" \ + || warn "Stapler validation warning for $arch_label (may still work)" + + log "Notarization complete ($arch_label)" +} + +notarize_macos() { + log "Starting macOS notarization..." + + check_command xcrun "xcode-select --install" + prompt_secret APPLE_SIGN_PASS "Enter Apple ID password (or app-specific password)" + + local aarch64_app + aarch64_app=$(find "$WORK_DIR/standalone-mac-aarch64" -name "*.app" -type d | head -1) + local x86_64_app + x86_64_app=$(find "$WORK_DIR/standalone-mac-x86_64" -name "*.app" -type d | head -1) + + [[ -n "$aarch64_app" ]] && notarize_macos_app "$aarch64_app" "aarch64" + [[ -n "$x86_64_app" ]] && notarize_macos_app "$x86_64_app" "x86_64" + + # Re-package signed+notarized apps into .dmg and .tar.gz + for arch in aarch64 x86_64; do + local app + app=$(find "$WORK_DIR/standalone-mac-${arch}" -name "*.app" -type d | head -1) + [[ -z "$app" ]] && continue + + local app_name + app_name=$(basename "$app") + + if [[ "$arch" == "aarch64" ]]; then + local dmg_name="$FNAME_MAC_ARM_DMG" + local tar_name="$FNAME_MAC_ARM_UPDATE" + else + local dmg_name="$FNAME_MAC_INTEL_DMG" + local tar_name="$FNAME_MAC_INTEL_UPDATE" + fi + + log "Creating $dmg_name..." + hdiutil create -volname "MouseTerm" -srcfolder "$app" \ + -ov -format UDZO "$WORK_DIR/$dmg_name" + + log "Creating $tar_name..." + tar -czf "$WORK_DIR/$tar_name" -C "$(dirname "$app")" "$app_name" + done + + log "All macOS notarization and packaging complete" +} + +# ============================================================================= +# Sign Windows Executable +# ============================================================================= + +sign_windows() { + log "Starting Windows code signing..." + + check_command jsign "brew install jsign" + prompt_secret EV_SIGN_PIN "Enter PIV PIN for Windows signing" + + # Find the inner exe + local exe_path + exe_path=$(find "$WORK_DIR/standalone-win-x64" -name "MouseTerm.exe" -not -name "*setup*" -not -name "*install*" | head -1) + [[ -n "$exe_path" ]] || error "Windows executable not found" + + log "Signing inner executable: $exe_path" + jsign \ + --storetype PIV \ + --storepass "$EV_SIGN_PIN" \ + --alias "$JSIGN_ALIAS" \ + --tsaurl "$TSA_URL" \ + --tsmode RFC3161 \ + "$exe_path" + + # Find the NSIS installer + local installer_path + installer_path=$(find "$WORK_DIR/standalone-win-x64" -name "*setup*.exe" -o -name "*install*.exe" | head -1) + + if [[ -n "$installer_path" ]]; then + rebuild_windows_installer "$exe_path" "$installer_path" + log "Signing installer: $installer_path" + jsign \ + --storetype PIV \ + --storepass "$EV_SIGN_PIN" \ + --alias "$JSIGN_ALIAS" \ + --tsaurl "$TSA_URL" \ + --tsmode RFC3161 \ + "$installer_path" + + # Copy with stable filename + cp "$installer_path" "$WORK_DIR/$FNAME_WIN_EXE" + fi + + log "Windows signing complete" +} + +# ============================================================================= +# Sign Update Bundles (Tauri Layer) +# ============================================================================= + +sign_updates() { + local version="$1" + + log "Signing update bundles with Tauri key..." + + prompt_secret_multiline TAURI_SIGNING_PRIVATE_KEY "Enter Tauri signing private key" + + local release_dir="$WORK_DIR/release-assets" + mkdir -p "$release_dir" + + # Collect and rename update bundles with stable filenames + # macOS .tar.gz (already created by notarize step) + [[ -f "$WORK_DIR/$FNAME_MAC_ARM_UPDATE" ]] && cp "$WORK_DIR/$FNAME_MAC_ARM_UPDATE" "$release_dir/" + [[ -f "$WORK_DIR/$FNAME_MAC_INTEL_UPDATE" ]] && cp "$WORK_DIR/$FNAME_MAC_INTEL_UPDATE" "$release_dir/" + [[ -f "$WORK_DIR/$FNAME_MAC_ARM_DMG" ]] && cp "$WORK_DIR/$FNAME_MAC_ARM_DMG" "$release_dir/" + [[ -f "$WORK_DIR/$FNAME_MAC_INTEL_DMG" ]] && cp "$WORK_DIR/$FNAME_MAC_INTEL_DMG" "$release_dir/" + + # Windows NSIS zip — rebuild with signed exe so Tauri auto-update gets the signed binary + local win_nsis + win_nsis=$(find "$WORK_DIR/standalone-win-x64" -name "*.nsis.zip" | head -1) + if [[ -n "$win_nsis" ]]; then + local signed_exe + signed_exe=$(find "$WORK_DIR/standalone-win-x64" -name "MouseTerm.exe" -not -name "*setup*" -not -name "*install*" | head -1) + if [[ -n "$signed_exe" ]]; then + log "Rebuilding NSIS zip with signed executable..." + local nsis_tmp="$WORK_DIR/nsis-repack" + mkdir -p "$nsis_tmp" + unzip -o "$win_nsis" -d "$nsis_tmp" + # Replace the unsigned exe inside the extracted zip with the signed one + local inner_exe + inner_exe=$(find "$nsis_tmp" -name "MouseTerm.exe" -not -name "*setup*" -not -name "*install*" | head -1) + if [[ -n "$inner_exe" ]]; then + cp "$signed_exe" "$inner_exe" + # Rebuild the zip + (cd "$nsis_tmp" && zip -r "$release_dir/$FNAME_WIN_UPDATE" .) + else + warn "Could not find exe inside NSIS zip; copying original" + cp "$win_nsis" "$release_dir/$FNAME_WIN_UPDATE" + fi + rm -rf "$nsis_tmp" + else + cp "$win_nsis" "$release_dir/$FNAME_WIN_UPDATE" + fi + fi + + # Windows installer + [[ -f "$WORK_DIR/$FNAME_WIN_EXE" ]] && cp "$WORK_DIR/$FNAME_WIN_EXE" "$release_dir/" + + # Linux AppImage + local linux_appimage + linux_appimage=$(find "$WORK_DIR/standalone-linux-x64" -name "*.AppImage" -not -name "*.tar.gz" | head -1) + [[ -n "$linux_appimage" ]] && cp "$linux_appimage" "$release_dir/$FNAME_LINUX_APPIMAGE" + + local linux_update + linux_update=$(find "$WORK_DIR/standalone-linux-x64" -name "*.AppImage.tar.gz" | head -1) + [[ -n "$linux_update" ]] && cp "$linux_update" "$release_dir/$FNAME_LINUX_UPDATE" + + local linux_deb + linux_deb=$(find "$WORK_DIR/standalone-linux-x64" -name "*.deb" | head -1) + [[ -n "$linux_deb" ]] && cp "$linux_deb" "$release_dir/$FNAME_LINUX_DEB" + + # Generate .sig files for update bundles using Tauri CLI + for bundle in "$release_dir/$FNAME_MAC_ARM_UPDATE" \ + "$release_dir/$FNAME_MAC_INTEL_UPDATE" \ + "$release_dir/$FNAME_WIN_UPDATE" \ + "$release_dir/$FNAME_LINUX_UPDATE"; do + if [[ -f "$bundle" ]]; then + log "Tauri-signing: $(basename "$bundle")" + # Use tauri signer to sign the bundle + TAURI_SIGNING_PRIVATE_KEY="$TAURI_SIGNING_PRIVATE_KEY" \ + TAURI_SIGNING_PRIVATE_KEY_PASSWORD="${TAURI_SIGNING_PRIVATE_KEY_PASSWORD:-}" \ + npx --prefix "$REPO_ROOT/standalone" tauri signer sign \ + --private-key "$TAURI_SIGNING_PRIVATE_KEY" \ + "$bundle" + fi + done + + # Build latest.json manifest + local base_url="https://github.com/$GITHUB_REPO/releases/download/v$version" + local pub_date + pub_date=$(date -u '+%Y-%m-%dT%H:%M:%SZ') + + # Read .sig file contents + local sig_mac_arm="" sig_mac_intel="" sig_win="" sig_linux="" + [[ -f "$release_dir/$FNAME_MAC_ARM_UPDATE.sig" ]] && sig_mac_arm=$(cat "$release_dir/$FNAME_MAC_ARM_UPDATE.sig") + [[ -f "$release_dir/$FNAME_MAC_INTEL_UPDATE.sig" ]] && sig_mac_intel=$(cat "$release_dir/$FNAME_MAC_INTEL_UPDATE.sig") + [[ -f "$release_dir/$FNAME_WIN_UPDATE.sig" ]] && sig_win=$(cat "$release_dir/$FNAME_WIN_UPDATE.sig") + [[ -f "$release_dir/$FNAME_LINUX_UPDATE.sig" ]] && sig_linux=$(cat "$release_dir/$FNAME_LINUX_UPDATE.sig") + + cat > "$release_dir/$FNAME_MANIFEST" < "$notes_file" + fi + + if [[ ! -s "$notes_file" ]]; then + echo "Release $tag" > "$notes_file" + fi + + # Create or update the release + if gh release view "$tag" --repo "$GITHUB_REPO" &>/dev/null; then + log "Release $tag already exists — updating assets..." + gh release upload "$tag" \ + --repo "$GITHUB_REPO" \ + --clobber \ + "$release_dir"/* + gh release edit "$tag" \ + --repo "$GITHUB_REPO" \ + --title "$tag" \ + --notes-file "$notes_file" + else + gh release create "$tag" \ + --repo "$GITHUB_REPO" \ + --title "$tag" \ + --notes-file "$notes_file" \ + "$release_dir"/* + fi + + rm -f "$notes_file" + + log "GitHub Release created: https://github.com/$GITHUB_REPO/releases/tag/$tag" +} + +# ============================================================================= +# Main Entry Point +# ============================================================================= + +usage() { + cat <" + + check_git_clean + download_artifacts "$version" + sign_macos + notarize_macos + sign_windows + sign_updates "$version" + create_release "$version" + ;; + resume) + local version="${2:-}" + [[ -z "$version" ]] && error "Usage: $(basename "$0") resume " + + resume_download "$version" + sign_macos + notarize_macos + sign_windows + sign_updates "$version" + create_release "$version" + ;; + sign-mac) + sign_macos + ;; + notarize) + notarize_macos + ;; + sign-win) + sign_windows + ;; + sign-updates) + local version="${2:-}" + [[ -z "$version" ]] && error "Usage: $(basename "$0") sign-updates " + sign_updates "$version" + ;; + release) + local version="${2:-}" + [[ -z "$version" ]] && error "Usage: $(basename "$0") release " + create_release "$version" + ;; + *) + error "Unknown command: $cmd. Use --help for usage." + ;; + esac + + log "Done!" +} + +main "$@" diff --git a/standalone/src-tauri/Cargo.toml b/standalone/src-tauri/Cargo.toml index a47023d..287d236 100644 --- a/standalone/src-tauri/Cargo.toml +++ b/standalone/src-tauri/Cargo.toml @@ -16,6 +16,7 @@ tauri-build = { version = "2", features = [] } [dependencies] tauri = { version = "2", features = [] } tauri-plugin-shell = "2" +tauri-plugin-updater = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/standalone/src-tauri/src/lib.rs b/standalone/src-tauri/src/lib.rs index 8092959..b127c63 100644 --- a/standalone/src-tauri/src/lib.rs +++ b/standalone/src-tauri/src/lib.rs @@ -255,6 +255,7 @@ fn start_sidecar(app: &AppHandle) -> SidecarState { pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_shell::init()) + .plugin(tauri_plugin_updater::Builder::new().build()) .setup(|app| { let sidecar_state = start_sidecar(app.handle()); app.manage(sidecar_state); diff --git a/standalone/src-tauri/tauri.conf.json b/standalone/src-tauri/tauri.conf.json index 1fb403f..58d9293 100644 --- a/standalone/src-tauri/tauri.conf.json +++ b/standalone/src-tauri/tauri.conf.json @@ -27,6 +27,7 @@ "bundle": { "active": true, "targets": "all", + "createUpdaterArtifacts": true, "externalBin": [ "binaries/node" ], @@ -40,5 +41,13 @@ "resources": [ "../sidecar/**/*" ] + }, + "plugins": { + "updater": { + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEFDNUE3RThENTQxQTY0REIKUldUYlpCcFVqWDVhckxRQjBFbGw4anhJMUZ5L2VEU0pGNTluS1hPR0F1OGc1T3BUYTVjbHd0WG0K", + "endpoints": [ + "https://mouseterm.com/standalone-latest.json" + ] + } } } diff --git a/vscode-ext/CHANGELOG.md b/vscode-ext/CHANGELOG.md deleted file mode 100644 index ca7d477..0000000 --- a/vscode-ext/CHANGELOG.md +++ /dev/null @@ -1,9 +0,0 @@ -# Changelog - -## 0.1.0 - -- Initial release -- Multiple terminal panes with dockview tiling layout -- Completion detection (submerged/rising/floating states) -- Keyboard-driven navigation and terminal management -- Spatial navigation with back-navigation support diff --git a/vscode-ext/CHANGELOG.md b/vscode-ext/CHANGELOG.md new file mode 120000 index 0000000..04c99a5 --- /dev/null +++ b/vscode-ext/CHANGELOG.md @@ -0,0 +1 @@ +../CHANGELOG.md \ No newline at end of file diff --git a/website/public/standalone-latest.json b/website/public/standalone-latest.json new file mode 100644 index 0000000..984cf0f --- /dev/null +++ b/website/public/standalone-latest.json @@ -0,0 +1,6 @@ +{ + "version": "0.0.0", + "notes": "Placeholder — replaced by sign-and-deploy.sh on each release", + "pub_date": "2026-01-01T00:00:00Z", + "platforms": {} +}