Skip to content

Commit 3f13841

Browse files
authored
Merge pull request #28 from radroid/t3x/autobuild-origin-main
feat(t3x): auto-build the desktop app from origin/main in a dedicated worktree
2 parents 7772b55 + fd249a0 commit 3f13841

3 files changed

Lines changed: 213 additions & 12 deletions

File tree

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# Auto-build from origin/main + unattended relaunch — design
2+
3+
**Date:** 2026-07-27
4+
**Status:** Approved by Raj (chat): "let's start with #2 [--relaunch] … For #3, I
5+
would like to use the origin/main instead of the local state, that would ensure my
6+
local agents can work on a repo branch without breaking it."
7+
8+
## Problem
9+
10+
Two findings from reviewing the desktop auto-update pipeline:
11+
12+
1. **The watcher never fetched.** `--watch` compared `git rev-parse HEAD` of the
13+
checkout the script lives in against the last-built marker. Nothing pulled, so
14+
the installed app only updated when Raj manually pulled local main — and any
15+
agent switching branches in that checkout would change (or break) what gets
16+
built and installed.
17+
2. **Installs quit the app and never relaunched it.** The LaunchAgent ran
18+
`--install` without `--relaunch`; `install_dmg` force-quits the app to swap
19+
`/Applications/T3 Code (Alpha).app`, leaving port 3773 — and therefore the
20+
Tailscale serve surface, Raj's primary way of using the app — down until a
21+
manual reopen.
22+
23+
## Design
24+
25+
### `--ref <remote>/<branch>` (script change, this PR)
26+
27+
- Each `build_once` tick in ref mode: `git fetch <remote> <branch>` → resolve the
28+
remote-tracking SHA → pin a dedicated build worktree to it (detached,
29+
`checkout --force`) → run the existing build machinery with `$REPO` repointed
30+
at that worktree.
31+
- Worktree path: `T3X_AUTOBUILD_WORKTREE`, default `<repo>-build`. Created on
32+
first use (`worktree prune` first, so a deleted-but-registered path can't wedge
33+
it). Guard: refuses to point at the main repo itself, since the forced checkout
34+
would clobber it.
35+
- Source of truth is the remote-tracking ref read from the main repo (worktrees
36+
share refs); the worktree is only checkout machinery. `current_sha` reflects
37+
this in ref mode.
38+
- Fresh worktree has no `node_modules`; the install step now triggers on
39+
"`node_modules` missing OR lockfile changed", not lockfile-diff alone.
40+
- Fetch failure (offline) → `fetch-failed` status, marker not advanced, retried
41+
next tick under the existing backoff (capped at the poll interval).
42+
- `--ref` is forwarded through the caffeinate re-exec and emitted by
43+
`--print-launchd` (plus `T3X_AUTOBUILD_WORKTREE` in the plist env).
44+
`WorkingDirectory` in the emitted plist stays the main repo — the build
45+
worktree may not exist yet, and launchd refuses a missing working directory.
46+
47+
### Relaunch (machine config, not a code change)
48+
49+
`--relaunch` already existed; the deployed LaunchAgent plist gains it alongside
50+
`--ref origin/main`. Post-install the app reopens (~20s blip) instead of staying
51+
quit indefinitely.
52+
53+
## Deliberately kept
54+
55+
- The 12h poll cadence — Raj wants updates on a slow, deliberate schedule.
56+
- The script runs from the main checkout (`~/Developer/t3code`); script updates
57+
take effect when that checkout is updated. Documented in the runbook.
58+
59+
## Rejected alternatives
60+
61+
- Running the script from inside the build worktree: the forced checkout would
62+
rewrite the script file mid-execution (bash reads scripts incrementally).
63+
- A post-merge git hook: rebuilds on every local merge, the exact churn the 12h
64+
cadence was introduced to stop.
65+
- A :3773 health-check watchdog agent: deferred — Raj chose relaunch-first; the
66+
watchdog is the fallback if relaunch proves flaky.
67+
68+
## Verification
69+
70+
Tested on this branch: bash syntax; flag validation (`--ref` format, unknown
71+
remote, self-worktree guard); dry-run preview with no worktree; real run creating
72+
the worktree at exactly `origin/main`'s SHA with bootstrap-install triggering and
73+
`build-failed` + unadvanced marker on failure; re-sync of a stale worktree;
74+
`--print-launchd` output passes `plutil -lint`.

docs/t3x/auto-build-runbook.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,27 @@ script logs "no change" and exits `3`. Use `--force` to rebuild anyway.
9696
Because the marker only advances on a **successful** build, a failed build is
9797
retried on the next poll rather than being silently skipped.
9898

99+
### `--ref`: build a remote ref, not the local checkout
100+
101+
By default the watcher builds whatever `HEAD` the checkout it lives in happens to
102+
be on — which couples "what's installed" to the state of that working tree. With
103+
`--ref origin/main`, every run instead:
104+
105+
1. `git fetch origin main` (a failure — e.g. offline — is logged as
106+
`fetch-failed` in the status JSON and retried next tick; the marker never
107+
advances on failure).
108+
2. Pins a **dedicated build worktree** (`T3X_AUTOBUILD_WORKTREE`, default
109+
`<repo>-build`) to that ref's SHA with a detached, `--force` checkout. The
110+
worktree is created on first use, and `pnpm install` runs automatically when
111+
its `node_modules` is missing.
112+
3. Builds there. The main checkout is never read for builds, so local branches,
113+
uncommitted work, and agents operating in the repo can't change what gets
114+
installed.
115+
116+
The worktree never holds a branch (always detached), so no other worktree is
117+
blocked from checking out `main`. The script itself still runs from the main
118+
checkout — script changes take effect after that checkout is updated.
119+
99120
## Flags
100121

101122
| Flag | Meaning |
@@ -104,6 +125,7 @@ retried on the next poll rather than being silently skipped.
104125
| `--relaunch` | With `--install`, `open` the app afterwards. |
105126
| `--watch` | Poll `HEAD` forever; build (and install, if asked) on each new SHA. |
106127
| `--interval N` | Poll interval in seconds for `--watch` (default `60`). |
128+
| `--ref R` | Build remote ref `R` (e.g. `origin/main`) in a dedicated worktree. |
107129
| `--dry-run` | Log every step; never build, never touch `/Applications`. |
108130
| `--force` | Build even if `HEAD` is unchanged. |
109131
| `--print-launchd` | Emit a ready-to-use LaunchAgent plist on stdout. |

scripts/t3x/auto-build-desktop.sh

Lines changed: 117 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@
2121
# when main has changed since the last build. It does NOT rebuild the instant
2222
# main moves; a change is picked up at the next poll. Run a one-shot build any
2323
# time with the plain --install form above (bypasses the interval).
24+
# scripts/t3x/auto-build-desktop.sh --ref origin/main [--watch ...] [--install]
25+
# --ref builds a REMOTE ref instead of the local checkout: each run fetches the
26+
# remote, pins a dedicated build worktree (T3X_AUTOBUILD_WORKTREE, default
27+
# <repo>-build) to that ref's sha, and builds there. The checkout this script
28+
# lives in is never read for builds, so local branches/agents can't change what
29+
# gets installed. A fetch failure (offline) is logged and retried next tick.
2430
# scripts/t3x/auto-build-desktop.sh --print-launchd # emit a ready-to-use LaunchAgent plist
2531
# scripts/t3x/auto-build-desktop.sh --help
2632
#
@@ -34,6 +40,7 @@
3440
# T3CODE_DESKTOP_OUTPUT_DIR (default: <repo>/release) — where the .dmg lands
3541
# T3X_AUTOBUILD_APP_NAME (default: derived from the .app inside the .dmg)
3642
# T3X_AUTOBUILD_KEEP_DMGS (default: 3) — how many recent .dmgs to keep per prune
43+
# T3X_AUTOBUILD_WORKTREE (default: <repo>-build) — build worktree used by --ref
3744
#
3845
set -euo pipefail
3946

@@ -47,14 +54,6 @@ LAST_SHA_FILE="$STATE_DIR/t3x-autobuild-last-sha"
4754
STATUS_FILE="$STATE_DIR/t3x-autobuild-status.json"
4855
LOG_FILE="$LOG_DIR/t3x-autobuild.log"
4956

50-
# Match scripts/build-desktop-artifact.ts, which does `path.resolve(repoRoot, outputDir)`:
51-
# a RELATIVE override is repo-relative there, so resolving it against $PWD here would make
52-
# us search/prune the caller's ./<dir> and then report "no .dmg" for a build that succeeded.
53-
OUTPUT_DIR="${T3CODE_DESKTOP_OUTPUT_DIR:-release}"
54-
case "$OUTPUT_DIR" in
55-
/*) ;; # absolute override: use as-is
56-
*) OUTPUT_DIR="$REPO/$OUTPUT_DIR" ;;
57-
esac
5857
KEEP_DMGS="${T3X_AUTOBUILD_KEEP_DMGS:-3}"
5958
# Validated because it is fed to `tail -n +$((KEEP_DMGS + 1))`: a non-numeric value makes
6059
# the arithmetic yield 1, so `tail -n +1` prunes EVERY dmg including the one just built,
@@ -77,6 +76,7 @@ FORCE=0
7776
INTERVAL=43200
7877
CAFFEINATED=0
7978
INSTALL_OK=0 # set only after an install actually succeeds; drives status JSON
79+
BUILD_REF="" # --ref remote/branch: build that ref in a dedicated worktree, not this checkout
8080

8181
usage() { grep '^#' "$0" | grep -v '^#!' | sed 's/^# \{0,1\}//;s/^#$//'; }
8282

@@ -89,6 +89,8 @@ while [[ $# -gt 0 ]]; do
8989
--force) FORCE=1 ;;
9090
--interval) INTERVAL="${2:?--interval needs a value}"; shift ;;
9191
--interval=*) INTERVAL="${1#*=}" ;;
92+
--ref) BUILD_REF="${2:?--ref needs a value (e.g. origin/main)}"; shift ;;
93+
--ref=*) BUILD_REF="${1#*=}" ;;
9294
--print-launchd) PRINT_LAUNCHD=1 ;;
9395
--_caffeinated) CAFFEINATED=1 ;; # internal: set after re-exec under caffeinate
9496
-h|--help) usage; exit 0 ;;
@@ -104,6 +106,42 @@ if ! [[ "$INTERVAL" =~ ^[0-9]+$ ]] || [[ "$INTERVAL" -lt 1 ]]; then
104106
exit 2
105107
fi
106108

109+
# --- ref mode: build a remote ref in a dedicated worktree --------------------
110+
# $REPO is repointed at the build worktree so every downstream consumer (sha
111+
# compare, lockfile diff, pnpm cwd, app-name prediction, relative OUTPUT_DIR)
112+
# operates on the pinned checkout. $MAIN_REPO keeps the script's own repo for
113+
# the git operations that manage the worktree (fetch, worktree add).
114+
MAIN_REPO="$REPO"
115+
if [[ -n "$BUILD_REF" ]]; then
116+
if [[ ! "$BUILD_REF" =~ ^[^/]+/.+$ ]]; then
117+
echo "--ref must be <remote>/<branch> (got: '$BUILD_REF')" >&2
118+
exit 2
119+
fi
120+
REF_REMOTE="${BUILD_REF%%/*}"
121+
REF_BRANCH="${BUILD_REF#*/}"
122+
if ! git -C "$MAIN_REPO" remote get-url "$REF_REMOTE" >/dev/null 2>&1; then
123+
echo "--ref remote '$REF_REMOTE' is not a remote of $MAIN_REPO" >&2
124+
exit 2
125+
fi
126+
BUILD_WT="${T3X_AUTOBUILD_WORKTREE:-${MAIN_REPO}-build}"
127+
# `checkout --force` runs in this directory every sync; pointing it at the main
128+
# checkout would clobber whatever branch (and uncommitted work) is there.
129+
if [[ "$BUILD_WT" == "$MAIN_REPO" ]]; then
130+
echo "T3X_AUTOBUILD_WORKTREE must not be the repo itself ($MAIN_REPO)" >&2
131+
exit 2
132+
fi
133+
REPO="$BUILD_WT"
134+
fi
135+
136+
# Match scripts/build-desktop-artifact.ts, which does `path.resolve(repoRoot, outputDir)`:
137+
# a RELATIVE override is repo-relative there, so resolving it against $PWD here would make
138+
# us search/prune the caller's ./<dir> and then report "no .dmg" for a build that succeeded.
139+
OUTPUT_DIR="${T3CODE_DESKTOP_OUTPUT_DIR:-release}"
140+
case "$OUTPUT_DIR" in
141+
/*) ;; # absolute override: use as-is
142+
*) OUTPUT_DIR="$REPO/$OUTPUT_DIR" ;;
143+
esac
144+
107145
mkdir -p "$STATE_DIR" "$LOG_DIR"
108146

109147
# --- logging -----------------------------------------------------------------
@@ -151,9 +189,62 @@ write_status() {
151189
}
152190

153191
# --- helpers -----------------------------------------------------------------
154-
current_sha() { git -C "$REPO" rev-parse HEAD; }
192+
# In ref mode "what should be built" is the remote-tracking ref, read from the main
193+
# repo (worktrees share refs). The build worktree is only checkout machinery — and it
194+
# may not exist yet (first run, dry-run), so its HEAD cannot be the source of truth.
195+
current_sha() {
196+
if [[ -n "$BUILD_REF" ]]; then
197+
git -C "$MAIN_REPO" rev-parse "refs/remotes/$BUILD_REF"
198+
else
199+
git -C "$REPO" rev-parse HEAD
200+
fi
201+
}
155202
read_last_sha() { [[ -f "$LAST_SHA_FILE" ]] && cat "$LAST_SHA_FILE" || printf ''; }
156203

204+
# Ref mode: fetch the remote and pin the build worktree to $BUILD_REF's sha.
205+
# Failure returns 1 without advancing the marker, so the next tick retries.
206+
ensure_ref_synced() {
207+
local sha
208+
if ! git -C "$MAIN_REPO" fetch --quiet "$REF_REMOTE" "$REF_BRANCH"; then
209+
write_status "fetch-failed" "" "" "git fetch $REF_REMOTE $REF_BRANCH failed (offline?)"
210+
log "fetch failed: $REF_REMOTE $REF_BRANCH (offline?); will retry next tick"
211+
return 1
212+
fi
213+
sha="$(git -C "$MAIN_REPO" rev-parse "refs/remotes/$BUILD_REF")" || return 1
214+
if [[ ! -e "$BUILD_WT/.git" ]]; then
215+
if [[ $DRY_RUN -eq 1 ]]; then
216+
log "DRY-RUN would: git worktree add --detach '$BUILD_WT' $sha"
217+
return 0
218+
fi
219+
# prune first: a build worktree deleted from disk stays registered and would
220+
# make `worktree add` refuse the path forever.
221+
git -C "$MAIN_REPO" worktree prune 2>/dev/null || true
222+
log "creating build worktree: $BUILD_WT @ $sha"
223+
if ! git -C "$MAIN_REPO" worktree add --detach "$BUILD_WT" "$sha"; then
224+
write_status "worktree-failed" "$sha" "" "git worktree add failed for $BUILD_WT"
225+
log "FAILED to create build worktree $BUILD_WT"
226+
return 1
227+
fi
228+
return 0
229+
fi
230+
if [[ "$(git -C "$BUILD_WT" rev-parse HEAD)" != "$sha" ]]; then
231+
if [[ $DRY_RUN -eq 1 ]]; then
232+
log "DRY-RUN would: sync build worktree $BUILD_WT to $sha"
233+
return 0
234+
fi
235+
# --force: stray build outputs must never block an update; node_modules is
236+
# untracked and survives. --detach: never hold a branch, so main and the
237+
# other worktrees stay free to check anything out.
238+
if ! git -C "$BUILD_WT" checkout --force --detach --quiet "$sha"; then
239+
write_status "worktree-failed" "$sha" "" "checkout failed in $BUILD_WT"
240+
log "FAILED to sync build worktree to $sha"
241+
return 1
242+
fi
243+
log "build worktree synced to $sha"
244+
fi
245+
return 0
246+
}
247+
157248
lockfile_changed() {
158249
# $1 = last sha ("" if unknown). True (0) when the lockfile differs or last is unknown.
159250
local last="$1"
@@ -344,6 +435,11 @@ install_dmg() {
344435
# --- build -------------------------------------------------------------------
345436
build_once() {
346437
local cur last
438+
# Ref mode: fetch + pin the build worktree first — current_sha reads the
439+
# remote-tracking ref, which is only meaningful after a fresh fetch.
440+
if [[ -n "$BUILD_REF" ]]; then
441+
ensure_ref_synced || return 1
442+
fi
347443
cur="$(current_sha)"
348444
last="$(read_last_sha)"
349445

@@ -361,8 +457,11 @@ build_once() {
361457
# `if ! build_once` by the watch loop, and bash disables errexit for the whole dynamic
362458
# extent of a function whose status is being tested. Without these checks a failed
363459
# install would fall through and the dmg would be built against stale dependencies.
364-
if lockfile_changed "$last"; then
365-
log "pnpm-lock.yaml changed -> pnpm install --frozen-lockfile"
460+
# A fresh build worktree has NO node_modules at all, and lockfile_changed alone
461+
# would skip the install whenever the lockfile happens to be unchanged since the
462+
# last-built sha — guaranteeing a build failure on the worktree's first use.
463+
if [[ ! -d "$REPO/node_modules" ]] || lockfile_changed "$last"; then
464+
log "node_modules missing or pnpm-lock.yaml changed -> pnpm install --frozen-lockfile"
366465
if ! ( cd "$REPO" && pnpm install --frozen-lockfile ); then
367466
write_status "build-failed" "$cur" "" "pnpm install --frozen-lockfile failed"
368467
log "BUILD FAILED for $cur (dependency install)"
@@ -484,7 +583,10 @@ EOF
484583
local label="dev.t3x.autobuild"
485584
local x_script x_repo x_log
486585
x_script="$(xml_escape "$SCRIPT_DIR/auto-build-desktop.sh")"
487-
x_repo="$(xml_escape "$REPO")"
586+
# MAIN_REPO, not REPO: in ref mode REPO is the build worktree, which may not exist
587+
# until the first tick — and launchd refuses to spawn a job whose WorkingDirectory
588+
# is missing.
589+
x_repo="$(xml_escape "$MAIN_REPO")"
488590
x_log="$(xml_escape "$LOG_FILE")"
489591
# These MUST be emitted. The plist's own StandardOutPath/marker paths are derived from
490592
# these vars, so without them a plist generated from a shell that overrode
@@ -510,6 +612,7 @@ EOF
510612
<string>--watch</string>
511613
<string>--interval</string>
512614
<string>${INTERVAL}</string>
615+
$( [[ -n "$BUILD_REF" ]] && printf ' <string>--ref</string>\n <string>%s</string>' "$(xml_escape "$BUILD_REF")" || true )
513616
$( [[ $DO_INSTALL -eq 1 ]] && printf ' <string>--install</string>' || true )
514617
$( [[ $DO_RELAUNCH -eq 1 ]] && printf ' <string>--relaunch</string>' || true )
515618
</array>
@@ -524,6 +627,7 @@ $( [[ $DO_RELAUNCH -eq 1 ]] && printf ' <string>--relaunch</string>' || true
524627
<key>T3X_AUTOBUILD_APPLICATIONS_DIR</key><string>${x_apps}</string>
525628
<key>T3CODE_DESKTOP_OUTPUT_DIR</key><string>${x_out}</string>
526629
<key>T3X_AUTOBUILD_KEEP_DMGS</key><string>${KEEP_DMGS}</string>
630+
$( [[ -n "$BUILD_REF" ]] && printf ' <key>T3X_AUTOBUILD_WORKTREE</key><string>%s</string>' "$(xml_escape "$BUILD_WT")" || true )
527631
<!-- StandardOutPath and StandardErrorPath above are the SAME file, so log() must not
528632
also echo to stderr or every line is written twice. See the logging section. -->
529633
<key>T3X_AUTOBUILD_STDERR_IS_LOG</key><string>1</string>
@@ -546,6 +650,7 @@ watch_loop() {
546650
# 3.2 (what macOS ships), so `--watch` with no other flag died before its first
547651
# poll. Seeding it with --watch keeps it non-empty, which sidesteps that entirely.
548652
local args=(--watch --interval "$INTERVAL" --_caffeinated)
653+
[[ -n "$BUILD_REF" ]] && args+=(--ref "$BUILD_REF")
549654
[[ $DO_INSTALL -eq 1 ]] && args+=(--install)
550655
[[ $DO_RELAUNCH -eq 1 ]] && args+=(--relaunch)
551656
[[ $DRY_RUN -eq 1 ]] && args+=(--dry-run)

0 commit comments

Comments
 (0)