specguard-ruby 0.3.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,472 @@
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 `local_output_path`,
145
+ # a local development record kept apart from the `output_path` replay queue
146
+ # (a run offered to the endpoint and not accepted). Local development is
147
+ # therefore the default, and it needs no opt-out — which is the whole
148
+ # reason the switch is the credential rather than a separate `enabled`
149
+ # flag nobody would remember to turn off.
150
+ #
151
+ # There is deliberately **no default endpoint**. SpecGuard is self-hostable
152
+ # and there is no address that is right for everybody; guessing one would
153
+ # mean a misconfigured project POSTing its test names to whatever happens to
154
+ # answer at that name.
155
+ class Configuration
156
+ # Relative to the working directory the suite was started from, which is
157
+ # the project root for every normal `bundle exec rspec` invocation.
158
+ DEFAULT_OUTPUT_PATH = "log/test_results.jsonl"
159
+
160
+ # The keyless branch's sink: a local development record, deliberately
161
+ # separate from the replay queue. See {DEFAULT_OUTPUT_PATH}.
162
+ DEFAULT_LOCAL_OUTPUT_PATH = "log/test_results.local.jsonl"
163
+
164
+ # Applied to opening the connection and to reading the response.
165
+ #
166
+ # `Net::HTTP`'s own defaults are 60s each, which is up to two minutes
167
+ # added to every CI run against a hung endpoint — squarely against the
168
+ # roadmap's "a 20,000-example run must not be meaningfully slowed". Ten
169
+ # seconds is the number the client-gem spec settles on, and it is the
170
+ # whole budget: there are **no retries**, because a retry doubles the
171
+ # worst case for telemetry that is explicitly allowed to be lost.
172
+ DEFAULT_TIMEOUT_SECONDS = 10
173
+
174
+ # Checked in order, first non-blank wins. The `SPECGUARD_`-prefixed name
175
+ # comes first everywhere so a project can always override whatever its CI
176
+ # provider decided to export.
177
+ #
178
+ # The provider list is not decoration. `commit_sha` is the one field the
179
+ # platform rejects a run over, so a list that only knew `GITHUB_SHA` meant
180
+ # every GitLab, CircleCI, Buildkite and Jenkins run 400ing as a matter of
181
+ # course — the *default* outcome on four of the five providers this gem is
182
+ # likely to meet.
183
+ COMMIT_SHA_KEYS = %w[
184
+ SPECGUARD_COMMIT_SHA
185
+ GITHUB_SHA
186
+ CI_COMMIT_SHA
187
+ CIRCLE_SHA1
188
+ BUILDKITE_COMMIT
189
+ GIT_COMMIT
190
+ ].freeze
191
+
192
+ # The same providers, in the same order. `GITHUB_REF_NAME` is the short
193
+ # name (`main`), not the full ref (`refs/heads/main`) that `GITHUB_REF`
194
+ # carries; Jenkins's `GIT_BRANCH` is passed through as it comes
195
+ # (`origin/main` on many setups), because `branch` is a free-form string
196
+ # to the platform and inventing a normalization here would be this gem
197
+ # guessing at somebody's ref layout.
198
+ BRANCH_KEYS = %w[
199
+ SPECGUARD_BRANCH
200
+ GITHUB_REF_NAME
201
+ CI_COMMIT_REF_NAME
202
+ CIRCLE_BRANCH
203
+ BUILDKITE_BRANCH
204
+ GIT_BRANCH
205
+ ].freeze
206
+
207
+ # The id the CI provider gave the *build*, which every shard of a sharded
208
+ # run shares.
209
+ #
210
+ # This is the field that makes a 20,000-example suite land as one run.
211
+ # Nobody runs a suite that size in a single process: under
212
+ # `parallel_tests`, Knapsack or a CI matrix each shard loads this
213
+ # formatter and POSTs its own slice, and every shard carries the same
214
+ # `commit_sha` and `branch` — so the platform had nothing to tell four
215
+ # shards of one run apart from four separate runs, and recorded four
216
+ # `TestRun` rows each holding a quarter of the denominator. Annotations
217
+ # tend to cluster in whatever area a team is currently working on rather
218
+ # than spreading evenly across shards, so the headline ratio then moved
219
+ # from re-run to re-run without the suite changing at all.
220
+ #
221
+ # == It identifies the run, and deliberately survives a re-run
222
+ #
223
+ # These ids are stable across re-attempts of the same build, and that is
224
+ # documented behaviour rather than an accident. GitHub Actions, verbatim:
225
+ # `GITHUB_RUN_ID` is *"A unique number for each workflow run within a
226
+ # repository. This number does not change if you re-run the workflow
227
+ # run."* (`GITHUB_RUN_ATTEMPT` is the value that increments per attempt.)
228
+ # Buildkite retries a job inside the same `BUILDKITE_BUILD_ID`, bumping
229
+ # `BUILDKITE_RETRY_COUNT`; GitLab retries a job inside the same
230
+ # `CI_PIPELINE_ID`.
231
+ #
232
+ # **The attempt is deliberately not part of this id**, and that is the
233
+ # correction this field carries over its first implementation. Pressing
234
+ # "re-run failed jobs" — the mainline recovery gesture for a sharded
235
+ # suite, because re-running all 20,000 examples for one flaky shard is
236
+ # the thing sharding exists to avoid — re-runs a *subset* of shards. Had
237
+ # the attempt been folded in, that subset would have formed a brand-new
238
+ # run holding only the shards that happened to be retried, which is the
239
+ # split denominator this whole field exists to abolish, one gesture
240
+ # further down. Keeping the id stable instead lets a retried shard land
241
+ # back on the run it belongs to and *replace* its own previous slice.
242
+ #
243
+ # That replacement is what {SHARD_ID_KEYS} is for, and the two fields are
244
+ # only correct together: without a shard identity the platform can add a
245
+ # re-delivered slice but cannot recognise it, so a re-run inflates the
246
+ # denominator instead of refreshing it.
247
+ #
248
+ # Same providers in the same order as above, and the same "unset is nil,
249
+ # never an error" rule — a laptop run has none of these, and the platform
250
+ # treats a run with no id as its own run, which is exactly right.
251
+ #
252
+ # The requirement on whatever a provider exports is only this: every shard
253
+ # of one run reads the same value, and no *other* run reads it. A project
254
+ # whose CI layout does not satisfy that for the variable below — Jenkins
255
+ # `BUILD_TAG` interpolates `JOB_NAME`, which some matrix layouts vary per
256
+ # axis — sets `SPECGUARD_RUN_ID` itself, which wins over all of them.
257
+ RUN_ID_KEYS = %w[
258
+ SPECGUARD_RUN_ID
259
+ GITHUB_RUN_ID
260
+ CI_PIPELINE_ID
261
+ CIRCLE_WORKFLOW_ID
262
+ BUILDKITE_BUILD_ID
263
+ BUILD_TAG
264
+ ].freeze
265
+
266
+ # Which shard of the run this process is.
267
+ #
268
+ # The platform keys each slice of a run by this, so a shard that reports
269
+ # twice — a retried job, a re-run of the whole build — replaces its own
270
+ # previous numbers rather than adding to them. Without it, ingest can
271
+ # only ever add, and "re-run failed jobs" reports a suite larger than the
272
+ # suite.
273
+ #
274
+ # It only has to be unique *within one run*; it is never compared across
275
+ # runs, so a bare parallel-process index is a perfectly good value.
276
+ #
277
+ # `nil` is allowed and is not an error: the slice is still counted, it
278
+ # just cannot be recognised if it arrives twice. See the "anonymous
279
+ # contribution" note in the platform's `Ingest::RunRecorder` for exactly
280
+ # what that costs.
281
+ #
282
+ # == Two traps that are the reason this is not a plain key list
283
+ #
284
+ # 1. `parallel_tests` — by far the most likely way a Ruby suite is
285
+ # sharded — sets `TEST_ENV_NUMBER` to the **empty string** for its
286
+ # first process (`''`, `'2'`, `'3'`, … so that the first process uses
287
+ # the plain `yourproject_test` database). A first-non-blank rule would
288
+ # therefore skip straight past it and leave process 1, and only
289
+ # process 1, anonymous — the single hardest version of this bug to
290
+ # see, because three shards of four would be idempotent. Presence of
291
+ # the variable is the signal here, not its value, so a set-but-empty
292
+ # `TEST_ENV_NUMBER` resolves to `"1"`. See {#resolve_shard_id}.
293
+ #
294
+ # 2. GitHub Actions exports **no** per-leg index for a `matrix:` job.
295
+ # `GITHUB_JOB` is the job's id as written in the workflow YAML and is
296
+ # identical across every leg of the matrix, so it is not in this list —
297
+ # it would make all N shards claim to be the same shard, and the run
298
+ # would keep only whichever finished last. A matrix-sharded suite sets
299
+ # `SPECGUARD_SHARD_ID` from the matrix value itself, e.g.
300
+ # `SPECGUARD_SHARD_ID: ${{ matrix.shard }}`.
301
+ #
302
+ # The same rule applies to any nesting: `parallel_tests` *inside* a
303
+ # matrix leg makes `TEST_ENV_NUMBER` repeat across legs, so those
304
+ # projects set `SPECGUARD_SHARD_ID` to something that composes both.
305
+ SHARD_ID_KEYS = %w[
306
+ SPECGUARD_SHARD_ID
307
+ TEST_ENV_NUMBER
308
+ CI_NODE_INDEX
309
+ CIRCLE_NODE_INDEX
310
+ BUILDKITE_PARALLEL_JOB
311
+ ].freeze
312
+
313
+ # The one key in {SHARD_ID_KEYS} whose empty value is meaningful rather
314
+ # than absent. See trap 1 above.
315
+ BLANK_MEANS_FIRST_SHARD_KEY = "TEST_ENV_NUMBER"
316
+
317
+ OUTPUT_PATH_KEYS = %w[SPECGUARD_OUTPUT_PATH].freeze
318
+ LOCAL_OUTPUT_PATH_KEYS = %w[SPECGUARD_LOCAL_OUTPUT_PATH].freeze
319
+ ENDPOINT_KEYS = %w[SPECGUARD_ENDPOINT].freeze
320
+ API_KEY_KEYS = %w[SPECGUARD_API_KEY].freeze
321
+ TIMEOUT_KEYS = %w[SPECGUARD_TIMEOUT].freeze
322
+
323
+ # The commit the suite ran against. `nil` when nothing said.
324
+ attr_accessor :commit_sha
325
+ # The branch the suite ran on. `nil` when nothing said — including a
326
+ # detached checkout, which has no branch to name and says so rather than
327
+ # inventing one. See {GitCheckout::BRANCH_COMMAND}.
328
+ attr_accessor :branch
329
+ # The CI provider's id for the build this process is one shard of.
330
+ # `nil` when nothing said, which is how a laptop run spells "I am my own
331
+ # run". See {RUN_ID_KEYS}.
332
+ attr_accessor :run_id
333
+ # Which shard of that build this process is. `nil` when nothing said.
334
+ # See {SHARD_ID_KEYS}.
335
+ attr_accessor :shard_id
336
+ # Where a run that was *offered to the endpoint and not accepted* is
337
+ # appended, one object per line — the replay queue. `specguard-ingest`
338
+ # pointed at this file re-delivers genuine failed deliveries by
339
+ # construction.
340
+ attr_accessor :output_path
341
+ # Where a keyless run (no `api_key` configured) is appended — a local
342
+ # development record, not a replay queue. Setting this to the same value
343
+ # as {#output_path} reproduces the pre-split single-file behaviour.
344
+ attr_accessor :local_output_path
345
+ # The SpecGuard installation to POST to, scheme and host only:
346
+ # `https://specguard.example.com`. The `/api/v1/ingest` path is the
347
+ # platform's contract, not a setting. `nil` when nothing said.
348
+ attr_accessor :endpoint
349
+ # The repository's SpecGuard API key. Present means "deliver over HTTP".
350
+ #
351
+ # Deliberately **not** format-checked. The platform mints `sgk_`-prefixed
352
+ # keys today; a client that gated on that prefix would start rejecting
353
+ # valid keys the day the platform rotated it, from a version of the gem
354
+ # nobody can retroactively fix. Send whatever is configured and let a 401
355
+ # be the answer.
356
+ attr_accessor :api_key
357
+ # Seconds, applied to opening the connection and to reading the response.
358
+ attr_accessor :timeout
359
+
360
+ # @param env [#[]] the environment to seed defaults from. Injectable so
361
+ # the mapping can be tested without mutating the process's own ENV.
362
+ # @param git [#commit_sha, #branch] the checkout to fall back to when no
363
+ # variable named the commit or the branch. Injectable for the same
364
+ # reason, and so a test does not silently pick up the sha or the branch
365
+ # of whatever repository it runs in.
366
+ def initialize(env: ENV, git: GitCheckout)
367
+ @commit_sha = first_present(env, COMMIT_SHA_KEYS) || blank_to_nil(git.commit_sha)
368
+ # `||` short-circuits, which is the whole of "do not spawn a subprocess
369
+ # when a provider already answered".
370
+ @branch = first_present(env, BRANCH_KEYS) || blank_to_nil(git.branch)
371
+ @run_id = first_present(env, RUN_ID_KEYS)
372
+ @shard_id = resolve_shard_id(env)
373
+ @output_path = first_present(env, OUTPUT_PATH_KEYS) || DEFAULT_OUTPUT_PATH
374
+ @local_output_path = first_present(env, LOCAL_OUTPUT_PATH_KEYS) || DEFAULT_LOCAL_OUTPUT_PATH
375
+ @endpoint = first_present(env, ENDPOINT_KEYS)
376
+ @api_key = first_present(env, API_KEY_KEYS)
377
+ @timeout = seconds(first_present(env, TIMEOUT_KEYS)) || DEFAULT_TIMEOUT_SECONDS
378
+ end
379
+
380
+ private
381
+
382
+ # {SHARD_ID_KEYS} in order, first non-blank wins — except that a
383
+ # {BLANK_MEANS_FIRST_SHARD_KEY} which is *set* counts as a hit even when
384
+ # it is empty, and resolves to `"1"`.
385
+ #
386
+ # `parallel_tests` numbers its processes `''`, `'2'`, `'3'`, … , so its
387
+ # first process is the one case where the empty string is a real answer
388
+ # rather than "nothing said". Reading it as "nothing said" would leave
389
+ # exactly one shard of a run anonymous while its siblings were
390
+ # identified, and the platform would then double-count that shard alone
391
+ # on a re-run — a wrong denominator that moves by a fifth of a suite and
392
+ # points at nothing.
393
+ #
394
+ # `--first-is-1` / `PARALLEL_TEST_FIRST_IS_1` make `parallel_tests` emit
395
+ # a literal `"1"` for that process, which the ordinary path already
396
+ # handles and which lands on the same value, so both configurations agree
397
+ # on what shard 1 is called.
398
+ def resolve_shard_id(env)
399
+ SHARD_ID_KEYS.each do |key|
400
+ value = env[key]
401
+ next if value.nil?
402
+
403
+ stripped = value.to_s.strip
404
+ return stripped unless stripped.empty?
405
+ return "1" if key == BLANK_MEANS_FIRST_SHARD_KEY
406
+ end
407
+
408
+ nil
409
+ end
410
+
411
+ def first_present(env, keys)
412
+ keys.each do |key|
413
+ value = env[key].to_s.strip
414
+ return value unless value.empty?
415
+ end
416
+
417
+ nil
418
+ end
419
+
420
+ def blank_to_nil(value)
421
+ string = value.to_s.strip
422
+ string.empty? ? nil : string
423
+ end
424
+
425
+ # A garbage `SPECGUARD_TIMEOUT` falls back to the default rather than
426
+ # raising or, worse, being coerced to `0` by `String#to_i` — which
427
+ # `Net::HTTP` reads as "time out immediately" and would turn a typo into
428
+ # a run that never delivers anything.
429
+ def seconds(value)
430
+ parsed = Float(value, exception: false)
431
+ return nil unless parsed&.finite? && parsed.positive?
432
+
433
+ parsed
434
+ end
435
+ end
436
+
437
+ class << self
438
+ # The process-wide formatter configuration.
439
+ #
440
+ # Built on first use rather than at load time so that a `configure` block
441
+ # in a `spec_helper.rb` and a variable exported by a CI job describe the
442
+ # same object regardless of which the interpreter reached first.
443
+ #
444
+ # @return [Configuration]
445
+ def configuration
446
+ @configuration ||= Configuration.new
447
+ end
448
+
449
+ # Configure the formatter.
450
+ #
451
+ # SpecGuard::RSpec.configure do |config|
452
+ # config.branch = "release/2.0"
453
+ # end
454
+ #
455
+ # @yieldparam configuration [Configuration]
456
+ # @return [Configuration] the configuration, block or no block
457
+ def configure
458
+ yield configuration if block_given?
459
+ configuration
460
+ end
461
+
462
+ # Drop the memoized configuration so the next read re-seeds from ENV.
463
+ # Exists for tests, and for the rare caller that changes the environment
464
+ # after this file was loaded.
465
+ #
466
+ # @return [void]
467
+ def reset_configuration!
468
+ @configuration = nil
469
+ end
470
+ end
471
+ end
472
+ end