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 +4 -4
- data/CHANGELOG.md +11 -0
- data/README.md +24 -13
- data/lib/deployangel/cli.rb +62 -6
- data/lib/deployangel/client.rb +6 -0
- data/lib/deployangel/exerciser.rb +115 -0
- data/lib/deployangel/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 59eed3c6629b0dc5b81cbec2636583d50f0038c2f51af8011108205a1a23eb8c
|
|
4
|
+
data.tar.gz: d10e3a967bed17b69b854a5b74deba72b66ad4e204b3c007bd1958fc81e1ca66
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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,
|
|
476
|
-
|
|
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,
|
|
487
|
-
cause, and propose a fix. Do not roll back or change
|
|
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
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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
|
data/lib/deployangel/cli.rb
CHANGED
|
@@ -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",
|
|
102
|
-
|
|
103
|
-
only
|
|
104
|
-
|
|
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]
|
data/lib/deployangel/client.rb
CHANGED
|
@@ -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
|
data/lib/deployangel/version.rb
CHANGED
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.
|
|
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
|