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.
@@ -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