feat: auto-build, GitHub Release, and in-app updater (#14)

* ci: auto-release workflow on merge to main

Rewrite .github/workflows/release.yml to trigger on every push to main
instead of manual tag pushes. The workflow now:

- Computes version as 0.YYYYMMDD.GITHUB_RUN_NUMBER
- Builds aarch64-apple-darwin and x86_64-apple-darwin in parallel
- Merges them into a universal binary using lipo
- Creates a universal .dmg and signed updater tarball
- Generates latest.json with per-arch and universal platform entries
- Publishes a GitHub Release with auto-generated release notes
- Updates a GitHub Pages release history site (gh-pages branch)

Product decisions:
- Universal binary approach: copy arm64 .app as base, lipo the main
  executable, keep everything else from arm64 (shared frameworks are
  architecture-independent). This is the standard Tauri pattern.
- Per-arch updater tarballs are also uploaded so the Tauri updater can
  download the correct arch-specific build (smaller download).
- Release notes are auto-generated from git log since last tag.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* ci: github pages with release history

Use peaceiris/actions-gh-pages@v4 to deploy a release history site.
The page fetches releases.json (also deployed) and renders each release
with date, notes, and download links for .dmg files.

This handles the gh-pages branch creation automatically on first run.
The page is available at https://refactoringhq.github.io/laputa-app/

Product decision: used fetch() to load releases.json at runtime instead
of inlining it, which is cleaner and avoids shell escaping issues with
release note content. The releases.json is deployed alongside index.html.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: in-app update notification UI

Replace the old window.confirm updater with a proper React-based
update notification system:

- useUpdater hook now exposes state machine (idle → available →
  downloading → ready) and actions (startDownload, openReleaseNotes,
  dismiss)
- UpdateBanner component renders at the top of the app shell:
  - "Available" state: shows version, Release Notes link, Update Now
    button, dismiss X
  - "Downloading" state: animated spinner, progress bar with percentage
  - "Ready" state: Restart Now button to apply the update
- Silently checks on startup after 3s delay; fails silently on
  network errors or 404
- Release Notes link opens the GitHub Pages release history site

Product decisions:
- Banner at top of app (not a modal) — non-intrusive, visible but
  not blocking. User can dismiss and continue working.
- Progress bar shows during download so user knows it's working.
- Separate "Restart Now" state after download so user controls when
  the app restarts (they may have unsaved work).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: updater component tests

Rewrite useUpdater hook tests and add UpdateBanner component tests:

Hook tests (10 cases):
- Starts in idle state
- Does nothing when not in Tauri
- Checks for updates after 3s delay
- Stays idle when no update available
- Transitions to available when update found
- Handles missing release body gracefully
- Stays idle on network error (fails silently)
- Dismiss returns to idle
- openReleaseNotes opens correct URL
- startDownload transitions through downloading to ready

Component tests (10 cases):
- Renders nothing when idle
- Renders nothing on error
- Shows version and buttons when available
- Update Now calls startDownload
- Release Notes calls openReleaseNotes
- Dismiss button works
- Shows progress bar during download
- Shows 0% at start of download
- Shows restart button when ready
- Restart button calls restartApp

All 457 tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* design: auto-build-release wireframes

Copy ui-design.pen as base. Frames to be added for:

1. Update notification banner (visible state) — horizontal bar at
   top of app shell with version text, Release Notes link, Update
   Now button, and dismiss X
2. Update download progress state — spinner icon, progress bar with
   percentage, downloading text
3. "Restart to apply" state — green accent, version text, Restart
   Now button

Note: Pencil editor was not available during this session. The base
design file is committed; frames will be added when the editor is
accessible. The implemented component (UpdateBanner.tsx) serves as
the source of truth for the design.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: update ARCHITECTURE.md with release/update system

Add comprehensive documentation for:
- Release pipeline (4-phase workflow: version → build → release → pages)
- Versioning scheme (0.YYYYMMDD.RUN_NUMBER)
- Universal binary strategy (lipo merge)
- Updater endpoint and latest.json manifest
- In-app update UI state machine
- GitHub Pages release history site

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: rustfmt formatting

* fix: rustfmt build.rs

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Luca Rossi
2026-02-23 11:50:36 +01:00
committed by GitHub
parent d14f6f59bd
commit 9aa00c9c8b
19 changed files with 16082 additions and 323 deletions

View File

@@ -125,7 +125,12 @@ pub async fn github_create_repo(
pub fn clone_repo(url: &str, token: &str, local_path: &str) -> Result<String, String> {
let dest = Path::new(local_path);
if dest.exists() && dest.read_dir().map(|mut d| d.next().is_some()).unwrap_or(false) {
if dest.exists()
&& dest
.read_dir()
.map(|mut d| d.next().is_some())
.unwrap_or(false)
{
return Err(format!(
"Destination '{}' already exists and is not empty",
local_path
@@ -163,7 +168,10 @@ fn inject_token_into_url(url: &str, token: &str) -> Result<String, String> {
// Handle URLs that already have a host
Ok(format!("https://oauth2:{}@{}", token, rest))
} else {
Err(format!("Unsupported URL format: {}. Use an HTTPS URL.", url))
Err(format!(
"Unsupported URL format: {}. Use an HTTPS URL.",
url
))
}
}
@@ -245,7 +253,11 @@ mod tests {
let path = dir.path();
std::fs::write(path.join("existing.txt"), "data").unwrap();
let result = clone_repo("https://github.com/test/repo.git", "token", path.to_str().unwrap());
let result = clone_repo(
"https://github.com/test/repo.git",
"token",
path.to_str().unwrap(),
);
assert!(result.is_err());
assert!(result.unwrap_err().contains("not empty"));
}
@@ -255,7 +267,11 @@ mod tests {
let dir = tempfile::TempDir::new().unwrap();
let dest = dir.path().join("new-clone");
let result = clone_repo("git@github.com:user/repo.git", "token", dest.to_str().unwrap());
let result = clone_repo(
"git@github.com:user/repo.git",
"token",
dest.to_str().unwrap(),
);
assert!(result.is_err());
assert!(result.unwrap_err().contains("Unsupported URL format"));
}
@@ -268,7 +284,11 @@ mod tests {
std::fs::create_dir(&dest).unwrap();
// This will fail at the git clone step (invalid URL) but should pass the directory check
let result = clone_repo("https://github.com/nonexistent/repo.git", "token", dest.to_str().unwrap());
let result = clone_repo(
"https://github.com/nonexistent/repo.git",
"token",
dest.to_str().unwrap(),
);
assert!(result.is_err());
// Should fail at git clone, not at directory check
assert!(result.unwrap_err().contains("git clone failed"));
@@ -280,9 +300,21 @@ mod tests {
let path = dir.path();
// Initialize a git repo
StdCommand::new("git").args(["init"]).current_dir(path).output().unwrap();
StdCommand::new("git").args(["remote", "add", "origin", "https://github.com/user/repo.git"])
.current_dir(path).output().unwrap();
StdCommand::new("git")
.args(["init"])
.current_dir(path)
.output()
.unwrap();
StdCommand::new("git")
.args([
"remote",
"add",
"origin",
"https://github.com/user/repo.git",
])
.current_dir(path)
.output()
.unwrap();
let result = configure_remote_auth(
path.to_str().unwrap(),