deployangel 0.1.11 → 0.1.13
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 +17 -0
- data/README.md +37 -4
- data/lib/deployangel/cli/formatter.rb +56 -0
- data/lib/deployangel/cli.rb +28 -1
- data/lib/deployangel/mcp.rb +22 -2
- data/lib/deployangel/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7d88c16de71d5010f445a966a6eea5701d96cc84aa3017df5fc362152331dd4e
|
|
4
|
+
data.tar.gz: a6df4ed365874fefe0708bf3cc5e482074b7f1f148f5bfe0aa25a7de2f44ed58
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2486f4bf45d20d65bc01bd3f89881e5def3ea9cbef692c4a87e0af631bccbb7ece176ad30c41040c8b57ac46b8635d8cc8d8625ac92eb503e0531086f4caefee
|
|
7
|
+
data.tar.gz: 19a7a768889a541fc9f7688e3999947440ade5f747cfe6d8726211eb9536f9700369855896e31d8d0fe4091c35ee2559381d9329f78dd303be21e1a87964ddba
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.13 (2026-10-06)
|
|
4
|
+
|
|
5
|
+
- The MCP tools `list_deployments` and `list_late_regressions` return their
|
|
6
|
+
lists inside an object (`{"deployments": [...]}`, `{"late_regressions":
|
|
7
|
+
[...]}`). MCP requires a tool's structured content to be an object, so
|
|
8
|
+
clients such as Claude Code rejected the bare lists before the agent saw
|
|
9
|
+
them.
|
|
10
|
+
|
|
11
|
+
## 0.1.12 (2026-10-06)
|
|
12
|
+
|
|
13
|
+
- `deployangel plan` and the MCP tool `get_exercise_plan` say what stands
|
|
14
|
+
between a release and clearance, and what to exercise against production
|
|
15
|
+
so it clears sooner. Routes that change data are marked. `deployangel
|
|
16
|
+
verify` and the GitHub Actions job summary list the first items when a
|
|
17
|
+
release isn't cleared yet. Needs a DeployAngel server that returns
|
|
18
|
+
exercise plans; older ones get a note instead.
|
|
19
|
+
|
|
3
20
|
## 0.1.11 (2026-10-05)
|
|
4
21
|
|
|
5
22
|
- Heroku: the commit comes from `HEROKU_BUILD_COMMIT`
|
data/README.md
CHANGED
|
@@ -364,6 +364,7 @@ export DEPLOYANGEL_API_TOKEN=da_live_...
|
|
|
364
364
|
bundle exec deployangel verify --wait # current git HEAD, until a verdict
|
|
365
365
|
bundle exec deployangel verify --wait --until=initial # return at the 15-minute initial check
|
|
366
366
|
bundle exec deployangel status # latest deployment
|
|
367
|
+
bundle exec deployangel plan # what to exercise so a release clears sooner
|
|
367
368
|
bundle exec deployangel release --commit=$SHA # register a deploy (manual or CI)
|
|
368
369
|
bundle exec deployangel check --name="smoke: signup" --status=pass --covers=registration
|
|
369
370
|
bundle exec deployangel exception <fingerprint>
|
|
@@ -386,13 +387,38 @@ choose with `--format=text|json`. Exit codes:
|
|
|
386
387
|
### MCP server
|
|
387
388
|
|
|
388
389
|
```bash
|
|
389
|
-
claude mcp add deployangel -- bundle exec deployangel mcp
|
|
390
|
+
claude mcp add deployangel -- bundle exec deployangel mcp # Claude Code
|
|
390
391
|
```
|
|
391
392
|
|
|
393
|
+
For Codex, in `~/.codex/config.toml`:
|
|
394
|
+
|
|
395
|
+
```toml
|
|
396
|
+
[mcp_servers.deployangel]
|
|
397
|
+
command = "bundle"
|
|
398
|
+
args = ["exec", "deployangel", "mcp"]
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
Any other MCP client (Cursor, VS Code, Zed, ...) runs the same command. The
|
|
402
|
+
server needs `DEPLOYANGEL_API_TOKEN` in the environment it starts in.
|
|
403
|
+
|
|
392
404
|
Tools: `get_verification`, `wait_for_verification` (up to 5 minutes per call),
|
|
393
|
-
`
|
|
394
|
-
`register_deployment` when the token allows it.
|
|
395
|
-
respect to production.
|
|
405
|
+
`get_exercise_plan`, `list_deployments`, `get_exception`,
|
|
406
|
+
`list_late_regressions`, and `register_deployment` when the token allows it.
|
|
407
|
+
The tools are read-only with respect to production.
|
|
408
|
+
|
|
409
|
+
### Clearing quiet releases sooner
|
|
410
|
+
|
|
411
|
+
A quiet app can take hours to clear a release. `deployangel plan` (or the
|
|
412
|
+
`get_exercise_plan` MCP tool) says what stands between a release and
|
|
413
|
+
clearance, in the numbers of the rule it's judged by, and what to exercise
|
|
414
|
+
against production so it clears sooner: normally active routes short of
|
|
415
|
+
their runs, routes this release changed that haven't run, and critical
|
|
416
|
+
flows. Requests to them count like any traffic. Routes that change data
|
|
417
|
+
are marked; use a test account for them, or ask first. Report what you
|
|
418
|
+
ran with the `deployangel check` command the plan gives you. A passing
|
|
419
|
+
check labels what it covered; only the requests themselves count as
|
|
420
|
+
evidence. A release deployed during an app's first 24 hours can't clear,
|
|
421
|
+
so its plan lists nothing.
|
|
396
422
|
|
|
397
423
|
### Suggested instructions for your coding agent
|
|
398
424
|
|
|
@@ -416,6 +442,13 @@ After deploying, run `bundle exec deployangel verify --wait --until initial`
|
|
|
416
442
|
cause, and propose a fix. Do not roll back or change production without
|
|
417
443
|
explicit approval.
|
|
418
444
|
- Exit 3: still verifying; run the command again.
|
|
445
|
+
- Not cleared yet and you can reach production: run
|
|
446
|
+
`bundle exec deployangel plan --format=json` (or `get_exercise_plan`). If
|
|
447
|
+
its status is "exercisable" or "waiting_for_activity", exercise the listed
|
|
448
|
+
items against production: read-only routes freely, routes marked mutating
|
|
449
|
+
only with a test account or after asking. Then run its `report_with`
|
|
450
|
+
command and wait again. If the status is "warm_up", don't exercise
|
|
451
|
+
anything: the release can't clear until DeployAngel has a day of history.
|
|
419
452
|
```
|
|
420
453
|
|
|
421
454
|
## Development
|
|
@@ -13,6 +13,10 @@ module DeployAngel
|
|
|
13
13
|
"checkpoint_rate" => "Checkpoint"
|
|
14
14
|
}.freeze
|
|
15
15
|
DURATIONS = %w[p95_latency job_duration queue_latency].freeze
|
|
16
|
+
# Exercise plan statuses with something worth running now.
|
|
17
|
+
EXERCISABLE = %w[exercisable waiting_for_activity].freeze
|
|
18
|
+
REASONS = { "normally_active" => "normally active", "changed_in_release" => "changed in this release",
|
|
19
|
+
"rarely_used" => "rarely used", "critical_flow" => "critical flow" }.freeze
|
|
16
20
|
COUNTS = %w[new_fingerprint checkpoint_rate].freeze
|
|
17
21
|
|
|
18
22
|
module_function
|
|
@@ -103,10 +107,62 @@ module DeployAngel
|
|
|
103
107
|
"#{flow["name"]}: #{flow["status"].tr("_", " ")}#{" (changed in this release)" if flow["changed_in_release"]}"
|
|
104
108
|
end ],
|
|
105
109
|
[ "Changed but not yet exercised", changed.map { |item| item["key"] } ],
|
|
110
|
+
[ "To clear sooner, exercise (deployangel plan for details)", exercisable_items(document).first(5) ],
|
|
106
111
|
[ "Late regressions", Array(document["late_regressions"]).map { |late| late["summary"] } ]
|
|
107
112
|
].reject { |_, items| items.empty? }
|
|
108
113
|
end
|
|
109
114
|
|
|
115
|
+
# The release's exercise plan, for `deployangel plan`.
|
|
116
|
+
def exercise_plan(document)
|
|
117
|
+
plan = document["exercise_plan"] || {}
|
|
118
|
+
lines = [ "#{release_name(document)}: #{plan["summary"] || "No exercise plan in this response; update the server."}" ]
|
|
119
|
+
shortfall = shortfall_lines(plan["shortfall"] || {})
|
|
120
|
+
if shortfall.any?
|
|
121
|
+
lines << "Short of:"
|
|
122
|
+
shortfall.each { |line| lines << " #{line}" }
|
|
123
|
+
end
|
|
124
|
+
items = Array(plan["items"])
|
|
125
|
+
if items.any?
|
|
126
|
+
lines << (EXERCISABLE.include?(plan["status"]) ? "Exercise against production:" : "Optional:")
|
|
127
|
+
items.each { |item| lines << " #{item_line(item)}" }
|
|
128
|
+
lines << "Use a test account, or ask first, for routes marked [changes data]." if items.any? { |item| item["mutating"] }
|
|
129
|
+
end
|
|
130
|
+
lines << "Then report it: #{plan["report_with"]}" if plan["report_with"]
|
|
131
|
+
lines.join("\n")
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def exercisable_items(document)
|
|
135
|
+
plan = document["exercise_plan"] || {}
|
|
136
|
+
EXERCISABLE.include?(plan["status"]) ? Array(plan["items"]).map { |item| item_line(item) } : []
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def shortfall_lines(shortfall)
|
|
140
|
+
lines = []
|
|
141
|
+
if (requests = shortfall["requests"])
|
|
142
|
+
lines << "requests: #{requests["have"]} of #{requests["need"]}#{" (low-traffic rule)" if shortfall["rule"] == "low_volume"}"
|
|
143
|
+
end
|
|
144
|
+
shortfall.each do |key, value|
|
|
145
|
+
next unless key.start_with?("routes_run_")
|
|
146
|
+
|
|
147
|
+
lines << "routes run #{key[/\d+/]}+ times: #{value["have"]} of the #{value["need"]} needed (#{value["of"]} normally active)"
|
|
148
|
+
end
|
|
149
|
+
lines << "coverage: #{(shortfall.dig("coverage", "have").to_f * 100).round}% of #{(shortfall.dig("coverage", "need").to_f * 100).round}%" if shortfall["coverage"]
|
|
150
|
+
if (jobs = shortfall["jobs"])
|
|
151
|
+
lines << (jobs["classes_not_run"] ? "jobs not run yet: #{Array(jobs["classes_not_run"]).join(", ")}" :
|
|
152
|
+
"job attempts: #{jobs.dig("attempts", "have")} of #{jobs.dig("attempts", "need")}")
|
|
153
|
+
end
|
|
154
|
+
lines << "elevated, review before exercising more: #{Array(shortfall["elevated"]).join(", ")}" if shortfall["elevated"]
|
|
155
|
+
lines << "critical flows not run: #{Array(shortfall["critical_flows"]).join(", ")}" if shortfall["critical_flows"]
|
|
156
|
+
lines
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def item_line(item)
|
|
160
|
+
runs = item["runs_needed"].to_i > 1 ? "run #{item["runs"]} of #{item["runs_needed"]}" : "not run yet"
|
|
161
|
+
runs = "runs when the app starts it" if item["triggered_by"] == "app_behavior"
|
|
162
|
+
[ item["key"], "(#{REASONS.fetch(item["reason"], item["reason"])}, #{runs})",
|
|
163
|
+
("[changes data]" if item["mutating"]), ("checked by #{Array(item["checked_by"]).join(", ")}" if item["checked_by"]) ].compact.join(" ")
|
|
164
|
+
end
|
|
165
|
+
|
|
110
166
|
def release_name(document)
|
|
111
167
|
deployment = document["deployment"] || {}
|
|
112
168
|
deployment["version"] || deployment["commit"].to_s[0, 7]
|
data/lib/deployangel/cli.rb
CHANGED
|
@@ -28,13 +28,15 @@ module DeployAngel
|
|
|
28
28
|
[--wait] [--until=initial|verdict|closed] [--timeout=30m]
|
|
29
29
|
[--format=text|json] [--all-findings]
|
|
30
30
|
status Latest deployment and its verification
|
|
31
|
+
plan What to exercise so a release [--commit=SHA | --version=V | --deployment=ID]
|
|
32
|
+
clears sooner [--format=text|json]
|
|
31
33
|
exception Details for a fingerprint deployangel exception FINGERPRINT
|
|
32
34
|
check Report a smoke test result --name=NAME --status=pass|fail [--covers=a,b]
|
|
33
35
|
[--commit=SHA | --deployment=ID] [--details-url=URL]
|
|
34
36
|
install Add a deploy hook deployangel install kamal
|
|
35
37
|
mcp Run the MCP server over stdio (for coding agents)
|
|
36
38
|
|
|
37
|
-
With no target, verify and check use the current git HEAD commit.
|
|
39
|
+
With no target, verify, plan, and check use the current git HEAD commit.
|
|
38
40
|
In GitHub Actions, GitLab CI, CircleCI, and Buildkite, release fills in the
|
|
39
41
|
commit, a build label (e.g. run-123), and a link to the run automatically.
|
|
40
42
|
In a Kamal hook, it registers Kamal's release (KAMAL_VERSION).
|
|
@@ -75,6 +77,7 @@ module DeployAngel
|
|
|
75
77
|
when "release" then release
|
|
76
78
|
when "verify" then verify
|
|
77
79
|
when "status" then verify(status: true)
|
|
80
|
+
when "plan" then plan
|
|
78
81
|
when "exception" then exception
|
|
79
82
|
when "check" then check
|
|
80
83
|
when "install" then install
|
|
@@ -150,6 +153,30 @@ module DeployAngel
|
|
|
150
153
|
outcome.exit_code
|
|
151
154
|
end
|
|
152
155
|
|
|
156
|
+
# The release's exercise plan: what stands between it and clearance,
|
|
157
|
+
# and what to exercise against production so it clears sooner.
|
|
158
|
+
def plan
|
|
159
|
+
options = parse(format: nil) do |o, opts|
|
|
160
|
+
o.on("--commit=SHA") { |v| opts[:commit] = v }
|
|
161
|
+
o.on("--version=VERSION") { |v| opts[:version] = v }
|
|
162
|
+
o.on("--deployment=ID") { |v| opts[:deployment_id] = v }
|
|
163
|
+
o.on("--format=FORMAT", %w[text json]) { |v| opts[:format] = v }
|
|
164
|
+
end
|
|
165
|
+
target = target_from(options)
|
|
166
|
+
return usage_error("no target: pass --commit, --version, or --deployment, or run inside a git repository") unless target
|
|
167
|
+
|
|
168
|
+
outcome = VerificationWaiter.new(client: client, sleeper: @sleeper, clock: @clock).wait(target, wait: false)
|
|
169
|
+
if outcome.not_found
|
|
170
|
+
@stderr.puts("deployangel: no deployment found for #{target.values.first}")
|
|
171
|
+
return outcome.exit_code
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
document = outcome.document
|
|
175
|
+
json = { "deployment" => document["deployment"], "exercise_plan" => document["exercise_plan"] }
|
|
176
|
+
output(json, options[:format]) { Formatter.exercise_plan(document) }
|
|
177
|
+
0
|
|
178
|
+
end
|
|
179
|
+
|
|
153
180
|
def exception
|
|
154
181
|
fingerprint = @argv.shift or return usage_error("exception needs a FINGERPRINT")
|
|
155
182
|
format = parse(format: nil) { |o, opts| o.on("--format=FORMAT", %w[text json]) { |v| opts[:format] = v } }[:format]
|
data/lib/deployangel/mcp.rb
CHANGED
|
@@ -21,6 +21,10 @@ module DeployAngel
|
|
|
21
21
|
failed means production regressed: read the findings and exceptions and investigate.
|
|
22
22
|
Findings are deterministic evidence; any "investigation" field is AI inference. These tools cannot
|
|
23
23
|
change production. Do not roll back or change production without explicit approval.
|
|
24
|
+
If a release isn't cleared yet, get_exercise_plan says what to exercise against production so it
|
|
25
|
+
clears sooner. Only act on status "exercisable" or "waiting_for_activity"; for "warm_up" or
|
|
26
|
+
"no_baseline" nothing you run can clear it. Exercise routes marked mutating only with a test account
|
|
27
|
+
or after asking. Report what you ran with the plan's report_with command (deployangel check).
|
|
24
28
|
TEXT
|
|
25
29
|
|
|
26
30
|
TARGET_PROPERTIES = {
|
|
@@ -96,6 +100,11 @@ module DeployAngel
|
|
|
96
100
|
"until" => { "type" => "string", "enum" => %w[initial verdict], "default" => "initial" },
|
|
97
101
|
"timeout_seconds" => { "type" => "integer", "minimum" => 1, "maximum" => MAX_WAIT_SECONDS, "default" => MAX_WAIT_SECONDS }
|
|
98
102
|
)),
|
|
103
|
+
tool("get_exercise_plan", "What stands between a release and clearance, and what to exercise against " \
|
|
104
|
+
"production so it clears sooner: normally active routes short of their runs, routes this release changed " \
|
|
105
|
+
"that haven't run, and critical flows. Routes marked mutating change data: use a test account or ask first. " \
|
|
106
|
+
"Your requests count as ordinary traffic; report what you ran with report_with. Defaults to the current git HEAD.",
|
|
107
|
+
TARGET_PROPERTIES),
|
|
99
108
|
tool("list_deployments", "Recent deployments with their verification state and verdict.",
|
|
100
109
|
{ "limit" => { "type" => "integer", "minimum" => 1, "maximum" => 50, "default" => 10 } }),
|
|
101
110
|
tool("get_exception", "Sanitized details and the application stack trace for an exception fingerprint.",
|
|
@@ -127,9 +136,12 @@ module DeployAngel
|
|
|
127
136
|
case name
|
|
128
137
|
when "get_verification" then waiter.wait(target(arguments), wait: false).then { |o| verification_content(o) }
|
|
129
138
|
when "wait_for_verification" then wait_content(arguments)
|
|
130
|
-
when "
|
|
139
|
+
when "get_exercise_plan" then waiter.wait(target(arguments), wait: false).then { |o| plan_content(o) }
|
|
140
|
+
# MCP requires structured content to be an object, never a bare list.
|
|
141
|
+
when "list_deployments" then { "deployments" => @client.deployments(limit: arguments.fetch("limit", 10)) }
|
|
131
142
|
when "get_exception" then @client.exception(arguments.fetch("fingerprint") { raise ArgumentError, "fingerprint is required" })
|
|
132
|
-
when "list_late_regressions"
|
|
143
|
+
when "list_late_regressions"
|
|
144
|
+
{ "late_regressions" => @client.late_regressions(since: arguments["since"], limit: arguments.fetch("limit", 10)) }
|
|
133
145
|
when "register_deployment"
|
|
134
146
|
raise ArgumentError, "register_deployment is not available for this token" unless scopes.include?("deployments")
|
|
135
147
|
|
|
@@ -157,6 +169,14 @@ module DeployAngel
|
|
|
157
169
|
"in_progress" => outcome.exit_code == VerificationWaiter::TIMED_OUT, "verification" => outcome.document }
|
|
158
170
|
end
|
|
159
171
|
|
|
172
|
+
def plan_content(outcome)
|
|
173
|
+
return { "found" => false, "note" => "No matching deployment is registered yet." } if outcome.not_found
|
|
174
|
+
|
|
175
|
+
{ "deployment" => outcome.document["deployment"],
|
|
176
|
+
"exercise_plan" => outcome.document["exercise_plan"] ||
|
|
177
|
+
{ "note" => "This DeployAngel server doesn't return exercise plans yet." } }
|
|
178
|
+
end
|
|
179
|
+
|
|
160
180
|
def meaning(code)
|
|
161
181
|
{
|
|
162
182
|
0 => "verified: the release is cleared",
|
data/lib/deployangel/version.rb
CHANGED