Optimize macOS build script and add auto-rebuild watch mode#1646
Merged
Conversation
## Changes ### Build Script Optimizations (build.sh) - **Incremental packaging**: Only repackages .app when binary changes (~0.1s when unchanged) - **Smart caching**: Frameworks and resource bundles only copied when modified - **Faster code signing**: Removed --deep flag in debug builds - **Better process cleanup**: Cleaner kill/relaunch sequence with timeout - **Result**: ~4 second rebuilds for small changes (down from ~5s) ### Auto-Rebuild Watch Mode (watch.sh) - New watch script using fswatch to detect Swift file changes - Automatically rebuilds and relaunches on save - Simple workflow: `./watch.sh` → edit → save → see changes in ~4s - Auto-installs fswatch via Homebrew if not present ### Documentation - Updated README with build performance details - Added watch mode usage instructions - Documented clean build workflow ## Developer Experience Before: Manual `./build.sh run` after each change (~5s) After: `./watch.sh` auto-rebuilds on save (~4s, hands-free) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4df6ed40b3
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
- Add || true to pkill to fix TOCTOU race condition with set -e - Check daemon binary timestamp to trigger rebuild when it changes - Fix misleading comment about skipping to code signing - Restore --deep flag for code signing to properly sign nested frameworks
The previous logic required both source and destination daemon binaries to exist before checking timestamps. This meant that if a developer added daemon-bin/vellum-daemon for the first time after an initial build, the incremental check would skip the rebuild entirely, and the daemon would never be bundled. Now checks: if source daemon exists AND (destination missing OR source is newer), trigger rebuild. This handles both updates and first-time additions. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
The fswatch include/exclude filter chain needs a catch-all exclude at the end to properly reject non-Swift files. Without it, fswatch's default action is to accept unmatched events, so editing resources like images, fonts, or markdown would trigger unnecessary rebuilds. The filter chain now works as: exclude build artifacts → include Swift files → exclude everything else. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
After pkill for the legacy process name, add a 300ms sleep before launching the new app. This prevents potential port conflicts or other issues if the legacy process hasn't fully exited yet. This restores the timing behavior from the old code (which had a single sleep 0.3 covering both kills) while keeping the improved wait loop for the main process. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Critical fixes: - Always regenerate Info.plist, compile assets, and code sign, even when binaries are unchanged. Previously these were skipped on early exit, causing version changes in CI to be silently ignored. - Restructured script to separate conditional repackaging (binaries, frameworks, bundles) from unconditional steps (Info.plist, assets, code signing). watch.sh improvements: - Prompt before installing fswatch instead of auto-installing - Check if Homebrew is available and show helpful error if not Documentation: - Added comment in build.sh explaining incremental build tradeoff (stale artifacts persist until 'clean') - Updated README to mention when to use 'clean' (after deletions) - Fixed README formatting: added "SwiftPM Commands" heading Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Added comment explaining that -nt on directories only detects top-level entry changes, not deep file modifications. This is acceptable because SPM recreates directories entirely, but manual framework edits won't be detected without 'clean'. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Moved resource bundle copying outside the NEEDS_REBUILD conditional. Previously, when only resource files (images, fonts, SVGs, recipes) changed without Swift code changes, SPM would rebuild the .bundle but the executable timestamp stayed the same, causing NEEDS_REBUILD to remain false and skipping the bundle copy. The resource bundle loop already has its own timestamp checks, so it can run independently every build. This ensures resource-only changes are detected and copied, especially important for watch mode. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
build.sh fixes: - Moved Sparkle.framework check outside NEEDS_REBUILD conditional. Previously, dependency version updates wouldn't copy the new framework if the executable timestamp didn't change, causing stale framework bugs. - Updated performance documentation to reflect that Info.plist regeneration, asset compilation, and codesigning still run on every build (~1-2s, not instant). watch.sh fixes: - Use process substitution instead of pipe to prevent orphaning fswatch on Ctrl+C. The pipe pattern created a subshell that wouldn't properly clean up the fswatch process when interrupted. - Added Package.swift to watched files (was in root, not in watched directories). - Added --event Removed to detect deleted Swift files. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
watch.sh improvements: - Watch resource files (images, fonts, JSON, SVGs, xcassets) in addition to Swift files. Previously resource changes required manual rebuild. - Add debouncing: skip new builds if one is already running. Prevents app flickering and wasted cycles when rapidly saving multiple files. - Remove Package.resolved exclusion so dependency changes trigger rebuilds. - Use set -euo pipefail for better error detection in pipelines. build.sh safety: - Force clean for release builds to prevent shipping stale artifacts. The incremental build system can leave removed files in the bundle, which is unacceptable for production. Debug builds still use fast incremental path. README updates: - Document resource watching and debouncing in watch mode section. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Critical fixes: - Implement proper debouncing with PENDING_REBUILD flag. The previous implementation couldn't work because 'wait' blocked the read loop, preventing the debounce check from ever firing during a build. Now uses a "rebuild pending" flag that triggers at most one additional build after completion, properly coalescing rapid saves. - Add Package.resolved to watch patterns. Previously claimed to watch it but the filter excluded it (doesn't match .swift$ and caught by catch-all exclude). Now explicitly included. - Add trap handler to kill background build process on Ctrl+C/SIGTERM. Prevents orphaned build processes when watch mode is interrupted. - Update watch message to accurately reflect what's being watched (Swift, resources, dependencies) instead of just "Swift file changes". The debouncing now works correctly: N rapid saves during a build = at most 1 additional rebuild (not N sequential rebuilds). This prevents app flickering and wasted cycles while still ensuring all changes are reflected. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Devin correctly identified that the previous debounce logic was still flawed. Even with PENDING_REBUILD flag, buffered events in the fswatch pipe would cause sequential rebuilds after the first build completed. New approach (suggested by Devin): - Run build synchronously (no background process complexity) - After build completes, drain all buffered events from pipe using 'read -r -t 0.1' with timeout - Show count of skipped events for visibility This properly prevents N rapid saves during a build from causing N sequential rebuilds. Now they coalesce into 1 build + drain, which is the correct debouncing behavior. Removed background process, PENDING_REBUILD flag, and trap (no longer needed). Much simpler and actually works. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
watch.sh fixes: - Add trap to kill background jobs (fswatch process) on INT/TERM. Process substitution doesn't guarantee cleanup on parent exit, which could leave fswatch orphaned consuming CPU after Ctrl+C. - Watch daemon-bin/ directory so daemon binary updates trigger auto-rebuild. - Add comment explaining that read -r -t 0.1 in while condition is exempt from set -e (for maintainability). build.sh documentation: - Document --deep codesigning limitation: re-signs daemon binary without its entitlements (JIT, network). This is inherited behavior from original build system. Apple recommends component-specific signing instead of --deep for production apps with nested binaries requiring entitlements. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Critical fixes: 1. bash 3.2 compatibility - Changed read timeout from 0.1 to 1 (integer). macOS ships with bash 3.2 which doesn't support fractional timeouts. The debounce drain would fail silently on system bash. 2. Fix --deep codesigning stripping daemon entitlements - Replaced --deep with explicit component signing (Apple's recommended approach): - Sign Sparkle.framework first - Sign daemon with entitlements - Sign outer app WITHOUT --deep (preserves nested signatures) This ensures daemon keeps its JIT and network entitlements at runtime. 3. Fix .xcassets pattern - Removed trailing slash so it matches files inside .xcassets directories, not just the directory itself. This addresses the inherited codesigning limitation that was previously only documented but not fixed. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Devin correctly identified that jobs -p doesn't track process substitution PIDs, so the trap handler couldn't kill fswatch on SIGTERM (SIGINT worked due to process groups, but SIGTERM would orphan fswatch). New approach using FIFO: - Create named pipe with mktemp -u and mkfifo - Start while read loop in background reading from FIFO - Start fswatch in background writing to FIFO, capture PID with $! - Trap handler can now kill fswatch explicitly using captured PID - Clean up FIFO in trap This ensures fswatch terminates properly on both SIGINT and SIGTERM, preventing orphaned processes. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
The FIFO refactor accidentally removed the 'while read -r _; do' line, leaving orphaned loop body code. This caused a syntax error that would prevent watch.sh from running at all. Added back the missing while loop opener after the FIFO setup comments. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Devin correctly identified that daemon-bin/vellum-daemon has no file extension, so it doesn't match any --include pattern and gets excluded by the catch-all --exclude='.*'. Added --include='daemon-bin/' before the catch-all to ensure daemon binary changes are actually detected as claimed in the PR description. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Optimized the macOS build script for faster iteration and added an auto-rebuild watch mode for a better development experience.
Background: Why This Approach?
Initially explored true hot reload via code injection (InjectionIII/InjectionNext), but these tools require Xcode build logs (
.xcactivitylogfiles) thatswift builddoesn't generate. Code injection is incompatible with command-line SPM builds.Options considered:
swift build❌This PR takes approach #3: make incremental builds fast (~4s) and automatic (watch mode), giving a comparable developer experience without requiring Xcode or code injection infrastructure.
Changes
🚀 Build Script Optimizations (
build.sh)Performance improvements:
--deepflag, sign each component individually to preserve daemon entitlementsHow it works:
Code signing approach:
--deepto preserve nested signaturesTradeoffs & Known Limitations:
./build.sh clean(speed vs. cleanup) — acceptable for debug, prevented for releaseswift build --show-bin-pathcalled beforeswift buildadds minor overhead but needed for logic flow./build.sh cleanafter deleting resources/frameworks👀 Auto-Rebuild Watch Mode (
watch.sh)New script for hands-free development:
What it watches:
vellum-assistantandvellum-assistant-appPackage.swift,Package.resolveddaemon-bin/directoryFeatures:
Workflow:
./watch.shin terminalHow debouncing works:
read -r -t 1timeoutread -t 0.1)Technical implementation:
jobs -p, so FIFO approach is required for proper cleanup📚 Documentation
Updated README with:
clean(after resource deletions)Performance Comparison
Developer Experience
Before:
./build.sh runafter each changeAfter:
./watch.shonceTesting
Design Decisions & Tradeoffs
Why not use code injection (InjectionIII/InjectionNext)?
These tools require Xcode build logs (
.xcactivitylogfiles) that command-lineswift builddoesn't generate. Code injection is fundamentally incompatible with SPM's command-line build system. Switching to Xcode builds would add complexity and be slower than optimized SPM builds.Why not use
--deepfor code signing?Apple's documentation warns against
--deepbecause it doesn't give control over how nested code is signed. Using--deepwould re-sign the daemon binary without its entitlements, breaking JIT functionality. The correct approach is to sign each component explicitly with appropriate flags/entitlements.Why does code signing run every build?
Even when no code changes, Info.plist/asset catalog/codesigning run to ensure the bundle is always valid. This adds ~1s overhead but guarantees correct signatures. Optimizing this would require complex checksum tracking with diminishing returns.
Why use integer timeout (1 second) instead of fractional (0.1 seconds)?
macOS ships with bash 3.2, which doesn't support fractional seconds in
read -t. Usingread -t 1ensures compatibility with the system bash. The 1-second tail delay is unfortunate but necessary to avoid requiring users to install bash 4+ from Homebrew.Why use FIFO instead of process substitution?
Process substitution PIDs aren't tracked by
jobs -p, so trap handlers can't kill them on SIGTERM. FIFO allows explicit PID capture with$!, enabling proper cleanup on all signals.Why call
swift build --show-bin-pathbeforeswift build?The bin path is needed for timestamp checks before the build. While this adds minor overhead from double package resolution, it's required for the logic flow. Functionally correct, minor performance cost.
Notes
This approach doesn't provide true hot reload (that would require Xcode build logs and code injection), but it significantly improves the development iteration speed with automatic rebuilds for code, resource, dependency, and daemon binary changes. The ~4 second turnaround is fast enough for productive development without the complexity of switching to Xcode or injection-based hot reload.