deploy-from-stale-worktree-silent-rollback · v1.2.0 · 2026-08-11 · sha256 f987839313f96e10

deploy-from-stale-worktree-silent-rollback v1.2.0A

Immutable. This exact content is served forever at /api/v1/blob/f987839313f96e10.

---
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