Skip to content

feat(deploy): scope rollback to failed stacks + image quarantine skill - #82

Merged
owine merged 13 commits into
mainfrom
feat/scoped-rollback-and-image-quarantine
Aug 25, 2026
Merged

feat(deploy): scope rollback to failed stacks + image quarantine skill#82
owine merged 13 commits into
mainfrom
feat/scoped-rollback-and-image-quarantine

Conversation

@owine

@owine owine commented Aug 25, 2026

Copy link
Copy Markdown
Owner

Problem

A bad upstream image (ghcr.io/lukegus/termix:release-2.7.1) took down every subsequent deployment. Two independent defects combined:

  1. Unbounded blast radius. Rollback did git reset --hard $PREVIOUS_SHA over the whole live tree, so one stack's failure reverted all ~15 stacks.
  2. Self-perpetuating failure. Rollback changes the live tree, not main. The next commit re-applied the bad image, failed, and rolled back again — indefinitely.

git revert alone cannot fix #2: the all-deps group automerges minor/patch/digest after a 1h minimum release age, so Renovate re-proposes the reverted version within the hour. Renovate has no memory of a version being bad.

Changes

A — scoped rollback (deploy.yml)

Rollback gains a per-stack path, used only when all three hold:

  • the change set is confined to stack directories (classify-rollback-scope.sh),
  • a culprit stack was identified,
  • every culprit is a stack this deploy actually touched (changed_stacks).

Everything else keeps today's whole-tree reset, byte-identical.

Job Change
prepare new rollback_scope + changed_stacks outputs; escape_json: false on changed-files
health-check new failed_stacks output (emitted before exit 1)
rollback Resolve rollback plan + Roll back failed stacks only; three existing steps gated on mode == 'whole-tree'
notify pipeline line reports scope + culprits

B — quarantine-image skill

Lives in ~/.claude/skills/ (not in this repo). Reverts a bad image to its last-known-good tag+digest and adds a negated-regex allowedVersions block in one commit, making the version invisible to Renovate rather than merely un-automerged.

Governing invariants

  1. Uncertainty always resolves to whole-tree, never per-stack. A wrong whole-tree is wasteful; a wrong per-stack leaves a broken deploy partially un-rolled-back.
  2. In a recovery job, malformed input changes the scope of the rollback, never whether one happens. Every degradation routes to whole-tree; nothing aborts the recovery.
  3. Every docker compose up is op run-wrapped. Without it every ${VAR} resolves to empty and the stack comes up misconfigured while reporting success.

Notable fixes found while building this

  • escape_json defaulted to true, emitting [\"a/b.yaml\"] — unparseable by jq. Without escape_json: false the classifier would have rejected every input and returned whole-tree forever: the feature would have been silently inert. This also revived detect_removed_stacks_discovery, dead since it was written (a strict duplicate of gitdiff, so no behavior change — verified across all 8 transition cases).
  • existing_stacks is the whole fleet, not the diff (detect-stack-changes.sh:401). An earlier version of the culprit guard used it and would have been vacuous. Hence the new changed_stacks output.
  • The skip-gate could strand a dirty tree. git checkout <sha> -- dir/ moves the worktree but not HEAD, so HEAD == TARGET_REF and the cleanup reset --hard never ran. Now the gate treats a dirty tree as a reason to deploy.

Testing

  • 19 new unit tests for the classifier (test-classify-rollback-scope.sh); 8 pre-existing transition tests still pass
  • Rollback loop exercised against real throwaway git repos with docker/op stubbed
  • yamllint --strict clean repo-wide; shellcheck -x clean; actionlint shows only the pre-existing job.workflow_sha warning

No live-host test was performed — see the plan's Task 7 section for what that leaves unverified and the accepted risk (a failed per-stack up warns but leaves the job green).

Rollout

Caller repos pin this workflow by SHA, so merging does not activate anything. The per-stack path goes live when Renovate bumps each caller's pin.

Docs

  • docs/superpowers/specs/2026-08-24-scoped-rollback-and-image-quarantine-design.md
  • docs/superpowers/plans/2026-08-24-scoped-rollback-and-image-quarantine.md

Summary by Sourcery

Make deployment recovery safer by rolling back only affected stacks when confidence is high and quarantining known-bad images from future automation.

New Features:

  • Scope deployment rollbacks to failed stacks when changes are confined to known stack directories and the failed stacks are unambiguously identified.
  • Add a quarantine-image skill that reverts bad images and prevents Renovate from proposing them again.

Bug Fixes:

  • Prevent malformed rollback inputs and dirty live trees from skipping recovery or selecting an unsafe per-stack rollback.
  • Ensure rollback compose deployments receive their required environment and cannot hang indefinitely on unhealthy startup.

Enhancements:

  • Add rollback planning, culprit validation, rollback-scope reporting, and conservative whole-tree fallback behavior.
  • Restore reliable changed-file classification and expose failed and changed stack metadata across deployment jobs.

Deployment:

  • Update deployment recovery to support per-stack rollback while preserving whole-tree rollback as the conservative fallback.

Documentation:

  • Add design and rollout documentation for scoped rollback and image quarantine.

Tests:

  • Add unit coverage for rollback-scope classification, including malformed inputs, empty values, path boundaries, and invocation errors.

owine added 11 commits August 24, 2026 18:45
Decides per-stack vs whole-tree rollback from the changed-file list.
Uncertainty always resolves to whole-tree.
Uncertainty must always resolve to whole-tree, never per-stack: a wrong
whole-tree is merely wasteful, a wrong per-stack leaves a broken deploy
partially un-rolled-back. Three input shapes violated that asymmetry:

- Non-string array elements (numbers, null, nested arrays) reached the
  unguarded split()/index() jq pipeline and crashed the script under
  set -euo pipefail before any output was written (rc=5, no output).
- An empty-string element (e.g. from a trailing-newline git diff pipeline)
  vacuously satisfied the "no paths outside a stack dir" check, landing on
  the unsafe per-stack side instead of whole-tree.
- A flag given as the final argument (no value) tripped set -e in the
  shift 2 parsing and exited 1 with no output, instead of falling through
  to whole-tree.

Tighten both array guards to require every element be a non-empty string,
make argument parsing tolerate a missing trailing value, and correct a
comment that inaccurately described the root-level-file fallthrough
behavior. Added 6 test cases (17 total, 11 original unchanged).
Addresses 6 findings from code-quality review of 7c3d300:

- Comment both empty-list guards explicitly as load-bearing vs cosmetic:
  deleting the changed-files-empty guard inverts the safe default (jq's
  filter is vacuously true over []), the stack-dirs-empty guard only buys
  a clearer reason string.
- Close the mid-argv missing-value hole for both flags
  (--changed-files --stack-dirs '[...]' no longer exits 1) by checking $#
  instead of shift 2, which also removes the `shift; [[ $# -gt 0 ]] && shift`
  construct a maintainer could "simplify" back into the exact bug already
  fixed. Distinguishing "missing value" from "explicit empty string" value
  requires checking argument count, not ${2:-} content -- the latter can't
  tell unset from empty.
- Rework the test harness so every case asserts an exit code via a new
  expect_case helper; expect_scope becomes a thin 2-flag wrapper so all
  existing call sites stay unchanged. Verified the new rc assertion can
  actually fail: temporarily injected `exit 3` on the per-stack success
  path, confirmed 3 cases went red, reverted.
- Add coverage for the unknown-flag exit-1 path, the most opinionated
  behavior in the file (invocation errors fail loudly; malformed data
  degrades to whole-tree) and previously untested.
- Extract the duplicated is_string_array predicate so tightening one
  guard can't accidentally miss its twin.
- Use log_warning (not log_info) for genuine data anomalies, with a
  truncated echo of the offending input so an operator doesn't have to
  dig through the upstream step's output.

19 test cases total (11 original unchanged, 8 new). Uncertainty still
always resolves to whole-tree, never the reverse.
Wire classify-rollback-scope.sh into the prepare job and expose its
result as a job output for the (not-yet-wired) rollback job to consume.

Also set escape_json: false on the tj-actions/changed-files step.
That input defaults to true, which backslash-escapes every quote in
the JSON outputs (e.g. all_changed_files becomes [\"x\"] instead of
["x"]) -- invalid JSON that jq can't parse. Every consumer of these
outputs in this job (detect-stack-changes.sh and the new classifier
step) pipes them through jq, so the escaped form silently broke
input validation: the classifier's strict is_string_array guard
rejected the malformed value and fell back to whole-tree every time,
with no error surfaced anywhere.
Per-stack rollback runs only when the change set is confined to stack
directories AND a culprit stack was identified. Every other case keeps
the existing whole-tree reset.

The governing principle for this job: in a recovery job, malformed input
changes the SCOPE of the rollback, never whether one happens.

`Resolve rollback plan` is the job's first step, so any hard failure
there skips every subsequent step and no rollback runs at all — neither
per-stack nor whole-tree — leaving production broken until a human
intervenes. The tradeoff is therefore not "loud failure vs. silently
wrong rollback" but "loud failure with production still down vs.
whole-tree rollback with production restored". So every unusable input
forces the conservative whole-tree path and raises a ::error:: plus a
step-summary entry: recovery still runs, the regression still screams.

That covers three classes of bad input, all validated in the plan step
rather than at their point of use, because a list we cannot trust should
keep us off the per-stack path entirely:
  - a stack list that is not an array of non-empty strings
  - NEW_STACKS specifically, since without it we cannot tell a new stack
    from an existing one
  - a culprit name failing the stack-name pattern. Degrading is also the
    safer security response: the whole-tree path never uses these names
    (it resets the tree and iterates prepare's own existing/removed
    lists, a different producer), so it discards the poisoned name
    instead of acting on it.

The per-stack loop also tolerates an unrevertable culprit. `git checkout
<sha> -- <stack>/` for a directory absent at that SHA is an unmatched
pathspec and exits non-zero, which under `set -e` aborted the whole loop
and stranded every remaining culprit. It now warns and continues, as
does its defence-in-depth name check.
Task 7 live validation declined; per-stack path ships enabled. Documents
what remains unverified and the accepted swallowed-failure risk.
C1 — culprits were never intersected with this deploy's change set.
health-check iterates the *critical* stacks (detected from labels across all
discovered stacks), not the changed ones, so failed_stacks could name a stack
that is byte-identical at PREVIOUS_SHA and TARGET_REF. A commit touching only
termix/ that knocked swag over produced mode=per-stack culprits=[swag]; the
`git checkout $PREVIOUS_SHA -- swag/` was a no-op, swag stayed broken, the
::warning:: was swallowed, the job went green — and termix, the only thing
that actually changed, was never reverted. Whole-tree would have caught it.

prepare now emits `changed_stacks` (first path segment of every changed file
that names a known stack dir) and `Resolve rollback plan` requires every
culprit to be a member. A culprit outside that set means the failure cannot
be attributed to a stack this deploy touched, so the plan degrades to
whole-tree. Note this deliberately does NOT use `existing_stacks`:
detect-stack-changes.sh defines it as (all discovered stacks - new stacks),
so it names the whole fleet on every run and the check would be vacuous.

I2a — the skip-gate left the live tree dirty indefinitely.
`git checkout <sha> -- <dir>/` moves index and worktree but not HEAD, so
after a per-stack rollback HEAD still equals TARGET_REF with a dirty tree.
The skip-gate's SHA comparison saw equality and set skipped=true, so the
`git reset --hard "$TARGET_REF"` never ran. "Re-run failed jobs" at the same
target-ref reported a green "Repository already at target commit" while a
stack sat pinned at the previous SHA. The gate now checks
`git status --porcelain` ahead of the SHA comparison and forces a deploy on
a dirty tree. This is what makes the deliberate absence of a cleanup step in
the rollback job safe — the dirt survives for an operator to inspect, and is
cleared by the next deploy's reset rather than by the recovery job.

I3 — the per-stack `up` had no timeout, unlike every deploy-path `up`.
`docker compose up --wait` waits indefinitely on a container stuck in
`starting`, which is exactly what a bad image produces. The job's
timeout-minutes then cancelled the run, stranding every remaining culprit
un-rolled-back — and a cancelled job takes no whole-tree fallback. Wrapped
in `timeout "$SERVICE_STARTUP_TIMEOUT"` to match the deploy path.

Minors:
- M8: the classifier wrapper's `jq -cn --argjson` aborted `prepare` on a
  malformed upstream list, where the script it calls would have degraded.
  Falls back to `[]`, which reaches the script's dirs_count guard and yields
  whole-tree — the same disposition the script itself would pick.
- M9: the new-stack teardown branch skipped a missing compose file silently;
  now warns, matching the pre-existing whole-tree teardown step.
- M10: the per-stack `up` failure warning now carries the manual recovery
  command, since this is the one path where the job still goes green.

Every degradation added here routes to whole-tree; none exits non-zero.
A recovery job must never abort and leave production down.

Design doc §A4 rewrote: it asserted "no cleanup step is required ... no drift
accumulates across runs", which was false — it did not account for the
skip-gate, and it did not account for the re-`up` needed after the reset.
Both dependencies are now stated explicitly, including the warning that the
second one rests on `existing_stacks` naming the whole fleet.
@sourcery-ai

sourcery-ai Bot commented Aug 25, 2026

Copy link
Copy Markdown

Reviewer's Guide

The PR limits rollback to failed, deploy-touched stacks only when scope classification and all input validation are unambiguous; otherwise it retains the existing whole-tree recovery path. It also adds classifier tests, dirty-tree recovery handling, richer notifications, and an external quarantine skill that prevents Renovate from repeatedly proposing a bad image.

Sequence diagram for scoped failed-stack rollback

sequenceDiagram
    participant Deploy
    participant HealthCheck
    participant Rollback
    participant LiveRepo
    participant OpDocker

    Deploy->>HealthCheck: health-check
    HealthCheck-->>Rollback: failed_stacks
    Deploy-->>Rollback: rollback_scope and changed_stacks
    Rollback->>Rollback: Resolve rollback plan
    alt per-stack and validated culprits
        loop each culprit
            Rollback->>LiveRepo: git checkout PREVIOUS_SHA -- stack/
            Rollback->>OpDocker: op run -- docker compose up -d --wait
            OpDocker-->>Rollback: recovery result
        end
    else whole-tree or degraded input
        Rollback->>LiveRepo: git reset --hard PREVIOUS_SHA
        Rollback->>OpDocker: redeploy stacks at previous SHA
    end
Loading

Flow diagram for conservative rollback scope selection

flowchart TD
    A["Deploy fails or health check fails"] --> B["classify-rollback-scope.sh"]
    B --> C{"All changed paths are known stack paths?"}
    C -- No --> W["whole-tree"]
    C -- Yes --> D["Resolve rollback plan"]
    D --> E{"Inputs valid and culprit list non-empty?"}
    E -- No --> W
    E -- Yes --> F{"Every culprit is in changed_stacks?"}
    F -- No --> W
    F -- Yes --> P["per-stack"]
    W --> R["Reset tree to PREVIOUS_SHA and redeploy"]
    P --> S["Roll back failed stacks only"]
Loading

File-Level Changes

Change Details Files
Adds conservative per-stack rollback planning while preserving whole-tree recovery as the fallback.
  • Classify changed paths against active, disabled, and removed stack directories.
  • Emit changed-stack and rollback-scope workflow outputs, with malformed inputs degrading to whole-tree.
  • Capture failed stacks before health-check failure exits.
  • Validate culprit lists and require every culprit to be among stacks touched by the deploy.
  • Rollback only culprit directories, tear down failed new stacks, and redeploy with the required 1Password wrapper.
  • Gate existing whole-tree teardown/reset/redeploy steps on the resolved rollback mode.
  • Treat a dirty live tree as deployable so partial rollback state is cleaned up on the next run.
  • Include rollback mode and culprit names in notifications.
.github/workflows/deploy.yml
scripts/deployment/classify-rollback-scope.sh
Introduces unit coverage for rollback-scope classification and its fail-safe input handling.
  • Cover stack-only, root-level, mixed, unknown-directory, empty, malformed, and prefix-collision inputs.
  • Assert both GitHub output and exit status, including missing-value and unknown-flag invocation behavior.
scripts/testing/test-classify-rollback-scope.sh
Documents an image quarantine workflow that prevents Renovate from reintroducing known-bad images.
  • Revert the image to its last-known-good tag and digest.
  • Add a negated-regex allowedVersions rule and commit both changes atomically.
  • Keep the skill outside this repository under the Claude skills directory.
~/.claude/skills/ (external to this repository)
Adds design and rollout documentation for scoped rollback and image quarantine.
  • Record the implementation design, safety invariants, recovery behavior, testing scope, and accepted live-host risk.
  • Describe SHA-pinned caller rollout and activation through subsequent pin updates.
docs/superpowers/specs/2026-08-24-scoped-rollback-and-image-quarantine-design.md
docs/superpowers/plans/2026-08-24-scoped-rollback-and-image-quarantine.md

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've reviewed your changes and they look great!

Sourcery assessment

Needs a human reviewer. This changes the production recovery policy from a whole-tree reset to selectively reverting and restarting individual stacks. If the classification or culprit attribution is wrong, shared configuration or dependencies can leave services broken while the rollback reports success, and the resulting production impact can span the fleet; reverting the workflow cannot undo an incident that already occurred.


Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

owine added 2 commits August 25, 2026 09:53
The warning emitted when a per-stack rollback up fails prints a command
for an operator to copy and paste. With an unquoted $LIVE_REPO_PATH,
a deploy path containing whitespace produced a command that word-splits:

  cd /opt/my compose/termix && op run --env-file=/opt/my compose/compose.env

so pasting it fails instead of recovering the stack. Since this is the
one rollback path that leaves the job green, the hint is likely to be
the operator's first action during an incident — it needs to work.

Now emits quoted components, verified to parse as valid shell:

  cd "/opt/my compose/termix" && op run ... --env-file="/opt/my compose/compose.env" ...

Found by Sourcery on the stacked PR #83; the same pattern was present
here and is fixed in each PR separately.
…nt (#83)

* fix(deploy): add timeout to whole-tree rollback up; correct its comment

Two pre-existing issues in `Redeploy stacks at previous SHA`, left
untouched by the scoped-rollback work because that change deliberately
did not modify this step's body.

1. Missing `timeout`. Every other `docker compose up` in this workflow
   is wrapped in `timeout "$SERVICE_STARTUP_TIMEOUT"`; this one was not.
   `--wait` blocks indefinitely on a container stuck in `starting` —
   exactly what a bad image produces — until the job's timeout-minutes
   cancels the whole job, stranding every stack after it with no further
   fallback. Verified with exec-able stubs: a 30s hang now aborts at the
   2s budget, emits a warning, and the loop continues.

   The failure message also now carries the manual recovery command,
   since a failed rollback up leaves the job green.

2. Inaccurate comment. It claimed this step reverts "only the stacks
   this deploy actually touched" and that skipping untouched stacks
   "avoids needlessly recreating the whole fleet". Both are false:
   detect-stack-changes.sh:401 computes existing_stacks as (all
   discovered stacks - new stacks), so this loop covers the whole fleet
   on every run. The comment now says so, and records that the
   fleet-wide scope is load-bearing — it is what pulls a stack pinned by
   a prior per-stack rollback back into line with the tree.

* fix(deploy): quote paths in the whole-tree rollback recovery hint

Same fix as the per-stack hint on the base branch, applied to the
whole-tree step's warning. An unquoted $LIVE_REPO_PATH produced a
copy-paste command that word-splits on a deploy path containing
whitespace, so pasting it fails instead of recovering the stack.

Reported by Sourcery on this PR.
@owine
owine merged commit 2486c3f into main Aug 25, 2026
3 checks passed
@owine
owine deleted the feat/scoped-rollback-and-image-quarantine branch August 25, 2026 15:02
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