Class: Fastlane::Actions::IsCheckRequiredAction
- Inherits:
-
Action
- Object
- Action
- Fastlane::Actions::IsCheckRequiredAction
- Defined in:
- lib/fastlane/plugin/stream_actions/actions/is_check_required.rb
Documentation collapse
- .available_options ⇒ Object
- .category ⇒ Object
- .description ⇒ Object
- .details ⇒ Object
- .example_code ⇒ Object
- .is_supported?(platform) ⇒ Boolean
- .return_value ⇒ Object
Class Method Summary collapse
-
.changed_file_paths(params) ⇒ Object
For pull_request: use full PR for
opened(etc.); forsynchronizepass github_event_before/after (e.g. github.event.before/after) to scope to this push. -
.commit_files(repo, sha) ⇒ Object
Files changed by a single commit (relative to its first parent).
- .compare_push_files(repo, before, after) ⇒ Object
- .gh_path_lines(output) ⇒ Object
-
.latest_conclusions(output) ⇒ Object
Reduces "name\tconclusion\tcompleted_at" lines to the latest conclusion per check name, so re-runs of the same check supersede earlier attempts.
-
.pr_commit_shas(pr_num) ⇒ Object
PR commits, newest first (gh returns them oldest first).
-
.required_checks_passed?(repo, sha, required_checks) ⇒ Boolean
True only if every required check has its latest run concluded as 'success' on the commit.
-
.required_due_to_history(params, required_checks) ⇒ Object
Walks the PR commits from newest to oldest.
- .run(params) ⇒ Object
- .touches_sources?(files, sources) ⇒ Boolean
Class Method Details
.available_options ⇒ Object
193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 |
# File 'lib/fastlane/plugin/stream_actions/actions/is_check_required.rb', line 193 def self. [ FastlaneCore::ConfigItem.new( key: :sources, description: 'Paths the check cares about. If a changed file path starts with any of these, the ' \ 'check is relevant and must run. Non-empty array of path prefixes, e.g. ["Sources", "Tests"]', is_string: false, verify_block: proc do |array| UI.user_error!("Sources have to be specified") unless array.kind_of?(Array) && array.size.positive? end ), FastlaneCore::ConfigItem.new( env_name: 'GITHUB_PR_NUM', key: :github_pr_num, description: 'Pull request number to analyze. When empty or nil (e.g. not running for a PR), the ' \ 'action always returns true', optional: true ), FastlaneCore::ConfigItem.new( key: :required_checks, description: 'GitHub check-run names (exactly as shown on the PR, e.g. "Test (iOS 17)") that must ' \ 'have concluded as success to prove the current sources were already verified. Only ' \ 'consulted when the current push does not touch :sources: the check is skipped only if ' \ 'all of these passed on a commit that has the current source tree. When empty, a push ' \ 'that does not touch :sources is skipped immediately, without any verification', is_string: false, optional: true, default_value: [] ), FastlaneCore::ConfigItem.new( key: :force_check, description: 'When truthy, bypass all analysis and always return true (force the check to run)', optional: true, is_string: false ), FastlaneCore::ConfigItem.new( env_name: 'GITHUB_EVENT_ACTION', key: :github_event_action, description: 'The pull_request event action, e.g. "opened" or "synchronize". When "synchronize" and ' \ 'github_event_before/after are set, only the files in that push are considered; ' \ 'otherwise the full PR diff is used', optional: true ), FastlaneCore::ConfigItem.new( env_name: 'GITHUB_EVENT_BEFORE', key: :github_event_before, description: 'github.event.before: the branch head SHA before the push. Combined with ' \ 'github_event_after to scope the diff to a single push on synchronize', optional: true ), FastlaneCore::ConfigItem.new( env_name: 'GITHUB_EVENT_AFTER', key: :github_event_after, description: 'github.event.after: the branch head SHA after the push. Combined with ' \ 'github_event_before to scope the diff to a single push on synchronize', optional: true ), FastlaneCore::ConfigItem.new( env_name: 'GITHUB_REPOSITORY', key: :github_repository, description: 'Repository in "owner/repo" form. Needed to scope the diff to a push and to look up ' \ 'commit history and check runs. Defaults to the GITHUB_REPOSITORY env var set in CI', optional: true ) ] end |
.category ⇒ Object
273 274 275 |
# File 'lib/fastlane/plugin/stream_actions/actions/is_check_required.rb', line 273 def self.category :testing end |
.changed_file_paths(params) ⇒ Object
For pull_request: use full PR for opened (etc.); for synchronize pass
github_event_before/after (e.g. github.event.before/after) to scope to this push.
119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 |
# File 'lib/fastlane/plugin/stream_actions/actions/is_check_required.rb', line 119 def self.changed_file_paths(params) action = params[:github_event_action].to_s before = params[:github_event_before].to_s.strip after = params[:github_event_after].to_s.strip repo = (params[:github_repository] || ENV['GITHUB_REPOSITORY']).to_s if action == 'synchronize' && !before.empty? && !after.empty? && !repo.empty? if before.match?(/\A0+\z/) || !before.match?(/\A[0-9a-f]{7,40}\z/i) || !after.match?(/\A[0-9a-f]{7,40}\z/i) UI.important("Invalid before/after for compare; falling back to full PR file list") else out = self.compare_push_files(repo, before, after) return out unless out.nil? UI.important("Could not list push diff (e.g. fork/cross-repo); falling back to full PR file list") end end self.gh_path_lines(Actions.sh("gh pr view #{params[:github_pr_num]} --json files -q '.files[].path'")) end |
.commit_files(repo, sha) ⇒ Object
Files changed by a single commit (relative to its first parent).
80 81 82 83 84 |
# File 'lib/fastlane/plugin/stream_actions/actions/is_check_required.rb', line 80 def self.commit_files(repo, sha) self.gh_path_lines(Actions.sh("gh api repos/#{repo}/commits/#{sha} --paginate -q '.files[].filename'")) rescue StandardError [] end |
.compare_push_files(repo, before, after) ⇒ Object
139 140 141 142 143 144 145 146 147 |
# File 'lib/fastlane/plugin/stream_actions/actions/is_check_required.rb', line 139 def self.compare_push_files(repo, before, after) self.gh_path_lines(Actions.sh( "gh api \"repos/#{repo}/compare/#{before}...#{after}\" " \ "-H \"Accept: application/vnd.github.v3+json\" " \ "-q '.files[].filename'" )) rescue StandardError nil end |
.description ⇒ Object
157 158 159 |
# File 'lib/fastlane/plugin/stream_actions/actions/is_check_required.rb', line 157 def self.description 'Decides whether a CI check (tests, build, etc.) needs to run for the current pull request state' end |
.details ⇒ Object
161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 |
# File 'lib/fastlane/plugin/stream_actions/actions/is_check_required.rb', line 161 def self.details <<~DETAILS Skips CI work that has nothing to verify, without ever skipping work that has not been verified yet. The action inspects which files changed and returns: - true -> the caller should run the check - false -> the caller can safely skip it (usually via `next unless is_check_required(...)`) How the decision is made: 1. If `force_check` is set, or no PR number is available (e.g. a push to a branch), it returns true. 2. It collects the changed files. On a `synchronize` event with `github_event_before`/`after`, only the files in that single push are considered; otherwise the whole PR diff is used. 3. If more than 99 files changed, it returns true (the GitHub CLI cannot reliably list more). 4. If any changed file lives under one of `sources`, the check is relevant -> returns true. 5. If no changed file touches `sources`: - With no `required_checks`, it returns false (nothing relevant changed in this push). - With `required_checks`, it confirms the sources were actually verified before skipping. It walks the PR commits newest-to-oldest: every commit newer than the last `sources` change shares the current source tree, so if all `required_checks` concluded `success` on any of them, the sources are proven and it returns false. The walk stops at the commit that changed `sources` (older commits carry different sources); if none of them passed, the sources are unverified -> true. If `sources` were never changed anywhere in the PR, there is nothing to test -> false. This is what prevents a docs-only follow-up commit (e.g. editing CHANGELOG.md) from skipping tests when the underlying source changes never passed CI. DETAILS end |
.example_code ⇒ Object
260 261 262 263 264 265 266 267 268 269 270 271 |
# File 'lib/fastlane/plugin/stream_actions/actions/is_check_required.rb', line 260 def self.example_code [ 'next unless is_check_required(sources: ["Sources", "Tests"])', 'unless is_check_required( sources: ["Sources"], github_pr_num: ENV["GITHUB_PR_NUM"], required_checks: ["Test (iOS 17)", "Build"] ) next end' ] end |
.gh_path_lines(output) ⇒ Object
149 150 151 |
# File 'lib/fastlane/plugin/stream_actions/actions/is_check_required.rb', line 149 def self.gh_path_lines(output) output.to_s.split("\n", -1).map(&:strip).reject(&:empty?) end |
.is_supported?(platform) ⇒ Boolean
277 278 279 |
# File 'lib/fastlane/plugin/stream_actions/actions/is_check_required.rb', line 277 def self.is_supported?(platform) true end |
.latest_conclusions(output) ⇒ Object
Reduces "name\tconclusion\tcompleted_at" lines to the latest conclusion per check name, so re-runs of the same check supersede earlier attempts.
101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 |
# File 'lib/fastlane/plugin/stream_actions/actions/is_check_required.rb', line 101 def self.latest_conclusions(output) latest = {} seen_at = {} output.to_s.split("\n", -1).each do |line| name, conclusion, completed_at = line.split("\t", -1) next if name.nil? || name.strip.empty? completed_at = completed_at.to_s next if seen_at.key?(name) && completed_at < seen_at[name] seen_at[name] = completed_at latest[name] = conclusion.to_s end latest end |
.pr_commit_shas(pr_num) ⇒ Object
PR commits, newest first (gh returns them oldest first).
73 74 75 76 77 |
# File 'lib/fastlane/plugin/stream_actions/actions/is_check_required.rb', line 73 def self.pr_commit_shas(pr_num) self.gh_path_lines(Actions.sh("gh pr view #{pr_num} --json commits -q '.commits[].oid'")).reverse rescue StandardError [] end |
.required_checks_passed?(repo, sha, required_checks) ⇒ Boolean
True only if every required check has its latest run concluded as 'success' on the commit.
87 88 89 90 91 92 93 94 95 96 97 |
# File 'lib/fastlane/plugin/stream_actions/actions/is_check_required.rb', line 87 def self.required_checks_passed?(repo, sha, required_checks) out = Actions.sh( "gh api \"repos/#{repo}/commits/#{sha}/check-runs?per_page=100\" --paginate " \ "-H \"Accept: application/vnd.github.v3+json\" " \ "-q '.check_runs[] | \"\\(.name)\\t\\(.conclusion)\\t\\(.completed_at)\"'" ) latest = self.latest_conclusions(out) required_checks.all? { |name| latest[name] == 'success' } rescue StandardError false end |
.required_due_to_history(params, required_checks) ⇒ Object
Walks the PR commits from newest to oldest. Every commit newer than the last :sources change has the current :sources tree, so a passing run on any of them means :sources were verified. The walk stops at the commit that changed :sources, since older commits have different sources.
42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 |
# File 'lib/fastlane/plugin/stream_actions/actions/is_check_required.rb', line 42 def self.required_due_to_history(params, required_checks) repo = (params[:github_repository] || ENV['GITHUB_REPOSITORY']).to_s if repo.empty? UI.important("No repository provided; cannot verify previous runs, running check") return true end shas = self.pr_commit_shas(params[:github_pr_num]) if shas.empty? UI.important("Could not list PR commits; running check") return true end shas.each do |sha| short = sha[0, 7] if self.required_checks_passed?(repo, sha, required_checks) UI.("Commit #{short} passed required checks for the current sources; safe to skip") return false end next unless self.touches_sources?(self.commit_files(repo, sha), params[:sources]) UI.important("Last sources commit #{short} was not verified by a passing run; running check") return true end UI.("No commit in this PR changed sources; nothing to test") false end |
.return_value ⇒ Object
189 190 191 |
# File 'lib/fastlane/plugin/stream_actions/actions/is_check_required.rb', line 189 def self.return_value 'Boolean. true when the check should run, false when it can be safely skipped for the current PR state.' end |
.run(params) ⇒ Object
4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
# File 'lib/fastlane/plugin/stream_actions/actions/is_check_required.rb', line 4 def self.run(params) return true if params[:force_check] || params[:github_pr_num].nil? || params[:github_pr_num].strip.empty? UI.("Checking if check is required for PR ##{params[:github_pr_num]}") changed_files = self.changed_file_paths(params) too_many_files = changed_files.size > 99 # TODO: https://github.com/cli/cli/issues/5368 if too_many_files UI.important("Check is required because there were too many files changed.") return true end if self.touches_sources?(changed_files, params[:sources]) UI.important("Check is required: true") return true end required_checks = params[:required_checks].to_a if required_checks.empty? UI.important("Check is required: false") return false end # The current push does not touch :sources. It is only safe to skip if :sources were already # verified, i.e. all required checks passed on some commit that has the current :sources tree. is_check_required = self.required_due_to_history(params, required_checks) UI.important("Check is required: #{is_check_required}") is_check_required end |
.touches_sources?(files, sources) ⇒ Boolean
35 36 37 |
# File 'lib/fastlane/plugin/stream_actions/actions/is_check_required.rb', line 35 def self.touches_sources?(files, sources) files.any? { |path| sources.any? { |required| path.start_with?(required) } } end |