deployangel 0.1.23 → 0.1.25

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: 2635666118ec2250b5414245e3ea0aa1026d30e4369788e2c82bf6e6b0a52f71
4
- data.tar.gz: 0fac6aa9bf419e2ad7a93a289902c94fa94a6d0b42c1418d7370afbaf90501cd
3
+ metadata.gz: 05e4cde0e2b605e294d51c73f25af3a2b5d9496c6a8e0b3270c358a9a5b9e4ec
4
+ data.tar.gz: 722541542ff63d2cb8eedb0020a974a71ecf4eaaff3338e50e1002bc88e7ebbd
5
5
  SHA512:
6
- metadata.gz: 41f77d3e77fae4bccf35c82570582e01501246314c540d6bf6d227cdcd15e033237d62d9b0b2769e80766f53293982b4e8fe07e644ced1e99d93ca13a57b93f2
7
- data.tar.gz: 674cb4c4972c88d861375904572c1048f247f041400c52b8d5380f44b3ff76d8b6f473362b404bedcf2377e1b6811cf96eea39f5cb9934b07271ddd627376afa
6
+ metadata.gz: c9674f14d5c5f09540e2fe9a102fd1e16571c6492214a47b70a155bb6c70c98af1e333c58734b8eca2079aab323aca6090a47bf8b964111a59e6734dcee316d3
7
+ data.tar.gz: 71fad66b1e6f4d36b1794e8f403b05524a0b151a2b4232067e39f3f7341c80d6673b28362215bdf2250ef179640124249c397ec366c85556a9eae5f9d87ce10a
data/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.25 (2026-10-10)
4
+
5
+ - `verify` prints what a clearance doesn't cover right under it: app-wide
6
+ signals without enough data, critical flows and changed items not run
7
+ since the deploy, and latency that rose but stayed within limits (from
8
+ the verdict document's new `clearance.scope`). The coding-agent
9
+ instructions `install agents` writes now say to report it too.
10
+
11
+ ## 0.1.24 (2026-10-10)
12
+
13
+ - `deployangel doctor` checks the setup: the agent in the bundle, the
14
+ schedules in this checkout's files, and how production will know the
15
+ release; then, with a token, what DeployAngel has seen (processes
16
+ reporting and their releases, what the agent reported, and setup
17
+ problems with fixes). Only errors fail it. Run where the app runs, it
18
+ uses the agent's `DEPLOYANGEL_TOKEN` and shows the release that process
19
+ reports. Needs a DeployAngel server with `GET /api/v1/setup`.
20
+
3
21
  ## 0.1.23 (2026-10-09)
4
22
 
5
23
  - GoodJob's cron (`config.good_job.cron`, or `GOOD_JOB_CRON`) is read from
data/README.md CHANGED
@@ -441,8 +441,20 @@ bundle exec deployangel exercise --url=https://example.com # send the plan's re
441
441
  bundle exec deployangel release --commit=$SHA # register a deploy (manual or CI)
442
442
  bundle exec deployangel check --name="smoke: signup" --status=pass --covers=registration
443
443
  bundle exec deployangel exception <fingerprint>
444
+ bundle exec deployangel doctor # check the setup
444
445
  ```
445
446
 
447
+ `deployangel doctor` checks this checkout (the agent in the bundle, the
448
+ schedules in its files, how production will know the release) and, with a
449
+ token, what DeployAngel has seen of the app: which processes report and the
450
+ release each runs, the routes, job classes, and scheduled jobs the agent
451
+ reported, and setup problems with what to do about each. Only errors (a
452
+ rejected token, an unreachable server, no agent in the bundle) exit 5;
453
+ warnings exit 0, so it's safe in CI. It reads `DEPLOYANGEL_API_TOKEN`, or
454
+ `DEPLOYANGEL_TOKEN` where the app runs, so
455
+ `heroku run bundle exec deployangel doctor` also shows the release that
456
+ process reports.
457
+
446
458
  Output is text on a terminal and JSON (the verdict document) when piped, or
447
459
  choose with `--format=text|json`. Exit codes:
448
460
 
@@ -533,7 +545,8 @@ verdict: call the `wait_for_verification` MCP tool with the commit and
533
545
  `until: "initial"`, calling again while it's in progress, or run
534
546
  `bundle exec deployangel verify --commit=<sha> --wait --until=initial`.
535
547
 
536
- - Exit 0 / verified: the release is cleared. Report the clearance line and
548
+ - Exit 0 / verified: the release is cleared. Report the clearance line, what
549
+ it says it doesn't cover ("Not assessed", "Not run since the deploy"), and
537
550
  anything DeployAngel is still watching, then move on.
538
551
  - Exit 6: no problems so far, but NOT cleared. Report "no problems so far, not
539
552
  yet cleared" and the expected clearance time. DeployAngel keeps verifying and
@@ -70,7 +70,9 @@ module DeployAngel
70
70
 
71
71
  def notes(document)
72
72
  v = document["verification"] || {}
73
- notes = []
73
+ # What a clearance doesn't cover, right under it (servers from
74
+ # 2026-10-10 send it).
75
+ notes = Array(document.dig("clearance", "scope", "lines"))
74
76
  if (check = v["initial_check"]) && v["verdict"].nil?
75
77
  notes << "Initial check: #{check["result"].tr("_", " ")}. Not cleared yet."
76
78
  end
@@ -12,13 +12,14 @@ require_relative "ci_environment"
12
12
  require_relative "verification_waiter"
13
13
  require_relative "cli/formatter"
14
14
  require_relative "exerciser"
15
+ require_relative "doctor"
15
16
 
16
17
  module DeployAngel
17
18
  # `deployangel` command for developers, CI, and coding agents.
18
19
  # Runs without booting Rails.
19
20
  class CLI
20
21
  USAGE_ERROR = 5
21
- COMMANDS = %w[release verify status plan exercise exception check install mcp version help].freeze
22
+ COMMANDS = %w[release verify status plan exercise exception check install doctor mcp version help].freeze
22
23
 
23
24
  HELP = <<~TEXT
24
25
  Usage: deployangel <command> [options]
@@ -39,6 +40,8 @@ module DeployAngel
39
40
  install Add a Kamal deploy hook, deployangel install kamal
40
41
  bake the commit into an image, deployangel install docker
41
42
  or set up coding agents deployangel install agents
43
+ doctor Check the setup: this checkout [--format=text|json]
44
+ and what DeployAngel has seen
42
45
  mcp Run the MCP server over stdio (for coding agents)
43
46
 
44
47
  With no target, verify, plan, and check use the current git HEAD commit.
@@ -88,7 +91,8 @@ module DeployAngel
88
91
  `until: "initial"`, calling again while it's in progress, or run
89
92
  `bundle exec deployangel verify --commit=<sha> --wait --until=initial`.
90
93
 
91
- - Exit 0 / verified: the release is cleared. Report the clearance line and
94
+ - Exit 0 / verified: the release is cleared. Report the clearance line, what
95
+ it says it doesn't cover ("Not assessed", "Not run since the deploy"), and
92
96
  anything DeployAngel is still watching, then move on.
93
97
  - Exit 6: no problems so far, but NOT cleared. Report "no problems so far, not
94
98
  yet cleared" and the expected clearance time. DeployAngel keeps verifying and
@@ -148,6 +152,7 @@ module DeployAngel
148
152
  when "exception" then exception
149
153
  when "check" then check
150
154
  when "install" then install
155
+ when "doctor" then doctor
151
156
  when "mcp" then mcp
152
157
  when "version", "--version", "-v" then @stdout.puts(DeployAngel::VERSION) || 0
153
158
  when nil, "help", "--help", "-h" then @stdout.puts(HELP) || 0
@@ -323,6 +328,36 @@ module DeployAngel
323
328
  0
324
329
  end
325
330
 
331
+ # What this checkout will report and what DeployAngel has seen of the
332
+ # app. Warnings are advice, so only errors (a rejected token, an
333
+ # unreachable server, no agent in the bundle) fail it.
334
+ def doctor
335
+ format = parse(format: nil) { |o, opts| o.on("--format=FORMAT", %w[text json]) { |v| opts[:format] = v } }[:format]
336
+ doctor = Doctor.new(root: @root, client: doctor_client, env: @env)
337
+ output(doctor.to_h, format) { doctor_text(doctor.checks) }
338
+ doctor.failed? ? USAGE_ERROR : 0
339
+ end
340
+
341
+ DOCTOR_MARKS = { ok: "✓", warn: "!", error: "✗", info: "·" }.freeze
342
+
343
+ def doctor_text(checks)
344
+ lines = [ "DeployAngel doctor" ]
345
+ checks.each do |check|
346
+ lines << " #{DOCTOR_MARKS.fetch(check.status)} #{check.message}"
347
+ lines << " #{check.fix}" if check.fix
348
+ end
349
+ lines.join("\n")
350
+ end
351
+
352
+ # Either token works: the setup endpoint answers any of the app's. The
353
+ # agent's own token is for running it where the app runs.
354
+ def doctor_client
355
+ return @client if @client
356
+
357
+ token = [ @env["DEPLOYANGEL_API_TOKEN"], @env["DEPLOYANGEL_TOKEN"] ].find { |value| !value.to_s.empty? } or return
358
+ Client.new(token: token, endpoint: @env["DEPLOYANGEL_URL"] || Configuration::DEFAULT_ENDPOINT)
359
+ end
360
+
326
361
  def install
327
362
  case (target = @argv.shift)
328
363
  when "kamal" then install_kamal
@@ -31,6 +31,7 @@ module DeployAngel
31
31
  end
32
32
 
33
33
  def token_info = get("/api/v1/token")
34
+ def setup = get("/api/v1/setup")
34
35
  def latest_deployment = get("/api/v1/deployments/latest")
35
36
  def deployments(**filters) = get("/api/v1/deployments", filters.compact)["deployments"]
36
37
  def exception(fingerprint) = get("/api/v1/exceptions/#{URI.encode_www_form_component(fingerprint)}")
@@ -0,0 +1,237 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require_relative "configuration"
5
+ require_relative "metadata"
6
+ require_relative "rails/metadata"
7
+ require_relative "core/release"
8
+
9
+ module DeployAngel
10
+ # `deployangel doctor`: what this checkout will report, and what
11
+ # DeployAngel has seen of the app so far (GET /api/v1/setup), as checks
12
+ # with what to do about each. It runs without booting the app, so it
13
+ # reads only the schedules kept in files.
14
+ class Doctor
15
+ # status is ok, warn, error, or info. Only errors fail the command.
16
+ Check = Struct.new(:status, :message, :fix, keyword_init: true) do
17
+ def to_h = { "status" => status.to_s, "message" => message, "fix" => fix }.compact
18
+ end
19
+
20
+ SOURCES = { "solid_queue" => "Solid Queue", "sidekiq_cron" => "sidekiq-cron", "sidekiq_scheduler" => "sidekiq-scheduler",
21
+ "whenever" => "whenever", "good_job" => "GoodJob", "config" => "agent config" }.freeze
22
+ TOKEN_HELP = "Set DEPLOYANGEL_API_TOKEN (a \"CLI & coding agents\" token from the app's Settings) or DEPLOYANGEL_TOKEN " \
23
+ "(the app's agent token), then run this again."
24
+ MAX_HOSTS = 5
25
+
26
+ attr_reader :setup
27
+
28
+ # client is nil when no token is set; the local checks still run.
29
+ def initialize(root:, client:, env: {})
30
+ @root = root
31
+ @client = client
32
+ @env = env
33
+ end
34
+
35
+ def checks
36
+ @checks ||= begin
37
+ @setup, server = server_checks
38
+ [ *agent_checks, here_check, schedule_check, *server, release_check, *comparison_checks ].compact
39
+ end
40
+ end
41
+
42
+ def failed?
43
+ checks.any? { |check| check.status == :error }
44
+ end
45
+
46
+ def to_h
47
+ { "checks" => checks.map(&:to_h), "setup" => setup }
48
+ end
49
+
50
+ private
51
+ def agent_checks
52
+ lock = read("Gemfile.lock") or return []
53
+ version = lock[/^ deployangel \(([^)]+)\)/, 1]
54
+ return [ Check.new(status: :ok, message: "Agent: deployangel #{version} in Gemfile.lock") ] if version
55
+
56
+ [ Check.new(status: :error, message: "The agent isn't in the bundle", fix: "Add gem \"deployangel\" to the Gemfile and run bundle install.") ]
57
+ end
58
+
59
+ def schedule_check
60
+ counts = local_schedules.group_by { |schedule| schedule["source"] }.map { |source, list| "#{SOURCES.fetch(source, source)} #{list.size}" }
61
+ later = [ ("GoodJob's cron" if read("Gemfile.lock").to_s.include?(" good_job (")), "config.recurring_jobs" ].compact
62
+ note = "#{later.join(" and ")} #{later.one? ? "is" : "are"} read when the app runs."
63
+ if counts.any?
64
+ Check.new(status: :ok, message: "#{count(local_schedules.size, "scheduled job")} in this checkout (#{counts.join(", ")}). #{note}")
65
+ else
66
+ Check.new(status: :info, message: "No scheduled jobs in this checkout's schedule files. #{note}")
67
+ end
68
+ end
69
+
70
+ # The token's app and what DeployAngel has seen of it.
71
+ def server_checks
72
+ return [ nil, [ Check.new(status: :warn, message: "No token, so only this checkout was checked", fix: TOKEN_HELP) ] ] unless @client
73
+
74
+ setup = @client.setup
75
+ checks = [ app_check(setup), agent_check(setup), metadata_check(setup), *gap_checks(setup), progress_check(setup) ]
76
+ [ setup, checks.compact ]
77
+ rescue Client::Unauthorized => e
78
+ [ nil, [ Check.new(status: :error, message: "DeployAngel rejected the token (#{e.message})", fix: TOKEN_HELP) ] ]
79
+ rescue Client::NotFound
80
+ [ nil, [ Check.new(status: :warn, message: "This DeployAngel server doesn't support doctor yet; only this checkout was checked") ] ]
81
+ rescue Client::Error => e
82
+ [ nil, [ Check.new(status: :error, message: "Couldn't reach DeployAngel: #{e.message}") ] ]
83
+ end
84
+
85
+ def app_check(setup)
86
+ app = setup["app"] || {}
87
+ Check.new(status: :ok, message: "App: #{app["name"]} (#{app["environment"]})")
88
+ end
89
+
90
+ def agent_check(setup)
91
+ agent = setup["agent"] || {}
92
+ processes = Array(agent["processes"])
93
+ if agent["reporting"]
94
+ releases = processes.map { |process| process["release"] }.uniq
95
+ release = releases.compact.one? && releases.size == 1 ? ", release #{releases.first}" : ""
96
+ from = processes.any? ? " from #{hosts(processes)}" : ""
97
+ Check.new(status: :ok, message: "Agent reporting#{from}#{release}")
98
+ elsif agent["last_received_at"]
99
+ Check.new(status: :warn, message: "The agent last reported #{time(agent["last_received_at"])}, and nothing since",
100
+ fix: "Check the app is running, and that DEPLOYANGEL_TOKEN is still set on its processes.")
101
+ else
102
+ Check.new(status: :warn, message: "The agent hasn't reported yet",
103
+ fix: "Deploy with DEPLOYANGEL_TOKEN set to the app's agent token (Settings, Tokens: \"Deployed application\").")
104
+ end
105
+ end
106
+
107
+ def metadata_check(setup)
108
+ metadata = setup["metadata"] or return
109
+ jobs = Array(setup["scheduled_jobs"])
110
+ problems = jobs.select { |job| %w[overdue failed].include?(job["status"]) }
111
+ problem_text = problems.any? ? ", #{problems.size} needing attention (#{problems.first(3).map { |job| "#{job["label"]} #{job["status"]}" }.join(", ")})" : ""
112
+ Check.new(status: problems.any? ? :warn : :ok,
113
+ message: "Reported: #{count(metadata["routes"], "route")}, #{count(metadata["job_classes"], "job class", "job classes")}, " \
114
+ "#{count(metadata["schedules"], "scheduled job")}#{problem_text}",
115
+ fix: ("See them on the app page, under Scheduled jobs." if problems.any?))
116
+ end
117
+
118
+ def gap_checks(setup)
119
+ Array(setup["gaps"]).map { |gap| Check.new(status: :warn, message: gap["title"], fix: gap["fix"]) }
120
+ end
121
+
122
+ def progress_check(setup)
123
+ deployments = setup["deployments"] || {}
124
+ latest = deployments["latest"]
125
+ message = if setup["warm_up_ends_at"]
126
+ "Learning what's normal for this app until #{time(setup["warm_up_ends_at"])}; deploys before then are checked, not cleared"
127
+ elsif latest
128
+ "#{count(deployments["count"], "deploy")} so far; the latest, #{latest["label"]}, is #{latest["verdict"] || latest["state"]}"
129
+ elsif setup.dig("agent", "last_received_at")
130
+ "No deploys yet: your next deploy gets the first verdict"
131
+ end
132
+ message && Check.new(status: :info, message: message)
133
+ end
134
+
135
+ # Run where the app runs (`heroku run`, a container shell), the release
136
+ # this process would report. On a developer machine it would only be
137
+ # the checkout's git HEAD, which says nothing about production, so
138
+ # it's left out there.
139
+ def here_check
140
+ release = Release.resolve(config: Configuration.new(@env), env: @env, root: @root)
141
+ if release.unknown?
142
+ return unless where_the_app_runs?
143
+
144
+ Check.new(status: :warn, message: "Here, the agent can't tell which release is running",
145
+ fix: "Set DEPLOYANGEL_REVISION to the deployed commit, or see https://deployangel.com/docs#containers.")
146
+ elsif release.source != "git_head"
147
+ Check.new(status: :ok, message: "Here, the agent would report release #{release.version || release.commit} (from #{release.source})")
148
+ end
149
+ rescue StandardError
150
+ nil
151
+ end
152
+
153
+ def where_the_app_runs?
154
+ !@env["DEPLOYANGEL_TOKEN"].to_s.empty? && @env["DEPLOYANGEL_API_TOKEN"].to_s.empty?
155
+ end
156
+
157
+ # How production will know the release, judged from what's reporting
158
+ # once anything is, and from this checkout's deploy files before then.
159
+ def release_check
160
+ return if Array(setup&.dig("agent", "processes")).any?
161
+
162
+ if file?("config/deploy.yml")
163
+ Check.new(status: :ok, message: "Kamal deploys: the agent reads the release from KAMAL_VERSION")
164
+ elsif file?("render.yaml")
165
+ Check.new(status: :ok, message: "Render deploys: the agent reads the commit from RENDER_GIT_COMMIT")
166
+ elsif read(".do/app.yaml").to_s.include?("DEPLOYANGEL_REVISION")
167
+ Check.new(status: :ok, message: "The DigitalOcean app spec passes the commit in DEPLOYANGEL_REVISION")
168
+ elsif (dockerfile = read("Dockerfile"))
169
+ if dockerfile.include?("DEPLOYANGEL_REVISION")
170
+ Check.new(status: :ok, message: "The Dockerfile passes the commit in DEPLOYANGEL_REVISION")
171
+ else
172
+ # Platforms can pass it another way, so this is advice until the
173
+ # agent reports: then the release it names settles it.
174
+ Check.new(status: :info, message: "The Dockerfile doesn't set DEPLOYANGEL_REVISION; unless your platform passes the commit, " \
175
+ "releases won't be named by commit",
176
+ fix: "Run `deployangel install docker`, then build with --build-arg GIT_SHA=$(git rev-parse HEAD).")
177
+ end
178
+ elsif file?("Capfile")
179
+ Check.new(status: :ok, message: "Capistrano deploys: the agent reads the REVISION file Capistrano writes")
180
+ elsif file?("Procfile") || file?("app.json")
181
+ Check.new(status: :info, message: "On Heroku, turn on dyno metadata so the agent knows the commit",
182
+ fix: "heroku labs:enable runtime-dyno-metadata && heroku labs:enable runtime-dyno-build-metadata")
183
+ end
184
+ end
185
+
186
+ # Schedules in this checkout that the running agent hasn't reported:
187
+ # not deployed yet, or an agent too old to read them.
188
+ def comparison_checks
189
+ reported = setup&.dig("metadata", "schedules") or return []
190
+ return [] unless local_schedules.size > reported
191
+
192
+ [ Check.new(status: :warn, message: "This checkout declares #{count(local_schedules.size, "scheduled job")}; the running agent reported #{reported}",
193
+ fix: "Deploy this code, or update the agent: whenever schedules need deployangel 0.1.22 or later, GoodJob's 0.1.23.") ]
194
+ end
195
+
196
+ def local_schedules
197
+ @local_schedules ||= begin
198
+ config = Configuration.new({})
199
+ metadata = if file?("config/application.rb")
200
+ Rails::Metadata.new(app: nil, config: config, root: @root, environment: "production")
201
+ else
202
+ DeployAngel::Metadata.new(config: config, root: @root, environment: "production")
203
+ end
204
+ metadata.schedules
205
+ rescue StandardError, ScriptError
206
+ []
207
+ end
208
+ end
209
+
210
+ def hosts(processes)
211
+ names = processes.map { |process| process["processes"].to_i > 1 ? "#{process["host"]} ×#{process["processes"]}" : process["host"] }
212
+ total = processes.sum { |process| process["processes"].to_i }
213
+ listed = names.first(MAX_HOSTS).join(", ")
214
+ listed += ", and others" if names.size > MAX_HOSTS
215
+ "#{count(total, "process", "processes")} (#{listed})"
216
+ end
217
+
218
+ def time(iso)
219
+ Time.iso8601(iso).utc.strftime("%b %-d, %H:%M UTC")
220
+ rescue ArgumentError, TypeError
221
+ iso.to_s
222
+ end
223
+
224
+ def count(number, singular, plural = "#{singular}s")
225
+ "#{number.to_i} #{number.to_i == 1 ? singular : plural}"
226
+ end
227
+
228
+ def file?(relative) = File.file?(File.join(@root, relative))
229
+
230
+ def read(relative)
231
+ path = File.join(@root, relative)
232
+ File.read(path) if File.file?(path)
233
+ rescue SystemCallError
234
+ nil
235
+ end
236
+ end
237
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DeployAngel
4
- VERSION = "0.1.23"
4
+ VERSION = "0.1.25"
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.23
4
+ version: 0.1.25
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/doctor.rb
49
50
  - lib/deployangel/exerciser.rb
50
51
  - lib/deployangel/fork_hook.rb
51
52
  - lib/deployangel/mcp.rb