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#
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#
3845set -euo pipefail
3946
@@ -47,14 +54,6 @@ LAST_SHA_FILE="$STATE_DIR/t3x-autobuild-last-sha"
4754STATUS_FILE=" $STATE_DIR /t3x-autobuild-status.json"
4855LOG_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
5857KEEP_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
7776INTERVAL=43200
7877CAFFEINATED=0
7978INSTALL_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
8181usage () { 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
105107fi
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+
107145mkdir -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+ }
155202read_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+
157248lockfile_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 -------------------------------------------------------------------
345436build_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)"
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
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