Skip to content

Install app_image by moving the AppImage, mirroring the app stanza - #23771

Merged
MikeMcQuaid merged 1 commit into
Homebrew:mainfrom
waldyrious:app_image-move-not-symlink
Sep 4, 2026
Merged

Install app_image by moving the AppImage, mirroring the app stanza#23771
MikeMcQuaid merged 1 commit into
Homebrew:mainfrom
waldyrious:app_image-move-not-symlink

Conversation

@waldyrious

@waldyrious waldyrious commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Today app_image installs the real AppImage into a versioned Caskroom directory and puts the user-facing entry at config.appimagedir (default ~/Applications) as a symlink back to it (via Artifact::Symlinked). For example: ~/Applications/OpenWhispr.AppImage -> /home/linuxbrew/.linuxbrew/Caskroom/openwhispr/1.8.3/OpenWhispr-1.8.3-linux-x86_64.AppImage.

That symlink exposes the versioned Caskroom path as $APPIMAGE to the running app. For casks that self-update with electron-updater, doInstall() branches on the $APPIMAGE filename: if the basename contains an X.Y.Z version, it writes a new versioned filename and unlinkSyncs the old one; conversely, version-less basenames are updated in place. Because the exposed path (the Caskroom one) is versioned, doInstall()'s rename branch is followed: a new <App>-<newver>.AppImage lands inside the older version's Caskroom dir (e.g. /home/linuxbrew/.linuxbrew/Caskroom/openwhispr/1.8.3/OpenWhispr-1.9.2-linux-x86_64.AppImage), the old file is deleted, and the appimagedir symlink (at ~/Applications) is left dangling until the user manually runs brew reinstall.

This patch inverts the relationship to match Cask::Artifact::App (i.e. the macOS flow): AppImage now subclasses Moved instead of Symlinked, so the real AppImage lives at the stable, unversioned appimagedir target, and the Caskroom holds the versioned back-symlink. Most app_image casks already declare a version-less target: (e.g. Obsidian.AppImage, Heroic.AppImage), so $APPIMAGE becomes version-less and electron-updater overwrites in place — the file stays put across self-updates and nothing user-facing breaks. (If some other AppImage updater renames anyway, only the disposable Caskroom-side pointer is affected, not the user's file.)

Note: This change introduces no new home-directory destination: app_image already writes to config.appimagedir, whose documented default ~/Applications was explicitly proposed and accepted when the stanza landed (#20334). This only changes that entry from a symlink to the real file, plus a Caskroom backlink — the same model App and other Moved artifacts (Font, Dictionary, ScreenSaver, ...) already use. Linux already extends Moved, so no new cross-platform machinery is needed.

The change applies to all app_image casks, not only self-updaters: gating on auto_updates would mean two opposing placement/uninstall models for the same artifact class.

On backward compatibility: app_image has shipped since 5.1.12, so users already have AppImages installed with the current symlink setup (appimagedir$APPIMAGE). To account for this, a small, clearly-marked migration shim in uninstall_phase reverses the old Symlinked on-disk layout directly (a plain Moved#move_back would otherwise error on a non-forced uninstall of a pre-inversion install).

Validation

  • Added test/cask/artifact/appimage_spec.rb covering placement + backlink, the executable bit, in-place self-update, adopt (with and without auto_updates), uninstall, migration from the old Symlinked layout, and installed-summary output. It runs with the rest of the cask suite on macOS CI.
  • Manually verified end-to-end on Linux with a real cask: install → in-place self-update (simulating electron-updater rewriting the target under the same name) → uninstall. Confirmed the target stays a real executable file, the Caskroom backlink stays valid and resolves to the updated contents across the self-update, and uninstall removes the target and the Caskroom entry.

  • Have you followed our Contributing guidelines?
  • Have you checked for other open Pull Requests for the same change?
  • Have you explained what your changes do? Performance claims (e.g. "this is faster") must include brew benchmark results.
  • Have you explained why you'd like these changes included, not just what they do?
  • For bug fixes, have you given step-by-step brew commands to reproduce the bug?
  • Have you written new tests (excluding integration tests)? Here's an example.
  • Have you successfully run brew lgtm (style, typechecking and tests) locally?
    Note: brew lgtm passes brew typecheck and brew style --changed --fix, but on my Linux machine brew tests --changed cannot run due to missing the Fiddle library.
    That's probably a bug to be fixed separately; either way, CI will validate the changes.

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

I used GPT 5.6 Sol to perform the initial investigation and draft an implementation plan, and then Claude Opus 4.8 to implement and run the tests. I reviewed every change and validated the tests locally as described above.

Copilot AI balanced review requested due to automatic review settings September 4, 2026 12:10

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

The migration misses dangling legacy links and can prevent rollback during upgrades.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Changes AppImage installation to use the standard moved-artifact model, preventing self-updates from breaking user-facing links.

Changes:

  • Moves AppImages into appimagedir with Caskroom backlinks.
  • Adds legacy-layout migration handling.
  • Adds AppImage fixtures and lifecycle tests.
File summaries
File Description
Library/Homebrew/cask/artifact/appimage.rb Implements moved installation and legacy cleanup.
Library/Homebrew/test/cask/artifact/appimage_spec.rb Tests installation, updating, adoption, migration, and summaries.
Library/Homebrew/test/support/fixtures/cask/Casks/with-appimage.rb Adds a test cask.
Library/Homebrew/test/support/fixtures/cask/naked.AppImage Adds an AppImage test fixture.
Review details

Suppressed comments (1)

Library/Homebrew/cask/artifact/appimage.rb:64

  • Deleting the real source here makes the first upgrade from the legacy layout non-rollbackable. Installer#start_upgrade calls this with upgrade: true and then backs up the staged directory; if the new installation fails, revert_upgrade restores that directory and calls install_artifacts, but the AppImage source is now gone and installation raises. Keep the source during upgrades so it is included in the backup; successful finalization will purge that backup.
          Utils.gain_permissions_remove(source, command:)
  • Files reviewed: 4/4 changed files
  • Comments generated: 1
  • Review effort level: Balanced

💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.

Comment thread Library/Homebrew/cask/artifact/appimage.rb Outdated
@MikeMcQuaid

Copy link
Copy Markdown
Member

Most app_image casks already declare a version-less target:

What about those that declare a version?

@MikeMcQuaid MikeMcQuaid left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks! A few questions/thoughts.

Comment thread Library/Homebrew/cask/artifact/appimage.rb Outdated
Currently `app_image` installs the real AppImage into a versioned Caskroom directory
and puts the user-facing entry at `config.appimagedir` (default `~/Applications`)
as a *symlink* back to it, via `Artifact::Symlinked`.

That symlink exposes the *versioned* Caskroom path as `$APPIMAGE` to the running application,
which means that casks that use electron-updater for auto-updates
will go through its `doInstall()` method which changes based on the `$APPIMAGE` filename:
if it contains an `X.Y.Z` version, it writes a *new* versioned filename and `unlinkSync`s the old one;
only a version-less basename results in an overwrite-in-place update [^1].
Therefore, because the exposed path to the AppImage is indeed versioned,
electron-updater applies that rename logic,
which results in a new `<App>-<newver>.AppImage` (inside the _older_ version's Caskroom dir!),
the old file being deleted. Worst of all: the `appimagedir` symlink is left dangling
until a manual `brew reinstall` is executed.

This patch inverts the relationship, matching `Cask::Artifact::App` (i.e. the macOS way):
`AppImage` now subclasses `Moved` so the real AppImage lives at the stable `appimagedir` target
and the Caskroom holds the back-symlink.
Most `app_image` casks already declare a version-less `target:` (e.g. `Obsidian.AppImage`, `Heroic.AppImage`),
so as a result, `$APPIMAGE` becomes version-less and electron-updater overwrites them in place;
i.e. the real file stays put across self-updates, and nothing user-facing breaks.
If some other AppImage updater renames anyway, only the disposable Caskroom-side pointer is affected,
not the user's file.
`resolve_target` is unchanged, so `appimagedir` and custom targets
behave exactly as before; the old `link` override becomes an `install_phase` override
that makes the moved target executable (by overriding `install_phase`, which is `overridable`,
rather than `Moved#post_move`, which is not).

This introduces no new home-directory destination:
`app_image` already writes to `config.appimagedir`, whose documented default `~/Applications`
was explicitly proposed and accepted when the stanza landed [^2] [^3] [^4];
this patch only changes that entry from a symlink to the real file, plus a Caskroom backlink.
Real artifacts under `$HOME` are already normal for `Moved` subclasses
(e.g. `Font`, `Dictionary`, `ScreenSaver`, ...).
Linux already extends `Moved` for `cp --reflink=auto` and no-ops the macOS-only altname xattr,
so no new cross-platform machinery is required.

This change affects all `app_image` casks, not only self-updaters; gating on `auto_updates`
would result in two opposing placement/uninstall models for the same artifact class
and rely on casks classifying their updater correctly.

`app_image` has shipped since 5.1.12, so users already have AppImage installs with the old `Symlinked` layout.
`brew upgrade` self-heals (it force-uninstalls then reinstalls),
but a non-forced `brew uninstall`/`reinstall` would fail,
because `Moved#move_back` only removes `source` when it is a symlink to `target`.
Therefore, a small shim was added to `uninstall_phase`,
to detect the old layout and reverse it directly.
This mirrors how `migrator.rb` already tolerates state from an earlier migration era.

This addresses AppImage updaters that replace the filename, specifically electron-updater.
Other major updaters are not affected:
Tauri's updater replaces `$APPIMAGE` in place and keeps the filename [^5],
and AppImageUpdate/zsync2 updates in place, moving the old file aside as `.zs-old` [^6].
electron-updater is the one updater that renames by default,
due to the version-in-basename heuristic that this patch neutralizes.
A hypothetical updater that would rename the *target* to a different name
would fall back to the inherited `Moved` behavior (it would report `Missing AppImage`).
The Brew 6 `auto_updates` upgrade check [^7] cannot help here since
`Cask#auto_updates_bundle_outdated?` requires an `Artifact::App` with an
`Info.plist`, which an AppImage lacks.

[^1]: https://github.com/electron-userland/electron-builder/blob/master/packages/electron-updater/src/AppImageUpdater.ts
[^2]: Homebrew#20334
[^3]: Homebrew#20334 (comment)
[^4]: Homebrew#20334 (comment)
[^5]: tauri-apps/tauri#6279
[^6]: https://github.com/AppImageCommunity/AppImageUpdate
[^7]: https://github.com/orgs/Homebrew/discussions/6916
@waldyrious

waldyrious commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Most app_image casks already declare a version-less target:

What about those that declare a version?

This is partially addressed in the PR description where I mentioned a similar failure case:

If some other AppImage updater renames anyway, only the disposable Caskroom-side pointer is affected, not the user's file.

An app_image cask that relies on electron-updater and declares a version-ful target would meet the same fate. Sure, not ideal, but strictly better than the current outcome (for any electron-updater, regardless of target), where the symlink would be broken and the actual cask file would live with the newer version inside the caskroom path for the older version.

As a concrete example, if the example cask above (OpenWhispr) had a versioned target:, this would be the case before:

  • ~/Applications/OpenWhispr-1.8.3.AppImage (broken symlink — user-facing AppImage stops working)
  • /home/linuxbrew/.linuxbrew/Caskroom/openwhispr/1.8.3/OpenWhispr-1.9.2-linux-x86_64.AppImage (new version's AppImage file in the old version's Caskroom path)

and with this PR:

  • ~/Applications/OpenWhispr-1.9.2.AppImage (still functional)
  • /home/linuxbrew/.linuxbrew/Caskroom/openwhispr/1.8.3/OpenWhispr-1.8.3-linux-x86_64.AppImage (broken symlink from the Caskroom — recoverable)

I should point out that this is exceedingly rare, btw: out of all 100+ existing app_image casks (which drop to 53 if we only consider those with auto_updates true):

  • only one includes version in the target field (but it only uses the major version number, so it wouldn't trigger the X.Y.Z case)
  • only two don't have a target at all, thus falling back to the source basename (of which only one is actually versioned)
  • none contain directly the X.Y.Z pattern that triggers electron-updater's rename flow.

That single exception is easily addressable by editing the cask file, but we could certainly add a check to the cask audit to ensure this doesn't recur. Should I fold such a check into this PR? (Again, even without fixing that cask, this PR already leads to a better outcome than what we currently have.)

@waldyrious
waldyrious force-pushed the app_image-move-not-symlink branch from 740a473 to d154fcc Compare September 4, 2026 13:51
@MikeMcQuaid

Copy link
Copy Markdown
Member

That single exception is easily addressable by editing the cask file, but we could certainly add a check to the cask audit to ensure this doesn't recur. Should I fold such a check into this PR? (Again, even without fixing that cask, this PR already leads to a better outcome than what we currently have.)

No, a new PR would be ideal, thanks. Please open PRs to:

  • add this audit
  • fix those casks that would fail this audit

🙇🏻

@MikeMcQuaid MikeMcQuaid left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Nice work, thanks!

@MikeMcQuaid
MikeMcQuaid added this pull request to the merge queue Sep 4, 2026
Merged via the queue into Homebrew:main with commit a9ee680 Sep 4, 2026
57 checks passed
@waldyrious
waldyrious deleted the app_image-move-not-symlink branch September 4, 2026 14:34
waldyrious added a commit to waldyrious/homebrew-cask that referenced this pull request Sep 4, 2026
AppImages will soon require (via a new audit rule) a non-versioned target name
to ensure that those that rely in electron-updater don't break installs
(see Homebrew/brew#23771 for context).

This fix prepares the bruno cask to pass the upcoming audit,
and brings it to parity with other AppImage casks.
@waldyrious

Copy link
Copy Markdown
Contributor Author

Please open PRs to:

  • add this audit

👉️ #23777

  • fix those casks that would fail this audit

👉️ Homebrew/homebrew-cask#285259

waldyrious added a commit to waldyrious/brew that referenced this pull request Sep 4, 2026
`Homebrew::DevCmd::Tests#setup_environment!` deletes every `HOMEBREW_*`
config var not in its `allowed_test_env` allowlist, for test isolation.
`HOMEBREW_NO_SANDBOX_LINUX` (and `HOMEBREW_SANDBOX_LINUX`) were not on that
list, so the documented opt-out was stripped before
`OS::Linux::DevCmd::Tests#check_test_environment!` read it: `sandbox_linux?`
fell back to its default (true) and the Linux Landlock check ran regardless.
That is why the same var reads false under `brew ruby` but effectively true
under `brew tests`.

On a dev host whose Ruby lacks Fiddle (Ruby 3.5/4.0 dropped it from the
default gems; portable-ruby on CI still has it), the Landlock sandbox cannot
initialise, so `brew tests` aborts with "Landlock requires Ruby's bundled
Fiddle library" and the documented opt-out is the only escape hatch — which
didn't work. This surfaced while running `brew lgtm` locally for Homebrew#23771. [^1]

Add both sandbox toggles to `allowed_test_env` so the opt-out survives the
cleanup and `check_test_environment!` can honor it. Default behavior is
unchanged: without the toggle the sandbox is still enforced.

Note: `HOMEBREW_SANDBOX_LINUX` / `HOMEBREW_NO_SANDBOX_LINUX` are `odeprecated`;
maintainers may prefer removing the toggle entirely over restoring it. Worth
confirming the intended direction in review.

[^1]: Homebrew#23771
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.

3 participants