fix(deploy): make-live.sh takes a lock — one deploy at a time per box (v0.448.4) - #833
Merged
Merged
Conversation
vikasprogrammer
force-pushed
the
feat/deploy-lock
branch
from
September 18, 2026 09:59
fadc1a9 to
864f243
Compare
… (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
force-pushed
the
feat/deploy-lock
branch
from
September 18, 2026 10:01
864f243 to
755cc88
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Every live checkout is shared by whoever runs the deploy, so two concurrent
make-live.shruns are twogit reset --hards racing each other's builds. The second moves a checkout out from under the first one'snpm run build; the first then restarts a service with a binary built from a commit it never resolved — and reports success, because it verifies/healthagainst 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 noflock) 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 whatevermainhas become by then is its own surprise.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:
AOS_LIVE_LOCK_MAX_AGE, default 2h--force-lockEXIT— a crashed deploy never blocks the next one--dry-runis 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.--helpis 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 | headexits 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.make-live.shinvocation was refused before touching any checkout, and the holder's lock survived. A real--dry-runstill reports all four tenants normally.npm run test:governancegreen.🤖 Generated with Claude Code