tebako-release 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 +7 -0
- data/LICENSE.md +24 -0
- data/README.md +66 -0
- data/exe/tebako-release +37 -0
- data/lib/tebako_release/adapter.rb +59 -0
- data/lib/tebako_release/bundler.rb +89 -0
- data/lib/tebako_release/config.rb +49 -0
- data/lib/tebako_release/error.rb +11 -0
- data/lib/tebako_release/platform.rb +76 -0
- data/lib/tebako_release/signer.rb +381 -0
- data/lib/tebako_release/uploader.rb +1636 -0
- data/lib/tebako_release/version.rb +9 -0
- data/lib/tebako_release.rb +41 -0
- metadata +72 -0
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Copyright (c) 2025-2026 [Ribose Inc](https://www.ribose.com).
|
|
4
|
+
# All rights reserved.
|
|
5
|
+
# This file is a part of tamatebako
|
|
6
|
+
#
|
|
7
|
+
# Redistribution and use in source and binary forms, with or without
|
|
8
|
+
# modification, are permitted provided that the following conditions
|
|
9
|
+
# are met:
|
|
10
|
+
# 1. Redistributions of source code must retain the above copyright
|
|
11
|
+
# notice, this list of conditions and the following disclaimer.
|
|
12
|
+
# 2. Redistributions in binary form must reproduce the above copyright
|
|
13
|
+
# notice, this list of conditions and the following disclaimer in the
|
|
14
|
+
# documentation and/or other materials provided with the distribution.
|
|
15
|
+
#
|
|
16
|
+
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
|
17
|
+
# ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
|
|
18
|
+
# TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
|
19
|
+
# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS
|
|
20
|
+
# BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
|
21
|
+
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
|
22
|
+
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
|
23
|
+
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
|
24
|
+
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
|
25
|
+
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
|
|
26
|
+
# THE POSSIBILITY OF SUCH DAMAGE.
|
|
27
|
+
|
|
28
|
+
require "octokit"
|
|
29
|
+
require "digest"
|
|
30
|
+
require "fileutils"
|
|
31
|
+
require "open3"
|
|
32
|
+
require "pathname"
|
|
33
|
+
require "tmpdir"
|
|
34
|
+
|
|
35
|
+
module TebakoRelease
|
|
36
|
+
# Signs one factory release (tebako spec 09 §5, the no-fold rule): EVERY
|
|
37
|
+
# served name carries its own detached OpenPGP .asc — the runtime
|
|
38
|
+
# packages, the env images, the dll facets, AND the derived metadata
|
|
39
|
+
# (the per-asset .sha256 sidecars, the per-package .manifest.json
|
|
40
|
+
# shards, the .contract.yaml cards). Nothing folds into a signed
|
|
41
|
+
# monolith: spec 13 §2a's de-rendezvous retired the monolithic
|
|
42
|
+
# manifest.json and SHA256SUMS.txt as release assets (each shard IS its
|
|
43
|
+
# release-index entry; consumers derive the monoliths from the shards),
|
|
44
|
+
# so no monolith .asc exists either. Each build leg signs its own fresh
|
|
45
|
+
# bytes in-leg, in the same invocation that published them — the
|
|
46
|
+
# write-once names that leg owns alone.
|
|
47
|
+
#
|
|
48
|
+
# The signing tool is the LATEST tamatebako/tebako release's tebako-pkg
|
|
49
|
+
# for THIS runner's platform (TEBAKO_PKG_HOST_ID overrides the
|
|
50
|
+
# detection), pinned by asset name and sha256-verified against that
|
|
51
|
+
# release's own sidecar before it runs. Every signed byte is
|
|
52
|
+
# provenance-checked against the release listing's digest: the leg's
|
|
53
|
+
# own workspace bytes are used only when they hash to the listed
|
|
54
|
+
# digest; a download that disagrees with the listing is never signed.
|
|
55
|
+
#
|
|
56
|
+
# Gate (the spec 31 §5 house style): TEBAKO_RELEASE_SIGNING_ENABLED=true
|
|
57
|
+
# arms the pass; armed + an empty TEBAKO_RELEASE_SIGNING_KEY is a fast
|
|
58
|
+
# named failure; disarmed exits 0 and the release ships unsigned
|
|
59
|
+
# (unsigned stays first-class — spec 09 §3). SIGN_ONLY_STEMS scopes the
|
|
60
|
+
# pass to the caller's own write-once names (the in-leg case); empty
|
|
61
|
+
# signs everything stale (the operator backfill case).
|
|
62
|
+
#
|
|
63
|
+
# The ONE implementation every factory consumes — lifted from
|
|
64
|
+
# tebako-runtime-ruby's scripts/sign_release.rb; the consuming repo is
|
|
65
|
+
# declared through TebakoRelease.configure (or TEBAKO_RELEASE_REPO),
|
|
66
|
+
# never by editing a copy.
|
|
67
|
+
class Signer # rubocop:disable Metrics/ClassLength
|
|
68
|
+
# Armed-but-cannot, provenance, and coverage failures: the pass never
|
|
69
|
+
# ships a partially signed release silently.
|
|
70
|
+
class SigningGateError < StandardError; end
|
|
71
|
+
|
|
72
|
+
# This run's fresh package bytes, materialized in the leg's workspace —
|
|
73
|
+
# signing prefers them over a re-download, but only when they hash to
|
|
74
|
+
# the release listing's digest (only a backfill onto an older release
|
|
75
|
+
# downloads). SIGN_LOCAL_DIR points a consumer whose legs stage bytes
|
|
76
|
+
# elsewhere (openjdk's out/<flavor>-<triplet>/) at this run's dir.
|
|
77
|
+
LOCAL_PACKAGES_DIR = "runtime-packages"
|
|
78
|
+
|
|
79
|
+
# upload convergence: a tiny metadata asset either lands or cycles;
|
|
80
|
+
# three bounded polls then a named failure.
|
|
81
|
+
CONVERGENCE_DELAYS = [5, 15, 30].freeze
|
|
82
|
+
|
|
83
|
+
# served-bytes convergence: a young release object lists an asset before
|
|
84
|
+
# the byte store serves it (the runtime-ruby 0.16.28 republish's
|
|
85
|
+
# sign-step class — the listing had converged, the download 404ed as
|
|
86
|
+
# "no assets"); bounded re-asks, then the named failure stands.
|
|
87
|
+
SERVED_BYTES_DELAYS = [5, 10, 20, 40, 80].freeze
|
|
88
|
+
|
|
89
|
+
def initialize(client: nil, executor: nil, env: ENV, config: nil)
|
|
90
|
+
@env = env
|
|
91
|
+
@config = config || TebakoRelease.config
|
|
92
|
+
@client = client || Octokit::Client.new(access_token: @env.fetch("GITHUB_TOKEN"), auto_paginate: true)
|
|
93
|
+
@executor = executor || ShellExecutor.new
|
|
94
|
+
# TEBAKO_RELEASE_TAG decouples the target tag from the version
|
|
95
|
+
# (the line-shard republication; asset names stay version-branded).
|
|
96
|
+
@tag = @env.fetch("TEBAKO_RELEASE_TAG") { "v#{@env.fetch("TEBAKO_VERSION")}" }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# The one public verb. Returns :disarmed or :signed.
|
|
100
|
+
def sign_release # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
101
|
+
unless enabled?
|
|
102
|
+
puts "release signing disarmed (TEBAKO_RELEASE_SIGNING_ENABLED != 'true') — unsigned-first (spec 09 §3)"
|
|
103
|
+
return :disarmed
|
|
104
|
+
end
|
|
105
|
+
if signing_key.empty?
|
|
106
|
+
raise SigningGateError,
|
|
107
|
+
"NAMED FAILURE: TEBAKO_RELEASE_SIGNING_ENABLED=true but the TEBAKO_RELEASE_SIGNING_KEY secret is not set"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
release = find_release
|
|
111
|
+
Dir.mktmpdir do |dir|
|
|
112
|
+
work = Pathname.new(dir)
|
|
113
|
+
tool = fetch_verified_tool(work)
|
|
114
|
+
key_file = materialize_key(work)
|
|
115
|
+
assets = @client.release_assets(release.url)
|
|
116
|
+
targets = signature_targets(assets.map(&:name))
|
|
117
|
+
stale = stale_targets(targets, assets)
|
|
118
|
+
puts "#{@tag}: #{targets.size} signature targets, #{stale.size} need (re)signing"
|
|
119
|
+
by_name = assets.to_h { |asset| [asset.name, asset] }
|
|
120
|
+
stale.each do |name|
|
|
121
|
+
digest = listed_sha(by_name.fetch(name))
|
|
122
|
+
if digest.empty?
|
|
123
|
+
raise SigningGateError,
|
|
124
|
+
"NAMED FAILURE: the release listing carries no digest for #{name} — " \
|
|
125
|
+
"signing needs the listing's sha256 to prove the signed bytes are the served bytes"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
sign_one(work, key_file, tool, release, name, digest)
|
|
129
|
+
end
|
|
130
|
+
assert_coverage!(release, targets)
|
|
131
|
+
end
|
|
132
|
+
:signed
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# The asset names that carry a .asc (spec 09 §5's no-fold rule): every
|
|
136
|
+
# served name — payloads, .sha256 sidecars, .manifest.json shards,
|
|
137
|
+
# .contract.yaml cards — except the .asc files themselves. SIGN_ONLY_STEMS
|
|
138
|
+
# scopes the set to the caller's write-once names: a name matches when it
|
|
139
|
+
# IS the stem or starts with "<stem>." (stems end in the platform id, so
|
|
140
|
+
# one package's stem can never swallow another package's names).
|
|
141
|
+
def signature_targets(asset_names)
|
|
142
|
+
names = asset_names.reject { |name| name.end_with?(".asc") }
|
|
143
|
+
stems = sign_only_stems
|
|
144
|
+
return names.sort if stems.empty?
|
|
145
|
+
|
|
146
|
+
names.select { |name| stems.any? { |stem| name == stem || name.start_with?("#{stem}.") } }.sort
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# The targets whose .asc is absent or older than the asset itself: a
|
|
150
|
+
# replaced asset invalidates its signature (new bytes), an untouched
|
|
151
|
+
# asset keeps it (a detached signature over unchanged bytes stays
|
|
152
|
+
# valid — re-signing would only churn the release).
|
|
153
|
+
def stale_targets(targets, assets)
|
|
154
|
+
by_name = assets.to_h { |asset| [asset.name, asset] }
|
|
155
|
+
targets.select do |name|
|
|
156
|
+
asc = by_name["#{name}.asc"]
|
|
157
|
+
asc.nil? || asc.updated_at < by_name.fetch(name).updated_at
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
private
|
|
162
|
+
|
|
163
|
+
def enabled?
|
|
164
|
+
@env["TEBAKO_RELEASE_SIGNING_ENABLED"] == "true"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def signing_key
|
|
168
|
+
(@env["TEBAKO_RELEASE_SIGNING_KEY"] || "").strip
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# The in-leg scope: comma/space-separated package stems this invocation
|
|
172
|
+
# owns (e.g. "tebako-runtime-0.17.0-3.4.2-macos-arm64"). Empty means the
|
|
173
|
+
# operator backfill case — every stale target on the release.
|
|
174
|
+
def sign_only_stems
|
|
175
|
+
(@env["SIGN_ONLY_STEMS"] || "").split(/[\s,]+/)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# This run's fresh-bytes dir (the LOCAL_PACKAGES_DIR constant's
|
|
179
|
+
# rationale): SIGN_LOCAL_DIR overrides it for consumers whose legs
|
|
180
|
+
# stage their publish bytes outside runtime-packages/.
|
|
181
|
+
def local_packages_dir
|
|
182
|
+
@env["SIGN_LOCAL_DIR"] || LOCAL_PACKAGES_DIR
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# The platform this pass runs on — the signing tool's asset name flows
|
|
186
|
+
# from it (TEBAKO_PKG_HOST_ID pins it in CI/specs; the Platform model
|
|
187
|
+
# detects it otherwise).
|
|
188
|
+
def tool_host_id
|
|
189
|
+
@tool_host_id ||= @env["TEBAKO_PKG_HOST_ID"] || Platform.new.host_id
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# The tebako-pkg asset name grammar on a tamatebako/tebako release, for
|
|
193
|
+
# this runner's platform (windows carries the .exe suffix).
|
|
194
|
+
def tool_asset_pattern
|
|
195
|
+
suffix = tool_host_id.start_with?("windows") ? ".exe" : ""
|
|
196
|
+
/\Atebako-pkg-\d+\.\d+\.\d+-#{Regexp.escape(tool_host_id)}#{Regexp.escape(suffix)}\z/
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def find_release
|
|
200
|
+
@client.release_for_tag(@config.repo, @tag)
|
|
201
|
+
rescue Octokit::NotFound
|
|
202
|
+
raise SigningGateError, "NAMED FAILURE: no release found for tag #{@tag} — nothing to sign"
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# The signing subkey export, base64-decoded to a 0600 file that lives
|
|
206
|
+
# and dies with the pass's tmpdir. The secret IS the base64 text —
|
|
207
|
+
# `[key].pack("m0")` (Array#pack) would ENCODE it a second time and an
|
|
208
|
+
# armed run could only die on rnp's BadFormat; the python factory's
|
|
209
|
+
# rehearsal (real tebako-pkg, throwaway key) caught it — the spec
|
|
210
|
+
# fakes never run real rnp. Garbage secrets fail named, never raw.
|
|
211
|
+
def materialize_key(work)
|
|
212
|
+
key_file = work.join("release-key.asc")
|
|
213
|
+
begin
|
|
214
|
+
key_file.write(signing_key.unpack1("m0"))
|
|
215
|
+
rescue ArgumentError
|
|
216
|
+
raise SigningGateError, "NAMED FAILURE: the TEBAKO_RELEASE_SIGNING_KEY secret is not valid base64"
|
|
217
|
+
end
|
|
218
|
+
key_file.chmod(0o600)
|
|
219
|
+
key_file
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# The latest tebako release's tebako-pkg for this runner's platform,
|
|
223
|
+
# provenance-pinned: downloaded with its .sha256 sidecar and executed
|
|
224
|
+
# only when the digest matches.
|
|
225
|
+
def fetch_verified_tool(work) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
226
|
+
latest = @client.latest_release(@config.tool_repo)
|
|
227
|
+
names = @client.release_assets(latest.url).map(&:name)
|
|
228
|
+
tool_name = names.find { |name| name.match?(tool_asset_pattern) }
|
|
229
|
+
unless tool_name
|
|
230
|
+
raise SigningGateError,
|
|
231
|
+
"NAMED FAILURE: no tebako-pkg #{tool_host_id} asset on #{latest.tag_name}"
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
tool_dir = work.join("tool")
|
|
235
|
+
FileUtils.mkdir_p(tool_dir)
|
|
236
|
+
@executor.run("gh", "release", "download", latest.tag_name, "--repo", @config.tool_repo,
|
|
237
|
+
"--pattern", tool_name, "--pattern", "#{tool_name}.sha256",
|
|
238
|
+
"--dir", tool_dir.to_s, "--clobber")
|
|
239
|
+
tool = tool_dir.join(tool_name)
|
|
240
|
+
want = tool_dir.join("#{tool_name}.sha256").read.split.first
|
|
241
|
+
actual = Digest::SHA256.file(tool).hexdigest
|
|
242
|
+
unless want == actual
|
|
243
|
+
raise SigningGateError,
|
|
244
|
+
"NAMED FAILURE: the signing tool #{tool_name} failed its provenance check " \
|
|
245
|
+
"(expected #{want}, got #{actual})"
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
tool.chmod(0o755)
|
|
249
|
+
tool.to_s
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# One stale target: the leg's own workspace bytes when they hash to the
|
|
253
|
+
# release listing's digest, otherwise a digest-verified download; sign,
|
|
254
|
+
# verify against the freshly registered key, then converge the .asc onto
|
|
255
|
+
# the release. The digest is the no-fold rule's provenance: the signed
|
|
256
|
+
# bytes are provably the bytes the release serves.
|
|
257
|
+
def sign_one(work, key_file, tool, release, name, digest) # rubocop:disable Metrics/AbcSize, Metrics/ParameterLists
|
|
258
|
+
local = Pathname.new(local_packages_dir).join(name)
|
|
259
|
+
target = if local.exist? && Digest::SHA256.file(local).hexdigest == digest
|
|
260
|
+
local
|
|
261
|
+
else
|
|
262
|
+
download_served_bytes(work.join("assets"), name, digest)
|
|
263
|
+
end
|
|
264
|
+
@executor.run(tool, "sign", "--key-file", key_file.to_s, "--no-sums", name, chdir: File.dirname(target.to_s))
|
|
265
|
+
@executor.run(tool, "verify", name, chdir: File.dirname(target.to_s))
|
|
266
|
+
converge_asc(release, Pathname.new(File.join(File.dirname(target.to_s), "#{name}.asc")))
|
|
267
|
+
puts "#{name}: signed and converged"
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# The backfill byte source: download the served asset and refuse to sign
|
|
271
|
+
# anything but the listing's bytes. A digest mismatch after a successful
|
|
272
|
+
# download is a hard provenance failure, never retried.
|
|
273
|
+
def download_served_bytes(dir, name, digest)
|
|
274
|
+
FileUtils.mkdir_p(dir)
|
|
275
|
+
download_when_served(dir, name)
|
|
276
|
+
target = dir.join(name)
|
|
277
|
+
actual = Digest::SHA256.file(target).hexdigest
|
|
278
|
+
return target if actual == digest
|
|
279
|
+
|
|
280
|
+
raise SigningGateError,
|
|
281
|
+
"NAMED FAILURE: refusing to sign bytes the release does not serve — " \
|
|
282
|
+
"#{name} downloaded with sha256 #{actual}, the listing says #{digest}"
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# The bounded re-ask for the young-release-object lag: the name came FROM
|
|
286
|
+
# the release listing, so gh's "no assets to download" is the byte store
|
|
287
|
+
# trailing the listing, never absence — it retries; every other named
|
|
288
|
+
# failure (auth, usage, a genuinely gone release) raises at once.
|
|
289
|
+
def download_when_served(dir, name)
|
|
290
|
+
pauses = SERVED_BYTES_DELAYS.dup
|
|
291
|
+
begin
|
|
292
|
+
@executor.run("gh", "release", "download", @tag, "--repo", @config.repo,
|
|
293
|
+
"--pattern", name, "--dir", dir.to_s, "--clobber")
|
|
294
|
+
rescue SigningGateError => e
|
|
295
|
+
raise unless e.message.include?("no assets to download") && (pause = pauses.shift)
|
|
296
|
+
|
|
297
|
+
puts "#{name} is listed but not served yet (young release object) — re-asking in #{pause}s"
|
|
298
|
+
sleep pause
|
|
299
|
+
retry
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# A tiny metadata upload, converged: replace whatever the name serves,
|
|
304
|
+
# then poll until the listing's digest is our bytes (the edge cache
|
|
305
|
+
# lesson of the uploader, bounded).
|
|
306
|
+
def converge_asc(release, asc_file)
|
|
307
|
+
sha = Digest::SHA256.file(asc_file).hexdigest
|
|
308
|
+
converged = false
|
|
309
|
+
CONVERGENCE_DELAYS.each do |pause|
|
|
310
|
+
converged = asc_converged?(release, asc_file, sha)
|
|
311
|
+
break if converged
|
|
312
|
+
|
|
313
|
+
puts "#{asc_file.basename} has not converged on the release yet; cycling in #{pause}s"
|
|
314
|
+
sleep pause
|
|
315
|
+
end
|
|
316
|
+
raise SigningGateError, "NAMED FAILURE: #{asc_file.basename} did not converge on #{@tag}" unless converged
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# One convergence cycle: the listing already serving our bytes is done;
|
|
320
|
+
# anything else is deleted/replaced and re-uploaded for the next poll.
|
|
321
|
+
# A 422 mid-replace is the deletion-propagation race (the wedge
|
|
322
|
+
# lesson): the name unblocks within a cycle, so it rides along as
|
|
323
|
+
# not-yet-converged instead of crashing the pass.
|
|
324
|
+
def asc_converged?(release, asc_file, sha) # rubocop:disable Metrics/AbcSize
|
|
325
|
+
existing = @client.release_assets(release.url).find { |asset| asset.name == asc_file.basename.to_s }
|
|
326
|
+
return true if existing && listed_sha(existing) == sha
|
|
327
|
+
|
|
328
|
+
@client.delete_release_asset(existing.id) if existing
|
|
329
|
+
@client.upload_asset(release.url, asc_file.to_s,
|
|
330
|
+
content_type: "text/plain",
|
|
331
|
+
name: asc_file.basename.to_s)
|
|
332
|
+
false
|
|
333
|
+
rescue Octokit::UnprocessableEntity => e
|
|
334
|
+
puts "#{asc_file.basename}: replace raced the 422 propagation window (#{e.class}) — cycling"
|
|
335
|
+
false
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# The coverage assertion: after the pass, every target has a .asc on
|
|
339
|
+
# the release — a partially signed release is a named failure, never a
|
|
340
|
+
# quiet state.
|
|
341
|
+
def assert_coverage!(release, targets)
|
|
342
|
+
names = @client.release_assets(release.url).map(&:name)
|
|
343
|
+
missing = targets.reject { |name| names.include?("#{name}.asc") }
|
|
344
|
+
return if missing.empty?
|
|
345
|
+
|
|
346
|
+
raise SigningGateError,
|
|
347
|
+
"NAMED FAILURE: #{missing.size} signature(s) missing on #{@tag}: #{missing.join(", ")}"
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# The listing's digest field is "sha256:<hex>" when the API serves one.
|
|
351
|
+
def listed_sha(asset)
|
|
352
|
+
asset.digest.to_s.sub(/\Asha256:/, "")
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# The default command seam: argv in, stdout out, named failure on a
|
|
356
|
+
# non-zero exit. Specs inject a recording stand-in.
|
|
357
|
+
class ShellExecutor
|
|
358
|
+
# gh's release-asset edges are transient-prone under release-storm
|
|
359
|
+
# load: the 5xx class and the intermediary 403 clear on a re-ask (the
|
|
360
|
+
# 0.16.24 publish lost a signing leg to an HTTP 500 on a manifest
|
|
361
|
+
# download — after every asset had already converged). Deterministic
|
|
362
|
+
# failures (404s, auth, usage) raise at once. Bounded, with backoff.
|
|
363
|
+
TRANSIENT = /HTTP 5\d\d|intermediary/i
|
|
364
|
+
ATTEMPTS = 4
|
|
365
|
+
|
|
366
|
+
def run(*argv, chdir: ".")
|
|
367
|
+
attempts = 0
|
|
368
|
+
loop do
|
|
369
|
+
out, err, status = Open3.capture3(*argv, chdir: chdir)
|
|
370
|
+
break out if status.success?
|
|
371
|
+
|
|
372
|
+
unless err =~ TRANSIENT && (attempts += 1) < ATTEMPTS
|
|
373
|
+
raise SigningGateError,
|
|
374
|
+
"NAMED FAILURE: `#{argv.join(" ")}` exited #{status.exitstatus}: #{err.strip}"
|
|
375
|
+
end
|
|
376
|
+
sleep(2**attempts)
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
end
|