Preserve cask backups across lifecycle phases #24295

Closed
MikeMcQuaid opened 7:05pm on October 18, 2026 wanted to merge 1 commit into homebrew/brew main from
preserve-cask-lifecycle-temp

Summary

Cask lifecycle steps that move preserved user data to {{temp}} can lose it before a later step restores it. Each sandboxed step resolves {{temp}} to its own disposable directory. The preflight move succeeds, then sandbox cleanup removes the preserved data; the postflight receives a different directory and silently skips restoration because its guard is false.

This caused actual loss of two named Conda environments during a Miniconda cask upgrade on Homebrew 7.0.9. They were recovered from a Time Machine snapshot. The reproducer below isolates the generic Homebrew lifecycle behavior in a synthetic cask and touches only newly created dummy files under /private/tmp; no installed package needs to be upgraded or removed.

brew doctor output

<details> <summary>Full output; warnings concern unrelated casks and third-party taps</summary>

Warning: Calling `preflight` is deprecated! Use `preflight_steps` instead.
Please report this issue to the nitschw/homebrew-tap tap (not Homebrew/* repositories), or even better, submit a PR to fix it:
  /opt/homebrew/Library/Taps/nitschw/homebrew-tap/Casks/panewright.rb:49

Please note that these warnings are just used to help the Homebrew maintainers
with debugging if you file an issue. If everything you use Homebrew for is
working fine: please don't worry or file an issue; just ignore this. Thanks!

Warning: Some installed casks are deprecated or disabled.
You should find replacements for the following casks:

  get-lyrical
  quakespasm

Warning: The following taps are not trusted:
  draftbrew/tap
  italomandara/cxpatcher
  sscotth/monoid

Homebrew is currently ignoring formulae, casks and commands
from these taps because tap trust is required.
Prefer trusting only the specific formulae, casks or commands you need.
Trust installed casks from these taps with:
  brew trust --cask draftbrew/tap/space-cadet-pinball
Trust other specific formulae and commands with:
  brew trust --formula <user>/<tap>/<formula>
  brew trust --command <user>/<tap>/<command>
Whole-tap trust is broader and includes all current and future formulae,
casks and commands from the listed taps. Trust whole taps with:
  brew trust draftbrew/tap italomandara/cxpatcher sscotth/monoid
Untap them with:
  brew untap draftbrew/tap italomandara/cxpatcher sscotth/monoid
For more information, see:
  https://docs.brew.sh/Tap-Trust

</details>

Verification

  • [x] I ran brew update twice and am still able to reproduce my issue.
  • [x] I have supplied my brew doctor output above and it seems unrelated to my issue.
  • [x] This issue's title and/or description do not reference a single formula e.g. brew install wget. If they do, open an issue at https://github.com/Homebrew/homebrew-core/issues/new/choose instead.
  • [x] I did not use AI/LLM to create this issue, or I disclosed the tool and model used; I will answer maintainer questions myself without AI/LLM.

Update-check qualification: Both brew update attempts encountered the same unrelated removed third-party tap:

==> Updating Homebrew...
remote: Repository not found.
fatal: repository 'https://github.com/draftbrew/homebrew-tap/' not found
Error: draftbrew/homebrew-tap does not exist! Run `brew untap draftbrew/homebrew-tap` to remove it.

I have not removed that unrelated tap. The bug was reproduced again after both attempts. The installed version remains 7.0.9, which the GitHub releases API also reports as the latest stable release at filing time. This is a generic sandbox/lifecycle bug; Miniconda is the affected real-world example, not a formula-specific build failure.

AI disclosure: OpenAI Codex (GPT-6) investigated the failure, executed the dummy-file reproductions and prepared this report at my request. I, the human reporter, will answer maintainer questions myself without AI/LLM.

brew config output

HOMEBREW_VERSION: 7.0.9
ORIGIN: https://github.com/Homebrew/brew
HEAD: cdc1ca503231b9a96da64526774915d913c3ec9f
Last commit: 9 days ago
Branch: stable
Core tap: N/A
Core cask tap: N/A
HOMEBREW_PREFIX: /opt/homebrew
HOMEBREW_NO_ANALYTICS: set
HOMEBREW_NO_AUTO_UPDATE: set
HOMEBREW_NO_INSECURE_REDIRECT: set
Homebrew Ruby: 4.0.7 => /opt/homebrew/Library/Homebrew/vendor/portable-ruby/4.0.7/bin/ruby
CPU: dodeca-core 64-bit arm_blizzard_avalanche
Clang: 21.0.0 build 2100
Git: 2.56.0 => /opt/homebrew/bin/git
Curl: 8.7.1 => /usr/bin/curl
macOS: 27.0.1-arm64
CLT: 27.0.0.0.1788430756
Xcode: N/A
Rosetta 2: false

What were you trying to do (and why)?

Run routine updates, including brew upgrade --cask --greedy, while retaining named Conda environments. The recorded Miniconda installer changed from py314_26.7.1-1 (installed with Homebrew 6.0.21 on 2026-09-01) to py314_26.9.3-0 (installed with Homebrew 7.0.9 on 2026-10-17).

Both recorded cask receipts contain environment-preservation steps: move base/envs to {{temp}}/{{token}}-envs in uninstall_preflight_steps, then restore it in postflight_steps if the backup exists. The installed-metadata loader retains these steps correctly.

What happened (include all command output)?

After the real upgrade, base/envs was empty. The original terminal transcript is unavailable; old/new receipts and a snapshot preceding the upgrade establish the version transition and previous environments. The following independent dummy-file reproduction demonstrates the failure without repeating an upgrade on real data.

Failing run (exit 1):

stable backup path: false
before preflight: original=true backup=false
/private/tmp/s-xajiVky2/phase-backup-demo-8274-envs/demo/marker
after preflight: original=false backup=false
repro.rb:43:in '<main>': Preserved data was lost (RuntimeError)
after postflight: original=false backup=false

The marker exists inside /private/tmp/s-.../ during preflight, but both the original and backup are absent immediately after that single phase returns. It is already lost before the simulated installer replacement.

Control with sandboxing retained and a backup path shared between phases (exit 0):

stable backup path: true
before preflight: original=true backup=false
/private/tmp/cask-phase-backup-20261017-8374-r9wvcr/shared/phase-backup-demo-8374-envs/demo/marker
after preflight: original=false backup=true
after postflight: original=true backup=false

File ownership fields from ls -l are omitted above. The paths and existence results are retained. An additional direct control using the same preservation steps without the sandbox also preserved the marker.

Suspected regression

The behavior is consistent with e29632cfe3846d0bc8fdd75c7dabc536ab170076, "Unify sandbox temporary directory handling", dated 2026-09-11 and included in 7.0.0. This sets the sandbox child's HOMEBREW_TEMP to its private temporary directory. Homebrew 6.0.21's sandbox runner did not override that variable. This attribution is from source inspection, not a full executable Git bisect.

On the tested 7.0.9 commit:

This deletes the data moved into the backup, rather than merely leaving it in an unexpected location. A later phase gets a new sandbox temp directory, so its restore guard does not find the backup. Neither conda clean nor brew cleanup is needed for the reproduction.

This differs from the unprotected installer-replacement behavior described in the historical Homebrew/homebrew-cask#57162: here preservation steps exist and execute, but their backup is removed by Homebrew's sandbox lifecycle.

What did you expect to happen?

Data explicitly moved aside for a later cask lifecycle phase should survive until that phase restores it. The restore must not silently succeed overall after its backup has been removed. A backup path shared between the phases preserves the dummy data while retaining sandboxing; globally disabling sandboxing is unnecessary.

Step-by-step reproduction instructions (running brew commands)

Save the following as repro.rb. It creates a synthetic cask and dummy marker under /private/tmp, invokes the real preflight and postflight artifact classes, and simulates distribution replacement only within that freshly created dummy directory. It does not invoke an actual package installer, remove an installed environment, or change Homebrew source/configuration.

require "tmpdir"
require "cask/cask"

# Dummy files only; no installed cask or environment is upgraded or removed.
root = Pathname(Dir.mktmpdir("cask-phase-backup-", "/private/tmp"))
token = "phase-backup-demo-#{Process.pid}"
stable = ARGV.include?("stable")
backup = stable ? (root/"shared/#{token}-envs").to_s : "{{temp}}/{{token}}-envs"
parent_backup = stable ? Pathname(backup) : HOMEBREW_TEMP/"#{token}-envs"
cask = Cask::Cask.new(token) do
  version "1"
  sha256 :no_check
  url "file:///not-used"

  uninstall_preflight_steps do
    if_path_exists "base/envs", base: :caskroom_path do
      move "base/envs", backup, source_base: :caskroom_path
      run "/bin/ls", args: ["-l", "#{backup}/demo/marker"], print_stdout: true
    end
  end

  postflight_steps do
    if_path_exists backup do
      remove "base/envs", base: :caskroom_path, recursive: true
      move backup, "base/envs", target_base: :caskroom_path
    end
  end
end
cask.define_singleton_method(:caskroom_path) { root }
marker = root/"base/envs/demo/marker"
marker.dirname.mkpath
marker.write("preserve me\n")
puts "stable backup path: #{stable}"
puts "before preflight: original=#{marker.exist?} backup=#{parent_backup.exist?}"
cask.artifacts.grep(Cask::Artifact::UninstallPreflightSteps).first.uninstall_phase
puts "after preflight: original=#{marker.exist?} backup=#{parent_backup.exist?}"

# Simulate replacement of the dummy distribution between lifecycle phases.
FileUtils.rm_rf(root/"base")
(root/"base/envs").mkpath
cask.artifacts.grep(Cask::Artifact::PostflightSteps).first.install_phase
puts "after postflight: original=#{marker.exist?} backup=#{parent_backup.exist?}"
raise "Preserved data was lost" unless marker.exist? && marker.read == "preserve me\n"

Run:

HOMEBREW_DEVELOPER=1 HOMEBREW_NO_AUTO_UPDATE=1 brew ruby repro.rb

Expected: the marker survives. Actual on 7.0.9: Preserved data was lost, exit 1.

Then run the control, which changes only the backup location to a shared path under the same dummy root and retains Homebrew sandboxing:

HOMEBREW_DEVELOPER=1 HOMEBREW_NO_AUTO_UPDATE=1 brew ruby repro.rb stable

Actual: after postflight: original=true backup=false, exit 0.

Diff Delta:
0
About 105 Diff Delta/hour
Classified as:  General

MikeMcQuaid's Description of Work #24293 Cask lifecycle backups in {{temp}} are deleted by sandbox cleanup before restoration


  • Share a private 0700 directory for one cask operation so sandbox cleanup cannot delete data awaiting restoration.

  • Retain the directory on failure and report its path for recovery.

  • Keep command scratch space isolated and clean up after success.

Fixes #24293



  • [x] Have you followed our Contributing guidelines?

  • [x] Have you checked for other open Pull Requests for the same change?

  • [x] Have you explained what your changes do? Performance claims (e.g. "this is faster") must include brew benchmark results.

  • [x] Have you explained why you'd like these changes included, not just what they do?

  • [x] For bug fixes, have you given step-by-step brew commands to reproduce the bug?

  • [x] Have you written new tests (excluding integration tests)? Here's an example.

  • [x] Have you successfully run brew lgtm (style, typechecking and tests) locally?



  • [x] I did not use AI/LLM to create this PR, or I disclosed the tool/model below and reviewed its output; I did not attribute commits to AI and will answer maintainer questions and review comments myself without AI/LLM.

Codex with GPT 6 Astra at Extra High effort, with local review and testing.


Summary by CodeRabbit



  • New Features


    • Cask install steps can use a private temporary directory shared across phases of an upgrade or reinstall, allowing backups to be restored later.

    • Temporary files are removed after successful operations. If an operation fails, they’re retained and their location is reported.

    • A move step fails if its expected backup is missing, including when if_path_exists is used.




  • Documentation


    • Clarified temporary directory behaviour across upgrades, reinstalls, and separate uninstall and install commands.
      <!-- end of auto-generated comment: release notes by coderabbit.ai -->



8 total changed files
Loading changes...
Loading changes...
Loading changes...
Loading changes...
Loading changes...
Loading changes...
Loading changes...
You've reached the end of this PR review
Reviewer: 1. Additional work: 0