deployangel 0.1.19 → 0.1.21

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: bf16d5f1c1214f77e3b72e911693d7e52efd852da3dcaa21308957d8789bbb63
4
- data.tar.gz: 6957cf6b461965450428d49b7d12631bcd2431e7a71bdfd9d995de9b63bd8f0a
3
+ metadata.gz: 2a115a5a35d86c5edce65dad1cdf3947833416ea66f47c69da18b85de3047153
4
+ data.tar.gz: ec954e62605c6452d3e374896085343cb6a12910e0b821794342a2e0a6dc0426
5
5
  SHA512:
6
- metadata.gz: f8eb73de68335aec60421c1e550c4983e1a1ffd0e93e27726ce75028d13245a4cc41ec3ea9193d4dbadb48a30eb6953e1bd41cac7837634cefea05bb9b2820f8
7
- data.tar.gz: 2c910d0882a1c2bed0b0e4bf24dc8b238121702d1d77ae760d74e207050757795b60d054a7f5fa3a6a0b113ee10d736da344ed49db29dfb3f9f6cf76f1a9f766
6
+ metadata.gz: 4c18ca61ecff8936bdfa7f4a5900fd27b96c2a787094e504593fae78f62eaee3c7d4ea11e74811b619313c1a726bce8e4644f5a6319c687450ff81629acdd2a5
7
+ data.tar.gz: 2754b3cc315c626df0dc57de4723721caa668f69b7b523a0ca4eaecaf4f84e2f2ec7f9f40f366be34f9a1989ff3c9b4650f1d02dc81b976218d2b3daf5e3968c
data/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.21 (2026-10-09)
4
+
5
+ - `deployangel exercise` stops requesting a page after its first 404, 405,
6
+ or 410, since a route table can list pages an app doesn't serve, and sends
7
+ that page's share to the pages that answered.
8
+
9
+ ## 0.1.20 (2026-10-09)
10
+
11
+ - `deployangel exercise --url=<production URL>` sends a release's exercise
12
+ plan's read-only requests (GET routes without path parameters, at most
13
+ 200, about 5 a second) so a quiet release can clear sooner, then records
14
+ what it sent on the release, which lists it as exercised from your side.
15
+ It skips and names routes that change data or need a path parameter.
16
+ `--dry-run` shows what it would send.
17
+ - The agent instructions `deployangel install agents` writes now tell the
18
+ agent to run `deployangel exercise` when a release isn't cleared yet.
19
+
3
20
  ## 0.1.19 (2026-10-09)
4
21
 
5
22
  - `deployangel install agents` sets up Claude Code, Cursor, and Codex in a
data/README.md CHANGED
@@ -392,6 +392,7 @@ bundle exec deployangel verify --wait # current git HEAD, until
392
392
  bundle exec deployangel verify --wait --until=initial # return at the 15-minute initial check
393
393
  bundle exec deployangel status # latest deployment
394
394
  bundle exec deployangel plan # what to exercise so a release clears sooner
395
+ bundle exec deployangel exercise --url=https://example.com # send the plan's read-only requests
395
396
  bundle exec deployangel release --commit=$SHA # register a deploy (manual or CI)
396
397
  bundle exec deployangel check --name="smoke: signup" --status=pass --covers=registration
397
398
  bundle exec deployangel exception <fingerprint>
@@ -464,16 +465,28 @@ check labels what it covered; only the requests themselves count as
464
465
  evidence. A release deployed during an app's first 24 hours can't clear,
465
466
  so its plan lists nothing.
466
467
 
468
+ `deployangel exercise --url=<production URL>` does the read-only part for
469
+ you, from wherever it runs: it requests the plan's GET routes that have no
470
+ path parameters, spreading any request shortfall across them (at most 200
471
+ requests, about 5 a second, as `DeployAngel-Exercise`), then records what it
472
+ sent on the release, where the page lists it under "Exercised from your
473
+ side". It skips and names routes that change data or need a path
474
+ parameter, and stops requesting a page after its first 404, sending its
475
+ share to the pages that answered. A "CLI & coding agents" token can run
476
+ it. `--dry-run` shows what it would send.
477
+
467
478
  ### Suggested instructions for your coding agent
468
479
 
469
480
  `deployangel install agents` adds instructions like these. To add them
470
481
  yourself, put this in your `CLAUDE.md` or `AGENTS.md`:
471
482
 
472
483
  ```markdown
473
- ## Production verification
484
+ ## Production verification (DeployAngel)
474
485
 
475
- After deploying, run `bundle exec deployangel verify --wait --until initial`
476
- (or call the `wait_for_verification` MCP tool with `until: "initial"`).
486
+ After deploying, or pushing a commit that deploys, wait for DeployAngel's
487
+ verdict: call the `wait_for_verification` MCP tool with the commit and
488
+ `until: "initial"`, calling again while it's in progress, or run
489
+ `bundle exec deployangel verify --commit=<sha> --wait --until=initial`.
477
490
 
478
491
  - Exit 0 / verified: the release is cleared. Report the clearance line and
479
492
  anything DeployAngel is still watching, then move on.
@@ -483,17 +496,16 @@ After deploying, run `bundle exec deployangel verify --wait --until initial`
483
496
  - Exit 7: warnings at the initial check. Report them and review the findings.
484
497
  The release is NOT cleared.
485
498
  - Exit 2 / inconclusive: the release is NOT verified. Do not claim success.
486
- - Exit 1 / failed: read the findings and exceptions, investigate the likely
487
- cause, and propose a fix. Do not roll back or change production without
488
- explicit approval.
499
+ - Exit 1 / failed: read the findings and exceptions (`get_exception`),
500
+ investigate the likely cause, and propose a fix. Do not roll back or change
501
+ production without explicit approval.
489
502
  - Exit 3: still verifying; run the command again.
490
- - Not cleared yet and you can reach production: run
491
- `bundle exec deployangel plan --format=json` (or `get_exercise_plan`). If
492
- its status is "exercisable" or "waiting_for_activity", exercise the listed
493
- items against production: read-only routes freely, routes marked mutating
494
- only with a test account or after asking. Then run its `report_with`
495
- command and wait again. If the status is "warm_up", don't exercise
496
- anything: the release can't clear until DeployAngel has a day of history.
503
+ - Not cleared yet: call `get_exercise_plan` (or `bundle exec deployangel plan`).
504
+ If its status is "exercisable" or "waiting_for_activity", run
505
+ `bundle exec deployangel exercise --url=<production URL>`: it sends the
506
+ plan's read-only requests and records them on the release. Offer to
507
+ exercise what it skips: routes that change data only with a test account
508
+ or after asking. If the status is "warm_up", nothing run can clear it.
497
509
  ```
498
510
 
499
511
  ## Development
@@ -11,13 +11,14 @@ require_relative "client"
11
11
  require_relative "ci_environment"
12
12
  require_relative "verification_waiter"
13
13
  require_relative "cli/formatter"
14
+ require_relative "exerciser"
14
15
 
15
16
  module DeployAngel
16
17
  # `deployangel` command for developers, CI, and coding agents.
17
18
  # Runs without booting Rails.
18
19
  class CLI
19
20
  USAGE_ERROR = 5
20
- COMMANDS = %w[release verify status exception check install mcp version help].freeze
21
+ COMMANDS = %w[release verify status plan exercise exception check install mcp version help].freeze
21
22
 
22
23
  HELP = <<~TEXT
23
24
  Usage: deployangel <command> [options]
@@ -30,6 +31,8 @@ module DeployAngel
30
31
  status Latest deployment and its verification
31
32
  plan What to exercise so a release [--commit=SHA | --version=V | --deployment=ID]
32
33
  clears sooner [--format=text|json]
34
+ exercise Send the plan's read-only --url=PRODUCTION_URL [--commit=SHA | --version=V | --deployment=ID]
35
+ requests to production [--max-requests=200] [--dry-run]
33
36
  exception Details for a fingerprint deployangel exception FINGERPRINT
34
37
  check Report a smoke test result --name=NAME --status=pass|fail [--covers=a,b]
35
38
  [--commit=SHA | --deployment=ID] [--details-url=URL]
@@ -98,10 +101,11 @@ module DeployAngel
98
101
  production without explicit approval.
99
102
  - Exit 3: still verifying; run the command again.
100
103
  - Not cleared yet: call `get_exercise_plan` (or `bundle exec deployangel plan`).
101
- If its status is "exercisable" or "waiting_for_activity", say what it lists
102
- and offer to exercise it: read-only routes freely, routes marked mutating
103
- only with a test account or after asking. If the status is "warm_up",
104
- nothing run can clear it.
104
+ If its status is "exercisable" or "waiting_for_activity", run
105
+ `bundle exec deployangel exercise --url=<production URL>`: it sends the
106
+ plan's read-only requests and records them on the release. Offer to
107
+ exercise what it skips: routes that change data only with a test account
108
+ or after asking. If the status is "warm_up", nothing run can clear it.
105
109
  #{AGENTS_END}
106
110
  MARKDOWN
107
111
 
@@ -119,7 +123,7 @@ module DeployAngel
119
123
  DOCKERFILE
120
124
 
121
125
  def initialize(argv, env: ENV, stdin: $stdin, stdout: $stdout, stderr: $stderr, client: nil,
122
- sleeper: ->(seconds) { sleep(seconds) }, clock: -> { Time.now }, git_head: nil, root: Dir.pwd)
126
+ sleeper: ->(seconds) { sleep(seconds) }, clock: -> { Time.now }, git_head: nil, root: Dir.pwd, requester: nil)
123
127
  @root = root
124
128
  @argv = argv.dup
125
129
  @env = env
@@ -130,6 +134,7 @@ module DeployAngel
130
134
  @sleeper = sleeper
131
135
  @clock = clock
132
136
  @git_head = git_head
137
+ @requester = requester
133
138
  end
134
139
 
135
140
  def run
@@ -139,6 +144,7 @@ module DeployAngel
139
144
  when "verify" then verify
140
145
  when "status" then verify(status: true)
141
146
  when "plan" then plan
147
+ when "exercise" then exercise
142
148
  when "exception" then exception
143
149
  when "check" then check
144
150
  when "install" then install
@@ -238,6 +244,56 @@ module DeployAngel
238
244
  0
239
245
  end
240
246
 
247
+ # Sends the plan's read-only requests from here, then records what it
248
+ # sent on the release (spec §16, Exercise records).
249
+ def exercise
250
+ options = parse(max_requests: Exerciser::MAX_REQUESTS, dry_run: false) do |o, opts|
251
+ o.on("--url=URL") { |v| opts[:url] = v }
252
+ o.on("--commit=SHA") { |v| opts[:commit] = v }
253
+ o.on("--version=VERSION") { |v| opts[:version] = v }
254
+ o.on("--deployment=ID") { |v| opts[:deployment_id] = v }
255
+ o.on("--max-requests=N", Integer) { |v| opts[:max_requests] = v }
256
+ o.on("--dry-run") { opts[:dry_run] = true }
257
+ end
258
+ return usage_error("exercise needs --url, the app's production URL, like https://example.com") unless options[:url].to_s.match?(%r{\Ahttps?://[^/\s]+})
259
+
260
+ target = target_from(options)
261
+ return usage_error("no target: pass --commit, --version, or --deployment, or run inside a git repository") unless target
262
+
263
+ outcome = VerificationWaiter.new(client: client, sleeper: @sleeper, clock: @clock).wait(target, wait: false)
264
+ if outcome.not_found
265
+ @stderr.puts("deployangel: no deployment found for #{target.values.first}")
266
+ return outcome.exit_code
267
+ end
268
+
269
+ document = outcome.document
270
+ plan = document["exercise_plan"] || {}
271
+ unless %w[exercisable waiting_for_activity].include?(plan["status"])
272
+ @stdout.puts("Nothing to exercise. #{plan["summary"]}".rstrip)
273
+ return 0
274
+ end
275
+
276
+ exerciser = Exerciser.new(base_url: options[:url], max_requests: options[:max_requests].clamp(1, Exerciser::MAX_REQUESTS),
277
+ sleeper: @sleeper, requester: @requester)
278
+ if options[:dry_run]
279
+ targets, skipped = exerciser.plan(plan)
280
+ @stdout.puts("Would send #{targets.sum(&:count)} requests:", *targets.map { |t| " #{exerciser.url_for(t.path)} ×#{t.count}" })
281
+ @stdout.puts("Skipped:", *skipped.map { |s| " #{s["key"]} (#{s["reason"]})" }) if skipped.any?
282
+ return 0
283
+ end
284
+
285
+ result = exerciser.run(plan)
286
+ @stdout.puts("Sent #{result.sent} requests to #{options[:url]}:",
287
+ *result.routes.map { |r| " #{r["key"]} ×#{r["requests"]}: #{r["statuses"].map { |kind, n| "#{n} #{kind}" }.join(", ")}" })
288
+ @stdout.puts("Skipped:", *result.skipped.map { |s| " #{s["key"]} (#{s["reason"]})" }) if result.skipped.any?
289
+ @stdout.puts("Stopped early: #{options[:url]} isn't answering.") if result.unreachable
290
+ if result.routes.any?
291
+ client.record_exercise(document.dig("deployment", "id"), routes: result.routes, skipped: result.skipped, ran_at: @clock.call.utc.iso8601)
292
+ @stdout.puts("Recorded on the release. Wait for the verdict with: deployangel verify --wait")
293
+ end
294
+ result.unreachable ? USAGE_ERROR : 0
295
+ end
296
+
241
297
  def exception
242
298
  fingerprint = @argv.shift or return usage_error("exception needs a FINGERPRINT")
243
299
  format = parse(format: nil) { |o, opts| o.on("--format=FORMAT", %w[text json]) { |v| opts[:format] = v } }[:format]
@@ -49,6 +49,12 @@ module DeployAngel
49
49
  { name: name, status: status, covers: covers, details_url: details_url }.compact)
50
50
  end
51
51
 
52
+ # What `deployangel exercise` sent (spec §16, Exercise records). A
53
+ # "CLI & coding agents" token may record one: it only labels the release.
54
+ def record_exercise(deployment_id, routes:, skipped:, ran_at:)
55
+ post("/api/v1/deployments/#{deployment_id}/exercises", { routes: routes, skipped: skipped, ran_at: ran_at })
56
+ end
57
+
52
58
  private
53
59
  def get(path, params = {})
54
60
  uri = URI("#{@endpoint}#{path}")
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+
6
+ module DeployAngel
7
+ # `deployangel exercise`: sends a release's exercise plan's read-only
8
+ # requests to production from the customer's side, then records what it
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.
13
+ class Exerciser
14
+ MAX_REQUESTS = 200
15
+ INTERVAL = 0.2 # about 5 requests a second
16
+ TIMEOUT = 10
17
+ # Stop when the app isn't answering, rather than send the rest into it.
18
+ MAX_CONSECUTIVE_ERRORS = 5
19
+ # A route table can list pages an app doesn't serve, like the edit page
20
+ # of a resource that has none. One answer like this is enough to know.
21
+ NOT_SERVED = [ 404, 405, 410 ].freeze
22
+ # Rails' :id and *path, Django's <int:pk>, FastAPI's {id}.
23
+ PARAMETER = /\/[:*{<]/
24
+
25
+ Target = Struct.new(:key, :path, :count)
26
+ Result = Struct.new(:routes, :skipped, :sent, :unreachable, keyword_init: true)
27
+
28
+ def initialize(base_url:, max_requests: MAX_REQUESTS, sleeper: ->(seconds) { sleep(seconds) }, requester: nil)
29
+ @base = URI(base_url)
30
+ @max_requests = max_requests
31
+ @sleeper = sleeper
32
+ @requester = requester || method(:get)
33
+ end
34
+
35
+ # What would be sent: [targets, skipped].
36
+ def plan(exercise_plan)
37
+ skipped = []
38
+ targets = []
39
+ Array(exercise_plan["items"]).each do |item|
40
+ next unless item["kind"] == "route"
41
+
42
+ 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
48
+
49
+ needed = item["runs_needed"] ? item["runs_needed"].to_i - item["runs"].to_i : 1
50
+ targets << Target.new(item["key"], path, [ needed, 1 ].max)
51
+ end
52
+ spread_shortfall(targets, exercise_plan.dig("shortfall", "requests"))
53
+ cap(targets)
54
+ [ targets, skipped ]
55
+ end
56
+
57
+ # A route whose first request says it isn't served gets no more; its
58
+ # share goes to the routes that answered.
59
+ def run(exercise_plan)
60
+ targets, skipped = plan(exercise_plan)
61
+ @statuses = Hash.new { |hash, key| hash[key] = Hash.new(0) }
62
+ @sent = 0
63
+ @errors_in_a_row = 0
64
+ leftover = 0
65
+ answered = []
66
+ targets.each do |target|
67
+ target.count.times do |i|
68
+ break if unreachable?
69
+
70
+ status = send_request(target)
71
+ if i.zero? && NOT_SERVED.include?(status)
72
+ leftover += target.count - 1
73
+ break
74
+ end
75
+ end
76
+ answered << target if @statuses[target.key].keys.intersect?(%w[2xx 3xx])
77
+ end
78
+ leftover.times { |i| send_request(answered[i % answered.size]) unless unreachable? } if answered.any?
79
+
80
+ routes = targets.filter_map do |target|
81
+ statuses = @statuses.fetch(target.key, nil) or next
82
+ { "key" => target.key, "requests" => statuses.values.sum, "statuses" => statuses.to_h }
83
+ end
84
+ Result.new(routes: routes, skipped: skipped, sent: @sent, unreachable: unreachable?)
85
+ end
86
+
87
+ def url_for(path)
88
+ uri = @base.dup
89
+ uri.path = "#{@base.path.chomp("/")}#{path}"
90
+ uri.query = nil
91
+ uri.to_s
92
+ end
93
+
94
+ private
95
+ def send_request(target)
96
+ @sleeper.call(INTERVAL) if @sent.positive?
97
+ status = @requester.call(url_for(target.path))
98
+ @sent += 1
99
+ kind = status ? "#{status / 100}xx" : "error"
100
+ @statuses[target.key][kind] += 1
101
+ @errors_in_a_row = kind == "error" ? @errors_in_a_row + 1 : 0
102
+ status
103
+ end
104
+
105
+ def unreachable?
106
+ @errors_in_a_row >= MAX_CONSECUTIVE_ERRORS
107
+ end
108
+
109
+ # Requests the release is short of overall, spread across the routes,
110
+ # or sent to the home page when the plan names no route to send them to.
111
+ def spread_shortfall(targets, requests)
112
+ return unless requests
113
+
114
+ extra = requests["need"].to_i - requests["have"].to_i - targets.sum(&:count)
115
+ return unless extra.positive?
116
+
117
+ targets << Target.new("GET /", "/", 0) if targets.empty?
118
+ extra.times { |i| targets[i % targets.size].count += 1 }
119
+ end
120
+
121
+ def cap(targets)
122
+ targets.max_by(&:count).count -= 1 while targets.sum(&:count) > @max_requests
123
+ end
124
+
125
+ # The status code, or nil when there was no answer. Redirects aren't
126
+ # followed: the request already reached the app.
127
+ def get(url)
128
+ uri = URI(url)
129
+ request = Net::HTTP::Get.new(uri)
130
+ request["User-Agent"] = "DeployAngel-Exercise/#{DeployAngel::VERSION} (+https://www.deployangel.com/docs#exercise)"
131
+ request["Accept"] = "text/html,application/json;q=0.9,*/*;q=0.8"
132
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: TIMEOUT, read_timeout: TIMEOUT) do |http|
133
+ http.request(request).code.to_i
134
+ end
135
+ rescue StandardError
136
+ nil
137
+ end
138
+ end
139
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DeployAngel
4
- VERSION = "0.1.19"
4
+ VERSION = "0.1.21"
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.19
4
+ version: 0.1.21
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jordan Owens
@@ -46,6 +46,7 @@ files:
46
46
  - lib/deployangel/core/redaction.rb
47
47
  - lib/deployangel/core/release.rb
48
48
  - lib/deployangel/core/transport.rb
49
+ - lib/deployangel/exerciser.rb
49
50
  - lib/deployangel/fork_hook.rb
50
51
  - lib/deployangel/mcp.rb
51
52
  - lib/deployangel/metadata.rb