specguard-ruby 0.3.30 → 0.3.31

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: 916a7326516ee737b20d0a0790fd6b6a0f1abe56404083ad67fe61d7d7fb5c46
4
- data.tar.gz: 0b39ff94f46fb053ea659cb5f69f878bf86a3b04ed1e0e630394769bed0d6589
3
+ metadata.gz: 14818808e8ef3b696b1b3834ea590734fa398f5fabbef1c5646d03699e844d99
4
+ data.tar.gz: ccce2e2cf09f6b3401284c0f7f3f343185c86fa2d48bc43ca7bbd9e48575910e
5
5
  SHA512:
6
- metadata.gz: 0d721e5d2d50648b3dfc7cf22ebbd2af16a9edd422feffa80b6d32d1092860c145ef6598919a802e3341a3eaa9fea6341fa26cab71f8abef33e0ff9d74d127b6
7
- data.tar.gz: 77a24a2798088706388f6ef2ba7c6d4bd648d4fd152b1dcf55eb9e391409a0ece0ec3046d6b0b14bfaa24db03e68dac7e89d4eef340d6c34ba6b9512acdfda7a
6
+ metadata.gz: a7612a5d841ecf6d7b1f675bfe362a5141948c1e227a89d7d90e449c9f3b802b650894e7cd3862bb37a897dcbc77ae6e0d627caa1b3cbde3275b54ebd41beb54
7
+ data.tar.gz: d78f0bea385d4a9ca63d1581648350c76662f7540b0c105dda6a0ebb15c7b3be1be98e7d2ca8f29b5536fac597a4ed155cb5b96f8ff8575e79bfde7292ff0034
data/README.md CHANGED
@@ -474,6 +474,15 @@ export SPECGUARD_API_KEY=… # from your repository's settings
474
474
  export SPECGUARD_TIMEOUT=10 # optional; seconds, applied to connect and read
475
475
  ```
476
476
 
477
+ **Using an `sga_` agent key? Also set `SPECGUARD_REPOSITORY_ID`.** An agent key
478
+ covers a set of repositories, so the key cannot say which one a run belongs to;
479
+ the request has to. With `SPECGUARD_REPOSITORY_ID=42` the run is POSTed to
480
+ `<endpoint>/api/v1/repositories/42/ingest` instead of `<endpoint>/api/v1/ingest`
481
+ (`config.repository_id` in Ruby). Under a repository `sgk_` key leave it unset —
482
+ if you do set it, the server ignores it. The id may contain only letters, digits,
483
+ `-` and `_`; anything else is refused before a request is made. A `404` with an
484
+ id configured means no repository with that id is available to the key.
485
+
477
486
  ```ruby
478
487
  # ...or in Ruby, if you would rather not use the environment
479
488
  SpecGuard.configure do |config|
@@ -515,6 +524,7 @@ offered to the endpoint and not accepted — so the run can be replayed later wi
515
524
  ```
516
525
  SpecGuard: could not deliver test telemetry (HTTP 401 — the API key was not
517
526
  accepted). Falling back to log/test_results.jsonl; the test run is unaffected.
527
+ Once the delivery is fixed, replay this run with: bundle exec specguard-ingest log/test_results.jsonl
518
528
  ```
519
529
 
520
530
  **That line carries the endpoint's own words when it has any.** A `400` refusal
@@ -527,7 +537,8 @@ SpecGuard: could not deliver test telemetry (HTTP 400 — the endpoint rejected
527
537
  the payload — spec 3 (spec/orders_spec.rb:9): line_number is required and must
528
538
  be a positive integer; spec 7 (spec/orders_spec.rb:31): outcome must be one of
529
539
  passed, failed, pending). Falling back to log/test_results.jsonl; the test run
530
- is unaffected.
540
+ is unaffected. Once the delivery is fixed, replay this run with: bundle exec
541
+ specguard-ingest log/test_results.jsonl
531
542
  ```
532
543
 
533
544
  It stays **one** line whatever comes back. A systemic problem can have the
@@ -628,8 +639,9 @@ specguard-ingest: lines 1, 2 carried ci_run_id 17442 and each came back with
628
639
  test_run_id 41f2c9b8 — the endpoint folded them onto one run
629
640
  ```
630
641
 
631
- It reads the same `SPECGUARD_ENDPOINT`, `SPECGUARD_API_KEY` and
632
- `SPECGUARD_TIMEOUT` the formatter does, and sends each line through the same
642
+ It reads the same `SPECGUARD_ENDPOINT`, `SPECGUARD_API_KEY`, `SPECGUARD_REPOSITORY_ID`
643
+ (for `sga_` agent keys — a replay queue written under one is re-delivered to the same
644
+ repository-scoped route) and `SPECGUARD_TIMEOUT` the formatter does, and sends each line through the same
633
645
  transport — so a run that was refused for a rotated key appears on the platform
634
646
  once the secret is fixed, with the shard folding described in
635
647
  [If you shard your suite](#if-you-shard-your-suite) applying exactly as it would
@@ -1212,9 +1224,11 @@ export SPECGUARD_ENDPOINT=https://specguard.internal.example.com
1212
1224
  nothing any other formatter wrote, is read or forwarded.
1213
1225
  - **No environment.** SpecGuard's own code reads a fixed list of variables and
1214
1226
  no others: the ones named in the envelope table above, which fill
1215
- `commit_sha`, `branch`, `ci_run_id` and `shard_id`; plus four that configure
1227
+ `commit_sha`, `branch`, `ci_run_id` and `shard_id`; plus five that configure
1216
1228
  the gem itself rather than describing your suite — `SPECGUARD_ENDPOINT` (where
1217
- to send the run), `SPECGUARD_OUTPUT_PATH` (where to write the replay queue —
1229
+ to send the run), `SPECGUARD_REPOSITORY_ID` (which repository an `sga_` agent
1230
+ key's run belongs to — sent as a path segment of the request URL, nothing else),
1231
+ `SPECGUARD_OUTPUT_PATH` (where to write the replay queue —
1218
1232
  runs the endpoint refused or could not be reached for), `SPECGUARD_LOCAL_OUTPUT_PATH`
1219
1233
  (where to write the local development record when there is no key),
1220
1234
  `SPECGUARD_TIMEOUT` (how long to wait), and
@@ -318,6 +318,7 @@ module SpecGuard
318
318
  LOCAL_OUTPUT_PATH_KEYS = %w[SPECGUARD_LOCAL_OUTPUT_PATH].freeze
319
319
  ENDPOINT_KEYS = %w[SPECGUARD_ENDPOINT].freeze
320
320
  API_KEY_KEYS = %w[SPECGUARD_API_KEY].freeze
321
+ REPOSITORY_ID_KEYS = %w[SPECGUARD_REPOSITORY_ID].freeze
321
322
  TIMEOUT_KEYS = %w[SPECGUARD_TIMEOUT].freeze
322
323
 
323
324
  # The commit the suite ran against. `nil` when nothing said.
@@ -344,7 +345,9 @@ module SpecGuard
344
345
  attr_accessor :local_output_path
345
346
  # The SpecGuard installation to POST to, scheme and host only:
346
347
  # `https://specguard.example.com`. The `/api/v1/ingest` path is the
347
- # platform's contract, not a setting. `nil` when nothing said.
348
+ # platform's contract, not a setting — with one exception: when
349
+ # {#repository_id} is set the path is `/api/v1/repositories/<id>/ingest`
350
+ # instead. `nil` when nothing said.
348
351
  attr_accessor :endpoint
349
352
  # The repository's SpecGuard API key. Present means "deliver over HTTP".
350
353
  #
@@ -354,6 +357,16 @@ module SpecGuard
354
357
  # nobody can retroactively fix. Send whatever is configured and let a 401
355
358
  # be the answer.
356
359
  attr_accessor :api_key
360
+ # The repository the run belongs to, sent as a path segment
361
+ # (`/api/v1/repositories/<id>/ingest`). Needed by an `sga_` agent key,
362
+ # which covers a set of repositories and so cannot name the run's
363
+ # repository itself; under an `sgk_` key the platform ignores it. `nil`
364
+ # when unset or blank, which keeps the plain `/api/v1/ingest` path.
365
+ #
366
+ # Deliberately **not** format-checked against the key prefix or a numeric
367
+ # shape (same reasoning as {#api_key}): the platform's 400/403/404 is the
368
+ # answer. {Transport} only refuses ids that are unsafe as a path segment.
369
+ attr_accessor :repository_id
357
370
  # Seconds, applied to opening the connection and to reading the response.
358
371
  attr_accessor :timeout
359
372
 
@@ -374,6 +387,7 @@ module SpecGuard
374
387
  @local_output_path = first_present(env, LOCAL_OUTPUT_PATH_KEYS) || DEFAULT_LOCAL_OUTPUT_PATH
375
388
  @endpoint = first_present(env, ENDPOINT_KEYS)
376
389
  @api_key = first_present(env, API_KEY_KEYS)
390
+ @repository_id = first_present(env, REPOSITORY_ID_KEYS)
377
391
  @timeout = seconds(first_present(env, TIMEOUT_KEYS)) || DEFAULT_TIMEOUT_SECONDS
378
392
  end
379
393
 
@@ -740,7 +740,7 @@ module SpecGuard
740
740
  raise UsageError, "no API key is configured (set SPECGUARD_API_KEY)" if blank?(configuration.api_key)
741
741
 
742
742
  transport = Transport.new(endpoint: configuration.endpoint, api_key: configuration.api_key,
743
- timeout: configuration.timeout)
743
+ timeout: configuration.timeout, repository_id: configuration.repository_id)
744
744
  # Asked once, here, so a malformed `SPECGUARD_ENDPOINT` is one exit 2
745
745
  # rather than N identical `:failed` lines. `Transport#deliver` would
746
746
  # otherwise swallow the same `ArgumentError` once per line and report a
@@ -127,6 +127,21 @@ module SpecGuard
127
127
  # Part of the platform's contract, so it is not configurable — the
128
128
  # `endpoint` setting is the installation's address and nothing more.
129
129
  PATH = "/api/v1/ingest"
130
+
131
+ # The repository-scoped form of the same endpoint (`post "ingest"` nested
132
+ # under `resources :repositories`). It exists because an `sga_` agent key
133
+ # covers a SET of repositories, so the credential cannot name the run's
134
+ # repository and the request has to, as a path segment. `%s` is only ever
135
+ # filled with an id that passed {REPOSITORY_ID_PATTERN}. Under an `sgk_`
136
+ # key the server ignores the segment, so {PATH} stays the default.
137
+ REPOSITORY_PATH = "/api/v1/repositories/%s/ingest"
138
+
139
+ # What a repository id may look like before it becomes a path segment.
140
+ # Deliberately not "digits": the platform's ids are numeric today and the
141
+ # gem must not bake that in. Dots are excluded outright, which is what
142
+ # refuses `..` (percent-escaping would not neutralise a dot segment), and
143
+ # `/`, `?`, `#` can never survive as path structure.
144
+ REPOSITORY_ID_PATTERN = /\A[0-9A-Za-z_-]+\z/
130
145
  CONTENT_TYPE = "application/json"
131
146
  USER_AGENT = "specguard-ruby/#{SpecGuard::VERSION}"
132
147
  CONTENT_ENCODING = "gzip"
@@ -167,7 +182,12 @@ module SpecGuard
167
182
  # same reason turned around: a 202 whose body will not parse is still an
168
183
  # acceptance, and relabelling it would tell the operator something untrue
169
184
  # about a run the platform has already stored. See {#test_run_id}.
170
- Result = Struct.new(:outcome, :code, :error, :reasons, :body, keyword_init: true) do
185
+ #
186
+ # `repository_scoped` is true when the request went to the repository-
187
+ # scoped route. It only changes the 404 sentence: under that route a 404
188
+ # means no repository with that id is available to this key, not a wrong
189
+ # URL. Nil/false (every pre-existing `Result.new`) keeps today's wording.
190
+ Result = Struct.new(:outcome, :code, :error, :reasons, :body, :repository_scoped, keyword_init: true) do
171
191
  # The status codes worth spelling out, because each implies a different
172
192
  # thing for the reader to *do*. A 401 means "rotate or fix the key"; a
173
193
  # 400 means "the payload this gem built was refused", which is a bug
@@ -193,6 +213,10 @@ module SpecGuard
193
213
  # tells the reader how widespread it is.
194
214
  MAX_RENDERED_REASONS = 3
195
215
 
216
+ # The 404 sentence when a repository id was sent. See `repository_scoped`.
217
+ REPOSITORY_NOT_FOUND_ADVICE =
218
+ "no repository with that id is available to this API key — check SPECGUARD_REPOSITORY_ID"
219
+
196
220
  # Ceiling on the spelled-out reasons, before the `and N more` tail,
197
221
  # which is appended afterwards so the count can never be the thing that
198
222
  # gets truncated away.
@@ -224,13 +248,19 @@ module SpecGuard
224
248
  def reason
225
249
  case outcome
226
250
  when :success then nil
227
- when :rejected then [+"HTTP #{code}", ADVICE[code], rendered_reasons].compact.join(" — ")
251
+ when :rejected then [+"HTTP #{code}", advice, rendered_reasons].compact.join(" — ")
228
252
  else "#{error.class}: #{error.message}"
229
253
  end
230
254
  end
231
255
 
232
256
  private
233
257
 
258
+ def advice
259
+ return REPOSITORY_NOT_FOUND_ADVICE if code == 404 && repository_scoped
260
+
261
+ ADVICE[code]
262
+ end
263
+
234
264
  # The refusal, bounded and flattened to fit on the one line. `nil` when
235
265
  # there is nothing readable to add, which is what keeps the bare
236
266
  # `HTTP 400 — the endpoint rejected the payload` intact for every body
@@ -270,9 +300,15 @@ module SpecGuard
270
300
  # @param timeout [Numeric, String, nil] seconds. Anything that is not a
271
301
  # positive finite number falls back to the default rather than raising:
272
302
  # a typo in `SPECGUARD_TIMEOUT` must not be able to fail a suite.
273
- def initialize(endpoint:, api_key:, timeout: Configuration::DEFAULT_TIMEOUT_SECONDS)
303
+ # @param repository_id [String, Integer, nil] when present, POST to the
304
+ # repository-scoped route an `sga_` agent key needs. nil/blank keeps the
305
+ # `sgk_` path byte-identical. An id outside {REPOSITORY_ID_PATTERN} makes
306
+ # {#uri} raise `ArgumentError`.
307
+ def initialize(endpoint:, api_key:, timeout: Configuration::DEFAULT_TIMEOUT_SECONDS, repository_id: nil)
274
308
  @endpoint = endpoint
275
309
  @api_key = api_key
310
+ @repository_id = repository_id.to_s.strip
311
+ @repository_id = nil if @repository_id.empty?
276
312
  @timeout = sanitize_timeout(timeout)
277
313
  end
278
314
 
@@ -301,7 +337,8 @@ module SpecGuard
301
337
  return Result.new(outcome: :success, code: code, body: success_body(response)) if
302
338
  response.is_a?(Net::HTTPSuccess)
303
339
 
304
- Result.new(outcome: :rejected, code: code, reasons: refusal_reasons(response))
340
+ Result.new(outcome: :rejected, code: code, reasons: refusal_reasons(response),
341
+ repository_scoped: !@repository_id.nil?)
305
342
  rescue ScriptError, StandardError => e
306
343
  # Connection refused, DNS failure, TLS failure, open/read timeout, a
307
344
  # malformed endpoint — one family, one shape. `ScriptError` is in the
@@ -427,7 +464,7 @@ module SpecGuard
427
464
  base = @endpoint.to_s.strip
428
465
  raise ArgumentError, "no endpoint is configured (set SPECGUARD_ENDPOINT)" if base.empty?
429
466
 
430
- parsed = URI.parse(base.sub(%r{/+\z}, "") + PATH)
467
+ parsed = URI.parse(base.sub(%r{/+\z}, "") + ingest_path)
431
468
  # `URI::HTTPS < URI::HTTP`, so this admits both and rejects the
432
469
  # scheme-less `specguard.example.com` that `URI.parse` happily returns
433
470
  # as a `URI::Generic` with a nil host — which `Net::HTTP` would then
@@ -438,6 +475,17 @@ module SpecGuard
438
475
  parsed
439
476
  end
440
477
 
478
+ def ingest_path
479
+ return PATH if @repository_id.nil?
480
+
481
+ unless REPOSITORY_ID_PATTERN.match?(@repository_id)
482
+ raise ArgumentError,
483
+ "SPECGUARD_REPOSITORY_ID must contain only letters, digits, '-' and '_', got #{@repository_id.inspect}"
484
+ end
485
+
486
+ format(REPOSITORY_PATH, @repository_id)
487
+ end
488
+
441
489
  def sanitize_timeout(value)
442
490
  parsed = Float(value, exception: false)
443
491
  return Configuration::DEFAULT_TIMEOUT_SECONDS unless parsed&.finite? && parsed.positive?
@@ -247,7 +247,8 @@ module SpecGuard
247
247
  SpecGuard::Client::Transport.new(
248
248
  endpoint: @configuration.endpoint,
249
249
  api_key: @configuration.api_key,
250
- timeout: @configuration.timeout
250
+ timeout: @configuration.timeout,
251
+ repository_id: @configuration.repository_id
251
252
  )
252
253
  end
253
254
 
@@ -405,12 +406,21 @@ module SpecGuard
405
406
  # Report, not promise — see `#fall_back` for why that is the whole of
406
407
  # this slice, and why the order above it did not move.
407
408
  def sink_clause(path, error)
408
- return "Falling back to the replay queue. The test run is unaffected." if error.nil?
409
+ return replay_clause(path) if error.nil?
409
410
 
410
411
  "The replay queue #{path} could not be written either " \
411
412
  "(#{error.class}: #{error.message}), so this run's telemetry was lost."
412
413
  end
413
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}"
422
+ end
423
+
414
424
  # Once per process, like the formatter's `warn_once`: fifty failing
415
425
  # deliveries is one problem, not fifty lines of it.
416
426
  def warn_once(message)
@@ -1040,7 +1040,8 @@ module SpecGuard
1040
1040
  SpecGuard::Client::Transport.new(
1041
1041
  endpoint: configuration.endpoint,
1042
1042
  api_key: configuration.api_key,
1043
- timeout: configuration.timeout
1043
+ timeout: configuration.timeout,
1044
+ repository_id: configuration.repository_id
1044
1045
  )
1045
1046
  end
1046
1047
 
@@ -1202,12 +1203,22 @@ module SpecGuard
1202
1203
  # which of the two things actually happened — see {#fall_back} for why that
1203
1204
  # is the whole of this slice, and why the order above it did not move.
1204
1205
  def sink_clause(path, error)
1205
- return "Falling back to #{path}; the test run is unaffected." if error.nil?
1206
+ return replay_clause(path) if error.nil?
1206
1207
 
1207
1208
  "The replay queue #{path} could not be written either " \
1208
1209
  "(#{error.class}: #{error.message}), so this run's telemetry was lost."
1209
1210
  end
1210
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}"
1220
+ end
1221
+
1211
1222
  def emit_warning(line)
1212
1223
  return if @warned
1213
1224
 
@@ -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.30"
10
+ VERSION = "0.3.31"
11
11
  end
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.30
4
+ version: 0.3.31
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-02 00:00:00.000000000 Z
11
+ date: 2026-10-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: json