Merge pull request #23431 from Homebrew/cask-os-stanza-fix

Fix cask Linux installs without `os` stanza
This commit is contained in:
Mike McQuaid
2026-08-04 19:01:20 +00:00
committed by GitHub
13 changed files with 248 additions and 42 deletions
+1 -1
View File
@@ -616,7 +616,7 @@ module Cask
if dsl!.on_system_blocks_exist?
begin
OnSystem::VALID_OS_ARCH_TAGS.each do |bottle_tag|
next if bottle_tag.linux? && dsl!.os.nil? && !dsl!.sha256_set_for_linux?
next if bottle_tag.linux? && dsl!.os.nil? && !dsl!.sha256_set_for_linux? && !dsl!.on_linux_blocks_exist?
macos_requirements = [depends_on.macos, depends_on.maximum_macos].compact
next if bottle_tag.macos? &&
+15 -3
View File
@@ -128,6 +128,7 @@ module Cask
:no_autobump_message,
:on_system_blocks_exist?,
:on_os_blocks_exist?,
:on_linux_blocks_exist?,
:on_system_block_min_os,
:depends_on_set_in_block?,
*ORDINARY_ARTIFACT_CLASSES.map(&:dsl_key),
@@ -197,6 +198,7 @@ module Cask
@auto_updates_set_in_block = T.let(false, T::Boolean)
@autobump = T.let(true, T::Boolean)
@called_in_on_system_block = T.let(false, T::Boolean)
@called_in_on_os_block = T.let(false, T::Boolean)
@cask = cask
@caveats = T.let(DSL::Caveats.new(cask), DSL::Caveats)
@conflicts_with = T.let(nil, T.nilable(DSL::ConflictsWith))
@@ -231,6 +233,7 @@ module Cask
@no_autobump_message = T.let(nil, T.nilable(T.any(String, Symbol)))
@on_system_blocks_exist = T.let(false, T::Boolean)
@on_os_blocks_exist = T.let(false, T::Boolean)
@on_linux_blocks_exist = T.let(false, T::Boolean)
@on_system_block_min_os = T.let(nil, T.nilable(MacOSVersion))
@os = T.let(nil, T.nilable(String))
@os_set_in_block = T.let(false, T::Boolean)
@@ -264,6 +267,9 @@ module Cask
sig { returns(T::Boolean) }
def on_os_blocks_exist? = @on_os_blocks_exist
sig { returns(T::Boolean) }
def on_linux_blocks_exist? = @on_linux_blocks_exist
sig { returns(T::Boolean) }
def sha256_set_for_linux? = @sha256_set_for_linux
@@ -570,13 +576,16 @@ module Cask
# running OS, matching `sha256` inside an `on_macos`/`on_linux` block;
# `depends_on` governs whether the cask is usable there. A checksum
# declared for the running OS but missing the running architecture
# still raises.
# still raises on the real system but is nil under simulation so
# API variations can be generated for the missing architecture.
running_os_checksums = if OnSystem.os_condition_met?(:linux)
[x86_64_linux, arm64_linux]
else
[arm, x86_64]
end
raise CaskInvalidError.new(cask, "invalid 'sha256' value: nil") if running_os_checksums.any?(&:present?)
if running_os_checksums.any?(&:present?) && !Homebrew::SimulateSystem.simulating?
raise CaskInvalidError.new(cask, "invalid 'sha256' value: nil")
end
nil
else
@@ -653,7 +662,10 @@ module Cask
return @depends_on if kwargs.empty?
begin
@depends_on.load(kwargs, set_in_block: @called_in_on_system_block)
# Only OS blocks scope a dependency to one OS: `on_arm`/`on_intel`
# blocks are evaluated on every OS, so a macOS dependency inside one
# applies everywhere and marks the cask macOS-only.
@depends_on.load(kwargs, set_in_block: @called_in_on_system_block, os_scoped: @called_in_on_os_block)
rescue RuntimeError => e
raise CaskInvalidError.new(cask, e)
end
+16 -11
View File
@@ -50,6 +50,7 @@ module Cask
@macos = T.let(nil, T.nilable(MacOSRequirement))
@maximum_macos = T.let(nil, T.nilable(MacOSRequirement))
@linux = T.let(nil, T.nilable(LinuxRequirement))
@macos_required = T.let(false, T::Boolean)
@macos_bare_set_top_level = T.let(false, T::Boolean)
@macos_version_set_top_level = T.let(false, T::Boolean)
@maximum_macos_set_top_level = T.let(false, T::Boolean)
@@ -70,9 +71,10 @@ module Cask
params(
pairs: T::Hash[Symbol, T.any(String, Symbol, T::Array[T.any(String, Symbol)])],
set_in_block: T::Boolean,
os_scoped: T::Boolean,
).void
}
def load(pairs, set_in_block: false)
def load(pairs, set_in_block: false, os_scoped: false)
pairs.each do |key, value|
raise "invalid depends_on key: '#{key.inspect}'" unless VALID_KEYS.include?(key)
@@ -83,7 +85,7 @@ module Cask
else
send(:"#{key}=", *value)
end
record_os_requirement(key, set_in_block:)
record_os_requirement(key, set_in_block:, os_scoped:)
next if key != :macos
next if value != :any
next unless previous_macos&.version_specified?
@@ -153,26 +155,24 @@ module Cask
def present? = !empty?
sig { returns(T::Boolean) }
def requires_macos?
@macos_bare_set_top_level || @macos_version_set_top_level || @maximum_macos_set_top_level
end
def requires_macos? = @macos_required
sig { returns(T::Boolean) }
def requires_linux? = @linux_set_top_level
sig { params(key: Symbol, set_in_block: T::Boolean).void }
def record_os_requirement(key, set_in_block:)
sig { params(key: Symbol, set_in_block: T::Boolean, os_scoped: T::Boolean).void }
def record_os_requirement(key, set_in_block:, os_scoped:)
case key
when :macos
macos = @macos
raise "invalid 'depends_on macos' value" unless macos
record_macos_requirement(macos, set_in_block:)
record_macos_requirement(macos, set_in_block:, os_scoped:)
when :maximum_macos
maximum_macos = @maximum_macos
raise "invalid 'depends_on maximum_macos' value" unless maximum_macos
record_macos_requirement(maximum_macos, set_in_block:)
record_macos_requirement(maximum_macos, set_in_block:, os_scoped:)
when :linux
return if set_in_block
raise "`depends_on :linux` cannot be combined with `depends_on macos:`" if requires_macos?
@@ -181,8 +181,13 @@ module Cask
end
end
sig { params(requirement: MacOSRequirement, set_in_block: T::Boolean).void }
def record_macos_requirement(requirement, set_in_block:)
sig { params(requirement: MacOSRequirement, set_in_block: T::Boolean, os_scoped: T::Boolean).void }
def record_macos_requirement(requirement, set_in_block:, os_scoped:)
# `on_arm`/`on_intel` blocks are evaluated on every OS, so a macOS
# dependency inside one applies everywhere; only an OS block scopes a
# dependency to macOS alone.
@macos_required = true unless os_scoped
return if set_in_block
raise "`depends_on :linux` cannot be combined with `depends_on macos:`" if requires_linux?
+8
View File
@@ -88,12 +88,15 @@ module OnSystem
base.define_method(:"on_#{base_os}") do |&block|
@on_system_blocks_exist = T.let(true, T.nilable(TrueClass))
@on_os_blocks_exist = T.let(true, T.nilable(TrueClass))
@on_linux_blocks_exist = T.let(true, T.nilable(TrueClass)) if base_os == :linux
return unless OnSystem.os_condition_met? OnSystem.condition_from_method_name(T.must(__method__))
@called_in_on_system_block = true
@called_in_on_os_block = T.let(true, T.nilable(T::Boolean))
result = block.call
@called_in_on_system_block = false
@called_in_on_os_block = false
result
end
@@ -102,6 +105,7 @@ module OnSystem
base.define_method(:on_system) do |linux, macos:, &block|
@on_system_blocks_exist = T.let(true, T.nilable(TrueClass))
@on_os_blocks_exist = T.let(true, T.nilable(TrueClass))
@on_linux_blocks_exist = T.let(true, T.nilable(TrueClass))
raise ArgumentError, "The first argument to `on_system` must be `:linux`" if linux != :linux
@@ -113,8 +117,10 @@ module OnSystem
return if !OnSystem.os_condition_met?(os_version, or_condition) && !OnSystem.os_condition_met?(:linux)
@called_in_on_system_block = true
@called_in_on_os_block = T.let(true, T.nilable(T::Boolean))
result = block.call
@called_in_on_system_block = false
@called_in_on_os_block = false
result
end
@@ -149,8 +155,10 @@ module OnSystem
T.nilable(MacOSVersion),
)
@called_in_on_system_block = T.let(true, T.nilable(T::Boolean))
@called_in_on_os_block = T.let(true, T.nilable(T::Boolean))
result = block.call
@called_in_on_system_block = false
@called_in_on_os_block = false
result
end
+15 -13
View File
@@ -129,8 +129,6 @@ module Readall
success = T.let(true, T::Boolean)
(files || tap.cask_files).each do |file|
next if file.read.match?(/^\s*depends_on(?:\s*\(\s*|\s+)(?::macos\b|macos:)/)
cask = if arch
Homebrew::SimulateSystem.with(os: :macos, arch:) do
loaded_cask = Cask::CaskLoader.load(file)
@@ -144,24 +142,28 @@ module Readall
end
next unless cask
linux_sha256 = if arch
Homebrew::SimulateSystem.with(os: :linux, arch:) do
cask.refresh
cask.sha256
end
check_linux_sha256 = lambda do
cask.refresh
arch_types = cask.depends_on.arch&.map { |cask_arch| cask_arch[:type] }
# `depends_on arch:` excludes this architecture, so no Linux
# checksum is expected for it.
next true if arch_types&.exclude?(Homebrew::SimulateSystem.current_arch)
!cask.sha256.nil?
end
linux_sha256_valid = if arch
Homebrew::SimulateSystem.with(os: :linux, arch:, &check_linux_sha256)
else
Homebrew::SimulateSystem.with(os: :linux) do
cask.refresh
cask.sha256
end
Homebrew::SimulateSystem.with(os: :linux, &check_linux_sha256)
end
# No `sha256` matched Linux, so the cask cannot be downloaded there
# despite not being marked macOS-only.
next unless linux_sha256.nil?
next if linux_sha256_valid
onoe "Invalid cask (#{os_and_arch}): #{file}"
$stderr.puts "Missing Linux stanzas can leave Linux `sha256` as nil. " \
"Add `depends_on :macos` if this cask is macOS-only."
"Add `depends_on :macos` if this cask is macOS-only or " \
"`depends_on arch:` if it does not support this architecture."
success = false
rescue Interrupt
raise
+5
View File
@@ -76,6 +76,11 @@ module Homebrew
@os = @arch = nil
end
sig { returns(T::Boolean) }
def simulating?
os.present? || arch.present?
end
sig { returns(T::Boolean) }
def simulating_or_running_on_macos?
[:macos, *MacOSVersion::SYMBOLS.keys].include?(os)
+3
View File
@@ -159,6 +159,9 @@ class Cask::Cask
sig { params(args: T.untyped, block: T.untyped).returns(T.untyped) }
def no_autobump_message(*args, &block); end
sig { params(args: T.untyped, block: T.untyped).returns(T::Boolean) }
def on_linux_blocks_exist?(*args, &block); end
sig { params(args: T.untyped, block: T.untyped).returns(T::Boolean) }
def on_os_blocks_exist?(*args, &block); end
+43 -6
View File
@@ -701,9 +701,22 @@ RSpec.describe Cask::Cask, :cask do
expect(cask.refresh_for_tag(tag) { cask.url.to_s }).to include("caffeine-intel-darwin")
end
it "returns nil for a tag the cask does not support" do
it "yields for a Linux architecture whose checksum is missing" do
tag = Utils::Bottles::Tag.new(system: :linux, arch: :arm)
expect(cask.refresh_for_tag(tag) { cask.url }).to be_nil
expect(cask.refresh_for_tag(tag) { cask.url.to_s }).to include("caffeine-arm-linux")
end
it "returns nil for a tag the cask cannot be refreshed for" do
invalid_on_linux_cask = described_class.new("on-linux-invalid") do
on_macos do
version "1.2.3"
end
sha256 :no_check
url "https://brew.sh/foo-#{version.major_minor}.zip"
end
tag = Utils::Bottles::Tag.new(system: :linux, arch: :arm)
expect(invalid_on_linux_cask.refresh_for_tag(tag) { invalid_on_linux_cask.url }).to be_nil
end
end
@@ -900,12 +913,14 @@ RSpec.describe Cask::Cask, :cask do
expect(JSON.pretty_generate(h["variations"])).to eq expected_sha256_variations_os.strip
end
it "omits tags a cask intentionally doesn't define in on_system blocks" do
it "emits variations without checksums for Linux architectures a cask omits" do
c = Cask::CaskLoader.load("on-linux-asymmetric")
h = c.to_hash_with_variations
h = JSON.parse(JSON.generate(c.to_hash_with_variations))
expect(h["variations"]).to include(:x86_64_linux)
expect(h["variations"]).not_to include(:arm64_linux)
expect(h["variations"]["arm64_linux"]).to include(
"depends_on" => { "arch" => [{ "type" => "intel", "bits" => 64 }] },
"sha256" => nil,
)
end
it "emits Linux variations for a cask with Linux checksums but no `os` stanza" do
@@ -925,6 +940,28 @@ RSpec.describe Cask::Cask, :cask do
)
end
it "emits Linux variations for a cask with `on_linux` content but no `os` stanza" do
c = Cask::CaskLoader.load("on-linux-blocks")
h = JSON.parse(JSON.generate(c.to_hash_with_variations))
app_image_artifacts = [{
"app_image" => ["Caffeine.AppImage"],
"target" => "#{TEST_TMPDIR}/cask-appimagedir/Caffeine.AppImage",
}]
expect(h["variations"].slice("x86_64_linux", "arm64_linux").transform_values do |v|
v.slice("sha256", "artifacts")
end).to eq(
"x86_64_linux" => {
"sha256" => "244d413861cecb3707cfbcc5c4346d5367daa827da5ea08fb3f3bc2b6276d239",
"artifacts" => app_image_artifacts,
},
"arm64_linux" => {
"sha256" => "9a1c0967baa46828930ccbbc88668d1b0db07e6edf778800ed4da073c00054f8",
"artifacts" => app_image_artifacts,
},
)
end
# NOTE: The calls to `Cask.generating_hash!` and `Cask.generated_hash!`
# are not idempotent so they can only be used in one test.
it "returns the correct hash placeholders" do
+26 -7
View File
@@ -185,17 +185,19 @@ RSpec.describe Cask::DSL, :cask, :no_api do
end
end
it "raises when the running-architecture macOS checksum is missing" do
it "has no checksum when simulating an architecture whose checksum is missing" do
Homebrew::SimulateSystem.with(os: :macos, arch: :intel) do
expect do
Cask::Cask.new("checksum-cask") do
sha256 arm: "imasha2arm", arm64_linux: "imasha2armlinux"
end
end.to raise_error(Cask::CaskInvalidError, /invalid 'sha256' value/)
cask = Cask::Cask.new("checksum-cask") do
sha256 arm: "imasha2arm", arm64_linux: "imasha2armlinux"
end
expect(cask.sha256).to be_nil
end
end
it "raises when the running-architecture Linux checksum is missing" do
it "raises on the real system when the running-architecture checksum is missing" do
allow(Homebrew::SimulateSystem).to receive(:simulating?).and_return(false)
Homebrew::SimulateSystem.with(os: :linux, arch: :intel) do
expect do
Cask::Cask.new("checksum-cask") do
@@ -580,6 +582,23 @@ RSpec.describe Cask::DSL, :cask, :no_api do
end
end
end
context "when only an arch block declares the macOS version" do
it "requires macOS because arch blocks are evaluated on every OS" do
Homebrew::SimulateSystem.with(os: :linux, arch: :arm) do
cask = Cask::Cask.new("with-arch-scoped-macos-version") do
on_arm do
depends_on macos: :ventura
end
on_intel do
depends_on macos: :monterey
end
end
expect(cask.depends_on.requires_macos?).to be true
end
end
end
end
describe "depends_on linux" do
+73 -1
View File
@@ -35,7 +35,7 @@ RSpec.describe Homebrew::Cmd::ReadallCmd do
name "Example"
desc "macOS-only cask"
homepage "https://example.invalid/"
depends_on macos: ">= :ventura"
depends_on macos: :ventura
binary "x"
end
RUBY
@@ -170,4 +170,76 @@ RSpec.describe Homebrew::Cmd::ReadallCmd do
expect(success).to be false
end
it "reports Linux architectures missing a checksum despite an `on_macos` macOS dependency" do
tap_path = mktmpdir
cross_os_cask_file = tap_path/"Casks/cross-os-example.rb"
cross_os_cask_file.dirname.mkpath
cross_os_cask_file.write <<~RUBY
cask "cross-os-example" do
version "1.0"
sha256 arm: "0000000000000000000000000000000000000000000000000000000000000000",
intel: "1111111111111111111111111111111111111111111111111111111111111111",
x86_64_linux: "2222222222222222222222222222222222222222222222222222222222222222"
url "https://example.invalid/x.tar.gz"
name "Example"
desc "Cross-OS cask"
homepage "https://example.invalid/"
on_macos do
depends_on macos: :ventura
end
binary "x"
end
RUBY
success = T.let(false, T::Boolean)
expect do
success = Homebrew::SimulateSystem.with(os: :linux) do
Readall.valid_tap?(
instance_double(Tap, formula_files: [], cask_files: [cross_os_cask_file]),
os_arch_combinations: [[:linux, :arm]],
)
end
end.to output(/Missing Linux stanzas/).to_stderr
expect(success).to be false
end
it "allows Linux architectures excluded by `depends_on arch:`" do
tap_path = mktmpdir
linux_intel_cask_file = tap_path/"Casks/linux-intel-example.rb"
linux_intel_cask_file.dirname.mkpath
linux_intel_cask_file.write <<~RUBY
cask "linux-intel-example" do
version "1.0"
sha256 arm: "0000000000000000000000000000000000000000000000000000000000000000",
intel: "1111111111111111111111111111111111111111111111111111111111111111",
x86_64_linux: "2222222222222222222222222222222222222222222222222222222222222222"
url "https://example.invalid/x.tar.gz"
name "Example"
desc "Intel-only-on-Linux cask"
homepage "https://example.invalid/"
on_linux do
depends_on arch: :x86_64
end
binary "x"
end
RUBY
success = T.let(false, T::Boolean)
expect do
success = Homebrew::SimulateSystem.with(os: :linux) do
Readall.valid_tap?(
instance_double(Tap, formula_files: [], cask_files: [linux_intel_cask_file]),
os_arch_combinations: [[:linux, :arm], [:linux, :intel]],
)
end
end.not_to output.to_stderr
expect(success).to be true
end
end
@@ -80,6 +80,25 @@ RSpec.describe Homebrew::SimulateSystem do
end
end
describe "::simulating?" do
it "returns false without any simulation" do
described_class.clear
expect(described_class.simulating?).to be false
end
it "returns true when simulating an OS" do
described_class.clear
described_class.os = :linux
expect(described_class.simulating?).to be true
end
it "returns true when simulating an architecture" do
described_class.clear
described_class.arch = :arm
expect(described_class.simulating?).to be true
end
end
describe "::current_arch" do
it "returns the current architecture" do
described_class.clear
@@ -0,0 +1,23 @@
# typed: false
cask "on-linux-blocks" do
version "1.2.3"
on_macos do
sha256 "67cdb8a02803ef37fdbf7e0be205863172e41a561ca446cd84f0d7ab35a99d94"
url "file://#{TEST_FIXTURE_DIR}/cask/caffeine.zip"
app "Caffeine.app"
end
on_linux do
sha256 x86_64_linux: "244d413861cecb3707cfbcc5c4346d5367daa827da5ea08fb3f3bc2b6276d239",
arm64_linux: "9a1c0967baa46828930ccbbc88668d1b0db07e6edf778800ed4da073c00054f8"
url "file://#{TEST_FIXTURE_DIR}/cask/caffeine-linux.zip"
app_image "Caffeine.AppImage"
end
homepage "https://brew.sh/"
end
@@ -11,6 +11,7 @@ module Cask
class Config
DEFAULT_DIRS_PATHNAMES = {
appdir: Pathname(TEST_TMPDIR)/"cask-appdir",
appimagedir: Pathname(TEST_TMPDIR)/"cask-appimagedir",
keyboard_layoutdir: Pathname(TEST_TMPDIR)/"cask-keyboard-layoutdir",
prefpanedir: Pathname(TEST_TMPDIR)/"cask-prefpanedir",
qlplugindir: Pathname(TEST_TMPDIR)/"cask-qlplugindir",