onair-cli 0.3.0 → 0.4.0

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: 1eb5f2eb79c1eb2da95ef69cb65f8bef60b72dc3d98563a6235573b4ae139fd6
4
- data.tar.gz: f5a38c5ef53340a88e41c9c007e68c34d1035d0427b953ac7b4193421201dde7
3
+ metadata.gz: e6929334a2f930a5e7e05e43067c8442ae9b85d3dc9967c960c4ff9c0e34ebc7
4
+ data.tar.gz: 4f029777e9e3ee5aabc3977ad930ba2cf35d82a4c5e9c76a0995197d250ae389
5
5
  SHA512:
6
- metadata.gz: 133e4480b8e8c22835294d0c2b18e79f9ee6ba065ee080c7c5f0e0c61a1a8bf92aecc6ee2e6092bce8ba095ffe7937ce8b6cb6526ca20938ae3d3d54dc39062e
7
- data.tar.gz: b4e08f469025a62273c6c848b4d790e8b9a1520a6ce6ab24f0b845bcd7453cfa2040b1d40b3d0a29fe73c13e9c3d4242779022b0b2c43ba11c968f0e5ae74be5
6
+ metadata.gz: c85de1face07ebb3c04cadc6001ac5e92edff4b3632f87f1f79158f212c3063761ee6efdfdf69709bfe09251b0d0c5271dec5aaa38291b84b3cc523f705d5f3e
7
+ data.tar.gz: 3325a141acd7f90491a2b9c547097acbd68b1eba14d72462d518e61e564923a6c5e332263eab7403f99a751bb7278acf47a0d98d2f010492267796ad797e0538
data/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.4.0] - 2026-11-02
6
+
7
+ - Dokku: `platform: dokku` with a `host:` reads the running commit from the
8
+ last `git push` over SSH. A running build shows as `Pending` and a build
9
+ that failed after the last success as `Failed`, both without a commit. The
10
+ rollout line counts containers per process type, and the window in which
11
+ the previous containers keep serving shows as an estimate.
12
+ - Rows drop `v<version>` when the platform numbers no releases. `--json`
13
+ carries `null` versions and shas there, and `pending` gains `build_id`.
14
+
5
15
  ## [0.3.0] - 2026-10-08
6
16
 
7
17
  - Heroku: a rollout line under Deployed counts the dynos of each process type
data/README.md CHANGED
@@ -42,8 +42,9 @@ case.
42
42
  gem install onair-cli
43
43
  ```
44
44
 
45
- Requires Ruby >= 3.2. Auth reuses the Heroku CLI's token from `~/.netrc`
46
- (written by `heroku login`), falling back to `heroku auth:token`.
45
+ Requires Ruby >= 3.2. On Heroku, auth reuses the Heroku CLI's token from
46
+ `~/.netrc` (written by `heroku login`), falling back to `heroku auth:token`.
47
+ On Dokku, it reuses your SSH setup (see [Dokku](#dokku)).
47
48
 
48
49
  ## Usage
49
50
 
@@ -76,7 +77,8 @@ the API is a faster transport, never a cache.
76
77
  run that release: `⟳ rolling out v1234: web 1/3, 2 starting` while dynos
77
78
  restart, `✓ rolled out` once every dyno is up on it. With preboot on, the
78
79
  previous web dynos keep serving for about three minutes after the new ones
79
- are up; that window shows as an estimate.
80
+ are up; that window shows as an estimate. On Dokku it counts containers,
81
+ and the estimate is the app's wait-to-retire after the newest build.
80
82
  - **Pending** - an in-flight build that's about to replace the deploy.
81
83
  - **Releasing** - a newer release whose release phase (e.g. migrations) is
82
84
  still running; production still serves the Deployed row until it finishes.
@@ -99,7 +101,27 @@ branch: main # comparison branch; default: main
99
101
  ```
100
102
 
101
103
  Resolution order: CLI flags → env vars (`HEROKU_APP`, `GITHUB_REPO`) →
102
- `.onair.yml`.
104
+ `.onair.yml`. `HEROKU_APP` applies only with `platform: heroku`.
105
+
106
+ ### Dokku
107
+
108
+ ```yaml
109
+ platform: dokku
110
+ host: my-dokku # any SSH destination: dokku@host, or a ~/.ssh/config alias
111
+ app: myapp
112
+ ```
113
+
114
+ onair runs `ssh -o BatchMode=yes <host> <command> <app> --format json` for
115
+ Dokku's report commands, so it needs Dokku 0.38 or newer and an `ssh` client.
116
+ The host must log in as Dokku's `dokku` user, directly or through an alias
117
+ with `User dokku`. Keys, addresses and users come from your SSH config;
118
+ `BatchMode` never prompts, so the key must be loadable without a passphrase
119
+ prompt (`ssh-add`).
120
+
121
+ The deployed commit is the one the last `git push` deployed. An app deployed
122
+ another way (`git:from-image`, `git:sync`) shows the deploy source instead
123
+ of a commit. Dokku builds record no commit, so `Pending` and `Failed` name
124
+ the build, and pinned detection stays silent.
103
125
 
104
126
  ### Task links (optional)
105
127
 
@@ -149,8 +171,11 @@ additively:
149
171
  ```
150
172
 
151
173
  - `delta.status` is `"current"`, `"behind"`, or `"unknown"`.
152
- - `pending`: `{ "sha", "started_at", "subject", "author" }` when a build is
153
- in flight.
174
+ - `pending`: `{ "sha", "build_id", "started_at", "subject", "author" }` when
175
+ a build is in flight. On Dokku `sha` is `null` and `build_id` names the
176
+ build; on Heroku `build_id` is `null`.
177
+ - `version` is `null` on platforms that number no releases (Dokku), and a
178
+ Dokku `release` is a failed build with a `null` sha.
154
179
  - `release`: `{ "sha", "version", "description", "status", "started_at",
155
180
  "subject", "author" }` when a release newer than the running one is not
156
181
  live; `status` is `"pending"` (release phase running) or `"failed"`. While
data/lib/onair/config.rb CHANGED
@@ -8,10 +8,13 @@ module Onair
8
8
  class Config
9
9
  FILENAME = ".onair.yml"
10
10
 
11
- attr_reader :platform, :app, :repo, :branch, :fetch, :task
11
+ attr_reader :platform, :app, :repo, :branch, :fetch, :task, :host
12
12
 
13
- def initialize(platform:, app:, repo:, branch:, fetch:, task: nil)
13
+ # `host` is the SSH destination for SSH-driven platforms (Dokku); nil
14
+ # elsewhere, and only the adapter that needs it checks it.
15
+ def initialize(platform:, app:, repo:, branch:, fetch:, task: nil, host: nil)
14
16
  @platform = platform
17
+ @host = host
15
18
  @app = app
16
19
  @repo = repo
17
20
  @branch = branch
@@ -21,16 +24,19 @@ module Onair
21
24
 
22
25
  def self.resolve(flags = {}, env: ENV, dir: Dir.pwd)
23
26
  file = find_file(dir) || {}
24
- app = flags[:app] || env["HEROKU_APP"] || file["app"]
27
+ platform = file["platform"] || "heroku"
28
+ # HEROKU_APP names a Heroku app, so it must not redirect another platform.
29
+ app = flags[:app] || ((platform == "heroku") ? env["HEROKU_APP"] : nil) || file["app"]
25
30
  raise Error, "no app configured - pass --app NAME, set HEROKU_APP, or run `onair init`" if app.nil?
26
31
 
27
32
  new(
28
- platform: file["platform"] || "heroku",
33
+ platform: platform,
29
34
  app: app,
30
35
  repo: env["GITHUB_REPO"] || file["repo"],
31
36
  branch: file["branch"] || "main",
32
37
  fetch: !flags[:no_fetch],
33
- task: TaskLink.from_config(file["task"])
38
+ task: TaskLink.from_config(file["task"]),
39
+ host: file["host"]
34
40
  )
35
41
  end
36
42
 
@@ -33,6 +33,9 @@ module Onair
33
33
  # release, plus an optional estimate of when the
34
34
  # previous release stops serving; nil when unknown
35
35
  #
36
+ # Versions may be nil on every value, and pending/release shas may be nil
37
+ # when the platform's builds carry no commit; renderers drop what is absent.
38
+ #
36
39
  # Adapters own their internal concurrency and auth. Everything above
37
40
  # (delta, pinned, mine, rendering) is platform-agnostic and must not
38
41
  # reference any specific platform.
@@ -48,6 +51,17 @@ module Onair
48
51
  def display_name
49
52
  self.class.name.split("::").last
50
53
  end
54
+
55
+ private
56
+
57
+ # Exceptions surface via #value; without this the dying thread also
58
+ # prints its own backtrace to stderr.
59
+ def quiet_thread(&block)
60
+ Thread.new do
61
+ Thread.current.report_on_exception = false
62
+ block.call
63
+ end
64
+ end
51
65
  end
52
66
  end
53
67
  end
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "json"
5
+ require "time"
6
+
7
+ module Onair
8
+ module Platform
9
+ # Dokku has no HTTP API, so every read is `ssh <host> <subcommand> <app>
10
+ # --format json`, run in parallel. Keys, users and addresses come from the
11
+ # user's SSH setup; the adapter only knows the destination.
12
+ class Dokku < Base
13
+ GIT_PUSH = "git-push"
14
+ FULL_SHA = /\A\h{40}\z/
15
+ # Dokku app names: lowercase letters, digits and dashes.
16
+ APP_NAME = /\A[a-z0-9][a-z0-9-]*\z/
17
+ # ps:report's JSON lists one `status-<process>.<n>` entry per container
18
+ # (named after its CONTAINER.<process>.<n> file), valued like
19
+ # "running (CID: 0123456789a)". The text output's dash form also matches.
20
+ CONTAINER_STATUS = /\Astatus-(?<type>.+)[.-]\d+\z/
21
+ SERVING_STATE = "running"
22
+ # After a deploy, Dokku keeps the previous containers serving for the
23
+ # app's wait-to-retire, 60 seconds unless configured.
24
+ DEFAULT_WAIT_TO_RETIRE = 60
25
+ RETIRE_NOTE = "retiring: previous containers may still serve"
26
+ # Wall-clock limit per command, matching the Heroku adapter's read timeout.
27
+ COMMAND_TIMEOUT = 15
28
+ SSH_OPTIONS = ["-o", "BatchMode=yes", "-o", "ConnectTimeout=5",
29
+ "-o", "ServerAliveInterval=5", "-o", "ServerAliveCountMax=3"].freeze
30
+
31
+ def snapshot
32
+ validate!
33
+ apps_thread = quiet_thread { dokku("apps:report") }
34
+ git_thread = quiet_thread { optional { dokku("git:report") } }
35
+ builds_thread = quiet_thread { optional { dokku("builds:list") } }
36
+ ps_thread = quiet_thread { optional { dokku("ps:report") } }
37
+ checks_thread = quiet_thread { optional { dokku("checks:report") } }
38
+
39
+ deployed = deployed(apps_thread.value, git_thread.value)
40
+ builds = Array(builds_thread.value).grep(Hash)
41
+ Snapshot.new(deployed: deployed, pending: pending(builds), release: failed(builds),
42
+ latest_built_sha: nil, succeeded_shas: [],
43
+ rollout: rollout(ps_thread.value, newest(builds, "succeeded"), checks_thread.value))
44
+ end
45
+
46
+ private
47
+
48
+ def host
49
+ @config.host
50
+ end
51
+
52
+ def app
53
+ @config.app
54
+ end
55
+
56
+ # The host and app go on the ssh command line, so a value starting with
57
+ # a dash would be read as an ssh option.
58
+ def validate!
59
+ raise Error, "no host configured - add `host:` to #{Config::FILENAME}" if host.to_s.empty?
60
+ raise Error, "invalid Dokku host #{host.inspect}" if host.start_with?("-")
61
+ raise Error, "invalid Dokku app name #{app.inspect}" unless APP_NAME.match?(app)
62
+ end
63
+
64
+ # Dokku strips the plugin prefix from report JSON keys, so git:report's
65
+ # `--git-last-updated-at` arrives as `last-updated-at`.
66
+ # Only a git push records the pushed commit. git:report's sha is not
67
+ # used: it can read the repo's HEAD rather than what was deployed.
68
+ def deployed(apps, git)
69
+ source = apps["deploy-source"].to_s
70
+ raise Error, "nothing deployed yet for app #{app}" if source.empty?
71
+
72
+ metadata = apps["deploy-source-metadata"].to_s
73
+ sha = (source == GIT_PUSH && FULL_SHA.match?(metadata)) ? metadata : nil
74
+ Deployed.new(sha: sha, version: nil, description: "deploy source: #{source}",
75
+ deployed_at: unix_time(git&.dig("last-updated-at")))
76
+ end
77
+
78
+ # Builds carry no commit, so the sha stays unknown until the build is
79
+ # correlated with the CI run that pushed it.
80
+ def pending(builds)
81
+ running = builds.find { |build| status(build) == "running" }
82
+ running && Pending.new(sha: nil, build_id: running["id"], started_at: parse_time(running["started_at"]))
83
+ end
84
+
85
+ # Only a failure newer than the last success matters; older failed
86
+ # records were superseded and are stale.
87
+ def failed(builds)
88
+ failure = newest(builds, "failed")
89
+ success = newest(builds, "succeeded")
90
+ return nil if failure.nil? || (success && started(success) >= started(failure))
91
+
92
+ Release.new(sha: nil, version: nil, description: "build #{failure["id"]}", status: :failed,
93
+ started_at: parse_time(failure["finished_at"]) || started(failure))
94
+ end
95
+
96
+ def newest(builds, wanted)
97
+ builds.select { |build| status(build) == wanted && started(build) }.max_by { |build| started(build) }
98
+ end
99
+
100
+ # display_status turns a running record whose process died into
101
+ # "abandoned"; older Dokku versions only report status.
102
+ def status(build)
103
+ build["display_status"] || build["status"]
104
+ end
105
+
106
+ def started(build)
107
+ parse_time(build["started_at"])
108
+ end
109
+
110
+ def rollout(ps, success, checks)
111
+ return nil unless ps.is_a?(Hash)
112
+
113
+ containers = ps.filter_map do |key, value|
114
+ match = CONTAINER_STATUS.match(key)
115
+ match && [match[:type], value.to_s.split(" ", 2).first.to_s]
116
+ end
117
+ return nil if containers.empty?
118
+
119
+ processes = containers.group_by(&:first)
120
+ .sort_by { |type, _| [(type == "web") ? 0 : 1, type] }
121
+ .map { |type, rows| process_rollout(type, rows.map(&:last)) }
122
+ Rollout.new(version: nil, processes: processes, overlap_note: RETIRE_NOTE,
123
+ overlap_until: retire_deadline(processes, success, checks))
124
+ end
125
+
126
+ # ps:report lists only the current deploy's containers, so nothing is
127
+ # ever counted as previous.
128
+ def process_rollout(type, states)
129
+ waiting = states.reject { |state| state == SERVING_STATE }.tally
130
+ ProcessRollout.new(type: type, total: states.size, up: states.size - waiting.values.sum,
131
+ waiting: waiting, previous: 0)
132
+ end
133
+
134
+ def retire_deadline(processes, success, checks)
135
+ finished = success && parse_time(success["finished_at"])
136
+ return nil if finished.nil? || !processes.all?(&:complete?)
137
+
138
+ finished + wait_to_retire(checks)
139
+ end
140
+
141
+ def wait_to_retire(checks)
142
+ Integer(checks&.dig("computed-wait-to-retire"), 10)
143
+ rescue ArgumentError, TypeError
144
+ DEFAULT_WAIT_TO_RETIRE
145
+ end
146
+
147
+ def optional
148
+ yield
149
+ rescue Error
150
+ nil
151
+ end
152
+
153
+ # Dokku prints warnings on stderr (e.g. "Skipping unreadable build
154
+ # record"), so only stdout is parsed.
155
+ def dokku(subcommand)
156
+ stdout, stderr, status = run(["ssh", *SSH_OPTIONS, host, subcommand, app, "--format", "json"])
157
+ raise Error, "dokku #{subcommand} timed out on #{host}" if status.nil?
158
+ raise Error, ssh_error(subcommand, stderr, status) unless status.success?
159
+
160
+ JSON.parse(stdout)
161
+ rescue Errno::ENOENT
162
+ raise Error, "ssh not found - the Dokku platform needs an ssh client on PATH"
163
+ rescue JSON::ParserError
164
+ raise Error, "Dokku returned invalid JSON for #{subcommand}"
165
+ end
166
+
167
+ # Returns [stdout, stderr, status], with a nil status when the command
168
+ # outlived the timeout and was killed.
169
+ def run(argv, timeout: COMMAND_TIMEOUT)
170
+ Open3.popen3(*argv) do |stdin, stdout, stderr, wait|
171
+ stdin.close
172
+ out = Thread.new { stdout.read }
173
+ err = Thread.new { stderr.read }
174
+ unless wait.join(timeout)
175
+ begin
176
+ Process.kill("KILL", wait.pid)
177
+ rescue Errno::ESRCH
178
+ nil
179
+ end
180
+ return [out.value, err.value, nil]
181
+ end
182
+ [out.value, err.value, wait.value]
183
+ end
184
+ end
185
+
186
+ # ssh itself exits 255; anything else is Dokku's own exit code.
187
+ def ssh_error(subcommand, stderr, status)
188
+ line = stderr.lines.map { |text| text.sub(/\A\s*!\s*/, "").strip }.reject(&:empty?).last
189
+ if status.exitstatus == 255 && stderr.include?("Permission denied")
190
+ "SSH to #{host} was refused - load the key with `ssh-add`"
191
+ elsif status.exitstatus == 255
192
+ "could not reach #{host} over SSH#{line && ": #{line}"}"
193
+ else
194
+ "dokku #{subcommand} failed on #{host}#{line && ": #{line}"}"
195
+ end
196
+ end
197
+
198
+ def unix_time(value)
199
+ Time.at(Integer(value, 10)).utc
200
+ rescue ArgumentError, TypeError
201
+ nil
202
+ end
203
+
204
+ def parse_time(value)
205
+ value && Time.iso8601(value)
206
+ rescue ArgumentError
207
+ nil
208
+ end
209
+
210
+ Platform.register("dokku", self)
211
+ end
212
+ end
213
+ end
@@ -16,6 +16,7 @@ module Onair
16
16
  # With preboot, Heroku keeps routing to the previous web dynos for about
17
17
  # three minutes after the new ones come up.
18
18
  PREBOOT_HANDOFF = 180
19
+ PREBOOT_NOTE = "preboot: previous web dynos may still serve"
19
20
 
20
21
  def snapshot
21
22
  token = resolve_token
@@ -36,13 +37,6 @@ module Onair
36
37
  @config.app
37
38
  end
38
39
 
39
- def quiet_thread(&block)
40
- Thread.new do
41
- Thread.current.report_on_exception = false
42
- block.call
43
- end
44
- end
45
-
46
40
  def resolve_token
47
41
  token = Auth::Netrc.token(HOST)
48
42
  token = Auth::HerokuCli.token if token.nil? || token.empty?
@@ -138,7 +132,7 @@ module Onair
138
132
  .sort_by { |type, _| [(type == "web") ? 0 : 1, type] }
139
133
  .map { |type, dynos| process_rollout(type, dynos, version) }
140
134
  Rollout.new(version: version, processes: processes,
141
- overlap_until: preboot ? preboot_handoff(formation, version) : nil)
135
+ overlap_until: preboot ? preboot_handoff(formation, version) : nil, overlap_note: PREBOOT_NOTE)
142
136
  end
143
137
 
144
138
  def process_rollout(type, dynos, version)
@@ -60,7 +60,8 @@ module Onair
60
60
  pending = snapshot.pending
61
61
  return nil if pending.nil?
62
62
 
63
- {sha: pending.sha, started_at: iso(pending.started_at)}.merge(commit_fields(pending.sha))
63
+ {sha: pending.sha, build_id: pending.build_id, started_at: iso(pending.started_at)}
64
+ .merge(commit_fields(pending.sha))
64
65
  end
65
66
 
66
67
  def release_payload
@@ -49,6 +49,12 @@ module Onair
49
49
  pending = snapshot.pending
50
50
  return [] if pending.nil?
51
51
 
52
+ if pending.sha.nil?
53
+ started = pending.started_at && "started #{age(pending.started_at)}"
54
+ return commitless_row("Pending:", :yellow, "build #{pending.build_id}", started,
55
+ paint("⟳ deploying", :yellow)) + [""]
56
+ end
57
+
52
58
  row_lines("Pending:", :yellow, pending.sha, age(pending.started_at)) + [""]
53
59
  end
54
60
 
@@ -57,9 +63,10 @@ module Onair
57
63
  def release_lines
58
64
  release = snapshot.release
59
65
  return [] if release.nil?
66
+ return build_failed_lines(release) if release.sha.nil?
60
67
 
61
- detail = "v#{release.version}"
62
- detail = "#{detail}: #{release.description}" if release.sha == snapshot.deployed&.sha
68
+ detail = version_label(release.version)
69
+ detail = [detail, release.description].compact.join(": ") if release.sha == snapshot.deployed&.sha
63
70
  label, color, note = if release.status == :failed
64
71
  ["Failed:", :red, "✗ release phase failed (#{detail})"]
65
72
  else
@@ -68,6 +75,12 @@ module Onair
68
75
  row_lines(label, color, release.sha, age(release.started_at), extra: paint(note, color)) + [""]
69
76
  end
70
77
 
78
+ # A failed build whose commit the platform does not record.
79
+ def build_failed_lines(release)
80
+ commitless_row("Failed:", :red, release.description, age(release.started_at),
81
+ paint("✗ build failed", :red)) + [""]
82
+ end
83
+
71
84
  def deployed_lines
72
85
  deployed = snapshot.deployed
73
86
  if deployed&.sha
@@ -77,8 +90,8 @@ module Onair
77
90
  lines.concat(yours_lines)
78
91
  lines
79
92
  else
80
- [" #{paint("Could not resolve the running commit for v#{deployed&.version} " \
81
- "(#{deployed&.description}).", :yellow)}"]
93
+ subject = ["the running commit", version_label(deployed&.version)].compact.join(" for ")
94
+ [" #{paint("Could not resolve #{subject} (#{deployed&.description}).", :yellow)}"]
82
95
  end
83
96
  end
84
97
 
@@ -86,13 +99,14 @@ module Onair
86
99
  # traffic to the previous release, so that window gets its own line.
87
100
  def rollout_line
88
101
  rollout = @report.rollout
102
+ target = [version_label(rollout.version)].compact.map { |label| " #{label}" }.join
89
103
  text, color = if rollout.complete?
90
- ["✓ rolled out v#{rollout.version}: #{process_counts(rollout)}", :dim]
104
+ ["✓ rolled out#{target}: #{process_counts(rollout)}", :dim]
91
105
  elsif rollout.processes.all?(&:complete?)
92
- ["⟳ preboot: previous web dynos may still serve for " \
106
+ ["⟳ #{rollout.overlap_note || "previous release may still serve"} for " \
93
107
  "~#{remaining(rollout.overlap_until)} (estimate)", :yellow]
94
108
  else
95
- ["⟳ rolling out v#{rollout.version}: #{process_counts(rollout)}", :yellow]
109
+ ["⟳ rolling out#{target}: #{process_counts(rollout)}", :yellow]
96
110
  end
97
111
  " #{paint(text, color)}"
98
112
  end
@@ -131,6 +145,17 @@ module Onair
131
145
  [first, " #{paint("→", :dim)} #{linkify(subject, info ? sha : nil)}"]
132
146
  end
133
147
 
148
+ # A row for a build with no commit: its name stands where the sha would,
149
+ # and there is no subject line to show.
150
+ def commitless_row(label, color_key, name, age, extra)
151
+ age_blurb = age ? " #{paint("(#{age})", :dim)}" : ""
152
+ [" #{paint(label.ljust(LABEL_WIDTH), color_key)} #{paint(name, :bold)}#{age_blurb} #{extra}"]
153
+ end
154
+
155
+ def version_label(version)
156
+ version && "v#{version}"
157
+ end
158
+
134
159
  def delta_text
135
160
  case @report.delta
136
161
  when :current
@@ -143,7 +168,8 @@ module Onair
143
168
 
144
169
  def pinned_line
145
170
  deployed = snapshot.deployed
146
- " #{paint("⏸ v#{deployed.version} (#{deployed.description}) - newer build " \
171
+ running = [version_label(deployed.version), "(#{deployed.description})"].compact.join(" ")
172
+ " #{paint("⏸ #{running} - newer build " \
147
173
  "#{snapshot.latest_built_sha[0, 9]} succeeded but is not running", :yellow)}"
148
174
  end
149
175
 
data/lib/onair/report.rb CHANGED
@@ -73,8 +73,11 @@ module Onair
73
73
  !sha.nil? && !latest.nil? && latest != sha && snapshot.pending.nil? && !release_of_other_commit?(snapshot)
74
74
  end
75
75
 
76
+ # A release with no known commit (a failed build on a platform whose
77
+ # builds carry none) says nothing about commits, so it never counts.
76
78
  def self.release_of_other_commit?(snapshot)
77
- !snapshot.release.nil? && snapshot.release.sha != snapshot.deployed&.sha
79
+ sha = snapshot.release&.sha
80
+ !sha.nil? && sha != snapshot.deployed&.sha
78
81
  end
79
82
 
80
83
  # "Did my merge just ship?" — deliberately a 2-commit first-parent window
data/lib/onair/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Onair
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/onair.rb CHANGED
@@ -9,13 +9,19 @@ module Onair
9
9
  CommitInfo = Data.define(:subject, :author_name, :author_email, :committed_at)
10
10
 
11
11
  # The release currently running in production. `sha` may be nil when the
12
- # platform can't resolve the running commit; version/description still render.
12
+ # platform can't resolve the running commit; `version` is nil on platforms
13
+ # that number nothing.
13
14
  Deployed = Data.define(:sha, :version, :description, :deployed_at)
14
15
 
15
- Pending = Data.define(:sha, :started_at)
16
+ # An in-flight build. `sha` is nil when the platform's builds carry no
17
+ # commit; `build_id` then names the row.
18
+ Pending = Data.define(:sha, :build_id, :started_at) do
19
+ def initialize(sha:, started_at:, build_id: nil) = super
20
+ end
16
21
 
17
22
  # A release newer than the running one that is not serving traffic: its
18
23
  # release phase is still running (status :pending) or it failed (:failed).
24
+ # A nil `sha` is a failed build whose commit the platform does not record.
19
25
  Release = Data.define(:sha, :version, :description, :status, :started_at)
20
26
 
21
27
  # Dynos of one process type against the running release. `up` counts dynos
@@ -28,8 +34,11 @@ module Onair
28
34
  end
29
35
 
30
36
  # How far the running release has reached its dynos. `overlap_until` is an
31
- # estimate of when the previous release stops serving traffic, or nil.
32
- Rollout = Data.define(:version, :processes, :overlap_until) do
37
+ # estimate of when the previous release stops serving traffic, or nil;
38
+ # `overlap_note` is the platform's phrase for that window.
39
+ Rollout = Data.define(:version, :processes, :overlap_until, :overlap_note) do
40
+ def initialize(version:, processes:, overlap_until:, overlap_note: nil) = super
41
+
33
42
  def complete?
34
43
  processes.all?(&:complete?) && overlap_until.nil?
35
44
  end
@@ -54,6 +63,7 @@ require_relative "onair/auth/github_token"
54
63
  require_relative "onair/remote_head"
55
64
  require_relative "onair/platform/base"
56
65
  require_relative "onair/platform/heroku"
66
+ require_relative "onair/platform/dokku"
57
67
  require_relative "onair/renderer/tty"
58
68
  require_relative "onair/renderer/json"
59
69
  require_relative "onair/cli"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: onair-cli
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eugene
@@ -31,6 +31,7 @@ files:
31
31
  - lib/onair/git.rb
32
32
  - lib/onair/orchestrator.rb
33
33
  - lib/onair/platform/base.rb
34
+ - lib/onair/platform/dokku.rb
34
35
  - lib/onair/platform/heroku.rb
35
36
  - lib/onair/remote_head.rb
36
37
  - lib/onair/renderer/json.rb