Skip to content

fix(deploy): make-live.sh takes a lock — one deploy at a time per box (v0.448.4) - #833

Merged
vikasprogrammer merged 1 commit into
mainfrom
feat/deploy-lock
Sep 18, 2026
Merged

vikasprogrammer merged 1 commit into
mainfrom
feat/deploy-lock

Conversation

@vikasprogrammer

Copy link
Copy Markdown
Owner

Why

Every live checkout is shared by whoever runs the deploy, so two concurrent make-live.sh runs are two git reset --hards racing each other's builds. The second moves a checkout out from under the first one's npm run build; the first then restarts a service with a binary built from a commit it never resolved — and reports success, because it verifies /health against the version it was told to expect.

On 2026-09-17 two sessions ran this concurrently against all four tenants. It was harmless only because both happened to be deploying the same sha. Luck, not design.

The lock

An atomic mkdir (macOS ships no flock) holding a meta file — the directory is the mutex, the meta only explains who holds it. A second run fails fast naming the holder's pid, user and start time, rather than queueing: a deploy that waits ten minutes and then runs against whatever main has become by then is its own surprise.

another deploy is already running on this box (pid 81341, vikas@mac-mini, started 2026-09-18 10:40:00).
  Deploys share every live checkout, so running two at once can restart a service on a half-synced build.
  Wait for it to finish, or — if you are certain it is dead — re-run with --force-lock.

A lock that can't be released would be worse than none

Every way it could wedge has an exit, and each is pinned by a test:

Situation Behaviour
Holder PID is dead (Ctrl-C, terminal died) cleared automatically
Live PID but ancient (wedged run, or PID reuse) cleared past AOS_LIVE_LOCK_MAX_AGE, default 2h
Meta unreadable (interrupted writer) cleared
You know it's dead and won't wait --force-lock
Run fails in preflight released on EXIT — a crashed deploy never blocks the next one
You lost the race you never delete the winner's lock

--dry-run is deliberately not gated — it changes nothing and it's what you reach for to see what the other run is doing — but it warns that it's reading checkouts another run is moving. --help is untouched.

Scope is one box. Two different machines deploying to the same remote tenant still race; that needs a lock on the remote side and isn't what bit us. Noted in the script header.

A trap worth recording

Under set -euo pipefail, sed missing-file | head exits non-zero and killed the script outright — silently, with the lock still in place. That's exactly the interrupted-writer case the staleness branch exists to clear, so it would have turned a self-healing path into a permanent wedge. The meta is now read into a variable first. The test caught it.

Testing

  • scripts/make-live-lock-test.cjs (new, in the gate) — 24 checks running the real script with a scratch lock path and a deliberately bogus target, so every case fails fast in preflight after the lock has been taken and released. Nothing deploys, no checkout is touched.
  • Verified end to end against the real lock path: a real make-live.sh invocation was refused before touching any checkout, and the holder's lock survived. A real --dry-run still reports all four tenants normally.
  • Full npm run test:governance green.

🤖 Generated with Claude Code

@vikasprogrammer vikasprogrammer changed the title fix(deploy): make-live.sh takes a lock — one deploy at a time per box (v0.447.2) fix(deploy): make-live.sh takes a lock — one deploy at a time per box (v0.448.3) Sep 18, 2026
… (v0.448.4)

Every live checkout is shared by whoever runs the deploy, so two concurrent runs are two
`git reset --hard`s racing each other's builds: the second moves a checkout out from under the first
one's `npm run build`, and the first then restarts a service with a binary built from a commit it never
resolved — while reporting success, because it verifies /health against the version it was TOLD to
expect. On 2026-09-17 two sessions ran this concurrently against all four tenants and it was harmless
only because both happened to be deploying the same sha.

The lock is an atomic `mkdir` (macOS ships no `flock`) holding a meta file. A second run fails fast,
naming the holder's pid/user/start time, rather than queueing — a deploy that waits ten minutes and then
runs against whatever `main` has become is its own surprise.

A lock that cannot be released is worse than no lock, so every wedge has an exit: a dead holder is
cleared automatically, one past `AOS_LIVE_LOCK_MAX_AGE` (default 2h) is cleared even with a live pid
(covers a wedged run and pid reuse), an unreadable meta is cleared, and `--force-lock` breaks one on
purpose. The loser never deletes the winner's lock, and the holder releases on EXIT — including on
failure, so a crashed deploy can't block the next one.

`--dry-run` is deliberately not gated (it changes nothing, and it is what you reach for to see what the
other run is doing) but warns it is reading checkouts another run is moving; `--help` is untouched.

One trap found while testing: under `set -euo pipefail` a `sed missing-file | head` pipeline exits
non-zero and killed the script outright — silently, with the lock still in place. That is exactly the
interrupted-writer case the staleness branch exists to clear, so the meta is read into a variable first.

Pinned by scripts/make-live-lock-test.cjs (24 checks, runs the real script against a scratch lock and a
bogus target so nothing deploys). Verified end to end against the real lock path: a real invocation was
refused before touching any checkout, and the holder's lock survived.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Second fix in the same change: BSD and GNU `stat` disagree on what `-f` MEANS (format string vs
--file-system), so chaining them with || inside one $() captured GNU's `File: "…"` dump alongside the
answer. The multi-line result reached $(( … )), which evaluated `File` as a variable and, under set -u,
killed the script mid-lock. Green on macOS, dead on every Linux box — CI caught it. Each stat is now
tried separately and accepted only if it is all digits. Verified 24/24 on a real Linux box.
@vikasprogrammer vikasprogrammer changed the title fix(deploy): make-live.sh takes a lock — one deploy at a time per box (v0.448.3) fix(deploy): make-live.sh takes a lock — one deploy at a time per box (v0.448.4) Sep 18, 2026
@vikasprogrammer
vikasprogrammer merged commit 22251b2 into main Sep 18, 2026
1 check passed
@vikasprogrammer
vikasprogrammer deleted the feat/deploy-lock branch September 18, 2026 10:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant