specguard-rspec 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,459 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecGuard
4
+ module RSpec
5
+ # Where the checkout is sitting — which commit, and which branch — asked of
6
+ # git directly.
7
+ #
8
+ # This is the *last* resort for both, consulted only when no environment
9
+ # variable named the answer.
10
+ #
11
+ # The two questions carry different stakes and the same honesty
12
+ # requirement. `commit_sha` is the one envelope field the platform refuses
13
+ # a run without (`Ingest::Payload#validate_commit_sha` rejects a blank one
14
+ # and the whole POST comes back 400, every example discarded), so a run
15
+ # that cannot name its commit is a run whose telemetry is lost entirely —
16
+ # not one with a gap in it. A nil `branch`, by contrast, is *accepted*: the
17
+ # platform stores it, and renders it as "not reported". Which is precisely
18
+ # why a guess is worse here than a gap — see {BRANCH_COMMAND}.
19
+ #
20
+ # Three properties, all load-bearing, and both questions have all three:
21
+ #
22
+ # * It never raises. `git` may not be installed at all, in which case
23
+ # `IO.popen` raises `Errno::ENOENT` before a subprocess ever exists.
24
+ # * It never prints. `git rev-parse HEAD` outside a repository writes
25
+ # "fatal: not a git repository" to stderr, and a telemetry tool that
26
+ # graffitis somebody's CI log with a git error has already failed.
27
+ # * It answers once per process. Each result is memoized *separately*
28
+ # because a subprocess is expensive relative to everything else here,
29
+ # and neither answer can change mid-run.
30
+ module GitCheckout
31
+ COMMIT_SHA_COMMAND = %w[git rev-parse HEAD].freeze
32
+
33
+ # `symbolic-ref`, deliberately, and **not** `rev-parse --abbrev-ref HEAD`.
34
+ #
35
+ # On a detached checkout `--abbrev-ref` *succeeds*, exit 0, printing the
36
+ # literal string `"HEAD"`. `actions/checkout` detaches by default, so that
37
+ # command would report `branch: "HEAD"` for a large share of CI runs — a
38
+ # value the platform stores, renders in its Branch column, and groups by,
39
+ # indistinguishable from a repository that genuinely has a branch called
40
+ # `HEAD`. It is the wrong answer wearing the costume of a right one.
41
+ #
42
+ # `git symbolic-ref --short -q HEAD` asks the question actually being
43
+ # asked: *what branch is HEAD a symbolic reference to?* Detached, there is
44
+ # no answer, and it says so the honest way — no output, exit 1, and `-q`
45
+ # keeps it silent while doing it. {.resolve}'s existing `$?.success?`
46
+ # guard turns that straight into `nil`, which is what a detached checkout
47
+ # should report and what the platform already knows how to render.
48
+ BRANCH_COMMAND = %w[git symbolic-ref --short -q HEAD].freeze
49
+
50
+ class << self
51
+ # @return [String, nil] the checked-out commit, or nil for any reason
52
+ # at all — no git, no repository, an empty repository, a broken HEAD.
53
+ def commit_sha
54
+ return @commit_sha if defined?(@commit_sha)
55
+
56
+ @commit_sha = resolve(COMMIT_SHA_COMMAND)
57
+ end
58
+
59
+ # @return [String, nil] the branch the checkout is on, or nil for any
60
+ # reason at all — no git, no repository, and notably a **detached
61
+ # HEAD**, which is nil rather than the string "HEAD". See
62
+ # {BRANCH_COMMAND}.
63
+ def branch
64
+ return @branch if defined?(@branch)
65
+
66
+ @branch = resolve(BRANCH_COMMAND)
67
+ end
68
+
69
+ # Drop the memoized answers — *both* of them. For tests, and for the
70
+ # rare caller that changes directory into a different checkout
71
+ # mid-process, where the commit and the branch have equally gone stale.
72
+ #
73
+ # @return [void]
74
+ def reset!
75
+ remove_instance_variable(:@commit_sha) if defined?(@commit_sha)
76
+ remove_instance_variable(:@branch) if defined?(@branch)
77
+ nil
78
+ end
79
+
80
+ private
81
+
82
+ # Shared by both questions so that the two guards below are written
83
+ # once and cannot drift apart — they are the whole of the never-raises
84
+ # and never-prints contract.
85
+ #
86
+ # @param command [Array<String>] argv, never a shell string
87
+ # @return [String, nil]
88
+ def resolve(command)
89
+ # `err: IO::NULL` is the "never prints" half: outside a repository
90
+ # either command writes the same "fatal: not a git repository" to
91
+ # stderr, and the exit status is the only part of that we want.
92
+ output = IO.popen(command, err: IO::NULL, &:read)
93
+ # A non-zero exit is a real answer for {BRANCH_COMMAND} rather than
94
+ # only an error: `symbolic-ref -q` exits 1 on a detached HEAD, and
95
+ # "no branch" is exactly what that should mean.
96
+ return nil unless $?&.success? # rubocop:disable Style/SpecialGlobalVars
97
+
98
+ value = output.to_s.strip
99
+ value.empty? ? nil : value
100
+ rescue ScriptError, StandardError
101
+ # `Errno::ENOENT` (no git binary) is the common one; the broad rescue
102
+ # is deliberate, because there is no failure here worth a raise.
103
+ nil
104
+ end
105
+ end
106
+ end
107
+
108
+ # What {SpecGuard::RSpecFormatter} needs to know about the run it is
109
+ # watching: which checkout produced it, and where to send what was captured.
110
+ #
111
+ # == Why the environment is the default source
112
+ #
113
+ # `commit_sha` and `branch` are facts about the *checkout*, not about the
114
+ # suite, and every CI provider already publishes them. Reading them from
115
+ # ENV means the common case — a CI job on any of the five major providers —
116
+ # needs no configuration at all. When no provider named either, both fall
117
+ # through to {GitCheckout}, which asks git itself — so a laptop run and a
118
+ # hand-rolled container report their checkout too, without being told to.
119
+ #
120
+ # The escape hatch stays open on top of all of it, for a value neither
121
+ # source can know:
122
+ #
123
+ # SpecGuard::RSpec.configure do |config|
124
+ # config.branch = "release/2.0"
125
+ # end
126
+ #
127
+ # An explicitly assigned value always wins; the ENV names and the git
128
+ # fallback are only consulted to seed the defaults.
129
+ #
130
+ # == Why an unset value is nil rather than an error
131
+ #
132
+ # Nothing here may ever be the reason a test run fails (see
133
+ # {SpecGuard::RSpecFormatter}'s never-block-CI contract). Somebody running
134
+ # `bundle exec rspec` on a laptop has no `GITHUB_SHA`, and that is not a
135
+ # misconfiguration to shout about — it is a run whose envelope honestly
136
+ # records "unknown commit". A blank or whitespace-only variable is treated
137
+ # the same as an unset one, because CI providers routinely export `""` for
138
+ # a variable that does not apply to the current event.
139
+ #
140
+ # == Transport, and what an unset `api_key` means
141
+ #
142
+ # `endpoint`, `api_key` and `timeout` configure the POST to SpecGuard's
143
+ # ingest endpoint. `api_key` is the switch: set it and the run is delivered
144
+ # over HTTP, leave it unset and the run is appended to `output_path` exactly
145
+ # as before. Local development is therefore the default, and it needs no
146
+ # opt-out — which is the whole reason the switch is the credential rather
147
+ # than a separate `enabled` flag nobody would remember to turn off.
148
+ #
149
+ # There is deliberately **no default endpoint**. SpecGuard is self-hostable
150
+ # and there is no address that is right for everybody; guessing one would
151
+ # mean a misconfigured project POSTing its test names to whatever happens to
152
+ # answer at that name.
153
+ class Configuration
154
+ # Relative to the working directory the suite was started from, which is
155
+ # the project root for every normal `bundle exec rspec` invocation.
156
+ DEFAULT_OUTPUT_PATH = "log/test_results.jsonl"
157
+
158
+ # Applied to opening the connection and to reading the response.
159
+ #
160
+ # `Net::HTTP`'s own defaults are 60s each, which is up to two minutes
161
+ # added to every CI run against a hung endpoint — squarely against the
162
+ # roadmap's "a 20,000-example run must not be meaningfully slowed". Ten
163
+ # seconds is the number the client-gem spec settles on, and it is the
164
+ # whole budget: there are **no retries**, because a retry doubles the
165
+ # worst case for telemetry that is explicitly allowed to be lost.
166
+ DEFAULT_TIMEOUT_SECONDS = 10
167
+
168
+ # Checked in order, first non-blank wins. The `SPECGUARD_`-prefixed name
169
+ # comes first everywhere so a project can always override whatever its CI
170
+ # provider decided to export.
171
+ #
172
+ # The provider list is not decoration. `commit_sha` is the one field the
173
+ # platform rejects a run over, so a list that only knew `GITHUB_SHA` meant
174
+ # every GitLab, CircleCI, Buildkite and Jenkins run 400ing as a matter of
175
+ # course — the *default* outcome on four of the five providers this gem is
176
+ # likely to meet.
177
+ COMMIT_SHA_KEYS = %w[
178
+ SPECGUARD_COMMIT_SHA
179
+ GITHUB_SHA
180
+ CI_COMMIT_SHA
181
+ CIRCLE_SHA1
182
+ BUILDKITE_COMMIT
183
+ GIT_COMMIT
184
+ ].freeze
185
+
186
+ # The same providers, in the same order. `GITHUB_REF_NAME` is the short
187
+ # name (`main`), not the full ref (`refs/heads/main`) that `GITHUB_REF`
188
+ # carries; Jenkins's `GIT_BRANCH` is passed through as it comes
189
+ # (`origin/main` on many setups), because `branch` is a free-form string
190
+ # to the platform and inventing a normalization here would be this gem
191
+ # guessing at somebody's ref layout.
192
+ BRANCH_KEYS = %w[
193
+ SPECGUARD_BRANCH
194
+ GITHUB_REF_NAME
195
+ CI_COMMIT_REF_NAME
196
+ CIRCLE_BRANCH
197
+ BUILDKITE_BRANCH
198
+ GIT_BRANCH
199
+ ].freeze
200
+
201
+ # The id the CI provider gave the *build*, which every shard of a sharded
202
+ # run shares.
203
+ #
204
+ # This is the field that makes a 20,000-example suite land as one run.
205
+ # Nobody runs a suite that size in a single process: under
206
+ # `parallel_tests`, Knapsack or a CI matrix each shard loads this
207
+ # formatter and POSTs its own slice, and every shard carries the same
208
+ # `commit_sha` and `branch` — so the platform had nothing to tell four
209
+ # shards of one run apart from four separate runs, and recorded four
210
+ # `TestRun` rows each holding a quarter of the denominator. Annotations
211
+ # tend to cluster in whatever area a team is currently working on rather
212
+ # than spreading evenly across shards, so the headline ratio then moved
213
+ # from re-run to re-run without the suite changing at all.
214
+ #
215
+ # == It identifies the run, and deliberately survives a re-run
216
+ #
217
+ # These ids are stable across re-attempts of the same build, and that is
218
+ # documented behaviour rather than an accident. GitHub Actions, verbatim:
219
+ # `GITHUB_RUN_ID` is *"A unique number for each workflow run within a
220
+ # repository. This number does not change if you re-run the workflow
221
+ # run."* (`GITHUB_RUN_ATTEMPT` is the value that increments per attempt.)
222
+ # Buildkite retries a job inside the same `BUILDKITE_BUILD_ID`, bumping
223
+ # `BUILDKITE_RETRY_COUNT`; GitLab retries a job inside the same
224
+ # `CI_PIPELINE_ID`.
225
+ #
226
+ # **The attempt is deliberately not part of this id**, and that is the
227
+ # correction this field carries over its first implementation. Pressing
228
+ # "re-run failed jobs" — the mainline recovery gesture for a sharded
229
+ # suite, because re-running all 20,000 examples for one flaky shard is
230
+ # the thing sharding exists to avoid — re-runs a *subset* of shards. Had
231
+ # the attempt been folded in, that subset would have formed a brand-new
232
+ # run holding only the shards that happened to be retried, which is the
233
+ # split denominator this whole field exists to abolish, one gesture
234
+ # further down. Keeping the id stable instead lets a retried shard land
235
+ # back on the run it belongs to and *replace* its own previous slice.
236
+ #
237
+ # That replacement is what {SHARD_ID_KEYS} is for, and the two fields are
238
+ # only correct together: without a shard identity the platform can add a
239
+ # re-delivered slice but cannot recognise it, so a re-run inflates the
240
+ # denominator instead of refreshing it.
241
+ #
242
+ # Same providers in the same order as above, and the same "unset is nil,
243
+ # never an error" rule — a laptop run has none of these, and the platform
244
+ # treats a run with no id as its own run, which is exactly right.
245
+ #
246
+ # The requirement on whatever a provider exports is only this: every shard
247
+ # of one run reads the same value, and no *other* run reads it. A project
248
+ # whose CI layout does not satisfy that for the variable below — Jenkins
249
+ # `BUILD_TAG` interpolates `JOB_NAME`, which some matrix layouts vary per
250
+ # axis — sets `SPECGUARD_RUN_ID` itself, which wins over all of them.
251
+ RUN_ID_KEYS = %w[
252
+ SPECGUARD_RUN_ID
253
+ GITHUB_RUN_ID
254
+ CI_PIPELINE_ID
255
+ CIRCLE_WORKFLOW_ID
256
+ BUILDKITE_BUILD_ID
257
+ BUILD_TAG
258
+ ].freeze
259
+
260
+ # Which shard of the run this process is.
261
+ #
262
+ # The platform keys each slice of a run by this, so a shard that reports
263
+ # twice — a retried job, a re-run of the whole build — replaces its own
264
+ # previous numbers rather than adding to them. Without it, ingest can
265
+ # only ever add, and "re-run failed jobs" reports a suite larger than the
266
+ # suite.
267
+ #
268
+ # It only has to be unique *within one run*; it is never compared across
269
+ # runs, so a bare parallel-process index is a perfectly good value.
270
+ #
271
+ # `nil` is allowed and is not an error: the slice is still counted, it
272
+ # just cannot be recognised if it arrives twice. See the "anonymous
273
+ # contribution" note in the platform's `Ingest::RunRecorder` for exactly
274
+ # what that costs.
275
+ #
276
+ # == Two traps that are the reason this is not a plain key list
277
+ #
278
+ # 1. `parallel_tests` — by far the most likely way a Ruby suite is
279
+ # sharded — sets `TEST_ENV_NUMBER` to the **empty string** for its
280
+ # first process (`''`, `'2'`, `'3'`, … so that the first process uses
281
+ # the plain `yourproject_test` database). A first-non-blank rule would
282
+ # therefore skip straight past it and leave process 1, and only
283
+ # process 1, anonymous — the single hardest version of this bug to
284
+ # see, because three shards of four would be idempotent. Presence of
285
+ # the variable is the signal here, not its value, so a set-but-empty
286
+ # `TEST_ENV_NUMBER` resolves to `"1"`. See {#resolve_shard_id}.
287
+ #
288
+ # 2. GitHub Actions exports **no** per-leg index for a `matrix:` job.
289
+ # `GITHUB_JOB` is the job's id as written in the workflow YAML and is
290
+ # identical across every leg of the matrix, so it is not in this list —
291
+ # it would make all N shards claim to be the same shard, and the run
292
+ # would keep only whichever finished last. A matrix-sharded suite sets
293
+ # `SPECGUARD_SHARD_ID` from the matrix value itself, e.g.
294
+ # `SPECGUARD_SHARD_ID: ${{ matrix.shard }}`.
295
+ #
296
+ # The same rule applies to any nesting: `parallel_tests` *inside* a
297
+ # matrix leg makes `TEST_ENV_NUMBER` repeat across legs, so those
298
+ # projects set `SPECGUARD_SHARD_ID` to something that composes both.
299
+ SHARD_ID_KEYS = %w[
300
+ SPECGUARD_SHARD_ID
301
+ TEST_ENV_NUMBER
302
+ CI_NODE_INDEX
303
+ CIRCLE_NODE_INDEX
304
+ BUILDKITE_PARALLEL_JOB
305
+ ].freeze
306
+
307
+ # The one key in {SHARD_ID_KEYS} whose empty value is meaningful rather
308
+ # than absent. See trap 1 above.
309
+ BLANK_MEANS_FIRST_SHARD_KEY = "TEST_ENV_NUMBER"
310
+
311
+ OUTPUT_PATH_KEYS = %w[SPECGUARD_OUTPUT_PATH].freeze
312
+ ENDPOINT_KEYS = %w[SPECGUARD_ENDPOINT].freeze
313
+ API_KEY_KEYS = %w[SPECGUARD_API_KEY].freeze
314
+ TIMEOUT_KEYS = %w[SPECGUARD_TIMEOUT].freeze
315
+
316
+ # The commit the suite ran against. `nil` when nothing said.
317
+ attr_accessor :commit_sha
318
+ # The branch the suite ran on. `nil` when nothing said — including a
319
+ # detached checkout, which has no branch to name and says so rather than
320
+ # inventing one. See {GitCheckout::BRANCH_COMMAND}.
321
+ attr_accessor :branch
322
+ # The CI provider's id for the build this process is one shard of.
323
+ # `nil` when nothing said, which is how a laptop run spells "I am my own
324
+ # run". See {RUN_ID_KEYS}.
325
+ attr_accessor :run_id
326
+ # Which shard of that build this process is. `nil` when nothing said.
327
+ # See {SHARD_ID_KEYS}.
328
+ attr_accessor :shard_id
329
+ # Where the run's JSON payload is appended, one object per line — the
330
+ # local sink when there is no API key, and the fallback when delivery
331
+ # fails.
332
+ attr_accessor :output_path
333
+ # The SpecGuard installation to POST to, scheme and host only:
334
+ # `https://specguard.example.com`. The `/api/v1/ingest` path is the
335
+ # platform's contract, not a setting. `nil` when nothing said.
336
+ attr_accessor :endpoint
337
+ # The repository's SpecGuard API key. Present means "deliver over HTTP".
338
+ #
339
+ # Deliberately **not** format-checked. The platform mints `sgk_`-prefixed
340
+ # keys today; a client that gated on that prefix would start rejecting
341
+ # valid keys the day the platform rotated it, from a version of the gem
342
+ # nobody can retroactively fix. Send whatever is configured and let a 401
343
+ # be the answer.
344
+ attr_accessor :api_key
345
+ # Seconds, applied to opening the connection and to reading the response.
346
+ attr_accessor :timeout
347
+
348
+ # @param env [#[]] the environment to seed defaults from. Injectable so
349
+ # the mapping can be tested without mutating the process's own ENV.
350
+ # @param git [#commit_sha, #branch] the checkout to fall back to when no
351
+ # variable named the commit or the branch. Injectable for the same
352
+ # reason, and so a test does not silently pick up the sha or the branch
353
+ # of whatever repository it runs in.
354
+ def initialize(env: ENV, git: GitCheckout)
355
+ @commit_sha = first_present(env, COMMIT_SHA_KEYS) || blank_to_nil(git.commit_sha)
356
+ # `||` short-circuits, which is the whole of "do not spawn a subprocess
357
+ # when a provider already answered".
358
+ @branch = first_present(env, BRANCH_KEYS) || blank_to_nil(git.branch)
359
+ @run_id = first_present(env, RUN_ID_KEYS)
360
+ @shard_id = resolve_shard_id(env)
361
+ @output_path = first_present(env, OUTPUT_PATH_KEYS) || DEFAULT_OUTPUT_PATH
362
+ @endpoint = first_present(env, ENDPOINT_KEYS)
363
+ @api_key = first_present(env, API_KEY_KEYS)
364
+ @timeout = seconds(first_present(env, TIMEOUT_KEYS)) || DEFAULT_TIMEOUT_SECONDS
365
+ end
366
+
367
+ private
368
+
369
+ # {SHARD_ID_KEYS} in order, first non-blank wins — except that a
370
+ # {BLANK_MEANS_FIRST_SHARD_KEY} which is *set* counts as a hit even when
371
+ # it is empty, and resolves to `"1"`.
372
+ #
373
+ # `parallel_tests` numbers its processes `''`, `'2'`, `'3'`, … , so its
374
+ # first process is the one case where the empty string is a real answer
375
+ # rather than "nothing said". Reading it as "nothing said" would leave
376
+ # exactly one shard of a run anonymous while its siblings were
377
+ # identified, and the platform would then double-count that shard alone
378
+ # on a re-run — a wrong denominator that moves by a fifth of a suite and
379
+ # points at nothing.
380
+ #
381
+ # `--first-is-1` / `PARALLEL_TEST_FIRST_IS_1` make `parallel_tests` emit
382
+ # a literal `"1"` for that process, which the ordinary path already
383
+ # handles and which lands on the same value, so both configurations agree
384
+ # on what shard 1 is called.
385
+ def resolve_shard_id(env)
386
+ SHARD_ID_KEYS.each do |key|
387
+ value = env[key]
388
+ next if value.nil?
389
+
390
+ stripped = value.to_s.strip
391
+ return stripped unless stripped.empty?
392
+ return "1" if key == BLANK_MEANS_FIRST_SHARD_KEY
393
+ end
394
+
395
+ nil
396
+ end
397
+
398
+ def first_present(env, keys)
399
+ keys.each do |key|
400
+ value = env[key].to_s.strip
401
+ return value unless value.empty?
402
+ end
403
+
404
+ nil
405
+ end
406
+
407
+ def blank_to_nil(value)
408
+ string = value.to_s.strip
409
+ string.empty? ? nil : string
410
+ end
411
+
412
+ # A garbage `SPECGUARD_TIMEOUT` falls back to the default rather than
413
+ # raising or, worse, being coerced to `0` by `String#to_i` — which
414
+ # `Net::HTTP` reads as "time out immediately" and would turn a typo into
415
+ # a run that never delivers anything.
416
+ def seconds(value)
417
+ parsed = Float(value, exception: false)
418
+ return nil unless parsed&.finite? && parsed.positive?
419
+
420
+ parsed
421
+ end
422
+ end
423
+
424
+ class << self
425
+ # The process-wide formatter configuration.
426
+ #
427
+ # Built on first use rather than at load time so that a `configure` block
428
+ # in a `spec_helper.rb` and a variable exported by a CI job describe the
429
+ # same object regardless of which the interpreter reached first.
430
+ #
431
+ # @return [Configuration]
432
+ def configuration
433
+ @configuration ||= Configuration.new
434
+ end
435
+
436
+ # Configure the formatter.
437
+ #
438
+ # SpecGuard::RSpec.configure do |config|
439
+ # config.branch = "release/2.0"
440
+ # end
441
+ #
442
+ # @yieldparam configuration [Configuration]
443
+ # @return [Configuration] the configuration, block or no block
444
+ def configure
445
+ yield configuration if block_given?
446
+ configuration
447
+ end
448
+
449
+ # Drop the memoized configuration so the next read re-seeds from ENV.
450
+ # Exists for tests, and for the rare caller that changes the environment
451
+ # after this file was loaded.
452
+ #
453
+ # @return [void]
454
+ def reset_configuration!
455
+ @configuration = nil
456
+ end
457
+ end
458
+ end
459
+ end