Merge pull request #24315 from Homebrew/zap_audit

Require `zap` stanzas in new casks
This commit is contained in:
Mike McQuaid authored and GitHub committed 2026-10-22 07:48:00 +00:00
commit 3c6a221562
13 files changed
+256 -16

No files matched your search

+11
View File
@@ -7,6 +7,17 @@ module Cask
module Artifact
# Artifact corresponding to the `zap` stanza.
class Zap < AbstractUninstall
# `:not_required` is serialised as an empty stanza, which older versions of
# Homebrew can still load from the API.
sig { params(cask: Cask, exemption: T.nilable(Symbol), directives: DirectivesType).returns(Zap) }
def self.from_args(cask, exemption = nil, **directives)
if exemption && (exemption != :not_required || directives.any?)
raise CaskInvalidError.new(cask, "'zap' stanza requires either directives or only ':not_required'.")
end
new(cask, **directives)
end
# Removing files also waits, as `uninstall` steps may still need them, e.g. to
# identify a keychain certificate to delete.
sig { override.returns(T::Array[Symbol]) }
+17
View File
@@ -357,6 +357,23 @@ module Cask
add_error "at least one installable artifact stanza is required" unless installable_artifact
end
sig { void }
def audit_zap_required
return unless new_cask?
return unless cask.tap&.official?
return if cask.artifacts.any?(Artifact::Zap)
zap_suggestion = if Homebrew::SimulateSystem.simulating_or_running_on_linux?
"Add one"
else
# `brew generate-zap` only scans macOS paths.
"Generate one with `brew generate-zap #{cask.token}`"
end
add_error "New casks must have a `zap` stanza for every OS and architecture they support. " \
"#{zap_suggestion} or use `zap :not_required` only if there is nothing to zap. " \
"See #{Formatter.url("https://docs.brew.sh/Cask-Cookbook#stanza-zap")}"
end
sig { void }
def audit_description
# Fonts seldom benefit from descriptions and requiring them disproportionately
+7 -2
View File
@@ -266,9 +266,14 @@ module Homebrew
path = cask.sourcefile_path
errors = os_arch_combinations.flat_map do |os, arch|
# Linux-only casks have no stanza values for macOS, so audit them
# under Linux instead.
# Casks have no stanza values for platforms they don't support, so
# audit them on one they do instead. `depends_on arch` can differ
# between OSes, so read it on the OS being audited.
os = :linux if os != :linux && !cask.supports_macos?
os = :macos if os == :linux && !cask.supports_linux?
cask_archs = SimulateSystem.with(os:, arch:) { Cask::CaskLoader.load(path) }
.depends_on.arch&.map { |cask_arch| cask_arch[:type] }
arch = OnSystem::ARCH_OPTIONS.find { cask_archs.include?(it) } if cask_archs&.exclude?(arch)
SimulateSystem.with(os:, arch:) do
odebug "Auditing Cask #{cask} on os #{os} and arch #{arch}"
+7 -4
View File
@@ -24,8 +24,8 @@ module Homebrew
The target application should have been launched at least once so that
preference files and caches exist on disk.
Outputs `trash`, `delete`, and `rmdir` directives suitable for pasting
into a cask definition.
Outputs `trash`, `delete` and `rmdir` directives suitable for pasting
into a cask definition, or `zap :not_required` if nothing is found.
EOS
switch "--name",
@@ -123,8 +123,11 @@ module Homebrew
rmdir_paths = derive_rmdir_candidates(trash_paths + delete_paths)
if trash_paths.empty? && delete_paths.empty?
opoo "No files found matching #{format_patterns(patterns)}."
puts "# No zap stanza required"
opoo <<~EOS
No files found matching #{format_patterns(patterns)}.
Only use `zap :not_required` if the app has been launched at least once.
EOS
puts "zap :not_required"
return
end
@@ -28,7 +28,7 @@ module RuboCop
return unless [:zap, :uninstall].include?(node.method_name)
hash_node = node.arguments.first
return if hash_node.nil? || (!hash_node.is_a?(AST::Node) && !hash_node.hash_type?)
return unless hash_node.is_a?(AST::HashNode)
comments = processed_source.comments
@@ -4,6 +4,19 @@
require_relative "shared_examples/uninstall_zap"
RSpec.describe Cask::Artifact::Zap, :cask do
describe "::from_args" do
let(:cask) { Cask::Cask.new("zap-not-required") }
it "serialises `:not_required` as an empty stanza that older versions can load" do
expect(described_class.from_args(cask, :not_required).to_args).to be_empty
end
it "rejects `:not_required` with directives" do
expect { described_class.from_args(cask, :not_required, trash: "~/Library/Foo") }
.to raise_error(Cask::CaskInvalidError, /:not_required/)
end
end
describe "directive dispatch" do
include_examples "uninstall/zap directive dispatch"
+66
View File
@@ -1780,6 +1780,72 @@ RSpec.describe Cask::Audit, :cask do
end
end
describe "zap required" do
subject(:run) { Homebrew::SimulateSystem.with(os:) { audit.run! } }
let(:only) { ["zap_required"] }
let(:new_cask) { true }
let(:os) { :sequoia }
let(:message) { /New casks must have a `zap` stanza/ }
let(:tap) { CoreCaskTap.instance }
let(:stanzas) { nil }
let(:cask) do
extra_stanzas = stanzas
Cask::Cask.new("zap-required", tap:) do
version "1.0"
sha256 :no_check
url "https://brew.sh/zap-required.dmg"
homepage "https://brew.sh"
instance_eval(&extra_stanzas) if extra_stanzas
end
end
context "when the cask has no zap stanza" do
it do
expect(run).to error_with(
/Generate one with `brew generate-zap zap-required` or use `zap :not_required`/,
)
end
end
context "when the cask is not new" do
let(:new_cask) { false }
it { is_expected.not_to error_with(message) }
end
context "when the cask is not in an official tap" do
let(:tap) { nil }
it { is_expected.not_to error_with(message) }
end
context "when the cask has `zap :not_required`" do
let(:stanzas) { proc { zap :not_required } }
it { is_expected.not_to error_with(message) }
end
context "when the cask only has a zap stanza on macOS" do
let(:stanzas) do
proc do
on_macos do
zap trash: "~/Library/Preferences/sh.brew.zap-required.plist"
end
end
end
it { is_expected.not_to error_with(message) }
context "when auditing Linux" do
let(:os) { :linux }
it { is_expected.to error_with(/Add one or use `zap :not_required`/) }
end
end
end
describe "GitHub repository notability" do
let(:only) { ["github_repository"] }
let(:new_cask) { true }
+110
View File
@@ -92,6 +92,116 @@ RSpec.describe Homebrew::DevCmd::Audit do
end
end
it "audits macOS-only casks under macOS when auditing all operating systems" do
macos_url_cask_file = tap_path/"Casks/macos-url-example.rb"
macos_url_cask_file.write <<~RUBY
cask "macos-url-example" do
version "1.0"
sha256 "0000000000000000000000000000000000000000000000000000000000000000"
on_macos do
url "https://example.invalid/x-\#{version}.pkg"
end
name "Example"
desc "macOS-only cask"
homepage "https://example.invalid/"
depends_on macos: :ventura
binary "x"
end
RUBY
allow(tap).to receive(:cask_files).and_return([macos_url_cask_file])
expect { described_class.new(["--tap=homebrew/test", "--os=all", "--arch=arm"]).run }
.not_to output.to_stdout
end
it "audits ARM-only casks under ARM when auditing Intel" do
arm_url_cask_file = tap_path/"Casks/arm-url-example.rb"
arm_url_cask_file.write <<~RUBY
cask "arm-url-example" do
version "1.0"
sha256 "0000000000000000000000000000000000000000000000000000000000000000"
on_arm do
url "https://example.invalid/x-\#{version}.tar.gz"
end
name "Example"
desc "ARM-only cask"
homepage "https://example.invalid/"
depends_on arch: :arm64
binary "x"
end
RUBY
allow(tap).to receive(:cask_files).and_return([arm_url_cask_file])
Homebrew::SimulateSystem.with(os: :macos, arch: :intel) do
expect { audit.run }.not_to output.to_stdout
end
end
it "audits casks on the architecture each operating system supports" do
os_arch_cask_file = tap_path/"Casks/os-arch-example.rb"
os_arch_cask_file.write <<~RUBY
cask "os-arch-example" do
version "1.0"
sha256 arm: "0000000000000000000000000000000000000000000000000000000000000000",
x86_64_linux: "1111111111111111111111111111111111111111111111111111111111111111"
url "https://example.invalid/x-\#{version}.tar.gz"
name "Example"
desc "macOS ARM and Linux Intel cask"
homepage "https://example.invalid/"
on_macos do
depends_on arch: :arm64
end
on_linux do
depends_on arch: :x86_64
end
binary "x"
end
RUBY
allow(tap).to receive(:cask_files).and_return([os_arch_cask_file])
expect { described_class.new(["--tap=homebrew/test", "--os=all", "--arch=intel"]).run }
.not_to output.to_stdout
end
it "audits Linux Intel for a cask that only requires macOS on ARM" do
arm_macos_cask_file = tap_path/"Casks/arm-macos-example.rb"
arm_macos_cask_file.write <<~RUBY
cask "arm-macos-example" do
version "1.0"
sha256 arm: "0000000000000000000000000000000000000000000000000000000000000000",
intel: "1111111111111111111111111111111111111111111111111111111111111111",
arm64_linux: "0000000000000000000000000000000000000000000000000000000000000000"
url "https://example.invalid/x-\#{version}.tar.gz"
name "Example"
desc "Cask without a Linux Intel checksum"
homepage "https://example.invalid/"
on_arm do
depends_on macos: :big_sur
end
binary "x"
end
RUBY
allow(tap).to receive(:cask_files).and_return([arm_macos_cask_file])
expect { described_class.new(["--tap=homebrew/test", "--os=all", "--arch=all"]).run }
.to output(/a sha256 stanza is required/).to_stdout
end
it "enables API access when auditing external formulae after it was automatically disabled" do
formula_file = tap_path/"Formula/example.rb"
formula_file.dirname.mkpath
@@ -22,8 +22,8 @@ RSpec.describe Homebrew::DevCmd::GenerateZap do
CoreCaskTap.instance.clear_cache
expect { brew "generate-zap", caskfile }
.to output(/Scanning for files matching.*No zap stanza required/m).to_stdout
.and output(/No files found matching/).to_stderr
.to output(/Scanning for files matching.*zap :not_required/m).to_stdout
.and output(/No files found matching.*Only use `zap :not_required` if the app has been launched/m).to_stderr
.and be_a_success
end
@@ -317,6 +317,16 @@ RSpec.describe RuboCop::Cop::Cask::UninstallMethodsOrder, :config do
CASK
end
end
it "does not report an offense when no zap is required" do
expect_no_offenses(<<~CASK)
cask "foo" do
url "https://example.com/foo.zip"
zap :not_required
end
CASK
end
end
context "with both uninstall and zap blocks" do
+9 -4
View File
@@ -126,7 +126,7 @@ An exception is `target:` which typically consists of short lines.
### Required stanzas
Each cask requires the identity and download stanzas below.
Submissions to `homebrew/cask` may require additional stanzas such as `livecheck`, `depends_on`, `uninstall` or `zap` when the package needs them.
New submissions to `homebrew/cask` also require a [`zap`](#stanza-zap) stanza and may require additional stanzas such as `livecheck`, `depends_on` or `uninstall` when the package needs them.
| name | multiple occurrences allowed? | value |
| ---------------------------------- | :---------------------------: | ----- |
@@ -211,7 +211,7 @@ Allowing writes to a directory does not allow connections to sockets in it.
| [`caveats`](#stanza-caveats) | yes | String or Ruby block providing the user with cask-specific information at install time. |
| [`livecheck`](#stanza-livecheck) | no | Ruby block describing how to find updates when automatic detection is insufficient. It is not used with `version :latest` unless the block uses `skip`. |
| [`depends_on`](#stanza-depends_on) | yes | Dependencies and operating-system or architecture requirements for this cask. |
| [`zap`](#stanza-zap) | no | Additional procedures for a more complete uninstall, including user files and shared resources. |
| [`zap`](#stanza-zap) | no | Additional procedures for a more complete uninstall, including user files and shared resources. Required for new casks in `homebrew/cask`. |
| [`deprecate!`](#stanza-deprecate--disable) | no | Date as a string in `YYYY-MM-DD` format and a string or symbol providing a reason. |
| [`disable!`](#stanza-deprecate--disable) | no | Date as a string in `YYYY-MM-DD` format and a string or symbol providing a reason. |
| `preflight_steps` | yes | Declarative file preparation steps run before artifact installation. |
@@ -1323,6 +1323,8 @@ brew uninstall --zap --force firefox
The form of the `zap` stanza follows the [`uninstall`](#stanza-uninstall) stanza. All the same directives are available. The `trash:` key is preferred over `delete:`.
Use `zap :not_required` instead of directives when there is nothing to zap (see [`zap` creation](#zap-creation)).
Example: [dropbox.rb](https://github.com/Homebrew/homebrew-cask/blob/974a55ade77bb4edc8bbb80ef72eec83ae0e76c0/Casks/d/dropbox.rb#L30-L68)
#### `zap` creation
@@ -1335,12 +1337,15 @@ Manual creation can be facilitated with:
* An uninstaller tool such as [AppCleaner](https://formulae.brew.sh/cask/appcleaner)
* Inspection of the usual paths, i.e. `/Library/{'Application Support',LaunchAgents,LaunchDaemons,Frameworks,Logs,Preferences,PrivilegedHelperTools}` and `~/Library/{'Application Support',Caches,Containers,LaunchAgents,Logs,Preferences,'Saved Application State'}`
If no additional files are discovered, instead of a zap stanza, include the following comment:
If no additional files are discovered, declare that nothing needs to be zapped instead:
```ruby
# No zap stanza required
zap :not_required
```
A cask that supports Linux and has its `zap` stanza inside an `on_macos` block also needs a `zap` stanza or `zap :not_required` inside its `on_linux` block.
`brew generate-zap` only scans macOS paths, so write a Linux `zap` stanza by hand.
## Conditional statements
### Handling different system configurations
+2 -2
View File
@@ -3524,8 +3524,8 @@ resolved from the cask's `app` artifact.
The target application should have been launched at least once so that
preference files and caches exist on disk.
Outputs `trash`, `delete`, and `rmdir` directives suitable for pasting into a
cask definition.
Outputs `trash`, `delete` and `rmdir` directives suitable for pasting into a
cask definition, or `zap :not_required` if nothing is found.
`--name`
+1 -1
View File
@@ -2217,7 +2217,7 @@ Accepts a cask token (e\.g\. \fBfirefox\fP) or, with \fB\-\-name\fP, a raw appli
.P
The target application should have been launched at least once so that preference files and caches exist on disk\.
.P
Outputs \fBtrash\fP, \fBdelete\fP, and \fBrmdir\fP directives suitable for pasting into a cask definition\.
Outputs \fBtrash\fP, \fBdelete\fP and \fBrmdir\fP directives suitable for pasting into a cask definition, or \fBzap :not_required\fP if nothing is found\.
.TP
\fB\-\-name\fP
Treat the argument as a raw application name instead of a cask token\.