onair-cli 0.1.0 → 0.2.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: 1faf09e85aa982fd64a664e4d1f566c4aaf836cc3f196cde37ef9477a42c498f
4
+ data.tar.gz: 41404044b99b5624b947bc726cefb8e85910622f95ff31b2419bd12c612b56bb
5
5
  SHA512:
6
- metadata.gz: a72e23588254686e5154cf43fc8253a67fd16b524f67e1d75fcd3088281af2ba23272045a14f0f6517007ab1f9d4a7c790425fa8c33d4f21834219d414ddb999
7
- data.tar.gz: f95b6452f90d13d024f8a4d432ea2bddb39b805149a6918cc78b4274e02aa0a965246c6758d20acb71531fc69b44def03d496afa3ea41e7bea0cd2998848ec5d
6
+ metadata.gz: 2343601742f56a59b9141dd06d597f040ca1f55eae53295dd34dad9da10127372cb544b5bf9ff6bff98fdcaf1b2d938eab0e3ea0c9309f93ebd000da9a298566
7
+ data.tar.gz: 4ae9c7a4348ded957fb88a922cc44d8ebebeba36f6c870303663fe922be631bed2b2ac5ea82acbfc4ac2111bffdeefc0e8ac05c60e8dbb8d9068c51a33fb1eee
data/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.2.0] - 2026-10-07
6
+
7
+ - Heroku: Deployed is the release Heroku is serving, not the newest one. A
8
+ newer release still in its release phase shows as `Releasing`, a failed one
9
+ as `Failed`, and neither counts as on air. While such a release carries a
10
+ different commit, `pinned` and `★ current` stay quiet. `--json` gains a
11
+ `release` object.
12
+
5
13
  ## [0.1.0]
6
14
 
7
15
  - 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,25 @@ 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
+ - **Pending** - an in-flight build that's about to replace the deploy.
76
+ - **Releasing** - a newer release whose release phase (e.g. migrations) is
77
+ still running; production still serves the Deployed row until it finishes.
78
+ - **Failed** - the newest release failed its release phase and never went
79
+ live. It stays until a newer release succeeds.
80
+ - **⏸ pinned** - a newer build succeeded but is *not* what's running
77
81
  (rollback / pinned release).
78
- - **Yours** — when someone else's commit is deployed but yours sits just
82
+ - **Yours** - when someone else's commit is deployed but yours sits just
79
83
  below it: your merge made it to prod, absorbed by a later deploy.
80
84
 
81
85
  ## Configuration
@@ -105,7 +109,7 @@ task:
105
109
 
106
110
  Matched ids in commit subjects become clickable terminal hyperlinks (the text
107
111
  stays identical), and `--json` gains a `task` object (`{ "id", "url" }`) per
108
- commit. No `task:` section — no behavior, nothing to opt out of.
112
+ commit. No `task:` section - no behavior, nothing to opt out of.
109
113
 
110
114
  ## JSON schema
111
115
 
@@ -125,6 +129,7 @@ additively:
125
129
  "author": "Eugene", "task": null
126
130
  },
127
131
  "pending": null,
132
+ "release": null,
128
133
  "delta": { "status": "current", "behind_by": 0 },
129
134
  "pinned": null,
130
135
  "yours": null
@@ -134,6 +139,10 @@ additively:
134
139
  - `delta.status` is `"current"`, `"behind"`, or `"unknown"`.
135
140
  - `pending`: `{ "sha", "started_at", "subject", "author" }` when a build is
136
141
  in flight.
142
+ - `release`: `{ "sha", "version", "description", "status", "started_at",
143
+ "subject", "author" }` when a release newer than the running one is not
144
+ live; `status` is `"pending"` (release phase running) or `"failed"`. While
145
+ it carries a different sha, `delta.status` is never `"current"`.
137
146
  - `pinned`: `{ "version", "description", "latest_built_sha" }` when a newer
138
147
  build succeeded but is not running.
139
148
  - `yours`: `{ "sha", "had_own_build", "subject", "author" }` when your commit
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",
@@ -17,7 +17,7 @@ module Onair
17
17
  snapshot = snapshot_thread.value
18
18
  remote_head = head_thread.value
19
19
 
20
- needed = [snapshot.deployed&.sha, snapshot.pending&.sha, remote_head].compact
20
+ needed = [snapshot.deployed&.sha, snapshot.pending&.sha, snapshot.release&.sha, remote_head].compact
21
21
  @git.fetch_once! if needed.any? { |sha| !@git.has_commit?(sha) }
22
22
 
23
23
  Report.build(snapshot: snapshot, remote_head: remote_head, git: @git)
@@ -25,6 +25,8 @@ 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
30
32
  #
@@ -8,14 +8,16 @@ 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
11
13
 
12
14
  def snapshot
13
15
  token = resolve_token
14
- release_thread = quiet_thread { deployed(token) }
16
+ releases_thread = quiet_thread { releases(token) }
15
17
  builds_thread = quiet_thread { builds(token) }
16
- deployed = release_thread.value
18
+ deployed, release = releases_thread.value
17
19
  pending, succeeded_shas = builds_thread.value
18
- Snapshot.new(deployed: deployed, pending: pending,
20
+ Snapshot.new(deployed: deployed, pending: pending, release: release,
19
21
  latest_built_sha: succeeded_shas.first, succeeded_shas: succeeded_shas)
20
22
  end
21
23
 
@@ -35,28 +37,47 @@ module Onair
35
37
  def resolve_token
36
38
  token = Auth::Netrc.token(HOST)
37
39
  token = Auth::HerokuCli.token if token.nil? || token.empty?
38
- raise Error, "no Heroku credentials found — run `heroku login`" if token.nil? || token.empty?
40
+ raise Error, "no Heroku credentials found - run `heroku login`" if token.nil? || token.empty?
39
41
 
40
42
  token
41
43
  end
42
44
 
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)
45
+ # The running release is the one Heroku routes to (`current`), not the
46
+ # newest: while a release phase runs, or after it fails, the previous
47
+ # release keeps serving. The slug records the commit a release was built
48
+ # from - the only reliable source after a rollback, when the builds list
49
+ # still shows the newer (no-longer-running) build on top.
50
+ def releases(token)
47
51
  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
- )
52
+ rows = get(http, token, "/apps/#{app}/releases", range: "version ..; order=desc, max=#{RELEASE_WINDOW}")
53
+ raise Error, "no releases found for app #{app}" if rows.empty?
54
+
55
+ running = rows.find { |row| row["current"] } || rows.find { |row| row["status"] == "succeeded" }
56
+ raise Error, "no succeeded release among the last #{RELEASE_WINDOW} for app #{app}" if running.nil?
57
+
58
+ deployed_sha = slug_commit(http, token, running.dig("slug", "id"))
59
+ [deployed(running, deployed_sha), in_flight(http, token, rows.first, running, deployed_sha)]
57
60
  end
58
61
  end
59
62
 
63
+ def deployed(row, sha)
64
+ Deployed.new(sha: sha, version: row["version"], description: row["description"],
65
+ deployed_at: parse_time(row["created_at"]))
66
+ end
67
+
68
+ # Only the newest release counts: an older failed one was superseded by
69
+ # whatever came after it. Without a commit there is no row to render.
70
+ def in_flight(http, token, newest, running, running_sha)
71
+ return nil if newest.equal?(running) || !IN_FLIGHT_STATUSES.include?(newest["status"])
72
+
73
+ slug_id = newest.dig("slug", "id")
74
+ sha = slug_id == running.dig("slug", "id") ? running_sha : slug_commit(http, token, slug_id)
75
+ return nil if sha.nil?
76
+
77
+ Release.new(sha: sha, version: newest["version"], description: newest["description"],
78
+ status: newest["status"].to_sym, started_at: parse_time(newest["created_at"]))
79
+ end
80
+
60
81
  def slug_commit(http, token, slug_id)
61
82
  return nil if slug_id.nil?
62
83
 
@@ -112,7 +133,7 @@ module Onair
112
133
 
113
134
  def error_message(response, path)
114
135
  case response.code.to_i
115
- when 401 then "Heroku rejected the token (401) — run `heroku login`"
136
+ when 401 then "Heroku rejected the token (401) - run `heroku login`"
116
137
  when 404 then "Heroku app not found: #{app}"
117
138
  else "Heroku API returned #{response.code} for #{path}"
118
139
  end
@@ -30,6 +30,7 @@ module Onair
30
30
  remote_head: @report.remote_head,
31
31
  deployed: deployed_payload,
32
32
  pending: pending_payload,
33
+ release: release_payload,
33
34
  delta: delta_payload,
34
35
  pinned: pinned_payload,
35
36
  yours: yours_payload
@@ -61,6 +62,19 @@ module Onair
61
62
  { sha: pending.sha, started_at: iso(pending.started_at) }.merge(commit_fields(pending.sha))
62
63
  end
63
64
 
65
+ def release_payload
66
+ release = snapshot.release
67
+ return nil if release.nil?
68
+
69
+ {
70
+ sha: release.sha,
71
+ version: release.version,
72
+ description: release.description,
73
+ status: release.status.to_s,
74
+ started_at: iso(release.started_at)
75
+ }.merge(commit_fields(release.sha))
76
+ end
77
+
64
78
  def delta_payload
65
79
  case @report.delta
66
80
  when :current then { status: "current", behind_by: 0 }
@@ -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,7 +49,23 @@ 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
@@ -68,7 +87,7 @@ module Onair
68
87
 
69
88
  note = mine.had_own_build ? "✓ released, then absorbed by current" : "✓ absorbed by current deploy"
70
89
  committed_at = @report.commits[mine.sha]&.committed_at
71
- [""] + row_lines("Yours: ", :cyan, mine.sha, age(committed_at), extra: paint(note, :cyan))
90
+ [""] + row_lines("Yours:", :cyan, mine.sha, age(committed_at), extra: paint(note, :cyan))
72
91
  end
73
92
 
74
93
  def row_lines(label, color_key, sha, age, extra: nil)
@@ -76,7 +95,8 @@ module Onair
76
95
  subject = info&.subject || NOT_FOUND_SUBJECT
77
96
  author = info&.author_name || "?"
78
97
  age_blurb = age ? "(#{age}) " : ""
79
- first = " #{paint(label, color_key)} #{paint(sha[0, 9], :bold)} #{paint("#{age_blurb}by #{author}", :dim)}"
98
+ padded = label.ljust(LABEL_WIDTH)
99
+ first = " #{paint(padded, color_key)} #{paint(sha[0, 9], :bold)} #{paint("#{age_blurb}by #{author}", :dim)}"
80
100
  first = "#{first} #{extra}" if extra
81
101
  [first, " #{paint('→', :dim)} #{linkify(subject, info ? sha : nil)}"]
82
102
  end
@@ -93,7 +113,7 @@ module Onair
93
113
 
94
114
  def pinned_line
95
115
  deployed = snapshot.deployed
96
- " #{paint("⏸ v#{deployed.version} (#{deployed.description}) — newer build " \
116
+ " #{paint("⏸ v#{deployed.version} (#{deployed.description}) - newer build " \
97
117
  "#{snapshot.latest_built_sha[0, 9]} succeeded but is not running", :yellow)}"
98
118
  end
99
119
 
data/lib/onair/report.rb CHANGED
@@ -16,15 +16,15 @@ module Onair
16
16
  # stale window the newest *succeeded* build is still the previous
17
17
  # deploy, which must not read as a rollback.
18
18
  pinned = pinned?(snapshot)
19
- snapshot = drop_stale_pending(snapshot)
19
+ snapshot = drop_superseded_pending(snapshot)
20
20
  deployed_sha = snapshot.deployed&.sha
21
21
  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)] }
22
+ commits = [deployed_sha, snapshot.pending&.sha, snapshot.release&.sha, mine&.sha]
23
+ .compact.uniq.to_h { |sha| [sha, git.commit_info(sha)] }
24
24
  new(
25
25
  snapshot: snapshot,
26
26
  remote_head: remote_head,
27
- delta: compute_delta(deployed_sha, remote_head, git),
27
+ delta: compute_delta(snapshot, remote_head, git),
28
28
  pinned: pinned,
29
29
  mine: mine,
30
30
  commits: commits
@@ -34,16 +34,21 @@ module Onair
34
34
  # Right after a deploy finishes, the platform's builds list can still
35
35
  # report the just-released build as pending while the releases endpoint
36
36
  # 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
37
+ # Pending and Deployed. A build that is already on air isn't pending, and
38
+ # one already in its release phase is shown by the release row.
39
+ def self.drop_superseded_pending(snapshot)
40
+ pending_sha = snapshot.pending&.sha
41
+ return snapshot unless pending_sha && [snapshot.deployed&.sha, snapshot.release&.sha].include?(pending_sha)
40
42
 
41
43
  snapshot.with(pending: nil)
42
44
  end
43
45
 
44
- def self.compute_delta(sha, head, git)
46
+ # "Current" compares only the running sha to the head, so it stays silent
47
+ # while a release of a different commit is in flight or failed.
48
+ def self.compute_delta(snapshot, head, git)
49
+ sha = snapshot.deployed&.sha
45
50
  return nil if sha.nil? || head.nil?
46
- return :current if sha == head
51
+ return (release_of_other_commit?(snapshot) ? nil : :current) if sha == head
47
52
  return nil unless git.has_commit?(sha) && git.has_commit?(head)
48
53
  return nil unless git.ancestor?(sha, head)
49
54
 
@@ -51,10 +56,16 @@ module Onair
51
56
  count&.positive? ? count : nil
52
57
  end
53
58
 
59
+ # A release of another commit, in flight or failed, explains the newer
60
+ # build on its own; a same-commit one (config change) does not.
54
61
  def self.pinned?(snapshot)
55
62
  sha = snapshot.deployed&.sha
56
63
  latest = snapshot.latest_built_sha
57
- !sha.nil? && !latest.nil? && latest != sha && snapshot.pending.nil?
64
+ !sha.nil? && !latest.nil? && latest != sha && snapshot.pending.nil? && !release_of_other_commit?(snapshot)
65
+ end
66
+
67
+ def self.release_of_other_commit?(snapshot)
68
+ !snapshot.release.nil? && snapshot.release.sha != snapshot.deployed&.sha
58
69
  end
59
70
 
60
71
  # "Did my merge just ship?" — deliberately a 2-commit first-parent window
@@ -79,6 +90,7 @@ module Onair
79
90
  (!identity.name.to_s.empty? && name == identity.name)
80
91
  end
81
92
 
82
- private_class_method :drop_stale_pending, :compute_delta, :pinned?, :compute_mine, :identity_match?
93
+ private_class_method :drop_superseded_pending, :compute_delta, :pinned?, :release_of_other_commit?,
94
+ :compute_mine, :identity_match?
83
95
  end
84
96
  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.2.0"
5
5
  end
data/lib/onair.rb CHANGED
@@ -14,10 +14,14 @@ 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
+
17
21
  # What a platform adapter returns. `latest_built_sha` is the newest
18
22
  # successfully built sha (rollback detection); `succeeded_shas` lists all
19
23
  # recent succeeded build shas, newest first ("yours had its own deploy").
20
- Snapshot = Data.define(:deployed, :pending, :latest_built_sha, :succeeded_shas)
24
+ Snapshot = Data.define(:deployed, :pending, :release, :latest_built_sha, :succeeded_shas)
21
25
 
22
26
  Mine = Data.define(:sha, :had_own_build)
23
27
  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.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eugene