deployangel 0.1.35 → 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: 275f765f13eee93ddc36007727d1f2278b343a524562c062eeab1a258f8abb16
4
- data.tar.gz: f167ad720f710b5cf3a527632795972a5dbbe972309bbbf470a937425d0282e7
3
+ metadata.gz: eb03031f58bd01feb61e1f478f4108eb3c969527e28cf86d970355a6bede0031
4
+ data.tar.gz: 3dd9679d37e80b23e449ee99ffd29bd3db9700bf8e2b5bfa665bf4413e2e4fb7
5
5
  SHA512:
6
- metadata.gz: cdd30481a4d28645ef05f5d570f253d73029258581743045677e5a7e1c19a395b28082656aa44f588eac2c8be074b01a93240102143a822d166fa250325efd93
7
- data.tar.gz: 5190303791d16fb83affbed23cd01e5b20c1b28a245f94824ee9f01670ec454a9b6c9696d632cabc3ce7c0990da8d0809adb14b7c3eddeda7aa8a5ec077f7f54
6
+ metadata.gz: bbaff8f03fce84081cdf13786dac7d26912ffeb9eae1c3f032f91d3b00afd23ba95a076c6213846cd44390017e8b7f8cdfd7b63833a07fcef9f782b4e9576dc0
7
+ data.tar.gz: 2919b83e169d1ac91175c733d9366df8e05b4910f8722a99cb3fdae57a07dda205be55dce418f2c0c7e1d8e382dc17a9007e23c96126b0174d24ed24588c13be
data/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
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
+
3
14
  ## 0.1.35 (2026-11-01)
4
15
 
5
16
  - `deployangel exercise` prints each route's status codes (`404 ×1`)
data/README.md CHANGED
@@ -597,8 +597,8 @@ 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
@@ -608,6 +608,19 @@ every page answers 404, 405, or 410, it records nothing and exits 5, since
608
608
  the URL is probably not the app's own host (a marketing site, say). A "CLI
609
609
  & coding agents" token can run it. `--dry-run` shows what it would send.
610
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.
623
+
611
624
  ### Suggested instructions for your coding agent
612
625
 
613
626
  `deployangel install agents` adds instructions like these. To add them
@@ -639,8 +652,9 @@ verdict: call the `wait_for_verification` MCP tool with the commit and
639
652
  If its status is "exercisable" or "waiting_for_activity", run
640
653
  `bundle exec deployangel exercise --url=<production URL>`: it sends the
641
654
  plan's read-only requests and records them on the release. Offer to
642
- exercise what it skips: routes that change data only with a test account
643
- 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.
644
658
  ```
645
659
 
646
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,19 +321,22 @@ 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
337
  *result.routes.map { |r| " #{r["key"]} ×#{r["requests"]}: #{status_codes(result.codes[r["key"]])}" })
323
- @stdout.puts("Skipped:", *result.skipped.map { |s| " #{s["key"]} (#{s["reason"]})" }) if result.skipped.any?
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
325
341
  # Requests that reached no page aren't worth recording: the app
326
342
  # likely never saw them.
@@ -332,12 +348,23 @@ module DeployAngel
332
348
  return USAGE_ERROR
333
349
  end
334
350
  if result.routes.any?
335
- 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)
336
353
  @stdout.puts("Recorded on the release. Wait for the verdict with: deployangel verify --wait")
337
354
  end
338
355
  result.unreachable ? USAGE_ERROR : 0
339
356
  end
340
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
+
341
368
  # "200 ×4, 500 ×1", in the order they came.
342
369
  def status_codes(codes)
343
370
  codes.map { |code, n| "#{code || "no answer"} ×#{n}" }.join(", ")
@@ -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,8 +20,14 @@ 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
33
  # codes: each route's exact status codes, nil for no answer, for
@@ -36,8 +43,30 @@ module DeployAngel
36
43
  end
37
44
  end
38
45
 
39
- def initialize(base_url:, max_requests: MAX_REQUESTS, sleeper: ->(seconds) { sleep(seconds) }, requester: nil)
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
64
+
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)
40
67
  @base = URI(base_url)
68
+ @headers = headers
69
+ @params = params
41
70
  @max_requests = max_requests
42
71
  @sleeper = sleeper
43
72
  @requester = requester || method(:get)
@@ -51,11 +80,12 @@ module DeployAngel
51
80
  next unless item["kind"] == "route"
52
81
 
53
82
  method, path = item["key"].to_s.split(" ", 2)
54
- reason =
55
- if item["mutating"] || method != "GET" then "changes data"
56
- elsif path.nil? || path.match?(PARAMETER) then "needs a path parameter"
57
- end
58
- 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
59
89
 
60
90
  needed = item["runs_needed"] ? item["runs_needed"].to_i - item["runs"].to_i : 1
61
91
  targets << Target.new(item["key"], path, [ needed, 1 ].max)
@@ -98,6 +128,24 @@ module DeployAngel
98
128
  codes: routes.to_h { |route| [ route["key"], @codes[route["key"]].to_h ] }, unsent: unsent)
99
129
  end
100
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 ]
147
+ end
148
+
101
149
  def url_for(path)
102
150
  uri = @base.dup
103
151
  uri.path = "#{@base.path.chomp("/")}#{path}"
@@ -108,7 +156,7 @@ module DeployAngel
108
156
  private
109
157
  def send_request(target)
110
158
  @sleeper.call(INTERVAL) if @sent.positive?
111
- status = @requester.call(url_for(target.path))
159
+ status = @requester.call(url_for(target.path), @headers)
112
160
  @sent += 1
113
161
  kind = status ? "#{status / 100}xx" : "error"
114
162
  @statuses[target.key][kind] += 1
@@ -139,11 +187,12 @@ module DeployAngel
139
187
 
140
188
  # The status code, or nil when there was no answer. Redirects aren't
141
189
  # followed: the request already reached the app.
142
- def get(url)
190
+ def get(url, headers)
143
191
  uri = URI(url)
144
192
  request = Net::HTTP::Get.new(uri)
145
193
  request["User-Agent"] = "DeployAngel-Exercise/#{DeployAngel::VERSION} (+https://www.deployangel.com/docs#exercise)"
146
194
  request["Accept"] = "text/html,application/json;q=0.9,*/*;q=0.8"
195
+ headers.each { |name, value| request[name] = value }
147
196
  Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: TIMEOUT, read_timeout: TIMEOUT) do |http|
148
197
  http.request(request).code.to_i
149
198
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DeployAngel
4
- VERSION = "0.1.35"
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.35
4
+ version: 0.1.36
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jordan Owens