diff --git a/Library/Homebrew/cask/cask.rb b/Library/Homebrew/cask/cask.rb index 3252cab748..0df140a958 100644 --- a/Library/Homebrew/cask/cask.rb +++ b/Library/Homebrew/cask/cask.rb @@ -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? && diff --git a/Library/Homebrew/cask/dsl.rb b/Library/Homebrew/cask/dsl.rb index 8b2b516bee..76cff2be9c 100644 --- a/Library/Homebrew/cask/dsl.rb +++ b/Library/Homebrew/cask/dsl.rb @@ -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 diff --git a/Library/Homebrew/cask/dsl/depends_on.rb b/Library/Homebrew/cask/dsl/depends_on.rb index 86aac01c9c..6f4e444ec5 100644 --- a/Library/Homebrew/cask/dsl/depends_on.rb +++ b/Library/Homebrew/cask/dsl/depends_on.rb @@ -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? diff --git a/Library/Homebrew/on_system.rb b/Library/Homebrew/on_system.rb index 1f1771d065..daccf5af48 100644 --- a/Library/Homebrew/on_system.rb +++ b/Library/Homebrew/on_system.rb @@ -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 diff --git a/Library/Homebrew/readall.rb b/Library/Homebrew/readall.rb index b9785bad71..22fe8914fe 100644 --- a/Library/Homebrew/readall.rb +++ b/Library/Homebrew/readall.rb @@ -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 diff --git a/Library/Homebrew/simulate_system.rb b/Library/Homebrew/simulate_system.rb index 9fb8c67dbe..3517418d4e 100644 --- a/Library/Homebrew/simulate_system.rb +++ b/Library/Homebrew/simulate_system.rb @@ -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) diff --git a/Library/Homebrew/sorbet/rbi/dsl/cask/cask.rbi b/Library/Homebrew/sorbet/rbi/dsl/cask/cask.rbi index 38343e962d..05043d698e 100644 --- a/Library/Homebrew/sorbet/rbi/dsl/cask/cask.rbi +++ b/Library/Homebrew/sorbet/rbi/dsl/cask/cask.rbi @@ -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 diff --git a/Library/Homebrew/test/cask/cask_spec.rb b/Library/Homebrew/test/cask/cask_spec.rb index d48ee98ffc..8b27af3d4f 100644 --- a/Library/Homebrew/test/cask/cask_spec.rb +++ b/Library/Homebrew/test/cask/cask_spec.rb @@ -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 diff --git a/Library/Homebrew/test/cask/dsl_spec.rb b/Library/Homebrew/test/cask/dsl_spec.rb index ab4180e18f..156ba27995 100644 --- a/Library/Homebrew/test/cask/dsl_spec.rb +++ b/Library/Homebrew/test/cask/dsl_spec.rb @@ -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 diff --git a/Library/Homebrew/test/cmd/readall_spec.rb b/Library/Homebrew/test/cmd/readall_spec.rb index b0ab304e12..f587114440 100644 --- a/Library/Homebrew/test/cmd/readall_spec.rb +++ b/Library/Homebrew/test/cmd/readall_spec.rb @@ -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 diff --git a/Library/Homebrew/test/simulate_system_spec.rb b/Library/Homebrew/test/simulate_system_spec.rb index 42ea512570..a53787f0a3 100644 --- a/Library/Homebrew/test/simulate_system_spec.rb +++ b/Library/Homebrew/test/simulate_system_spec.rb @@ -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 diff --git a/Library/Homebrew/test/support/fixtures/cask/Casks/on-linux-blocks.rb b/Library/Homebrew/test/support/fixtures/cask/Casks/on-linux-blocks.rb new file mode 100644 index 0000000000..34590cdba4 --- /dev/null +++ b/Library/Homebrew/test/support/fixtures/cask/Casks/on-linux-blocks.rb @@ -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 diff --git a/Library/Homebrew/test/support/helper/spec/shared_context/homebrew_cask.rb b/Library/Homebrew/test/support/helper/spec/shared_context/homebrew_cask.rb index c869c760f7..fa56302605 100644 --- a/Library/Homebrew/test/support/helper/spec/shared_context/homebrew_cask.rb +++ b/Library/Homebrew/test/support/helper/spec/shared_context/homebrew_cask.rb @@ -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",