onair-cli 0.1.0 → 0.3.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: e4350eed5352315ed4c6591c3ae03bdfb6648986d844a749051fed27506427b9
4
- data.tar.gz: 9193566d1ea45893c68ff3d10340d80b6c1845a9e5f71eb21ece94df935e4a22
3
+ metadata.gz: 1eb5f2eb79c1eb2da95ef69cb65f8bef60b72dc3d98563a6235573b4ae139fd6
4
+ data.tar.gz: f5a38c5ef53340a88e41c9c007e68c34d1035d0427b953ac7b4193421201dde7
5
5
  SHA512:
6
- metadata.gz: a72e23588254686e5154cf43fc8253a67fd16b524f67e1d75fcd3088281af2ba23272045a14f0f6517007ab1f9d4a7c790425fa8c33d4f21834219d414ddb999
7
- data.tar.gz: f95b6452f90d13d024f8a4d432ea2bddb39b805149a6918cc78b4274e02aa0a965246c6758d20acb71531fc69b44def03d496afa3ea41e7bea0cd2998848ec5d
6
+ metadata.gz: 133e4480b8e8c22835294d0c2b18e79f9ee6ba065ee080c7c5f0e0c61a1a8bf92aecc6ee2e6092bce8ba095ffe7937ce8b6cb6526ca20938ae3d3d54dc39062e
7
+ data.tar.gz: b4e08f469025a62273c6c848b4d790e8b9a1520a6ce6ab24f0b845bcd7453cfa2040b1d40b3d0a29fe73c13e9c3d4242779022b0b2c43ba11c968f0e5ae74be5
data/CHANGELOG.md CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.3.0] - 2026-10-08
6
+
7
+ - Heroku: a rollout line under Deployed counts the dynos of each process type
8
+ running the deployed release (`web 1/3, 2 starting`) and says when every
9
+ dyno is up on it. With preboot on, the handoff from the previous web dynos
10
+ shows as an estimate. `--json` gains a `rollout` object.
11
+
12
+ ## [0.2.0] - 2026-10-07
13
+
14
+ - Heroku: Deployed is the release Heroku is serving, not the newest one. A
15
+ newer release still in its release phase shows as `Releasing`, a failed one
16
+ as `Failed`, and neither counts as on air. While such a release carries a
17
+ different commit, `pinned` and `★ current` stay quiet. `--json` gains a
18
+ `release` object.
19
+
5
20
  ## [0.1.0]
6
21
 
7
22
  - Initial release: Heroku adapter with netrc auth, full behavior parity with
data/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  <div align="center">
6
6
 
7
- ### See what's on air — which commit is actually running in production.
7
+ ### See what's on air - which commit is actually running in production.
8
8
 
9
9
  [![Gem Version](https://img.shields.io/gem/v/onair-cli)](https://rubygems.org/gems/onair-cli)
10
10
  [![CI](https://github.com/amberpixels/onair-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/amberpixels/onair-cli/actions/workflows/ci.yml)
@@ -16,12 +16,12 @@
16
16
 
17
17
  <p align="center">
18
18
  <img src="assets/demo.svg"
19
- alt="onair report: a pending build, the deployed release one commit behind origin/main, and your commit absorbed by the current deploy"
19
+ alt="onair report: a release still in its release phase, the deployed release one commit behind origin/main, and your commit absorbed by the current deploy"
20
20
  width="760">
21
21
  </p>
22
22
 
23
23
  `onair` answers one question fast and truthfully. The deployed commit comes
24
- from the running release's *slug*, never from "newest build" — after a
24
+ from the running release's *slug*, never from "newest build" - after a
25
25
  rollback those differ, and the whole point of this tool is to not lie in that
26
26
  case.
27
27
 
@@ -33,7 +33,7 @@ case.
33
33
  an explicit lazy `git fetch`.
34
34
  4. **Platform-agnostic core.** Heroku is the first adapter, not the
35
35
  architecture.
36
- 5. **Degrade gracefully.** Offline, missing commits, no color support — every
36
+ 5. **Degrade gracefully.** Offline, missing commits, no color support - every
37
37
  failure mode renders something useful, never a stack trace.
38
38
 
39
39
  ## Installation
@@ -61,21 +61,30 @@ is not a TTY or `NO_COLOR` is set.
61
61
  ### Speed
62
62
 
63
63
  The remote head of `origin/<branch>` is resolved through the GitHub API
64
- (~0.4s) when a token is ambient — `GH_TOKEN`, `GITHUB_TOKEN`, or a logged-in
65
- `gh` CLI — with `git ls-remote` always racing alongside as the fallback
64
+ (~0.4s) when a token is ambient - `GH_TOKEN`, `GITHUB_TOKEN`, or a logged-in
65
+ `gh` CLI - with `git ls-remote` always racing alongside as the fallback
66
66
  (no token, non-GitHub remote, API failure). Both report the live remote ref;
67
67
  the API is a faster transport, never a cache.
68
68
 
69
69
  ### What the report shows
70
70
 
71
- - **Deployed** — the commit the running release was built from, its age,
71
+ - **Deployed** - the commit the running release was built from, its age,
72
72
  author, and a delta against `origin/main`: `★ current` or
73
73
  `↓ N commits behind`. If the relationship is unknown (diverged history,
74
- commits unavailable), no marker is shown — silence over speculation.
75
- - **Pending** — an in-flight build that's about to replace the deploy.
76
- - **⏸ pinned** — a newer build succeeded but is *not* what's running
74
+ commits unavailable), no marker is shown - silence over speculation.
75
+ - **Rollout** - under the Deployed row, how many dynos of each process type
76
+ run that release: `⟳ rolling out v1234: web 1/3, 2 starting` while dynos
77
+ restart, `✓ rolled out` once every dyno is up on it. With preboot on, the
78
+ previous web dynos keep serving for about three minutes after the new ones
79
+ are up; that window shows as an estimate.
80
+ - **Pending** - an in-flight build that's about to replace the deploy.
81
+ - **Releasing** - a newer release whose release phase (e.g. migrations) is
82
+ still running; production still serves the Deployed row until it finishes.
83
+ - **Failed** - the newest release failed its release phase and never went
84
+ live. It stays until a newer release succeeds.
85
+ - **⏸ pinned** - a newer build succeeded but is *not* what's running
77
86
  (rollback / pinned release).
78
- - **Yours** — when someone else's commit is deployed but yours sits just
87
+ - **Yours** - when someone else's commit is deployed but yours sits just
79
88
  below it: your merge made it to prod, absorbed by a later deploy.
80
89
 
81
90
  ## Configuration
@@ -105,7 +114,7 @@ task:
105
114
 
106
115
  Matched ids in commit subjects become clickable terminal hyperlinks (the text
107
116
  stays identical), and `--json` gains a `task` object (`{ "id", "url" }`) per
108
- commit. No `task:` section — no behavior, nothing to opt out of.
117
+ commit. No `task:` section - no behavior, nothing to opt out of.
109
118
 
110
119
  ## JSON schema
111
120
 
@@ -125,6 +134,14 @@ additively:
125
134
  "author": "Eugene", "task": null
126
135
  },
127
136
  "pending": null,
137
+ "release": null,
138
+ "rollout": {
139
+ "version": 1234, "complete": true,
140
+ "processes": [
141
+ { "type": "web", "up": 3, "total": 3, "waiting": {}, "previous": 0 }
142
+ ],
143
+ "overlap_until": null
144
+ },
128
145
  "delta": { "status": "current", "behind_by": 0 },
129
146
  "pinned": null,
130
147
  "yours": null
@@ -134,6 +151,16 @@ additively:
134
151
  - `delta.status` is `"current"`, `"behind"`, or `"unknown"`.
135
152
  - `pending`: `{ "sha", "started_at", "subject", "author" }` when a build is
136
153
  in flight.
154
+ - `release`: `{ "sha", "version", "description", "status", "started_at",
155
+ "subject", "author" }` when a release newer than the running one is not
156
+ live; `status` is `"pending"` (release phase running) or `"failed"`. While
157
+ it carries a different sha, `delta.status` is never `"current"`.
158
+ - `rollout`: dynos per process type against the running release. `up`
159
+ counts dynos serving it, `waiting` counts the rest of its dynos by state
160
+ (`starting`, `crashed`, ...), `previous` counts dynos on an older release.
161
+ `overlap_until` is an estimated end of the preboot handoff while it lasts,
162
+ otherwise `null`. `complete` is true once every dyno serves the running
163
+ release and no handoff remains. `null` when the dynos can't be read.
137
164
  - `pinned`: `{ "version", "description", "latest_built_sha" }` when a newer
138
165
  build succeeded but is not running.
139
166
  - `yours`: `{ "sha", "had_own_build", "subject", "author" }` when your commit
@@ -146,7 +173,7 @@ additively:
146
173
 
147
174
  ```sh
148
175
  bundle install
149
- bundle exec rake # specs + rubocop
176
+ bundle exec rake # specs + standard
150
177
  ```
151
178
 
152
179
  The behavioral source of truth is `reference/prod-release.sh`, the
data/lib/onair/cli.rb CHANGED
@@ -64,16 +64,17 @@ module Onair
64
64
  git = Git.new(fetch_allowed: config.fetch)
65
65
  adapter = Platform.build(config)
66
66
  repo = config.repo || git.origin_repo
67
- report = Orchestrator.new(config: config, adapter: adapter, git: git, repo: repo).run
67
+ now = Time.now
68
+ report = Orchestrator.new(config: config, adapter: adapter, git: git, repo: repo, now: now).run
68
69
 
69
70
  if flags[:json]
70
71
  puts Renderer::Json.new(report: report, app: config.app, platform: config.platform,
71
- branch: config.branch, repo: repo, task: config.task).render
72
+ branch: config.branch, repo: repo, task: config.task).render
72
73
  else
73
74
  tty = $stdout.tty?
74
75
  print Renderer::Tty.new(report: report, app: config.app, platform_label: adapter.display_name,
75
- branch: config.branch, repo: repo, task: config.task,
76
- color: tty && !no_color?, hyperlinks: tty, now: Time.now).render
76
+ branch: config.branch, repo: repo, task: config.task,
77
+ color: tty && !no_color?, hyperlinks: tty, now: now).render
77
78
  end
78
79
  end
79
80
 
data/lib/onair/config.rb CHANGED
@@ -22,7 +22,7 @@ module Onair
22
22
  def self.resolve(flags = {}, env: ENV, dir: Dir.pwd)
23
23
  file = find_file(dir) || {}
24
24
  app = flags[:app] || env["HEROKU_APP"] || file["app"]
25
- raise Error, "no app configured — pass --app NAME, set HEROKU_APP, or run `onair init`" if app.nil?
25
+ raise Error, "no app configured - pass --app NAME, set HEROKU_APP, or run `onair init`" if app.nil?
26
26
 
27
27
  new(
28
28
  platform: file["platform"] || "heroku",
data/lib/onair/git.rb CHANGED
@@ -91,7 +91,7 @@ module Onair
91
91
  end
92
92
 
93
93
  def opts
94
- @dir ? { chdir: @dir } : {}
94
+ @dir ? {chdir: @dir} : {}
95
95
  end
96
96
  end
97
97
  end
@@ -4,11 +4,12 @@ module Onair
4
4
  # Runs the platform adapter and git concurrently, lazily fetches missing
5
5
  # commits at most once, and assembles the Report.
6
6
  class Orchestrator
7
- def initialize(config:, adapter:, git:, repo: nil)
7
+ def initialize(config:, adapter:, git:, repo: nil, now: Time.now)
8
8
  @config = config
9
9
  @adapter = adapter
10
10
  @git = git
11
11
  @repo = repo
12
+ @now = now
12
13
  end
13
14
 
14
15
  def run
@@ -17,10 +18,10 @@ module Onair
17
18
  snapshot = snapshot_thread.value
18
19
  remote_head = head_thread.value
19
20
 
20
- needed = [snapshot.deployed&.sha, snapshot.pending&.sha, remote_head].compact
21
+ needed = [snapshot.deployed&.sha, snapshot.pending&.sha, snapshot.release&.sha, remote_head].compact
21
22
  @git.fetch_once! if needed.any? { |sha| !@git.has_commit?(sha) }
22
23
 
23
- Report.build(snapshot: snapshot, remote_head: remote_head, git: @git)
24
+ Report.build(snapshot: snapshot, remote_head: remote_head, git: @git, now: @now)
24
25
  end
25
26
 
26
27
  private
@@ -13,7 +13,7 @@ module Onair
13
13
  klass = @registry[config.platform]
14
14
  if klass.nil?
15
15
  raise Error,
16
- "unknown platform #{config.platform.inspect} (available: #{@registry.keys.join(', ')})"
16
+ "unknown platform #{config.platform.inspect} (available: #{@registry.keys.join(", ")})"
17
17
  end
18
18
 
19
19
  klass.new(config)
@@ -25,8 +25,13 @@ module Onair
25
25
  # deployed: the release CURRENTLY RUNNING (nil sha only if
26
26
  # truly unresolvable)
27
27
  # pending: newest in-flight build, or nil
28
+ # release: newest release above the running one that is not
29
+ # running (release phase pending or failed), or nil
28
30
  # latest_built_sha: newest successfully built sha, or nil
29
31
  # succeeded_shas: recent succeeded build shas, newest first
32
+ # rollout: per process type, how many dynos run the running
33
+ # release, plus an optional estimate of when the
34
+ # previous release stops serving; nil when unknown
30
35
  #
31
36
  # Adapters own their internal concurrency and auth. Everything above
32
37
  # (delta, pinned, mine, rendering) is platform-agnostic and must not
@@ -8,15 +8,26 @@ module Onair
8
8
  module Platform
9
9
  class Heroku < Base
10
10
  HOST = "api.heroku.com"
11
+ RELEASE_WINDOW = 10
12
+ IN_FLIGHT_STATUSES = %w[pending failed].freeze
13
+ ONE_OFF_TYPES = %w[run scheduler release].freeze
14
+ # An eco dyno asleep on the running release boots that release on wake.
15
+ SERVING_STATES = %w[up idle].freeze
16
+ # With preboot, Heroku keeps routing to the previous web dynos for about
17
+ # three minutes after the new ones come up.
18
+ PREBOOT_HANDOFF = 180
11
19
 
12
20
  def snapshot
13
21
  token = resolve_token
14
- release_thread = quiet_thread { deployed(token) }
22
+ releases_thread = quiet_thread { releases(token) }
15
23
  builds_thread = quiet_thread { builds(token) }
16
- deployed = release_thread.value
24
+ dynos_thread = quiet_thread { dynos(token) }
25
+ deployed, release = releases_thread.value
17
26
  pending, succeeded_shas = builds_thread.value
18
- Snapshot.new(deployed: deployed, pending: pending,
19
- latest_built_sha: succeeded_shas.first, succeeded_shas: succeeded_shas)
27
+ dyno_rows, preboot = dynos_thread.value
28
+ Snapshot.new(deployed: deployed, pending: pending, release: release,
29
+ latest_built_sha: succeeded_shas.first, succeeded_shas: succeeded_shas,
30
+ rollout: rollout(dyno_rows, preboot, deployed.version))
20
31
  end
21
32
 
22
33
  private
@@ -35,28 +46,47 @@ module Onair
35
46
  def resolve_token
36
47
  token = Auth::Netrc.token(HOST)
37
48
  token = Auth::HerokuCli.token if token.nil? || token.empty?
38
- raise Error, "no Heroku credentials found — run `heroku login`" if token.nil? || token.empty?
49
+ raise Error, "no Heroku credentials found - run `heroku login`" if token.nil? || token.empty?
39
50
 
40
51
  token
41
52
  end
42
53
 
43
- # The slug records the commit the running release was built from — the
44
- # only reliable source after a rollback, when the builds list still
45
- # shows the newer (no-longer-running) build on top.
46
- def deployed(token)
54
+ # The running release is the one Heroku routes to (`current`), not the
55
+ # newest: while a release phase runs, or after it fails, the previous
56
+ # release keeps serving. The slug records the commit a release was built
57
+ # from - the only reliable source after a rollback, when the builds list
58
+ # still shows the newer (no-longer-running) build on top.
59
+ def releases(token)
47
60
  with_http do |http|
48
- release = get(http, token, "/apps/#{app}/releases", range: "version ..; order=desc, max=1").first
49
- raise Error, "no releases found for app #{app}" if release.nil?
50
-
51
- Deployed.new(
52
- sha: slug_commit(http, token, release.dig("slug", "id")),
53
- version: release["version"],
54
- description: release["description"],
55
- deployed_at: parse_time(release["created_at"])
56
- )
61
+ rows = get(http, token, "/apps/#{app}/releases", range: "version ..; order=desc, max=#{RELEASE_WINDOW}")
62
+ raise Error, "no releases found for app #{app}" if rows.empty?
63
+
64
+ running = rows.find { |row| row["current"] } || rows.find { |row| row["status"] == "succeeded" }
65
+ raise Error, "no succeeded release among the last #{RELEASE_WINDOW} for app #{app}" if running.nil?
66
+
67
+ deployed_sha = slug_commit(http, token, running.dig("slug", "id"))
68
+ [deployed(running, deployed_sha), in_flight(http, token, rows.first, running, deployed_sha)]
57
69
  end
58
70
  end
59
71
 
72
+ def deployed(row, sha)
73
+ Deployed.new(sha: sha, version: row["version"], description: row["description"],
74
+ deployed_at: parse_time(row["created_at"]))
75
+ end
76
+
77
+ # Only the newest release counts: an older failed one was superseded by
78
+ # whatever came after it. Without a commit there is no row to render.
79
+ def in_flight(http, token, newest, running, running_sha)
80
+ return nil if newest.equal?(running) || !IN_FLIGHT_STATUSES.include?(newest["status"])
81
+
82
+ slug_id = newest.dig("slug", "id")
83
+ sha = (slug_id == running.dig("slug", "id")) ? running_sha : slug_commit(http, token, slug_id)
84
+ return nil if sha.nil?
85
+
86
+ Release.new(sha: sha, version: newest["version"], description: newest["description"],
87
+ status: newest["status"].to_sym, started_at: parse_time(newest["created_at"]))
88
+ end
89
+
60
90
  def slug_commit(http, token, slug_id)
61
91
  return nil if slug_id.nil?
62
92
 
@@ -75,12 +105,62 @@ module Onair
75
105
  pending_sha = pending_build&.dig("source_blob", "version")
76
106
  pending = pending_sha && Pending.new(sha: pending_sha, started_at: parse_time(pending_build["created_at"]))
77
107
  succeeded = builds.select { |build| build["status"] == "succeeded" }
78
- .filter_map { |build| build.dig("source_blob", "version") }
108
+ .filter_map { |build| build.dig("source_blob", "version") }
79
109
  [pending, succeeded]
80
110
  rescue Error
81
111
  [nil, []]
82
112
  end
83
113
 
114
+ # A failed dynos call drops the rollout and nothing else; a failed
115
+ # preboot lookup only drops the handoff estimate.
116
+ def dynos(token)
117
+ with_http do |http|
118
+ rows = get(http, token, "/apps/#{app}/dynos")
119
+ [rows, preboot?(http, token)]
120
+ end
121
+ rescue Error
122
+ [nil, false]
123
+ end
124
+
125
+ def preboot?(http, token)
126
+ get(http, token, "/apps/#{app}/features/preboot")["enabled"] == true
127
+ rescue Error
128
+ false
129
+ end
130
+
131
+ def rollout(rows, preboot, version)
132
+ return nil if rows.nil? || version.nil?
133
+
134
+ formation = rows.reject { |dyno| ONE_OFF_TYPES.include?(dyno["type"]) }
135
+ return nil if formation.empty?
136
+
137
+ processes = formation.group_by { |dyno| dyno["type"] }
138
+ .sort_by { |type, _| [(type == "web") ? 0 : 1, type] }
139
+ .map { |type, dynos| process_rollout(type, dynos, version) }
140
+ Rollout.new(version: version, processes: processes,
141
+ overlap_until: preboot ? preboot_handoff(formation, version) : nil)
142
+ end
143
+
144
+ def process_rollout(type, dynos, version)
145
+ current, previous = dynos.partition { |dyno| dyno.dig("release", "version") == version }
146
+ waiting = current.map { |dyno| dyno["state"] }.reject { |state| SERVING_STATES.include?(state) }.tally
147
+ ProcessRollout.new(type: type, total: dynos.size, up: current.size - waiting.values.sum,
148
+ waiting: waiting, previous: previous.size)
149
+ end
150
+
151
+ # Old web dynos still listed are already counted as previous; the
152
+ # estimate covers only the handoff the dynos list does not show.
153
+ def preboot_handoff(formation, version)
154
+ web = formation.select { |dyno| dyno["type"] == "web" }
155
+ return nil if web.empty?
156
+ return nil unless web.all? do |dyno|
157
+ dyno.dig("release", "version") == version && SERVING_STATES.include?(dyno["state"])
158
+ end
159
+
160
+ last_up = web.filter_map { |dyno| parse_time(dyno["updated_at"]) }.max
161
+ last_up && (last_up + PREBOOT_HANDOFF)
162
+ end
163
+
84
164
  # One connection per thread — the dependent releases → slug pair must
85
165
  # not pay a second TLS handshake.
86
166
  def with_http(&)
@@ -112,7 +192,7 @@ module Onair
112
192
 
113
193
  def error_message(response, path)
114
194
  case response.code.to_i
115
- when 401 then "Heroku rejected the token (401) — run `heroku login`"
195
+ when 401 then "Heroku rejected the token (401) - run `heroku login`"
116
196
  when 404 then "Heroku app not found: #{app}"
117
197
  else "Heroku API returned #{response.code} for #{path}"
118
198
  end
@@ -38,7 +38,7 @@ module Onair
38
38
 
39
39
  sha = JSON.parse(response.body).dig("object", "sha")
40
40
  sha&.match?(/\A\h{40}\z/) ? sha : nil
41
- rescue StandardError
41
+ rescue
42
42
  nil
43
43
  end
44
44
  private_class_method :api_head
@@ -30,6 +30,8 @@ module Onair
30
30
  remote_head: @report.remote_head,
31
31
  deployed: deployed_payload,
32
32
  pending: pending_payload,
33
+ release: release_payload,
34
+ rollout: rollout_payload,
33
35
  delta: delta_payload,
34
36
  pinned: pinned_payload,
35
37
  yours: yours_payload
@@ -58,14 +60,42 @@ module Onair
58
60
  pending = snapshot.pending
59
61
  return nil if pending.nil?
60
62
 
61
- { sha: pending.sha, started_at: iso(pending.started_at) }.merge(commit_fields(pending.sha))
63
+ {sha: pending.sha, started_at: iso(pending.started_at)}.merge(commit_fields(pending.sha))
64
+ end
65
+
66
+ def release_payload
67
+ release = snapshot.release
68
+ return nil if release.nil?
69
+
70
+ {
71
+ sha: release.sha,
72
+ version: release.version,
73
+ description: release.description,
74
+ status: release.status.to_s,
75
+ started_at: iso(release.started_at)
76
+ }.merge(commit_fields(release.sha))
77
+ end
78
+
79
+ def rollout_payload
80
+ rollout = @report.rollout
81
+ return nil if rollout.nil?
82
+
83
+ {
84
+ version: rollout.version,
85
+ complete: rollout.complete?,
86
+ processes: rollout.processes.map do |process|
87
+ {type: process.type, up: process.up, total: process.total,
88
+ waiting: process.waiting, previous: process.previous}
89
+ end,
90
+ overlap_until: iso(rollout.overlap_until)
91
+ }
62
92
  end
63
93
 
64
94
  def delta_payload
65
95
  case @report.delta
66
- when :current then { status: "current", behind_by: 0 }
67
- when Integer then { status: "behind", behind_by: @report.delta }
68
- else { status: "unknown", behind_by: nil }
96
+ when :current then {status: "current", behind_by: 0}
97
+ when Integer then {status: "behind", behind_by: @report.delta}
98
+ else {status: "unknown", behind_by: nil}
69
99
  end
70
100
  end
71
101
 
@@ -84,19 +114,19 @@ module Onair
84
114
  mine = @report.mine
85
115
  return nil if mine.nil?
86
116
 
87
- { sha: mine.sha, had_own_build: mine.had_own_build }.merge(commit_fields(mine.sha))
117
+ {sha: mine.sha, had_own_build: mine.had_own_build}.merge(commit_fields(mine.sha))
88
118
  end
89
119
 
90
120
  def commit_fields(sha)
91
121
  info = sha && @report.commits[sha]
92
- { subject: info&.subject, author: info&.author_name, task: task_fields(info&.subject) }
122
+ {subject: info&.subject, author: info&.author_name, task: task_fields(info&.subject)}
93
123
  end
94
124
 
95
125
  def task_fields(subject)
96
126
  task_id = @task&.find(subject)
97
127
  return nil if task_id.nil?
98
128
 
99
- { id: task_id, url: @task.url_for(task_id) }
129
+ {id: task_id, url: @task.url_for(task_id)}
100
130
  end
101
131
 
102
132
  def iso(time)
@@ -7,6 +7,7 @@ module Onair
7
7
  COLORS = {
8
8
  bold: "\e[1m",
9
9
  green: "\e[0;32m",
10
+ red: "\e[0;31m",
10
11
  yellow: "\e[1;33m",
11
12
  dim: "\e[2m",
12
13
  purple: "\e[38;5;176m",
@@ -15,6 +16,7 @@ module Onair
15
16
  }.freeze
16
17
 
17
18
  NOT_FOUND_SUBJECT = "(commit not found in local git)"
19
+ LABEL_WIDTH = "Releasing:".length
18
20
 
19
21
  def initialize(report:, app:, platform_label:, branch:, repo:, color:, hyperlinks:, now:, task: nil)
20
22
  @report = report
@@ -31,6 +33,7 @@ module Onair
31
33
  def render
32
34
  lines = ["", " #{code(:purple)}#{@platform_label} #{code(:bold)}#{@app}#{code(:reset)}", ""]
33
35
  lines.concat(pending_lines)
36
+ lines.concat(release_lines)
34
37
  lines.concat(deployed_lines)
35
38
  lines << ""
36
39
  "#{lines.join("\n")}\n"
@@ -46,13 +49,30 @@ module Onair
46
49
  pending = snapshot.pending
47
50
  return [] if pending.nil?
48
51
 
49
- row_lines("Pending: ", :yellow, pending.sha, age(pending.started_at)) + [""]
52
+ row_lines("Pending:", :yellow, pending.sha, age(pending.started_at)) + [""]
53
+ end
54
+
55
+ # Same-commit releases (config changes, rollbacks) name what they are,
56
+ # since the sha alone would look like the deploy below.
57
+ def release_lines
58
+ release = snapshot.release
59
+ return [] if release.nil?
60
+
61
+ detail = "v#{release.version}"
62
+ detail = "#{detail}: #{release.description}" if release.sha == snapshot.deployed&.sha
63
+ label, color, note = if release.status == :failed
64
+ ["Failed:", :red, "✗ release phase failed (#{detail})"]
65
+ else
66
+ ["Releasing:", :yellow, "⟳ release phase (#{detail})"]
67
+ end
68
+ row_lines(label, color, release.sha, age(release.started_at), extra: paint(note, color)) + [""]
50
69
  end
51
70
 
52
71
  def deployed_lines
53
72
  deployed = snapshot.deployed
54
73
  if deployed&.sha
55
74
  lines = row_lines("Deployed:", :green, deployed.sha, age(deployed.deployed_at), extra: delta_text)
75
+ lines << rollout_line if @report.rollout
56
76
  lines << pinned_line if @report.pinned
57
77
  lines.concat(yours_lines)
58
78
  lines
@@ -62,13 +82,42 @@ module Onair
62
82
  end
63
83
  end
64
84
 
85
+ # The dynos list can look finished while preboot still routes some
86
+ # traffic to the previous release, so that window gets its own line.
87
+ def rollout_line
88
+ rollout = @report.rollout
89
+ text, color = if rollout.complete?
90
+ ["✓ rolled out v#{rollout.version}: #{process_counts(rollout)}", :dim]
91
+ elsif rollout.processes.all?(&:complete?)
92
+ ["⟳ preboot: previous web dynos may still serve for " \
93
+ "~#{remaining(rollout.overlap_until)} (estimate)", :yellow]
94
+ else
95
+ ["⟳ rolling out v#{rollout.version}: #{process_counts(rollout)}", :yellow]
96
+ end
97
+ " #{paint(text, color)}"
98
+ end
99
+
100
+ def process_counts(rollout)
101
+ rollout.processes.map do |process|
102
+ parts = ["#{process.type} #{process.up}/#{process.total}"]
103
+ parts.concat(process.waiting.map { |state, count| "#{count} #{state}" })
104
+ parts << "#{process.previous} on older release" if process.previous.positive?
105
+ parts.join(", ")
106
+ end.join(" · ")
107
+ end
108
+
109
+ def remaining(time)
110
+ seconds = (time - @now).ceil.clamp(0..)
111
+ (seconds < 60) ? "#{seconds}s" : "#{(seconds / 60.0).ceil}m"
112
+ end
113
+
65
114
  def yours_lines
66
115
  mine = @report.mine
67
116
  return [] if mine.nil?
68
117
 
69
118
  note = mine.had_own_build ? "✓ released, then absorbed by current" : "✓ absorbed by current deploy"
70
119
  committed_at = @report.commits[mine.sha]&.committed_at
71
- [""] + row_lines("Yours: ", :cyan, mine.sha, age(committed_at), extra: paint(note, :cyan))
120
+ [""] + row_lines("Yours:", :cyan, mine.sha, age(committed_at), extra: paint(note, :cyan))
72
121
  end
73
122
 
74
123
  def row_lines(label, color_key, sha, age, extra: nil)
@@ -76,9 +125,10 @@ module Onair
76
125
  subject = info&.subject || NOT_FOUND_SUBJECT
77
126
  author = info&.author_name || "?"
78
127
  age_blurb = age ? "(#{age}) " : ""
79
- first = " #{paint(label, color_key)} #{paint(sha[0, 9], :bold)} #{paint("#{age_blurb}by #{author}", :dim)}"
128
+ padded = label.ljust(LABEL_WIDTH)
129
+ first = " #{paint(padded, color_key)} #{paint(sha[0, 9], :bold)} #{paint("#{age_blurb}by #{author}", :dim)}"
80
130
  first = "#{first} #{extra}" if extra
81
- [first, " #{paint('→', :dim)} #{linkify(subject, info ? sha : nil)}"]
131
+ [first, " #{paint("→", :dim)} #{linkify(subject, info ? sha : nil)}"]
82
132
  end
83
133
 
84
134
  def delta_text
@@ -87,13 +137,13 @@ module Onair
87
137
  paint("★ current", :green)
88
138
  when Integer
89
139
  count = @report.delta
90
- paint("↓ #{count} #{count == 1 ? 'commit' : 'commits'} behind origin/#{@branch}", :yellow)
140
+ paint("↓ #{count} #{(count == 1) ? "commit" : "commits"} behind origin/#{@branch}", :yellow)
91
141
  end
92
142
  end
93
143
 
94
144
  def pinned_line
95
145
  deployed = snapshot.deployed
96
- " #{paint("⏸ v#{deployed.version} (#{deployed.description}) — newer build " \
146
+ " #{paint("⏸ v#{deployed.version} (#{deployed.description}) - newer build " \
97
147
  "#{snapshot.latest_built_sha[0, 9]} succeeded but is not running", :yellow)}"
98
148
  end
99
149
 
data/lib/onair/report.rb CHANGED
@@ -10,40 +10,54 @@ module Onair
10
10
  # mine: the local user's commit just below someone else's deployed head.
11
11
  # commits: sha => CommitInfo (or nil when absent locally) for every sha a
12
12
  # renderer may need to describe.
13
- Report = Data.define(:snapshot, :remote_head, :delta, :pinned, :mine, :commits) do
14
- def self.build(snapshot:, remote_head:, git:)
13
+ # rollout: the snapshot's rollout, its handoff estimate dropped once `now`
14
+ # has passed it, or nil when unknown.
15
+ Report = Data.define(:snapshot, :remote_head, :delta, :pinned, :mine, :commits, :rollout) do
16
+ def self.build(snapshot:, remote_head:, git:, now:)
15
17
  # Pinned is judged before the stale pending is dropped: during the
16
18
  # stale window the newest *succeeded* build is still the previous
17
19
  # deploy, which must not read as a rollback.
18
20
  pinned = pinned?(snapshot)
19
- snapshot = drop_stale_pending(snapshot)
21
+ snapshot = drop_superseded_pending(snapshot)
20
22
  deployed_sha = snapshot.deployed&.sha
21
23
  mine = compute_mine(snapshot, git)
22
- commits = [deployed_sha, snapshot.pending&.sha, mine&.sha].compact.uniq
23
- .to_h { |sha| [sha, git.commit_info(sha)] }
24
+ commits = [deployed_sha, snapshot.pending&.sha, snapshot.release&.sha, mine&.sha]
25
+ .compact.uniq.to_h { |sha| [sha, git.commit_info(sha)] }
24
26
  new(
25
27
  snapshot: snapshot,
26
28
  remote_head: remote_head,
27
- delta: compute_delta(deployed_sha, remote_head, git),
29
+ delta: compute_delta(snapshot, remote_head, git),
28
30
  pinned: pinned,
29
31
  mine: mine,
30
- commits: commits
32
+ commits: commits,
33
+ rollout: live_rollout(snapshot.rollout, now)
31
34
  )
32
35
  end
33
36
 
37
+ def self.live_rollout(rollout, now)
38
+ return rollout if rollout.nil? || rollout.overlap_until.nil? || rollout.overlap_until > now
39
+
40
+ rollout.with(overlap_until: nil)
41
+ end
42
+
34
43
  # Right after a deploy finishes, the platform's builds list can still
35
44
  # report the just-released build as pending while the releases endpoint
36
45
  # already shows it running — the same commit would render as both
37
- # Pending and Deployed. A build that is already on air isn't pending.
38
- def self.drop_stale_pending(snapshot)
39
- return snapshot unless snapshot.pending && snapshot.pending.sha == snapshot.deployed&.sha
46
+ # Pending and Deployed. A build that is already on air isn't pending, and
47
+ # one already in its release phase is shown by the release row.
48
+ def self.drop_superseded_pending(snapshot)
49
+ pending_sha = snapshot.pending&.sha
50
+ return snapshot unless pending_sha && [snapshot.deployed&.sha, snapshot.release&.sha].include?(pending_sha)
40
51
 
41
52
  snapshot.with(pending: nil)
42
53
  end
43
54
 
44
- def self.compute_delta(sha, head, git)
55
+ # "Current" compares only the running sha to the head, so it stays silent
56
+ # while a release of a different commit is in flight or failed.
57
+ def self.compute_delta(snapshot, head, git)
58
+ sha = snapshot.deployed&.sha
45
59
  return nil if sha.nil? || head.nil?
46
- return :current if sha == head
60
+ return (release_of_other_commit?(snapshot) ? nil : :current) if sha == head
47
61
  return nil unless git.has_commit?(sha) && git.has_commit?(head)
48
62
  return nil unless git.ancestor?(sha, head)
49
63
 
@@ -51,10 +65,16 @@ module Onair
51
65
  count&.positive? ? count : nil
52
66
  end
53
67
 
68
+ # A release of another commit, in flight or failed, explains the newer
69
+ # build on its own; a same-commit one (config change) does not.
54
70
  def self.pinned?(snapshot)
55
71
  sha = snapshot.deployed&.sha
56
72
  latest = snapshot.latest_built_sha
57
- !sha.nil? && !latest.nil? && latest != sha && snapshot.pending.nil?
73
+ !sha.nil? && !latest.nil? && latest != sha && snapshot.pending.nil? && !release_of_other_commit?(snapshot)
74
+ end
75
+
76
+ def self.release_of_other_commit?(snapshot)
77
+ !snapshot.release.nil? && snapshot.release.sha != snapshot.deployed&.sha
58
78
  end
59
79
 
60
80
  # "Did my merge just ship?" — deliberately a 2-commit first-parent window
@@ -79,6 +99,7 @@ module Onair
79
99
  (!identity.name.to_s.empty? && name == identity.name)
80
100
  end
81
101
 
82
- private_class_method :drop_stale_pending, :compute_delta, :pinned?, :compute_mine, :identity_match?
102
+ private_class_method :drop_superseded_pending, :compute_delta, :pinned?, :release_of_other_commit?,
103
+ :compute_mine, :identity_match?, :live_rollout
83
104
  end
84
105
  end
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.1.0"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/onair.rb CHANGED
@@ -14,10 +14,31 @@ module Onair
14
14
 
15
15
  Pending = Data.define(:sha, :started_at)
16
16
 
17
+ # A release newer than the running one that is not serving traffic: its
18
+ # release phase is still running (status :pending) or it failed (:failed).
19
+ Release = Data.define(:sha, :version, :description, :status, :started_at)
20
+
21
+ # Dynos of one process type against the running release. `up` counts dynos
22
+ # on the running version that serve (or idle until woken); `waiting` counts
23
+ # the rest of them by state; `previous` counts dynos on an older release.
24
+ ProcessRollout = Data.define(:type, :total, :up, :waiting, :previous) do
25
+ def complete?
26
+ up == total
27
+ end
28
+ end
29
+
30
+ # 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
33
+ def complete?
34
+ processes.all?(&:complete?) && overlap_until.nil?
35
+ end
36
+ end
37
+
17
38
  # What a platform adapter returns. `latest_built_sha` is the newest
18
39
  # successfully built sha (rollback detection); `succeeded_shas` lists all
19
40
  # recent succeeded build shas, newest first ("yours had its own deploy").
20
- Snapshot = Data.define(:deployed, :pending, :latest_built_sha, :succeeded_shas)
41
+ Snapshot = Data.define(:deployed, :pending, :release, :latest_built_sha, :succeeded_shas, :rollout)
21
42
 
22
43
  Mine = Data.define(:sha, :had_own_build)
23
44
  end
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.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eugene