specguard-ruby 0.3.31 → 0.3.33

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 14818808e8ef3b696b1b3834ea590734fa398f5fabbef1c5646d03699e844d99
4
- data.tar.gz: ccce2e2cf09f6b3401284c0f7f3f343185c86fa2d48bc43ca7bbd9e48575910e
3
+ metadata.gz: f0f1cdce1e2e763d549b0ccb1bd5d9a22b1c5a9da9f63645704af3ecd8149de1
4
+ data.tar.gz: aa80ba0c1f264a50d307c83fd1a8c9a543d6b5eef75037f53ac5ffef68c5d812
5
5
  SHA512:
6
- metadata.gz: a7612a5d841ecf6d7b1f675bfe362a5141948c1e227a89d7d90e449c9f3b802b650894e7cd3862bb37a897dcbc77ae6e0d627caa1b3cbde3275b54ebd41beb54
7
- data.tar.gz: d78f0bea385d4a9ca63d1581648350c76662f7540b0c105dda6a0ebb15c7b3be1be98e7d2ca8f29b5536fac597a4ed155cb5b96f8ff8575e79bfde7292ff0034
6
+ metadata.gz: 4673b30c851eb2324326704ab72539c4a909b068f1addc354cb461de216816a05ee2f195b01c70a9c78cadd3810760d6d59c89947e6c56a31971ca6915e658e4
7
+ data.tar.gz: fd2ae5c3fafa5ed48b05c71f5109fea22b8b1796b7d0869cf1fb6bcfe2b8adaad440aa79c2cea493c2984bf7fb2a032fe17a7666d19387f4803852692f2f925d
data/README.md CHANGED
@@ -1146,8 +1146,8 @@ body is encoded on the wire, never what is in it.
1146
1146
 
1147
1147
  | Field | What it holds | Where it comes from |
1148
1148
  | --- | --- | --- |
1149
- | `commit_sha` | the commit the suite ran against | `SPECGUARD_COMMIT_SHA` if you set it, else `GITHUB_SHA`, `CI_COMMIT_SHA`, `CIRCLE_SHA1`, `BUILDKITE_COMMIT`, `GIT_COMMIT`, else `git rev-parse HEAD` |
1150
- | `branch` | the branch name; `null` on a detached checkout | `SPECGUARD_BRANCH` if you set it, else `GITHUB_REF_NAME`, `CI_COMMIT_REF_NAME`, `CIRCLE_BRANCH`, `BUILDKITE_BRANCH`, `GIT_BRANCH`, else `git symbolic-ref --short -q HEAD` |
1149
+ | `commit_sha` | the commit the suite ran against | `SPECGUARD_COMMIT_SHA` if you set it, else — on a same-repository GitHub Actions `pull_request` / `pull_request_target` run — the pushed head sha from the event payload at `GITHUB_EVENT_PATH` (`GITHUB_SHA` there is an ephemeral merge commit), else `GITHUB_SHA`, `CI_COMMIT_SHA`, `CIRCLE_SHA1`, `BUILDKITE_COMMIT`, `GIT_COMMIT`, else `git rev-parse HEAD` |
1150
+ | `branch` | the branch name; `null` on a detached checkout | `SPECGUARD_BRANCH` if you set it, else — on a same-repository GitHub Actions `pull_request` / `pull_request_target` run — `GITHUB_HEAD_REF` (`GITHUB_REF_NAME` there is `<n>/merge`), else `GITHUB_REF_NAME`, `CI_COMMIT_REF_NAME`, `CIRCLE_BRANCH`, `BUILDKITE_BRANCH`, `GIT_BRANCH`, else `git symbolic-ref --short -q HEAD` |
1151
1151
  | `ci_run_id` | your provider's build id, so shards of one run fold together; `null` on a laptop | `SPECGUARD_RUN_ID` if you set it, else `GITHUB_RUN_ID`, `CI_PIPELINE_ID`, `CIRCLE_WORKFLOW_ID`, `BUILDKITE_BUILD_ID`, `BUILD_TAG` |
1152
1152
  | `shard_id` | which slice of that run this process is; `null` when unsharded | `SPECGUARD_SHARD_ID` if you set it, else `TEST_ENV_NUMBER`, `CI_NODE_INDEX`, `CIRCLE_NODE_INDEX`, `BUILDKITE_PARALLEL_JOB` |
1153
1153
  | `duration_seconds` | wall clock for the whole run | measured by the formatter |
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
4
+
3
5
  module SpecGuard
4
6
  module Client
5
7
  # Where the checkout is sitting — which commit, and which branch — asked of
@@ -180,6 +182,12 @@ module SpecGuard
180
182
  # every GitLab, CircleCI, Buildkite and Jenkins run 400ing as a matter of
181
183
  # course — the *default* outcome on four of the five providers this gem is
182
184
  # likely to meet.
185
+ #
186
+ # On a GitHub Actions `pull_request` / `pull_request_target` run from the
187
+ # same repository, the pushed head commit in the event payload
188
+ # (`#pull_request_head`) is consulted *between* `SPECGUARD_COMMIT_SHA` and
189
+ # the rest of this list: `GITHUB_SHA` there is the ephemeral merge commit
190
+ # (the base tip on `_target`), a sha nobody pushed.
183
191
  COMMIT_SHA_KEYS = %w[
184
192
  SPECGUARD_COMMIT_SHA
185
193
  GITHUB_SHA
@@ -195,6 +203,11 @@ module SpecGuard
195
203
  # (`origin/main` on many setups), because `branch` is a free-form string
196
204
  # to the platform and inventing a normalization here would be this gem
197
205
  # guessing at somebody's ref layout.
206
+ #
207
+ # On a same-repository GitHub Actions `pull_request` /
208
+ # `pull_request_target` run, {PULL_REQUEST_BRANCH_KEYS} (`GITHUB_HEAD_REF`,
209
+ # the source branch) is consulted between `SPECGUARD_BRANCH` and the rest
210
+ # of this list, because `GITHUB_REF_NAME` there is `<n>/merge`.
198
211
  BRANCH_KEYS = %w[
199
212
  SPECGUARD_BRANCH
200
213
  GITHUB_REF_NAME
@@ -314,6 +327,22 @@ module SpecGuard
314
327
  # than absent. See trap 1 above.
315
328
  BLANK_MEANS_FIRST_SHARD_KEY = "TEST_ENV_NUMBER"
316
329
 
330
+ # GitHub Actions only: the event that triggered the run, the file holding
331
+ # its payload, and the source branch of a pull request. They are read
332
+ # only together, by `#pull_request_head`, and only when `GITHUB_EVENT_NAME`
333
+ # is one of {PULL_REQUEST_EVENTS}.
334
+ EVENT_NAME_KEYS = %w[GITHUB_EVENT_NAME].freeze
335
+ EVENT_PATH_KEYS = %w[GITHUB_EVENT_PATH].freeze
336
+ PULL_REQUEST_BRANCH_KEYS = %w[GITHUB_HEAD_REF].freeze
337
+
338
+ # The GitHub events whose `GITHUB_SHA` / `GITHUB_REF_NAME` describe a
339
+ # merge ref rather than anything a person pushed.
340
+ PULL_REQUEST_EVENTS = %w[pull_request pull_request_target].freeze
341
+
342
+ # Largest `GITHUB_EVENT_PATH` payload read; a bigger file is treated as
343
+ # unreadable (mirrors specguard-ts).
344
+ MAX_EVENT_PAYLOAD_BYTES = 1024 * 1024
345
+
317
346
  OUTPUT_PATH_KEYS = %w[SPECGUARD_OUTPUT_PATH].freeze
318
347
  LOCAL_OUTPUT_PATH_KEYS = %w[SPECGUARD_LOCAL_OUTPUT_PATH].freeze
319
348
  ENDPOINT_KEYS = %w[SPECGUARD_ENDPOINT].freeze
@@ -377,10 +406,22 @@ module SpecGuard
377
406
  # reason, and so a test does not silently pick up the sha or the branch
378
407
  # of whatever repository it runs in.
379
408
  def initialize(env: ENV, git: GitCheckout)
380
- @commit_sha = first_present(env, COMMIT_SHA_KEYS) || blank_to_nil(git.commit_sha)
409
+ explicit_sha = first_present(env, COMMIT_SHA_KEYS.first(1))
410
+ explicit_branch = first_present(env, BRANCH_KEYS.first(1))
411
+ # One read of the event payload decides branch and sha together, so
412
+ # they cannot disagree; skipped when both were set explicitly.
413
+ pr_head = explicit_sha && explicit_branch ? nil : pull_request_head(env)
414
+
381
415
  # `||` short-circuits, which is the whole of "do not spawn a subprocess
382
416
  # when a provider already answered".
383
- @branch = first_present(env, BRANCH_KEYS) || blank_to_nil(git.branch)
417
+ @commit_sha = explicit_sha ||
418
+ pr_head&.fetch(:sha) ||
419
+ first_present(env, COMMIT_SHA_KEYS) ||
420
+ blank_to_nil(git.commit_sha)
421
+ @branch = explicit_branch ||
422
+ (pr_head && first_present(env, PULL_REQUEST_BRANCH_KEYS)) ||
423
+ first_present(env, BRANCH_KEYS) ||
424
+ blank_to_nil(git.branch)
384
425
  @run_id = first_present(env, RUN_ID_KEYS)
385
426
  @shard_id = resolve_shard_id(env)
386
427
  @output_path = first_present(env, OUTPUT_PATH_KEYS) || DEFAULT_OUTPUT_PATH
@@ -393,6 +434,65 @@ module SpecGuard
393
434
 
394
435
  private
395
436
 
437
+ # The pushed head of a GitHub Actions pull-request run, or nil when it
438
+ # cannot be established as a SAME-REPOSITORY pull request.
439
+ #
440
+ # On `pull_request` / `pull_request_target`, `GITHUB_SHA` is the
441
+ # ephemeral merge commit (the base tip on `_target`) and
442
+ # `GITHUB_REF_NAME` is `<n>/merge` — neither is anything a person pushed.
443
+ # `git rev-parse HEAD` does not rescue it: `actions/checkout` puts the
444
+ # merge commit at HEAD. The real head sha lives only in the event payload
445
+ # at `GITHUB_EVENT_PATH`.
446
+ #
447
+ # A fork PR yields nil: `GITHUB_HEAD_REF` is then the FORK's branch name
448
+ # (`main` on a `contrib:main` PR would be filed under the base
449
+ # repository's own `main`), so the merge-ref behaviour is kept. A
450
+ # missing, unreadable, oversized or garbled payload also yields nil.
451
+ # Never raises and shells out to nothing.
452
+ #
453
+ # @return [Hash{Symbol=>String,nil}, nil] `{sha:}` — sha is nil when the
454
+ # payload's head sha is not 40 hex characters (the branch is still the
455
+ # source branch then) — or nil when this is not a same-repo PR.
456
+ def pull_request_head(env)
457
+ return nil unless PULL_REQUEST_EVENTS.include?(first_present(env, EVENT_NAME_KEYS))
458
+
459
+ path = first_present(env, EVENT_PATH_KEYS)
460
+ return nil if path.nil?
461
+
462
+ pull_request = read_event_payload(path)&.fetch("pull_request", nil)
463
+ return nil unless pull_request.is_a?(Hash)
464
+
465
+ head = pull_request["head"]
466
+ base = pull_request["base"]
467
+ head_repo = repo_full_name(head)
468
+ return nil if head_repo.nil? || head_repo != repo_full_name(base)
469
+
470
+ sha = head["sha"]
471
+ { sha: sha.is_a?(String) && sha.match?(/\A[0-9a-f]{40}\z/i) ? sha : nil }
472
+ rescue ScriptError, StandardError
473
+ nil
474
+ end
475
+
476
+ def repo_full_name(side)
477
+ return nil unless side.is_a?(Hash) && side["repo"].is_a?(Hash)
478
+
479
+ name = side["repo"]["full_name"]
480
+ name.is_a?(String) && !name.empty? ? name : nil
481
+ end
482
+
483
+ # @return [Hash, nil] the parsed payload, or nil if it is absent, not a
484
+ # regular file, larger than {MAX_EVENT_PAYLOAD_BYTES}, or not a JSON
485
+ # object.
486
+ def read_event_payload(path)
487
+ return nil unless File.file?(path)
488
+
489
+ raw = File.open(path, "rb") { |file| file.read(MAX_EVENT_PAYLOAD_BYTES + 1) }
490
+ return nil if raw.nil? || raw.bytesize > MAX_EVENT_PAYLOAD_BYTES
491
+
492
+ parsed = JSON.parse(raw)
493
+ parsed.is_a?(Hash) ? parsed : nil
494
+ end
495
+
396
496
  # {SHARD_ID_KEYS} in order, first non-blank wins — except that a
397
497
  # {BLANK_MEANS_FIRST_SHARD_KEY} which is *set* counts as a hit even when
398
498
  # it is empty, and resolves to `"1"`.
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecGuard
4
+ module Client
5
+ # The two clauses that finish a delivery-failure warning line, shared by
6
+ # the RSpec formatter and the Minitest reporter.
7
+ #
8
+ # Both clients print the same sentence after their own prefix and status
9
+ # clause: where the run ended up, and (when it was saved) the command that
10
+ # gets it back. They used to carry two inline copies kept equal
11
+ # "word-for-word" by hand, and the pair had already been co-edited in
12
+ # separate commits (SPGD-1139, SPGD-1413). One definition here means a
13
+ # wording change cannot reach one client and miss the other.
14
+ #
15
+ # What stays in each client: the prefix, the warning budget (`@warned`),
16
+ # and the `append` that decides whether `error` is nil. Those differ by
17
+ # design; only the clause text is shared.
18
+ module DeliveryWarning
19
+ module_function
20
+
21
+ # Report, not promise. Composed after the write so it can tell the
22
+ # operator which of the two things actually happened. `error` is the
23
+ # client's `append` answer: `nil` means the queue took the run (the
24
+ # success arm, {#replay_clause}); anything else means both sinks are
25
+ # gone and the run really is lost (SPGD-1413).
26
+ def sink_clause(path, error)
27
+ return replay_clause(path) if error.nil?
28
+
29
+ "The replay queue #{path} could not be written either " \
30
+ "(#{error.class}: #{error.message}), so this run's telemetry was lost."
31
+ end
32
+
33
+ # The success arm (SPGD-1139): where the run sits AND the command that
34
+ # gets it back, fix-the-delivery first and replay after — the order the
35
+ # ingest CLI's own doctrine describes. The lost-run arm deliberately
36
+ # names no command: with no queue written there is nothing to replay.
37
+ def replay_clause(path)
38
+ "Falling back to #{path}; the test run is unaffected. " \
39
+ "Once the delivery is fixed, replay this run with: bundle exec specguard-ingest #{path}"
40
+ end
41
+ end
42
+ end
43
+ end
@@ -739,8 +739,7 @@ module SpecGuard
739
739
  raise UsageError, "no endpoint is configured (set SPECGUARD_ENDPOINT)" if blank?(configuration.endpoint)
740
740
  raise UsageError, "no API key is configured (set SPECGUARD_API_KEY)" if blank?(configuration.api_key)
741
741
 
742
- transport = Transport.new(endpoint: configuration.endpoint, api_key: configuration.api_key,
743
- timeout: configuration.timeout, repository_id: configuration.repository_id)
742
+ transport = Transport.from_configuration(configuration)
744
743
  # Asked once, here, so a malformed `SPECGUARD_ENDPOINT` is one exit 2
745
744
  # rather than N identical `:failed` lines. `Transport#deliver` would
746
745
  # otherwise swallow the same `ArgumentError` once per line and report a
@@ -293,6 +293,21 @@ module SpecGuard
293
293
  end
294
294
  end
295
295
 
296
+ # Builds a transport from a {Configuration}, projecting the four settings
297
+ # every ingest writer forwards (endpoint, api_key, timeout, repository_id)
298
+ # so a new keyword is threaded in one place rather than in each writer.
299
+ #
300
+ # @param configuration [Configuration, #endpoint, #api_key, #timeout, #repository_id]
301
+ # @return [Transport]
302
+ def self.from_configuration(configuration)
303
+ new(
304
+ endpoint: configuration.endpoint,
305
+ api_key: configuration.api_key,
306
+ timeout: configuration.timeout,
307
+ repository_id: configuration.repository_id
308
+ )
309
+ end
310
+
296
311
  # @param endpoint [String, nil] the installation's base URL. Any trailing
297
312
  # slashes are dropped; a path prefix is preserved, so an installation
298
313
  # behind `https://tools.example.com/specguard` works.
@@ -9,6 +9,7 @@ require "fileutils"
9
9
  # loads them on a minitest-only consumer's machine.
10
10
  require_relative "../client"
11
11
  require_relative "../client/configuration"
12
+ require_relative "../client/delivery_warning"
12
13
 
13
14
  # The annotation lookup is the third framework-free half: what counts as a
14
15
  # test's annotation is a property of the source line, not of the framework
@@ -244,12 +245,7 @@ module SpecGuard
244
245
 
245
246
  def transport_for
246
247
  require_relative "../client/transport"
247
- SpecGuard::Client::Transport.new(
248
- endpoint: @configuration.endpoint,
249
- api_key: @configuration.api_key,
250
- timeout: @configuration.timeout,
251
- repository_id: @configuration.repository_id
252
- )
248
+ SpecGuard::Client::Transport.from_configuration(@configuration)
253
249
  end
254
250
 
255
251
  def row_for(result)
@@ -404,21 +400,10 @@ module SpecGuard
404
400
  end
405
401
 
406
402
  # Report, not promise — see `#fall_back` for why that is the whole of
407
- # this slice, and why the order above it did not move.
403
+ # this slice, and why the order above it did not move. The wording is
404
+ # shared with the RSpec formatter in {Client::DeliveryWarning}.
408
405
  def sink_clause(path, error)
409
- return replay_clause(path) if error.nil?
410
-
411
- "The replay queue #{path} could not be written either " \
412
- "(#{error.class}: #{error.message}), so this run's telemetry was lost."
413
- end
414
-
415
- # The success arm (SPGD-1139): names the configured path (the one
416
- # `#append` just wrote) and the replay command, word-for-word the
417
- # formatter's clause. The lost-run arm names no command — nothing to
418
- # replay.
419
- def replay_clause(path)
420
- "Falling back to #{path}; the test run is unaffected. " \
421
- "Once the delivery is fixed, replay this run with: bundle exec specguard-ingest #{path}"
406
+ SpecGuard::Client::DeliveryWarning.sink_clause(path, error)
422
407
  end
423
408
 
424
409
  # Once per process, like the formatter's `warn_once`: fifty failing
@@ -48,6 +48,7 @@ require "fileutils"
48
48
  require_relative "../rspec"
49
49
  require_relative "../client/configuration"
50
50
  require_relative "../client/transport"
51
+ require_relative "../client/delivery_warning"
51
52
  # Brings the linter's discovery chain with it (Scanner, Finding, Schema). That
52
53
  # direction is safe — `specguard/rspec` does not require `rspec/core`, so the
53
54
  # linter stays loadable without RSpec; it is only the reverse that would break
@@ -1037,12 +1038,7 @@ module SpecGuard
1037
1038
  end
1038
1039
 
1039
1040
  def transport_for(configuration)
1040
- SpecGuard::Client::Transport.new(
1041
- endpoint: configuration.endpoint,
1042
- api_key: configuration.api_key,
1043
- timeout: configuration.timeout,
1044
- repository_id: configuration.repository_id
1045
- )
1041
+ SpecGuard::Client::Transport.from_configuration(configuration)
1046
1042
  end
1047
1043
 
1048
1044
  def blank?(value)
@@ -1201,22 +1197,10 @@ module SpecGuard
1201
1197
 
1202
1198
  # Report, not promise. Composed after the write so it can tell the operator
1203
1199
  # which of the two things actually happened — see {#fall_back} for why that
1204
- # is the whole of this slice, and why the order above it did not move.
1200
+ # is the whole of this slice, and why the order above it did not move. The
1201
+ # wording is shared with the Minitest reporter in {Client::DeliveryWarning}.
1205
1202
  def sink_clause(path, error)
1206
- return replay_clause(path) if error.nil?
1207
-
1208
- "The replay queue #{path} could not be written either " \
1209
- "(#{error.class}: #{error.message}), so this run's telemetry was lost."
1210
- end
1211
-
1212
- # The success arm (SPGD-1139): where the run sits AND the command that gets
1213
- # it back, fix-the-delivery first and replay after — the order the ingest
1214
- # CLI's own doctrine describes. The lost-run arm above deliberately names no
1215
- # command: with no queue written there is nothing to replay. Kept
1216
- # word-for-word identical to the Minitest reporter's clause.
1217
- def replay_clause(path)
1218
- "Falling back to #{path}; the test run is unaffected. " \
1219
- "Once the delivery is fixed, replay this run with: bundle exec specguard-ingest #{path}"
1203
+ SpecGuard::Client::DeliveryWarning.sink_clause(path, error)
1220
1204
  end
1221
1205
 
1222
1206
  def emit_warning(line)
@@ -7,5 +7,5 @@
7
7
  # framework-scoped version constant a lie, so the number moved here and the
8
8
  # rspec module now points at it for back-compat.
9
9
  module SpecGuard
10
- VERSION = "0.3.31"
10
+ VERSION = "0.3.33"
11
11
  end
@@ -58,9 +58,9 @@ if git rev-parse -q --verify "refs/tags/v$NEW_VERSION" >/dev/null || \
58
58
  exit 1
59
59
  fi
60
60
  if command -v curl >/dev/null 2>&1; then
61
- if curl -fsS --max-time 10 "https://rubygems.org/api/v1/versions/specguard-rspec.json" 2>/dev/null \
61
+ if curl -fsS --max-time 10 "https://rubygems.org/api/v1/versions/specguard-ruby.json" 2>/dev/null \
62
62
  | grep -q "\"number\":\"$NEW_VERSION\""; then
63
- echo "Error: specguard-rspec $NEW_VERSION is already published on RubyGems"
63
+ echo "Error: specguard-ruby $NEW_VERSION is already published on RubyGems"
64
64
  exit 1
65
65
  fi
66
66
  fi
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: specguard-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.31
4
+ version: 0.3.33
5
5
  platform: ruby
6
6
  authors:
7
7
  - specguard Agent
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-04 00:00:00.000000000 Z
11
+ date: 2026-11-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: json
@@ -52,6 +52,7 @@ files:
52
52
  - lib/specguard/client/annotation_scanner.rb
53
53
  - lib/specguard/client/cli.rb
54
54
  - lib/specguard/client/configuration.rb
55
+ - lib/specguard/client/delivery_warning.rb
55
56
  - lib/specguard/client/file_selector.rb
56
57
  - lib/specguard/client/finding.rb
57
58
  - lib/specguard/client/ingest_cli.rb