deployangel 0.1.34 → 0.1.36

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: 7e0552271dfa638f21feb4c8591729a8c226df32dc75201a12a8e41843534f75
4
- data.tar.gz: d62f917c014872a66458dab5e73de67b87b594b0e654b519388d193cd62d4965
3
+ metadata.gz: eb03031f58bd01feb61e1f478f4108eb3c969527e28cf86d970355a6bede0031
4
+ data.tar.gz: 3dd9679d37e80b23e449ee99ffd29bd3db9700bf8e2b5bfa665bf4413e2e4fb7
5
5
  SHA512:
6
- metadata.gz: 9e116aca53991800ed66a98f288fad4706f51baf45b174678156cfcd51202bac16e7d151ef5c323ad97d14f6ca774bf2092f034ef2764fd2106d530f2a48c0ec
7
- data.tar.gz: 2f31abc979d9de0d0fac5da35d975025240144df25bc82693c25c5ca271ecb206032437cbfbca61571a78a70559f40b5083e423d98d94f1a35290968fa5b01bd
6
+ metadata.gz: bbaff8f03fce84081cdf13786dac7d26912ffeb9eae1c3f032f91d3b00afd23ba95a076c6213846cd44390017e8b7f8cdfd7b63833a07fcef9f782b4e9576dc0
7
+ data.tar.gz: 2919b83e169d1ac91175c733d9366df8e05b4910f8722a99cb3fdae57a07dda205be55dce418f2c0c7e1d8e382dc17a9007e23c96126b0174d24ed24588c13be
data/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.36 (2026-11-01)
4
+
5
+ - `deployangel exercise --param id=42` fills path parameters (`:id`,
6
+ `{id}`, `<int:id>`), so routes like `GET /apps/:app_id/deployments/:id`
7
+ can be exercised. Skipped routes name the parameters they need.
8
+ - `deployangel exercise --header="Name: value"`, or
9
+ `DEPLOYANGEL_EXERCISE_HEADERS` (one per line), sends headers with every
10
+ request, such as a Bearer token or a test account's session cookie. The
11
+ output names them but never shows their values, and they go only to the
12
+ `--url` host.
13
+
14
+ ## 0.1.35 (2026-11-01)
15
+
16
+ - `deployangel exercise` prints each route's status codes (`404 ×1`)
17
+ instead of their class (`1 4xx`). When every page answers 404, 405, or
18
+ 410, as when `--url` is the app's marketing site rather than its own
19
+ host, it says so, records nothing on the release, and exits 5.
20
+
3
21
  ## 0.1.34 (2026-11-01)
4
22
 
5
23
  - `deployangel verify` prints a failed release's next steps: the last
data/README.md CHANGED
@@ -597,14 +597,29 @@ evidence. A release deployed during an app's first 24 hours can't clear,
597
597
  so its plan lists nothing.
598
598
 
599
599
  `deployangel exercise --url=<production URL>` does the read-only part for
600
- you, from wherever it runs: it requests the plan's GET routes that have no
601
- path parameters, spreading any request shortfall across them (at most 200
600
+ you, from wherever it runs: it requests the plan's GET routes whose path
601
+ parameters it can fill, spreading any request shortfall across them (at most 200
602
602
  requests, about 5 a second, as `DeployAngel-Exercise`), then records what it
603
603
  sent on the release, where the page lists it under "Exercised from your
604
604
  side". It skips and names routes that change data or need a path
605
605
  parameter, and stops requesting a page after its first 404, sending its
606
- share to the pages that answered. A "CLI & coding agents" token can run
607
- it. `--dry-run` shows what it would send.
606
+ share to the pages that answered. It prints each route's status codes. If
607
+ every page answers 404, 405, or 410, it records nothing and exits 5, since
608
+ the URL is probably not the app's own host (a marketing site, say). A "CLI
609
+ & coding agents" token can run it. `--dry-run` shows what it would send.
610
+
611
+ `--param id=42` fills a path parameter (`:id`, `{id}`, `<int:id>`) in every
612
+ route that has it, so `--param app_id=3 --param id=212` reaches
613
+ `GET /apps/:app_id/deployments/:id`. A value fills every route with that
614
+ name, so a wrong one just gets a 404 and no more requests.
615
+
616
+ `--header="Name: value"` sends a header with every request, such as
617
+ `Authorization: Bearer …` for an API or a test account's session cookie for
618
+ signed-in pages. Put secrets in `DEPLOYANGEL_EXERCISE_HEADERS` instead (one
619
+ header per line), which keeps them out of shell history. Headers go only to
620
+ the `--url` host, since redirects aren't followed. The output names them
621
+ but never shows their values, and they're never sent to DeployAngel. Use a
622
+ test account with as little access as possible: its cookie is a full sign-in.
608
623
 
609
624
  ### Suggested instructions for your coding agent
610
625
 
@@ -637,8 +652,9 @@ verdict: call the `wait_for_verification` MCP tool with the commit and
637
652
  If its status is "exercisable" or "waiting_for_activity", run
638
653
  `bundle exec deployangel exercise --url=<production URL>`: it sends the
639
654
  plan's read-only requests and records them on the release. Offer to
640
- exercise what it skips: routes that change data only with a test account
641
- or after asking. If the status is "warm_up", nothing run can clear it.
655
+ exercise what it skips: fill a route's IDs with `--param name=value`,
656
+ using an ID you can see in the app, and run routes that change data only
657
+ with a test account or after asking. If the status is "warm_up", nothing run can clear it.
642
658
  ```
643
659
 
644
660
  ## Development
@@ -34,7 +34,8 @@ module DeployAngel
34
34
  plan What to exercise so a release [--commit=SHA | --version=V | --deployment=ID]
35
35
  clears sooner [--format=text|json]
36
36
  exercise Send the plan's read-only --url=PRODUCTION_URL [--commit=SHA | --version=V | --deployment=ID]
37
- requests to production [--max-requests=200] [--dry-run]
37
+ requests to production [--param=NAME=VALUE] [--header="Name: value"]
38
+ [--max-requests=200] [--dry-run]
38
39
  exception Details for a fingerprint deployangel exception FINGERPRINT
39
40
  check Report a smoke test result --name=NAME --status=pass|fail [--covers=a,b]
40
41
  [--commit=SHA | --deployment=ID] [--details-url=URL]
@@ -116,8 +117,9 @@ module DeployAngel
116
117
  If its status is "exercisable" or "waiting_for_activity", run
117
118
  `bundle exec deployangel exercise --url=<production URL>`: it sends the
118
119
  plan's read-only requests and records them on the release. Offer to
119
- exercise what it skips: routes that change data only with a test account
120
- or after asking. If the status is "warm_up", nothing run can clear it.
120
+ exercise what it skips: fill a route's IDs with `--param name=value`,
121
+ using an ID you can see in the app, and run routes that change data only
122
+ with a test account or after asking. If the status is "warm_up", nothing run can clear it.
121
123
  - Confidence `medium` clears a release just as `high` does (high also needs 80%
122
124
  coverage and a full baseline), so don't call a medium clearance weak.
123
125
  Warnings (`suspicious: true`) aren't failures: report them as "watching
@@ -282,8 +284,10 @@ module DeployAngel
282
284
  # Sends the plan's read-only requests from here, then records what it
283
285
  # sent on the release (spec §16, Exercise records).
284
286
  def exercise
285
- options = parse(max_requests: Exerciser::MAX_REQUESTS, dry_run: false) do |o, opts|
287
+ options = parse(max_requests: Exerciser::MAX_REQUESTS, dry_run: false, headers: [], params: []) do |o, opts|
286
288
  o.on("--url=URL") { |v| opts[:url] = v }
289
+ o.on("--header=HEADER") { |v| opts[:headers] << v }
290
+ o.on("--param=NAME=VALUE") { |v| opts[:params] << v }
287
291
  o.on("--commit=SHA") { |v| opts[:commit] = v }
288
292
  o.on("--version=VERSION") { |v| opts[:version] = v }
289
293
  o.on("--deployment=ID") { |v| opts[:deployment_id] = v }
@@ -292,6 +296,15 @@ module DeployAngel
292
296
  end
293
297
  return usage_error("exercise needs --url, the app's production URL, like https://example.com") unless options[:url].to_s.match?(%r{\Ahttps?://[^/\s]+})
294
298
 
299
+ # DEPLOYANGEL_EXERCISE_HEADERS, one per line, keeps a secret out of
300
+ # shell history and the process list.
301
+ begin
302
+ headers = Exerciser.parse_headers(options[:headers] + @env["DEPLOYANGEL_EXERCISE_HEADERS"].to_s.split("\n"))
303
+ params = Exerciser.parse_params(options[:params])
304
+ rescue ArgumentError => e
305
+ return usage_error(e.message)
306
+ end
307
+
295
308
  target = target_from(options)
296
309
  return usage_error("no target: pass --commit, --version, or --deployment, or run inside a git repository") unless target
297
310
 
@@ -308,27 +321,55 @@ module DeployAngel
308
321
  return 0
309
322
  end
310
323
 
311
- exerciser = Exerciser.new(base_url: options[:url], max_requests: options[:max_requests].clamp(1, Exerciser::MAX_REQUESTS),
324
+ exerciser = Exerciser.new(base_url: options[:url], headers: headers, params: params, max_requests: options[:max_requests].clamp(1, Exerciser::MAX_REQUESTS),
312
325
  sleeper: @sleeper, requester: @requester)
326
+ header_names = ("With headers: #{headers.keys.join(", ")} (values not shown)" if headers.any?)
313
327
  if options[:dry_run]
314
328
  targets, skipped = exerciser.plan(plan)
315
329
  @stdout.puts("Would send #{targets.sum(&:count)} requests:", *targets.map { |t| " #{exerciser.url_for(t.path)} ×#{t.count}" })
316
- @stdout.puts("Skipped:", *skipped.map { |s| " #{s["key"]} (#{s["reason"]})" }) if skipped.any?
330
+ @stdout.puts(header_names) if header_names
331
+ print_skipped(skipped)
317
332
  return 0
318
333
  end
319
334
 
320
335
  result = exerciser.run(plan)
321
336
  @stdout.puts("Sent #{result.sent} requests to #{options[:url]}:",
322
- *result.routes.map { |r| " #{r["key"]} ×#{r["requests"]}: #{r["statuses"].map { |kind, n| "#{n} #{kind}" }.join(", ")}" })
323
- @stdout.puts("Skipped:", *result.skipped.map { |s| " #{s["key"]} (#{s["reason"]})" }) if result.skipped.any?
337
+ *result.routes.map { |r| " #{r["key"]} ×#{r["requests"]}: #{status_codes(result.codes[r["key"]])}" })
338
+ @stdout.puts(header_names) if header_names
339
+ print_skipped(result.skipped)
324
340
  @stdout.puts("Stopped early: #{options[:url]} isn't answering.") if result.unreachable
341
+ # Requests that reached no page aren't worth recording: the app
342
+ # likely never saw them.
343
+ if result.routes.any? && result.nothing_served? && !result.unreachable
344
+ @stdout.puts("", "No page answered at #{options[:url]}: every route said it doesn't exist. Is that the app's own host, " \
345
+ "and not a marketing site or a different domain? Pass the URL where the app's pages load.")
346
+ @stdout.puts("#{result.unsent} more requests weren't sent, since no route answered.") if result.unsent.positive?
347
+ @stdout.puts("Nothing was recorded on the release.")
348
+ return USAGE_ERROR
349
+ end
325
350
  if result.routes.any?
326
- client.record_exercise(document.dig("deployment", "id"), routes: result.routes, skipped: result.skipped, ran_at: @clock.call.utc.iso8601)
351
+ client.record_exercise(document.dig("deployment", "id"), routes: result.routes,
352
+ skipped: result.skipped.map { |s| s.except("missing") }, ran_at: @clock.call.utc.iso8601)
327
353
  @stdout.puts("Recorded on the release. Wait for the verdict with: deployangel verify --wait")
328
354
  end
329
355
  result.unreachable ? USAGE_ERROR : 0
330
356
  end
331
357
 
358
+ # Each skipped route and why, naming the parameters a --param would fill.
359
+ def print_skipped(skipped)
360
+ return if skipped.empty?
361
+
362
+ @stdout.puts("Skipped:", *skipped.map { |s| " #{s["key"]} (#{s["reason"]}#{": #{s["missing"].join(", ")}" if s["missing"]&.any?})" })
363
+ return unless skipped.any? { |s| s["missing"]&.any? }
364
+
365
+ @stdout.puts("Fill path parameters with --param, such as --param #{skipped.find { |s| s["missing"]&.any? }["missing"].first}=42.")
366
+ end
367
+
368
+ # "200 ×4, 500 ×1", in the order they came.
369
+ def status_codes(codes)
370
+ codes.map { |code, n| "#{code || "no answer"} ×#{n}" }.join(", ")
371
+ end
372
+
332
373
  def exception
333
374
  fingerprint = @argv.shift or return usage_error("exception needs a FINGERPRINT")
334
375
  format = parse(format: nil) { |o, opts| o.on("--format=FORMAT", %w[text json]) { |v| opts[:format] = v } }[:format]
@@ -7,9 +7,10 @@ module DeployAngel
7
7
  # `deployangel exercise`: sends a release's exercise plan's read-only
8
8
  # requests to production from the customer's side, then records what it
9
9
  # sent, so the release page says which routes were exercised that way.
10
- # Only GET routes with no path parameters and not marked as changing data;
11
- # the rest are skipped and named, for the agent or a smoke test with a test
12
- # account. The requests count like any traffic through the app's agent.
10
+ # Only GET routes not marked as changing data, and only those whose path
11
+ # parameters the caller filled (--param id=42); the rest are skipped and
12
+ # named, for the agent or a smoke test with a test account. The requests
13
+ # count like any traffic through the app's agent.
13
14
  class Exerciser
14
15
  MAX_REQUESTS = 200
15
16
  INTERVAL = 0.2 # about 5 requests a second
@@ -19,14 +20,53 @@ module DeployAngel
19
20
  # A route table can list pages an app doesn't serve, like the edit page
20
21
  # of a resource that has none. One answer like this is enough to know.
21
22
  NOT_SERVED = [ 404, 405, 410 ].freeze
22
- # Rails' :id and *path, Django's <int:pk>, FastAPI's {id}.
23
- PARAMETER = /\/[:*{<]/
23
+ # A path parameter, capturing its name: Rails' :id and *path, FastAPI's
24
+ # and ASP.NET Core's {id}, {id:int}, and {*path}, Django's <int:pk>.
25
+ PARAMETER = /[:*]([A-Za-z_]\w*)|\{\*{0,2}([A-Za-z_]\w*)(?::[^}]*)?\??\}|<(?:\w+:)?([A-Za-z_]\w*)>/
26
+ # One that can span segments, so its value keeps its slashes.
27
+ GLOB = %r{\A(?:\*|\{\*|\{\w+:path\}|<path:)}
28
+ PARAM = /\A([A-Za-z_]\w*)=(.+)\z/
29
+ # "Name: value", as with curl -H: a header name, then a value on one line.
30
+ HEADER = /\A([!#$%&'*+.^_`|~0-9A-Za-z-]+):[ \t]*(\S[^\r\n]*)\z/
24
31
 
25
32
  Target = Struct.new(:key, :path, :count)
26
- Result = Struct.new(:routes, :skipped, :sent, :unreachable, keyword_init: true)
33
+ # codes: each route's exact status codes, nil for no answer, for
34
+ # display; routes carries status classes, which is what's recorded.
35
+ # unsent: requests the plan wanted that went nowhere, since no route
36
+ # answered.
37
+ Result = Struct.new(:routes, :skipped, :sent, :unreachable, :codes, :unsent, keyword_init: true) do
38
+ # Every answer said the page isn't served. Usually the wrong host,
39
+ # like the app's marketing site.
40
+ def nothing_served?
41
+ answers = codes.values.flat_map(&:keys)
42
+ answers.any? && answers.all? { |code| NOT_SERVED.include?(code) }
43
+ end
44
+ end
45
+
46
+ # Headers to send with every request, such as a token or a test
47
+ # account's session cookie, from "Name: value" lines. Raises
48
+ # ArgumentError naming the line, never its value.
49
+ def self.parse_headers(lines)
50
+ lines.map(&:strip).reject(&:empty?).each_with_index.to_h do |line, i|
51
+ match = HEADER.match(line) or raise ArgumentError, "header #{i + 1} must look like \"Name: value\""
52
+ [ match[1], match[2].rstrip ]
53
+ end
54
+ end
55
+
56
+ # Path parameter values, from "name=value" (--param id=42). Each fills
57
+ # that name in every route that has it.
58
+ def self.parse_params(pairs)
59
+ pairs.to_h do |pair|
60
+ match = PARAM.match(pair) or raise ArgumentError, "--param must look like name=value, such as --param id=42 (got #{pair.inspect})"
61
+ [ match[1], match[2] ]
62
+ end
63
+ end
27
64
 
28
- def initialize(base_url:, max_requests: MAX_REQUESTS, sleeper: ->(seconds) { sleep(seconds) }, requester: nil)
65
+ # headers go only to base_url's host: redirects aren't followed.
66
+ def initialize(base_url:, headers: {}, params: {}, max_requests: MAX_REQUESTS, sleeper: ->(seconds) { sleep(seconds) }, requester: nil)
29
67
  @base = URI(base_url)
68
+ @headers = headers
69
+ @params = params
30
70
  @max_requests = max_requests
31
71
  @sleeper = sleeper
32
72
  @requester = requester || method(:get)
@@ -40,11 +80,12 @@ module DeployAngel
40
80
  next unless item["kind"] == "route"
41
81
 
42
82
  method, path = item["key"].to_s.split(" ", 2)
43
- reason =
44
- if item["mutating"] || method != "GET" then "changes data"
45
- elsif path.nil? || path.match?(PARAMETER) then "needs a path parameter"
46
- end
47
- next skipped << { "key" => item["key"], "reason" => reason } if reason
83
+ next skipped << { "key" => item["key"], "reason" => "changes data" } if item["mutating"] || method != "GET"
84
+
85
+ path, missing = path ? fill(path) : [ nil, [] ]
86
+ if path.nil? || missing.any?
87
+ next skipped << { "key" => item["key"], "reason" => "needs a path parameter", "missing" => missing }
88
+ end
48
89
 
49
90
  needed = item["runs_needed"] ? item["runs_needed"].to_i - item["runs"].to_i : 1
50
91
  targets << Target.new(item["key"], path, [ needed, 1 ].max)
@@ -59,6 +100,7 @@ module DeployAngel
59
100
  def run(exercise_plan)
60
101
  targets, skipped = plan(exercise_plan)
61
102
  @statuses = Hash.new { |hash, key| hash[key] = Hash.new(0) }
103
+ @codes = Hash.new { |hash, key| hash[key] = Hash.new(0) }
62
104
  @sent = 0
63
105
  @errors_in_a_row = 0
64
106
  leftover = 0
@@ -76,12 +118,32 @@ module DeployAngel
76
118
  answered << target if @statuses[target.key].keys.intersect?(%w[2xx 3xx])
77
119
  end
78
120
  leftover.times { |i| send_request(answered[i % answered.size]) unless unreachable? } if answered.any?
121
+ unsent = answered.empty? ? leftover : 0
79
122
 
80
123
  routes = targets.filter_map do |target|
81
124
  statuses = @statuses.fetch(target.key, nil) or next
82
125
  { "key" => target.key, "requests" => statuses.values.sum, "statuses" => statuses.to_h }
83
126
  end
84
- Result.new(routes: routes, skipped: skipped, sent: @sent, unreachable: unreachable?)
127
+ Result.new(routes: routes, skipped: skipped, sent: @sent, unreachable: unreachable?,
128
+ codes: routes.to_h { |route| [ route["key"], @codes[route["key"]].to_h ] }, unsent: unsent)
129
+ end
130
+
131
+ # The path with the given parameters filled in, and the names of any
132
+ # left unfilled.
133
+ def fill(path)
134
+ missing = []
135
+ filled = path.gsub(PARAMETER) do |segment|
136
+ name = $1 || $2 || $3
137
+ value = @params[name]
138
+ unless value
139
+ missing << name
140
+ next segment
141
+ end
142
+
143
+ glob = segment.match?(GLOB)
144
+ (glob ? value.split("/", -1) : [ value ]).map { |part| URI.encode_www_form_component(part).gsub("+", "%20") }.join("/")
145
+ end
146
+ [ filled, missing.uniq ]
85
147
  end
86
148
 
87
149
  def url_for(path)
@@ -94,10 +156,11 @@ module DeployAngel
94
156
  private
95
157
  def send_request(target)
96
158
  @sleeper.call(INTERVAL) if @sent.positive?
97
- status = @requester.call(url_for(target.path))
159
+ status = @requester.call(url_for(target.path), @headers)
98
160
  @sent += 1
99
161
  kind = status ? "#{status / 100}xx" : "error"
100
162
  @statuses[target.key][kind] += 1
163
+ @codes[target.key][status] += 1
101
164
  @errors_in_a_row = kind == "error" ? @errors_in_a_row + 1 : 0
102
165
  status
103
166
  end
@@ -124,11 +187,12 @@ module DeployAngel
124
187
 
125
188
  # The status code, or nil when there was no answer. Redirects aren't
126
189
  # followed: the request already reached the app.
127
- def get(url)
190
+ def get(url, headers)
128
191
  uri = URI(url)
129
192
  request = Net::HTTP::Get.new(uri)
130
193
  request["User-Agent"] = "DeployAngel-Exercise/#{DeployAngel::VERSION} (+https://www.deployangel.com/docs#exercise)"
131
194
  request["Accept"] = "text/html,application/json;q=0.9,*/*;q=0.8"
195
+ headers.each { |name, value| request[name] = value }
132
196
  Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: TIMEOUT, read_timeout: TIMEOUT) do |http|
133
197
  http.request(request).code.to_i
134
198
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DeployAngel
4
- VERSION = "0.1.34"
4
+ VERSION = "0.1.36"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: deployangel
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.34
4
+ version: 0.1.36
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jordan Owens