deployangel 0.1.22 → 0.1.24

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: edccb6c72eff7d259c14c59deaa403fb78c890cb091b40d59b080623945e1e9f
4
- data.tar.gz: 364fd681c6199d591292fc67799cb6f7933da99736113ef382ff4ccf628697f8
3
+ metadata.gz: 2304a80caf17ea6df84a2487145d51438244f15e38528e817fb93ed45a07dff0
4
+ data.tar.gz: b28c3f544144e1b4a8cd9417fbdb4407bd814d814fe88522073e4d463d25f11a
5
5
  SHA512:
6
- metadata.gz: 0ec79d7a8b20600e3fc57604b990749ff578a6b39bdd2caf945c8f60bf5f39ed74f3991659f8d3e26c851e8a7509cc4dabca339f36f35aaa79aca33648ba4c9d
7
- data.tar.gz: 37db580510184b407f7941e6c27ad00eb956fab8df6fb59a68406753c14813bca7007de9da53ca7cf9a858f456d40f2a5da2fdd872c44bdd33997b052132ba2c
6
+ metadata.gz: ba778f41f2922f597a225d2bbdefc58ffce09443ec7f4c342f8df2729103e36a49115bcd76502222a95717eace53b4c737cc836fccff3a865bb52e835501fc0a
7
+ data.tar.gz: 98abb8b69f7cf9e61980a68befcff22dee0ede110019a2b6e398a92efaef155e2c700ce3eaf426fb9f3346fcbe0ce320b7bd3ef78979c0459a91bc4a6f0cd263
data/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.24 (2026-10-10)
4
+
5
+ - `deployangel doctor` checks the setup: the agent in the bundle, the
6
+ schedules in this checkout's files, and how production will know the
7
+ release; then, with a token, what DeployAngel has seen (processes
8
+ reporting and their releases, what the agent reported, and setup
9
+ problems with fixes). Only errors fail it. Run where the app runs, it
10
+ uses the agent's `DEPLOYANGEL_TOKEN` and shows the release that process
11
+ reports. Needs a DeployAngel server with `GET /api/v1/setup`.
12
+
13
+ ## 0.1.23 (2026-10-09)
14
+
15
+ - GoodJob's cron (`config.good_job.cron`, or `GOOD_JOB_CRON`) is read from
16
+ the processes that run it, so its jobs are expected on schedule. Entries
17
+ whose schedule or class is a lambda, or that are off by default, are
18
+ left out; schedules without a zone are read in the server's.
19
+
3
20
  ## 0.1.22 (2026-10-09)
4
21
 
5
22
  - Work scheduled outside the app's own scheduler is watched too.
data/README.md CHANGED
@@ -98,7 +98,8 @@ the dashboard says so.
98
98
  as it is. If your app's messages might hold personal or health data, [turn
99
99
  messages off](#exception-messages).
100
100
  - Once per process: the route table, job classes, recurring schedules
101
- declared for Solid Queue, sidekiq-cron, or sidekiq-scheduler, critical
101
+ declared for Solid Queue, GoodJob, sidekiq-cron, sidekiq-scheduler, or
102
+ whenever, critical
102
103
  flows, and file digests (relative paths and hashes, never file contents) so
103
104
  DeployAngel can tell which routes changed in a release.
104
105
  Disable digests with `DEPLOYANGEL_FILE_DIGESTS=false`.
@@ -210,6 +211,13 @@ end
210
211
  Jobs that exist only in Redis, such as ones created in code or in the
211
212
  Sidekiq web UI, aren't read.
212
213
 
214
+ GoodJob's cron (`config.good_job.cron`, or `GOOD_JOB_CRON`) is read from the
215
+ processes that run it, the ones with cron enabled. Schedules without a zone
216
+ are read in the server's, as GoodJob reads them. An entry whose `cron:` or
217
+ `class:` is a lambda, or that's `enabled_by_default: false`, isn't expected.
218
+ The agent doesn't see entries turned off in GoodJob's dashboard, so one you
219
+ switch off there will look missed after the next deploy.
220
+
213
221
  #### Cron, Heroku Scheduler, and whenever
214
222
 
215
223
  Work that something outside the app starts on a schedule, such as cron,
@@ -433,8 +441,20 @@ bundle exec deployangel exercise --url=https://example.com # send the plan's re
433
441
  bundle exec deployangel release --commit=$SHA # register a deploy (manual or CI)
434
442
  bundle exec deployangel check --name="smoke: signup" --status=pass --covers=registration
435
443
  bundle exec deployangel exception <fingerprint>
444
+ bundle exec deployangel doctor # check the setup
436
445
  ```
437
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
+
438
458
  Output is text on a terminal and JSON (the verdict document) when piped, or
439
459
  choose with `--format=text|json`. Exit codes:
440
460
 
@@ -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.
@@ -148,6 +151,7 @@ module DeployAngel
148
151
  when "exception" then exception
149
152
  when "check" then check
150
153
  when "install" then install
154
+ when "doctor" then doctor
151
155
  when "mcp" then mcp
152
156
  when "version", "--version", "-v" then @stdout.puts(DeployAngel::VERSION) || 0
153
157
  when nil, "help", "--help", "-h" then @stdout.puts(HELP) || 0
@@ -323,6 +327,36 @@ module DeployAngel
323
327
  0
324
328
  end
325
329
 
330
+ # What this checkout will report and what DeployAngel has seen of the
331
+ # app. Warnings are advice, so only errors (a rejected token, an
332
+ # unreachable server, no agent in the bundle) fail it.
333
+ def doctor
334
+ format = parse(format: nil) { |o, opts| o.on("--format=FORMAT", %w[text json]) { |v| opts[:format] = v } }[:format]
335
+ doctor = Doctor.new(root: @root, client: doctor_client, env: @env)
336
+ output(doctor.to_h, format) { doctor_text(doctor.checks) }
337
+ doctor.failed? ? USAGE_ERROR : 0
338
+ end
339
+
340
+ DOCTOR_MARKS = { ok: "✓", warn: "!", error: "✗", info: "·" }.freeze
341
+
342
+ def doctor_text(checks)
343
+ lines = [ "DeployAngel doctor" ]
344
+ checks.each do |check|
345
+ lines << " #{DOCTOR_MARKS.fetch(check.status)} #{check.message}"
346
+ lines << " #{check.fix}" if check.fix
347
+ end
348
+ lines.join("\n")
349
+ end
350
+
351
+ # Either token works: the setup endpoint answers any of the app's. The
352
+ # agent's own token is for running it where the app runs.
353
+ def doctor_client
354
+ return @client if @client
355
+
356
+ token = [ @env["DEPLOYANGEL_API_TOKEN"], @env["DEPLOYANGEL_TOKEN"] ].find { |value| !value.to_s.empty? } or return
357
+ Client.new(token: token, endpoint: @env["DEPLOYANGEL_URL"] || Configuration::DEFAULT_ENDPOINT)
358
+ end
359
+
326
360
  def install
327
361
  case (target = @argv.shift)
328
362
  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
@@ -3,7 +3,7 @@
3
3
  module DeployAngel
4
4
  module Rails
5
5
  # The Rails adapter: the route table from the router, job classes from
6
- # Active Job, and Solid Queue's recurring tasks. The schedule files
6
+ # Active Job, and Solid Queue's and GoodJob's recurring tasks. The schedule files
7
7
  # sidekiq-cron and sidekiq-scheduler read, and the file digests, are
8
8
  # gathered in DeployAngel::Metadata.
9
9
  class Metadata < ::DeployAngel::Metadata
@@ -44,10 +44,10 @@ module DeployAngel
44
44
  []
45
45
  end
46
46
 
47
- # Solid Queue's schedule is Rails' own; the rest are read the same way
48
- # on every framework.
47
+ # Solid Queue's and GoodJob's schedules live in the Rails app; the rest
48
+ # are read the same way on every framework.
49
49
  def schedules
50
- solid_queue_schedules + super
50
+ solid_queue_schedules + good_job_schedules + super
51
51
  end
52
52
 
53
53
  # Solid Queue recurring tasks for the current environment.
@@ -69,6 +69,33 @@ module DeployAngel
69
69
  []
70
70
  end
71
71
 
72
+ # GoodJob's cron, from a process that runs it. GoodJob turns cron on
73
+ # per process (often only the worker's, with GOOD_JOB_ENABLE_CRON), and
74
+ # its CLI applies --enable-cron after boot, before metadata is first
75
+ # sent. A schedule without a zone is read in the server's, as Fugit
76
+ # reads it. A schedule or class given as a lambda can't be known
77
+ # without running app code, so it's left out.
78
+ def good_job_schedules
79
+ return [] unless defined?(::GoodJob) && ::GoodJob.respond_to?(:configuration)
80
+
81
+ configuration = ::GoodJob.configuration
82
+ return [] unless configuration.enable_cron?
83
+
84
+ configuration.cron.filter_map do |key, entry|
85
+ next unless entry.is_a?(Hash)
86
+
87
+ entry = entry.transform_keys(&:to_sym)
88
+ cron, job_class = entry.values_at(:cron, :class)
89
+ job_class = job_class.name if job_class.is_a?(Module)
90
+ next if entry[:enabled_by_default] == false
91
+ next unless cron.is_a?(String) && !cron.strip.empty? && job_class.is_a?(String) && !job_class.empty?
92
+
93
+ { "key" => key.to_s, "class" => job_class, "schedule" => cron, "source" => "good_job", "time_zone" => local_time_zone }
94
+ end
95
+ rescue StandardError
96
+ []
97
+ end
98
+
72
99
  # A command task runs as a job of this class, so its runs show up in
73
100
  # job telemetry under that name.
74
101
  def command_job_class
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DeployAngel
4
- VERSION = "0.1.22"
4
+ VERSION = "0.1.24"
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.22
4
+ version: 0.1.24
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