onair-cli 0.4.0 → 0.5.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: e6929334a2f930a5e7e05e43067c8442ae9b85d3dc9967c960c4ff9c0e34ebc7
4
- data.tar.gz: 4f029777e9e3ee5aabc3977ad930ba2cf35d82a4c5e9c76a0995197d250ae389
3
+ metadata.gz: 863a5cc9f6796e525c125ed320542a3e4d8b076b5a6a5660744c9136ead84cc2
4
+ data.tar.gz: c771ccb2bd0c72fcf075e48c829556de327ad4dc3b2527db4ca0b5b0ee763e34
5
5
  SHA512:
6
- metadata.gz: c85de1face07ebb3c04cadc6001ac5e92edff4b3632f87f1f79158f212c3063761ee6efdfdf69709bfe09251b0d0c5271dec5aaa38291b84b3cc523f705d5f3e
7
- data.tar.gz: 3325a141acd7f90491a2b9c547097acbd68b1eba14d72462d518e61e564923a6c5e332263eab7403f99a751bb7278acf47a0d98d2f010492267796ad797e0538
6
+ metadata.gz: e8016ad8033b28ec15e73cdae96b1b0e94562a4bc28226dfd5b556c8a3a24dc713106b9f8fe5b3c3881c330e71f9a297c768c0a2c9175114eccfdd4cc4eae76d
7
+ data.tar.gz: f78628cf04574876bc46322b9eba02d8f252152c90d7bc75bd032041be4a9f3741f6f3619d01693a3f9f098e18fcd64c6e14e722c27e65d51640a1bf5e7660e7
data/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.5.0] - 2026-11-02
6
+
7
+ - GitHub Deployments: `platform: github_deployments` reads one environment's
8
+ deployments (`environment:`, default `production`) through the GitHub API
9
+ with the `gh` token, so anyone who can read the repo sees what is deployed
10
+ without host access. The newest successful deployment is Deployed, a newer
11
+ in-flight one is Pending with its commit, and a newer failed one is Failed.
12
+ It reports what CI recorded, not what the host runs.
13
+ - `app:` is required only by the platforms that use it. `--json` gains an
14
+ `environment` field, and `deployed` is `null` when nothing successful is deployed yet.
15
+
5
16
  ## [0.4.0] - 2026-11-02
6
17
 
7
18
  - Dokku: `platform: dokku` with a `host:` reads the running commit from the
data/README.md CHANGED
@@ -28,6 +28,8 @@ case.
28
28
  ## Principles
29
29
 
30
30
  1. **Truth over convenience.** What's *running*, not what was built last.
31
+ The one exception is opt-in: `platform: github_deployments` reports what
32
+ CI recorded, not what the host runs (see [GitHub Deployments](#github-deployments)).
31
33
  2. **Zero runtime dependencies.** Ruby stdlib only.
32
34
  3. **Read-only.** Never deploys, never rolls back, never writes to git except
33
35
  an explicit lazy `git fetch`.
@@ -123,6 +125,65 @@ another way (`git:from-image`, `git:sync`) shows the deploy source instead
123
125
  of a commit. Dokku builds record no commit, so `Pending` and `Failed` name
124
126
  the build, and pinned detection stays silent.
125
127
 
128
+ ### GitHub Deployments
129
+
130
+ For teams whose CI deploys to a host most developers cannot reach (a Dokku
131
+ box behind a firewall, a VPS where only admins hold SSH keys). If CI records
132
+ each deploy as a GitHub Deployment, anyone who can read the repo sees what is
133
+ deployed, with no access to the host.
134
+
135
+ ```yaml
136
+ platform: github_deployments
137
+ repo: acme/widgets # default: inferred from origin
138
+ environment: production # default: production
139
+ ```
140
+
141
+ No `app:` is needed. onair reads the environment's last 10 deployments and
142
+ the newest status of each through the GitHub API, with the token from
143
+ `GH_TOKEN`, `GITHUB_TOKEN` or `gh auth token`; without one it asks you to run
144
+ `gh auth login`. The token needs read access to the repo's deployments.
145
+
146
+ - **Deployed** is the newest deployment whose status is `success`.
147
+ - **Pending** is a newer deployment that is `queued`, `pending` or
148
+ `in_progress`, with its commit.
149
+ - **Failed** is a newer deployment whose status is `failure` or `error`.
150
+ - A deployment with no status yet is ignored.
151
+ - With no successful deployment among the last 10, the report says so in one
152
+ line, under any Pending or Failed row.
153
+ - If the status of a deployment newer than the deployed one cannot be read,
154
+ the report fails rather than show an older commit as running.
155
+
156
+ **This adapter reports what CI recorded, not what the host runs.** A deploy
157
+ that bypassed CI stays invisible until the next recorded one, and a CI job
158
+ that records the sha it pushed reports that sha even if the host ended up
159
+ running something else. So have CI record the sha it reads back from the host
160
+ after the deploy, not the sha it pushed. For example, in GitHub Actions:
161
+
162
+ ```yaml
163
+ permissions:
164
+ deployments: write
165
+ steps:
166
+ - uses: actions/checkout@v4
167
+ - name: Deploy
168
+ env:
169
+ GH_TOKEN: ${{ github.token }}
170
+ run: |
171
+ id=$(gh api repos/${{ github.repository }}/deployments \
172
+ -f ref=${{ github.sha }} -f environment=production \
173
+ -F auto_merge=false -f 'required_contexts[]' --jq .id)
174
+ gh api repos/${{ github.repository }}/deployments/$id/statuses -f state=in_progress
175
+ if ./deploy.sh && [ "$(./running-sha.sh)" = "${{ github.sha }}" ]; then
176
+ state=success
177
+ else
178
+ state=failure
179
+ fi
180
+ gh api repos/${{ github.repository }}/deployments/$id/statuses -f state=$state
181
+ ```
182
+
183
+ Here `deploy.sh` deploys and `running-sha.sh` asks the host which commit it
184
+ runs. When the host runs a different commit than the one pushed, the job
185
+ records a failure rather than a false success.
186
+
126
187
  ### Task links (optional)
127
188
 
128
189
  If your commit subjects carry task ids (Jira, Linear, Notion, anything), tell
@@ -149,6 +210,7 @@ additively:
149
210
  "platform": "heroku",
150
211
  "branch": "main",
151
212
  "repo": "acme/widgets",
213
+ "environment": null,
152
214
  "remote_head": "<sha>",
153
215
  "deployed": {
154
216
  "sha": "<sha>", "version": 1234, "description": "Deploy a1b2c3d4",
@@ -171,6 +233,9 @@ additively:
171
233
  ```
172
234
 
173
235
  - `delta.status` is `"current"`, `"behind"`, or `"unknown"`.
236
+ - `environment` is the GitHub Deployments environment read, `null` on other
237
+ platforms. With `platform: github_deployments`, `app` is `null`, and
238
+ `deployed` is `null` when no deployment among the last 10 succeeded.
174
239
  - `pending`: `{ "sha", "build_id", "started_at", "subject", "author" }` when
175
240
  a build is in flight. On Dokku `sha` is `null` and `build_id` names the
176
241
  build; on Heroku `build_id` is `null`.
data/lib/onair/cli.rb CHANGED
@@ -62,17 +62,17 @@ module Onair
62
62
  def status(flags)
63
63
  config = Config.resolve(flags)
64
64
  git = Git.new(fetch_allowed: config.fetch)
65
- adapter = Platform.build(config)
66
65
  repo = config.repo || git.origin_repo
66
+ adapter = Platform.build(config, repo: repo)
67
67
  now = Time.now
68
68
  report = Orchestrator.new(config: config, adapter: adapter, git: git, repo: repo, now: now).run
69
69
 
70
70
  if flags[:json]
71
71
  puts Renderer::Json.new(report: report, app: config.app, platform: config.platform,
72
- branch: config.branch, repo: repo, task: config.task).render
72
+ branch: config.branch, repo: repo, environment: adapter.environment, task: config.task).render
73
73
  else
74
74
  tty = $stdout.tty?
75
- print Renderer::Tty.new(report: report, app: config.app, platform_label: adapter.display_name,
75
+ print Renderer::Tty.new(report: report, app: adapter.target_label, platform_label: adapter.display_name,
76
76
  branch: config.branch, repo: repo, task: config.task,
77
77
  color: tty && !no_color?, hyperlinks: tty, now: now).render
78
78
  end
data/lib/onair/config.rb CHANGED
@@ -8,13 +8,16 @@ module Onair
8
8
  class Config
9
9
  FILENAME = ".onair.yml"
10
10
 
11
- attr_reader :platform, :app, :repo, :branch, :fetch, :task, :host
11
+ DEFAULT_ENVIRONMENT = "production"
12
12
 
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)
13
+ attr_reader :platform, :app, :repo, :branch, :fetch, :task, :host, :environment
14
+
15
+ # `app`, `host` and `environment` are each read by the adapters that need
16
+ # them; those adapters check them, so any may be nil elsewhere.
17
+ def initialize(platform:, app:, repo:, branch:, fetch:, task: nil, host: nil, environment: nil)
16
18
  @platform = platform
17
19
  @host = host
20
+ @environment = environment
18
21
  @app = app
19
22
  @repo = repo
20
23
  @branch = branch
@@ -27,7 +30,6 @@ module Onair
27
30
  platform = file["platform"] || "heroku"
28
31
  # HEROKU_APP names a Heroku app, so it must not redirect another platform.
29
32
  app = flags[:app] || ((platform == "heroku") ? env["HEROKU_APP"] : nil) || file["app"]
30
- raise Error, "no app configured - pass --app NAME, set HEROKU_APP, or run `onair init`" if app.nil?
31
33
 
32
34
  new(
33
35
  platform: platform,
@@ -36,7 +38,8 @@ module Onair
36
38
  branch: file["branch"] || "main",
37
39
  fetch: !flags[:no_fetch],
38
40
  task: TaskLink.from_config(file["task"]),
39
- host: file["host"]
41
+ host: file["host"],
42
+ environment: file["environment"] || DEFAULT_ENVIRONMENT
40
43
  )
41
44
  end
42
45
 
@@ -9,21 +9,23 @@ module Onair
9
9
  @registry[name] = klass
10
10
  end
11
11
 
12
- def build(config)
12
+ def build(config, repo: nil)
13
13
  klass = @registry[config.platform]
14
14
  if klass.nil?
15
15
  raise Error,
16
16
  "unknown platform #{config.platform.inspect} (available: #{@registry.keys.join(", ")})"
17
17
  end
18
18
 
19
- klass.new(config)
19
+ klass.new(config, repo: repo)
20
20
  end
21
21
  end
22
22
 
23
23
  # Adapter contract: implement #snapshot returning a Snapshot.
24
24
  #
25
25
  # deployed: the release CURRENTLY RUNNING (nil sha only if
26
- # truly unresolvable)
26
+ # truly unresolvable), or nil when the platform
27
+ # records no successful deploy; renderers then show
28
+ # one line in its place
27
29
  # pending: newest in-flight build, or nil
28
30
  # release: newest release above the running one that is not
29
31
  # running (release phase pending or failed), or nil
@@ -40,8 +42,12 @@ module Onair
40
42
  # (delta, pinned, mine, rendering) is platform-agnostic and must not
41
43
  # reference any specific platform.
42
44
  class Base
43
- def initialize(config)
45
+ NO_APP = "no app configured - pass --app NAME, set HEROKU_APP, or run `onair init`"
46
+
47
+ # `repo` is the GitHub repo (config or origin remote), or nil.
48
+ def initialize(config, repo: nil)
44
49
  @config = config
50
+ @repo = repo
45
51
  end
46
52
 
47
53
  def snapshot
@@ -52,6 +58,16 @@ module Onair
52
58
  self.class.name.split("::").last
53
59
  end
54
60
 
61
+ # What the report's header names next to the platform.
62
+ def target_label
63
+ @config.app
64
+ end
65
+
66
+ # The deploy environment the adapter reads, for platforms that have one.
67
+ def environment
68
+ nil
69
+ end
70
+
55
71
  private
56
72
 
57
73
  # Exceptions surface via #value; without this the dying thread also
@@ -58,6 +58,7 @@ module Onair
58
58
  def validate!
59
59
  raise Error, "no host configured - add `host:` to #{Config::FILENAME}" if host.to_s.empty?
60
60
  raise Error, "invalid Dokku host #{host.inspect}" if host.start_with?("-")
61
+ raise Error, NO_APP if app.nil?
61
62
  raise Error, "invalid Dokku app name #{app.inspect}" unless APP_NAME.match?(app)
62
63
  end
63
64
 
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "time"
6
+ require "uri"
7
+
8
+ module Onair
9
+ module Platform
10
+ # Reads what CI recorded as GitHub Deployments for one environment, so
11
+ # anyone who can read the repo sees production without host access. It
12
+ # reports CI's record, not what the host runs.
13
+ class GithubDeployments < Base
14
+ HOST = "api.github.com"
15
+ API_VERSION = "2022-11-28"
16
+ WINDOW = 10
17
+ # owner/name, with no all-dot segment, since the repo goes into a URL path.
18
+ REPO_NAME = %r{\A(?!\.+/)[\w.-]+/(?!\.+\z)[\w.-]+\z}
19
+ SUCCESS = "success"
20
+ # GitHub marks an older successful deployment inactive once a newer one
21
+ # in the environment succeeds, so both states count as deployed once.
22
+ SUCCEEDED_STATES = %w[success inactive].freeze
23
+ IN_FLIGHT_STATES = %w[queued pending in_progress].freeze
24
+ FAILED_STATES = %w[failure error].freeze
25
+
26
+ def initialize(config, repo: nil)
27
+ super
28
+ raise Error, "no GitHub repo - add `repo: owner/name` to #{Config::FILENAME}" if @repo.to_s.empty?
29
+ raise Error, "invalid GitHub repo #{@repo.inspect} - expected owner/name" unless REPO_NAME.match?(@repo)
30
+ end
31
+
32
+ def snapshot
33
+ token = resolve_token
34
+ rows = with_statuses(token, deployments(token))
35
+ # A deployment whose status could not be read may be the one running,
36
+ # so any such row above the newest success fails the report.
37
+ index = rows.index { |row| row[:error] || row[:state] == SUCCESS }
38
+ raise rows[index][:error] if index && rows[index][:error]
39
+
40
+ newer = index ? rows.first(index) : rows
41
+ succeeded = rows.select { |row| SUCCEEDED_STATES.include?(row[:state]) }.map { |row| row[:sha] }.uniq
42
+ Snapshot.new(deployed: index && deployed(rows[index]), pending: pending(newer), release: failed(newer),
43
+ latest_built_sha: succeeded.first, succeeded_shas: succeeded, rollout: nil)
44
+ end
45
+
46
+ def display_name
47
+ "GitHub"
48
+ end
49
+
50
+ def target_label
51
+ "#{@repo} (#{environment})"
52
+ end
53
+
54
+ def environment
55
+ @config.environment || Config::DEFAULT_ENVIRONMENT
56
+ end
57
+
58
+ private
59
+
60
+ def resolve_token
61
+ token = Auth::GithubToken.token
62
+ raise Error, "no GitHub token found - run `gh auth login`" if token.nil? || token.empty?
63
+
64
+ token
65
+ end
66
+
67
+ def deployments(token)
68
+ query = URI.encode_www_form(environment: environment, per_page: WINDOW)
69
+ with_http { |http| get(http, token, "/repos/#{@repo}/deployments?#{query}") }
70
+ end
71
+
72
+ # Pairs each deployment with its newest status, newest deployment first.
73
+ # A failed statuses call keeps its row with the error, for #snapshot to
74
+ # judge; a deployment with no status yet says nothing, so it is skipped.
75
+ def with_statuses(token, deployments)
76
+ threads = deployments.map do |deployment|
77
+ quiet_thread do
78
+ newest_status(token, deployment["id"])
79
+ rescue Error => e
80
+ e
81
+ end
82
+ end
83
+ deployments.zip(threads.map(&:value)).filter_map do |deployment, status|
84
+ next if status.nil?
85
+
86
+ row = {id: deployment["id"], sha: deployment["sha"], created_at: parse_time(deployment["created_at"])}
87
+ next row.merge(error: status) if status.is_a?(Error)
88
+
89
+ row.merge(state: status["state"], status_at: parse_time(status["created_at"]))
90
+ end.sort_by { |row| row[:created_at] || Time.at(0) }.reverse
91
+ end
92
+
93
+ def newest_status(token, id)
94
+ with_http { |http| get(http, token, "/repos/#{@repo}/deployments/#{id}/statuses?per_page=1") }.first
95
+ end
96
+
97
+ def deployed(row)
98
+ Deployed.new(sha: row[:sha], version: nil, description: "deployment #{row[:id]}",
99
+ deployed_at: row[:status_at])
100
+ end
101
+
102
+ def pending(newer)
103
+ row = newer.find { |candidate| IN_FLIGHT_STATES.include?(candidate[:state]) }
104
+ row && Pending.new(sha: row[:sha], started_at: row[:created_at])
105
+ end
106
+
107
+ def failed(newer)
108
+ row = newer.find { |candidate| FAILED_STATES.include?(candidate[:state]) }
109
+ row && Release.new(sha: row[:sha], version: nil, description: "deployment #{row[:id]}",
110
+ status: :failed, started_at: row[:status_at])
111
+ end
112
+
113
+ def with_http(&)
114
+ Net::HTTP.start(HOST, 443, use_ssl: true, open_timeout: 5, read_timeout: 15, &)
115
+ rescue Net::OpenTimeout, SocketError, SystemCallError, OpenSSL::SSL::SSLError => e
116
+ raise Error, "GitHub API request failed: #{e.message}"
117
+ end
118
+
119
+ def get(http, token, path)
120
+ request = Net::HTTP::Get.new(path)
121
+ request["Authorization"] = "Bearer #{token}"
122
+ request["Accept"] = "application/vnd.github+json"
123
+ request["X-GitHub-Api-Version"] = API_VERSION
124
+ response = http.request(request)
125
+ raise Error, error_message(response, path) unless response.is_a?(Net::HTTPSuccess)
126
+
127
+ JSON.parse(response.body)
128
+ rescue JSON::ParserError
129
+ raise Error, "GitHub API returned invalid JSON for #{path}"
130
+ rescue Net::OpenTimeout, Net::ReadTimeout, SocketError, SystemCallError, OpenSSL::SSL::SSLError => e
131
+ raise Error, "GitHub API request failed: #{e.message}"
132
+ end
133
+
134
+ def error_message(response, path)
135
+ case response.code.to_i
136
+ when 401 then "GitHub rejected the token (401) - run `gh auth login`"
137
+ when 404 then "GitHub repo not found or not visible to this token: #{@repo}"
138
+ when 403, 429
139
+ if response["x-ratelimit-remaining"] == "0"
140
+ "GitHub API rate limit reached - try again later"
141
+ else
142
+ "GitHub API returned #{response.code} for #{path}"
143
+ end
144
+ else "GitHub API returned #{response.code} for #{path}"
145
+ end
146
+ end
147
+
148
+ def parse_time(value)
149
+ value && Time.iso8601(value)
150
+ rescue ArgumentError
151
+ nil
152
+ end
153
+
154
+ Platform.register("github_deployments", self)
155
+ end
156
+ end
157
+ end
@@ -18,6 +18,11 @@ module Onair
18
18
  PREBOOT_HANDOFF = 180
19
19
  PREBOOT_NOTE = "preboot: previous web dynos may still serve"
20
20
 
21
+ def initialize(config, repo: nil)
22
+ super
23
+ raise Error, NO_APP if app.nil?
24
+ end
25
+
21
26
  def snapshot
22
27
  token = resolve_token
23
28
  releases_thread = quiet_thread { releases(token) }
@@ -8,8 +8,9 @@ module Onair
8
8
  # Machine-readable status. The schema is a public API documented in the
9
9
  # README — additive changes only.
10
10
  class Json
11
- def initialize(report:, app:, platform:, branch:, repo:, task: nil)
11
+ def initialize(report:, app:, platform:, branch:, repo:, environment: nil, task: nil)
12
12
  @report = report
13
+ @environment = environment
13
14
  @app = app
14
15
  @platform = platform
15
16
  @branch = branch
@@ -27,6 +28,7 @@ module Onair
27
28
  platform: @platform,
28
29
  branch: @branch,
29
30
  repo: @repo,
31
+ environment: @environment,
30
32
  remote_head: @report.remote_head,
31
33
  deployed: deployed_payload,
32
34
  pending: pending_payload,
@@ -65,14 +65,27 @@ module Onair
65
65
  return [] if release.nil?
66
66
  return build_failed_lines(release) if release.sha.nil?
67
67
 
68
+ label, color, note = release.version.nil? ? versionless_release(release) : versioned_release(release)
69
+ row_lines(label, color, release.sha, age(release.started_at), extra: paint(note, color)) + [""]
70
+ end
71
+
72
+ def versioned_release(release)
68
73
  detail = version_label(release.version)
69
74
  detail = [detail, release.description].compact.join(": ") if release.sha == snapshot.deployed&.sha
70
- label, color, note = if release.status == :failed
75
+ if release.status == :failed
71
76
  ["Failed:", :red, "✗ release phase failed (#{detail})"]
72
77
  else
73
78
  ["Releasing:", :yellow, "⟳ release phase (#{detail})"]
74
79
  end
75
- row_lines(label, color, release.sha, age(release.started_at), extra: paint(note, color)) + [""]
80
+ end
81
+
82
+ # A platform that numbers no releases names the deploy by its description.
83
+ def versionless_release(release)
84
+ if release.status == :failed
85
+ ["Failed:", :red, "✗ deploy failed (#{release.description})"]
86
+ else
87
+ ["Releasing:", :yellow, "⟳ deploying (#{release.description})"]
88
+ end
76
89
  end
77
90
 
78
91
  # A failed build whose commit the platform does not record.
@@ -83,15 +96,17 @@ module Onair
83
96
 
84
97
  def deployed_lines
85
98
  deployed = snapshot.deployed
86
- if deployed&.sha
99
+ return [" #{paint("No successful deploy recorded.", :yellow)}"] if deployed.nil?
100
+
101
+ if deployed.sha
87
102
  lines = row_lines("Deployed:", :green, deployed.sha, age(deployed.deployed_at), extra: delta_text)
88
103
  lines << rollout_line if @report.rollout
89
104
  lines << pinned_line if @report.pinned
90
105
  lines.concat(yours_lines)
91
106
  lines
92
107
  else
93
- subject = ["the running commit", version_label(deployed&.version)].compact.join(" for ")
94
- [" #{paint("Could not resolve #{subject} (#{deployed&.description}).", :yellow)}"]
108
+ subject = ["the running commit", version_label(deployed.version)].compact.join(" for ")
109
+ [" #{paint("Could not resolve #{subject} (#{deployed.description}).", :yellow)}"]
95
110
  end
96
111
  end
97
112
 
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.4.0"
4
+ VERSION = "0.5.0"
5
5
  end
data/lib/onair.rb CHANGED
@@ -64,6 +64,7 @@ require_relative "onair/remote_head"
64
64
  require_relative "onair/platform/base"
65
65
  require_relative "onair/platform/heroku"
66
66
  require_relative "onair/platform/dokku"
67
+ require_relative "onair/platform/github_deployments"
67
68
  require_relative "onair/renderer/tty"
68
69
  require_relative "onair/renderer/json"
69
70
  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.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eugene
@@ -32,6 +32,7 @@ files:
32
32
  - lib/onair/orchestrator.rb
33
33
  - lib/onair/platform/base.rb
34
34
  - lib/onair/platform/dokku.rb
35
+ - lib/onair/platform/github_deployments.rb
35
36
  - lib/onair/platform/heroku.rb
36
37
  - lib/onair/remote_head.rb
37
38
  - lib/onair/renderer/json.rb