From 212cf434ee2e3bda114b0279da2c1957586b9b79 Mon Sep 17 00:00:00 2001 From: Tom Kaczocha Date: Thu, 15 Oct 2026 20:34:28 +1000 Subject: [PATCH 1/9] test: install, rollback and build version scripts Red on purpose: install.sh, rollback.sh and build.sh do not exist yet. Issue: #3 --- .../server/src/factory/installScripts.test.ts | 231 ++++++++++++++++++ 1 file changed, 231 insertions(+) create mode 100644 apps/server/src/factory/installScripts.test.ts diff --git a/apps/server/src/factory/installScripts.test.ts b/apps/server/src/factory/installScripts.test.ts new file mode 100644 index 0000000000000..ce144602435e3 --- /dev/null +++ b/apps/server/src/factory/installScripts.test.ts @@ -0,0 +1,231 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test"; + +const scripts = NodePath.resolve(import.meta.dirname, "../../../../factory/scripts"); +const label = "io.github.rawphp.t3fac"; +const uid = process.getuid?.() ?? 0; + +const v1 = "0.0.1-test+fac.aaaaaaa"; +const v2 = "0.0.2-test+fac.bbbbbbb"; + +let tmp: string; +let home: string; +let bin: string; +let launchctlLog: string; +let launchctlLoaded: string; +const archives: Record = {}; + +const plistPath = () => NodePath.join(home, "Library/LaunchAgents", `${label}.plist`); +const root = () => NodePath.join(home, ".local/share/t3fac"); + +const run = (script: string, ...args: Array) => { + NodeFS.writeFileSync(launchctlLog, ""); + const result = NodeChildProcess.spawnSync("bash", [NodePath.join(scripts, script), ...args], { + env: { + HOME: home, + PATH: `${bin}:/usr/bin:/bin`, + LAUNCHCTL_LOG: launchctlLog, + LAUNCHCTL_LOADED: launchctlLoaded, + }, + encoding: "utf8", + }); + const calls = NodeFS.readFileSync(launchctlLog, "utf8").split("\n").filter(Boolean); + return { + status: result.status, + stderr: result.stderr, + output: result.stdout + result.stderr, + calls, + }; +}; + +const makeArchive = (version: string) => { + const dir = `t3-${version}-darwin-arm64`; + const src = NodePath.join(tmp, "src", dir); + NodeFS.mkdirSync(NodePath.join(src, "client"), { recursive: true }); + NodeFS.writeFileSync(NodePath.join(src, "t3"), "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + NodeFS.writeFileSync(NodePath.join(src, "client/index.html"), "\n"); + const archive = NodePath.join(tmp, `t3fac-${version}-darwin-arm64.tar.gz`); + const result = NodeChildProcess.spawnSync( + "tar", + ["-czf", archive, "-C", NodePath.join(tmp, "src"), dir], + { + encoding: "utf8", + }, + ); + if (result.status !== 0) throw new Error(`tar: ${result.stderr}`); + return archive; +}; + +describe("install.sh and rollback.sh", () => { + beforeAll(() => { + tmp = NodeFS.realpathSync(NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3fac-install-"))); + home = NodePath.join(tmp, "home"); + bin = NodePath.join(tmp, "bin"); + launchctlLog = NodePath.join(tmp, "launchctl.log"); + launchctlLoaded = NodePath.join(tmp, "launchctl.loaded"); + NodeFS.mkdirSync(home); + NodeFS.mkdirSync(bin); + NodeFS.writeFileSync( + NodePath.join(bin, "launchctl"), + [ + "#!/bin/sh", + 'echo "$@" >> "$LAUNCHCTL_LOG"', + 'case "$1" in', + ' print) [ -e "$LAUNCHCTL_LOADED" ] && exit 0; exit 113 ;;', + ' bootstrap) touch "$LAUNCHCTL_LOADED" ;;', + ' bootout) rm -f "$LAUNCHCTL_LOADED" ;;', + "esac", + "exit 0", + "", + ].join("\n"), + { mode: 0o755 }, + ); + archives[v1] = makeArchive(v1); + archives[v2] = makeArchive(v2); + }); + + afterAll(() => { + NodeFS.rmSync(tmp, { recursive: true, force: true }); + }); + + it("installs the first version and bootstraps the job without a bootout", () => { + const result = run("install.sh", archives[v1]!); + expect(result.status).toBe(0); + + const current = NodePath.join(root(), "current"); + expect(NodeFS.lstatSync(current).isSymbolicLink()).toBe(true); + expect(NodeFS.readlinkSync(current)).toBe(`versions/${v1}`); + expect(NodeFS.realpathSync(current).startsWith(NodeFS.realpathSync(home))).toBe(true); + expect(NodeFS.existsSync(NodePath.join(root(), "versions", v1, "t3"))).toBe(true); + + const plist = NodeFS.readFileSync(plistPath(), "utf8"); + expect(plist).not.toContain("@HOME@"); + for (const part of [ + `${home}/.local/share/t3fac/current/t3`, + "serve", + "--base-dir", + `${home}/.config/t3fac`, + "--port", + "3876", + `${home}/Library/Logs/t3fac/`, + ]) { + expect(plist).toContain(part); + } + + expect(result.calls).toEqual([ + `print gui/${uid}/${label}`, + `bootstrap gui/${uid} ${plistPath()}`, + ]); + }); + + it("keeps the old version as previous and boots the job out first when loaded", () => { + const result = run("install.sh", archives[v2]!); + expect(result.status).toBe(0); + expect(NodeFS.readlinkSync(NodePath.join(root(), "current"))).toBe(`versions/${v2}`); + expect(NodeFS.readlinkSync(NodePath.join(root(), "previous"))).toBe(`versions/${v1}`); + expect(result.calls).toEqual([ + `print gui/${uid}/${label}`, + `bootout gui/${uid}/${label}`, + `bootstrap gui/${uid} ${plistPath()}`, + ]); + }); + + it("rolls back to the previous version", () => { + const result = run("rollback.sh"); + expect(result.status).toBe(0); + expect(NodeFS.readlinkSync(NodePath.join(root(), "current"))).toBe(`versions/${v1}`); + expect(NodeFS.existsSync(NodePath.join(root(), "previous"))).toBe(false); + expect(NodeFS.lstatSync(plistPath()).isFile()).toBe(true); + expect(result.calls).toEqual([ + `print gui/${uid}/${label}`, + `bootout gui/${uid}/${label}`, + `bootstrap gui/${uid} ${plistPath()}`, + ]); + }); + + it("exits 1 when there is no earlier version", () => { + const result = run("rollback.sh"); + expect(result.status).toBe(1); + expect(result.stderr).toContain("no earlier version"); + expect(NodeFS.readlinkSync(NodePath.join(root(), "current"))).toBe(`versions/${v1}`); + expect(result.calls).toEqual([]); + }); + + it("refuses an archive with the wrong name", () => { + const wrong = NodePath.join(tmp, "t3-0.0.3-darwin-arm64.tar.gz"); + NodeFS.copyFileSync(archives[v1]!, wrong); + const result = run("install.sh", wrong); + expect(result.status).toBe(1); + expect(NodeFS.readlinkSync(NodePath.join(root(), "current"))).toBe(`versions/${v1}`); + }); +}); + +describe("build.sh --print-version", () => { + let repo: string; + + const git = (...args: Array) => { + const result = NodeChildProcess.spawnSync("git", args, { cwd: repo, encoding: "utf8" }); + if (result.status !== 0) throw new Error(`git ${args.join(" ")}: ${result.stderr}`); + return result.stdout.trim(); + }; + + const write = (file: string, content: string) => { + const full = NodePath.join(repo, file); + NodeFS.mkdirSync(NodePath.dirname(full), { recursive: true }); + NodeFS.writeFileSync(full, content); + }; + + const printVersion = () => { + const result = NodeChildProcess.spawnSync( + "bash", + [NodePath.join(scripts, "build.sh"), "--print-version"], + { + cwd: repo, + env: { ...process.env, T3FAC_REPO_ROOT: "" }, + encoding: "utf8", + }, + ); + return { status: result.status, stdout: result.stdout.trim(), stderr: result.stderr }; + }; + + beforeAll(() => { + repo = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3fac-version-")); + git("init", "-q"); + git("config", "user.name", "Test"); + git("config", "user.email", "test@example.com"); + git("config", "commit.gpgsign", "false"); + write("apps/server/package.json", '{ "name": "t3", "version": "9.9.9" }\n'); + }); + + afterAll(() => { + NodeFS.rmSync(repo, { recursive: true, force: true }); + }); + + it("uses the nightly tag without its leading v", () => { + write( + "factory/UPSTREAM", + "v1.2.3-nightly.20260101.1 0123456789abcdef0123456789abcdef01234567\n", + ); + git("add", "-A"); + git("commit", "-q", "-m", "nightly"); + const result = printVersion(); + expect(result.status).toBe(0); + expect(result.stdout).toBe( + `1.2.3-nightly.20260101.1+fac.${git("rev-parse", "--short", "HEAD")}`, + ); + }); + + it("uses the server package version for a non-nightly tag", () => { + write("factory/UPSTREAM", "v1.2.3 0123456789abcdef0123456789abcdef01234567\n"); + git("add", "-A"); + git("commit", "-q", "-m", "stable"); + const result = printVersion(); + expect(result.status).toBe(0); + expect(result.stdout).toBe(`9.9.9+fac.${git("rev-parse", "--short", "HEAD")}`); + }); +}); From 10a3b70f5456bf1d9a538fe47a9032a320ce8f6f Mon Sep 17 00:00:00 2001 From: Tom Kaczocha Date: Thu, 15 Oct 2026 20:35:01 +1000 Subject: [PATCH 2/9] feat(factory): build, install and rollback scripts for the darwin-arm64 binary Issue: #3 --- factory/README.md | 8 ++ factory/launchd/io.github.rawphp.t3fac.plist | 34 ++++++ factory/scripts/build.sh | 105 +++++++++++++++++++ factory/scripts/install.sh | 55 ++++++++++ factory/scripts/rollback.sh | 29 +++++ 5 files changed, 231 insertions(+) create mode 100644 factory/launchd/io.github.rawphp.t3fac.plist create mode 100755 factory/scripts/build.sh create mode 100755 factory/scripts/install.sh create mode 100755 factory/scripts/rollback.sh diff --git a/factory/README.md b/factory/README.md index 7bfa5ddb932da..1c96a0221c05c 100644 --- a/factory/README.md +++ b/factory/README.md @@ -19,3 +19,11 @@ vp test run apps/server/src/factory vp run --filter t3 typecheck factory/scripts/check-seams.sh ``` + +Build, install and roll back the darwin-arm64 binary (LaunchAgent `io.github.rawphp.t3fac`, port 3876): + +```sh +factory/scripts/build.sh # -> release/t3fac--darwin-arm64.tar.gz, then a smoke test +factory/scripts/install.sh # unpack, point `current` at it, restart the job +factory/scripts/rollback.sh # point `current` at the previous version, restart the job +``` diff --git a/factory/launchd/io.github.rawphp.t3fac.plist b/factory/launchd/io.github.rawphp.t3fac.plist new file mode 100644 index 0000000000000..9d13924487e78 --- /dev/null +++ b/factory/launchd/io.github.rawphp.t3fac.plist @@ -0,0 +1,34 @@ + + + + + Label + io.github.rawphp.t3fac + ProgramArguments + + @HOME@/.local/share/t3fac/current/t3 + serve + --base-dir + @HOME@/.config/t3fac + --port + 3876 + + WorkingDirectory + @HOME@ + EnvironmentVariables + + PATH + /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:@HOME@/.local/bin + + RunAtLoad + + KeepAlive + + ThrottleInterval + 10 + StandardOutPath + @HOME@/Library/Logs/t3fac/t3fac.log + StandardErrorPath + @HOME@/Library/Logs/t3fac/t3fac.log + + diff --git a/factory/scripts/build.sh b/factory/scripts/build.sh new file mode 100755 index 0000000000000..0d769723f83d2 --- /dev/null +++ b/factory/scripts/build.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# Builds the fork into release/t3fac--darwin-arm64.tar.gz with upstream's own +# build scripts, then smoke-tests the archive. `build.sh --print-version` only prints the version. +set -euo pipefail + +root=${T3FAC_REPO_ROOT:-$(git rev-parse --show-toplevel)} +cd "$root" + +read -r tag _sha < factory/UPSTREAM +if [[ $tag =~ ^v[^\ ]*-nightly\. ]]; then + upstream=${tag#v} +else + upstream=$(node -p 'require("./apps/server/package.json").version') +fi +version="${upstream}+fac.$(git rev-parse --short HEAD)" + +if [[ ${1:-} == --print-version ]]; then + echo "$version" + exit 0 +fi + +stage="" +smoke="" +server_pid="" + +cleanup() { + if [[ -n $server_pid ]] && kill -0 "$server_pid" 2>/dev/null; then + kill "$server_pid" 2>/dev/null || true + for _ in $(seq 1 50); do + kill -0 "$server_pid" 2>/dev/null || break + sleep 0.2 + done + kill -9 "$server_pid" 2>/dev/null || true + fi + [[ -z $smoke ]] || rm -rf "$smoke" + [[ -z $stage ]] || rm -rf "$stage" +} +trap cleanup EXIT + +# The SEA build needs the Node version upstream pins in the server's vite config. +sea_node=$(sed -n 's/^const SEA_NODE_VERSION = "\([^"]*\)";.*/\1/p' apps/server/vite.config.ts) +if [[ -z $sea_node ]]; then + echo "could not read SEA_NODE_VERSION from apps/server/vite.config.ts" >&2 + exit 1 +fi + +echo "build: version $version (SEA node $sea_node)" + +vp i +vp run --filter t3 build +(cd native/resource-monitor && cargo build --locked --release) +VP_NODE_VERSION="$sea_node" vp node apps/server/scripts/cli.ts build-exe + +stage=$(mktemp -d) +mkdir -p "$stage/monitor/darwin-arm64" "$stage/out" +cp native/resource-monitor/target/release/t3-resource-monitor "$stage/monitor/darwin-arm64/t3-resource-monitor" +VP_NODE_VERSION="$sea_node" vp node scripts/build-cli-archive.ts \ + --platform mac --arch arm64 --version "$version" \ + --resource-monitor-dir "$stage/monitor" --output-dir "$stage/out" + +mkdir -p release +archive="release/t3fac-${version}-darwin-arm64.tar.gz" +mv "$stage/out/t3-${version}-darwin-arm64.tar.gz" "$archive" + +# Smoke test: start the built server on a free port with a throwaway HOME and base dir. +smoke=$(mktemp -d) +mkdir -p "$smoke/pkg" "$smoke/home" +tar -xzf "$archive" -C "$smoke/pkg" +pkg=$(find "$smoke/pkg" -mindepth 1 -maxdepth 1 -type d | head -n 1) +port=$(node -e 'const s=require("net").createServer();s.listen(0,"127.0.0.1",()=>{console.log(s.address().port);s.close()})') + +fail() { + echo "smoke: FAILED: $1" >&2 + tail -n 40 "$smoke/server.log" >&2 || true + exit 1 +} + +echo "smoke: serve on 127.0.0.1:$port" +HOME="$smoke/home" "$pkg/t3" serve --base-dir "$smoke/base" --host 127.0.0.1 --port "$port" \ + > "$smoke/server.log" 2>&1 & +server_pid=$! + +ok="" +start=$SECONDS +for _ in $(seq 1 120); do + kill -0 "$server_pid" 2>/dev/null || fail "server exited early" + code=$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:$port/.well-known/t3/environment" || true) + if [[ $code == 200 ]]; then + ok=1 + break + fi + sleep 0.5 +done +[[ -n $ok ]] || fail "/.well-known/t3/environment did not answer 200 within 60s" +echo "smoke: /.well-known/t3/environment -> 200 after $((SECONDS - start))s" + +grep -q 'factory started' "$smoke/server.log" || fail 'server log does not hold "factory started"' +echo 'smoke: log holds "factory started"' + +cleanup +smoke="" +server_pid="" +echo "smoke: server stopped, temp dir removed" + +echo "$root/$archive" diff --git a/factory/scripts/install.sh b/factory/scripts/install.sh new file mode 100755 index 0000000000000..436999edfe026 --- /dev/null +++ b/factory/scripts/install.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Installs a t3fac archive under ~/.local/share/t3fac/versions, points `current` at it, +# writes the LaunchAgent plist and restarts the job. Usage: install.sh +set -euo pipefail + +label=io.github.rawphp.t3fac +archive=${1:-} +if [[ -z $archive || ! -f $archive ]]; then + echo "usage: install.sh -darwin-arm64.tar.gz>" >&2 + exit 1 +fi + +name=$(basename "$archive") +if [[ $name != t3fac-*-darwin-arm64.tar.gz ]]; then + echo "unexpected archive name: $name (want t3fac--darwin-arm64.tar.gz)" >&2 + exit 1 +fi +version=${name#t3fac-} +version=${version%-darwin-arm64.tar.gz} + +root="$HOME/.local/share/t3fac" +dest="$root/versions/$version" +template="$(cd "$(dirname "$0")" && pwd)/../launchd/$label.plist" +plist="$HOME/Library/LaunchAgents/$label.plist" + +mkdir -p "$root/versions" +rm -rf "$dest" +mkdir -p "$dest" +tar -xzf "$archive" -C "$dest" --strip-components=1 +if [[ ! -e $dest/t3 ]]; then + echo "archive has no t3 binary: $name" >&2 + exit 1 +fi + +# Keep the version that was current as `previous`, unless it is the one being reinstalled. +if [[ -L $root/current ]]; then + old=$(readlink "$root/current") + if [[ $old != "versions/$version" ]]; then + ln -sfn "$old" "$root/previous" + fi +fi +ln -sfn "versions/$version" "$root/current" + +mkdir -p "$HOME/Library/LaunchAgents" "$HOME/Library/Logs/t3fac" "$HOME/.config/t3fac" +sed "s|@HOME@|$HOME|g" "$template" > "$plist" + +uid=$(id -u) +if launchctl print "gui/$uid/$label" > /dev/null 2>&1; then + launchctl bootout "gui/$uid/$label" +fi +launchctl bootstrap "gui/$uid" "$plist" + +echo "installed $version" +echo "current -> versions/$version" +echo "job $label restarted" diff --git a/factory/scripts/rollback.sh b/factory/scripts/rollback.sh new file mode 100755 index 0000000000000..7772f0ded2fcd --- /dev/null +++ b/factory/scripts/rollback.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Points `current` at the version installed before it and restarts the LaunchAgent job. +# Exits 1 when there is no earlier version. +set -euo pipefail + +label=io.github.rawphp.t3fac +root="$HOME/.local/share/t3fac" +template="$(cd "$(dirname "$0")" && pwd)/../launchd/$label.plist" +plist="$HOME/Library/LaunchAgents/$label.plist" + +if [[ ! -L $root/previous || ! -e $root/previous ]]; then + echo "no earlier version to roll back to" >&2 + exit 1 +fi + +target=$(readlink "$root/previous") +ln -sfn "$target" "$root/current" +rm -f "$root/previous" + +mkdir -p "$HOME/Library/LaunchAgents" "$HOME/Library/Logs/t3fac" "$HOME/.config/t3fac" +sed "s|@HOME@|$HOME|g" "$template" > "$plist" + +uid=$(id -u) +if launchctl print "gui/$uid/$label" > /dev/null 2>&1; then + launchctl bootout "gui/$uid/$label" +fi +launchctl bootstrap "gui/$uid" "$plist" + +echo "rolled back to ${target#versions/}" From fb7399f8bdfdef1d39ec3a67ebeef9c1000a32f6 Mon Sep 17 00:00:00 2001 From: Tom Kaczocha Date: Thu, 15 Oct 2026 20:47:10 +1000 Subject: [PATCH 3/9] fix(factory): build.sh ignores host cargo config and bounds each smoke probe The cargo step now runs from / with its own CARGO_HOME under the gitignored target/, so a host-level crates.io source replacement in a parent or home cargo config no longer breaks the build. Each smoke probe gets curl --max-time 2, and the poll loop breaks once 60s have elapsed, so a connection that hangs while the server initialises cannot stall the build. Issue: #3 --- factory/scripts/build.sh | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/factory/scripts/build.sh b/factory/scripts/build.sh index 0d769723f83d2..1d2d6336d0d7a 100755 --- a/factory/scripts/build.sh +++ b/factory/scripts/build.sh @@ -48,7 +48,12 @@ echo "build: version $version (SEA node $sea_node)" vp i vp run --filter t3 build -(cd native/resource-monitor && cargo build --locked --release) +# Cargo reads .cargo/config.toml from every parent of the cwd and from CARGO_HOME, so a +# build under $HOME would inherit a host-level crates.io replacement. The lockfile pins +# crates.io, so build from / with a CARGO_HOME of its own under the gitignored target/. +cargo_home="$root/native/resource-monitor/target/cargo-home" +mkdir -p "$cargo_home" +(cd / && CARGO_HOME="$cargo_home" cargo build --locked --release --manifest-path "$root/native/resource-monitor/Cargo.toml") VP_NODE_VERSION="$sea_node" vp node apps/server/scripts/cli.ts build-exe stage=$(mktemp -d) @@ -84,11 +89,12 @@ ok="" start=$SECONDS for _ in $(seq 1 120); do kill -0 "$server_pid" 2>/dev/null || fail "server exited early" - code=$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:$port/.well-known/t3/environment" || true) + code=$(curl -s --max-time 2 -o /dev/null -w '%{http_code}' "http://127.0.0.1:$port/.well-known/t3/environment" || true) if [[ $code == 200 ]]; then ok=1 break fi + (( SECONDS - start >= 60 )) && break sleep 0.5 done [[ -n $ok ]] || fail "/.well-known/t3/environment did not answer 200 within 60s" From 3c841833d7f9f9b2a6bb2927313a85ed8912b955 Mon Sep 17 00:00:00 2001 From: Tom Kaczocha Date: Thu, 15 Oct 2026 20:51:22 +1000 Subject: [PATCH 4/9] test: install keeps the live version on a bad archive; restart waits for launchd to unload The launchctl stub now keeps the job loaded for two print polls after bootout and fails bootstrap with EIO while loaded (or when LAUNCHCTL_BOOTSTRAP_FAIL is set). Red before the fix: the second install and the rollback exit 5 because the scripts bootstrap straight after bootout. The new failure-case describe cannot even set up (its second install hits the same exit 5), so the bad-archive, empty-version and failed-restart cases are skipped rather than individually red. Issue: #3 --- .../server/src/factory/installScripts.test.ts | 156 +++++++++++++----- 1 file changed, 118 insertions(+), 38 deletions(-) diff --git a/apps/server/src/factory/installScripts.test.ts b/apps/server/src/factory/installScripts.test.ts index ce144602435e3..55d35de92528a 100644 --- a/apps/server/src/factory/installScripts.test.ts +++ b/apps/server/src/factory/installScripts.test.ts @@ -18,12 +18,15 @@ let home: string; let bin: string; let launchctlLog: string; let launchctlLoaded: string; +let launchctlDrain: string; const archives: Record = {}; const plistPath = () => NodePath.join(home, "Library/LaunchAgents", `${label}.plist`); const root = () => NodePath.join(home, ".local/share/t3fac"); -const run = (script: string, ...args: Array) => { +const run = (script: string, ...args: Array) => runWith({}, script, ...args); + +const runWith = (extraEnv: Record, script: string, ...args: Array) => { NodeFS.writeFileSync(launchctlLog, ""); const result = NodeChildProcess.spawnSync("bash", [NodePath.join(scripts, script), ...args], { env: { @@ -31,6 +34,8 @@ const run = (script: string, ...args: Array) => { PATH: `${bin}:/usr/bin:/bin`, LAUNCHCTL_LOG: launchctlLog, LAUNCHCTL_LOADED: launchctlLoaded, + LAUNCHCTL_DRAIN: launchctlDrain, + ...extraEnv, }, encoding: "utf8", }); @@ -43,16 +48,19 @@ const run = (script: string, ...args: Array) => { }; }; -const makeArchive = (version: string) => { +const makeArchive = (version: string, options: { binary?: boolean; label?: string } = {}) => { + const base = options.label ? NodePath.join(tmp, options.label) : tmp; const dir = `t3-${version}-darwin-arm64`; - const src = NodePath.join(tmp, "src", dir); + const src = NodePath.join(base, "src", dir); NodeFS.mkdirSync(NodePath.join(src, "client"), { recursive: true }); - NodeFS.writeFileSync(NodePath.join(src, "t3"), "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + if (options.binary !== false) { + NodeFS.writeFileSync(NodePath.join(src, "t3"), "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + } NodeFS.writeFileSync(NodePath.join(src, "client/index.html"), "\n"); - const archive = NodePath.join(tmp, `t3fac-${version}-darwin-arm64.tar.gz`); + const archive = NodePath.join(base, `t3fac-${version}-darwin-arm64.tar.gz`); const result = NodeChildProcess.spawnSync( "tar", - ["-czf", archive, "-C", NodePath.join(tmp, "src"), dir], + ["-czf", archive, "-C", NodePath.join(base, "src"), dir], { encoding: "utf8", }, @@ -61,30 +69,60 @@ const makeArchive = (version: string) => { return archive; }; +// A little launchd-like: bootout leaves the job loaded for two more `print` polls, and +// bootstrap fails with EIO while it is still loaded (or when LAUNCHCTL_BOOTSTRAP_FAIL is set). +const setupSandbox = () => { + tmp = NodeFS.realpathSync(NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3fac-install-"))); + home = NodePath.join(tmp, "home"); + bin = NodePath.join(tmp, "bin"); + launchctlLog = NodePath.join(tmp, "launchctl.log"); + launchctlLoaded = NodePath.join(tmp, "launchctl.loaded"); + launchctlDrain = NodePath.join(tmp, "launchctl.drain"); + NodeFS.mkdirSync(home); + NodeFS.mkdirSync(bin); + NodeFS.writeFileSync( + NodePath.join(bin, "launchctl"), + [ + "#!/bin/sh", + 'echo "$@" >> "$LAUNCHCTL_LOG"', + 'case "$1" in', + " print)", + ' if [ -e "$LAUNCHCTL_DRAIN" ]; then', + ' n=$(cat "$LAUNCHCTL_DRAIN")', + ' if [ "$n" -gt 0 ]; then echo $((n - 1)) > "$LAUNCHCTL_DRAIN"; exit 0; fi', + ' rm -f "$LAUNCHCTL_DRAIN" "$LAUNCHCTL_LOADED"', + " exit 113", + " fi", + ' [ -e "$LAUNCHCTL_LOADED" ] && exit 0', + " exit 113 ;;", + " bootstrap)", + ' if [ -n "$LAUNCHCTL_BOOTSTRAP_FAIL" ] || [ -e "$LAUNCHCTL_LOADED" ]; then', + ' echo "Bootstrap failed: 5: Input/output error" >&2', + " exit 5", + " fi", + ' touch "$LAUNCHCTL_LOADED" ;;', + ' bootout) echo 2 > "$LAUNCHCTL_DRAIN" ;;', + "esac", + "exit 0", + "", + ].join("\n"), + { mode: 0o755 }, + ); +}; + +// bootout, then at least one print poll, then bootstrap. +const expectRestartSequence = (calls: Array) => { + expect(calls[0]).toBe(`print gui/${uid}/${label}`); + expect(calls[1]).toBe(`bootout gui/${uid}/${label}`); + expect(calls.at(-1)).toBe(`bootstrap gui/${uid} ${plistPath()}`); + const between = calls.slice(2, -1); + expect(between.length).toBeGreaterThanOrEqual(1); + expect(between.every((call) => call === `print gui/${uid}/${label}`)).toBe(true); +}; + describe("install.sh and rollback.sh", () => { beforeAll(() => { - tmp = NodeFS.realpathSync(NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3fac-install-"))); - home = NodePath.join(tmp, "home"); - bin = NodePath.join(tmp, "bin"); - launchctlLog = NodePath.join(tmp, "launchctl.log"); - launchctlLoaded = NodePath.join(tmp, "launchctl.loaded"); - NodeFS.mkdirSync(home); - NodeFS.mkdirSync(bin); - NodeFS.writeFileSync( - NodePath.join(bin, "launchctl"), - [ - "#!/bin/sh", - 'echo "$@" >> "$LAUNCHCTL_LOG"', - 'case "$1" in', - ' print) [ -e "$LAUNCHCTL_LOADED" ] && exit 0; exit 113 ;;', - ' bootstrap) touch "$LAUNCHCTL_LOADED" ;;', - ' bootout) rm -f "$LAUNCHCTL_LOADED" ;;', - "esac", - "exit 0", - "", - ].join("\n"), - { mode: 0o755 }, - ); + setupSandbox(); archives[v1] = makeArchive(v1); archives[v2] = makeArchive(v2); }); @@ -128,11 +166,7 @@ describe("install.sh and rollback.sh", () => { expect(result.status).toBe(0); expect(NodeFS.readlinkSync(NodePath.join(root(), "current"))).toBe(`versions/${v2}`); expect(NodeFS.readlinkSync(NodePath.join(root(), "previous"))).toBe(`versions/${v1}`); - expect(result.calls).toEqual([ - `print gui/${uid}/${label}`, - `bootout gui/${uid}/${label}`, - `bootstrap gui/${uid} ${plistPath()}`, - ]); + expectRestartSequence(result.calls); }); it("rolls back to the previous version", () => { @@ -141,11 +175,7 @@ describe("install.sh and rollback.sh", () => { expect(NodeFS.readlinkSync(NodePath.join(root(), "current"))).toBe(`versions/${v1}`); expect(NodeFS.existsSync(NodePath.join(root(), "previous"))).toBe(false); expect(NodeFS.lstatSync(plistPath()).isFile()).toBe(true); - expect(result.calls).toEqual([ - `print gui/${uid}/${label}`, - `bootout gui/${uid}/${label}`, - `bootstrap gui/${uid} ${plistPath()}`, - ]); + expectRestartSequence(result.calls); }); it("exits 1 when there is no earlier version", () => { @@ -165,6 +195,56 @@ describe("install.sh and rollback.sh", () => { }); }); +describe("install.sh and rollback.sh failure cases", () => { + let v2Dir: string; + + beforeAll(() => { + setupSandbox(); + archives[v1] = makeArchive(v1); + archives[v2] = makeArchive(v2); + expect(run("install.sh", archives[v1]!).status).toBe(0); + expect(run("install.sh", archives[v2]!).status).toBe(0); + v2Dir = NodePath.join(root(), "versions", v2); + }); + + afterAll(() => { + NodeFS.rmSync(tmp, { recursive: true, force: true }); + }); + + it("keeps the live version when reinstalling it from an archive without a binary", () => { + const broken = makeArchive(v2, { binary: false, label: "broken" }); + const result = run("install.sh", broken); + expect(result.status).toBe(1); + expect(result.stderr).toContain("archive has no t3 binary"); + expect(NodeFS.existsSync(NodePath.join(v2Dir, "t3"))).toBe(true); + expect(NodeFS.readlinkSync(NodePath.join(root(), "current"))).toBe(`versions/${v2}`); + expect(NodeFS.readlinkSync(NodePath.join(root(), "previous"))).toBe(`versions/${v1}`); + expect(NodeFS.readdirSync(NodePath.join(root(), "versions")).sort()).toEqual([v1, v2].sort()); + expect(result.calls).toEqual([]); + }); + + it("rejects an archive name with an empty version before touching disk", () => { + const versionsBefore = NodeFS.readdirSync(NodePath.join(root(), "versions")).sort(); + const result = run("install.sh", makeArchive("", { label: "empty" })); + expect(result.status).toBe(1); + expect(NodeFS.readdirSync(NodePath.join(root(), "versions")).sort()).toEqual(versionsBefore); + expect(NodeFS.existsSync(NodePath.join(v2Dir, "t3"))).toBe(true); + expect(result.calls).toEqual([]); + }); + + it("leaves previous in place when the restart fails, so rollback can be rerun", () => { + const failed = runWith({ LAUNCHCTL_BOOTSTRAP_FAIL: "1" }, "rollback.sh"); + expect(failed.status).not.toBe(0); + expect(NodeFS.readlinkSync(NodePath.join(root(), "current"))).toBe(`versions/${v1}`); + expect(NodeFS.existsSync(NodePath.join(root(), "previous"))).toBe(true); + + const retry = run("rollback.sh"); + expect(retry.status).toBe(0); + expect(NodeFS.readlinkSync(NodePath.join(root(), "current"))).toBe(`versions/${v1}`); + expect(NodeFS.existsSync(NodePath.join(root(), "previous"))).toBe(false); + }); +}); + describe("build.sh --print-version", () => { let repo: string; From d0229babcb19cd32c73886d063240aad657b10de Mon Sep 17 00:00:00 2001 From: Tom Kaczocha Date: Thu, 15 Oct 2026 20:51:33 +1000 Subject: [PATCH 5/9] fix(factory): install validates the archive before replacing a version; restart waits for bootout to finish Issue: #3 --- factory/scripts/install.sh | 27 +++++++++++++++++++++++---- factory/scripts/rollback.sh | 14 +++++++++++++- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/factory/scripts/install.sh b/factory/scripts/install.sh index 436999edfe026..0d0fc1eb0224d 100755 --- a/factory/scripts/install.sh +++ b/factory/scripts/install.sh @@ -17,6 +17,10 @@ if [[ $name != t3fac-*-darwin-arm64.tar.gz ]]; then fi version=${name#t3fac-} version=${version%-darwin-arm64.tar.gz} +if [[ -z $version || $version == */* || $version == . || $version == .. ]]; then + echo "invalid version in archive name: $name" >&2 + exit 1 +fi root="$HOME/.local/share/t3fac" dest="$root/versions/$version" @@ -24,13 +28,18 @@ template="$(cd "$(dirname "$0")" && pwd)/../launchd/$label.plist" plist="$HOME/Library/LaunchAgents/$label.plist" mkdir -p "$root/versions" -rm -rf "$dest" -mkdir -p "$dest" -tar -xzf "$archive" -C "$dest" --strip-components=1 -if [[ ! -e $dest/t3 ]]; then +# Unpack beside the destination and swap in only a good archive, so a bad one never +# damages the version that `current` may already point at. +tmp=$(mktemp -d "$root/versions/.t3fac-install.XXXXXX") +trap 'rm -rf "$tmp"' EXIT +chmod 755 "$tmp" +tar -xzf "$archive" -C "$tmp" --strip-components=1 +if [[ ! -e $tmp/t3 ]]; then echo "archive has no t3 binary: $name" >&2 exit 1 fi +rm -rf "$dest" +mv "$tmp" "$dest" # Keep the version that was current as `previous`, unless it is the one being reinstalled. if [[ -L $root/current ]]; then @@ -45,8 +54,18 @@ mkdir -p "$HOME/Library/LaunchAgents" "$HOME/Library/Logs/t3fac" "$HOME/.config/ sed "s|@HOME@|$HOME|g" "$template" > "$plist" uid=$(id -u) +wait_unloaded() { + local i + for ((i = 0; i < 50; i++)); do + launchctl print "gui/$uid/$label" > /dev/null 2>&1 || return 0 + sleep 0.2 + done + echo "job $label is still loaded 10s after bootout" >&2 + return 1 +} if launchctl print "gui/$uid/$label" > /dev/null 2>&1; then launchctl bootout "gui/$uid/$label" + wait_unloaded fi launchctl bootstrap "gui/$uid" "$plist" diff --git a/factory/scripts/rollback.sh b/factory/scripts/rollback.sh index 7772f0ded2fcd..b6c8d605153d4 100755 --- a/factory/scripts/rollback.sh +++ b/factory/scripts/rollback.sh @@ -15,15 +15,27 @@ fi target=$(readlink "$root/previous") ln -sfn "$target" "$root/current" -rm -f "$root/previous" mkdir -p "$HOME/Library/LaunchAgents" "$HOME/Library/Logs/t3fac" "$HOME/.config/t3fac" sed "s|@HOME@|$HOME|g" "$template" > "$plist" uid=$(id -u) +wait_unloaded() { + local i + for ((i = 0; i < 50; i++)); do + launchctl print "gui/$uid/$label" > /dev/null 2>&1 || return 0 + sleep 0.2 + done + echo "job $label is still loaded 10s after bootout" >&2 + return 1 +} if launchctl print "gui/$uid/$label" > /dev/null 2>&1; then launchctl bootout "gui/$uid/$label" + wait_unloaded fi launchctl bootstrap "gui/$uid" "$plist" +# Only now is the rollback done; a failed restart above leaves `previous` for a rerun. +rm -f "$root/previous" + echo "rolled back to ${target#versions/}" From a2df0cec780378c9fd8040b416b9a1629b8b9571 Mon Sep 17 00:00:00 2001 From: Tom Kaczocha Date: Thu, 15 Oct 2026 20:56:00 +1000 Subject: [PATCH 6/9] test: reinstalling the current version keeps previous; invalid names and restart retry Closes three gaps from the testing review: - reinstalling the current version leaves previous untouched, so rollback still lands on the older version - invalid-version guard is tested for both the empty and '..' archive names - the failed-restart rollback retry now asserts it ends with bootstrap instead of re-checking current Issue: #3 --- .../server/src/factory/installScripts.test.ts | 35 +++++++++++++++++-- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/apps/server/src/factory/installScripts.test.ts b/apps/server/src/factory/installScripts.test.ts index 55d35de92528a..a878a5cb0a2ac 100644 --- a/apps/server/src/factory/installScripts.test.ts +++ b/apps/server/src/factory/installScripts.test.ts @@ -223,10 +223,14 @@ describe("install.sh and rollback.sh failure cases", () => { expect(result.calls).toEqual([]); }); - it("rejects an archive name with an empty version before touching disk", () => { + it.each([ + ["empty", ""], + ["dotdot", ".."], + ])("rejects an archive name with a %s version before touching disk", (label, version) => { const versionsBefore = NodeFS.readdirSync(NodePath.join(root(), "versions")).sort(); - const result = run("install.sh", makeArchive("", { label: "empty" })); + const result = run("install.sh", makeArchive(version, { label })); expect(result.status).toBe(1); + expect(result.stderr).toContain("invalid version in archive name"); expect(NodeFS.readdirSync(NodePath.join(root(), "versions")).sort()).toEqual(versionsBefore); expect(NodeFS.existsSync(NodePath.join(v2Dir, "t3"))).toBe(true); expect(result.calls).toEqual([]); @@ -240,11 +244,36 @@ describe("install.sh and rollback.sh failure cases", () => { const retry = run("rollback.sh"); expect(retry.status).toBe(0); - expect(NodeFS.readlinkSync(NodePath.join(root(), "current"))).toBe(`versions/${v1}`); + expect(retry.calls.at(-1)).toBe(`bootstrap gui/${uid} ${plistPath()}`); expect(NodeFS.existsSync(NodePath.join(root(), "previous"))).toBe(false); }); }); +describe("install.sh reinstalling the current version", () => { + beforeAll(() => { + setupSandbox(); + archives[v1] = makeArchive(v1); + archives[v2] = makeArchive(v2); + expect(run("install.sh", archives[v1]!).status).toBe(0); + expect(run("install.sh", archives[v2]!).status).toBe(0); + }); + + afterAll(() => { + NodeFS.rmSync(tmp, { recursive: true, force: true }); + }); + + it("keeps previous unchanged, so rollback still lands on the older version", () => { + const reinstall = run("install.sh", archives[v2]!); + expect(reinstall.status).toBe(0); + expect(NodeFS.readlinkSync(NodePath.join(root(), "current"))).toBe(`versions/${v2}`); + expect(NodeFS.readlinkSync(NodePath.join(root(), "previous"))).toBe(`versions/${v1}`); + + const rollback = run("rollback.sh"); + expect(rollback.status).toBe(0); + expect(NodeFS.readlinkSync(NodePath.join(root(), "current"))).toBe(`versions/${v1}`); + }); +}); + describe("build.sh --print-version", () => { let repo: string; From dee5cff63f082163892c16af9bbee23f7751c270 Mon Sep 17 00:00:00 2001 From: Tom Kaczocha Date: Thu, 15 Oct 2026 21:38:58 +1000 Subject: [PATCH 7/9] fix(factory): smoke-test before release, share the job restart, mark dirty builds - build.sh smoke-tests the archive in its stage dir and moves it to release/ only after the smoke passes, so a failed smoke leaves no archive install.sh would take. T3FAC_SMOKE_TIMEOUT sets the deadline (60 s). - Uncommitted changes to tracked files add `.dirty` to the version, so a rebuild of the same commit never replaces the clean build on install. - install.sh and rollback.sh source factory/scripts/lib/t3fac.sh for the install root, the LaunchAgent label and plist, and restart_job. T3FAC_UNLOAD_WAIT sets the unload wait (10 s). - Tests: the whole of build.sh with stubbed vp and cargo (pass, early exit, no 200, no "factory started"), a dangling `previous`, and a job that never unloads. --- .../server/src/factory/installScripts.test.ts | 270 +++++++++++++++++- factory/README.md | 2 +- factory/scripts/build.sh | 27 +- factory/scripts/install.sh | 25 +- factory/scripts/lib/t3fac.sh | 32 +++ factory/scripts/rollback.sh | 24 +- 6 files changed, 313 insertions(+), 67 deletions(-) create mode 100644 factory/scripts/lib/t3fac.sh diff --git a/apps/server/src/factory/installScripts.test.ts b/apps/server/src/factory/installScripts.test.ts index a878a5cb0a2ac..3c7ff80d9087b 100644 --- a/apps/server/src/factory/installScripts.test.ts +++ b/apps/server/src/factory/installScripts.test.ts @@ -69,8 +69,9 @@ const makeArchive = (version: string, options: { binary?: boolean; label?: strin return archive; }; -// A little launchd-like: bootout leaves the job loaded for two more `print` polls, and -// bootstrap fails with EIO while it is still loaded (or when LAUNCHCTL_BOOTSTRAP_FAIL is set). +// A little launchd-like: bootout leaves the job loaded for two more `print` polls (or +// LAUNCHCTL_BOOTOUT_DRAIN), and bootstrap fails with EIO while it is still loaded (or when +// LAUNCHCTL_BOOTSTRAP_FAIL is set). const setupSandbox = () => { tmp = NodeFS.realpathSync(NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3fac-install-"))); home = NodePath.join(tmp, "home"); @@ -101,7 +102,7 @@ const setupSandbox = () => { " exit 5", " fi", ' touch "$LAUNCHCTL_LOADED" ;;', - ' bootout) echo 2 > "$LAUNCHCTL_DRAIN" ;;', + ' bootout) echo "${LAUNCHCTL_BOOTOUT_DRAIN:-2}" > "$LAUNCHCTL_DRAIN" ;;', "esac", "exit 0", "", @@ -110,6 +111,15 @@ const setupSandbox = () => { ); }; +// A sandbox with v1 then v2 installed: the job is loaded, `current` is v2, `previous` is v1. +const setupInstalledSandbox = () => { + setupSandbox(); + archives[v1] = makeArchive(v1); + archives[v2] = makeArchive(v2); + expect(run("install.sh", archives[v1]!).status).toBe(0); + expect(run("install.sh", archives[v2]!).status).toBe(0); +}; + // bootout, then at least one print poll, then bootstrap. const expectRestartSequence = (calls: Array) => { expect(calls[0]).toBe(`print gui/${uid}/${label}`); @@ -199,11 +209,7 @@ describe("install.sh and rollback.sh failure cases", () => { let v2Dir: string; beforeAll(() => { - setupSandbox(); - archives[v1] = makeArchive(v1); - archives[v2] = makeArchive(v2); - expect(run("install.sh", archives[v1]!).status).toBe(0); - expect(run("install.sh", archives[v2]!).status).toBe(0); + setupInstalledSandbox(); v2Dir = NodePath.join(root(), "versions", v2); }); @@ -251,11 +257,7 @@ describe("install.sh and rollback.sh failure cases", () => { describe("install.sh reinstalling the current version", () => { beforeAll(() => { - setupSandbox(); - archives[v1] = makeArchive(v1); - archives[v2] = makeArchive(v2); - expect(run("install.sh", archives[v1]!).status).toBe(0); - expect(run("install.sh", archives[v2]!).status).toBe(0); + setupInstalledSandbox(); }); afterAll(() => { @@ -274,6 +276,50 @@ describe("install.sh reinstalling the current version", () => { }); }); +describe("rollback.sh when the previous version is gone from disk", () => { + beforeAll(() => { + setupInstalledSandbox(); + }); + + afterAll(() => { + NodeFS.rmSync(tmp, { recursive: true, force: true }); + }); + + it("exits 1 and leaves current and the job alone", () => { + NodeFS.rmSync(NodePath.join(root(), "versions", v1), { recursive: true }); + const result = run("rollback.sh"); + expect(result.status).toBe(1); + expect(result.stderr).toContain("no earlier version"); + expect(NodeFS.readlinkSync(NodePath.join(root(), "current"))).toBe(`versions/${v2}`); + expect(result.calls).toEqual([]); + }); +}); + +describe("install.sh and rollback.sh when the job never unloads", () => { + const stuck = { LAUNCHCTL_BOOTOUT_DRAIN: "1000", T3FAC_UNLOAD_WAIT: "1" }; + + beforeAll(() => { + setupInstalledSandbox(); + }); + + afterAll(() => { + NodeFS.rmSync(tmp, { recursive: true, force: true }); + }); + + it.each([ + ["install.sh", () => [archives[v1]!]], + ["rollback.sh", () => []], + ])("%s fails without bootstrapping", (script, args) => { + const result = runWith(stuck, script, ...args()); + NodeFS.rmSync(launchctlDrain, { force: true }); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain(`job ${label} is still loaded 1s after bootout`); + expect(result.calls[1]).toBe(`bootout gui/${uid}/${label}`); + expect(result.calls.length).toBeGreaterThan(3); + expect(result.calls.some((call) => call.startsWith("bootstrap"))).toBe(false); + }); +}); + describe("build.sh --print-version", () => { let repo: string; @@ -337,4 +383,202 @@ describe("build.sh --print-version", () => { expect(result.status).toBe(0); expect(result.stdout).toBe(`9.9.9+fac.${git("rev-parse", "--short", "HEAD")}`); }); + + it("marks a build with uncommitted changes to tracked files as dirty", () => { + write("apps/server/package.json", '{ "name": "t3", "version": "9.9.9", "private": true }\n'); + const result = printVersion(); + git("checkout", "--", "apps/server/package.json"); + expect(result.status).toBe(0); + expect(result.stdout).toBe(`9.9.9+fac.${git("rev-parse", "--short", "HEAD")}.dirty`); + }); +}); + +// The whole of build.sh in a throwaway repo, with `vp` and `cargo` stubbed so the archive +// holds a fake `t3`. FAKE_T3_MODE picks how that server behaves in the smoke test. +describe("build.sh smoke test", () => { + const version = "1.2.3-nightly.20260101.1"; + let sandbox: string; + let repo: string; + let tmpdir: string; + let pidFile: string; + + const write = (file: string, content: string, mode = 0o644) => { + NodeFS.mkdirSync(NodePath.dirname(file), { recursive: true }); + NodeFS.writeFileSync(file, content, { mode }); + }; + + const git = (...args: Array) => { + const result = NodeChildProcess.spawnSync("git", args, { cwd: repo, encoding: "utf8" }); + if (result.status !== 0) throw new Error(`git ${args.join(" ")}: ${result.stderr}`); + return result.stdout.trim(); + }; + + const build = (mode: string, extraEnv: Record = {}) => { + NodeFS.rmSync(tmpdir, { recursive: true, force: true }); + NodeFS.mkdirSync(tmpdir); + NodeFS.rmSync(pidFile, { force: true }); + const result = NodeChildProcess.spawnSync("bash", [NodePath.join(scripts, "build.sh")], { + cwd: repo, + env: { + ...process.env, + PATH: `${NodePath.join(sandbox, "bin")}:${process.env.PATH}`, + TMPDIR: tmpdir, + T3FAC_REPO_ROOT: repo, + FAKE_WORK: NodePath.join(sandbox, "work"), + FAKE_T3: NodePath.join(sandbox, "t3"), + FAKE_T3_MODE: mode, + FAKE_T3_PID: pidFile, + ...extraEnv, + }, + encoding: "utf8", + }); + const release = NodePath.join(repo, "release"); + return { + status: result.status, + stdout: result.stdout.trim(), + stderr: result.stderr, + released: NodeFS.existsSync(release) ? NodeFS.readdirSync(release) : [], + tmpLeft: NodeFS.readdirSync(tmpdir), + }; + }; + + const serverAlive = () => { + const pid = Number(NodeFS.readFileSync(pidFile, "utf8")); + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } + }; + + beforeAll(() => { + sandbox = NodeFS.realpathSync( + NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3fac-build-")), + ); + repo = NodePath.join(sandbox, "repo"); + tmpdir = NodePath.join(sandbox, "tmp"); + pidFile = NodePath.join(sandbox, "t3.pid"); + NodeFS.mkdirSync(NodePath.join(sandbox, "work")); + + write( + NodePath.join(repo, "factory/UPSTREAM"), + `v${version} 0123456789abcdef0123456789abcdef01234567\n`, + ); + write( + NodePath.join(repo, "apps/server/package.json"), + '{ "name": "t3", "version": "9.9.9" }\n', + ); + write( + NodePath.join(repo, "apps/server/vite.config.ts"), + 'const SEA_NODE_VERSION = "24.0.0";\n', + ); + write(NodePath.join(repo, "native/resource-monitor/Cargo.toml"), ""); + git("init", "-q"); + git("config", "user.name", "Test"); + git("config", "user.email", "test@example.com"); + git("config", "commit.gpgsign", "false"); + git("add", "-A"); + git("commit", "-q", "-m", "fake repo"); + + // build-cli-archive.ts writes t3--darwin-arm64.tar.gz holding the fake t3. + write( + NodePath.join(sandbox, "bin/vp"), + [ + "#!/bin/sh", + 'if [ "$1 $2" = "node scripts/build-cli-archive.ts" ]; then', + " shift 2", + " while [ $# -gt 0 ]; do", + ' case "$1" in', + " --version) v=$2; shift 2 ;;", + " --output-dir) out=$2; shift 2 ;;", + " *) shift ;;", + " esac", + " done", + ' d="$FAKE_WORK/t3-$v-darwin-arm64"', + ' rm -rf "$d"; mkdir -p "$d/client"; cp "$FAKE_T3" "$d/t3"', + ' tar -czf "$out/t3-$v-darwin-arm64.tar.gz" -C "$FAKE_WORK" "t3-$v-darwin-arm64"', + "fi", + "exit 0", + "", + ].join("\n"), + 0o755, + ); + write( + NodePath.join(sandbox, "bin/cargo"), + [ + "#!/bin/sh", + "for arg; do manifest=$arg; done", + 'out="$(dirname "$manifest")/target/release"', + 'mkdir -p "$out" && : > "$out/t3-resource-monitor"', + "", + ].join("\n"), + 0o755, + ); + write( + NodePath.join(sandbox, "t3"), + [ + "#!/usr/bin/env node", + 'require("node:fs").writeFileSync(process.env.FAKE_T3_PID, String(process.pid));', + "const mode = process.env.FAKE_T3_MODE;", + 'if (mode === "exit") process.exit(1);', + 'if (mode === "deaf") setInterval(() => {}, 1000);', + "else {", + ' const port = Number(process.argv[process.argv.indexOf("--port") + 1]);', + ' require("node:http")', + " .createServer((req, res) => {", + ' res.statusCode = req.url === "/.well-known/t3/environment" ? 200 : 404;', + " res.end();", + " })", + ' .listen(port, "127.0.0.1", () => {', + ' if (mode === "ok") console.log("factory started");', + " });", + "}", + "", + ].join("\n"), + 0o755, + ); + }); + + afterAll(() => { + NodeFS.rmSync(sandbox, { recursive: true, force: true }); + }); + + it("moves a smoked archive into release/ and cleans up after itself", () => { + const result = build("ok"); + const archive = `t3fac-${version}+fac.${git("rev-parse", "--short", "HEAD")}-darwin-arm64.tar.gz`; + expect(result.status).toBe(0); + expect(result.released).toEqual([archive]); + expect(result.stdout.split("\n").at(-1)).toBe(NodePath.join(repo, "release", archive)); + expect(result.tmpLeft).toEqual([]); + expect(serverAlive()).toBe(false); + }, 20_000); + + it.each([ + ["exits at startup", "exit", {}, "server exited early"], + [ + "never answers", + "deaf", + { T3FAC_SMOKE_TIMEOUT: "1" }, + "/.well-known/t3/environment did not answer 200 within 1s", + ], + [ + "never logs that the factory started", + "quiet", + {}, + 'server log does not hold "factory started"', + ], + ])( + "fails and releases nothing when the server %s", + (_name, mode, env: Record, message) => { + NodeFS.rmSync(NodePath.join(repo, "release"), { recursive: true, force: true }); + const result = build(mode, env); + expect(result.status).toBe(1); + expect(result.stderr).toContain(`smoke: FAILED: ${message}`); + expect(result.released).toEqual([]); + expect(result.tmpLeft).toEqual([]); + expect(serverAlive()).toBe(false); + }, + 20_000, + ); }); diff --git a/factory/README.md b/factory/README.md index 1c96a0221c05c..7fad80f004389 100644 --- a/factory/README.md +++ b/factory/README.md @@ -23,7 +23,7 @@ factory/scripts/check-seams.sh Build, install and roll back the darwin-arm64 binary (LaunchAgent `io.github.rawphp.t3fac`, port 3876): ```sh -factory/scripts/build.sh # -> release/t3fac--darwin-arm64.tar.gz, then a smoke test +factory/scripts/build.sh # build, smoke test, then -> release/t3fac--darwin-arm64.tar.gz factory/scripts/install.sh # unpack, point `current` at it, restart the job factory/scripts/rollback.sh # point `current` at the previous version, restart the job ``` diff --git a/factory/scripts/build.sh b/factory/scripts/build.sh index 1d2d6336d0d7a..c476410e9bfc3 100755 --- a/factory/scripts/build.sh +++ b/factory/scripts/build.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash -# Builds the fork into release/t3fac--darwin-arm64.tar.gz with upstream's own -# build scripts, then smoke-tests the archive. `build.sh --print-version` only prints the version. +# Builds the fork with upstream's own build scripts, smoke-tests the archive and only then +# moves it to release/t3fac--darwin-arm64.tar.gz. `build.sh --print-version` only +# prints the version. T3FAC_SMOKE_TIMEOUT sets the smoke test's deadline in seconds (60). set -euo pipefail root=${T3FAC_REPO_ROOT:-$(git rev-parse --show-toplevel)} @@ -13,6 +14,11 @@ else upstream=$(node -p 'require("./apps/server/package.json").version') fi version="${upstream}+fac.$(git rev-parse --short HEAD)" +# Uncommitted changes to tracked files mark the build, so it never takes the name (and on +# install, the place) of the clean build of the same commit. +if [[ -n $(git status --porcelain --untracked-files=no) ]]; then + version="$version.dirty" +fi if [[ ${1:-} == --print-version ]]; then echo "$version" @@ -63,14 +69,13 @@ VP_NODE_VERSION="$sea_node" vp node scripts/build-cli-archive.ts \ --platform mac --arch arm64 --version "$version" \ --resource-monitor-dir "$stage/monitor" --output-dir "$stage/out" -mkdir -p release -archive="release/t3fac-${version}-darwin-arm64.tar.gz" -mv "$stage/out/t3-${version}-darwin-arm64.tar.gz" "$archive" +built="$stage/out/t3-${version}-darwin-arm64.tar.gz" # Smoke test: start the built server on a free port with a throwaway HOME and base dir. +smoke_timeout=${T3FAC_SMOKE_TIMEOUT:-60} smoke=$(mktemp -d) mkdir -p "$smoke/pkg" "$smoke/home" -tar -xzf "$archive" -C "$smoke/pkg" +tar -xzf "$built" -C "$smoke/pkg" pkg=$(find "$smoke/pkg" -mindepth 1 -maxdepth 1 -type d | head -n 1) port=$(node -e 'const s=require("net").createServer();s.listen(0,"127.0.0.1",()=>{console.log(s.address().port);s.close()})') @@ -87,22 +92,26 @@ server_pid=$! ok="" start=$SECONDS -for _ in $(seq 1 120); do +while :; do kill -0 "$server_pid" 2>/dev/null || fail "server exited early" code=$(curl -s --max-time 2 -o /dev/null -w '%{http_code}' "http://127.0.0.1:$port/.well-known/t3/environment" || true) if [[ $code == 200 ]]; then ok=1 break fi - (( SECONDS - start >= 60 )) && break + (( SECONDS - start >= smoke_timeout )) && break sleep 0.5 done -[[ -n $ok ]] || fail "/.well-known/t3/environment did not answer 200 within 60s" +[[ -n $ok ]] || fail "/.well-known/t3/environment did not answer 200 within ${smoke_timeout}s" echo "smoke: /.well-known/t3/environment -> 200 after $((SECONDS - start))s" grep -q 'factory started' "$smoke/server.log" || fail 'server log does not hold "factory started"' echo 'smoke: log holds "factory started"' +mkdir -p release +archive="release/t3fac-${version}-darwin-arm64.tar.gz" +mv "$built" "$archive" + cleanup smoke="" server_pid="" diff --git a/factory/scripts/install.sh b/factory/scripts/install.sh index 0d0fc1eb0224d..367a05788246e 100755 --- a/factory/scripts/install.sh +++ b/factory/scripts/install.sh @@ -3,7 +3,8 @@ # writes the LaunchAgent plist and restarts the job. Usage: install.sh set -euo pipefail -label=io.github.rawphp.t3fac +source "$(dirname "$0")/lib/t3fac.sh" + archive=${1:-} if [[ -z $archive || ! -f $archive ]]; then echo "usage: install.sh -darwin-arm64.tar.gz>" >&2 @@ -22,10 +23,7 @@ if [[ -z $version || $version == */* || $version == . || $version == .. ]]; then exit 1 fi -root="$HOME/.local/share/t3fac" dest="$root/versions/$version" -template="$(cd "$(dirname "$0")" && pwd)/../launchd/$label.plist" -plist="$HOME/Library/LaunchAgents/$label.plist" mkdir -p "$root/versions" # Unpack beside the destination and swap in only a good archive, so a bad one never @@ -50,24 +48,7 @@ if [[ -L $root/current ]]; then fi ln -sfn "versions/$version" "$root/current" -mkdir -p "$HOME/Library/LaunchAgents" "$HOME/Library/Logs/t3fac" "$HOME/.config/t3fac" -sed "s|@HOME@|$HOME|g" "$template" > "$plist" - -uid=$(id -u) -wait_unloaded() { - local i - for ((i = 0; i < 50; i++)); do - launchctl print "gui/$uid/$label" > /dev/null 2>&1 || return 0 - sleep 0.2 - done - echo "job $label is still loaded 10s after bootout" >&2 - return 1 -} -if launchctl print "gui/$uid/$label" > /dev/null 2>&1; then - launchctl bootout "gui/$uid/$label" - wait_unloaded -fi -launchctl bootstrap "gui/$uid" "$plist" +restart_job echo "installed $version" echo "current -> versions/$version" diff --git a/factory/scripts/lib/t3fac.sh b/factory/scripts/lib/t3fac.sh new file mode 100644 index 0000000000000..ddd465e67418c --- /dev/null +++ b/factory/scripts/lib/t3fac.sh @@ -0,0 +1,32 @@ +# Sourced by install.sh and rollback.sh: where versions live, the LaunchAgent job, and +# how to restart it. + +root="$HOME/.local/share/t3fac" +label=io.github.rawphp.t3fac +template="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../launchd" && pwd)/$label.plist" +plist="$HOME/Library/LaunchAgents/$label.plist" + +# Waits for launchd to report the job unloaded, up to T3FAC_UNLOAD_WAIT seconds (10). +wait_unloaded() { + local uid=$1 wait=${T3FAC_UNLOAD_WAIT:-10} i + for ((i = 0; i < wait * 5; i++)); do + launchctl print "gui/$uid/$label" > /dev/null 2>&1 || return 0 + sleep 0.2 + done + echo "job $label is still loaded ${wait}s after bootout" >&2 + return 1 +} + +# Writes the plist from the template, boots the job out when it is loaded, waits for it to +# unload and bootstraps it. +restart_job() { + local uid + uid=$(id -u) + mkdir -p "$HOME/Library/LaunchAgents" "$HOME/Library/Logs/t3fac" "$HOME/.config/t3fac" + sed "s|@HOME@|$HOME|g" "$template" > "$plist" + if launchctl print "gui/$uid/$label" > /dev/null 2>&1; then + launchctl bootout "gui/$uid/$label" + wait_unloaded "$uid" + fi + launchctl bootstrap "gui/$uid" "$plist" +} diff --git a/factory/scripts/rollback.sh b/factory/scripts/rollback.sh index b6c8d605153d4..247e5df45848b 100755 --- a/factory/scripts/rollback.sh +++ b/factory/scripts/rollback.sh @@ -3,10 +3,7 @@ # Exits 1 when there is no earlier version. set -euo pipefail -label=io.github.rawphp.t3fac -root="$HOME/.local/share/t3fac" -template="$(cd "$(dirname "$0")" && pwd)/../launchd/$label.plist" -plist="$HOME/Library/LaunchAgents/$label.plist" +source "$(dirname "$0")/lib/t3fac.sh" if [[ ! -L $root/previous || ! -e $root/previous ]]; then echo "no earlier version to roll back to" >&2 @@ -16,24 +13,7 @@ fi target=$(readlink "$root/previous") ln -sfn "$target" "$root/current" -mkdir -p "$HOME/Library/LaunchAgents" "$HOME/Library/Logs/t3fac" "$HOME/.config/t3fac" -sed "s|@HOME@|$HOME|g" "$template" > "$plist" - -uid=$(id -u) -wait_unloaded() { - local i - for ((i = 0; i < 50; i++)); do - launchctl print "gui/$uid/$label" > /dev/null 2>&1 || return 0 - sleep 0.2 - done - echo "job $label is still loaded 10s after bootout" >&2 - return 1 -} -if launchctl print "gui/$uid/$label" > /dev/null 2>&1; then - launchctl bootout "gui/$uid/$label" - wait_unloaded -fi -launchctl bootstrap "gui/$uid" "$plist" +restart_job # Only now is the rollback done; a failed restart above leaves `previous` for a rerun. rm -f "$root/previous" From a25530eaef481653859734d7d27549922c2e2eb3 Mon Sep 17 00:00:00 2001 From: Tom Kaczocha Date: Thu, 15 Oct 2026 21:41:44 +1000 Subject: [PATCH 8/9] fix(factory): a dirty build's version carries a hash of its uncommitted changes Two builds of one commit with different uncommitted edits used to share the `.dirty` version, so installing the second deleted the first. The suffix is now `.dirty.<8 hex>` from `git diff HEAD --binary`, so only identical contents share a name. --- .../server/src/factory/installScripts.test.ts | 24 ++++++++++++++----- factory/scripts/build.sh | 6 ++--- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/apps/server/src/factory/installScripts.test.ts b/apps/server/src/factory/installScripts.test.ts index 3c7ff80d9087b..68a61581f2605 100644 --- a/apps/server/src/factory/installScripts.test.ts +++ b/apps/server/src/factory/installScripts.test.ts @@ -384,12 +384,24 @@ describe("build.sh --print-version", () => { expect(result.stdout).toBe(`9.9.9+fac.${git("rev-parse", "--short", "HEAD")}`); }); - it("marks a build with uncommitted changes to tracked files as dirty", () => { - write("apps/server/package.json", '{ "name": "t3", "version": "9.9.9", "private": true }\n'); - const result = printVersion(); - git("checkout", "--", "apps/server/package.json"); - expect(result.status).toBe(0); - expect(result.stdout).toBe(`9.9.9+fac.${git("rev-parse", "--short", "HEAD")}.dirty`); + it("names a build with uncommitted changes to tracked files after those changes", () => { + const versionWith = (content: string) => { + write("apps/server/package.json", content); + const result = printVersion(); + git("checkout", "--", "apps/server/package.json"); + expect(result.status).toBe(0); + return result.stdout; + }; + const a = versionWith('{ "name": "t3", "version": "9.9.9", "private": true }\n'); + const b = versionWith('{ "name": "t3", "version": "9.9.9", "private": false }\n'); + + const clean = `9.9.9+fac.${git("rev-parse", "--short", "HEAD")}`; + for (const version of [a, b]) { + expect(version.slice(0, clean.length)).toBe(clean); + expect(version.slice(clean.length)).toMatch(/^\.dirty\.[0-9a-f]{8}$/); + } + expect(b).not.toBe(a); + expect(versionWith('{ "name": "t3", "version": "9.9.9", "private": true }\n')).toBe(a); }); }); diff --git a/factory/scripts/build.sh b/factory/scripts/build.sh index c476410e9bfc3..b05271cc406cb 100755 --- a/factory/scripts/build.sh +++ b/factory/scripts/build.sh @@ -14,10 +14,10 @@ else upstream=$(node -p 'require("./apps/server/package.json").version') fi version="${upstream}+fac.$(git rev-parse --short HEAD)" -# Uncommitted changes to tracked files mark the build, so it never takes the name (and on -# install, the place) of the clean build of the same commit. +# Uncommitted changes to tracked files add a hash of those changes, so a build never takes +# the name (and on install, the place) of a build with other contents. if [[ -n $(git status --porcelain --untracked-files=no) ]]; then - version="$version.dirty" + version="$version.dirty.$(git diff HEAD --binary | git hash-object --stdin | cut -c1-8)" fi if [[ ${1:-} == --print-version ]]; then From 0dbf91ce5158b8a1b2d746bbd5424a14f5c3afb0 Mon Sep 17 00:00:00 2001 From: Tom Kaczocha Date: Thu, 15 Oct 2026 22:07:04 +1000 Subject: [PATCH 9/9] fix(factory): build.sh refuses a tree with uncommitted changes A version named after the commit could not tell builds of one commit apart when the tree held edits or untracked files, and installing a second such build deleted the first. build.sh now refuses to build when `git status --porcelain` lists anything, naming the first ten paths, so the version always names exactly what was built. The `.dirty` suffix is gone. .worktrees/ joins .gitignore (a new seam in factory/SEAMS) so the main checkout, which holds local worktrees, counts as clean. --- .gitignore | 1 + .../server/src/factory/installScripts.test.ts | 57 ++++++++++++------- factory/README.md | 2 +- factory/SEAMS | 1 + factory/scripts/build.sh | 20 ++++--- 5 files changed, 53 insertions(+), 28 deletions(-) diff --git a/.gitignore b/.gitignore index 38bac103f1f41..f911d7361b4fc 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ build/ release/ release-mock/ .t3 +.worktrees/ .idea/ apps/web/.playwright apps/web/playwright-report diff --git a/apps/server/src/factory/installScripts.test.ts b/apps/server/src/factory/installScripts.test.ts index 68a61581f2605..a2b818dc057f3 100644 --- a/apps/server/src/factory/installScripts.test.ts +++ b/apps/server/src/factory/installScripts.test.ts @@ -383,26 +383,6 @@ describe("build.sh --print-version", () => { expect(result.status).toBe(0); expect(result.stdout).toBe(`9.9.9+fac.${git("rev-parse", "--short", "HEAD")}`); }); - - it("names a build with uncommitted changes to tracked files after those changes", () => { - const versionWith = (content: string) => { - write("apps/server/package.json", content); - const result = printVersion(); - git("checkout", "--", "apps/server/package.json"); - expect(result.status).toBe(0); - return result.stdout; - }; - const a = versionWith('{ "name": "t3", "version": "9.9.9", "private": true }\n'); - const b = versionWith('{ "name": "t3", "version": "9.9.9", "private": false }\n'); - - const clean = `9.9.9+fac.${git("rev-parse", "--short", "HEAD")}`; - for (const version of [a, b]) { - expect(version.slice(0, clean.length)).toBe(clean); - expect(version.slice(clean.length)).toMatch(/^\.dirty\.[0-9a-f]{8}$/); - } - expect(b).not.toBe(a); - expect(versionWith('{ "name": "t3", "version": "9.9.9", "private": true }\n')).toBe(a); - }); }); // The whole of build.sh in a throwaway repo, with `vp` and `cargo` stubbed so the archive @@ -486,6 +466,7 @@ describe("build.sh smoke test", () => { 'const SEA_NODE_VERSION = "24.0.0";\n', ); write(NodePath.join(repo, "native/resource-monitor/Cargo.toml"), ""); + write(NodePath.join(repo, ".gitignore"), "release/\nnative/**/target/\n"); git("init", "-q"); git("config", "user.name", "Test"); git("config", "user.email", "test@example.com"); @@ -566,6 +547,42 @@ describe("build.sh smoke test", () => { expect(serverAlive()).toBe(false); }, 20_000); + it.each([ + [ + "a changed tracked file", + "apps/server/package.json", + () => write(NodePath.join(repo, "apps/server/package.json"), '{ "version": "0.0.0" }\n'), + ], + [ + "an untracked file", + "apps/server/new.ts", + () => write(NodePath.join(repo, "apps/server/new.ts"), "export {};\n"), + ], + ])("refuses to build with %s and names it", (_name, path, dirty) => { + NodeFS.rmSync(NodePath.join(repo, "release"), { recursive: true, force: true }); + dirty(); + const result = build("ok"); + git("checkout", "--", "."); + git("clean", "-fdq"); + expect(result.status).toBe(1); + expect(result.stderr).toContain("uncommitted changes"); + expect(result.stderr).toContain(path); + expect(result.stdout).not.toContain("build:"); + expect(result.released).toEqual([]); + }); + + it("lists the first ten uncommitted paths and counts the rest", () => { + for (let i = 0; i < 12; i++) { + write(NodePath.join(repo, `scratch-${String(i).padStart(2, "0")}.ts`), ""); + } + const result = build("ok"); + git("clean", "-fdq"); + expect(result.status).toBe(1); + expect(result.stderr).toContain("scratch-09.ts"); + expect(result.stderr).not.toContain("scratch-10.ts"); + expect(result.stderr).toContain("... and 2 more"); + }); + it.each([ ["exits at startup", "exit", {}, "server exited early"], [ diff --git a/factory/README.md b/factory/README.md index 7fad80f004389..eb7cf6ac1506b 100644 --- a/factory/README.md +++ b/factory/README.md @@ -23,7 +23,7 @@ factory/scripts/check-seams.sh Build, install and roll back the darwin-arm64 binary (LaunchAgent `io.github.rawphp.t3fac`, port 3876): ```sh -factory/scripts/build.sh # build, smoke test, then -> release/t3fac--darwin-arm64.tar.gz +factory/scripts/build.sh # clean tree only: build, smoke test, then -> release/t3fac--darwin-arm64.tar.gz factory/scripts/install.sh # unpack, point `current` at it, restart the job factory/scripts/rollback.sh # point `current` at the previous version, restart the job ``` diff --git a/factory/SEAMS b/factory/SEAMS index c8f6dc05b4c4f..d4d9422fb5829 100644 --- a/factory/SEAMS +++ b/factory/SEAMS @@ -1 +1,2 @@ +.gitignore apps/server/src/server.ts diff --git a/factory/scripts/build.sh b/factory/scripts/build.sh index b05271cc406cb..7bca6bf9d1024 100755 --- a/factory/scripts/build.sh +++ b/factory/scripts/build.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash # Builds the fork with upstream's own build scripts, smoke-tests the archive and only then -# moves it to release/t3fac--darwin-arm64.tar.gz. `build.sh --print-version` only -# prints the version. T3FAC_SMOKE_TIMEOUT sets the smoke test's deadline in seconds (60). +# moves it to release/t3fac--darwin-arm64.tar.gz. Refuses a tree with uncommitted +# changes, so the version always names exactly what was built. `build.sh --print-version` +# only prints the version. T3FAC_SMOKE_TIMEOUT sets the smoke test's deadline in seconds (60). set -euo pipefail root=${T3FAC_REPO_ROOT:-$(git rev-parse --show-toplevel)} @@ -14,17 +15,22 @@ else upstream=$(node -p 'require("./apps/server/package.json").version') fi version="${upstream}+fac.$(git rev-parse --short HEAD)" -# Uncommitted changes to tracked files add a hash of those changes, so a build never takes -# the name (and on install, the place) of a build with other contents. -if [[ -n $(git status --porcelain --untracked-files=no) ]]; then - version="$version.dirty.$(git diff HEAD --binary | git hash-object --stdin | cut -c1-8)" -fi if [[ ${1:-} == --print-version ]]; then echo "$version" exit 0 fi +# Changed tracked files and untracked files that are not ignored both end up in the build. +dirty=$(git status --porcelain) +if [[ -n $dirty ]]; then + echo "build: refusing to build $version with uncommitted changes; commit or remove them:" >&2 + head -n 10 <<< "$dirty" >&2 + count=$(wc -l <<< "$dirty") + (( count <= 10 )) || echo "... and $((count - 10)) more" >&2 + exit 1 +fi + stage="" smoke="" server_pid=""