Skip to content

Commit ece9450

Browse files
ralyodioclaude
andcommitted
Let a container keep itself current by running from a checkout on its volume
The self-updater shipped in 0.4.0 can move a git checkout but not an image, so a board on Railway — including bbs.hqtui.com — only ever saw the "new version available" notice. This gives the image a way to be updated from the inside. With TSBB_CHECKOUT_DIR pointing into a volume, the container clones the repository there on first boot, at the image's own commit, and installs its dependencies from the image's pnpm store, so the first start takes about three seconds and needs no download. It then runs the server from that checkout in a loop: the updater fetches each release into the volume and exits, and the loop starts the new code. A container restart comes back on the release it had rather than the image it was built from, because the checkout is on the volume. Without TSBB_CHECKOUT_DIR the entrypoint runs the image's own code exactly as before, so tsbb.dev is unchanged. Copying the image's node_modules into the checkout instead of installing does not work — pnpm sees a tree made for another path and stops to ask whether it may rebuild it, and there is no TTY to say yes. Linking from the store is what the install already does, and it is as fast. Tried for real in Docker: a container seeded at the commit before the 0.4.0 bump found v0.4.0 a minute after boot, fetched, checked out, installed in one second, restarted, and answered the healthcheck on the new commit. A restart of that container came back on 0.4.0. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PY5Y9gpnQ9kevUFwXJakyn
1 parent 30fad12 commit ece9450

4 files changed

Lines changed: 90 additions & 8 deletions

File tree

‎Dockerfile‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@ ENV NODE_ENV=production \
99

1010
RUN corepack enable
1111

12+
# git is for a container that runs from a checkout on a volume and updates
13+
# itself (TSBB_CHECKOUT_DIR — see bin/entrypoint.sh). An image without it can
14+
# only ever be replaced by another image.
15+
RUN apt-get update \
16+
&& apt-get install -y --no-install-recommends git ca-certificates \
17+
&& rm -rf /var/lib/apt/lists/*
18+
1219
# The whole tree is copied before installing rather than listing each workspace
1320
# manifest by hand. Listing them is faster to rebuild and silently wrong: a new
1421
# package is invisible to pnpm until it happens to gain an external dependency.
@@ -23,5 +30,7 @@ RUN pnpm install --frozen-lockfile --ignore-scripts
2330
EXPOSE 3000
2431

2532
# Migrations run at boot inside the server, so a deploy can never leave new code
26-
# running against an old schema.
27-
CMD ["node", "apps/server/src/index.ts"]
33+
# running against an old schema. The entrypoint runs the image's own code
34+
# unless TSBB_CHECKOUT_DIR says to run — and keep updating — a checkout on a
35+
# volume instead.
36+
CMD ["sh", "bin/entrypoint.sh"]

‎README.md‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,7 @@ one with a fresh session secret and whatever you already had set.
267267
| `TSBB_UPDATES` | on | Set to `off` and the board never checks for a release |
268268
| `TSBB_RESTART` | `respawn` | `exit` under a supervisor that restarts the process itself |
269269
| `TSBB_UPDATE_REPO` | `profullstack/tsbb` | A fork that publishes its own releases |
270+
| `TSBB_CHECKOUT_DIR` | — | A directory on a volume; the container runs, and updates, a checkout there |
270271

271272
The worker runs inside the server by default, so email works from one command.
272273

@@ -284,10 +285,19 @@ out, and a **Check now** button; with automatic updates off it also offers
284285
**Update now**, and `tsbb update` does the same from a shell.
285286

286287
Two things it will not do. It never touches a checkout with local changes —
287-
that is somebody's work, and it stops and says so. And it never updates a
288-
container: the Docker image has no `.git` to move, so a board on Railway, Fly
289-
or a plain `docker run` sees the notice and is updated by redeploying the image,
290-
which is how those platforms expect it anyway.
288+
that is somebody's work, and it stops and says so. And it never updates an
289+
image: the Docker image has no `.git` to move, so a board running the image's
290+
own code sees the notice and is updated by redeploying, which is how those
291+
platforms expect it anyway.
292+
293+
A container *can* keep itself current if it has a volume. Set
294+
`TSBB_CHECKOUT_DIR` to a directory on it (`/app/data/app`, say) and the
295+
container clones the repository there on first boot — at the image's own
296+
commit, with dependencies linked from the image's package store, so the first
297+
start is as fast as any other — and runs from that checkout instead. Each release is then
298+
fetched into the volume and the server restarted from it, and a container
299+
restart comes back on the release it had, not the image it was built from.
300+
`bbs.hqtui.com` runs this way.
291301

292302
On restart the board spawns a fresh copy of itself with the same command line
293303
and exits, so `pnpm start` in a terminal or under nohup carries on with the new

‎bin/entrypoint.sh‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
#!/bin/sh
2+
#
3+
# How the container starts.
4+
#
5+
# With no TSBB_CHECKOUT_DIR it runs the code baked into the image, which is
6+
# what an image is for: it is updated by building and deploying a new one.
7+
#
8+
# With TSBB_CHECKOUT_DIR pointing into a volume, the container instead keeps a
9+
# git checkout THERE and runs from it. On first boot it clones the repository
10+
# at the image's own commit and installs from the image's own package store,
11+
# so the first start needs no download and passes the healthcheck like any
12+
# other. From then on the board updates itself: the server fetches each new
13+
# release into the checkout, exits, and this script starts it again from the
14+
# new code — so a board on a platform that only redeploys images still follows
15+
# releases within minutes, and survives a container restart because the
16+
# checkout is on the volume, not in the container.
17+
set -eu
18+
19+
IMAGE_ROOT="${TSBB_IMAGE_ROOT:-/app}"
20+
DIR="${TSBB_CHECKOUT_DIR:-}"
21+
REPO="${TSBB_UPDATE_REPO:-profullstack/tsbb}"
22+
23+
if [ -z "$DIR" ]; then
24+
cd "$IMAGE_ROOT"
25+
exec node apps/server/src/index.ts
26+
fi
27+
28+
if [ ! -d "$DIR/.git" ]; then
29+
echo "[tsbb] first boot: cloning $REPO into $DIR"
30+
rm -rf "$DIR"
31+
git clone --quiet "https://github.com/$REPO" "$DIR"
32+
# Start from exactly the commit this image was built from, so the checkout
33+
# and the image agree, so the install below finds everything in the store.
34+
if [ -n "${RAILWAY_GIT_COMMIT_SHA:-}" ]; then
35+
git -C "$DIR" -c advice.detachedHead=false checkout --quiet "$RAILWAY_GIT_COMMIT_SHA" \
36+
|| echo "[tsbb] commit ${RAILWAY_GIT_COMMIT_SHA} is not in the clone; staying on the default branch"
37+
fi
38+
# The image's install left every package in pnpm's store, so linking them
39+
# into the new checkout is seconds and needs no network. Copying the image's
40+
# node_modules instead does not work: pnpm sees a tree made for another path
41+
# and stops to ask whether it may rebuild it. CI=true is the "yes" it wants
42+
# if the lockfile has drifted and it must.
43+
echo "[tsbb] installing dependencies from the image's store"
44+
( cd "$DIR" && CI=true pnpm install --frozen-lockfile --ignore-scripts --offline ) \
45+
|| ( cd "$DIR" && CI=true pnpm install --frozen-lockfile --ignore-scripts )
46+
fi
47+
48+
cd "$DIR"
49+
echo "[tsbb] running from $DIR at $(git rev-parse --short HEAD) ($(git describe --tags --always 2>/dev/null))"
50+
51+
# The updater exits after installing a release; this loop is the supervisor
52+
# that brings the new code up. A crash waits a few seconds so a broken build
53+
# cannot spin the container.
54+
export TSBB_RESTART=exit
55+
while :; do
56+
if node apps/server/src/index.ts; then
57+
echo "[tsbb] server exited; starting again from $(git rev-parse --short HEAD)"
58+
else
59+
code=$?
60+
echo "[tsbb] server exited with status $code; starting again in 5s"
61+
sleep 5
62+
fi
63+
done

‎railway.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@
55
"dockerfilePath": "Dockerfile"
66
},
77
"deploy": {
8-
"startCommand": "node apps/server/src/index.ts",
8+
"startCommand": "sh bin/entrypoint.sh",
99
"healthcheckPath": "/healthz",
10-
"healthcheckTimeout": 120,
10+
"healthcheckTimeout": 300,
1111
"restartPolicyType": "ON_FAILURE",
1212
"restartPolicyMaxRetries": 10,
1313
"numReplicas": 1

0 commit comments

Comments
 (0)