From 0a6575c767e1e6f9870b63a7389cada42b987aea Mon Sep 17 00:00:00 2001 From: Michka Popoff Date: Tue, 28 Jul 2026 22:17:46 +0200 Subject: [PATCH 01/10] Build and publish large runner docker image --- .github/scripts/Dockerfile.long_runner | 20 ++++ .github/scripts/startup_long_runner.sh | 60 ++++++++++ .github/workflows/docker.yml | 154 ++++++++++++++++++++++++- Library/Homebrew/style.rb | 1 + 4 files changed, 233 insertions(+), 2 deletions(-) create mode 100644 .github/scripts/Dockerfile.long_runner create mode 100644 .github/scripts/startup_long_runner.sh diff --git a/.github/scripts/Dockerfile.long_runner b/.github/scripts/Dockerfile.long_runner new file mode 100644 index 0000000000..1a7fde0684 --- /dev/null +++ b/.github/scripts/Dockerfile.long_runner @@ -0,0 +1,20 @@ +ARG HOMEBREW_BREW_IMAGE=ghcr.io/homebrew/brew:main + +FROM ghcr.io/actions/actions-runner:latest AS runner-source + +FROM ${HOMEBREW_BREW_IMAGE} + +COPY --from=runner-source --chown=linuxbrew:linuxbrew /home/runner/ /home/linuxbrew/actions-runner/ + +COPY --chown=linuxbrew:linuxbrew .github/scripts/startup_long_runner.sh /home/linuxbrew/startup_long_runner.sh +RUN chmod +x /home/linuxbrew/startup_long_runner.sh + +# Must be set when running the container (e.g., via `docker run -e RUNNER_JITCONFIG=...`) +# This caries the GitHub configuration the runner needs to start, so we can't set it to any meaningful default. +# This line ensures that it is set. +ENV RUNNER_JITCONFIG= + +# Do not let .NET load icu to avoid polluting the image +ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 + +ENTRYPOINT ["/home/linuxbrew/startup_long_runner.sh"] diff --git a/.github/scripts/startup_long_runner.sh b/.github/scripts/startup_long_runner.sh new file mode 100644 index 0000000000..e641fb9c13 --- /dev/null +++ b/.github/scripts/startup_long_runner.sh @@ -0,0 +1,60 @@ +#!/bin/bash +# Validates runtime context, configures the GitHub Actions long runner from RUNNER_JITCONFIG, then starts it. +set -euo pipefail + +RUNNER_DIRECTORY="${HOME}/actions-runner" + +if [[ "$(id -un)" != "linuxbrew" ]] +then + echo "Startup script run as incorrect user." >&2 + exit 1 +fi + +if [[ -f "${RUNNER_DIRECTORY}/.runner" ]] +then + echo "Runner already configured, starting..." + exec "${RUNNER_DIRECTORY}/run.sh" +fi + +if [[ -z "${RUNNER_JITCONFIG:-}" ]] +then + echo "Not configuring runner: missing RUNNER_JITCONFIG." >&2 + exit 1 +fi + +echo "Configuring long runner..." + +cd "${RUNNER_DIRECTORY}" + +jitconfig_json=$(printf '%s' "${RUNNER_JITCONFIG}" | base64 -d) || { + echo "Failed to decode RUNNER_JITCONFIG: invalid base64 data." >&2 + exit 1 +} + +if ! jq -e 'type == "object"' <<<"${jitconfig_json}" >/dev/null +then + echo "Failed to parse RUNNER_JITCONFIG: expected a JSON object." >&2 + exit 1 +fi + +config_files=$(jq -er 'keys[]' <<<"${jitconfig_json}") || { + echo "Failed to parse RUNNER_JITCONFIG: could not list config keys." >&2 + exit 1 +} + +while IFS= read -r config_file +do + if [[ "${config_file}" != "$(basename "${config_file}")" || "${config_file:0:1}" != "." ]] + then + echo "Invalid file name in JIT config: ${config_file}" >&2 + continue + fi + + jq -er --arg key "${config_file}" '.[$key]' <<<"${jitconfig_json}" | base64 -d | install -m 600 /dev/stdin "${config_file}" +done <<<"${config_files}" + +echo "GITHUB_ACTIONS_HOMEBREW_SELF_HOSTED=1" >.env + +echo "Runner configured." + +exec ./run.sh diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index cfc581a31d..710b81e76f 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -227,7 +227,6 @@ jobs: outputs: type=image,name=ghcr.io/homebrew/ubuntu${{ matrix.version }},name-canonical=true,push=true,push-by-digest=true - name: Export the Docker image digest - if: fromJSON(steps.attributes.outputs.push) run: | mkdir -p "${RUNNER_TEMP}"/digests echo "${DIGEST#sha256:}" >"${RUNNER_TEMP}/digests/${VERSION}-${ARCH}" @@ -237,12 +236,30 @@ jobs: ARCH: ${{ matrix.arch }} - name: Upload the Docker image digest - if: fromJSON(steps.attributes.outputs.push) uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: digest-${{ matrix.version }}-${{ matrix.arch }} path: ${{ runner.temp }}/digests/* + - name: Export base image for long runner + if: matrix.version == '24.04' + run: | + mkdir -p "${RUNNER_TEMP}/long-runner-base" + image_name="long-runner-base:${VERSION}-${ARCH}" + docker tag brew "${image_name}" + docker save "${image_name}" | gzip >"${RUNNER_TEMP}/long-runner-base/image-${VERSION}-${ARCH}.tar.gz" + env: + VERSION: ${{ matrix.version }} + ARCH: ${{ matrix.arch }} + DIGEST: ${{ steps.digest.outputs.digest }} + + - name: Upload base image artifact for long runner + if: matrix.version == '24.04' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: long-runner-base-24.04-${{ matrix.arch }} + path: ${{ runner.temp }}/long-runner-base/* + merge: needs: [generate-tags, build] if: github.repository_owner == 'Homebrew' && fromJSON(needs.generate-tags.outputs.merge) @@ -298,3 +315,136 @@ jobs: echo "Push failed (attempt $attempts). Retrying in ${delay} seconds..." sleep ${delay} done + + build-and-publish-long-runner: + name: Build runner Docker image (${{ matrix.platform }}) + needs: [generate-tags, build] + if: github.repository_owner == 'Homebrew' + runs-on: ${{ matrix.runner }} + permissions: + contents: read + id-token: write + strategy: + fail-fast: false + matrix: + platform: + - linux/amd64 + - linux/arm64 + include: + - platform: linux/amd64 + platform_tag: linux-amd64 + arch: x86_64 + runner: ubuntu-24.04 + - platform: linux/arm64 + platform_tag: linux-arm64 + arch: arm64 + runner: ubuntu-24.04-arm + steps: + - env: + IMAGE_VALUE: ghcr.io/${{ github.repository_owner }}/runner + run: echo "IMAGE=${IMAGE_VALUE,,}" >> "$GITHUB_ENV" + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Retrieve build attributes + id: attributes + env: + VERSION: 24.04 + PUSH: ${{ needs.generate-tags.outputs.push }} + run: | + filter="$(printf '.["%s"]' "${VERSION}")" + echo "push=$(jq --raw-output "${filter}" <<<"${PUSH}")" >>"${GITHUB_OUTPUT}" + + - name: Download long runner base image artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: long-runner-base-24.04-${{ matrix.arch }} + path: ${{ runner.temp }}/long-runner-base + + - name: Load long runner base image + id: base_image + run: | + artifact_dir="${{ runner.temp }}/long-runner-base" + image_archive="${artifact_dir}/image-24.04-${ARCH}.tar.gz" + [[ -f "${image_archive}" ]] || { + echo "Missing brew image archive: ${image_archive}" >&2 + exit 1 + } + base_image="long-runner-base:24.04-${ARCH}" + gunzip --stdout "${image_archive}" | docker load + docker image inspect "${base_image}" >/dev/null + echo "image=${base_image}" >> "$GITHUB_OUTPUT" + env: + ARCH: ${{ matrix.arch }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + with: + # Use the local Docker daemon so Buildx can resolve + # the base image loaded from the artifact tarball. + driver: docker + + - name: Build ${{ matrix.platform }} runner Docker image + id: runner_build + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: . + file: .github/scripts/Dockerfile.long_runner + platforms: ${{ matrix.platform }} + build-args: HOMEBREW_BREW_IMAGE=${{ steps.base_image.outputs.image }} + tags: runner:build-${{ matrix.platform_tag }} + + - name: Verify AWS deployment secrets + if: fromJSON(steps.attributes.outputs.push) + shell: bash + env: + AWS_PROJECT_ID: ${{ secrets.AWS_PROJECT_ID }} + run: | + missing=() + [ -z "$AWS_PROJECT_ID" ] && missing+=("AWS_PROJECT_ID") + if [ "${#missing[@]}" -ne 0 ]; then + printf 'Missing required deployment secrets: %s\n' "${missing[*]}" >&2 + exit 1 + fi + + - name: Configure AWS Credentials + if: fromJSON(steps.attributes.outputs.push) + id: aws_registry_credentials + uses: aws-actions/configure-aws-credentials@517a711dbcd0e402f90c77e7e2f81e849156e31d # v6.2.2 + with: + role-to-assume: arn:aws:iam::${{ secrets.AWS_PROJECT_ID }}:role/GithubActionsRoleECRPush + aws-region: us-east-1 + + - name: AWS Elastic Container Registry Login + if: fromJSON(steps.attributes.outputs.push) + id: aws_registry_login + uses: aws-actions/amazon-ecr-login@d539f0932e70871a027e9d5a9d8fc38589180a64 # v2.1.6 + + - name: Generate AWS image name + id: aws_image + env: + AWS_REGISTRY: ${{ steps.aws_registry_login.outputs.registry }} + LONG_RUNNER_IMAGE_DIGEST: ${{ steps.runner_build.outputs.digest }} + run: | + digest_without_prefix="${LONG_RUNNER_IMAGE_DIGEST#sha256:}" + [[ -n "${digest_without_prefix}" ]] || { + echo "Missing long runner image digest from build output" >&2 + exit 1 + } + echo "image=${AWS_REGISTRY}/runner-ecr:${digest_without_prefix}-${GITHUB_SHA}" >> "$GITHUB_OUTPUT" + echo "image_latest=${AWS_REGISTRY}/runner-ecr:latest" >> "$GITHUB_OUTPUT" + + - name: Push image to AWS Elastic Container Registry + if: fromJSON(steps.attributes.outputs.push) + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: . + file: .github/scripts/Dockerfile.long_runner + platforms: ${{ matrix.platform }} + build-args: HOMEBREW_BREW_IMAGE=${{ steps.base_image.outputs.image }} + push: true + tags: | + ${{ steps.aws_image.outputs.image }} + ${{ steps.aws_image.outputs.image_latest }} diff --git a/Library/Homebrew/style.rb b/Library/Homebrew/style.rb index f4d85e1620..e3612ee65e 100644 --- a/Library/Homebrew/style.rb +++ b/Library/Homebrew/style.rb @@ -472,6 +472,7 @@ module Homebrew HOMEBREW_REPOSITORY/"completions/bash/brew", HOMEBREW_REPOSITORY/"Dockerfile", *HOMEBREW_REPOSITORY.glob(".devcontainer/**/*.sh"), + *HOMEBREW_REPOSITORY.glob(".github/scripts/*.sh"), *HOMEBREW_REPOSITORY.glob("package/scripts/*"), *HOMEBREW_LIBRARY.glob("Homebrew/**/*.sh").reject { |path| path.to_s.include?("/vendor/") }, *HOMEBREW_LIBRARY.glob("Homebrew/shims/**/*").map(&:realpath).uniq From 169a254416315ef27b7a4241c067757a60151e45 Mon Sep 17 00:00:00 2001 From: Mike McQuaid Date: Tue, 11 Aug 2026 08:22:13 +0100 Subject: [PATCH 02/10] Report skipped dependent upgrades `brew upgrade` discovers outdated reverse dependants before it upgrades the formulae selected for primary upgrade. This candidate list is also needed for the confirmation prompt. After the primary upgrades finish, Homebrew prepares installers for the candidate dependants. An installer is filtered when installed runtime dependencies already satisfy target bottle metadata. A candidate may also already have been upgraded as a dependency of another primary formula. The execution heading and final summary used the earlier candidate list. They therefore claimed a filtered dependant was upgraded although its installed version remained unchanged. Candidates removed because `FormulaInstaller.installed` contained them were also treated as unsuccessful even when dependency installation had upgraded them. - Build dependant installers before printing the execution-time list. - Preserve installed candidates unless the primary list represents them. - Merge those candidates with dependants upgraded by their installers. - State the exact bottle-metadata condition when filtering a candidate. - Cover filtered, dependency-installed and primary candidates in tests. Fixes #23483 --- Library/Homebrew/cmd/upgrade.rb | 20 ++++--- Library/Homebrew/test/cmd/upgrade_spec.rb | 70 ++++++++++++++++++++++- Library/Homebrew/test/upgrade_spec.rb | 52 +++++++++++++++++ Library/Homebrew/upgrade.rb | 67 +++++++++++++--------- 4 files changed, 170 insertions(+), 39 deletions(-) diff --git a/Library/Homebrew/cmd/upgrade.rb b/Library/Homebrew/cmd/upgrade.rb index 41261c0f78..37d0014b69 100644 --- a/Library/Homebrew/cmd/upgrade.rb +++ b/Library/Homebrew/cmd/upgrade.rb @@ -636,7 +636,8 @@ module Homebrew formula_version_changes = formula_upgrade_descriptions(context.formulae_installer.map(&:formula), include_sizes: dry_run) - dependent_version_changes = formula_upgrade_descriptions(context.dependants.upgradeable, + dependent_formulae = context.dependants.upgradeable.dup + dependent_version_changes = formula_upgrade_descriptions(dependent_formulae, include_sizes: dry_run) if dry_run record_formula_upgrade_summary(context, @@ -663,7 +664,7 @@ module Homebrew skip_formula_names:, ) - Upgrade.upgrade_dependents( + upgraded_dependent_formulae = Upgrade.upgrade_dependents( context.dependants, context.formulae_to_install, flags: args.flags_only, dry_run:, @@ -680,17 +681,18 @@ module Homebrew ) unless dry_run - upgraded_formula_installers_by_identity = T.let({}.compare_by_identity, - T::Hash[FormulaInstaller, T::Boolean]) - upgraded_formula_installers.each do |formula_installer| - upgraded_formula_installers_by_identity[formula_installer] = true + upgraded_formulae_by_identity = T.let({}.compare_by_identity, T::Hash[Formula, T::Boolean]) + (upgraded_formula_installers.map(&:formula) + upgraded_dependent_formulae).each do |formula| + upgraded_formulae_by_identity[formula] = true end + planned_formulae = context.formulae_installer.map(&:formula) + dependent_formulae + version_changes = formula_version_changes + dependent_version_changes record_formula_upgrade_summary( context, formulae_installer: upgraded_formula_installers, - version_changes: context.formulae_installer.each_with_index.filter_map do |formula_installer, index| - formula_version_changes.fetch(index) if upgraded_formula_installers_by_identity.key?(formula_installer) - end + dependent_version_changes, + version_changes: planned_formulae.each_with_index.filter_map do |formula, index| + version_changes.fetch(index) if upgraded_formulae_by_identity.key?(formula) + end, ) end diff --git a/Library/Homebrew/test/cmd/upgrade_spec.rb b/Library/Homebrew/test/cmd/upgrade_spec.rb index aa9535fdf6..fca1ac59af 100644 --- a/Library/Homebrew/test/cmd/upgrade_spec.rb +++ b/Library/Homebrew/test/cmd/upgrade_spec.rb @@ -687,6 +687,25 @@ RSpec.describe Homebrew::Cmd::UpgradeCmd do end end + it "does not claim to upgrade dependents whose runtime dependencies are satisfied" do + formula = formula("sqlite") do + T.bind(self, T.class_of(Formula)) + url "https://brew.sh/sqlite-3.53.2.tar.gz" + end + dependent = formula("python@3.14") do + T.bind(self, T.class_of(Formula)) + url "https://brew.sh/python@3.14-3.14.5.tar.gz" + end + dependants = Homebrew::Upgrade::Dependents.new(upgradeable: [dependent], pinned: [], skipped: []) + + allow(Homebrew::Upgrade).to receive(:formula_installers).and_return([]) + allow(FormulaInstaller).to receive(:installed).and_return([]) + + expect do + Homebrew::Upgrade.upgrade_dependents(dependants, [formula], flags: []) + end.not_to output(/Upgrading.*python@3\.14/m).to_stdout + end + it "does not print aggregate package sizes" do cmd = described_class.new(["--dry-run"]) summary = Homebrew::Cmd::UpgradeCmd::FinalUpgradeSummary.new( @@ -1200,7 +1219,7 @@ RSpec.describe Homebrew::Cmd::UpgradeCmd do allow(formula).to receive(:opt_prefix).and_return(new_keg) [formula_installer] end - allow(Homebrew::Upgrade).to receive(:upgrade_dependents) + allow(Homebrew::Upgrade).to receive(:upgrade_dependents).and_return([]) cmd.upgrade_outdated_formulae!([]) @@ -1234,8 +1253,8 @@ RSpec.describe Homebrew::Cmd::UpgradeCmd do dependants: Homebrew::Upgrade::Dependents.new(upgradeable: [], pinned: [], skipped: []), ), ) - allow(Homebrew::Upgrade).to receive(:upgrade_formulae).and_return([successful_formula_installer]) - allow(Homebrew::Upgrade).to receive(:upgrade_dependents) + allow(Homebrew::Upgrade).to receive_messages(upgrade_formulae: [successful_formula_installer], + upgrade_dependents: []) cmd.upgrade_outdated_formulae!([]) @@ -1245,5 +1264,50 @@ RSpec.describe Homebrew::Cmd::UpgradeCmd do ) end + it "reports only successful dependent version changes in the final summary" do + formula = formula("testball") do + T.bind(self, T.class_of(Formula)) + url "https://brew.sh/testball-0.2" + end + upgraded_dependent = formula("upgraded-dependent") do + T.bind(self, T.class_of(Formula)) + url "https://brew.sh/upgraded-dependent-0.2" + end + skipped_dependent = formula("skipped-dependent") do + T.bind(self, T.class_of(Formula)) + url "https://brew.sh/skipped-dependent-0.2" + end + formula_installer = FormulaInstaller.new(formula) + old_formula_keg = HOMEBREW_CELLAR/"testball/0.1" + old_upgraded_dependent_keg = HOMEBREW_CELLAR/"upgraded-dependent/0.1" + old_skipped_dependent_keg = HOMEBREW_CELLAR/"skipped-dependent/0.1" + old_formula_keg.mkpath + old_upgraded_dependent_keg.mkpath + old_skipped_dependent_keg.mkpath + allow(formula).to receive_messages(optlinked?: true, opt_prefix: old_formula_keg) + allow(upgraded_dependent).to receive_messages(optlinked?: true, opt_prefix: old_upgraded_dependent_keg) + allow(skipped_dependent).to receive_messages(optlinked?: true, opt_prefix: old_skipped_dependent_keg) + cmd = described_class.new([]) + + allow(cmd).to receive(:formulae_upgrade_context).and_return( + Homebrew::Cmd::UpgradeCmd::FormulaeUpgradeContext.new( + formulae_to_install: [formula], + formulae_installer: [formula_installer], + dependants: Homebrew::Upgrade::Dependents.new( + upgradeable: [upgraded_dependent, skipped_dependent], pinned: [], skipped: [], + ), + ), + ) + allow(Homebrew::Upgrade).to receive_messages( + upgrade_formulae: [formula_installer], + upgrade_dependents: [upgraded_dependent], + ) + + cmd.upgrade_outdated_formulae!([]) + + expect(cmd.final_upgrade_summary.version_changes) + .to contain_exactly("testball 0.1 -> 0.2", "upgraded-dependent 0.1 -> 0.2") + end + it_behaves_like "reinstall_pkgconf_if_needed" end diff --git a/Library/Homebrew/test/upgrade_spec.rb b/Library/Homebrew/test/upgrade_spec.rb index 0b29c52dce..424a0109db 100644 --- a/Library/Homebrew/test/upgrade_spec.rb +++ b/Library/Homebrew/test/upgrade_spec.rb @@ -70,4 +70,56 @@ RSpec.describe Homebrew::Upgrade do .to output(/Would upgrade.*python@3.14 3.7.1 -> 3.14.6/m).to_stdout end end + + describe "::formula_installers" do + it "explains when installed dependencies satisfy the bottle metadata" do + dependent = formula("dependent") do + T.bind(self, T.class_of(Formula)) + url "https://brew.sh/dependent-2.0" + end + formula_installer = instance_double( + FormulaInstaller, + bottle_tab_runtime_dependencies: { "dependency" => { "version" => "2.0", "revision" => "0" } }, + determine_bottle_tab_attributes: nil, + fetch_bottle_tab: nil, + formula: dependent, + ) + dependency = instance_double(Dependency) + download_queue = instance_double(Homebrew::DownloadQueue, fetch: nil, shutdown: nil) + + allow(Migrator).to receive(:migrate_if_needed) + allow(described_class).to receive(:create_formula_installer).and_return(formula_installer) + allow(Homebrew::DownloadQueue).to receive(:new).and_return(download_queue) + allow(Dependency).to receive(:new).with("dependency").and_return(dependency) + allow(dependency).to receive(:installed?) + .with(minimum_version: Version.new("2.0"), minimum_revision: 0) + .and_return(true) + + expect do + described_class.formula_installers([dependent], flags: [], dependents: true) + end.to output( + "==> Not upgrading dependent: installed runtime dependencies satisfy bottle metadata\n", + ).to_stdout + end + end + + describe "::upgrade_dependents" do + it "returns installed dependents unless they are primary formulae" do + installed_dependent = formula("installed-dependent") do + T.bind(self, T.class_of(Formula)) + url "https://brew.sh/installed-dependent-2.0" + end + primary_formula = formula("primary") do + T.bind(self, T.class_of(Formula)) + url "https://brew.sh/primary-2.0" + end + FormulaInstaller.installed.merge([installed_dependent, primary_formula]) + dependants = Homebrew::Upgrade::Dependents.new( + upgradeable: [installed_dependent, primary_formula], pinned: [], skipped: [], + ) + + expect(described_class.upgrade_dependents(dependants, [primary_formula], flags: [])) + .to contain_exactly(installed_dependent) + end + end end diff --git a/Library/Homebrew/upgrade.rb b/Library/Homebrew/upgrade.rb index a19a924803..bf231d7f2e 100644 --- a/Library/Homebrew/upgrade.rb +++ b/Library/Homebrew/upgrade.rb @@ -152,8 +152,8 @@ module Homebrew end if all_runtime_deps_installed - # Don't need to install this bottle if all of the runtime - # dependencies have the same or newer version already installed. + ohai "Not upgrading #{fi.formula.full_specified_name}: " \ + "installed runtime dependencies satisfy bottle metadata" next end end @@ -278,7 +278,7 @@ module Homebrew dry_run: T::Boolean, installed_on_request: T::Boolean, force_bottle: T::Boolean, build_from_source_formulae: T::Array[String], interactive: T::Boolean, keep_tmp: T::Boolean, debug_symbols: T::Boolean, force: T::Boolean, debug: T::Boolean, quiet: T::Boolean, - verbose: T::Boolean, skip_formula_names: T::Array[String]).void + verbose: T::Boolean, skip_formula_names: T::Array[String]).returns(T::Array[Formula]) } def upgrade_dependents(deps, formulae, flags:, @@ -294,7 +294,7 @@ module Homebrew quiet: false, verbose: false, skip_formula_names: []) - return if deps.blank? + return [] if deps.blank? upgradeable = deps.upgradeable pinned = deps.pinned @@ -314,8 +314,38 @@ module Homebrew EOS end + installed_formulae = FormulaInstaller.installed + upgraded_formulae = T.let([], T::Array[Formula]) + unless dry_run + primary_formula_names = formulae.map(&:full_name) + upgraded_formulae.concat(upgradeable.select do |f| + installed_formulae.include?(f) && primary_formula_names.exclude?(f.full_name) + end) + end + upgradeable.reject! do |f| - FormulaInstaller.installed.include?(f) || (dry_run && skip_formula_names.include?(f.full_name)) + installed_formulae.include?(f) || (dry_run && skip_formula_names.include?(f.full_name)) + end + + return upgraded_formulae if upgradeable.blank? + + dependent_installers = T.let([], T::Array[FormulaInstaller]) + unless dry_run + dependent_installers = formula_installers( + upgradeable.dup, + flags:, + force_bottle:, + build_from_source_formulae:, + dependents: true, + interactive:, + keep_tmp:, + debug_symbols:, + force:, + debug:, + quiet:, + verbose:, + ) + upgradeable = dependent_installers.map(&:formula) end # Print the upgradable dependents. @@ -337,30 +367,12 @@ module Homebrew puts format_upgrade_summary(formulae_upgrades).join("\n") end - return if upgradeable.blank? - - unless dry_run - dependent_installers = formula_installers( - upgradeable, - flags:, - force_bottle:, - build_from_source_formulae:, - dependents: true, - interactive:, - keep_tmp:, - debug_symbols:, - force:, - debug:, - quiet:, - verbose:, - ) - upgrade_formulae(dependent_installers, dry_run:, verbose:) - end + upgraded_formulae.concat(upgrade_formulae(dependent_installers, verbose:).map(&:formula)) unless dry_run # Update non-core installed formulae for linkage checks after upgrading # Don't need to check core formulae because we do so at CI time. installed_non_core_formulae = FormulaInstaller.installed.to_a.reject(&:core_formula?) - return if installed_non_core_formulae.blank? + return upgraded_formulae if installed_non_core_formulae.blank? # Assess the dependents tree again now we've upgraded. unless dry_run @@ -376,7 +388,7 @@ module Homebrew else ohai "No broken dependents found!" end - return + return upgraded_formulae end reinstallable_broken_dependents = @@ -409,7 +421,7 @@ module Homebrew .join(", ") end - return if dry_run + return upgraded_formulae if dry_run reinstall_contexts = reinstallable_broken_dependents.map do |formula| Reinstall.build_install_context( @@ -444,6 +456,7 @@ module Homebrew puts Homebrew.failed = true end + upgraded_formulae end sig { From 8b3a119e783f90ddd3422f61c42ed0d81ea30b20 Mon Sep 17 00:00:00 2001 From: Mike McQuaid Date: Tue, 11 Aug 2026 12:02:16 +0100 Subject: [PATCH 03/10] Allow cask runs to access the network Structured cask install steps run in a sandbox that denies network access. This breaks casks whose upstream setup commands must retrieve installation-time dependencies, including `gcloud-cli` virtualenv creation. - Add a serialised `network_access:` option to `run` steps. - Omit the option from API data when it retains the default denial. - Enable network access only when an explicit run requests it. - Preserve network denial for existing steps and generated completions. - Document the opt-in and cover both sandbox policies in tests. This avoids disabling network restrictions for all cask operations while giving affected casks an explicit, auditable escape hatch. See #23495. --- .../cask/artifact/abstract_artifact.rb | 6 ++--- .../Homebrew/cask/artifact/install_steps.rb | 4 +++- Library/Homebrew/install_steps.rb | 6 +++-- .../test/cask/artifact/install_steps_spec.rb | 24 ++++++++++++++++++- docs/Cask-Cookbook.md | 2 +- 5 files changed, 34 insertions(+), 8 deletions(-) diff --git a/Library/Homebrew/cask/artifact/abstract_artifact.rb b/Library/Homebrew/cask/artifact/abstract_artifact.rb index 7d5b1d3cc6..9f5247d20d 100644 --- a/Library/Homebrew/cask/artifact/abstract_artifact.rb +++ b/Library/Homebrew/cask/artifact/abstract_artifact.rb @@ -201,13 +201,13 @@ module Cask cask.config end - sig { returns(T.nilable(Sandbox)) } - def cask_sandbox + sig { params(network_access_allowed: T::Boolean).returns(T.nilable(Sandbox)) } + def cask_sandbox(network_access_allowed: false) return unless Sandbox.use_for?("running cask artifact operations") Sandbox.new.tap do |sandbox| sandbox.allow_read(path: cask.staged_path, type: :subpath) - sandbox.add_install_hook_rules(network_access_allowed: false) + sandbox.add_install_hook_rules(network_access_allowed:) end end diff --git a/Library/Homebrew/cask/artifact/install_steps.rb b/Library/Homebrew/cask/artifact/install_steps.rb index 04d623a163..fe5c1c1967 100644 --- a/Library/Homebrew/cask/artifact/install_steps.rb +++ b/Library/Homebrew/cask/artifact/install_steps.rb @@ -33,7 +33,9 @@ module Cask sig { params(command: T.class_of(SystemCommand), phase: Symbol).void } def run_steps(command, phase: :install) runner = Homebrew::InstallSteps::Runner.new(context: cask, command:) - sandbox = cask_sandbox + sandbox = cask_sandbox(network_access_allowed: steps.any? do |step| + step["type"] == "run" && step["network_access"] == true + end) unless sandbox runner.run(steps, phase:) return diff --git a/Library/Homebrew/install_steps.rb b/Library/Homebrew/install_steps.rb index 9274e001f8..60efe3fc32 100644 --- a/Library/Homebrew/install_steps.rb +++ b/Library/Homebrew/install_steps.rb @@ -683,11 +683,12 @@ module Homebrew chdir: ::T.nilable(::T.any(::String, ::Pathname)), writable_paths: Paths, writable_base: ::T.nilable(::T.any(::String, ::Symbol)), + network_access: ::T::Boolean, ).void } def run(command, args: [], base: nil, env: {}, sudo: false, must_succeed: true, print_stdout: false, print_stderr: true, stdin_path: nil, stdout_path: nil, chdir: nil, writable_paths: [], - writable_base: nil) + writable_base: nil, network_access: false) add_step("run", "command" => path_spec(command, base:, default_base: nil), "args" => args.map(&:to_s), @@ -703,7 +704,8 @@ module Homebrew writable_paths, base: writable_base, default_base: @default_base, - )) + ), + "network_access" => network_access) end sig { diff --git a/Library/Homebrew/test/cask/artifact/install_steps_spec.rb b/Library/Homebrew/test/cask/artifact/install_steps_spec.rb index 0729f1a926..9f66aae0f9 100644 --- a/Library/Homebrew/test/cask/artifact/install_steps_spec.rb +++ b/Library/Homebrew/test/cask/artifact/install_steps_spec.rb @@ -64,7 +64,7 @@ RSpec.describe Cask::Artifact::AbstractInstallSteps, :cask do artifact = cask.artifacts.find { |candidate| candidate.is_a?(Cask::Artifact::PostflightSteps) } run_step = artifact.steps.find { |step| step["type"] == "run" } - expect(run_step).not_to include("print_stdout", "suppress_stderr", "writable_paths") + expect(run_step).not_to include("print_stdout", "suppress_stderr", "writable_paths", "network_access") end it "sandboxes complete step blocks, including system commands" do @@ -119,6 +119,28 @@ RSpec.describe Cask::Artifact::AbstractInstallSteps, :cask do expect(original_home/"Library/Application Support/cask-home-state").to exist end + it "allows network access for runs that request it" do + cask = Cask::Cask.new("with-networked-install-step") do + version "1.2.3" + sha256 :no_check + url "file://#{TEST_FIXTURE_DIR}/cask/container.zip" + + postflight_steps do + run "/usr/bin/true", network_access: true + end + end + sandbox = instance_double(Sandbox).as_null_object + cask.staged_path.mkpath + cask.config_path.dirname.mkpath + + allow(Sandbox).to receive_messages(available?: true, new: sandbox) + allow(Sandbox).to receive(:with_preserved_brew_file).and_yield + expect(sandbox).to receive(:add_install_hook_rules).with(network_access_allowed: true) + expect(sandbox).to receive(:run) + + Cask::Installer.new(cask, command: NeverSudoSystemCommand).install_artifacts + end + context "when install steps may require sudo" do { "an explicitly privileged command" => proc { run "/usr/bin/true", sudo: true }, diff --git a/docs/Cask-Cookbook.md b/docs/Cask-Cookbook.md index 7d23ce2052..fe30a89b34 100644 --- a/docs/Cask-Cookbook.md +++ b/docs/Cask-Cookbook.md @@ -659,7 +659,7 @@ Relative paths default to `staged_path` for `base:`, `source_base:` and `target_ Use `if_path_exists`, `unless_path_exists`, `on_macos` and `on_linux` blocks to guard one or more steps. A condition is evaluated once when its scope begins, so related steps make the same decision. Use `unless_path_exists` around `write_file` when an existing file must be preserved. -`run` does not evaluate a shell command string. It supports a literal `env:`, `stdin_path:`, `stdout_path:`, `chdir:` and `sudo:`. Standard output is hidden by default and standard error is printed; use `print_stdout: true` or `print_stderr: false` to change that behaviour. Failure aborts the installation or uninstallation; pass `must_succeed: false` when a non-zero exit status is expected and should be ignored, such as a cleanup command that cannot run when its dependency is absent. Nothing is written to `stdout_path:` when an ignored command fails. The complete steps block runs in an isolated Ruby subprocess without network or general home-directory access where Homebrew has an available sandbox. The sandbox permits writes to declared step destinations, the caskroom, app directory, temporary and cache directories and Homebrew's link directories. Ruby file operations and system or cask-provided commands therefore share the same restrictions. Use `writable_paths:` with directory roots, and `writable_base:` for relative roots, when an opaque command needs another declared write location. +`run` does not evaluate a shell command string. It supports a literal `env:`, `stdin_path:`, `stdout_path:`, `chdir:` and `sudo:`. Standard output is hidden by default and standard error is printed; use `print_stdout: true` or `print_stderr: false` to change that behaviour. Failure aborts the installation or uninstallation; pass `must_succeed: false` when a non-zero exit status is expected and should be ignored, such as a cleanup command that cannot run when its dependency is absent. Nothing is written to `stdout_path:` when an ignored command fails. The complete steps block runs in an isolated Ruby subprocess without network or general home-directory access where Homebrew has an available sandbox. Pass `network_access: true` when a `run` command must access the network. The sandbox permits writes to declared step destinations, the caskroom, app directory, temporary and cache directories and Homebrew's link directories. Ruby file operations and system or cask-provided commands therefore share the same restrictions. Use `writable_paths:` with directory roots, and `writable_base:` for relative roots, when an opaque command needs another declared write location. #### Interpolation in steps blocks From b3f0e09fa3dc29b91d84e18c009648d2213f479a Mon Sep 17 00:00:00 2001 From: Mike McQuaid Date: Tue, 11 Aug 2026 13:04:35 +0100 Subject: [PATCH 04/10] Use cask dependencies for platform support - Keep `sha256` responsible only for selecting a checksum. - Use OS and architecture `depends_on` declarations as the source of truth for supported platforms. - Reject missing platform checksums before any download and make `--require-sha` reject both `nil` and `:no_check`. Fixes #23494 --- Library/Homebrew/cask/download.rb | 12 +++++-- Library/Homebrew/cask/dsl.rb | 15 --------- Library/Homebrew/dev-cmd/bump-cask-pr.rb | 8 +++++ Library/Homebrew/test/cask/download_spec.rb | 22 +++++++++++++ Library/Homebrew/test/cask/dsl_spec.rb | 13 ++++---- .../test/dev-cmd/bump-cask-pr_spec.rb | 33 +++++++++++++++++++ 6 files changed, 80 insertions(+), 23 deletions(-) diff --git a/Library/Homebrew/cask/download.rb b/Library/Homebrew/cask/download.rb index 0efb9c97fa..267bea8caa 100644 --- a/Library/Homebrew/cask/download.rb +++ b/Library/Homebrew/cask/download.rb @@ -59,7 +59,8 @@ module Cask .returns(Pathname) } def fetch(quiet: nil, verify_download_integrity: true, timeout: nil) - verify_has_sha if @require_sha + verify_has_sha if @require_sha || + (@cask.sha256.nil? && (@cask.on_system_blocks_exist? || @cask.loaded_from_api?)) downloader.quiet! if quiet begin @@ -233,7 +234,14 @@ module Cask sig { void } def verify_has_sha - return if @cask.sha256 != :no_check + return if @cask.sha256.is_a?(Checksum) + + unless @require_sha + raise CaskError, <<~EOS + Cask '#{@cask}' does not have a sha256 checksum defined for this platform. + Add an appropriate `depends_on` stanza if the cask does not support this platform. + EOS + end raise CaskError, <<~EOS Cask '#{@cask}' does not have a sha256 checksum defined. diff --git a/Library/Homebrew/cask/dsl.rb b/Library/Homebrew/cask/dsl.rb index c686821c01..2975a70c64 100644 --- a/Library/Homebrew/cask/dsl.rb +++ b/Library/Homebrew/cask/dsl.rb @@ -562,21 +562,6 @@ module Cask when String Checksum.new(val) when nil - # Checksums declared for only the other OS mean no checksum for the - # 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 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 - if running_os_checksums.any?(&:present?) && !Homebrew::SimulateSystem.simulating? - raise CaskInvalidError.new(cask, "invalid 'sha256' value: nil") - end - nil else raise CaskInvalidError.new(cask, "invalid 'sha256' value: #{val.inspect}") diff --git a/Library/Homebrew/dev-cmd/bump-cask-pr.rb b/Library/Homebrew/dev-cmd/bump-cask-pr.rb index 1e02993a6f..d226e4efa2 100644 --- a/Library/Homebrew/dev-cmd/bump-cask-pr.rb +++ b/Library/Homebrew/dev-cmd/bump-cask-pr.rb @@ -293,6 +293,9 @@ module Homebrew generate_system_options(cask, new_version).each do |os, arch| tag = Utils::Bottles::Tag.new(system: os, arch:) old_cask.refresh_for_tag(tag) do + next if tag.macos? && !old_cask.supports_macos? + next if tag.linux? && !old_cask.supports_linux? + # Skip archs excluded by the cask's `depends_on arch:`. reloaded_archs = old_cask.depends_on.arch&.filter_map { |a| a[:type] }&.uniq next if reloaded_archs.present? && reloaded_archs.exclude?(arch) @@ -317,6 +320,11 @@ module Homebrew tmp_cask = Cask::CaskLoader::FromContentLoader.new(contents) .load(config: nil) old_hash = tmp_cask.sha256 + if old_hash.nil? + raise Cask::CaskError, "#{cask}: No checksum is defined for #{tag.to_sym.inspect}. " \ + "Add `depends_on arch:` or an operating system `depends_on` to " \ + "declare unsupported platforms." + end next if new_hash.is_a?(String) && old_hash.to_s == new_hash checksum_scope = cask_stanza_scope(contents, :sha256, arch) diff --git a/Library/Homebrew/test/cask/download_spec.rb b/Library/Homebrew/test/cask/download_spec.rb index 88c7d82851..f74d643cc0 100644 --- a/Library/Homebrew/test/cask/download_spec.rb +++ b/Library/Homebrew/test/cask/download_spec.rb @@ -55,6 +55,28 @@ RSpec.describe Cask::Download, :cask do expect(download).not_to receive(:downloader) expect { download.fetch }.to raise_error(/--require-sha/) end + + it "fails before downloading if sha256 is nil with --require-sha" do + missing_checksum = Cask::CaskLoader.load(cask_path("missing-checksum")) + download = described_class.new(missing_checksum, require_sha: true) + allow(download).to receive(:downloader) { raise "download attempted" } + + expect { download.fetch }.to raise_error(Cask::CaskError, /--require-sha/) + end + + it "fails before downloading if a platform checksum is missing" do + Homebrew::SimulateSystem.with(os: :macos, arch: :intel) do + cask = Cask::Cask.new("missing-platform-checksum") do + version "1.2.3" + sha256 arm: "0000000000000000000000000000000000000000000000000000000000000000" + url "https://brew.sh/example.zip" + end + download = described_class.new(cask) + allow(download).to receive(:downloader) { raise "download attempted" } + + expect { download.fetch }.to raise_error(Cask::CaskError, /`depends_on`/) + end + end end describe "#stage_from_download_queue?" do diff --git a/Library/Homebrew/test/cask/dsl_spec.rb b/Library/Homebrew/test/cask/dsl_spec.rb index 160b90c52a..1af79f135f 100644 --- a/Library/Homebrew/test/cask/dsl_spec.rb +++ b/Library/Homebrew/test/cask/dsl_spec.rb @@ -195,15 +195,16 @@ RSpec.describe Cask::DSL, :cask, :no_api do end end - it "raises on the real system when the running-architecture checksum is missing" do + it "loads the architecture requirement 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 - sha256 arm64_linux: "imasha2armlinux", intel: "imasha2intel" - end - end.to raise_error(Cask::CaskInvalidError, /invalid 'sha256' value/) + cask = Cask::Cask.new("checksum-cask") do + sha256 arm64_linux: "imasha2armlinux", intel: "imasha2intel" + depends_on arch: :arm64 + end + + expect([cask.sha256, cask.depends_on.arch]).to eq([nil, [{ type: :arm, bits: 64 }]]) end end end diff --git a/Library/Homebrew/test/dev-cmd/bump-cask-pr_spec.rb b/Library/Homebrew/test/dev-cmd/bump-cask-pr_spec.rb index 6bc609bfd6..8354ce52da 100644 --- a/Library/Homebrew/test/dev-cmd/bump-cask-pr_spec.rb +++ b/Library/Homebrew/test/dev-cmd/bump-cask-pr_spec.rb @@ -350,6 +350,7 @@ RSpec.describe Homebrew::DevCmd::BumpCaskPr do url "https://brew.sh/foo-\#{arch}-\#{version}.dmg" name "Foo" + depends_on :macos end RUBY cask = Homebrew::SimulateSystem.with(os: newest_macos, arch: :arm) { cask_from_contents(contents) } @@ -371,6 +372,7 @@ RSpec.describe Homebrew::DevCmd::BumpCaskPr do url "https://brew.sh/foo-\#{arch}-\#{version}.dmg" name "Foo" + depends_on :macos end RUBY end @@ -521,6 +523,37 @@ RSpec.describe Homebrew::DevCmd::BumpCaskPr do RUBY end + it "requires depends_on arch when a checksum is missing" do + contents = <<~RUBY + cask "foo" do + arch arm: "arm64", intel: "x64" + os macos: "macos", linux: "linux" + + version "1.0" + sha256 arm: "#{old_hash}", + arm64_linux: "#{new_hash}", + x86_64_linux: "#{intel_hash}" + + url "https://brew.sh/foo-\#{os}-\#{arch}-\#{version}.zip" + name "Foo" + + on_macos do + app "Foo.app" + end + on_linux do + binary "foo" + end + end + RUBY + cask = cask_from_contents(contents) + new_version = Homebrew::BumpVersionParser.new(general: "2.0") + allow(Cask::Download).to receive(:new) { raise "download attempted" } + + expect do + bump_cask_pr.replace_version_and_checksum(cask, nil, new_version, contents) + end.to raise_error(Cask::CaskError, /No checksum.*`depends_on arch:`/) + end + it "leaves nested architecture stanzas unchanged when matching values could be replaced globally" do contents = <<~RUBY cask "foo" do From 1a00b1063b35414f326eb606bea9c74137ca07ad Mon Sep 17 00:00:00 2001 From: Mike McQuaid Date: Tue, 11 Aug 2026 19:36:42 +0100 Subject: [PATCH 05/10] Avoid nil cask versions during installation - Platform-specific casks can have no `version` when evaluated on an unsupported operating system. - Let upgrade detection skip `latest?` in that case so installation reaches normal platform validation instead of raising `NoMethodError`. - Cover a macOS-only cask evaluated on Linux. Fixes #23498 --- Library/Homebrew/cask/upgrade.rb | 2 +- Library/Homebrew/test/cask/upgrade_spec.rb | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/Library/Homebrew/cask/upgrade.rb b/Library/Homebrew/cask/upgrade.rb index 15037d92c3..b8165e3812 100644 --- a/Library/Homebrew/cask/upgrade.rb +++ b/Library/Homebrew/cask/upgrade.rb @@ -64,7 +64,7 @@ module Cask if cask.outdated?(greedy: true) true - elsif cask.version.latest? + elsif cask.version&.latest? opoo "Not upgrading #{cask.token}, the downloaded artifact has not changed" unless quiet false else diff --git a/Library/Homebrew/test/cask/upgrade_spec.rb b/Library/Homebrew/test/cask/upgrade_spec.rb index 02f676e3b7..2bf8f34c26 100644 --- a/Library/Homebrew/test/cask/upgrade_spec.rb +++ b/Library/Homebrew/test/cask/upgrade_spec.rb @@ -47,6 +47,18 @@ RSpec.describe Cask::Upgrade, :cask do allow(Homebrew::EnvConfig).to receive(:upgrade_auto_updates_casks?).and_return(true) end + it "excludes casks with no version for the current OS" do + cask = Homebrew::SimulateSystem.with(os: :linux) do + Cask::Cask.new("macos-only") do + on_macos do + version "1.2.3" + end + end + end + + expect(described_class.outdated_casks([cask], args:, force: true, quiet: true)).to be_empty + end + context "when the upgrade is a dry run" do # Use stub installation for dry-run tests since they mock upgrade_cask # and only need to verify installation state, not perform real upgrades. From 2347a879467c6f41760f66937f7f41121a4fa1e9 Mon Sep 17 00:00:00 2001 From: Michka Popoff Date: Tue, 11 Aug 2026 22:15:06 +0200 Subject: [PATCH 06/10] Fix role used to push to ECR This is the role that is specifically scoped for Homebrew/brew --- .github/workflows/docker.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 710b81e76f..b717019dcf 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -414,7 +414,7 @@ jobs: id: aws_registry_credentials uses: aws-actions/configure-aws-credentials@517a711dbcd0e402f90c77e7e2f81e849156e31d # v6.2.2 with: - role-to-assume: arn:aws:iam::${{ secrets.AWS_PROJECT_ID }}:role/GithubActionsRoleECRPush + role-to-assume: arn:aws:iam::${{ secrets.AWS_PROJECT_ID }}:role/GithubActionsRoleECRPushBrew aws-region: us-east-1 - name: AWS Elastic Container Registry Login From eebfc7280760d375ee9aee45bebfcb4e770fc13a Mon Sep 17 00:00:00 2001 From: Mike McQuaid Date: Tue, 11 Aug 2026 21:31:48 +0100 Subject: [PATCH 07/10] Report unavailable cask platforms accurately - `outdated_casks` treated a missing `version` as non-latest but then reported that the latest version was already installed. - Handle the missing version explicitly and reuse the narrowed value so non-quiet output identifies the unavailable current platform. - Strengthen the regression coverage for the warning path. --- Library/Homebrew/cask/upgrade.rb | 8 ++++++-- Library/Homebrew/test/cask/upgrade_spec.rb | 6 ++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/Library/Homebrew/cask/upgrade.rb b/Library/Homebrew/cask/upgrade.rb index b8165e3812..36c759cf0d 100644 --- a/Library/Homebrew/cask/upgrade.rb +++ b/Library/Homebrew/cask/upgrade.rb @@ -62,9 +62,13 @@ module Cask next false end - if cask.outdated?(greedy: true) + version = cask.version + if version.nil? + opoo "Not upgrading #{cask.token}, no version is available for the current platform" unless quiet + false + elsif cask.outdated?(greedy: true) true - elsif cask.version&.latest? + elsif version.latest? opoo "Not upgrading #{cask.token}, the downloaded artifact has not changed" unless quiet false else diff --git a/Library/Homebrew/test/cask/upgrade_spec.rb b/Library/Homebrew/test/cask/upgrade_spec.rb index 2bf8f34c26..5918750fc9 100644 --- a/Library/Homebrew/test/cask/upgrade_spec.rb +++ b/Library/Homebrew/test/cask/upgrade_spec.rb @@ -47,7 +47,7 @@ RSpec.describe Cask::Upgrade, :cask do allow(Homebrew::EnvConfig).to receive(:upgrade_auto_updates_casks?).and_return(true) end - it "excludes casks with no version for the current OS" do + it "warns and excludes casks with no version for the current platform" do cask = Homebrew::SimulateSystem.with(os: :linux) do Cask::Cask.new("macos-only") do on_macos do @@ -56,7 +56,9 @@ RSpec.describe Cask::Upgrade, :cask do end end - expect(described_class.outdated_casks([cask], args:, force: true, quiet: true)).to be_empty + expect do + expect(described_class.outdated_casks([cask], args:, force: true, quiet: false)).to be_empty + end.to output(/Not upgrading macos-only, no version is available for the current platform/).to_stderr end context "when the upgrade is a dry run" do From 88d6178c7df739278827293520f71f2857b1fe5c Mon Sep 17 00:00:00 2001 From: Patrick Linnane Date: Tue, 11 Aug 2026 22:29:32 -0700 Subject: [PATCH 08/10] Add supported_platforms to cask API data Signed-off-by: Patrick Linnane --- Library/Homebrew/cask/cask.rb | 63 +++++- Library/Homebrew/cask/installer.rb | 7 +- .../extend/os/linux/cask/installer.rb | 11 +- .../Homebrew/extend/os/mac/cask/installer.rb | 9 +- Library/Homebrew/test/cask/cask_spec.rb | 197 ++++++++++++++++++ 5 files changed, 262 insertions(+), 25 deletions(-) diff --git a/Library/Homebrew/cask/cask.rb b/Library/Homebrew/cask/cask.rb index 6e79bf1c84..8bee99a57a 100644 --- a/Library/Homebrew/cask/cask.rb +++ b/Library/Homebrew/cask/cask.rb @@ -256,6 +256,31 @@ module Cask artifacts.all?(Artifact::Font) end + sig { returns(T::Boolean) } + def installable_artifact? + artifacts.any? do |artifact| + artifact.respond_to?(:install_phase) || artifact.is_a?(Artifact::StageOnly) + end + end + + sig { params(os: Symbol).returns(T::Boolean) } + def artifacts_supported_on_os?(os) + case os + when :linux + artifacts.all? do |artifact| + if artifact.is_a?(Artifact::Installer) + !artifact.manual_install + else + Artifact::MACOS_ONLY_ARTIFACTS.exclude?(artifact.class) + end + end + when :macos + artifacts.none? { |artifact| Artifact::LINUX_ONLY_ARTIFACTS.include?(artifact.class) } + else + raise ArgumentError, "Unsupported operating system: #{os.inspect}" + end + end + sig { returns(T::Boolean) } def supports_linux? return true if depends_on.requires_linux? @@ -612,17 +637,24 @@ module Cask hash = to_h_with_language_variations variations = {} + supported_platforms = [] + on_system_blocks_exist = dsl!.on_system_blocks_exist? - if dsl!.on_system_blocks_exist? + if on_system_blocks_exist begin OnSystem::VALID_OS_ARCH_TAGS.each do |bottle_tag| macos_requirements = [depends_on.macos, depends_on.maximum_macos].compact next if bottle_tag.macos? && macos_requirements.present? && !dsl!.depends_on_set_in_block? && - macos_requirements.any? { |requirement| !requirement.allows?(bottle_tag.to_macos_version) } + macos_requirements.any? do |requirement| + # Avoid recursive equality between cached version-comparison keys across casks. + !requirement.allows?(MacOSVersion.from_symbol(bottle_tag.system)) + end refresh_for_tag(bottle_tag) do + supported_platforms << bottle_tag.to_sym if platform_supported?(bottle_tag) + to_h_with_language_variations.each do |key, value| next if HASH_KEYS_TO_SKIP.include? key next if value.to_s == hash[key].to_s @@ -635,9 +667,14 @@ module Cask ensure refresh end + else + supported_platforms = OnSystem::VALID_OS_ARCH_TAGS.filter_map do |bottle_tag| + bottle_tag.to_sym if platform_supported?(bottle_tag) + end end hash["variations"] = variations + hash["supported_platforms"] = supported_platforms hash end @@ -720,6 +757,28 @@ module Cask private + sig { params(bottle_tag: ::Utils::Bottles::Tag).returns(T::Boolean) } + def platform_supported?(bottle_tag) + return false if bottle_tag.linux? && !supports_linux? + return false if bottle_tag.macos? && !supports_macos? + return false if version.blank? || sha256.blank? || url.blank? + return false unless installable_artifact? + return false if bottle_tag.linux? && !artifacts_supported_on_os?(:linux) + return false if bottle_tag.macos? && !artifacts_supported_on_os?(:macos) + + arch_supported = depends_on.arch&.any? do |arch| + required_arch = ::Utils::Bottles::Tag.new(system: bottle_tag.system, arch: arch[:type]).standardized_arch + required_arch == bottle_tag.standardized_arch + end + return false if arch_supported == false + + return true unless bottle_tag.macos? + + [depends_on.macos, depends_on.maximum_macos].compact.all? do |requirement| + requirement.allows?(bottle_tag.to_macos_version) + end + end + # Returns caveats text for API serialization, excluding conditional # built-in caveats that depend on the current machine's state. # These are stored as separate boolean fields (e.g. caveats_rosetta) diff --git a/Library/Homebrew/cask/installer.rb b/Library/Homebrew/cask/installer.rb index b08089aa49..3eaf5d038a 100644 --- a/Library/Homebrew/cask/installer.rb +++ b/Library/Homebrew/cask/installer.rb @@ -372,13 +372,10 @@ on_request: true) sig { void } def check_supported_system - # Audited casks always have an activatable artifact for the systems they - # support, so API data without one means this system is unsupported. + # API data without an installable artifact means this system is unsupported. # Source loads keep working for unaudited casks, e.g. naked containers. return unless @cask.loaded_from_api? - return if @cask.artifacts.any? do |artifact| - artifact.respond_to?(:install_phase) || artifact.is_a?(Artifact::StageOnly) - end + return if @cask.installable_artifact? os_name = Homebrew::SimulateSystem.simulating_or_running_on_macos? ? "macOS" : "Linux" raise CaskError, "#{@cask}: This cask is not available on #{os_name}." diff --git a/Library/Homebrew/extend/os/linux/cask/installer.rb b/Library/Homebrew/extend/os/linux/cask/installer.rb index e7761d4cad..1f581a7678 100644 --- a/Library/Homebrew/extend/os/linux/cask/installer.rb +++ b/Library/Homebrew/extend/os/linux/cask/installer.rb @@ -11,19 +11,10 @@ module OS sig { void } def check_stanza_os_requirements - return if !cask.depends_on.requires_macos? && artifacts.all? { |artifact| supported_artifact?(artifact) } + return if !cask.depends_on.requires_macos? && cask.artifacts_supported_on_os?(:linux) raise ::Cask::CaskError, "#{cask}: This cask requires macOS." end - - private - - sig { params(artifact: ::Cask::Artifact::AbstractArtifact).returns(T::Boolean) } - def supported_artifact?(artifact) - return !artifact.manual_install if artifact.is_a?(::Cask::Artifact::Installer) - - ::Cask::Artifact::MACOS_ONLY_ARTIFACTS.exclude?(artifact.class) - end end end end diff --git a/Library/Homebrew/extend/os/mac/cask/installer.rb b/Library/Homebrew/extend/os/mac/cask/installer.rb index 7a446b6556..e07c097dd7 100644 --- a/Library/Homebrew/extend/os/mac/cask/installer.rb +++ b/Library/Homebrew/extend/os/mac/cask/installer.rb @@ -11,17 +11,10 @@ module OS sig { void } def check_stanza_os_requirements - return if !cask.depends_on.requires_linux? && artifacts.all? { |artifact| supported_artifact?(artifact) } + return if !cask.depends_on.requires_linux? && cask.artifacts_supported_on_os?(:macos) raise ::Cask::CaskError, "#{cask}: This cask requires Linux." end - - private - - sig { params(artifact: ::Cask::Artifact::AbstractArtifact).returns(T::Boolean) } - def supported_artifact?(artifact) - ::Cask::Artifact::LINUX_ONLY_ARTIFACTS.exclude?(artifact.class) - end end end end diff --git a/Library/Homebrew/test/cask/cask_spec.rb b/Library/Homebrew/test/cask/cask_spec.rb index a446dadb3a..7f1fd7f966 100644 --- a/Library/Homebrew/test/cask/cask_spec.rb +++ b/Library/Homebrew/test/cask/cask_spec.rb @@ -646,6 +646,13 @@ RSpec.describe Cask::Cask, :cask do end end + describe "#artifacts_supported_on_os?" do + it "rejects unknown operating systems" do + expect { cask.artifacts_supported_on_os?(:windows) } + .to raise_error(ArgumentError, "Unsupported operating system: :windows") + end + end + describe "#outdated_info" do it "includes pinned cask details" do cask = Cask::CaskLoader.load("local-caffeine") @@ -979,6 +986,196 @@ RSpec.describe Cask::Cask, :cask do ) end + context "when recording supported platforms" do + let(:platform_tags) do + [ + Utils::Bottles::Tag.new(system: :sonoma, arch: :intel), + Utils::Bottles::Tag.new(system: :sonoma, arch: :arm), + Utils::Bottles::Tag.new(system: :monterey, arch: :intel), + Utils::Bottles::Tag.new(system: :monterey, arch: :arm), + Utils::Bottles::Tag.new(system: :catalina, arch: :intel), + Utils::Bottles::Tag.new(system: :linux, arch: :intel), + Utils::Bottles::Tag.new(system: :linux, arch: :arm), + ] + end + let(:macos_platforms) { [:sonoma, :arm64_sonoma, :monterey, :arm64_monterey, :catalina] } + + before do + stub_const("OnSystem::VALID_OS_ARCH_TAGS", platform_tags) + end + + it "records platforms allowed by scoped macOS requirements" do + c = Cask::CaskLoader.load("with-depends-on-macos-in-on-macos") + + expect(c.to_hash_with_variations["supported_platforms"]).to eq( + [:sonoma, :arm64_sonoma, :monterey, :arm64_monterey, :x86_64_linux, :arm64_linux], + ) + end + + it "excludes platforms without complete download data" do + c = Cask::CaskLoader.load("multiple-versions") + + expect(c.to_hash_with_variations["supported_platforms"]).to eq(macos_platforms) + end + + it "excludes platforms rejected by architecture requirements" do + c = described_class.new("architecture-restricted") do + version :latest + arch arm: "arm64", intel: "x86_64" + sha256 :no_check + url "https://brew.sh/#{arch}.zip" + on_linux do + depends_on arch: :x86_64 + end + binary "foo" + end + + expect(c.to_hash_with_variations["supported_platforms"]).to eq( + [*macos_platforms, :x86_64_linux], + ) + end + + it "excludes Linux for macOS-only artifacts" do + c = described_class.new("macos-artifact") do + version :latest + arch arm: "arm64", intel: "x86_64" + sha256 :no_check + url "https://brew.sh/#{arch}.zip" + app "Foo.app" + end + + expect(c.to_hash_with_variations["supported_platforms"]).to eq(macos_platforms) + end + + it "excludes Linux for manual installers" do + c = described_class.new("manual-installer") do + version :latest + arch arm: "arm64", intel: "x86_64" + sha256 :no_check + url "https://brew.sh/#{arch}.zip" + installer manual: "Foo.app" + end + + expect(c.to_hash_with_variations["supported_platforms"]).to eq(macos_platforms) + end + + it "excludes macOS for Linux-only artifacts" do + c = described_class.new("linux-artifact") do + version :latest + arch arm: "arm64", intel: "x86_64" + sha256 :no_check + url "https://brew.sh/#{arch}.zip" + app_image "Foo.AppImage" + end + + expect(c.to_hash_with_variations["supported_platforms"]).to eq([:x86_64_linux, :arm64_linux]) + end + + it "includes stage-only casks" do + c = described_class.new("stage-only") do + version :latest + arch arm: "arm64", intel: "x86_64" + sha256 :no_check + url "https://brew.sh/#{arch}.zip" + stage_only true + end + + expect(c.to_hash_with_variations["supported_platforms"]).to eq(platform_tags.map(&:to_sym)) + end + + it "records no supported platforms for a cask without an installable artifact" do + c = described_class.new("zap-only") do + version :latest + arch arm: "arm64", intel: "x86_64" + sha256 :no_check + url "https://brew.sh/#{arch}.zip" + zap trash: "~/Library/Caches/brew-test" + end + + expect(c.to_hash_with_variations["supported_platforms"]).to eq([]) + end + + it "records every platform when a cask has no platform variations" do + c = described_class.new("no-platform-variations") do + version :latest + sha256 :no_check + url "https://brew.sh/foo.zip" + binary "foo" + end + + expect(c.to_hash_with_variations["supported_platforms"]).to eq(platform_tags.map(&:to_sym)) + end + + it "records top-level platform requirements without variations" do + c = described_class.new("top-level-platform-requirements") do + version :latest + sha256 :no_check + url "https://brew.sh/foo.zip" + depends_on macos: :monterey + depends_on arch: :x86_64 + binary "foo" + end + + expect(c.to_hash_with_variations["supported_platforms"]).to eq([:sonoma, :monterey]) + end + + it "serializes architecture-varying and universal casks with the same macOS requirement" do + tags = OnSystem::ALL_OS_ARCH_COMBINATIONS.filter_map do |os, arch| + tag = Utils::Bottles::Tag.new(system: os, arch:) + tag if tag.valid_combination? + end + stub_const("OnSystem::VALID_OS_ARCH_TAGS", tags) + + architecture_varying = described_class.new("architecture-varying") do + arch arm: "ARM64", intel: "64" + version "1.2.3" + sha256 arm: "a" * 64, + intel: "b" * 64 + url "https://brew.sh/#{arch}.zip" + depends_on macos: :big_sur + app "Foo.app" + end + universal = described_class.new("universal") do + version :latest + sha256 :no_check + url "https://brew.sh/foo.zip" + depends_on macos: :big_sur + app "Foo.app" + end + + architecture_varying.to_hash_with_variations + supported_platforms = Timeout.timeout(5) do + universal.to_hash_with_variations["supported_platforms"] + end + + expected_platforms = tags.filter_map do |tag| + tag.to_sym if tag.macos? && tag.system != :catalina + end + expect(supported_platforms).to eq(expected_platforms) + end + + it "isolates macOS requirement comparisons between casks" do + supported_platforms = [:monterey, :sonoma].map do |minimum_macos| + c = described_class.new("requires-#{minimum_macos}") do + version :latest + sha256 :no_check + url "https://brew.sh/foo.zip" + depends_on macos: minimum_macos + binary "foo" + end + + c.to_hash_with_variations["supported_platforms"] + end + + expect(supported_platforms).to eq( + [ + [:sonoma, :arm64_sonoma, :monterey, :arm64_monterey], + [:sonoma, :arm64_sonoma], + ], + ) + end + 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 From 6549e8ff73eb9cfef22dd5300c509cb12de4a6e9 Mon Sep 17 00:00:00 2001 From: Patrick Linnane Date: Tue, 11 Aug 2026 22:29:38 -0700 Subject: [PATCH 09/10] Require an installable artifact when auditing casks Signed-off-by: Patrick Linnane --- Library/Homebrew/cask/audit.rb | 17 ++++++++--- Library/Homebrew/test/cask/audit_spec.rb | 36 ++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/Library/Homebrew/cask/audit.rb b/Library/Homebrew/cask/audit.rb index c0d6f600ba..44291f05a1 100644 --- a/Library/Homebrew/cask/audit.rb +++ b/Library/Homebrew/cask/audit.rb @@ -344,10 +344,19 @@ module Cask add_error "a #{sym} stanza is required" unless cask.public_send(sym) end add_error "at least one name stanza is required" if cask.name.empty? - # TODO: specific DSL knowledge should not be spread around in various files like this - rejected_artifacts = [:uninstall, :zap] - installable_artifacts = cask.artifacts.reject { |k| rejected_artifacts.include?(k) } - add_error "at least one activatable artifact stanza is required" if installable_artifacts.empty? + + installable_artifact = if cask.on_system_blocks_exist? + begin + OnSystem::VALID_OS_ARCH_TAGS.any? do |tag| + cask.refresh_for_tag(tag) { cask.installable_artifact? } + end + ensure + cask.refresh + end + else + cask.installable_artifact? + end + add_error "at least one installable artifact stanza is required" unless installable_artifact end sig { void } diff --git a/Library/Homebrew/test/cask/audit_spec.rb b/Library/Homebrew/test/cask/audit_spec.rb index 5f68157726..b4d556fc93 100644 --- a/Library/Homebrew/test/cask/audit_spec.rb +++ b/Library/Homebrew/test/cask/audit_spec.rb @@ -170,6 +170,42 @@ RSpec.describe Cask::Audit, :cask do it { is_expected.to error_with(/#{stanza} stanza is required/) } end end + + context "when a cask has only uninstall artifacts" do + let(:cask) do + Cask::Cask.new("uninstall-only") do + version :latest + sha256 :no_check + url "https://brew.sh/foo.pkg" + name "Uninstall Only" + homepage "https://brew.sh/" + uninstall pkgutil: "org.example.foo" + zap trash: "~/Library/Caches/org.example.foo" + end + end + + it { is_expected.to error_with("at least one installable artifact stanza is required") } + end + + context "when a cask has an installable artifact only on Linux" do + let(:cask) do + Homebrew::SimulateSystem.with(os: :sequoia, arch: :arm) do + Cask::Cask.new("linux-only-artifact") do + version :latest + sha256 :no_check + url "https://brew.sh/foo" + name "Linux Only Artifact" + homepage "https://brew.sh/" + + on_linux do + binary "foo" + end + end + end + end + + it { is_expected.not_to error_with("at least one installable artifact stanza is required") } + end end describe "checking homepage availability" do From 1570e3c5dd1414c25151300419072f54e067b8bb Mon Sep 17 00:00:00 2001 From: hyuraku <32809703+hyuraku@users.noreply.github.com> Date: Wed, 12 Aug 2026 21:46:05 +0900 Subject: [PATCH 10/10] Implement merging of multiple `conflicts_with` stanzas and update related tests --- Library/Homebrew/cask/dsl.rb | 13 +++++++++--- Library/Homebrew/cask/dsl/conflicts_with.rb | 6 ++++++ .../Homebrew/rubocops/cask/no_overrides.rb | 4 ++-- Library/Homebrew/test/cask/dsl_spec.rb | 10 ++++++++++ .../test/rubocops/cask/no_overrides_spec.rb | 13 ++++++++++++ .../Casks/with-conflicts-with-multiple.rb | 20 +++++++++++++++++++ 6 files changed, 61 insertions(+), 5 deletions(-) create mode 100644 Library/Homebrew/test/support/fixtures/cask/Casks/with-conflicts-with-multiple.rb diff --git a/Library/Homebrew/cask/dsl.rb b/Library/Homebrew/cask/dsl.rb index 2975a70c64..9ccb94ef65 100644 --- a/Library/Homebrew/cask/dsl.rb +++ b/Library/Homebrew/cask/dsl.rb @@ -201,7 +201,6 @@ module Cask @cask = cask @caveats = T.let(DSL::Caveats.new(cask), DSL::Caveats) @conflicts_with = T.let(nil, T.nilable(DSL::ConflictsWith)) - @conflicts_with_set_in_block = T.let(false, T::Boolean) @container = T.let(nil, T.nilable(DSL::Container)) @container_set_in_block = T.let(false, T::Boolean) @depends_on = T.let(DSL::DependsOn.new, DSL::DependsOn) @@ -649,11 +648,19 @@ module Cask # Declare conflicts that keep a cask from installing or working correctly. # + # NOTE: Multiple `conflicts_with` stanzas can be specified; they are merged. + # # @api public sig { params(kwargs: T.anything).returns(T.nilable(DSL::ConflictsWith)) } def conflicts_with(**kwargs) - # TODO: Remove this constraint and instead merge multiple `conflicts_with` stanzas - set_unique_stanza(:conflicts_with, kwargs.empty?) { DSL::ConflictsWith.new(**kwargs) } + return @conflicts_with if kwargs.empty? + + new_conflicts = DSL::ConflictsWith.new(**kwargs) + @conflicts_with = @conflicts_with&.merge!(new_conflicts) || new_conflicts + rescue CaskInvalidError + raise + rescue => e + raise CaskInvalidError.new(cask, "'conflicts_with' stanza failed with: #{e}") end sig { returns(Pathname) } diff --git a/Library/Homebrew/cask/dsl/conflicts_with.rb b/Library/Homebrew/cask/dsl/conflicts_with.rb index a9b57d28e5..26f2f90e30 100644 --- a/Library/Homebrew/cask/dsl/conflicts_with.rb +++ b/Library/Homebrew/cask/dsl/conflicts_with.rb @@ -21,6 +21,12 @@ module Cask super(conflicts) end + sig { params(other: ConflictsWith).returns(T.self_type) } + def merge!(other) + other.to_h.each { |key, values| __getobj__[key] |= Set.new(values) } + self + end + sig { returns(T::Hash[Symbol, T::Array[String]]) } def to_h __getobj__.transform_values(&:to_a) diff --git a/Library/Homebrew/rubocops/cask/no_overrides.rb b/Library/Homebrew/rubocops/cask/no_overrides.rb index 7ac3689968..6d657305ba 100644 --- a/Library/Homebrew/rubocops/cask/no_overrides.rb +++ b/Library/Homebrew/rubocops/cask/no_overrides.rb @@ -8,9 +8,9 @@ module RuboCop include CaskHelp # These stanzas can be overridden by `on_*` blocks, so take them into account. - # TODO: Update this list if new stanzas are added to `Cask::DSL` that call `set_unique_stanza`. + # TODO: Update this list when stanzas in `Cask::DSL` start or stop calling `set_unique_stanza`. OVERRIDABLE_METHODS = [ - :appcast, :arch, :auto_updates, :conflicts_with, :container, + :appcast, :arch, :auto_updates, :container, :desc, :homepage, :os, :sha256, :url, :version ].freeze diff --git a/Library/Homebrew/test/cask/dsl_spec.rb b/Library/Homebrew/test/cask/dsl_spec.rb index 1af79f135f..5423eca663 100644 --- a/Library/Homebrew/test/cask/dsl_spec.rb +++ b/Library/Homebrew/test/cask/dsl_spec.rb @@ -738,6 +738,16 @@ RSpec.describe Cask::DSL, :cask, :no_api do end end + context "when specified multiple times" do + let(:token) { "with-conflicts-with-multiple" } + + it "merges and deduplicates all conflicts_with stanzas" do + os_conflict = OS.mac? ? "macos-caffeine" : "linux-caffeine" + expect(cask.conflicts_with[:cask]) + .to eq(Set.new(["local-caffeine", "with-caffeine", os_conflict])) + end + end + context "with invalid conflicts_with key" do let(:token) { "invalid-conflicts-with-key" } diff --git a/Library/Homebrew/test/rubocops/cask/no_overrides_spec.rb b/Library/Homebrew/test/rubocops/cask/no_overrides_spec.rb index 65ce65c854..684b4d652b 100644 --- a/Library/Homebrew/test/rubocops/cask/no_overrides_spec.rb +++ b/Library/Homebrew/test/rubocops/cask/no_overrides_spec.rb @@ -153,6 +153,19 @@ RSpec.describe RuboCop::Cop::Cask::NoOverrides, :config do CASK end + it "accepts `conflicts_with` in both top-level and `on_*` blocks" do + expect_no_offenses <<~CASK + cask "foo" do + version "1.2.3" + conflicts_with cask: "foo-beta" + + on_sequoia :or_older do + conflicts_with cask: "foo-legacy" + end + end + CASK + end + it "reports an offense when `on_*` blocks override a single upper-level stanza" do expect_offense <<~CASK cask 'foo' do diff --git a/Library/Homebrew/test/support/fixtures/cask/Casks/with-conflicts-with-multiple.rb b/Library/Homebrew/test/support/fixtures/cask/Casks/with-conflicts-with-multiple.rb new file mode 100644 index 0000000000..1b67c88503 --- /dev/null +++ b/Library/Homebrew/test/support/fixtures/cask/Casks/with-conflicts-with-multiple.rb @@ -0,0 +1,20 @@ +# typed: false + +cask "with-conflicts-with-multiple" do + version "1.2.3" + sha256 "8dd95f83a6cbf67dd73f003c476ea38a5aab367e3af8f56d485c0ff9017b6ee5" + + on_macos do + conflicts_with cask: "macos-caffeine" + end + on_linux do + conflicts_with cask: "linux-caffeine" + end + + url "https://brew.sh/ConflictsWith-1.2.3.dmg" + name "ConflictsWith" + homepage "https://brew.sh/" + + conflicts_with cask: "local-caffeine" + conflicts_with cask: ["local-caffeine", "with-caffeine"] +end