deployangel 0.1.19 → 0.1.20

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: 59eed3c6629b0dc5b81cbec2636583d50f0038c2f51af8011108205a1a23eb8c
4
+ data.tar.gz: d10e3a967bed17b69b854a5b74deba72b66ad4e204b3c007bd1958fc81e1ca66
5
5
  SHA512:
6
- metadata.gz: f8eb73de68335aec60421c1e550c4983e1a1ffd0e93e27726ce75028d13245a4cc41ec3ea9193d4dbadb48a30eb6953e1bd41cac7837634cefea05bb9b2820f8
7
- data.tar.gz: 2c910d0882a1c2bed0b0e4bf24dc8b238121702d1d77ae760d74e207050757795b60d054a7f5fa3a6a0b113ee10d736da344ed49db29dfb3f9f6cf76f1a9f766
6
+ metadata.gz: cecef6e5f9328a9b72877c16bbd51388d67a5673b98a75a56c78e6916f5bf99fd033baaf9056f101b25cd36553d0e6033a253800f571ecfab3724655f1301901
7
+ data.tar.gz: cb8460812b9f04eaf761e23282a644c62f4de9aaca20fcf42e1bc81478f898e096675fd1bdfc67e70aef60ac3053bce969de7310539b9261b036c262dae70bd1
data/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.20 (2026-10-09)
4
+
5
+ - `deployangel exercise --url=<production URL>` sends a release's exercise
6
+ plan's read-only requests (GET routes without path parameters, at most
7
+ 200, about 5 a second) so a quiet release can clear sooner, then records
8
+ what it sent on the release, which lists it as exercised from your side.
9
+ It skips and names routes that change data or need a path parameter.
10
+ `--dry-run` shows what it would send.
11
+ - The agent instructions `deployangel install agents` writes now tell the
12
+ agent to run `deployangel exercise` when a release isn't cleared yet.
13
+
3
14
  ## 0.1.19 (2026-10-09)
4
15
 
5
16
  - `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,27 @@ 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. A "CLI & coding agents" token can run it. `--dry-run` shows what
475
+ it would send.
476
+
467
477
  ### Suggested instructions for your coding agent
468
478
 
469
479
  `deployangel install agents` adds instructions like these. To add them
470
480
  yourself, put this in your `CLAUDE.md` or `AGENTS.md`:
471
481
 
472
482
  ```markdown
473
- ## Production verification
483
+ ## Production verification (DeployAngel)
474
484
 
475
- After deploying, run `bundle exec deployangel verify --wait --until initial`
476
- (or call the `wait_for_verification` MCP tool with `until: "initial"`).
485
+ After deploying, or pushing a commit that deploys, wait for DeployAngel's
486
+ verdict: call the `wait_for_verification` MCP tool with the commit and
487
+ `until: "initial"`, calling again while it's in progress, or run
488
+ `bundle exec deployangel verify --commit=<sha> --wait --until=initial`.
477
489
 
478
490
  - Exit 0 / verified: the release is cleared. Report the clearance line and
479
491
  anything DeployAngel is still watching, then move on.
@@ -483,17 +495,16 @@ After deploying, run `bundle exec deployangel verify --wait --until initial`
483
495
  - Exit 7: warnings at the initial check. Report them and review the findings.
484
496
  The release is NOT cleared.
485
497
  - 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.
498
+ - Exit 1 / failed: read the findings and exceptions (`get_exception`),
499
+ investigate the likely cause, and propose a fix. Do not roll back or change
500
+ production without explicit approval.
489
501
  - 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.
502
+ - Not cleared yet: call `get_exercise_plan` (or `bundle exec deployangel plan`).
503
+ If its status is "exercisable" or "waiting_for_activity", run
504
+ `bundle exec deployangel exercise --url=<production URL>`: it sends the
505
+ plan's read-only requests and records them on the release. Offer to
506
+ exercise what it skips: routes that change data only with a test account
507
+ or after asking. If the status is "warm_up", nothing run can clear it.
497
508
  ```
498
509
 
499
510
  ## 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,115 @@
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
+ # Rails' :id and *path, Django's <int:pk>, FastAPI's {id}.
20
+ PARAMETER = /\/[:*{<]/
21
+
22
+ Target = Struct.new(:key, :path, :count)
23
+ Result = Struct.new(:routes, :skipped, :sent, :unreachable, keyword_init: true)
24
+
25
+ def initialize(base_url:, max_requests: MAX_REQUESTS, sleeper: ->(seconds) { sleep(seconds) }, requester: nil)
26
+ @base = URI(base_url)
27
+ @max_requests = max_requests
28
+ @sleeper = sleeper
29
+ @requester = requester || method(:get)
30
+ end
31
+
32
+ # What would be sent: [targets, skipped].
33
+ def plan(exercise_plan)
34
+ skipped = []
35
+ targets = []
36
+ Array(exercise_plan["items"]).each do |item|
37
+ next unless item["kind"] == "route"
38
+
39
+ method, path = item["key"].to_s.split(" ", 2)
40
+ reason =
41
+ if item["mutating"] || method != "GET" then "changes data"
42
+ elsif path.nil? || path.match?(PARAMETER) then "needs a path parameter"
43
+ end
44
+ next skipped << { "key" => item["key"], "reason" => reason } if reason
45
+
46
+ needed = item["runs_needed"] ? item["runs_needed"].to_i - item["runs"].to_i : 1
47
+ targets << Target.new(item["key"], path, [ needed, 1 ].max)
48
+ end
49
+ spread_shortfall(targets, exercise_plan.dig("shortfall", "requests"))
50
+ cap(targets)
51
+ [ targets, skipped ]
52
+ end
53
+
54
+ def run(exercise_plan)
55
+ targets, skipped = plan(exercise_plan)
56
+ routes = []
57
+ errors_in_a_row = 0
58
+ sent = 0
59
+ targets.each do |target|
60
+ statuses = Hash.new(0)
61
+ target.count.times do
62
+ break if errors_in_a_row >= MAX_CONSECUTIVE_ERRORS
63
+
64
+ @sleeper.call(INTERVAL) if sent.positive?
65
+ status = @requester.call(url_for(target.path))
66
+ sent += 1
67
+ kind = status ? "#{status / 100}xx" : "error"
68
+ statuses[kind] += 1
69
+ errors_in_a_row = kind == "error" ? errors_in_a_row + 1 : 0
70
+ end
71
+ requests = statuses.values.sum
72
+ routes << { "key" => target.key, "requests" => requests, "statuses" => statuses.to_h } if requests.positive?
73
+ end
74
+ Result.new(routes: routes, skipped: skipped, sent: sent, unreachable: errors_in_a_row >= MAX_CONSECUTIVE_ERRORS)
75
+ end
76
+
77
+ def url_for(path)
78
+ uri = @base.dup
79
+ uri.path = "#{@base.path.chomp("/")}#{path}"
80
+ uri.query = nil
81
+ uri.to_s
82
+ end
83
+
84
+ private
85
+ # Requests the release is short of overall, spread across the routes,
86
+ # or sent to the home page when the plan names no route to send them to.
87
+ def spread_shortfall(targets, requests)
88
+ return unless requests
89
+
90
+ extra = requests["need"].to_i - requests["have"].to_i - targets.sum(&:count)
91
+ return unless extra.positive?
92
+
93
+ targets << Target.new("GET /", "/", 0) if targets.empty?
94
+ extra.times { |i| targets[i % targets.size].count += 1 }
95
+ end
96
+
97
+ def cap(targets)
98
+ targets.max_by(&:count).count -= 1 while targets.sum(&:count) > @max_requests
99
+ end
100
+
101
+ # The status code, or nil when there was no answer. Redirects aren't
102
+ # followed: the request already reached the app.
103
+ def get(url)
104
+ uri = URI(url)
105
+ request = Net::HTTP::Get.new(uri)
106
+ request["User-Agent"] = "DeployAngel-Exercise/#{DeployAngel::VERSION} (+https://www.deployangel.com/docs#exercise)"
107
+ request["Accept"] = "text/html,application/json;q=0.9,*/*;q=0.8"
108
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: TIMEOUT, read_timeout: TIMEOUT) do |http|
109
+ http.request(request).code.to_i
110
+ end
111
+ rescue StandardError
112
+ nil
113
+ end
114
+ end
115
+ 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.20"
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.20
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