deployangel 0.1.11 → 0.1.12

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: 832e7ada784cf0e86d34f802ef299dfd9114b844f542444a69331b7d622e3d35
4
- data.tar.gz: 31c8f9e2efc46a96325e7813c8b5902c23cbf758c55dccdb3536048af881f7a7
3
+ metadata.gz: c3835d3c334a91d9ad7a75a3247ede4797a27e207b123f684bc1563fffd33cb0
4
+ data.tar.gz: c578f30dc3f828dabce95b7c4e48682b1248d306b0982d50483f17d3c3cf57c8
5
5
  SHA512:
6
- metadata.gz: a0e4683b471211a023e925fa4cbacdd57f908c0a1c1ee18af950490b291b47c9cfe9a33bb4d3a5e376e5992f8d478919167d0d32320448c5c3c362a9873e6afa
7
- data.tar.gz: 2cc10162cd04a158808c71eb51fb30b36716a3d9ca8e70c231d35289de887810194bf0b172481fd0f68d5ad025e36e3b8dda6b34335b2701b7b9b6980e5ea1d0
6
+ metadata.gz: 5a8e5b23a9d1a06732add016e3d31e813d08a10afc13016408c26944112098948476cfe51d7c39c60710c9f411cf6d0af01d145a4bd0a125ebcc90728d06b695
7
+ data.tar.gz: 7dea64425c2cd98cda899277a2561d67dde2d0871e38156c9efad654769fdcb6198ab94089d56fad0d72c7e15e48300d5b40d5018851491c6378043b435bb470
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.12 (2026-10-06)
4
+
5
+ - `deployangel plan` and the MCP tool `get_exercise_plan` say what stands
6
+ between a release and clearance, and what to exercise against production
7
+ so it clears sooner. Routes that change data are marked. `deployangel
8
+ verify` and the GitHub Actions job summary list the first items when a
9
+ release isn't cleared yet. Needs a DeployAngel server that returns
10
+ exercise plans; older ones get a note instead.
11
+
3
12
  ## 0.1.11 (2026-10-05)
4
13
 
5
14
  - 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>
@@ -390,9 +391,23 @@ claude mcp add deployangel -- bundle exec deployangel mcp
390
391
  ```
391
392
 
392
393
  Tools: `get_verification`, `wait_for_verification` (up to 5 minutes per call),
393
- `list_deployments`, `get_exception`, `list_late_regressions`, and
394
- `register_deployment` when the token allows it. The tools are read-only with
395
- respect to production.
394
+ `get_exercise_plan`, `list_deployments`, `get_exception`,
395
+ `list_late_regressions`, and `register_deployment` when the token allows it.
396
+ The tools are read-only with respect to production.
397
+
398
+ ### Clearing quiet releases sooner
399
+
400
+ A quiet app can take hours to clear a release. `deployangel plan` (or the
401
+ `get_exercise_plan` MCP tool) says what stands between a release and
402
+ clearance, in the numbers of the rule it's judged by, and what to exercise
403
+ against production so it clears sooner: normally active routes short of
404
+ their runs, routes this release changed that haven't run, and critical
405
+ flows. Requests to them count like any traffic. Routes that change data
406
+ are marked; use a test account for them, or ask first. Report what you
407
+ ran with the `deployangel check` command the plan gives you. A passing
408
+ check labels what it covered; only the requests themselves count as
409
+ evidence. A release deployed during an app's first 24 hours can't clear,
410
+ so its plan lists nothing.
396
411
 
397
412
  ### Suggested instructions for your coding agent
398
413
 
@@ -416,6 +431,13 @@ After deploying, run `bundle exec deployangel verify --wait --until initial`
416
431
  cause, and propose a fix. Do not roll back or change production without
417
432
  explicit approval.
418
433
  - Exit 3: still verifying; run the command again.
434
+ - Not cleared yet and you can reach production: run
435
+ `bundle exec deployangel plan --format=json` (or `get_exercise_plan`). If
436
+ its status is "exercisable" or "waiting_for_activity", exercise the listed
437
+ items against production: read-only routes freely, routes marked mutating
438
+ only with a test account or after asking. Then run its `report_with`
439
+ command and wait again. If the status is "warm_up", don't exercise
440
+ anything: the release can't clear until DeployAngel has a day of history.
419
441
  ```
420
442
 
421
443
  ## 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]
@@ -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]
@@ -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,6 +136,7 @@ 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)
139
+ when "get_exercise_plan" then waiter.wait(target(arguments), wait: false).then { |o| plan_content(o) }
130
140
  when "list_deployments" then @client.deployments(limit: arguments.fetch("limit", 10))
131
141
  when "get_exception" then @client.exception(arguments.fetch("fingerprint") { raise ArgumentError, "fingerprint is required" })
132
142
  when "list_late_regressions" then @client.late_regressions(since: arguments["since"], limit: arguments.fetch("limit", 10))
@@ -157,6 +167,14 @@ module DeployAngel
157
167
  "in_progress" => outcome.exit_code == VerificationWaiter::TIMED_OUT, "verification" => outcome.document }
158
168
  end
159
169
 
170
+ def plan_content(outcome)
171
+ return { "found" => false, "note" => "No matching deployment is registered yet." } if outcome.not_found
172
+
173
+ { "deployment" => outcome.document["deployment"],
174
+ "exercise_plan" => outcome.document["exercise_plan"] ||
175
+ { "note" => "This DeployAngel server doesn't return exercise plans yet." } }
176
+ end
177
+
160
178
  def meaning(code)
161
179
  {
162
180
  0 => "verified: the release is cleared",
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DeployAngel
4
- VERSION = "0.1.11"
4
+ VERSION = "0.1.12"
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.11
4
+ version: 0.1.12
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jordan Owens