Diagnose "I deployed a new Cloud Run / Docker image but a bunch of recently merged fixes regressed in production." Use when: (1) the deploy script does `gcloud builds submit ... "${SCRIPT_DIR}"` or `docker build <dir>` (build context = local filesystem, NOT a git ref), (2) the user has many git worktrees / multiple checkouts of the same repo, (3) the user reports "redeployed but the fix from PR #X isn't live" or "lots of fixes regressed after my deploy", (4) the deployed revision created at t...
Scanned 9/6/2026
Install to Claude Code
npx -y skills add wan-huiyan/agent-traffic-control --skill deploy-from-stale-worktree-silent-rollback --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Deploy From Stale Worktree Silent Rollback?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/wan-huiyan-deploy-from-stale-worktree-silent-rollback)More formats (shields.io, HTML) on the badges page.
---
name: deploy-from-stale-worktree-silent-rollback
description: |
Diagnose "I deployed a new Cloud Run / Docker image but a bunch of recently
merged fixes regressed in production." Use when: (1) the deploy script does
`gcloud builds submit ... "${SCRIPT_DIR}"` or `docker build <dir>` (build
context = local filesystem, NOT a git ref), (2) the user has many git
worktrees / multiple checkouts of the same repo, (3) the user reports
"redeployed but the fix from PR #X isn't live" or "lots of fixes regressed
after my deploy", (4) the deployed revision created at time T was authored
by the user themselves (not CI). Root cause: deploy was run from a worktree
whose HEAD predates the merged PRs; the build packaged stale local files,
silently rolling back N commits worth of merged fixes. Provides the
authoritative diagnostic (download Cloud Build source tarball + byte-diff
against git history to identify the source commit) and the fix (rebuild from
current main checkout, or instant traffic-switch rollback to the last
known-good revision). v1.1 adds a pre-deploy preflight (5 lines from the
deploy directory: fetch + `git status -sb` first-line interpretation table +
ahead/behind count + uncommitted check) that catches the trap BEFORE the
build runs, plus an explicit Variant note for when the *main repo root* is
parked on a feature branch (operators using .claude/worktrees treat the
main dir as scratch space for small commits — the deploy script reads it,
ships the feature branch instead of main, silent rollback). Sister skill to
`baked-payload-stale-after-merge` (same "fix not visible after redeploy"
symptom, but root cause is the separately-deployed baker; this skill is
when the SERVING image itself is stale-from-build).
author: Claude Code
version: 1.2.0
date: 2026-05-07
disable-model-invocation: true
---
# Deploy from a stale worktree silently rolls back merged fixes
## Problem
The user merged a PR (or many PRs), redeployed the serving service, and
reports the fix isn't live — sometimes "a lot of fixes regressed". The
deployed image is brand new (recent timestamp, latest revision) and the
deploy succeeded green. The diff between code and what's deployed is
*not* a baking issue, *not* a browser cache issue — the **image itself
contains old code**.
This happens when the deploy script's build context is the local
filesystem (`gcloud builds submit ... "${LOCAL_DIR}"`,
`docker build <dir>`, `gcloud run deploy --source <dir>`, `kaniko
--context dir://...`) and the user runs it from a stale checkout. With
many git worktrees, it is easy to be sitting in `worktrees/foo/` 21
commits behind `main` and not notice — the deploy ships whatever is on
disk.
## Trigger conditions
All of these are typical:
1. The repo has a deploy script that uses one of:
- `gcloud builds submit --tag=... "${SCRIPT_DIR}"` (no commit SHA tag)
- `docker build -t ... <local-dir>` then push
- `gcloud run deploy --source <local-dir>`
2. The user has multiple checkouts of the same repo (worktrees,
sibling clones, IDE-managed copies). `git worktree list` shows
≥3 worktrees.
3. The user reports a regression after a self-initiated deploy
(`serving.knative.dev/creator` on the new revision is the user's
email, not a CI service account).
4. The "regressed" PRs were all merged BEFORE the deploy timestamp —
they exist on `origin/main` but not necessarily in the local cwd.
5. There is no commit SHA visible on the deployed image (only `:latest`
or no tag at all). This is a strong tell that the build context was
local FS, not a git ref.
## Pre-deploy preflight (catch the trap BEFORE it fires)
Run this preflight from the deploy script's directory before you invoke
the build. It costs ~1s and catches every variant of this trap without
needing to inspect Cloud Build tarballs after the fact.
```sh
cd <main-repo-root> # the dir whose path the deploy script feeds to gcloud builds submit
git fetch origin main
git status -sb # FIRST line — if not "## main", switch before pulling
git rev-list --count HEAD..origin/main # must be 0 to deploy
git status --porcelain # must be empty (uncommitted = will deploy)
git log --oneline -1 # v1.2.0 — actually verify HEAD advanced (don't trust git pull's "Updating X..Y" line)
grep -F "<sentinel-string-from-latest-commit>" <expected-file> # v1.2.0 — file-level proof the working tree matches the commit
```
The branch line of `git status -sb` (the very first line of output)
is the authoritative tell. Three cases:
| `git status -sb` first line | What it means | Action |
|----------------------------------------------------------------|------------------------------------------------|------------------------------------------------------------------------------------|
| `## main...origin/main` | Clean, up-to-date | Proceed |
| `## main...origin/main [behind N]` | On main but stale | `git pull --ff-only origin main` |
| `## main...origin/main [ahead N]` or `[ahead N, behind M]` | Local main has unmerged commits | Stop. Rebase or push first, never deploy local-only commits silently |
| **`## <any-other-branch>`** | **Main repo root parked on a feature branch** | `git checkout main && git pull --ff-only` (see Variant below for the fuller story) |
If you don't recognise the first-line format, **stop and read it** —
this is the one sentence whose misread costs you a silent rollback.
### Variant — `git pull` printed "Updating X..Y" but HEAD silently didn't advance (v1.2.0)
`git status -sb` can show `## main...origin/main` (clean, up-to-date)
**after a pull that didn't actually update anything**, leaving the
working tree stranded at the old commit. The pull's stdout claim
("Updating 9774935..f90b952") is not authoritative — it represents
the operation that *was attempted*, not the result.
**Root cause:** a stale `.git/worktrees/<name>/index.lock` from an
interrupted async post-commit hook blocks the index update. Git silently
skips the working-tree write but still prints the "Updating ..." line
based on the fetched ref. `git status` then shows clean — because
the index DID get reset, just to the old tree.
**Symptoms — all three together:**
1. `git pull --ff-only origin main` reports `Updating <old>..<new>` and exits 0.
2. `git log --oneline -1` shows `<old>`, not `<new>`. **This is the canonical tell.**
3. A file you know is in `<new>` is missing from disk or has the pre-`<new>` content.
**Prevention — add two lines to the preflight:**
```sh
# (after `git pull --ff-only origin main`)
git log --oneline -1 # must show the SHA you expected
grep -F "<sentinel-from-latest-commit>" <expected-file> # must succeed (exits 0)
```
For the second check, pick any unique short string from the most
recent merge — a function name, a comment, a hex value. If the
working tree is at the right commit the grep succeeds; if it's
stranded the grep returns non-zero and you stop before the build runs.
**Fix when caught:** `find .git/worktrees -name index.lock -delete`,
then re-pull. The lock file is always empty (`0` bytes) — verify
with `ls -la` that no process holds it before deleting.
**Cross-reference:** the `worktree-index-corrupt-async-post-commit-hook`
skill covers the lock-file mechanism itself (and the
`fatal: unable to read <sha>` errors when the corruption is worse).
This skill's variant is specifically the *silent* case where neither
git nor the deploy script signals anything wrong.
**Worked example (brief-runner s18b, 2026-05-27):** PR #84 merged
a favicon at `f90b952`. The deploy worktree (long-lived `main`
checkout used by `gcloud builds submit`) had a stale
`index.lock`. `git pull` printed `Updating 9774935..f90b952` but
`git log --oneline -1` still showed `9774935`. The build tarball
captured the pre-favicon state, deployed at revision `00005-fvw`,
and `/static/favicon.svg` returned 404. After diagnosis: removed
the lock, re-pulled (HEAD actually advanced this time), redeployed
at `00006-pkv`. **Adopted: `grep -F <sentinel> <expected-file>`
sanity check before every subsequent `gcloud builds submit`** —
caught the trap proactively on the next deploy cycle. The `grep`
adds maybe 50ms and saves a ~4-minute Cloud Build cycle plus the
user-facing "the change didn't ship" round-trip.
## Diagnostic — confirm before redeploying
Three steps. Each one rules out alternatives.
### Step 1: identify the deployed image and its build
```sh
# What revision is serving traffic?
gcloud run services describe <SERVICE> --region=<REGION> --project=<PROJECT> \
--format="value(status.latestReadyRevisionName,spec.template.spec.containers[0].image)"
# Recent revisions (look for who deployed and when)
gcloud run revisions list --service=<SERVICE> --region=<REGION> --project=<PROJECT> \
--limit=5 --format="table(metadata.name,metadata.creationTimestamp,metadata.annotations['serving.knative.dev/creator'],spec.containers[0].image.basename())"
# Recent builds (find the build that produced the deployed digest)
gcloud builds list --project=<PROJECT> --limit=5 \
--format="table(id,createTime,status,images[0])"
```
If the build was from `gcloud builds submit`, the source tarball is in GCS:
```sh
gcloud builds describe <BUILD_ID> --project=<PROJECT> \
--format="value(source.storageSource.bucket,source.storageSource.object)"
```
### Step 2: download the build source tarball + inspect
```sh
gcloud storage cp gs://<bucket>/<object> /tmp/build_source.tgz \
--project=<PROJECT>
mkdir -p /tmp/build && tar -xzf /tmp/build_source.tgz -C /tmp/build
# Inspect the file the user reports as broken (e.g. a template, a Python module)
grep -n "<unique-string-from-the-fix>" /tmp/build/<path/to/file>
```
If the fix is missing from the tarball, **the image was built from stale
code**. Confirm by comparing against git: find which commit's version
of the file matches the deployed one byte-for-byte:
```sh
git log --all --oneline --format="%H" -- <path/to/file> | while read sha; do
if git show "$sha:<path/to/file>" 2>/dev/null | diff -q - /tmp/build/<path/to/file> >/dev/null 2>&1; then
echo "MATCH: $sha"
git log -1 --format=" %cI %s" "$sha"
fi
done | head -5
```
The matching commit tells you what state the local FS was in when the
deploy ran. Now find the worktree.
### Step 3: identify the stale worktree
```sh
# Find worktrees whose copy of the file matches the deployed (stale) version
for wt in <repo-root> <repo-root>/.claude/worktrees/*/ <other-checkouts>/; do
f="$wt/<path/to/file>"
[[ -f "$f" ]] || continue
if diff -q "$f" /tmp/build/<path/to/file> >/dev/null 2>&1; then
mtime=$(stat -f "%Sm" "$f")
echo "MATCH $mtime $wt"
fi
done
```
The worktree with mtime closest to (but before) the build timestamp is
the one the user deployed from. Confirm by checking how far behind it is:
```sh
cd <suspect-worktree>
git rev-list --count HEAD..origin/main # commits missing
git merge-base --is-ancestor <fix-commit> HEAD && echo "fix present" || echo "FIX MISSING"
```
## Fix
### Immediate (instant, reversible) — traffic-switch to last known-good revision
```sh
gcloud run services update-traffic <SERVICE> \
--region=<REGION> --project=<PROJECT> \
--to-revisions=<KNOWN_GOOD_REVISION>=100
```
This restores prior state in ~30 seconds with no rebuild. Use when the
user wants the regression unwound NOW; defer the proper rebuild.
### Proper — rebuild from current main checkout
```sh
cd <repo-root> # NOT a feature worktree
git checkout main && git pull --ff-only origin main
git status --porcelain # confirm no uncommitted changes
git rev-list --count HEAD..origin/main # confirm zero behind
bash <path-to-deploy-script> --force
```
Then verify with the diagnostic (Step 2) — the new build's tarball
should contain the fix.
## Prevention — guard the deploy script
Add this near the top of the deploy script:
```sh
# Refuse to deploy from anything other than a current main checkout.
GIT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
GIT_BEHIND=$(git rev-list --count HEAD..origin/main 2>/dev/null || echo "?")
GIT_DIRTY=$(git status --porcelain 2>/dev/null | wc -l | tr -d ' ')
if [[ "${GIT_BRANCH}" != "main" ]]; then
echo "ERROR: not on main (currently on '${GIT_BRANCH}'). Refusing to deploy."
echo " cd to the main checkout, or pass --i-know-what-im-doing to override."
[[ "${1:-}" == "--i-know-what-im-doing" ]] || exit 1
fi
if [[ "${GIT_BEHIND}" != "0" ]]; then
echo "ERROR: ${GIT_BEHIND} commits behind origin/main. Run 'git pull' first."
[[ "${1:-}" == "--i-know-what-im-doing" ]] || exit 1
fi
if [[ "${GIT_DIRTY}" != "0" ]]; then
echo "WARNING: working tree has uncommitted changes — they will be deployed."
fi
```
Equivalent for Dockerfile / Cloud Build YAML projects: bake the
`git rev-parse HEAD` into a build arg + image label, then assert at
deploy time that the label matches `git rev-parse origin/main`.
## Verification
After the proper rebuild:
```sh
# 1. New revision exists and serves traffic
gcloud run revisions list --service=<SERVICE> --region=<REGION> \
--project=<PROJECT> --limit=2
# 2. Pull the new build's source tarball + grep for the fix
NEW_BUILD_ID=$(gcloud builds list --project=<PROJECT> --limit=1 \
--format="value(id)")
SOURCE=$(gcloud builds describe "$NEW_BUILD_ID" --project=<PROJECT> \
--format="value(source.storageSource.bucket,source.storageSource.object)")
gcloud storage cp gs://${SOURCE/$'\t'//} /tmp/new_build.tgz --project=<PROJECT>
mkdir -p /tmp/new_build && tar -xzf /tmp/new_build.tgz -C /tmp/new_build
grep -n "<fix-marker>" /tmp/new_build/<path/to/file> # should now find it
```
## Worked example (a client analytics dashboard, 2026-05-07)
User: "I just deployed a new version of cloud run but a lot of the
fixes got regressed. e.g, Feature Snapshot are still in driver but we
moved it to explore."
PR #317 (commit `c382b28f`) had merged 8 hours earlier removing the
duplicate Feature Snapshot section from `/drivers`. After the user's
deploy, it was back.
**Diagnostic:**
```sh
$ gcloud run services describe the-dashboard-service --region=us-central1 \
--project=your-project --format="value(status.latestReadyRevisionName)"
the-dashboard-service-00011-w22
$ gcloud builds list --project=your-project --limit=3 \
--format="table(id,createTime,images[0])"
50826116-... 2026-05-07 10:10 UTC gcr.io/your-project/the-dashboard-service
$ gcloud builds describe 50826116-... --project=your-project \
--format="value(source.storageSource.bucket,source.storageSource.object)"
your-project_cloudbuild source/1778148635.480733-c08dc0a63ec245b98158888fdb0fd39e.tgz
$ gcloud storage cp gs://your-project_cloudbuild/source/1778148635...tgz /tmp/build.tgz
$ tar -xzf /tmp/build.tgz -C /tmp/build
$ grep -n "Feature Snapshot" /tmp/build/templates/drivers.html
92: Feature Snapshot # ← THE SECTION HEADER, not a comment
```
The deployed `drivers.html` line 92 was the section header, NOT the
"moved to /library/explorer" Jinja comment that PR #317 had replaced
it with. **The image was built from pre-#317 code.**
Match against git:
```sh
$ git log --all --oneline --format="%H" -- ...drivers.html | while read sha; do
git show "$sha:<analytics_pkg>/.../drivers.html" 2>/dev/null \
| diff -q - /tmp/build/templates/drivers.html >/dev/null \
&& echo "MATCH: $sha"
done | head
MATCH: 53dbf71181... # PR #303, BEFORE PR #317
```
Worktree search:
```sh
$ for wt in .../.claude/worktrees/*/; do
[[ -f "$wt/...drivers.html" ]] && \
diff -q "$wt/...drivers.html" /tmp/build/templates/drivers.html >/dev/null \
&& echo "MATCH $(stat -f %Sm $wt/...drivers.html) $wt"
done
MATCH May 7 10:40:47 2026 .../worktrees/monitor_poor/ # ← deploy mtime ~9:40 UTC
```
`monitor_poor/` HEAD = `6149771d` (PR #311 squash, S140b's `/actions`
redesign), **21 commits behind origin/main**, missing PRs #317, #318,
#319, #320 + 17 wave1 fixes (#343–362). The deploy from this worktree
silently rolled back ~21 merged fixes from production.
**Fix applied:** instant traffic switch to `00010-bt2` (S140c's last
good revision), then proper rebuild instructions handed back to user
(harness blocks production deploys; user runs the rebuild themselves).
## Notes
- **The "no commit SHA" tell**: when you see a deployed image tagged
only `:latest` (or unlabeled) — no `:abc123def` SHA tag — it almost
always means the deploy script doesn't bake a git ref into the image.
That's the architectural precondition for this trap. Recommend adding
`--tag=${IMAGE}:$(git rev-parse --short HEAD)` to the deploy script.
- **Why worktrees worsen this**: a single-clone repo enforces "you are
on one branch at a time" — `git status` is loud. Worktrees let you
have 100+ checkouts each on a different (often stale) branch. The
user's mental model — "I just merged that PR, surely my checkout has
it" — silently breaks.
- **Variant: main repo root parked on a feature branch** (v1.1, observed
3× across May 2026). In workflows that use `.claude/worktrees/` (or
similar isolated-feature-branch tools), the *main* repo dir frequently
ends up checked out to a feature branch — operators treat it as scratch
space for the WIP that doesn't merit a fresh worktree (e.g. small
doc-only commits, exploratory `/monitor` rework). When the deploy
script targets the main repo dir's path (`SCRIPT_DIR=…/repo`,
`gcloud builds submit "${SCRIPT_DIR}"`), it ships whatever branch is
checked out there — NOT origin/main. The trap fires silently because
the operator's mental model is "the main repo dir is on main." **Fix:**
always run `git status -sb` from the deploy directory FIRST (see
Pre-deploy preflight above) — if the first line isn't
`## main...origin/main`, you have a feature branch. Then
`git checkout main && git pull --ff-only origin main` before invoking
the build. Untracked files (often the operator's in-progress docs)
follow the branch switch fine *unless* origin/main has the same path,
so verify via `git ls-tree -r origin/main --name-only | grep -F <path>`
before checkout when the working dir has untracked content. This
variant is more common than the `.claude/worktrees/<feature>/` case
because the main repo dir reads as "the canonical one" — operators
don't think to check its branch.
- **Why `:latest` resolution doesn't save you**: `gcloud run deploy`
resolves `:latest` to a digest at deploy time. But the IMAGE that
`:latest` points to was just built from the stale FS by the earlier
`gcloud builds submit`. The digest is stable, but the bytes are old.
- **Filtering the worktree-search loop**: 100+ worktrees is a lot. If
performance is an issue, narrow with `find <worktree-root> -name
drivers.html -newer <known-old-marker> -mtime -1` to scope to recent
files.
- **For Docker / non-gcloud builds**: the equivalent is to inspect the
pushed image directly with `crane export <ref> -` (or `docker run
--entrypoint cat <ref> /path/to/file` if you have docker locally).
Same diagnostic — read the file FROM the deployed image and byte-diff
against git history.
## Sister skills
- `worktree-index-corrupt-async-post-commit-hook` — the underlying
lock-file mechanism the v1.2 "silent pull" variant depends on.
That skill covers the LOUD case (`fatal: unable to read <sha>`
errors); this skill's v1.2 variant covers the SILENT case (no
error, pull's "Updating X..Y" line lies, deploy ships old code).
- `gh-pr-merge-worktree-checkout-trap` — different worktree pitfall
(gh CLI refusing to delete branch checked out in another worktree).
- `subagent-bash-cd-wrong-worktree` — subagent context, not deploy.
- `worktree-historical-test-replay-missing-dirs` — running historical
tests against a worktree missing newer dirs.
## Changelog
- **v1.2.0 (2026-05-27):** Added "silent pull" variant — `git pull
--ff-only` prints `Updating X..Y` and exits 0, but HEAD silently
doesn't advance (stale `.git/worktrees/<name>/index.lock` from an
interrupted async post-commit hook blocks the index update). The
v1.1 preflight (`git status -sb` + ahead/behind counts) does NOT
catch this because the post-pull state shows clean. Two new
preflight lines: `git log --oneline -1` (verify the SHA actually
advanced) + `grep -F <sentinel> <expected-file>` (file-level proof
the working tree matches the commit). Source: brief-runner s18b
PR #84 (favicon deployed at `00005-fvw` shipped without the SVG
because the deploy worktree was silently stranded at the
pre-favicon commit).
- **v1.1.0:** Added pre-deploy preflight (`git status -sb` first-line
interpretation table + ahead/behind count + uncommitted check).
- **v1.0.0:** Initial skill.
## References
- gcloud builds submit:
https://cloud.google.com/sdk/gcloud/reference/builds/submit
- Cloud Run revision rollback via traffic split:
https://cloud.google.com/run/docs/managing/revisions#rollback
- git worktree:
https://git-scm.com/docs/git-worktree
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!