Class: Fastlane::Actions::IsCheckRequiredAction

Inherits:
Action
  • Object
show all
Defined in:
lib/fastlane/plugin/stream_actions/actions/is_check_required.rb

Documentation collapse

Class Method Summary collapse

Class Method Details

.available_optionsObject



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.available_options
  [
    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

.categoryObject



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

.descriptionObject



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

.detailsObject



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_codeObject



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

Returns:

  • (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.

Returns:

  • (Boolean)


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.message("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.message("No commit in this PR changed sources; nothing to test")
  false
end

.return_valueObject



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.message("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

Returns:

  • (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