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 updatetwice and am still able to reproduce my issue. - [x] I have supplied my
brew doctoroutput 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:
InstallSteps::Runner#root_path("temp")resolves the template toHOMEBREW_TEMP.Sandbox#runcreates a directory for this invocation, andsandbox_environmentsupplies it as the child'sHOMEBREW_TEMP.Mktemp#runrecursively deletes that directory when the phase finishes.
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.