├── .github ├── dependabot.yml ├── workflows │ ├── actionlint.yml │ ├── stale-issues.yml │ └── tests.yml └── zizmor.yml ├── .gitignore ├── .rubocop.yml ├── .ruby-version ├── Formula └── t │ ├── tarballs │ └── testbottest-0.1.tbz │ └── testbottest.rb ├── Gemfile ├── Gemfile.lock ├── LICENSE.txt ├── README.md ├── cmd └── test-bot.rb ├── lib ├── junit.rb ├── step.rb ├── test.rb ├── test_bot.rb ├── test_bot.rbi ├── test_cleanup.rb ├── test_formulae.rb ├── test_formulae.rbi ├── test_runner.rb ├── test_runner.rbi └── tests │ ├── bottles_fetch.rb │ ├── cleanup_after.rb │ ├── cleanup_before.rb │ ├── formulae.rb │ ├── formulae_dependents.rb │ ├── formulae_detect.rb │ ├── setup.rb │ └── tap_syntax.rb └── spec ├── global.rb ├── homebrew ├── step_spec.rb └── tests │ └── setup_spec.rb ├── spec_helper.rb └── stub ├── development_tools.rb ├── formula.rb ├── formula_installer.rb ├── github_releases.rb ├── os.rb ├── system_command.rb ├── tap.rb ├── utils.rb └── utils ├── analytics.rb ├── bottles.rb └── github └── artifacts.rb /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # This file is synced from the `.github` repository, do not modify it directly. 2 | --- 3 | version: 2 4 | updates: 5 | - package-ecosystem: github-actions 6 | directory: "/" 7 | schedule: 8 | interval: weekly 9 | day: friday 10 | time: '08:00' 11 | timezone: Etc/UTC 12 | allow: 13 | - dependency-type: all 14 | groups: 15 | github-actions: 16 | patterns: 17 | - "*" 18 | - package-ecosystem: bundler 19 | directories: 20 | - "/" 21 | - "/Library/Homebrew" 22 | schedule: 23 | interval: weekly 24 | day: friday 25 | time: '08:00' 26 | timezone: Etc/UTC 27 | allow: 28 | - dependency-type: all 29 | groups: 30 | bundler: 31 | patterns: 32 | - "*" 33 | 34 | -------------------------------------------------------------------------------- /.github/workflows/actionlint.yml: -------------------------------------------------------------------------------- 1 | # This file is synced from the `.github` repository, do not modify it directly. 2 | name: Actionlint 3 | 4 | on: 5 | push: 6 | branches: 7 | - main 8 | - master 9 | pull_request: 10 | 11 | defaults: 12 | run: 13 | shell: bash -xeuo pipefail {0} 14 | 15 | concurrency: 16 | group: "actionlint-${{ github.ref }}" 17 | cancel-in-progress: ${{ github.event_name == 'pull_request' }} 18 | 19 | env: 20 | HOMEBREW_DEVELOPER: 1 21 | HOMEBREW_NO_AUTO_UPDATE: 1 22 | HOMEBREW_NO_ENV_HINTS: 1 23 | 24 | permissions: {} 25 | 26 | jobs: 27 | workflow_syntax: 28 | if: github.repository_owner == 'Homebrew' 29 | runs-on: ubuntu-latest 30 | permissions: 31 | contents: read 32 | steps: 33 | - name: Set up Homebrew 34 | id: setup-homebrew 35 | uses: Homebrew/actions/setup-homebrew@master 36 | with: 37 | core: false 38 | cask: false 39 | test-bot: false 40 | 41 | - name: Install tools 42 | run: brew install actionlint shellcheck zizmor 43 | 44 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 45 | with: 46 | persist-credentials: false 47 | 48 | - run: zizmor --format sarif . > results.sarif 49 | 50 | - name: Upload SARIF file 51 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 52 | # We can't use the SARIF file when triggered by `merge_group` so we don't upload it. 53 | if: always() && github.event_name != 'merge_group' 54 | with: 55 | name: results.sarif 56 | path: results.sarif 57 | 58 | - name: Set up actionlint 59 | run: echo "::add-matcher::$(brew --repository)/.github/actionlint-matcher.json" 60 | 61 | - run: actionlint 62 | 63 | upload_sarif: 64 | needs: workflow_syntax 65 | # We want to always upload this even if `actionlint` failed. 66 | # This is only available on public repositories. 67 | if: > 68 | always() && 69 | !contains(fromJSON('["cancelled", "skipped"]'), needs.workflow_syntax.result) && 70 | !github.event.repository.private && 71 | github.event_name != 'merge_group' 72 | runs-on: ubuntu-latest 73 | permissions: 74 | contents: read 75 | security-events: write 76 | steps: 77 | - name: Download SARIF file 78 | uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 79 | with: 80 | name: results.sarif 81 | path: results.sarif 82 | 83 | - name: Upload SARIF file 84 | uses: github/codeql-action/upload-sarif@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19 85 | with: 86 | sarif_file: results.sarif 87 | category: zizmor 88 | -------------------------------------------------------------------------------- /.github/workflows/stale-issues.yml: -------------------------------------------------------------------------------- 1 | # This file is synced from the `.github` repository, do not modify it directly. 2 | name: Manage stale issues 3 | 4 | on: 5 | push: 6 | paths: 7 | - .github/workflows/stale-issues.yml 8 | branches-ignore: 9 | - dependabot/** 10 | schedule: 11 | # Once every day at midnight UTC 12 | - cron: "0 0 * * *" 13 | issue_comment: 14 | 15 | permissions: {} 16 | 17 | defaults: 18 | run: 19 | shell: bash -xeuo pipefail {0} 20 | 21 | concurrency: 22 | group: stale-issues 23 | cancel-in-progress: ${{ github.event_name != 'issue_comment' }} 24 | 25 | jobs: 26 | stale: 27 | if: > 28 | github.repository_owner == 'Homebrew' && ( 29 | github.event_name != 'issue_comment' || ( 30 | contains(github.event.issue.labels.*.name, 'stale') || 31 | contains(github.event.pull_request.labels.*.name, 'stale') 32 | ) 33 | ) 34 | runs-on: ubuntu-latest 35 | permissions: 36 | contents: write 37 | issues: write 38 | pull-requests: write 39 | steps: 40 | - name: Mark/Close Stale Issues and Pull Requests 41 | uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0 42 | with: 43 | repo-token: ${{ secrets.GITHUB_TOKEN }} 44 | days-before-stale: 21 45 | days-before-close: 7 46 | stale-issue-message: > 47 | This issue has been automatically marked as stale because it has not had 48 | recent activity. It will be closed if no further activity occurs. 49 | stale-pr-message: > 50 | This pull request has been automatically marked as stale because it has not had 51 | recent activity. It will be closed if no further activity occurs. 52 | exempt-issue-labels: "gsoc-outreachy,help wanted,in progress" 53 | exempt-pr-labels: "gsoc-outreachy,help wanted,in progress" 54 | delete-branch: true 55 | 56 | bump-pr-stale: 57 | if: > 58 | github.repository_owner == 'Homebrew' && ( 59 | github.event_name != 'issue_comment' || ( 60 | contains(github.event.issue.labels.*.name, 'stale') || 61 | contains(github.event.pull_request.labels.*.name, 'stale') 62 | ) 63 | ) 64 | runs-on: ubuntu-latest 65 | permissions: 66 | contents: write 67 | issues: write 68 | pull-requests: write 69 | steps: 70 | - name: Mark/Close Stale `bump-formula-pr` and `bump-cask-pr` Pull Requests 71 | uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0 72 | with: 73 | repo-token: ${{ secrets.GITHUB_TOKEN }} 74 | days-before-stale: 2 75 | days-before-close: 1 76 | stale-pr-message: > 77 | This pull request has been automatically marked as stale because it has not had 78 | recent activity. It will be closed if no further activity occurs. To keep this 79 | pull request open, add a `help wanted` or `in progress` label. 80 | exempt-pr-labels: "help wanted,in progress" 81 | any-of-labels: "bump-formula-pr,bump-cask-pr" 82 | delete-branch: true 83 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: master 5 | pull_request: 6 | jobs: 7 | rspec: 8 | strategy: 9 | matrix: 10 | include: 11 | - os: macOS 12 | runner: macOS-latest 13 | - os: Linux 14 | runner: ubuntu-latest 15 | name: RSpec (${{ matrix.os }}) 16 | runs-on: ${{ matrix.runner }} 17 | steps: 18 | - name: Set up Homebrew 19 | id: set-up-homebrew 20 | uses: Homebrew/actions/setup-homebrew@master 21 | 22 | - name: Set up Ruby 23 | uses: ruby/setup-ruby@13e7a03dc3ac6c3798f4570bfead2aed4d96abfb # v1.244.0 24 | with: 25 | bundler-cache: true 26 | 27 | - name: Run Homebrew/homebrew-test-bot RSpec tests 28 | run: bundle exec rspec 29 | 30 | tests: 31 | permissions: 32 | contents: read 33 | strategy: 34 | matrix: 35 | include: 36 | - os: macOS 37 | runner: macOS-latest 38 | - os: Linux 39 | runner: ubuntu-latest 40 | workdir: /github/home 41 | container: '{"image": "ghcr.io/homebrew/ubuntu22.04:master", "options": "--user=linuxbrew"}' 42 | name: ${{ matrix.os }} 43 | runs-on: ${{ matrix.runner }} 44 | container: ${{ matrix.container && fromJSON(matrix.container) || '' }} 45 | defaults: 46 | run: 47 | shell: bash 48 | working-directory: ${{ matrix.workdir || github.workspace }} 49 | steps: 50 | - name: Set up Homebrew 51 | id: set-up-homebrew 52 | uses: Homebrew/actions/setup-homebrew@master 53 | 54 | - name: Cache Homebrew Bundler RubyGems 55 | id: cache 56 | uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0 57 | with: 58 | path: ${{ steps.set-up-homebrew.outputs.gems-path }} 59 | key: ${{ runner.os }}-rubygems-${{ steps.set-up-homebrew.outputs.gems-hash }} 60 | restore-keys: ${{ runner.os }}-rubygems- 61 | 62 | - run: brew test-bot --only-cleanup-before 63 | 64 | - run: brew test-bot --only-setup 65 | 66 | - run: brew test-bot --only-tap-syntax 67 | 68 | - run: brew test-bot --only-formulae-detect --test-default-formula 69 | id: formulae-detect 70 | 71 | - id: brew-test-bot-formulae 72 | run: | 73 | brew test-bot \ 74 | --only-formulae \ 75 | --junit \ 76 | --only-json-tab \ 77 | --skip-dependents \ 78 | --testing-formulae="$TESTING_FORMULAE" 79 | env: 80 | HOMEBREW_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }} 81 | TESTING_FORMULAE: ${{ steps.formulae-detect.outputs.testing_formulae }} 82 | 83 | - run: | 84 | brew test-bot --only-formulae-dependents --junit \ 85 | --testing-formulae="$TESTING_FORMULAE" \ 86 | --skipped-or-failed-formulae="$SKIPPED_OR_FAILED_FORMULAE" 87 | env: 88 | TESTING_FORMULAE: ${{ steps.formulae-detect.outputs.testing_formulae }} 89 | SKIPPED_OR_FAILED_FORMULAE: ${{ steps.brew-test-bot-formulae.outputs.skipped_or_failed_formulae }} 90 | 91 | - name: Output brew test-bot failures 92 | run: | 93 | cat steps_output.txt 94 | rm steps_output.txt 95 | 96 | - name: Output brew bottle output 97 | run: | 98 | cat bottle_output.txt 99 | rm bottle_output.txt 100 | 101 | - run: brew test-bot --only-cleanup-after 102 | 103 | - run: rm -rvf -- *.bottle*.{json,tar.gz} 104 | 105 | - run: brew test-bot --only-setup --dry-run 106 | 107 | - run: brew test-bot testbottest --only-formulae --dry-run 108 | -------------------------------------------------------------------------------- /.github/zizmor.yml: -------------------------------------------------------------------------------- 1 | rules: 2 | unpinned-uses: 3 | config: 4 | policies: 5 | Homebrew/actions/*: ref-pin 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage/ 2 | vendor/ 3 | bottle_output.txt 4 | brew-test-bot.xml 5 | steps_output.txt 6 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | # This file is synced from `Homebrew/brew` by the `.github` repository, do not modify it directly. 2 | --- 3 | AllCops: 4 | ParserEngine: parser_prism 5 | TargetRubyVersion: 3.3 6 | NewCops: enable 7 | Include: 8 | - "**/*.rbi" 9 | Exclude: 10 | - Homebrew/sorbet/rbi/{annotations,dsl,gems}/**/*.rbi 11 | - Homebrew/sorbet/rbi/parser*.rbi 12 | - Homebrew/bin/* 13 | - Homebrew/vendor/**/* 14 | - Taps/*/*/vendor/**/* 15 | - "**/vendor/**/*" 16 | SuggestExtensions: 17 | rubocop-minitest: false 18 | Layout/ArgumentAlignment: 19 | Exclude: 20 | - Taps/*/*/*.rb 21 | - "/**/Formula/**/*.rb" 22 | - "**/Formula/**/*.rb" 23 | Layout/CaseIndentation: 24 | EnforcedStyle: end 25 | Layout/FirstArrayElementIndentation: 26 | EnforcedStyle: consistent 27 | Layout/FirstHashElementIndentation: 28 | EnforcedStyle: consistent 29 | Layout/EndAlignment: 30 | EnforcedStyleAlignWith: start_of_line 31 | Layout/HashAlignment: 32 | EnforcedHashRocketStyle: table 33 | EnforcedColonStyle: table 34 | Layout/LeadingCommentSpace: 35 | Exclude: 36 | - Taps/*/*/cmd/*.rb 37 | Layout/LineLength: 38 | Max: 118 39 | AllowedPatterns: 40 | - "#: " 41 | - ' url "' 42 | - ' mirror "' 43 | - " plist_options " 44 | - ' executable: "' 45 | - ' font "' 46 | - ' homepage "' 47 | - ' name "' 48 | - ' pkg "' 49 | - ' pkgutil: "' 50 | - " sha256 cellar: " 51 | - " sha256 " 52 | - "#{language}" 53 | - "#{version." 54 | - ' "/Library/Application Support/' 55 | - "\"/Library/Caches/" 56 | - "\"/Library/PreferencePanes/" 57 | - ' "~/Library/Application Support/' 58 | - "\"~/Library/Caches/" 59 | - "\"~/Library/Containers" 60 | - "\"~/Application Support" 61 | - " was verified as official when first introduced to the cask" 62 | Layout/SpaceAroundOperators: 63 | Enabled: false 64 | Layout/SpaceBeforeBrackets: 65 | Exclude: 66 | - "**/*_spec.rb" 67 | - Taps/*/*/*.rb 68 | - "/**/{Formula,Casks}/**/*.rb" 69 | - "**/{Formula,Casks}/**/*.rb" 70 | Lint/AmbiguousBlockAssociation: 71 | Enabled: false 72 | Lint/DuplicateBranch: 73 | Exclude: 74 | - Taps/*/*/*.rb 75 | - "/**/{Formula,Casks}/**/*.rb" 76 | - "**/{Formula,Casks}/**/*.rb" 77 | Lint/ParenthesesAsGroupedExpression: 78 | Exclude: 79 | - Taps/*/*/*.rb 80 | - "/**/Formula/**/*.rb" 81 | - "**/Formula/**/*.rb" 82 | Lint/UnusedMethodArgument: 83 | AllowUnusedKeywordArguments: true 84 | Metrics: 85 | Enabled: false 86 | Naming/BlockForwarding: 87 | Enabled: false 88 | Naming/FileName: 89 | Regex: !ruby/regexp /^[\w\@\-\+\.]+(\.rb)?$/ 90 | Naming/HeredocDelimiterNaming: 91 | ForbiddenDelimiters: 92 | - END, EOD, EOF 93 | Naming/InclusiveLanguage: 94 | CheckStrings: true 95 | FlaggedTerms: 96 | slave: 97 | AllowedRegex: 98 | - gitslave 99 | - log_slave 100 | - ssdb_slave 101 | - var_slave 102 | - patches/13_fix_scope_for_show_slave_status_data.patch 103 | Naming/MethodName: 104 | AllowedPatterns: 105 | - "\\A(fetch_)?HEAD\\?\\Z" 106 | Naming/MethodParameterName: 107 | inherit_mode: 108 | merge: 109 | - AllowedNames 110 | Naming/VariableNumber: 111 | Enabled: false 112 | Style/AndOr: 113 | EnforcedStyle: always 114 | Style/ArgumentsForwarding: 115 | Enabled: false 116 | Style/AutoResourceCleanup: 117 | Enabled: true 118 | Style/BarePercentLiterals: 119 | EnforcedStyle: percent_q 120 | Style/BlockDelimiters: 121 | BracesRequiredMethods: 122 | - sig 123 | Style/ClassAndModuleChildren: 124 | Exclude: 125 | - "**/*.rbi" 126 | Style/CollectionMethods: 127 | Enabled: true 128 | Style/DisableCopsWithinSourceCodeDirective: 129 | Enabled: true 130 | Include: 131 | - Taps/*/*/*.rb 132 | - "/**/{Formula,Casks}/**/*.rb" 133 | - "**/{Formula,Casks}/**/*.rb" 134 | Style/Documentation: 135 | Exclude: 136 | - Taps/**/* 137 | - "/**/{Formula,Casks}/**/*.rb" 138 | - "**/{Formula,Casks}/**/*.rb" 139 | - "**/*.rbi" 140 | Style/EmptyMethod: 141 | Exclude: 142 | - "**/*.rbi" 143 | Style/FetchEnvVar: 144 | Exclude: 145 | - Taps/*/*/*.rb 146 | - "/**/Formula/**/*.rb" 147 | - "**/Formula/**/*.rb" 148 | Style/FrozenStringLiteralComment: 149 | EnforcedStyle: always 150 | Exclude: 151 | - Taps/*/*/*.rb 152 | - "/**/{Formula,Casks}/**/*.rb" 153 | - "**/{Formula,Casks}/**/*.rb" 154 | - Homebrew/test/**/Casks/**/*.rb 155 | - "**/*.rbi" 156 | - "**/Brewfile" 157 | Style/GuardClause: 158 | Exclude: 159 | - Taps/*/*/*.rb 160 | - "/**/{Formula,Casks}/**/*.rb" 161 | - "**/{Formula,Casks}/**/*.rb" 162 | Style/HashAsLastArrayItem: 163 | Exclude: 164 | - Taps/*/*/*.rb 165 | - "/**/Formula/**/*.rb" 166 | - "**/Formula/**/*.rb" 167 | Style/InverseMethods: 168 | InverseMethods: 169 | :blank?: :present? 170 | Style/InvertibleUnlessCondition: 171 | Enabled: true 172 | InverseMethods: 173 | :==: :!= 174 | :zero?: 175 | :blank?: :present? 176 | Style/MutableConstant: 177 | EnforcedStyle: strict 178 | Style/NumericLiteralPrefix: 179 | EnforcedOctalStyle: zero_only 180 | Style/NumericLiterals: 181 | MinDigits: 11 182 | Strict: true 183 | Style/OpenStructUse: 184 | Exclude: 185 | - Taps/**/* 186 | Style/OptionalBooleanParameter: 187 | AllowedMethods: 188 | - respond_to? 189 | - respond_to_missing? 190 | Style/RedundantLineContinuation: 191 | Enabled: false 192 | Style/RescueStandardError: 193 | EnforcedStyle: implicit 194 | Style/ReturnNil: 195 | Enabled: true 196 | Style/StderrPuts: 197 | Enabled: false 198 | Style/StringConcatenation: 199 | Exclude: 200 | - Taps/*/*/*.rb 201 | - "/**/{Formula,Casks}/**/*.rb" 202 | - "**/{Formula,Casks}/**/*.rb" 203 | Style/StringLiterals: 204 | EnforcedStyle: double_quotes 205 | Style/StringLiteralsInInterpolation: 206 | EnforcedStyle: double_quotes 207 | Style/StringMethods: 208 | Enabled: true 209 | Style/SuperWithArgsParentheses: 210 | Enabled: false 211 | Style/SymbolArray: 212 | EnforcedStyle: brackets 213 | Style/TernaryParentheses: 214 | EnforcedStyle: require_parentheses_when_complex 215 | Style/TopLevelMethodDefinition: 216 | Enabled: true 217 | Exclude: 218 | - Taps/**/* 219 | Style/TrailingCommaInArguments: 220 | EnforcedStyleForMultiline: comma 221 | Style/TrailingCommaInArrayLiteral: 222 | EnforcedStyleForMultiline: comma 223 | Style/TrailingCommaInHashLiteral: 224 | EnforcedStyleForMultiline: comma 225 | Style/UnlessLogicalOperators: 226 | Enabled: true 227 | EnforcedStyle: forbid_logical_operators 228 | Style/WordArray: 229 | MinSize: 4 230 | 231 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.4.4 2 | -------------------------------------------------------------------------------- /Formula/t/tarballs/testbottest-0.1.tbz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Homebrew/homebrew-test-bot/d8513f35bea19a203b542a74a1d34988a77be9d7/Formula/t/tarballs/testbottest-0.1.tbz -------------------------------------------------------------------------------- /Formula/t/testbottest.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Testbottest < Formula 4 | desc "Minimal C program and Makefile used for testing Homebrew" 5 | homepage "https://github.com/Homebrew/brew" 6 | url "file://#{Tap.fetch("homebrew", "test-bot").formula_dir}/t/tarballs/testbottest-0.1.tbz" 7 | sha256 "246c4839624d0b97338ce976100d56bd9331d9416e178eb0f74ef050c1dbdaad" 8 | license "BSD-2-Clause" 9 | head "https://github.com/Homebrew/homebrew-test-bot.git" 10 | 11 | depends_on xcode: ["10.2", :optional] 12 | 13 | fails_with gcc: "6" 14 | 15 | def install 16 | odie "whoops, shouldn't be using java!" if build.with?("xcode") 17 | 18 | system "make", "install", "PREFIX=#{prefix}" 19 | end 20 | 21 | def post_install 22 | system "#{bin}/testbottest" 23 | end 24 | 25 | test do 26 | assert_equal "testbottest\n", shell_output("#{bin}/testbottest") 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | ruby file: ".ruby-version" 6 | 7 | group :test do 8 | gem "activesupport" 9 | gem "rspec" 10 | gem "simplecov" 11 | gem "sorbet-runtime" 12 | end 13 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | activesupport (8.0.2) 5 | base64 6 | benchmark (>= 0.3) 7 | bigdecimal 8 | concurrent-ruby (~> 1.0, >= 1.3.1) 9 | connection_pool (>= 2.2.5) 10 | drb 11 | i18n (>= 1.6, < 2) 12 | logger (>= 1.4.2) 13 | minitest (>= 5.1) 14 | securerandom (>= 0.3) 15 | tzinfo (~> 2.0, >= 2.0.5) 16 | uri (>= 0.13.1) 17 | base64 (0.3.0) 18 | benchmark (0.4.1) 19 | bigdecimal (3.2.1) 20 | concurrent-ruby (1.3.5) 21 | connection_pool (2.5.3) 22 | diff-lcs (1.6.2) 23 | docile (1.4.1) 24 | drb (2.2.3) 25 | i18n (1.14.7) 26 | concurrent-ruby (~> 1.0) 27 | logger (1.7.0) 28 | minitest (5.25.5) 29 | rspec (3.13.1) 30 | rspec-core (~> 3.13.0) 31 | rspec-expectations (~> 3.13.0) 32 | rspec-mocks (~> 3.13.0) 33 | rspec-core (3.13.4) 34 | rspec-support (~> 3.13.0) 35 | rspec-expectations (3.13.5) 36 | diff-lcs (>= 1.2.0, < 2.0) 37 | rspec-support (~> 3.13.0) 38 | rspec-mocks (3.13.5) 39 | diff-lcs (>= 1.2.0, < 2.0) 40 | rspec-support (~> 3.13.0) 41 | rspec-support (3.13.4) 42 | securerandom (0.4.1) 43 | simplecov (0.22.0) 44 | docile (~> 1.1) 45 | simplecov-html (~> 0.11) 46 | simplecov_json_formatter (~> 0.1) 47 | simplecov-html (0.13.1) 48 | simplecov_json_formatter (0.1.4) 49 | sorbet-runtime (0.5.12142) 50 | tzinfo (2.0.6) 51 | concurrent-ruby (~> 1.0) 52 | uri (1.0.3) 53 | 54 | PLATFORMS 55 | ruby 56 | 57 | DEPENDENCIES 58 | activesupport 59 | rspec 60 | simplecov 61 | sorbet-runtime 62 | 63 | RUBY VERSION 64 | ruby 3.4.4p34 65 | 66 | BUNDLED WITH 67 | 2.6.7 68 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | BSD 2-Clause License 2 | 3 | Copyright (c) 2009-present, Homebrew contributors 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 17 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 18 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 20 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 21 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 22 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 24 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 25 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Homebrew Test Bot 2 | 3 | Tests the full lifecycle of a Homebrew change to a tap (Git repository). 4 | 5 | ## Install 6 | 7 | `brew test-bot` is automatically installed when first run. 8 | 9 | ## Usage 10 | 11 | See [the `brew test-bot` section of the `brew man` output](https://docs.brew.sh/Manpage#test-bot-options-formula) or `brew test-bot --help`. 12 | 13 | ## Tests 14 | 15 | Tests can be run with `bundle install && bundle exec rspec`. 16 | 17 | ## Copyright 18 | 19 | Copyright (c) Homebrew maintainers. See [LICENSE.txt](https://github.com/Homebrew/homebrew-test-bot/blob/HEAD/LICENSE.txt) for details. 20 | -------------------------------------------------------------------------------- /cmd/test-bot.rb: -------------------------------------------------------------------------------- 1 | # typed: true 2 | # frozen_string_literal: true 3 | 4 | require "abstract_command" 5 | require_relative "../lib/test_bot" 6 | 7 | module Homebrew 8 | module Cmd 9 | class TestBotCmd < AbstractCommand 10 | cmd_args do 11 | usage_banner <<~EOS 12 | `test-bot` [] [] 13 | 14 | Tests the full lifecycle of a Homebrew change to a tap (Git repository). For example, for a GitHub Actions pull request that changes a formula `brew test-bot` will ensure the system is cleaned and set up to test the formula, install the formula, run various tests and checks on it, bottle (package) the binaries and test formulae that depend on it to ensure they aren't broken by these changes. 15 | 16 | Only supports GitHub Actions as a CI provider. This is because Homebrew uses GitHub Actions and it's freely available for public and private use with macOS and Linux workers. 17 | EOS 18 | 19 | switch "--dry-run", 20 | description: "Print what would be done rather than doing it." 21 | switch "--cleanup", 22 | description: "Clean all state from the Homebrew directory. Use with care!" 23 | switch "--skip-setup", 24 | description: "Don't check if the local system is set up correctly." 25 | switch "--build-from-source", 26 | description: "Build from source rather than building bottles." 27 | switch "--build-dependents-from-source", 28 | description: "Build dependents from source rather than testing bottles." 29 | switch "--junit", 30 | description: "generate a JUnit XML test results file." 31 | switch "--keep-old", 32 | description: "Run `brew bottle --keep-old` to build new bottles for a single platform." 33 | switch "--skip-relocation", 34 | description: "Run `brew bottle --skip-relocation` to build new bottles that don't require relocation." 35 | switch "--only-json-tab", 36 | description: "Run `brew bottle --only-json-tab` to build new bottles that do not contain a tab." 37 | switch "--local", 38 | description: "Ask Homebrew to write verbose logs under `./logs/` and set `$HOME` to `./home/`" 39 | flag "--tap=", 40 | description: "Use the Git repository of the given tap. Defaults to the core tap for syntax checking." 41 | switch "--fail-fast", 42 | description: "Immediately exit on a failing step." 43 | switch "-v", "--verbose", 44 | description: "Print test step output in real time. Has the side effect of " \ 45 | "passing output as raw bytes instead of re-encoding in UTF-8." 46 | switch "--test-default-formula", 47 | description: "Use a default testing formula when not building " \ 48 | "a tap and no other formulae are specified." 49 | flag "--root-url=", 50 | description: "Use the specified as the root of the bottle's URL instead of Homebrew's default." 51 | flag "--git-name=", 52 | description: "Set the Git author/committer names to the given name." 53 | flag "--git-email=", 54 | description: "Set the Git author/committer email to the given email." 55 | switch "--publish", 56 | description: "Publish the uploaded bottles." 57 | switch "--skip-online-checks", 58 | description: "Don't pass `--online` to `brew audit` and skip `brew livecheck`." 59 | switch "--skip-new", 60 | description: "Don't pass `--new` to `brew audit` for new formulae." 61 | switch "--skip-new-strict", 62 | depends_on: "--skip-new", 63 | description: "Don't pass `--strict` to `brew audit` for new formulae." 64 | switch "--skip-dependents", 65 | description: "Don't test any dependents." 66 | switch "--skip-livecheck", 67 | description: "Don't test livecheck." 68 | switch "--skip-recursive-dependents", 69 | description: "Only test the direct dependents." 70 | switch "--skip-checksum-only-audit", 71 | description: "Don't audit checksum-only changes." 72 | switch "--skip-stable-version-audit", 73 | description: "Don't audit the stable version." 74 | switch "--skip-revision-audit", 75 | description: "Don't audit the revision." 76 | switch "--only-cleanup-before", 77 | description: "Only run the pre-cleanup step. Needs `--cleanup`." 78 | switch "--only-setup", 79 | description: "Only run the local system setup check step." 80 | switch "--only-tap-syntax", 81 | description: "Only run the tap syntax check step." 82 | switch "--stable", 83 | depends_on: "--only-tap-syntax", 84 | description: "Only run the tap syntax checks needed on stable brew." 85 | switch "--only-formulae", 86 | description: "Only run the formulae steps." 87 | switch "--only-formulae-detect", 88 | description: "Only run the formulae detection steps." 89 | switch "--only-formulae-dependents", 90 | description: "Only run the formulae dependents steps." 91 | switch "--only-bottles-fetch", 92 | description: "Only run the bottles fetch steps. This optional post-upload test checks that all " \ 93 | "the bottles were uploaded correctly. It is not run unless requested and only needs " \ 94 | "to be run on a single machine. The bottle commit to be tested must be on the tested " \ 95 | "branch." 96 | switch "--only-cleanup-after", 97 | description: "Only run the post-cleanup step. Needs `--cleanup`." 98 | comma_array "--testing-formulae=", 99 | description: "Use these testing formulae rather than running the formulae detection steps." 100 | comma_array "--added-formulae=", 101 | description: "Use these added formulae rather than running the formulae detection steps." 102 | comma_array "--deleted-formulae=", 103 | description: "Use these deleted formulae rather than running the formulae detection steps." 104 | comma_array "--skipped-or-failed-formulae=", 105 | description: "Use these skipped or failed formulae from formulae steps for a " \ 106 | "formulae dependents step." 107 | comma_array "--tested-formulae=", 108 | description: "Use these tested formulae from formulae steps for a formulae dependents step." 109 | conflicts "--only-formulae-detect", "--testing-formulae" 110 | conflicts "--only-formulae-detect", "--added-formulae" 111 | conflicts "--only-formulae-detect", "--deleted-formulae" 112 | conflicts "--skip-dependents", "--only-formulae-dependents" 113 | conflicts "--only-cleanup-before", "--only-setup", "--only-tap-syntax", 114 | "--only-formulae", "--only-formulae-detect", "--only-formulae-dependents", 115 | "--only-cleanup-after", "--skip-setup" 116 | end 117 | 118 | def run 119 | if ENV["GITHUB_ACTIONS"].present? 120 | ENV["HOMEBREW_COLOR"] = "1" 121 | ENV["HOMEBREW_GITHUB_ACTIONS"] = "1" 122 | end 123 | ENV["HOMEBREW_TEST_BOT"] = "1" 124 | 125 | Homebrew::TestBot.run!(args) 126 | end 127 | end 128 | end 129 | end 130 | -------------------------------------------------------------------------------- /lib/junit.rb: -------------------------------------------------------------------------------- 1 | # typed: true 2 | # frozen_string_literal: true 3 | 4 | module Homebrew 5 | # Creates Junit report with only required by BuildPulse attributes 6 | # See https://github.com/Homebrew/homebrew-test-bot/pull/621#discussion_r658712640 7 | class Junit 8 | def initialize(tests) 9 | @tests = tests 10 | end 11 | 12 | def build(filters: nil) 13 | filters ||= [] 14 | 15 | require "rexml/document" 16 | require "rexml/xmldecl" 17 | require "rexml/cdata" 18 | 19 | @xml_document = REXML::Document.new 20 | @xml_document << REXML::XMLDecl.new 21 | testsuites = @xml_document.add_element "testsuites" 22 | 23 | @tests.each do |test| 24 | next if test.steps.empty? 25 | 26 | testsuite = testsuites.add_element "testsuite" 27 | testsuite.add_attribute "name", "brew-test-bot.#{Utils::Bottles.tag}" 28 | testsuite.add_attribute "timestamp", test.steps.first.start_time.iso8601 29 | 30 | test.steps.each do |step| 31 | next unless filters.any? { |filter| step.command_short.start_with? filter } 32 | 33 | testcase = testsuite.add_element "testcase" 34 | testcase.add_attribute "name", step.command_short 35 | testcase.add_attribute "status", step.status 36 | testcase.add_attribute "time", step.time 37 | testcase.add_attribute "timestamp", step.start_time.iso8601 38 | 39 | next if step.passed? 40 | 41 | elem = testcase.add_element "failure" 42 | elem.add_attribute "message", "#{step.status}: #{step.command.join(" ")}" 43 | end 44 | end 45 | end 46 | 47 | def write(filename) 48 | output_path = Pathname(filename) 49 | output_path.unlink if output_path.exist? 50 | output_path.open("w") do |xml_file| 51 | pretty_print_indent = 2 52 | @xml_document.write(xml_file, pretty_print_indent) 53 | end 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/step.rb: -------------------------------------------------------------------------------- 1 | # typed: true 2 | # frozen_string_literal: true 3 | 4 | require "system_command" 5 | 6 | module Homebrew 7 | # Wraps command invocations. Instantiated by Test#test. 8 | # Handles logging and pretty-printing. 9 | class Step 10 | include SystemCommand::Mixin 11 | 12 | attr_reader :command, :name, :status, :output, :start_time, :end_time 13 | 14 | # Instantiates a Step object. 15 | # @param command [Array] Command to execute and arguments. 16 | # @param env [Hash] Environment variables to set when running command. 17 | def initialize(command, env:, verbose:, named_args: nil, ignore_failures: false, repository: nil) 18 | @named_args = [named_args].flatten.compact.map(&:to_s) 19 | @command = command + @named_args 20 | @env = env 21 | @verbose = verbose 22 | @ignore_failures = ignore_failures 23 | @repository = repository 24 | 25 | @name = command[1]&.delete("-") 26 | @status = :running 27 | @output = nil 28 | end 29 | 30 | def command_trimmed 31 | command.reject { |arg| arg.to_s.start_with?("--exclude") } 32 | .join(" ") 33 | .delete_prefix("#{HOMEBREW_LIBRARY}/Taps/") 34 | .delete_prefix("#{HOMEBREW_PREFIX}/") 35 | .delete_prefix("/usr/bin/") 36 | end 37 | 38 | def command_short 39 | (@command - %W[ 40 | brew 41 | -C 42 | #{HOMEBREW_PREFIX} 43 | #{HOMEBREW_REPOSITORY} 44 | #{@repository} 45 | #{Dir.pwd} 46 | --force 47 | --retry 48 | --verbose 49 | --json 50 | ].freeze).join(" ") 51 | .gsub(HOMEBREW_PREFIX.to_s, "") 52 | .gsub(HOMEBREW_REPOSITORY.to_s, "") 53 | .gsub(@repository.to_s, "") 54 | .gsub(Dir.pwd, "") 55 | end 56 | 57 | def passed? 58 | @status == :passed 59 | end 60 | 61 | def failed? 62 | @status == :failed 63 | end 64 | 65 | def ignored? 66 | @status == :ignored 67 | end 68 | 69 | def puts_command 70 | puts Formatter.headline(command_trimmed, color: :blue) 71 | end 72 | 73 | def puts_result 74 | puts Formatter.headline(Formatter.error("FAILED"), color: :red) unless passed? 75 | end 76 | 77 | def in_github_actions? 78 | ENV["GITHUB_ACTIONS"].present? 79 | end 80 | 81 | def puts_github_actions_annotation(message, title, file, line) 82 | return unless in_github_actions? 83 | 84 | type = if passed? 85 | :notice 86 | elsif ignored? 87 | :warning 88 | else 89 | :error 90 | end 91 | 92 | annotation = GitHub::Actions::Annotation.new(type, message, title:, file:, line:) 93 | puts annotation 94 | end 95 | 96 | def puts_in_github_actions_group(title) 97 | puts "::group::#{title}" if in_github_actions? 98 | yield 99 | puts "::endgroup::" if in_github_actions? 100 | end 101 | 102 | def output? 103 | @output.present? 104 | end 105 | 106 | # The execution time of the task. 107 | # Precondition: Step#run has been called. 108 | # @return [Float] execution time in seconds 109 | def time 110 | end_time - start_time 111 | end 112 | 113 | def puts_full_output 114 | return if @output.blank? || @verbose 115 | 116 | puts_in_github_actions_group("Full #{command_short} output") do 117 | puts @output 118 | end 119 | end 120 | 121 | def annotation_location(name) 122 | formula = Formulary.factory(name) 123 | method_sym = command.second.to_sym 124 | method_location = formula.method(method_sym).source_location if formula.respond_to?(method_sym) 125 | 126 | if method_location.present? && (method_location.first == formula.path.to_s) 127 | method_location 128 | else 129 | [formula.path, nil] 130 | end 131 | rescue FormulaUnavailableError 132 | [@repository.glob("**/#{name}*").first, nil] 133 | end 134 | 135 | def truncate_output(output, max_kb:, context_lines:) 136 | output_lines = output.lines 137 | first_error_index = output_lines.find_index do |line| 138 | line.match?(/\berror:\s+/i) && !line.strip.match?(/^::error( .*)?::/) 139 | end 140 | 141 | if first_error_index.blank? 142 | output = [] 143 | 144 | # Collect up to max_kb worth of the last lines of output. 145 | output_lines.reverse_each do |line| 146 | # Check output.present? so that we at least have _some_ output. 147 | break if line.length + output.join.length > max_kb && output.present? 148 | 149 | output.unshift line 150 | end 151 | 152 | output.join 153 | else 154 | start = [first_error_index - context_lines, 0].max 155 | # Let GitHub Actions truncate us to 4KB if needed. 156 | output_lines[start..].join 157 | end 158 | end 159 | 160 | def run(dry_run: false, fail_fast: false) 161 | @start_time = Time.now 162 | 163 | puts_command 164 | if dry_run 165 | @status = :passed 166 | puts_result 167 | return 168 | end 169 | 170 | raise "git should always be called with -C!" if command[0] == "git" && %w[-C clone].exclude?(command[1]) 171 | 172 | executable, *args = command 173 | 174 | result = system_command executable, args:, 175 | print_stdout: @verbose, 176 | print_stderr: @verbose, 177 | env: @env 178 | 179 | @end_time = Time.now 180 | 181 | @status = if result.success? 182 | :passed 183 | elsif @ignore_failures 184 | :ignored 185 | else 186 | :failed 187 | end 188 | 189 | puts_result 190 | 191 | output = result.merged_output 192 | 193 | # ActiveSupport can barf on some Unicode so don't use .present? 194 | if output.empty? 195 | puts if @verbose 196 | exit 1 if fail_fast && failed? 197 | return 198 | end 199 | 200 | output.force_encoding(Encoding::UTF_8) 201 | @output = if output.valid_encoding? 202 | output 203 | else 204 | output.encode!(Encoding::UTF_16, invalid: :replace) 205 | output.encode!(Encoding::UTF_8) 206 | end 207 | 208 | return if passed? 209 | 210 | puts_full_output 211 | 212 | unless in_github_actions? 213 | puts 214 | exit 1 if fail_fast && failed? 215 | return 216 | end 217 | 218 | os_string = if OS.mac? 219 | str = "macOS #{MacOS.version.pretty_name} (#{MacOS.version})" 220 | str << " on Apple Silicon" if Hardware::CPU.arm? 221 | 222 | str 223 | else 224 | "#{OS.kernel_name} #{Hardware::CPU.arch}" 225 | end 226 | 227 | @named_args.each do |name| 228 | next if name.blank? 229 | 230 | path, line = annotation_location(name) 231 | next if path.blank? 232 | 233 | # GitHub Actions has a 4KB maximum for annotations. 234 | annotation_output = truncate_output(@output, max_kb: 4, context_lines: 5) 235 | 236 | annotation_title = "`#{command_trimmed}` failed on #{os_string}!" 237 | file = path.to_s.delete_prefix("#{@repository}/") 238 | puts_in_github_actions_group("Truncated #{command_short} output") do 239 | puts_github_actions_annotation(annotation_output, annotation_title, file, line) 240 | end 241 | end 242 | 243 | exit 1 if fail_fast && failed? 244 | end 245 | end 246 | end 247 | -------------------------------------------------------------------------------- /lib/test.rb: -------------------------------------------------------------------------------- 1 | # typed: true 2 | # frozen_string_literal: true 3 | 4 | require "utils/analytics" 5 | 6 | module Homebrew 7 | class Test 8 | def failed_steps 9 | @steps.select(&:failed?) 10 | end 11 | 12 | def ignored_steps 13 | @steps.select(&:ignored?) 14 | end 15 | 16 | attr_reader :steps 17 | 18 | protected 19 | 20 | def cleanup?(args) 21 | Homebrew::TestBot.cleanup?(args) 22 | end 23 | 24 | def local?(args) 25 | Homebrew::TestBot.local?(args) 26 | end 27 | 28 | private 29 | 30 | attr_reader :tap, :git, :repository 31 | 32 | def initialize(tap: nil, git: nil, dry_run: false, fail_fast: false, verbose: false) 33 | @tap = tap 34 | @git = git 35 | @dry_run = dry_run 36 | @fail_fast = fail_fast 37 | @verbose = verbose 38 | 39 | @steps = [] 40 | 41 | @repository = if @tap 42 | @tap.path 43 | else 44 | CoreTap.instance.path 45 | end 46 | end 47 | 48 | def test_header(klass, method: "run!") 49 | puts 50 | puts Formatter.headline("Running #{klass}##{method}", color: :magenta) 51 | end 52 | 53 | def info_header(text) 54 | puts Formatter.headline(text, color: :cyan) 55 | end 56 | 57 | def test(*arguments, named_args: nil, env: {}, verbose: @verbose, ignore_failures: false, report_analytics: false) 58 | step = Step.new( 59 | arguments, 60 | named_args:, 61 | env:, 62 | verbose:, 63 | ignore_failures:, 64 | repository: @repository, 65 | ) 66 | step.run(dry_run: @dry_run, fail_fast: @fail_fast) 67 | @steps << step 68 | 69 | if ENV["HOMEBREW_TEST_BOT_ANALYTICS"].present? && report_analytics 70 | ::Utils::Analytics.report_test_bot_test(step.command_short, step.passed?) 71 | end 72 | 73 | step 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /lib/test_bot.rb: -------------------------------------------------------------------------------- 1 | # typed: true 2 | # frozen_string_literal: true 3 | 4 | require_relative "step" 5 | require_relative "test_runner" 6 | 7 | require "date" 8 | require "json" 9 | 10 | require "development_tools" 11 | require "formula" 12 | require "formula_installer" 13 | require "os" 14 | require "tap" 15 | require "utils" 16 | require "utils/bottles" 17 | 18 | module Homebrew 19 | module TestBot 20 | module_function 21 | 22 | GIT = "/usr/bin/git" 23 | 24 | HOMEBREW_TAP_REGEX = %r{^([\w-]+)/homebrew-([\w-]+)$} 25 | 26 | def cleanup?(args) 27 | args.cleanup? || ENV["GITHUB_ACTIONS"].present? 28 | end 29 | 30 | def local?(args) 31 | args.local? || ENV["GITHUB_ACTIONS"].present? 32 | end 33 | 34 | def resolve_test_tap(tap = nil) 35 | return Tap.fetch(tap) if tap 36 | 37 | # Get tap from GitHub Actions GITHUB_REPOSITORY 38 | git_url = ENV.fetch("GITHUB_REPOSITORY", nil) 39 | return if git_url.blank? 40 | 41 | url_path = git_url.sub(%r{^https?://.*github\.com/}, "") 42 | .chomp("/") 43 | .sub(/\.git$/, "") 44 | 45 | return CoreTap.instance if url_path == CoreTap.instance.full_name 46 | 47 | begin 48 | Tap.fetch(url_path) if url_path.match?(HOMEBREW_TAP_REGEX) 49 | rescue 50 | # Don't care if tap fetch fails 51 | nil 52 | end 53 | end 54 | 55 | def run!(args) 56 | $stdout.sync = true 57 | $stderr.sync = true 58 | 59 | if Pathname.pwd == HOMEBREW_PREFIX && cleanup?(args) 60 | raise UsageError, "cannot use --cleanup from HOMEBREW_PREFIX as it will delete all output." 61 | end 62 | 63 | ENV["HOMEBREW_BOOTSNAP"] = "1" if OS.linux? || (OS.mac? && MacOS.version != :sequoia) 64 | ENV["HOMEBREW_DEVELOPER"] = "1" 65 | ENV["HOMEBREW_NO_AUTO_UPDATE"] = "1" 66 | ENV["HOMEBREW_NO_EMOJI"] = "1" 67 | ENV["HOMEBREW_NO_INSTALLED_DEPENDENTS_CHECK"] = "1" 68 | ENV["HOMEBREW_FAIL_LOG_LINES"] = "150" 69 | ENV["HOMEBREW_CURL"] = ENV["HOMEBREW_CURL_PATH"] = "/usr/bin/curl" 70 | ENV["HOMEBREW_GIT"] = ENV["HOMEBREW_GIT_PATH"] = GIT 71 | ENV["HOMEBREW_DISALLOW_LIBNSL1"] = "1" 72 | ENV["HOMEBREW_NO_ENV_HINTS"] = "1" 73 | ENV["HOMEBREW_PATH"] = ENV["PATH"] = 74 | "#{HOMEBREW_PREFIX}/bin:#{HOMEBREW_PREFIX}/sbin:#{ENV.fetch("PATH")}" 75 | 76 | if local?(args) 77 | home = "#{Dir.pwd}/home" 78 | logs = "#{Dir.pwd}/logs" 79 | gitconfig = "#{Dir.home}/.gitconfig" 80 | ENV["HOMEBREW_HOME"] = ENV["HOME"] = home 81 | ENV["HOMEBREW_LOGS"] = logs 82 | FileUtils.mkdir_p home 83 | FileUtils.mkdir_p logs 84 | FileUtils.cp gitconfig, home if File.exist?(gitconfig) 85 | end 86 | 87 | tap = resolve_test_tap(args.tap) 88 | 89 | if tap.to_s == CoreTap.instance.name 90 | ENV["HOMEBREW_NO_INSTALL_FROM_API"] = "1" 91 | ENV["HOMEBREW_VERIFY_ATTESTATIONS"] = "1" if args.only_formulae? 92 | end 93 | 94 | # Tap repository if required, this is done before everything else 95 | # because Formula parsing and/or git commit hash lookup depends on it. 96 | # At the same time, make sure Tap is not a shallow clone. 97 | # bottle rebuild and bottle upload rely on full clone. 98 | if tap 99 | if !tap.path.exist? 100 | safe_system "brew", "tap", tap.name 101 | elsif (tap.path/".git/shallow").exist? 102 | raise unless quiet_system GIT, "-C", tap.path, "fetch", "--unshallow" 103 | end 104 | end 105 | 106 | test_bot_tap = Tap.fetch("homebrew/test-bot") 107 | 108 | if test_bot_tap != tap 109 | test_bot_revision = Utils.safe_popen_read( 110 | GIT, "-C", test_bot_tap.path.to_s, 111 | "log", "-1", "--format=%h (%s)" 112 | ).strip 113 | puts Formatter.headline("Using Homebrew/homebrew-test-bot #{test_bot_revision}", color: :cyan) 114 | end 115 | 116 | brew_version = Utils.safe_popen_read( 117 | GIT, "-C", HOMEBREW_REPOSITORY.to_s, 118 | "describe", "--tags", "--abbrev", "--dirty" 119 | ).strip 120 | brew_commit_subject = Utils.safe_popen_read( 121 | GIT, "-C", HOMEBREW_REPOSITORY.to_s, 122 | "log", "-1", "--format=%s" 123 | ).strip 124 | puts Formatter.headline("Using Homebrew/brew #{brew_version} (#{brew_commit_subject})", color: :cyan) 125 | 126 | if tap.to_s != CoreTap.instance.name && CoreTap.instance.installed? 127 | core_revision = Utils.safe_popen_read( 128 | GIT, "-C", CoreTap.instance.path.to_s, 129 | "log", "-1", "--format=%h (%s)" 130 | ).strip 131 | puts Formatter.headline("Using #{CoreTap.instance.full_name} #{core_revision}", color: :cyan) 132 | end 133 | 134 | if tap 135 | tap_github = " (#{ENV["GITHUB_REPOSITORY"]}" if tap.full_name != ENV["GITHUB_REPOSITORY"] 136 | tap_revision = Utils.safe_popen_read( 137 | GIT, "-C", tap.path.to_s, 138 | "log", "-1", "--format=%h (%s)" 139 | ).strip 140 | puts Formatter.headline("Testing #{tap.full_name}#{tap_github} #{tap_revision}:", color: :cyan) 141 | end 142 | 143 | ENV["HOMEBREW_GIT_NAME"] = args.git_name || "BrewTestBot" 144 | ENV["HOMEBREW_GIT_EMAIL"] = args.git_email || 145 | "1589480+BrewTestBot@users.noreply.github.com" 146 | 147 | Homebrew.failed = !TestRunner.run!(tap, git: GIT, args:) 148 | end 149 | end 150 | end 151 | -------------------------------------------------------------------------------- /lib/test_bot.rbi: -------------------------------------------------------------------------------- 1 | # typed: strict 2 | 3 | module Homebrew 4 | module TestBot 5 | include Kernel 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/test_cleanup.rb: -------------------------------------------------------------------------------- 1 | # typed: true 2 | # frozen_string_literal: true 3 | 4 | require "os" 5 | require "tap" 6 | 7 | module Homebrew 8 | class TestCleanup < Test 9 | protected 10 | 11 | REQUIRED_TAPS = %w[ 12 | homebrew/test-bot 13 | ].freeze 14 | 15 | ALLOWED_TAPS = (REQUIRED_TAPS + %W[ 16 | #{CoreTap.instance.name} 17 | homebrew/bundle 18 | homebrew/cask 19 | homebrew/cask-versions 20 | homebrew/services 21 | ]).freeze 22 | 23 | def reset_if_needed(repository) 24 | default_ref = default_origin_ref(repository) 25 | 26 | return if system(git, "-C", repository, "diff", "--quiet", default_ref) 27 | 28 | test git, "-C", repository, "reset", "--hard", default_ref 29 | end 30 | 31 | # Moving files is faster than removing them, 32 | # so move them if the current runner is ephemeral. 33 | def delete_or_move(paths, sudo: false) 34 | return if paths.blank? 35 | 36 | symlinks, paths = paths.partition(&:symlink?) 37 | 38 | FileUtils.rm_f symlinks 39 | return if ENV["HOMEBREW_GITHUB_ACTIONS"].blank? 40 | 41 | paths.select!(&:exist?) 42 | return if paths.blank? 43 | 44 | if ENV["GITHUB_ACTIONS_HOMEBREW_SELF_HOSTED"].present? 45 | if sudo 46 | test "sudo", "rm", "-rf", *paths 47 | else 48 | FileUtils.rm_rf paths 49 | end 50 | else 51 | paths.each do |path| 52 | if sudo 53 | test "sudo", "mv", path, Dir.mktmpdir 54 | else 55 | FileUtils.mv path, Dir.mktmpdir, force: true 56 | end 57 | end 58 | end 59 | end 60 | 61 | def cleanup_shared 62 | FileUtils.chmod_R("u+X", HOMEBREW_CELLAR, force: true) 63 | 64 | if repository.exist? 65 | cleanup_git_meta(repository) 66 | clean_if_needed(repository) 67 | prune_if_needed(repository) 68 | end 69 | 70 | if HOMEBREW_REPOSITORY != HOMEBREW_PREFIX 71 | paths_to_delete = [] 72 | 73 | info_header "Determining #{HOMEBREW_PREFIX} files to purge..." 74 | Keg.must_be_writable_directories.each(&:mkpath) 75 | Pathname.glob("#{HOMEBREW_PREFIX}/**/*", File::FNM_DOTMATCH).each do |path| 76 | next if Keg.must_be_writable_directories.include?(path) 77 | next if path == HOMEBREW_PREFIX/"bin/brew" 78 | next if path == HOMEBREW_PREFIX/"var" 79 | next if path == HOMEBREW_PREFIX/"var/homebrew" 80 | 81 | basename = path.basename.to_s 82 | next if basename == "." 83 | next if basename == ".keepme" 84 | 85 | path_string = path.to_s 86 | next if path_string.start_with?(HOMEBREW_REPOSITORY.to_s) 87 | next if path_string.start_with?(Dir.pwd.to_s) 88 | 89 | # allow deleting non-existent osxfuse symlinks. 90 | if (!path.symlink? || path.resolved_path_exists?) && 91 | # don't try to delete other osxfuse files 92 | path_string.match?("(include|lib)/(lib|osxfuse/|pkgconfig/)?(osx|mac)?fuse(.*.(dylib|h|la|pc))?$") 93 | next 94 | end 95 | 96 | FileUtils.chmod("u+rw", path) if path.owned? && (!path.readable? || !path.writable?) 97 | paths_to_delete << path 98 | end 99 | 100 | # Do this in a second pass so that all children have their permissions fixed before we delete the parent. 101 | info_header "Purging..." 102 | delete_or_move paths_to_delete 103 | end 104 | 105 | if tap 106 | checkout_branch_if_needed(HOMEBREW_REPOSITORY) 107 | reset_if_needed(HOMEBREW_REPOSITORY) 108 | clean_if_needed(HOMEBREW_REPOSITORY) 109 | end 110 | 111 | # Keep all "brew" invocations after HOMEBREW_REPOSITORY operations 112 | # (which cleans up Homebrew/brew) 113 | taps_to_remove = Tap.map do |t| 114 | next if t.name == tap&.name 115 | next if ALLOWED_TAPS.include?(t.name) 116 | 117 | t.path 118 | end.uniq.compact 119 | delete_or_move taps_to_remove 120 | 121 | Pathname.glob("#{HOMEBREW_LIBRARY}/Taps/*/*").each do |git_repo| 122 | cleanup_git_meta(git_repo) 123 | next if repository == git_repo 124 | 125 | checkout_branch_if_needed(git_repo) 126 | reset_if_needed(git_repo) 127 | prune_if_needed(git_repo) 128 | end 129 | 130 | # don't need to do `brew cleanup` unless we're self-hosted. 131 | return if ENV["HOMEBREW_GITHUB_ACTIONS"] && !ENV["GITHUB_ACTIONS_HOMEBREW_SELF_HOSTED"] 132 | 133 | test "brew", "cleanup", "--prune=3" 134 | end 135 | 136 | private 137 | 138 | def default_origin_ref(repository) 139 | default_branch = Utils.popen_read( 140 | git, "-C", repository, "symbolic-ref", "refs/remotes/origin/HEAD", "--short" 141 | ).strip.presence 142 | default_branch ||= "origin/master" 143 | default_branch 144 | end 145 | 146 | def checkout_branch_if_needed(repository) 147 | # We limit this to two parts, because branch names can have slashes in 148 | default_branch = default_origin_ref(repository).split("/", 2).last 149 | current_branch = Utils.safe_popen_read( 150 | git, "-C", repository, "symbolic-ref", "HEAD", "--short" 151 | ).strip 152 | return if default_branch == current_branch 153 | 154 | test git, "-C", repository, "checkout", "-f", default_branch 155 | end 156 | 157 | def cleanup_git_meta(repository) 158 | pr_locks = "#{repository}/.git/refs/remotes/*/pr/*/*.lock" 159 | Dir.glob(pr_locks) { |lock| FileUtils.rm_f lock } 160 | FileUtils.rm_f "#{repository}/.git/gc.log" 161 | end 162 | 163 | def clean_if_needed(repository) 164 | return if repository == HOMEBREW_PREFIX && HOMEBREW_PREFIX != HOMEBREW_REPOSITORY 165 | 166 | clean_args = [ 167 | "-dx", 168 | "--exclude=/*.bottle*.*", 169 | "--exclude=/Library/Taps", 170 | "--exclude=/Library/Homebrew/vendor", 171 | ] 172 | return if Utils.safe_popen_read( 173 | git, "-C", repository, "clean", "--dry-run", *clean_args 174 | ).strip.empty? 175 | 176 | test git, "-C", repository, "clean", "-ff", *clean_args 177 | end 178 | 179 | def prune_if_needed(repository) 180 | return unless Utils.safe_popen_read( 181 | "#{git} -C '#{repository}' -c gc.autoDetach=false gc --auto 2>&1", 182 | ).include?("git prune") 183 | 184 | test git, "-C", repository, "prune" 185 | end 186 | end 187 | end 188 | -------------------------------------------------------------------------------- /lib/test_formulae.rb: -------------------------------------------------------------------------------- 1 | # typed: true 2 | # frozen_string_literal: true 3 | 4 | module Homebrew 5 | module Tests 6 | class TestFormulae < Test 7 | attr_accessor :skipped_or_failed_formulae 8 | attr_reader :artifact_cache 9 | 10 | def initialize(tap:, git:, dry_run:, fail_fast:, verbose:) 11 | super 12 | 13 | @skipped_or_failed_formulae = [] 14 | @artifact_cache = Pathname.new("artifact-cache") 15 | # Let's keep track of the artifacts we've already downloaded 16 | # to avoid repeatedly trying to download the same thing. 17 | @downloaded_artifacts = Hash.new { |h, k| h[k] = [] } 18 | end 19 | 20 | protected 21 | 22 | def cached_event_json 23 | return unless (event_json = artifact_cache/"event.json").exist? 24 | 25 | event_json 26 | end 27 | 28 | def github_event_payload 29 | return if (github_event_path = ENV.fetch("GITHUB_EVENT_PATH", nil)).blank? 30 | 31 | JSON.parse(File.read(github_event_path)) 32 | end 33 | 34 | def previous_github_sha 35 | return if tap.blank? 36 | return unless repository.directory? 37 | return if ENV["GITHUB_ACTIONS"].blank? 38 | return if (payload = github_event_payload).blank? 39 | 40 | head_repo_owner = payload.dig("pull_request", "head", "repo", "owner", "login") 41 | head_from_fork = head_repo_owner != ENV.fetch("GITHUB_REPOSITORY_OWNER") 42 | return if head_from_fork && head_repo_owner != "BrewTestBot" 43 | 44 | # If we have a cached event payload, then we failed to get the artifact we wanted 45 | # from `GITHUB_EVENT_PATH`, so use the cached payload to check for a SHA1. 46 | event_payload = JSON.parse(cached_event_json.read) if cached_event_json.present? 47 | event_payload ||= payload 48 | 49 | event_payload.fetch("before", nil) 50 | end 51 | 52 | def artifact_metadata(check_suite_nodes, repo, event_name, workflow_name, check_run_name, artifact_pattern) 53 | candidate_nodes = check_suite_nodes.select do |node| 54 | next false if node.fetch("status") != "COMPLETED" 55 | 56 | workflow_run = node.fetch("workflowRun") 57 | next false if workflow_run.blank? 58 | next false if workflow_run.fetch("event") != event_name 59 | next false if workflow_run.dig("workflow", "name") != workflow_name 60 | 61 | check_run_nodes = node.dig("checkRuns", "nodes") 62 | next false if check_run_nodes.blank? 63 | 64 | check_run_nodes.any? do |check_run_node| 65 | check_run_node.fetch("name") == check_run_name && check_run_node.fetch("status") == "COMPLETED" 66 | end 67 | end 68 | return [] if candidate_nodes.blank? 69 | 70 | run_id = candidate_nodes.max_by { |node| Time.parse(node.fetch("updatedAt")) } 71 | .dig("workflowRun", "databaseId") 72 | return [] if run_id.blank? 73 | 74 | url = GitHub.url_to("repos", repo, "actions", "runs", run_id, "artifacts") 75 | response = GitHub::API.open_rest(url) 76 | return [] if response.fetch("total_count").zero? 77 | 78 | artifacts = response.fetch("artifacts") 79 | artifacts.select do |artifact| 80 | File.fnmatch?(artifact_pattern, artifact.fetch("name"), File::FNM_EXTGLOB) 81 | end 82 | end 83 | 84 | GRAPHQL_QUERY = <<~GRAPHQL 85 | query ($owner: String!, $repo: String!, $commit: GitObjectID!) { 86 | repository(owner: $owner, name: $repo) { 87 | object(oid: $commit) { 88 | ... on Commit { 89 | checkSuites(last: 100) { 90 | nodes { 91 | status 92 | updatedAt 93 | workflowRun { 94 | databaseId 95 | event 96 | workflow { 97 | name 98 | } 99 | } 100 | checkRuns(last: 100) { 101 | nodes { 102 | name 103 | status 104 | } 105 | } 106 | } 107 | } 108 | } 109 | } 110 | } 111 | } 112 | GRAPHQL 113 | 114 | def download_artifacts_from_previous_run!(artifact_pattern, dry_run:) 115 | return if dry_run 116 | return if GitHub::API.credentials_type == :none 117 | return if (sha = previous_github_sha).blank? 118 | 119 | pull_number = github_event_payload.dig("pull_request", "number") 120 | return if pull_number.blank? 121 | 122 | github_repository = ENV.fetch("GITHUB_REPOSITORY") 123 | owner, repo = *github_repository.split("/") 124 | pr_labels = GitHub.pull_request_labels(owner, repo, pull_number) 125 | # Also disable bottle cache for PRs modifying workflows to avoid cache poisoning. 126 | return if pr_labels.include?("CI-no-bottle-cache") || pr_labels.include?("workflows") 127 | 128 | variables = { 129 | owner:, 130 | repo:, 131 | commit: sha, 132 | } 133 | 134 | response = GitHub::API.open_graphql(GRAPHQL_QUERY, variables:) 135 | check_suite_nodes = response.dig("repository", "object", "checkSuites", "nodes") 136 | return if check_suite_nodes.blank? 137 | 138 | wanted_artifacts = artifact_metadata(check_suite_nodes, github_repository, "pull_request", 139 | "CI", "conclusion", artifact_pattern) 140 | wanted_artifacts_pattern = artifact_pattern 141 | if wanted_artifacts.empty? 142 | # If we didn't find the artifacts that we wanted, fall back to the `event_payload` artifact. 143 | wanted_artifacts = artifact_metadata(check_suite_nodes, github_repository, "pull_request_target", 144 | "Triage tasks", "upload-metadata", "event_payload") 145 | wanted_artifacts_pattern = "event_payload" 146 | end 147 | return if wanted_artifacts.empty? 148 | 149 | if (attempted_artifact = wanted_artifacts.find do |artifact| 150 | @downloaded_artifacts[sha].include?(artifact.fetch("name")) 151 | end) 152 | opoo "Already tried #{attempted_artifact.fetch("name")} from #{sha}, giving up" 153 | return 154 | end 155 | 156 | cached_event_json&.unlink if File.fnmatch?(wanted_artifacts_pattern, "event_payload", File::FNM_EXTGLOB) 157 | 158 | require "utils/github/artifacts" 159 | 160 | ohai "Downloading artifacts matching pattern #{wanted_artifacts_pattern} from #{sha}" 161 | artifact_cache.mkpath 162 | artifact_cache.cd do 163 | wanted_artifacts.each do |artifact| 164 | name = artifact.fetch("name") 165 | ohai "Downloading artifact #{name} from #{sha}" 166 | @downloaded_artifacts[sha] << name 167 | 168 | download_url = artifact.fetch("archive_download_url") 169 | artifact_id = artifact.fetch("id") 170 | GitHub.download_artifact(download_url, artifact_id.to_s) 171 | end 172 | end 173 | 174 | return if wanted_artifacts_pattern == artifact_pattern 175 | 176 | # If we made it here, then we downloaded an `event_payload` artifact. 177 | # We can now use this `event_payload` artifact to attempt to download the artifact we wanted. 178 | download_artifacts_from_previous_run!(artifact_pattern, dry_run:) 179 | rescue GitHub::API::AuthenticationFailedError => e 180 | opoo e 181 | end 182 | 183 | def no_diff?(formula, git_ref) 184 | return false unless repository.directory? 185 | 186 | @fetched_refs ||= [] 187 | if @fetched_refs.exclude?(git_ref) 188 | test git, "-C", repository, "fetch", "origin", git_ref, ignore_failures: true 189 | @fetched_refs << git_ref if steps.last.passed? 190 | end 191 | 192 | relative_formula_path = formula.path.relative_path_from(repository) 193 | system(git, "-C", repository, "diff", "--no-ext-diff", "--quiet", git_ref, "--", relative_formula_path) 194 | end 195 | 196 | def local_bottle_hash(formula, bottle_dir:) 197 | return if (local_bottle_json = bottle_glob(formula, bottle_dir, ".json").first).blank? 198 | 199 | JSON.parse(local_bottle_json.read) 200 | end 201 | 202 | def artifact_cache_valid?(formula, formulae_dependents: false) 203 | sha = if formulae_dependents 204 | previous_github_sha 205 | else 206 | local_bottle_hash(formula, bottle_dir: artifact_cache)&.dig(formula.name, "formula", "tap_git_revision") 207 | end 208 | 209 | return false if sha.blank? 210 | return false unless no_diff?(formula, sha) 211 | 212 | recursive_dependencies = if formulae_dependents 213 | formula.recursive_dependencies 214 | else 215 | formula.recursive_dependencies do |_, dep| 216 | Dependency.prune if dep.build? || dep.test? 217 | end 218 | end 219 | 220 | recursive_dependencies.all? do |dep| 221 | no_diff?(dep.to_formula, sha) 222 | end 223 | end 224 | 225 | def bottle_glob(formula_name, bottle_dir = Pathname.pwd, ext = ".tar.gz", bottle_tag: Utils::Bottles.tag.to_s) 226 | bottle_dir.glob("#{formula_name}--*.#{bottle_tag}.bottle*#{ext}") 227 | end 228 | 229 | def install_formula_from_bottle(formula_name, testing_formulae_dependents:, dry_run:, bottle_dir: Pathname.pwd) 230 | bottle_filename = bottle_glob(formula_name, bottle_dir).first 231 | if bottle_filename.blank? 232 | if testing_formulae_dependents && !dry_run 233 | raise "Failed to find bottle for '#{formula_name}'." 234 | elsif !dry_run 235 | return false 236 | end 237 | 238 | bottle_filename = "$BOTTLE_FILENAME" 239 | end 240 | 241 | install_args = [] 242 | install_args += %w[--ignore-dependencies --skip-post-install] if testing_formulae_dependents 243 | test "brew", "install", *install_args, bottle_filename 244 | install_step = steps.last 245 | 246 | if !dry_run && !testing_formulae_dependents && install_step.passed? 247 | bottle_hash = local_bottle_hash(formula_name, bottle_dir:) 248 | bottle_revision = bottle_hash.dig(formula_name, "formula", "tap_git_revision") 249 | bottle_header = "Bottle cache hit" 250 | bottle_commit_details = if @fetched_refs&.include?(bottle_revision) 251 | Utils.safe_popen_read(git, "-C", repository, "show", "--format=reference", bottle_revision) 252 | else 253 | bottle_revision 254 | end 255 | bottle_message = "Bottle for #{formula_name} built at #{bottle_commit_details}".strip 256 | 257 | if ENV["GITHUB_ACTIONS"].present? 258 | puts GitHub::Actions::Annotation.new( 259 | :notice, 260 | bottle_message, 261 | file: bottle_hash.dig(formula_name, "formula", "tap_git_path"), 262 | title: bottle_header, 263 | ) 264 | else 265 | ohai bottle_header, bottle_message 266 | end 267 | end 268 | return install_step.passed? if !testing_formulae_dependents || !install_step.passed? 269 | 270 | test "brew", "unlink", formula_name 271 | puts 272 | 273 | install_step.passed? 274 | end 275 | 276 | def bottled?(formula, no_older_versions: false) 277 | # If a formula has an `:all` bottle, then all its dependencies have 278 | # to be bottled too for us to use it. We only need to recurse 279 | # up the dep tree when we encounter an `:all` bottle because 280 | # a formula is not bottled unless its dependencies are. 281 | if formula.bottle_specification.tag?(Utils::Bottles.tag(:all)) 282 | formula.deps.all? do |dep| 283 | bottle_no_older_versions = no_older_versions && (!dep.test? || dep.build?) 284 | bottled?(dep.to_formula, no_older_versions: bottle_no_older_versions) 285 | end 286 | else 287 | formula.bottle_specification.tag?(Utils::Bottles.tag, no_older_versions:) 288 | end 289 | end 290 | 291 | def bottled_or_built?(formula, built_formulae, no_older_versions: false) 292 | bottled?(formula, no_older_versions:) || built_formulae.include?(formula.full_name) 293 | end 294 | 295 | def downloads_using_homebrew_curl?(formula) 296 | [:stable, :head].any? do |spec_name| 297 | next false unless (spec = formula.send(spec_name)) 298 | 299 | spec.using == :homebrew_curl || spec.resources.values.any? { |r| r.using == :homebrew_curl } 300 | end 301 | end 302 | 303 | def install_curl_if_needed(formula) 304 | return unless downloads_using_homebrew_curl?(formula) 305 | 306 | test "brew", "install", "curl", 307 | env: { "HOMEBREW_DEVELOPER" => nil } 308 | end 309 | 310 | def install_mercurial_if_needed(deps, reqs) 311 | return if (deps | reqs).none? { |d| d.name == "mercurial" && d.build? } 312 | 313 | test "brew", "install", "mercurial", 314 | env: { "HOMEBREW_DEVELOPER" => nil } 315 | end 316 | 317 | def install_subversion_if_needed(deps, reqs) 318 | return if (deps | reqs).none? { |d| d.name == "subversion" && d.build? } 319 | 320 | test "brew", "install", "subversion", 321 | env: { "HOMEBREW_DEVELOPER" => nil } 322 | end 323 | 324 | def skipped(formula_name, reason) 325 | @skipped_or_failed_formulae << formula_name 326 | 327 | puts Formatter.headline( 328 | "#{Formatter.warning("SKIPPED")} #{Formatter.identifier(formula_name)}", 329 | color: :yellow, 330 | ) 331 | opoo reason 332 | end 333 | 334 | def failed(formula_name, reason) 335 | @skipped_or_failed_formulae << formula_name 336 | 337 | puts Formatter.headline( 338 | "#{Formatter.error("FAILED")} #{Formatter.identifier(formula_name)}", 339 | color: :red, 340 | ) 341 | onoe reason 342 | end 343 | 344 | def unsatisfied_requirements_messages(formula) 345 | f = Formulary.factory(formula.full_name) 346 | fi = FormulaInstaller.new(f, build_bottle: true) 347 | 348 | unsatisfied_requirements, = fi.expand_requirements 349 | return if unsatisfied_requirements.blank? 350 | 351 | unsatisfied_requirements.values.flatten.map(&:message).join("\n").presence 352 | end 353 | 354 | def cleanup_during!(keep_formulae = [], args:) 355 | return unless cleanup?(args) 356 | return unless HOMEBREW_CACHE.exist? 357 | 358 | free_gb = Utils.safe_popen_read({ "BLOCKSIZE" => (1000 ** 3).to_s }, "df", HOMEBREW_CACHE.to_s) 359 | .lines[1] # HOMEBREW_CACHE 360 | .split[3] # free GB 361 | .to_i 362 | return if free_gb > 10 363 | 364 | test_header(:TestFormulae, method: :cleanup_during!) 365 | 366 | # HOMEBREW_LOGS can be a subdirectory of HOMEBREW_CACHE. 367 | # Preserve the logs in that case. 368 | logs_are_in_cache = HOMEBREW_LOGS.ascend { |path| break true if path == HOMEBREW_CACHE } 369 | should_save_logs = logs_are_in_cache && HOMEBREW_LOGS.exist? 370 | 371 | test "mv", HOMEBREW_LOGS.to_s, (tmpdir = Dir.mktmpdir) if should_save_logs 372 | FileUtils.chmod_R "u+rw", HOMEBREW_CACHE, force: true 373 | test "rm", "-rf", HOMEBREW_CACHE.to_s 374 | if should_save_logs 375 | FileUtils.mkdir_p HOMEBREW_LOGS.parent 376 | test "mv", "#{tmpdir}/#{HOMEBREW_LOGS.basename}", HOMEBREW_LOGS.to_s 377 | end 378 | 379 | if @cleaned_up_during.blank? 380 | @cleaned_up_during = true 381 | return 382 | end 383 | 384 | installed_formulae = Utils.safe_popen_read("brew", "list", "--full-name", "--formulae").split("\n") 385 | uninstallable_formulae = installed_formulae - keep_formulae 386 | 387 | @installed_formulae_deps ||= Hash.new do |h, formula| 388 | h[formula] = Utils.safe_popen_read("brew", "deps", "--full-name", formula).split("\n") 389 | end 390 | uninstallable_formulae.reject! do |name| 391 | keep_formulae.any? { |f| @installed_formulae_deps[f].include?(name) } 392 | end 393 | 394 | return if uninstallable_formulae.blank? 395 | 396 | test "brew", "uninstall", "--force", "--ignore-dependencies", *uninstallable_formulae 397 | end 398 | 399 | def sorted_formulae 400 | changed_formulae_dependents = {} 401 | 402 | @testing_formulae.each do |formula| 403 | begin 404 | formula_dependencies = 405 | Utils.popen_read("brew", "deps", "--full-name", 406 | "--include-build", 407 | "--include-test", formula) 408 | .split("\n") 409 | # deps can fail if deps are not tapped 410 | unless $CHILD_STATUS.success? 411 | Formulary.factory(formula).recursive_dependencies 412 | # If we haven't got a TapFormulaUnavailableError, then something else is broken 413 | raise "Failed to determine dependencies for '#{formula}'." 414 | end 415 | rescue TapFormulaUnavailableError => e 416 | raise if e.tap.installed? 417 | 418 | e.tap.clear_cache 419 | safe_system "brew", "tap", e.tap.name 420 | retry 421 | end 422 | 423 | unchanged_dependencies = formula_dependencies - @testing_formulae 424 | changed_dependencies = formula_dependencies - unchanged_dependencies 425 | changed_dependencies.each do |changed_formula| 426 | changed_formulae_dependents[changed_formula] ||= 0 427 | changed_formulae_dependents[changed_formula] += 1 428 | end 429 | end 430 | 431 | changed_formulae = changed_formulae_dependents.sort do |a1, a2| 432 | a2[1].to_i <=> a1[1].to_i 433 | end 434 | changed_formulae.map!(&:first) 435 | unchanged_formulae = @testing_formulae - changed_formulae 436 | changed_formulae + unchanged_formulae 437 | end 438 | end 439 | end 440 | end 441 | -------------------------------------------------------------------------------- /lib/test_formulae.rbi: -------------------------------------------------------------------------------- 1 | # typed: strict 2 | 3 | module Homebrew 4 | module TestFormulae 5 | include Kernel 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/test_runner.rb: -------------------------------------------------------------------------------- 1 | # typed: true 2 | # frozen_string_literal: true 3 | 4 | require_relative "junit" 5 | require_relative "test" 6 | require_relative "test_cleanup" 7 | require_relative "test_formulae" 8 | require_relative "tests/cleanup_after" 9 | require_relative "tests/cleanup_before" 10 | require_relative "tests/formulae_detect" 11 | require_relative "tests/formulae_dependents" 12 | require_relative "tests/bottles_fetch" 13 | require_relative "tests/formulae" 14 | require_relative "tests/setup" 15 | require_relative "tests/tap_syntax" 16 | 17 | module Homebrew 18 | module TestRunner 19 | module_function 20 | 21 | def ensure_blank_file_exists!(file) 22 | if file.exist? 23 | file.truncate(0) 24 | else 25 | FileUtils.touch(file) 26 | end 27 | end 28 | 29 | def run!(tap, git:, args:) 30 | tests = T.let([], T::Array[Homebrew::Test]) 31 | skip_setup = args.skip_setup? 32 | skip_cleanup_before = T.let(false, T::Boolean) 33 | 34 | bottle_output_path = Pathname.new("bottle_output.txt") 35 | linkage_output_path = Pathname.new("linkage_output.txt") 36 | @skipped_or_failed_formulae_output_path = Pathname.new("skipped_or_failed_formulae-#{Utils::Bottles.tag}.txt") 37 | 38 | if no_only_args?(args) || args.only_formulae? 39 | ensure_blank_file_exists!(bottle_output_path) 40 | ensure_blank_file_exists!(linkage_output_path) 41 | ensure_blank_file_exists!(@skipped_or_failed_formulae_output_path) 42 | end 43 | 44 | output_paths = { 45 | bottle: bottle_output_path, 46 | linkage: linkage_output_path, 47 | skipped_or_failed_formulae: @skipped_or_failed_formulae_output_path, 48 | } 49 | 50 | test_bot_args = args.named.dup 51 | 52 | # With no arguments just build the most recent commit. 53 | test_bot_args << "HEAD" if test_bot_args.empty? 54 | 55 | test_bot_args.each do |argument| 56 | skip_cleanup_after = argument != test_bot_args.last 57 | current_tests = build_tests(argument, tap:, 58 | git:, 59 | output_paths:, 60 | skip_setup:, 61 | skip_cleanup_before:, 62 | skip_cleanup_after:, 63 | args:) 64 | skip_setup = true 65 | skip_cleanup_before = true 66 | tests += current_tests.values 67 | run_tests(current_tests, args:) 68 | end 69 | 70 | failed_steps = tests.map(&:failed_steps) 71 | .flatten 72 | .compact 73 | ignored_steps = tests.map(&:ignored_steps) 74 | .flatten 75 | .compact 76 | steps_output = if failed_steps.blank? && ignored_steps.blank? 77 | "All steps passed!" 78 | else 79 | output_lines = [] 80 | 81 | if ignored_steps.present? 82 | output_lines += ["Warning: #{ignored_steps.count} failed step#{"s" if ignored_steps.count > 1} ignored!"] 83 | output_lines += ignored_steps.map(&:command_trimmed) 84 | end 85 | 86 | if failed_steps.present? 87 | output_lines += ["Error: #{failed_steps.count} failed step#{"s" if failed_steps.count > 1}!"] 88 | output_lines += failed_steps.map(&:command_trimmed) 89 | end 90 | 91 | output_lines.join("\n") 92 | end 93 | puts steps_output 94 | 95 | steps_output_path = Pathname.new("steps_output.txt") 96 | steps_output_path.unlink if steps_output_path.exist? 97 | steps_output_path.write(steps_output) 98 | 99 | if args.junit? && (no_only_args?(args) || args.only_formulae? || args.only_formulae_dependents?) 100 | junit_filters = %w[audit test] 101 | junit = ::Homebrew::Junit.new(tests) 102 | junit.build(filters: junit_filters) 103 | junit.write("brew-test-bot.xml") 104 | end 105 | 106 | failed_steps.empty? 107 | end 108 | 109 | def no_only_args?(args) 110 | any_only = args.only_cleanup_before? || 111 | args.only_setup? || 112 | args.only_tap_syntax? || 113 | args.only_formulae? || 114 | args.only_formulae_detect? || 115 | args.only_formulae_dependents? || 116 | args.only_bottles_fetch? || 117 | args.only_cleanup_after? 118 | !any_only 119 | end 120 | 121 | def build_tests(argument, tap:, git:, output_paths:, skip_setup:, 122 | skip_cleanup_before:, skip_cleanup_after:, args:) 123 | tests = {} 124 | 125 | no_only_args = no_only_args?(args) 126 | 127 | if !skip_setup && (no_only_args || args.only_setup?) 128 | tests[:setup] = Tests::Setup.new(dry_run: args.dry_run?, 129 | fail_fast: args.fail_fast?, 130 | verbose: args.verbose?) 131 | end 132 | 133 | if no_only_args || args.only_tap_syntax? 134 | tests[:tap_syntax] = Tests::TapSyntax.new(tap: tap || CoreTap.instance, 135 | dry_run: args.dry_run?, 136 | git:, 137 | fail_fast: args.fail_fast?, 138 | verbose: args.verbose?) 139 | end 140 | 141 | no_formulae_flags = args.testing_formulae.nil? && 142 | args.added_formulae.nil? && 143 | args.deleted_formulae.nil? 144 | if no_formulae_flags && (no_only_args || args.only_formulae? || args.only_formulae_detect?) 145 | tests[:formulae_detect] = Tests::FormulaeDetect.new(argument, tap:, 146 | git:, 147 | dry_run: args.dry_run?, 148 | fail_fast: args.fail_fast?, 149 | verbose: args.verbose?) 150 | end 151 | 152 | if no_only_args || args.only_formulae? 153 | tests[:formulae] = Tests::Formulae.new(tap:, 154 | git:, 155 | dry_run: args.dry_run?, 156 | fail_fast: args.fail_fast?, 157 | verbose: args.verbose?, 158 | output_paths:) 159 | end 160 | 161 | if !args.skip_dependents? && (no_only_args || args.only_formulae? || args.only_formulae_dependents?) 162 | tests[:formulae_dependents] = Tests::FormulaeDependents.new(tap:, 163 | git:, 164 | dry_run: args.dry_run?, 165 | fail_fast: args.fail_fast?, 166 | verbose: args.verbose?) 167 | end 168 | 169 | if Homebrew::TestBot.cleanup?(args) 170 | if !skip_cleanup_before && (no_only_args || args.only_cleanup_before?) 171 | tests[:cleanup_before] = Tests::CleanupBefore.new(tap:, 172 | git:, 173 | dry_run: args.dry_run?, 174 | fail_fast: args.fail_fast?, 175 | verbose: args.verbose?) 176 | end 177 | 178 | if !skip_cleanup_after && (no_only_args || args.only_cleanup_after?) 179 | tests[:cleanup_after] = Tests::CleanupAfter.new(tap:, 180 | git:, 181 | dry_run: args.dry_run?, 182 | fail_fast: args.fail_fast?, 183 | verbose: args.verbose?) 184 | end 185 | end 186 | 187 | if args.only_bottles_fetch? 188 | tests[:bottles_fetch] = Tests::BottlesFetch.new(tap:, 189 | git:, 190 | dry_run: args.dry_run?, 191 | fail_fast: args.fail_fast?, 192 | verbose: args.verbose?) 193 | end 194 | 195 | tests 196 | end 197 | 198 | def run_tests(tests, args:) 199 | tests[:cleanup_before]&.run!(args:) 200 | begin 201 | tests[:setup]&.run!(args:) 202 | tests[:tap_syntax]&.run!(args:) 203 | 204 | testing_formulae, added_formulae, deleted_formulae = if (detect_test = tests[:formulae_detect]) 205 | detect_test.run!(args:) 206 | 207 | [ 208 | detect_test.testing_formulae, 209 | detect_test.added_formulae, 210 | detect_test.deleted_formulae, 211 | ] 212 | else 213 | [ 214 | args.testing_formulae.to_a, 215 | args.added_formulae.to_a, 216 | args.deleted_formulae.to_a, 217 | ] 218 | end 219 | 220 | skipped_or_failed_formulae = if (formulae_test = tests[:formulae]) 221 | formulae_test.testing_formulae = testing_formulae 222 | formulae_test.added_formulae = added_formulae 223 | formulae_test.deleted_formulae = deleted_formulae 224 | 225 | formulae_test.run!(args:) 226 | 227 | formulae_test.skipped_or_failed_formulae 228 | elsif args.skipped_or_failed_formulae.present? 229 | Array.new(args.skipped_or_failed_formulae) 230 | elsif @skipped_or_failed_formulae_output_path.exist? 231 | @skipped_or_failed_formulae_output_path.read.chomp.split(",") 232 | else 233 | [] 234 | end 235 | 236 | if (dependents_test = tests[:formulae_dependents]) 237 | dependents_test.testing_formulae = testing_formulae 238 | dependents_test.skipped_or_failed_formulae = skipped_or_failed_formulae 239 | dependents_test.tested_formulae = args.tested_formulae.to_a.presence || testing_formulae 240 | 241 | dependents_test.run!(args:) 242 | end 243 | 244 | if (fetch_test = tests[:bottles_fetch]) 245 | fetch_test.testing_formulae = testing_formulae 246 | 247 | fetch_test.run!(args:) 248 | end 249 | ensure 250 | tests[:cleanup_after]&.run!(args:) 251 | end 252 | end 253 | end 254 | end 255 | -------------------------------------------------------------------------------- /lib/test_runner.rbi: -------------------------------------------------------------------------------- 1 | # typed: strict 2 | 3 | module Homebrew 4 | module TestRunner 5 | include Kernel 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/tests/bottles_fetch.rb: -------------------------------------------------------------------------------- 1 | # typed: true 2 | # frozen_string_literal: true 3 | 4 | module Homebrew 5 | module Tests 6 | class BottlesFetch < TestFormulae 7 | attr_accessor :testing_formulae 8 | 9 | def run!(args:) 10 | info_header "Testing formulae:" 11 | puts testing_formulae 12 | puts 13 | 14 | testing_formulae.each do |formula_name| 15 | fetch_bottles!(formula_name, args:) 16 | puts 17 | end 18 | end 19 | 20 | private 21 | 22 | def fetch_bottles!(formula_name, args:) 23 | test_header(:BottlesFetch, method: "fetch_bottles!(#{formula_name})") 24 | 25 | formula = Formula[formula_name] 26 | return if formula.disabled? 27 | 28 | tags = formula.bottle_specification.collector.tags 29 | 30 | odie "#{formula_name} is missing bottles! Did you mean to use `brew pr-publish`?" if tags.blank? 31 | 32 | tags.each do |tag| 33 | cleanup_during!(args:) 34 | test "brew", "fetch", "--retry", "--formulae", "--bottle-tag=#{tag}", formula_name 35 | end 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/tests/cleanup_after.rb: -------------------------------------------------------------------------------- 1 | # typed: true 2 | # frozen_string_literal: true 3 | 4 | module Homebrew 5 | module Tests 6 | class CleanupAfter < TestCleanup 7 | def run!(args:) 8 | if ENV["HOMEBREW_GITHUB_ACTIONS"].present? && ENV["GITHUB_ACTIONS_HOMEBREW_SELF_HOSTED"].blank? && 9 | # don't need to do post-build cleanup unless testing test-bot itself. 10 | tap.to_s != "homebrew/test-bot" 11 | return 12 | end 13 | 14 | test_header(:CleanupAfter) 15 | 16 | pkill_if_needed 17 | 18 | cleanup_shared 19 | 20 | # Keep all "brew" invocations after cleanup_shared 21 | # (which cleans up Homebrew/brew) 22 | return unless local?(args) 23 | 24 | FileUtils.rm_rf ENV.fetch("HOMEBREW_HOME") 25 | FileUtils.rm_rf ENV.fetch("HOMEBREW_LOGS") 26 | end 27 | 28 | private 29 | 30 | def pkill_if_needed 31 | pgrep = ["pgrep", "-f", HOMEBREW_CELLAR.to_s] 32 | 33 | return unless quiet_system(*pgrep) 34 | 35 | test "pkill", "-f", HOMEBREW_CELLAR.to_s 36 | 37 | return unless quiet_system(*pgrep) 38 | 39 | sleep 1 40 | test "pkill", "-9", "-f", HOMEBREW_CELLAR.to_s if system(*pgrep) 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/tests/cleanup_before.rb: -------------------------------------------------------------------------------- 1 | # typed: true 2 | # frozen_string_literal: true 3 | 4 | module Homebrew 5 | module Tests 6 | class CleanupBefore < TestCleanup 7 | def run!(args:) 8 | test_header(:CleanupBefore) 9 | 10 | if tap.to_s != CoreTap.instance.name && CoreTap.instance.installed? 11 | reset_if_needed(CoreTap.instance.path.to_s) 12 | end 13 | 14 | Pathname.glob("*.bottle*.*").each(&:unlink) 15 | 16 | if ENV["HOMEBREW_GITHUB_ACTIONS"] && !ENV["GITHUB_ACTIONS_HOMEBREW_SELF_HOSTED"] 17 | # minimally fix brew doctor failures (a full clean takes ~5m) 18 | if OS.linux? 19 | # brew doctor complains 20 | bad_paths = %w[ 21 | /usr/local/include/node/ 22 | /opt/pipx_bin/ansible-config 23 | ].map { |path| Pathname.new(path) } 24 | 25 | delete_or_move bad_paths, sudo: true 26 | elsif OS.mac? 27 | delete_or_move HOMEBREW_CELLAR.glob("*") 28 | 29 | frameworks_dir = Pathname("/Library/Frameworks") 30 | frameworks = %w[ 31 | Mono.framework 32 | PluginManager.framework 33 | Python.framework 34 | R.framework 35 | Xamarin.Android.framework 36 | Xamarin.Mac.framework 37 | Xamarin.iOS.framework 38 | ].map { |framework| frameworks_dir/framework } 39 | 40 | delete_or_move frameworks, sudo: true 41 | end 42 | 43 | test "brew", "cleanup", "--prune-prefix" 44 | end 45 | 46 | # Keep all "brew" invocations after cleanup_shared 47 | # (which cleans up Homebrew/brew) 48 | cleanup_shared 49 | 50 | installed_taps = Tap.select(&:installed?).map(&:name) 51 | (REQUIRED_TAPS - installed_taps).each do |tap| 52 | test "brew", "tap", tap 53 | end 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/tests/formulae.rb: -------------------------------------------------------------------------------- 1 | # typed: true 2 | # frozen_string_literal: true 3 | 4 | module Homebrew 5 | module Tests 6 | class Formulae < TestFormulae 7 | attr_writer :testing_formulae, :added_formulae, :deleted_formulae 8 | 9 | def initialize(tap:, git:, dry_run:, fail_fast:, verbose:, output_paths:) 10 | super(tap:, git:, dry_run:, fail_fast:, verbose:) 11 | 12 | @built_formulae = [] 13 | @bottle_checksums = {} 14 | @bottle_output_path = output_paths[:bottle] 15 | @linkage_output_path = output_paths[:linkage] 16 | @skipped_or_failed_formulae_output_path = output_paths[:skipped_or_failed_formulae] 17 | end 18 | 19 | def run!(args:) 20 | test_header(:Formulae) 21 | 22 | verify_local_bottles 23 | 24 | with_env(HOMEBREW_DISABLE_LOAD_FORMULA: "1") do 25 | bottle_specifier = if OS.linux? 26 | "{linux,ubuntu}" 27 | else 28 | "{macos-#{MacOS.version},#{MacOS.version}-#{Hardware::CPU.arch}}" 29 | end 30 | download_artifacts_from_previous_run!("bottles{,_#{bottle_specifier}*}", dry_run: args.dry_run?) 31 | end 32 | @bottle_checksums.merge!( 33 | bottle_glob("*", artifact_cache, ".{json,tar.gz}", bottle_tag: "*").to_h do |bottle_file| 34 | [bottle_file.realpath, bottle_file.sha256] 35 | end, 36 | ) 37 | 38 | # #run! modifies `@testing_formulae`, so we need to track this separately. 39 | @testing_formulae_count = @testing_formulae.count 40 | perform_bash_cleanup = @testing_formulae.include?("bash") 41 | 42 | sorted_formulae.each do |f| 43 | formula!(f, args:) 44 | verify_local_bottles 45 | puts 46 | end 47 | 48 | @deleted_formulae.each do |f| 49 | deleted_formula!(f) 50 | verify_local_bottles 51 | puts 52 | end 53 | 54 | return unless ENV["GITHUB_ACTIONS"] 55 | 56 | # Remove `bash` after it is tested, since leaving a broken `bash` 57 | # installation in the environment can cause issues with subsequent 58 | # GitHub Actions steps. 59 | test "brew", "uninstall", "--formula", "--force", "bash" if perform_bash_cleanup 60 | 61 | File.open(ENV.fetch("GITHUB_OUTPUT"), "a") do |f| 62 | f.puts "skipped_or_failed_formulae=#{@skipped_or_failed_formulae.join(",")}" 63 | end 64 | 65 | @skipped_or_failed_formulae_output_path.write(@skipped_or_failed_formulae.join(",")) 66 | ensure 67 | verify_local_bottles 68 | FileUtils.rm_rf artifact_cache 69 | end 70 | 71 | private 72 | 73 | def tap_needed_taps(deps) 74 | deps.each { |d| d.to_formula.recursive_dependencies } 75 | rescue TapFormulaUnavailableError => e 76 | raise if e.tap.installed? 77 | 78 | e.tap.clear_cache 79 | safe_system "brew", "tap", e.tap.name 80 | retry 81 | end 82 | 83 | def install_ca_certificates_if_needed 84 | return if DevelopmentTools.ca_file_handles_most_https_certificates? 85 | 86 | test "brew", "install", "--formulae", "ca-certificates", 87 | env: { "HOMEBREW_DEVELOPER" => nil } 88 | end 89 | 90 | def install_gcc_if_needed(formula, deps) 91 | installed_gcc = T.let(false, T::Boolean) 92 | begin 93 | deps.each { |dep| CompilerSelector.select_for(dep.to_formula) } 94 | CompilerSelector.select_for(formula) 95 | rescue CompilerSelectionError => e 96 | unless installed_gcc 97 | test "brew", "install", "--formula", "gcc", 98 | env: { "HOMEBREW_DEVELOPER" => nil } 99 | installed_gcc = true 100 | DevelopmentTools.clear_version_cache 101 | retry 102 | end 103 | skipped formula.name, e.message 104 | end 105 | end 106 | 107 | def setup_formulae_deps_instances(formula, formula_name, args:) 108 | conflicts = formula.conflicts 109 | formula_recursive_dependencies = formula.recursive_dependencies.map(&:to_formula) 110 | formula_recursive_dependencies.each do |dependency| 111 | conflicts += dependency.conflicts 112 | end 113 | 114 | # If we depend on a versioned formula, make sure to unlink any other 115 | # installed versions to make sure that we use the right one. 116 | versioned_dependencies = formula_recursive_dependencies.select(&:versioned_formula?) 117 | versioned_dependencies.each do |dependency| 118 | alternative_versions = dependency.versioned_formulae 119 | 120 | begin 121 | unversioned_name = dependency.name.sub(/@\d+(\.\d+)*$/, "") 122 | alternative_versions << Formula[unversioned_name] 123 | rescue FormulaUnavailableError 124 | nil 125 | end 126 | 127 | unneeded_alternative_versions = alternative_versions - formula_recursive_dependencies 128 | conflicts += unneeded_alternative_versions 129 | end 130 | 131 | unlink_formulae = conflicts.map(&:name) 132 | unlink_formulae.uniq.each do |name| 133 | unlink_formula = Formulary.factory(name) 134 | next unless unlink_formula.latest_version_installed? 135 | next unless unlink_formula.linked_keg.exist? 136 | 137 | test "brew", "unlink", name 138 | end 139 | 140 | info_header "Determining dependencies..." 141 | installed = Utils.safe_popen_read("brew", "list", "--formula", "--full-name").split("\n") 142 | dependencies = 143 | Utils.safe_popen_read("brew", "deps", "--formula", 144 | "--include-build", 145 | "--include-test", 146 | "--full-name", 147 | formula_name) 148 | .split("\n") 149 | installed_dependencies = installed & dependencies 150 | installed_dependencies.each do |name| 151 | link_formula = Formulary.factory(name) 152 | next if link_formula.keg_only? 153 | next if link_formula.linked_keg.exist? 154 | 155 | test "brew", "link", name 156 | end 157 | 158 | dependencies -= installed 159 | @unchanged_dependencies = dependencies - @testing_formulae 160 | test "brew", "fetch", "--formulae", "--retry", *@unchanged_dependencies unless @unchanged_dependencies.empty? 161 | 162 | changed_dependencies = dependencies - @unchanged_dependencies 163 | unless changed_dependencies.empty? 164 | test "brew", "fetch", "--formulae", "--retry", "--build-from-source", 165 | *changed_dependencies 166 | 167 | ignore_failures = !args.test_default_formula? && changed_dependencies.any? do |dep| 168 | !bottled?(Formulary.factory(dep), no_older_versions: true) 169 | end 170 | 171 | # Install changed dependencies as new bottles so we don't have 172 | # checksum problems. We have to install all `changed_dependencies` 173 | # in one `brew install` command to make sure they are installed in 174 | # the right order. 175 | test("brew", "install", "--formulae", "--build-from-source", 176 | named_args: changed_dependencies, 177 | ignore_failures:) 178 | # Run postinstall on them because the tested formula might depend on 179 | # this step 180 | test "brew", "postinstall", named_args: changed_dependencies, ignore_failures: 181 | end 182 | 183 | runtime_or_test_dependencies = 184 | Utils.safe_popen_read("brew", "deps", "--formula", "--include-test", formula_name) 185 | .split("\n") 186 | build_dependencies = dependencies - runtime_or_test_dependencies 187 | @unchanged_build_dependencies = build_dependencies - @testing_formulae 188 | end 189 | 190 | def cleanup_bottle_etc_var(formula) 191 | bottle_prefix = formula.opt_prefix/".bottle" 192 | # Nuke etc/var to have them be clean to detect bottle etc/var 193 | # file additions. 194 | Pathname.glob("#{bottle_prefix}/{etc,var}/**/*").each do |bottle_path| 195 | prefix_path = bottle_path.sub(bottle_prefix, HOMEBREW_PREFIX) 196 | FileUtils.rm_rf prefix_path 197 | end 198 | end 199 | 200 | def verify_local_bottles 201 | # Setting `HOMEBREW_DISABLE_LOAD_FORMULA` probably doesn't do anything here but let's set it just to be safe. 202 | with_env(HOMEBREW_DISABLE_LOAD_FORMULA: "1") do 203 | missing_bottles = @bottle_checksums.keys.reject do |bottle_path| 204 | next true if bottle_path.exist? 205 | 206 | what = (bottle_path.extname == ".json") ? "JSON" : "tarball" 207 | onoe "Missing bottle #{what}: #{bottle_path}" 208 | false 209 | end 210 | 211 | mismatched_checksums = @bottle_checksums.reject do |bottle_path, expected_sha256| 212 | next true unless bottle_path.exist? 213 | next true if (actual_sha256 = bottle_path.sha256) == expected_sha256 214 | 215 | onoe <<~ERROR 216 | Bottle checksum mismatch for #{bottle_path}! 217 | Expected: #{expected_sha256} 218 | Actual: #{actual_sha256} 219 | ERROR 220 | false 221 | end 222 | 223 | unexpected_bottles = bottle_glob( 224 | "**/*", Pathname.pwd, ".{json,tar.gz}", bottle_tag: "*" 225 | ).reject do |local_bottle| 226 | next true if @bottle_checksums.key?(local_bottle.realpath) 227 | 228 | what = (local_bottle.extname == ".json") ? "JSON" : "tarball" 229 | onoe "Unexpected bottle #{what}: #{local_bottle}" 230 | false 231 | end 232 | 233 | return true if missing_bottles.blank? && mismatched_checksums.blank? && unexpected_bottles.blank? 234 | 235 | # Delete these files so we don't end up uploading them. 236 | files_to_delete = mismatched_checksums.keys + unexpected_bottles 237 | files_to_delete += files_to_delete.select(&:symlink?).map(&:realpath) 238 | FileUtils.rm_rf files_to_delete 239 | 240 | test "false" # ensure that `test-bot` exits with an error. 241 | 242 | false 243 | end 244 | end 245 | 246 | def bottle_reinstall_formula(formula, new_formula, args:) 247 | unless build_bottle?(formula, args:) 248 | @bottle_filename = nil 249 | return 250 | end 251 | 252 | root_url = args.root_url 253 | 254 | # GitHub Releases url 255 | root_url ||= if tap.present? && !tap.core_tap? && !args.test_default_formula? 256 | "#{tap.default_remote}/releases/download/#{formula.name}-#{formula.pkg_version}" 257 | end 258 | 259 | # This is needed where sparse files may be handled (bsdtar >=3.0). 260 | # We use gnu-tar with sparse files disabled when --only-json-tab is passed. 261 | ENV["HOMEBREW_BOTTLE_SUDO_PURGE"] = "1" if OS.mac? && MacOS.version >= :catalina && !args.only_json_tab? 262 | 263 | bottle_args = ["--verbose", "--json", formula.full_name] 264 | bottle_args << "--keep-old" if args.keep_old? && !new_formula 265 | bottle_args << "--skip-relocation" if args.skip_relocation? 266 | bottle_args << "--force-core-tap" if args.test_default_formula? 267 | bottle_args << "--root-url=#{root_url}" if root_url 268 | bottle_args << "--only-json-tab" if args.only_json_tab? 269 | 270 | verify_local_bottles 271 | test "brew", "bottle", *bottle_args 272 | bottle_step = steps.last 273 | 274 | if !bottle_step.passed? || !bottle_step.output? 275 | failed formula.full_name, "bottling failed" unless args.dry_run? 276 | return 277 | end 278 | 279 | @bottle_filename = Pathname.new( 280 | bottle_step.output 281 | .gsub(%r{.*(\./\S+#{HOMEBREW_BOTTLES_EXTNAME_REGEX}).*}om, '\1'), 282 | ) 283 | @bottle_json_filename = Pathname.new( 284 | @bottle_filename.to_s.gsub(/\.(\d+\.)?tar\.gz$/, ".json"), 285 | ) 286 | 287 | @bottle_checksums[@bottle_filename.realpath] = @bottle_filename.sha256 288 | @bottle_checksums[@bottle_json_filename.realpath] = @bottle_json_filename.sha256 289 | 290 | @bottle_output_path.write(bottle_step.output, mode: "a") 291 | 292 | bottle_merge_args = 293 | ["--merge", "--write", "--no-commit", "--no-all-checks", @bottle_json_filename] 294 | bottle_merge_args << "--keep-old" if args.keep_old? && !new_formula 295 | 296 | test "brew", "bottle", *bottle_merge_args 297 | test "brew", "uninstall", "--formula", "--force", "--ignore-dependencies", formula.full_name 298 | 299 | @testing_formulae.delete(formula.name) 300 | 301 | unless @unchanged_build_dependencies.empty? 302 | test "brew", "uninstall", "--formulae", "--force", "--ignore-dependencies", *@unchanged_build_dependencies 303 | @unchanged_dependencies -= @unchanged_build_dependencies 304 | end 305 | 306 | verify_attestations = if formula.name == "gh" 307 | nil 308 | else 309 | ENV.fetch("HOMEBREW_VERIFY_ATTESTATIONS", nil) 310 | end 311 | test "brew", "install", "--only-dependencies", @bottle_filename, 312 | env: { "HOMEBREW_VERIFY_ATTESTATIONS" => verify_attestations } 313 | test "brew", "install", @bottle_filename, 314 | env: { "HOMEBREW_VERIFY_ATTESTATIONS" => verify_attestations } 315 | end 316 | 317 | def build_bottle?(formula, args:) 318 | # Build and runtime dependencies must be bottled on the current OS, 319 | # but accept an older compatible bottle for test dependencies. 320 | return false if formula.deps.any? do |dep| 321 | !bottled_or_built?( 322 | dep.to_formula, 323 | @built_formulae - @skipped_or_failed_formulae, 324 | no_older_versions: !dep.test?, 325 | ) 326 | end 327 | 328 | !args.build_from_source? 329 | end 330 | 331 | def livecheck(formula) 332 | return unless formula.livecheck_defined? 333 | return if formula.livecheck.skip? 334 | 335 | livecheck_step = test "brew", "livecheck", "--autobump", "--formula", 336 | "--json", "--full-name", formula.full_name 337 | 338 | return if livecheck_step.failed? 339 | return unless livecheck_step.output? 340 | 341 | livecheck_info = JSON.parse(livecheck_step.output)&.first 342 | 343 | if livecheck_info["status"] == "error" 344 | error_msg = if livecheck_info["messages"].present? && livecheck_info["messages"].length.positive? 345 | livecheck_info["messages"].join("\n") 346 | else 347 | # An error message should always be provided alongside an "error" 348 | # status but this is a failsafe 349 | "Error encountered (no message provided)" 350 | end 351 | 352 | if ENV["GITHUB_ACTIONS"].present? 353 | puts GitHub::Actions::Annotation.new( 354 | :error, 355 | error_msg, 356 | title: "#{formula}: livecheck error", 357 | file: formula.path.to_s.delete_prefix("#{repository}/"), 358 | ) 359 | else 360 | onoe error_msg 361 | end 362 | end 363 | 364 | # `status` and `version` are mutually exclusive (the presence of one 365 | # indicates the absence of the other) 366 | return if livecheck_info["status"].present? 367 | 368 | return if livecheck_info["version"]["newer_than_upstream"] != true 369 | 370 | current_version = livecheck_info["version"]["current"] 371 | latest_version = livecheck_info["version"]["latest"] 372 | 373 | newer_than_upstream_msg = if current_version.present? && latest_version.present? 374 | "The formula version (#{current_version}) is newer than the " \ 375 | "version from `brew livecheck` (#{latest_version})." 376 | else 377 | "The formula version is newer than the version from `brew livecheck`." 378 | end 379 | 380 | if ENV["GITHUB_ACTIONS"].present? 381 | puts GitHub::Actions::Annotation.new( 382 | :warning, 383 | newer_than_upstream_msg, 384 | title: "#{formula}: Formula version newer than livecheck", 385 | file: formula.path.to_s.delete_prefix("#{repository}/"), 386 | ) 387 | else 388 | opoo newer_than_upstream_msg 389 | end 390 | end 391 | 392 | def formula!(formula_name, args:) 393 | cleanup_during!(@testing_formulae, args:) 394 | 395 | test_header(:Formulae, method: "formula!(#{formula_name})") 396 | 397 | formula = Formulary.factory(formula_name) 398 | if formula.disabled? 399 | skipped formula_name, "#{formula.full_name} has been disabled!" 400 | return 401 | end 402 | 403 | test "brew", "deps", "--tree", "--annotate", "--include-build", "--include-test", named_args: formula_name 404 | 405 | deps_without_compatible_bottles = formula.deps.map(&:to_formula) 406 | deps_without_compatible_bottles.reject! do |dep| 407 | bottled_or_built?(dep, @built_formulae - @skipped_or_failed_formulae) 408 | end 409 | bottled_on_current_version = bottled?(formula, no_older_versions: true) 410 | 411 | if deps_without_compatible_bottles.present? && !bottled_on_current_version 412 | message = <<~EOS 413 | #{formula_name} has dependencies without compatible bottles: 414 | #{deps_without_compatible_bottles * "\n "} 415 | EOS 416 | skipped formula_name, message 417 | return 418 | end 419 | 420 | new_formula = @added_formulae.include?(formula_name) 421 | ignore_failures = !args.test_default_formula? && !bottled_on_current_version && !new_formula 422 | 423 | deps = [] 424 | reqs = [] 425 | 426 | build_flag = if build_bottle?(formula, args:) 427 | "--build-bottle" 428 | else 429 | if ENV["GITHUB_ACTIONS"].present? 430 | puts GitHub::Actions::Annotation.new( 431 | :warning, 432 | "#{formula} has unbottled dependencies, so a bottle will not be built.", 433 | title: "No bottle built for #{formula}!", 434 | file: formula.path.to_s.delete_prefix("#{repository}/"), 435 | ) 436 | else 437 | onoe "Not building a bottle for #{formula} because it has unbottled dependencies." 438 | end 439 | 440 | skipped formula_name, "No bottle built." 441 | return 442 | end 443 | 444 | # Online checks are a bit flaky and less useful for PRs that modify multiple formulae. 445 | skip_online_checks = args.skip_online_checks? || (@testing_formulae_count > 5) 446 | 447 | fetch_args = [formula_name] 448 | fetch_args << build_flag 449 | fetch_args << "--force" if cleanup?(args) 450 | 451 | audit_args = [formula_name] 452 | audit_args << "--online" unless skip_online_checks 453 | if new_formula 454 | if !args.skip_new? 455 | audit_args << "--new" 456 | elsif !args.skip_new_strict? 457 | audit_args << "--strict" 458 | end 459 | else 460 | audit_args << "--git" << "--skip-style" 461 | audit_args << "--except=unconfirmed_checksum_change" if args.skip_checksum_only_audit? 462 | audit_args << "--except=stable_version" if args.skip_stable_version_audit? 463 | audit_args << "--except=revision" if args.skip_revision_audit? 464 | end 465 | 466 | # This needs to be done before any network operation. 467 | install_ca_certificates_if_needed 468 | 469 | if (messages = unsatisfied_requirements_messages(formula)) 470 | test "brew", "fetch", "--formula", "--retry", *fetch_args 471 | test "brew", "audit", "--formula", *audit_args 472 | 473 | skipped formula_name, messages 474 | return 475 | end 476 | 477 | deps |= formula.deps.to_a.reject(&:optional?) 478 | reqs |= formula.requirements.to_a.reject(&:optional?) 479 | 480 | tap_needed_taps(deps) 481 | install_curl_if_needed(formula) 482 | install_mercurial_if_needed(deps, reqs) 483 | install_subversion_if_needed(deps, reqs) 484 | setup_formulae_deps_instances(formula, formula_name, args:) 485 | 486 | test "brew", "uninstall", "--formula", "--force", formula_name if formula.latest_version_installed? 487 | 488 | install_args = ["--verbose", "--formula"] 489 | install_args << build_flag 490 | 491 | # We can't verify attestations if we're building `gh`. 492 | verify_attestations = if formula_name == "gh" 493 | nil 494 | else 495 | ENV.fetch("HOMEBREW_VERIFY_ATTESTATIONS", nil) 496 | end 497 | # Don't care about e.g. bottle failures for dependencies. 498 | test "brew", "install", "--only-dependencies", *install_args, formula_name, 499 | env: { "HOMEBREW_DEVELOPER" => nil, 500 | "HOMEBREW_VERIFY_ATTESTATIONS" => verify_attestations } 501 | 502 | # Do this after installing dependencies to avoid skipping formulae 503 | # that build with and declare a dependency on GCC. See discussion at 504 | # https://github.com/Homebrew/homebrew-core/pull/86826 505 | install_gcc_if_needed(formula, deps) 506 | 507 | info_header "Starting tests for #{formula_name}" 508 | 509 | test "brew", "fetch", "--formula", "--retry", *fetch_args 510 | 511 | env = {} 512 | env["HOMEBREW_GIT_PATH"] = nil if deps.any? do |d| 513 | d.name == "git" && (!d.test? || d.build?) 514 | end 515 | 516 | install_step_passed = formula_installed_from_bottle = 517 | artifact_cache_valid?(formula) && 518 | verify_local_bottles && # Checking the artifact cache loads formulae, so do this check second. 519 | install_formula_from_bottle(formula_name, 520 | bottle_dir: artifact_cache, 521 | testing_formulae_dependents: false, 522 | dry_run: args.dry_run?) 523 | 524 | install_step_passed ||= begin 525 | test("brew", "install", *install_args, 526 | named_args: formula_name, 527 | env: env.merge({ "HOMEBREW_DEVELOPER" => nil, 528 | "HOMEBREW_VERIFY_ATTESTATIONS" => verify_attestations }), 529 | ignore_failures:, report_analytics: true) 530 | steps.last.passed? 531 | end 532 | 533 | livecheck(formula) if !args.skip_livecheck? && !skip_online_checks 534 | 535 | if ENV["GITHUB_ACTIONS_HOMEBREW_SELF_HOSTED"].present? && OS.mac? && MacOS.version == :sequoia 536 | # Fix intermittent broken disk cache on Sequoia after building from source. 537 | test "/usr/bin/sudo", "--non-interactive", "/usr/sbin/purge" 538 | end 539 | 540 | test "brew", "style", "--formula", formula_name, report_analytics: true 541 | test "brew", "audit", "--formula", *audit_args, report_analytics: true unless formula.deprecated? 542 | unless install_step_passed 543 | if ignore_failures 544 | skipped formula_name, "install failed" 545 | else 546 | failed formula_name, "install failed" 547 | end 548 | 549 | return 550 | end 551 | 552 | if formula_installed_from_bottle 553 | moved_artifacts = bottle_glob(formula_name, artifact_cache, ".{json,tar.gz}").map(&:realpath) 554 | Pathname.pwd.install moved_artifacts 555 | 556 | moved_artifacts.each do |old_location| 557 | new_location = old_location.basename.realpath 558 | @bottle_checksums[new_location] = @bottle_checksums.fetch(old_location) 559 | @bottle_checksums.delete(old_location) 560 | end 561 | else 562 | bottle_reinstall_formula(formula, new_formula, args:) 563 | end 564 | @built_formulae << formula.full_name 565 | test("brew", "linkage", "--test", named_args: formula_name, ignore_failures:, report_analytics: true) 566 | failed_linkage_or_test_messages ||= [] 567 | failed_linkage_or_test_messages << "linkage failed" unless steps.last.passed? 568 | 569 | if steps.last.passed? 570 | # Check for opportunistic linkage. Ignore failures because 571 | # they can be unavoidable but we still want to know about them. 572 | test "brew", "linkage", "--cached", "--test", "--strict", 573 | named_args: formula_name, 574 | ignore_failures: !args.test_default_formula? 575 | end 576 | 577 | test "brew", "linkage", "--cached", formula_name 578 | @linkage_output_path.write(Formatter.headline(steps.last.command_trimmed, color: :blue), mode: "a") 579 | @linkage_output_path.write("\n", mode: "a") 580 | @linkage_output_path.write(steps.last.output, mode: "a") 581 | 582 | test "brew", "install", "--formula", "--only-dependencies", "--include-test", formula_name 583 | 584 | if formula.test_defined? 585 | env = {} 586 | env["HOMEBREW_GIT_PATH"] = nil if deps.any? do |d| 587 | d.name == "git" && (!d.build? || d.test?) 588 | end 589 | 590 | # Intentionally not passing --retry here to avoid papering over 591 | # flaky tests when a formula isn't being pulled in as a dependent. 592 | test("brew", "test", "--verbose", named_args: formula_name, env:, ignore_failures:, report_analytics: true) 593 | failed_linkage_or_test_messages << "test failed" unless steps.last.passed? 594 | end 595 | 596 | # Move bottle and don't test dependents if the formula linkage or test failed. 597 | if failed_linkage_or_test_messages.present? 598 | if @bottle_filename 599 | failed_dir = @bottle_filename.dirname/"failed" 600 | moved_artifacts = [@bottle_filename, @bottle_json_filename].map(&:realpath) 601 | failed_dir.install moved_artifacts 602 | 603 | moved_artifacts.each do |old_location| 604 | new_location = (failed_dir/old_location.basename).realpath 605 | @bottle_checksums[new_location] = @bottle_checksums.fetch(old_location) 606 | @bottle_checksums.delete(old_location) 607 | end 608 | end 609 | 610 | if ignore_failures 611 | skipped formula_name, failed_linkage_or_test_messages.join(", ") 612 | else 613 | failed formula_name, failed_linkage_or_test_messages.join(", ") 614 | end 615 | end 616 | ensure 617 | cleanup_bottle_etc_var(formula) if cleanup?(args) 618 | 619 | if @unchanged_dependencies.present? 620 | test "brew", "uninstall", "--formulae", "--force", "--ignore-dependencies", *@unchanged_dependencies 621 | end 622 | end 623 | 624 | def deleted_formula!(formula_name) 625 | test_header(:Formulae, method: "deleted_formula!(#{formula_name})") 626 | 627 | test "brew", "uses", 628 | "--formula", 629 | "--eval-all", 630 | "--include-build", 631 | "--include-optional", 632 | "--include-test", 633 | formula_name 634 | end 635 | end 636 | end 637 | end 638 | -------------------------------------------------------------------------------- /lib/tests/formulae_dependents.rb: -------------------------------------------------------------------------------- 1 | # typed: true 2 | # frozen_string_literal: true 3 | 4 | module Homebrew 5 | module Tests 6 | class FormulaeDependents < TestFormulae 7 | attr_writer :testing_formulae, :tested_formulae 8 | 9 | def run!(args:) 10 | installable_bottles = @tested_formulae - @skipped_or_failed_formulae 11 | unneeded_formulae = @tested_formulae - @testing_formulae 12 | @skipped_or_failed_formulae += unneeded_formulae 13 | 14 | info_header "Skipped or failed formulae:" 15 | puts skipped_or_failed_formulae 16 | 17 | @testing_formulae_with_tested_dependents = [] 18 | @tested_dependents_list = Pathname("tested-dependents-#{Utils::Bottles.tag}.txt") 19 | 20 | @dependent_testing_formulae = sorted_formulae - skipped_or_failed_formulae 21 | 22 | install_formulae_if_needed_from_bottles!(installable_bottles, args:) 23 | 24 | artifact_specifier = if OS.linux? 25 | "{linux,ubuntu}" 26 | else 27 | "{macos-#{MacOS.version},#{MacOS.version}-#{Hardware::CPU.arch}}" 28 | end 29 | download_artifacts_from_previous_run!("dependents{,_#{artifact_specifier}*}", dry_run: args.dry_run?) 30 | @skip_candidates = if (tested_dependents_cache = artifact_cache/@tested_dependents_list).exist? 31 | tested_dependents_cache.read.split("\n") 32 | else 33 | [] 34 | end 35 | 36 | @dependent_testing_formulae.each do |formula_name| 37 | dependent_formulae!(formula_name, args:) 38 | puts 39 | end 40 | 41 | return unless ENV["GITHUB_ACTIONS"] 42 | 43 | # Remove `bash` after it is tested, since leaving a broken `bash` 44 | # installation in the environment can cause issues with subsequent 45 | # GitHub Actions steps. 46 | return unless @dependent_testing_formulae.include?("bash") 47 | 48 | test "brew", "uninstall", "--formula", "--force", "bash" 49 | end 50 | 51 | private 52 | 53 | def install_formulae_if_needed_from_bottles!(installable_bottles, args:) 54 | installable_bottles.each do |formula_name| 55 | formula = Formulary.factory(formula_name) 56 | next if formula.latest_version_installed? 57 | 58 | install_formula_from_bottle(formula_name, testing_formulae_dependents: true, dry_run: args.dry_run?) 59 | end 60 | end 61 | 62 | def dependent_formulae!(formula_name, args:) 63 | cleanup_during!(@dependent_testing_formulae, args:) 64 | 65 | test_header(:FormulaeDependents, method: "dependent_formulae!(#{formula_name})") 66 | @testing_formulae_with_tested_dependents << formula_name 67 | 68 | formula = Formulary.factory(formula_name) 69 | 70 | source_dependents, bottled_dependents, testable_dependents = 71 | dependents_for_formula(formula, formula_name, args:) 72 | 73 | return if source_dependents.blank? && bottled_dependents.blank? && testable_dependents.blank? 74 | 75 | # If we installed this from a bottle, then the formula isn't linked. 76 | # If the formula isn't linked, `brew install --only-dependences` does 77 | # nothing with the message: 78 | # Warning: formula x.y.z is already installed, it's just not linked. 79 | # To link this version, run: 80 | # brew link formula 81 | unlink_conflicts formula 82 | test "brew", "link", formula_name unless formula.keg_only? 83 | 84 | # Install formula dependencies. These may not be installed. 85 | test "brew", "install", "--only-dependencies", 86 | named_args: formula_name, 87 | ignore_failures: !bottled?(formula, no_older_versions: true), 88 | env: { "HOMEBREW_DEVELOPER" => nil } 89 | return unless steps.last.passed? 90 | 91 | # Restore etc/var files that may have been nuked in the build stage. 92 | test "brew", "postinstall", 93 | named_args: formula_name, 94 | ignore_failures: !bottled?(formula, no_older_versions: true) 95 | return unless steps.last.passed? 96 | 97 | # Test texlive first to avoid GitHub-hosted runners running out of storage. 98 | # TODO: Try generalising this by sorting dependents according to install size, 99 | # where ideally install size should include recursive dependencies. 100 | [source_dependents, bottled_dependents].each do |dependent_array| 101 | texlive = dependent_array.find { |dependent| dependent.name == "texlive" } 102 | next unless texlive.present? 103 | 104 | dependent_array.delete(texlive) 105 | dependent_array.unshift(texlive) 106 | end 107 | 108 | source_dependents.each do |dependent| 109 | install_dependent(dependent, testable_dependents, build_from_source: true, args:) 110 | install_dependent(dependent, testable_dependents, args:) if bottled?(dependent) 111 | end 112 | 113 | bottled_dependents.each do |dependent| 114 | install_dependent(dependent, testable_dependents, args:) 115 | end 116 | end 117 | 118 | def dependents_for_formula(formula, formula_name, args:) 119 | info_header "Determining dependents..." 120 | 121 | # Always skip recursive dependents on Intel. It's really slow. 122 | # Also skip recursive dependents on Linux unless it's a Linux-only formula. 123 | skip_recursive_dependents = args.skip_recursive_dependents? || 124 | (OS.mac? && Hardware::CPU.intel?) || 125 | (OS.linux? && formula.requirements.exclude?(LinuxRequirement.new)) 126 | 127 | uses_args = %w[--formula --eval-all] 128 | uses_include_test_args = [*uses_args, "--include-test"] 129 | uses_include_test_args << "--recursive" unless skip_recursive_dependents 130 | dependents = with_env(HOMEBREW_STDERR: "1") do 131 | Utils.safe_popen_read("brew", "uses", *uses_include_test_args, formula_name) 132 | .split("\n") 133 | end 134 | 135 | # TODO: Consider handling the following case better. 136 | # `foo` has a build dependency on `bar`, and `bar` has a runtime dependency on 137 | # `baz`. When testing `baz` with `--build-dependents-from-source`, `foo` is 138 | # not tested, but maybe should be. 139 | dependents += with_env(HOMEBREW_STDERR: "1") do 140 | Utils.safe_popen_read("brew", "uses", *uses_args, "--include-build", formula_name) 141 | .split("\n") 142 | end 143 | dependents&.uniq! 144 | dependents&.sort! 145 | 146 | dependents -= @tested_formulae 147 | dependents = dependents.map { |d| Formulary.factory(d) } 148 | 149 | dependents = dependents.zip(dependents.map do |f| 150 | if skip_recursive_dependents 151 | f.deps.reject(&:implicit?) 152 | else 153 | begin 154 | Dependency.expand(f, cache_key: "test-bot-dependents") do |_, dependency| 155 | Dependency.skip if dependency.implicit? 156 | Dependency.keep_but_prune_recursive_deps if dependency.build? || dependency.test? 157 | end 158 | rescue TapFormulaUnavailableError => e 159 | raise if e.tap.installed? 160 | 161 | e.tap.clear_cache 162 | safe_system "brew", "tap", e.tap.name 163 | retry 164 | end 165 | end.reject(&:optional?) 166 | end) 167 | 168 | # Defer formulae which could be tested later 169 | # i.e. formulae that also depend on something else yet to be built in this test run. 170 | dependents.reject! do |_, deps| 171 | still_to_test = @dependent_testing_formulae - @testing_formulae_with_tested_dependents 172 | deps.map { |d| d.to_formula.full_name }.intersect?(still_to_test) 173 | end 174 | 175 | # Split into dependents that we could potentially be building from source and those 176 | # we should not. The criteria is that a dependent must have bottled dependencies, and 177 | # either the `--build-dependents-from-source` flag was passed or a dependent has no 178 | # bottle on the current OS. 179 | source_dependents, dependents = dependents.partition do |dependent, deps| 180 | next false if OS.linux? && dependent.requirements.exclude?(LinuxRequirement.new) 181 | 182 | all_deps_bottled_or_built = deps.all? do |d| 183 | bottled_or_built?(d.to_formula, @dependent_testing_formulae) 184 | end 185 | args.build_dependents_from_source? && all_deps_bottled_or_built 186 | end 187 | 188 | # From the non-source list, get rid of any dependents we are only a build dependency to 189 | dependents.select! do |_, deps| 190 | deps.reject { |d| d.build? && !d.test? } 191 | .map(&:to_formula) 192 | .include?(formula) 193 | end 194 | 195 | dependents = dependents.transpose.first.to_a 196 | source_dependents = source_dependents.transpose.first.to_a 197 | 198 | testable_dependents = source_dependents.select(&:test_defined?) 199 | bottled_dependents = dependents.select { |dep| bottled?(dep) } 200 | testable_dependents += bottled_dependents.select(&:test_defined?) 201 | 202 | info_header "Source dependents:" 203 | puts source_dependents 204 | 205 | info_header "Bottled dependents:" 206 | puts bottled_dependents 207 | 208 | info_header "Testable dependents:" 209 | puts testable_dependents 210 | 211 | [source_dependents, bottled_dependents, testable_dependents] 212 | end 213 | 214 | def install_dependent(dependent, testable_dependents, args:, build_from_source: false) 215 | if @skip_candidates.include?(dependent.full_name) && 216 | artifact_cache_valid?(dependent, formulae_dependents: true) 217 | @tested_dependents_list.write(dependent.full_name, mode: "a") 218 | @tested_dependents_list.write("\n", mode: "a") 219 | skipped dependent.name, "#{dependent.full_name} has been tested at #{previous_github_sha}" 220 | return 221 | end 222 | 223 | if (messages = unsatisfied_requirements_messages(dependent)) 224 | skipped dependent, messages 225 | return 226 | end 227 | 228 | if dependent.deprecated? || dependent.disabled? 229 | verb = dependent.deprecated? ? :deprecated : :disabled 230 | skipped dependent.name, "#{dependent.full_name} has been #{verb}!" 231 | return 232 | end 233 | 234 | cleanup_during!(@dependent_testing_formulae, args:) 235 | 236 | required_dependent_deps = dependent.deps.reject(&:optional?) 237 | bottled_on_current_version = bottled?(dependent, no_older_versions: true) 238 | dependent_was_previously_installed = dependent.latest_version_installed? 239 | 240 | dependent_dependencies = Dependency.expand( 241 | dependent, 242 | cache_key: "test-bot-dependent-dependencies-#{dependent.full_name}", 243 | ) do |dep_dependent, dependency| 244 | next if !dependency.build? && !dependency.test? && !dependency.optional? 245 | next if dependency.test? && 246 | dep_dependent == dependent && 247 | !dependency.optional? && 248 | testable_dependents.include?(dependent) 249 | 250 | Dependency.prune 251 | end 252 | 253 | unless dependent_was_previously_installed 254 | build_args = [] 255 | 256 | fetch_formulae = dependent_dependencies.reject(&:satisfied?).map(&:name) 257 | 258 | if build_from_source 259 | required_dependent_reqs = dependent.requirements.reject(&:optional?) 260 | install_curl_if_needed(dependent) 261 | install_mercurial_if_needed(required_dependent_deps, required_dependent_reqs) 262 | install_subversion_if_needed(required_dependent_deps, required_dependent_reqs) 263 | 264 | build_args << "--build-from-source" 265 | 266 | test "brew", "fetch", "--build-from-source", "--retry", dependent.full_name 267 | return if steps.last.failed? 268 | else 269 | fetch_formulae << dependent.full_name 270 | end 271 | 272 | if fetch_formulae.present? 273 | test "brew", "fetch", "--retry", *fetch_formulae 274 | return if steps.last.failed? 275 | end 276 | 277 | unlink_conflicts dependent 278 | 279 | test "brew", "install", *build_args, "--only-dependencies", 280 | named_args: dependent.full_name, 281 | ignore_failures: !bottled_on_current_version, 282 | env: { "HOMEBREW_DEVELOPER" => nil } 283 | 284 | env = {} 285 | env["HOMEBREW_GIT_PATH"] = nil if build_from_source && required_dependent_deps.any? do |d| 286 | d.name == "git" && (!d.test? || d.build?) 287 | end 288 | test "brew", "install", *build_args, 289 | named_args: dependent.full_name, 290 | env: env.merge({ "HOMEBREW_DEVELOPER" => nil }), 291 | ignore_failures: !args.test_default_formula? && !bottled_on_current_version 292 | install_step = steps.last 293 | 294 | return unless install_step.passed? 295 | end 296 | return unless dependent.latest_version_installed? 297 | 298 | if !dependent.keg_only? && !dependent.linked_keg.exist? 299 | unlink_conflicts dependent 300 | test "brew", "link", dependent.full_name 301 | end 302 | test "brew", "install", "--only-dependencies", dependent.full_name 303 | test "brew", "linkage", "--test", 304 | named_args: dependent.full_name, 305 | ignore_failures: !args.test_default_formula? && !bottled_on_current_version 306 | linkage_step = steps.last 307 | 308 | if linkage_step.passed? && !build_from_source 309 | # Check for opportunistic linkage. Ignore failures because 310 | # they can be unavoidable but we still want to know about them. 311 | test "brew", "linkage", "--cached", "--test", "--strict", 312 | named_args: dependent.full_name, 313 | ignore_failures: !args.test_default_formula? 314 | end 315 | 316 | if testable_dependents.include? dependent 317 | test "brew", "install", "--only-dependencies", "--include-test", dependent.full_name 318 | 319 | dependent_dependencies.each do |dependency| 320 | dependency_f = dependency.to_formula 321 | next if dependency_f.keg_only? 322 | next if dependency_f.linked? 323 | 324 | unlink_conflicts dependency_f 325 | test "brew", "link", dependency_f.full_name 326 | end 327 | 328 | env = {} 329 | env["HOMEBREW_GIT_PATH"] = nil if required_dependent_deps.any? do |d| 330 | d.name == "git" && (!d.build? || d.test?) 331 | end 332 | test "brew", "test", "--retry", "--verbose", 333 | named_args: dependent.full_name, 334 | env:, 335 | ignore_failures: !args.test_default_formula? && !bottled_on_current_version 336 | test_step = steps.last 337 | end 338 | 339 | test "brew", "uninstall", "--force", "--ignore-dependencies", dependent.full_name 340 | 341 | all_tests_passed = (dependent_was_previously_installed || install_step.passed?) && 342 | linkage_step.passed? && 343 | (testable_dependents.exclude?(dependent) || test_step.passed?) 344 | 345 | if all_tests_passed 346 | @tested_dependents_list.write(dependent.full_name, mode: "a") 347 | @tested_dependents_list.write("\n", mode: "a") 348 | end 349 | 350 | return if ENV["GITHUB_ACTIONS"].blank? 351 | 352 | if build_from_source && 353 | !bottled_on_current_version && 354 | !dependent_was_previously_installed && 355 | all_tests_passed && 356 | dependent.deps.all? { |d| bottled?(d.to_formula, no_older_versions: true) } 357 | os_string = if OS.mac? 358 | str = "macOS #{MacOS.version.pretty_name} (#{MacOS.version})" 359 | str << " on Apple Silicon" if Hardware::CPU.arm? 360 | 361 | str 362 | else 363 | OS.kernel_name 364 | end 365 | 366 | puts GitHub::Actions::Annotation.new( 367 | :notice, 368 | "All tests passed.", 369 | file: dependent.path.to_s.delete_prefix("#{repository}/"), 370 | title: "#{dependent} should be bottled for #{os_string}!", 371 | ) 372 | end 373 | end 374 | 375 | def unlink_conflicts(formula) 376 | return if formula.keg_only? 377 | return if formula.linked_keg.exist? 378 | 379 | conflicts = formula.conflicts.map { |c| Formulary.factory(c.name) } 380 | .select(&:any_version_installed?) 381 | formula_recursive_dependencies = begin 382 | formula.recursive_dependencies 383 | rescue TapFormulaUnavailableError => e 384 | raise if e.tap.installed? 385 | 386 | e.tap.clear_cache 387 | safe_system "brew", "tap", e.tap.name 388 | retry 389 | end 390 | formula_recursive_dependencies.each do |dependency| 391 | conflicts += dependency.to_formula.conflicts.map do |c| 392 | Formulary.factory(c.name) 393 | end.select(&:any_version_installed?) 394 | end 395 | conflicts.each do |conflict| 396 | test "brew", "unlink", conflict.name 397 | end 398 | end 399 | end 400 | end 401 | end 402 | -------------------------------------------------------------------------------- /lib/tests/formulae_detect.rb: -------------------------------------------------------------------------------- 1 | # typed: true 2 | # frozen_string_literal: true 3 | 4 | module Homebrew 5 | module Tests 6 | class FormulaeDetect < Test 7 | attr_reader :testing_formulae, :added_formulae, :deleted_formulae 8 | 9 | def initialize(argument, tap:, git:, dry_run:, fail_fast:, verbose:) 10 | super(tap:, git:, dry_run:, fail_fast:, verbose:) 11 | 12 | @argument = argument 13 | @added_formulae = [] 14 | @deleted_formulae = [] 15 | @formulae_to_fetch = [] 16 | end 17 | 18 | def run!(args:) 19 | detect_formulae!(args:) 20 | 21 | return unless ENV["GITHUB_ACTIONS"] 22 | 23 | File.open(ENV.fetch("GITHUB_OUTPUT"), "a") do |f| 24 | f.puts "testing_formulae=#{@testing_formulae.join(",")}" 25 | f.puts "added_formulae=#{@added_formulae.join(",")}" 26 | f.puts "deleted_formulae=#{@deleted_formulae.join(",")}" 27 | f.puts "formulae_to_fetch=#{@formulae_to_fetch.join(",")}" 28 | end 29 | end 30 | 31 | private 32 | 33 | def detect_formulae!(args:) 34 | test_header(:FormulaeDetect, method: :detect_formulae!) 35 | 36 | url = nil 37 | origin_ref = "origin/master" 38 | 39 | github_repository = ENV.fetch("GITHUB_REPOSITORY", nil) 40 | github_ref = ENV.fetch("GITHUB_REF", nil) 41 | 42 | if @argument == "HEAD" 43 | @testing_formulae = [] 44 | # Use GitHub Actions variables for pull request jobs. 45 | if github_ref.present? && github_repository.present? && 46 | %r{refs/pull/(\d+)/merge} =~ github_ref 47 | url = "https://github.com/#{github_repository}/pull/#{Regexp.last_match(1)}/checks" 48 | end 49 | elsif (canonical_formula_name = safe_formula_canonical_name(@argument, args:)) 50 | unless canonical_formula_name.include?("/") 51 | ENV["HOMEBREW_NO_INSTALL_FROM_API"] = "1" 52 | CoreTap.instance.ensure_installed! 53 | end 54 | 55 | @testing_formulae = [canonical_formula_name] 56 | else 57 | raise UsageError, 58 | "#{@argument} is not detected from GitHub Actions or a formula name!" 59 | end 60 | 61 | github_sha = ENV.fetch("GITHUB_SHA", nil) 62 | if github_repository.blank? || github_sha.blank? || github_ref.blank? 63 | if ENV["GITHUB_ACTIONS"] 64 | odie <<~EOS 65 | We cannot find the needed GitHub Actions environment variables! Check you have e.g. exported them to a Docker container. 66 | EOS 67 | elsif ENV["CI"] 68 | onoe <<~EOS 69 | No known CI provider detected! If you are using GitHub Actions then we cannot find the expected environment variables! Check you have e.g. exported them to a Docker container. 70 | EOS 71 | end 72 | elsif tap.present? && tap.full_name.casecmp(github_repository).zero? 73 | # Use GitHub Actions variables for pull request jobs. 74 | if (base_ref = ENV.fetch("GITHUB_BASE_REF", nil)).present? 75 | unless tap.official? 76 | test git, "-C", repository, "fetch", 77 | "origin", "+refs/heads/#{base_ref}" 78 | end 79 | origin_ref = "origin/#{base_ref}" 80 | diff_start_sha1 = rev_parse(origin_ref) 81 | diff_end_sha1 = github_sha 82 | # Use GitHub Actions variables for merge group jobs. 83 | elsif ENV.fetch("GITHUB_EVENT_NAME", nil) == "merge_group" 84 | diff_start_sha1 = rev_parse(origin_ref) 85 | origin_ref = "origin/#{github_ref.gsub(%r{^refs/heads/}, "")}" 86 | diff_end_sha1 = github_sha 87 | # Use GitHub Actions variables for branch jobs. 88 | else 89 | test git, "-C", repository, "fetch", "origin", "+#{github_ref}" unless tap.official? 90 | origin_ref = "origin/#{github_ref.gsub(%r{^refs/heads/}, "")}" 91 | diff_end_sha1 = diff_start_sha1 = github_sha 92 | end 93 | end 94 | 95 | if diff_start_sha1.present? && diff_end_sha1.present? 96 | merge_base_sha1 = 97 | Utils.safe_popen_read(git, "-C", repository, "merge-base", 98 | diff_start_sha1, diff_end_sha1).strip 99 | diff_start_sha1 = merge_base_sha1 if merge_base_sha1.present? 100 | end 101 | 102 | diff_start_sha1 = current_sha1 if diff_start_sha1.blank? 103 | diff_end_sha1 = current_sha1 if diff_end_sha1.blank? 104 | 105 | diff_start_sha1 = diff_end_sha1 if @testing_formulae.present? 106 | 107 | if tap 108 | tap_origin_ref_revision_args = 109 | [git, "-C", tap.path.to_s, "log", "-1", "--format=%h (%s)", origin_ref] 110 | tap_origin_ref_revision = if args.dry_run? 111 | # May fail on dry run as we've not fetched. 112 | Utils.popen_read(*tap_origin_ref_revision_args).strip 113 | else 114 | Utils.safe_popen_read(*tap_origin_ref_revision_args) 115 | end.strip 116 | tap_revision = Utils.safe_popen_read( 117 | git, "-C", tap.path.to_s, 118 | "log", "-1", "--format=%h (%s)" 119 | ).strip 120 | end 121 | 122 | puts <<-EOS 123 | url #{url.presence || "(blank)"} 124 | tap #{origin_ref} #{tap_origin_ref_revision.presence || "(blank)"} 125 | HEAD #{tap_revision.presence || "(blank)"} 126 | diff_start_sha1 #{diff_start_sha1.presence || "(blank)"} 127 | diff_end_sha1 #{diff_end_sha1.presence || "(blank)"} 128 | EOS 129 | 130 | modified_formulae = [] 131 | 132 | if tap && diff_start_sha1 != diff_end_sha1 133 | formula_path = tap.formula_dir.to_s 134 | @added_formulae += 135 | diff_formulae(diff_start_sha1, diff_end_sha1, formula_path, "A") 136 | modified_formulae += 137 | diff_formulae(diff_start_sha1, diff_end_sha1, formula_path, "M") 138 | @deleted_formulae += 139 | diff_formulae(diff_start_sha1, diff_end_sha1, formula_path, "D") 140 | end 141 | 142 | # If a formula is both added and deleted: it's actually modified. 143 | added_and_deleted_formulae = @added_formulae & @deleted_formulae 144 | @added_formulae -= added_and_deleted_formulae 145 | @deleted_formulae -= added_and_deleted_formulae 146 | modified_formulae += added_and_deleted_formulae 147 | 148 | if args.test_default_formula? 149 | # Build the default test formula. 150 | modified_formulae << "homebrew/test-bot/testbottest" 151 | end 152 | 153 | @testing_formulae += @added_formulae + modified_formulae 154 | 155 | # TODO: Remove `GITHUB_EVENT_NAME` check when formulae detection 156 | # is fixed for branch jobs. 157 | if @testing_formulae.blank? && 158 | @deleted_formulae.blank? && 159 | diff_start_sha1 == diff_end_sha1 && 160 | (ENV["GITHUB_EVENT_NAME"] != "push") 161 | raise UsageError, "Did not find any formulae or commits to test!" 162 | end 163 | 164 | # Remove all duplicates. 165 | @testing_formulae.uniq! 166 | @added_formulae.uniq! 167 | modified_formulae.uniq! 168 | @deleted_formulae.uniq! 169 | 170 | # We only need to do a fetch test on formulae that have had a change in the pkg version or bottle block. 171 | # These fetch tests only happen in merge queues. 172 | @formulae_to_fetch = if diff_start_sha1 == diff_end_sha1 || ENV["GITHUB_EVENT_NAME"] != "merge_group" 173 | [] 174 | else 175 | require "formula_versions" 176 | 177 | @testing_formulae.reject do |formula_name| 178 | latest_formula = Formula[formula_name] 179 | 180 | # nil = formula not found, false = bottles changed, true = bottles not changed 181 | equal_bottles = FormulaVersions.new(latest_formula).formula_at_revision(diff_start_sha1) do |old_formula| 182 | old_formula.pkg_version == latest_formula.pkg_version && 183 | old_formula.bottle_specification == latest_formula.bottle_specification 184 | end 185 | 186 | equal_bottles # only exclude the true case (bottles not changed) 187 | end 188 | end 189 | 190 | puts <<-EOS 191 | 192 | testing_formulae #{@testing_formulae.join(" ").presence || "(none)"} 193 | added_formulae #{@added_formulae.join(" ").presence || "(none)"} 194 | modified_formulae #{modified_formulae.join(" ").presence || "(none)"} 195 | deleted_formulae #{@deleted_formulae.join(" ").presence || "(none)"} 196 | formulae_to_fetch #{@formulae_to_fetch.join(" ").presence || "(none)"} 197 | EOS 198 | end 199 | 200 | def safe_formula_canonical_name(formula_name, args:) 201 | Homebrew.with_no_api_env do 202 | Formulary.factory(formula_name).full_name 203 | end 204 | rescue TapFormulaUnavailableError => e 205 | raise if e.tap.installed? 206 | 207 | test "brew", "tap", e.tap.name 208 | retry unless steps.last.failed? 209 | onoe e 210 | puts e.backtrace if args.debug? 211 | rescue FormulaUnavailableError, TapFormulaAmbiguityError => e 212 | onoe e 213 | puts e.backtrace if args.debug? 214 | end 215 | 216 | def rev_parse(ref) 217 | Utils.popen_read(git, "-C", repository, "rev-parse", "--verify", ref).strip 218 | end 219 | 220 | def current_sha1 221 | rev_parse("HEAD") 222 | end 223 | 224 | def diff_formulae(start_revision, end_revision, path, filter) 225 | return unless tap 226 | 227 | Utils.safe_popen_read( 228 | git, "-C", repository, 229 | "diff-tree", "-r", "--name-only", "--diff-filter=#{filter}", 230 | start_revision, end_revision, "--", path 231 | ).lines(chomp: true).filter_map do |file| 232 | next unless tap.formula_file?(file) 233 | 234 | file = Pathname.new(file) 235 | tap.formula_file_to_name(file) 236 | end 237 | end 238 | end 239 | end 240 | end 241 | -------------------------------------------------------------------------------- /lib/tests/setup.rb: -------------------------------------------------------------------------------- 1 | # typed: true 2 | # frozen_string_literal: true 3 | 4 | module Homebrew 5 | module Tests 6 | class Setup < Test 7 | def run!(args:) 8 | test_header(:Setup) 9 | 10 | test "brew", "install-bundler-gems", "--add-groups=ast,audit,bottle,formula_test,livecheck,style" 11 | 12 | # Always output `brew config` output even when it doesn't fail. 13 | test "brew", "config", verbose: true 14 | 15 | test "brew", "doctor" 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/tests/tap_syntax.rb: -------------------------------------------------------------------------------- 1 | # typed: true 2 | # frozen_string_literal: true 3 | 4 | module Homebrew 5 | module Tests 6 | class TapSyntax < Test 7 | def run!(args:) 8 | test_header(:TapSyntax) 9 | return unless tap.installed? 10 | 11 | unless args.stable? 12 | # Run `brew typecheck` if this tap is typed. 13 | # TODO: consider in future if we want to allow unsupported taps here. 14 | if tap.official? && quiet_system(git, "-C", tap.path.to_s, "grep", "-qE", "^# typed: (true|strict|strong)$") 15 | test "brew", "typecheck", tap.name 16 | end 17 | 18 | test "brew", "style", tap.name 19 | end 20 | 21 | return if tap.formula_files.blank? && tap.cask_files.blank? 22 | 23 | test "brew", "readall", "--aliases", "--os=all", "--arch=all", tap.name 24 | return if args.stable? 25 | 26 | test "brew", "audit", "--except=installed", "--tap=#{tap.name}" 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/global.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | HOMEBREW_REPOSITORY = Pathname.new("/usr/local/Homebrew").freeze 4 | HOMEBREW_PULL_OR_COMMIT_URL_REGEX = /.+/ 5 | HOMEBREW_LIBRARY = (HOMEBREW_REPOSITORY/"Library").freeze 6 | HOMEBREW_PREFIX = Pathname.new("/usr/local").freeze 7 | -------------------------------------------------------------------------------- /spec/homebrew/step_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "ostruct" 4 | require "spec_helper" 5 | 6 | describe Homebrew::Step do 7 | let(:command) { ["brew", "config"] } 8 | let(:env) { {} } 9 | let(:verbose) { false } 10 | let(:step) { described_class.new(command, env:, verbose:) } 11 | 12 | describe "#run" do 13 | it "runs the command" do 14 | expect(step).to receive(:system_command) 15 | .with("brew", args: ["config"], env:, print_stderr: verbose, print_stdout: verbose) 16 | .and_return(OpenStruct.new(success?: true, merged_output: "")) 17 | step.run 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /spec/homebrew/tests/setup_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "ostruct" 4 | require "spec_helper" 5 | 6 | describe Homebrew::Tests::Setup do 7 | before do 8 | allow(Homebrew).to receive(:args).and_return(OpenStruct.new) 9 | end 10 | 11 | describe "#run!" do 12 | it "is successful" do 13 | expect(described_class.new.run!(args: OpenStruct.new).passed?).to be(true) 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "simplecov" 4 | SimpleCov.start do 5 | add_filter "/vendor/" 6 | minimum_coverage 10 7 | end 8 | 9 | # Setup Sorbet for usage in stubs. 10 | require "sorbet-runtime" 11 | class Module 12 | include T::Sig 13 | end 14 | 15 | PROJECT_ROOT = Pathname(__dir__).parent.freeze 16 | STUB_PATH = (PROJECT_ROOT/"spec/stub").freeze 17 | $LOAD_PATH.unshift(STUB_PATH) 18 | 19 | Dir.glob("#{PROJECT_ROOT}/lib/**/*.rb").each do |file| 20 | require file 21 | end 22 | 23 | require "global" 24 | require "active_support/core_ext/object/blank" 25 | 26 | SimpleCov.formatters = [SimpleCov::Formatter::HTMLFormatter] 27 | 28 | require "bundler" 29 | require "rspec/support/object_formatter" 30 | 31 | RSpec.configure do |config| 32 | config.filter_run_when_matching :focus 33 | config.expect_with :rspec do |c| 34 | c.max_formatted_output_length = 200 35 | end 36 | 37 | # Never truncate output objects. 38 | RSpec::Support::ObjectFormatter.default_instance.max_formatted_output_length = nil 39 | 40 | config.around do |example| 41 | Bundler.with_original_env { example.run } 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /spec/stub/development_tools.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Intentionally empty for no-op require. 4 | -------------------------------------------------------------------------------- /spec/stub/formula.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Intentionally empty for no-op require. 4 | -------------------------------------------------------------------------------- /spec/stub/formula_installer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Intentionally empty for no-op require. 4 | -------------------------------------------------------------------------------- /spec/stub/github_releases.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Intentionally empty for no-op require. 4 | -------------------------------------------------------------------------------- /spec/stub/os.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module OS 4 | module_function 5 | 6 | def mac? 7 | RUBY_PLATFORM[/darwin/] 8 | end 9 | 10 | def linux? 11 | RUBY_PLATFORM[/linux/] 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/stub/system_command.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "ostruct" 4 | 5 | class SystemCommand 6 | module Mixin 7 | def system_command(*) 8 | OpenStruct.new( 9 | success?: true, 10 | merged_output: "", 11 | ) 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /spec/stub/tap.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "ostruct" 4 | 5 | class Tap 6 | def self.fetch(*) 7 | OpenStruct.new(name: "Homebrew/homebrew-core") 8 | end 9 | 10 | def self.map 11 | [] 12 | end 13 | end 14 | 15 | class CoreTap 16 | def self.instance 17 | Tap.fetch 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/stub/utils.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | def quiet_system(*) end 4 | 5 | module Formatter 6 | module_function 7 | 8 | def headline(*) end 9 | end 10 | -------------------------------------------------------------------------------- /spec/stub/utils/analytics.rb: -------------------------------------------------------------------------------- 1 | # typed: true 2 | # frozen_string_literal: true 3 | 4 | module Utils 5 | module Analytics 6 | sig { params(command: String, passed: T::Boolean).void } 7 | def self.report_test_bot_test(command, passed); end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/stub/utils/bottles.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Intentionally empty for no-op require. 4 | -------------------------------------------------------------------------------- /spec/stub/utils/github/artifacts.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Intentionally empty for no-op require. 4 | --------------------------------------------------------------------------------