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,1636 @@
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 "json"
31
+ require "pathname"
32
+ require "tmpdir"
33
+ require "yaml"
34
+
35
+ module TebakoRelease
36
+ # Upload release manager for the tebako factory build workflows — the
37
+ # ONE implementation every runtime factory consumes (spec 00 §10: the
38
+ # machinery has exactly one owner; factories declare identity + policy
39
+ # through TebakoRelease.configure and never carry a copy).
40
+ #
41
+ # Lifted from tebako-runtime-ruby's scripts/upload_release.rb, where
42
+ # every convergence/retry rule below was earned against a named incident
43
+ # (the comments carry the dates); the factory-specific seams — repo,
44
+ # language version key/grammar, capability gates, DLL naming — flow
45
+ # through the Config/Adapter.
46
+ class Uploader # rubocop:disable Metrics/ClassLength
47
+ # Named error (spec 00: named errors, never silent fallbacks): a release
48
+ # asset's deletion did not stop the name from being listed within the
49
+ # propagation deadline — the name stays 422-blocked server-side.
50
+ class DeletionPropagationTimeout < StandardError; end
51
+
52
+ # The rate-limit ride-out has a bound: two full hourly windows waited in
53
+ # one process means something is systemically wrong — give up loudly
54
+ # instead of blocking the runner forever.
55
+ class RateLimitBudgetExhausted < StandardError; end
56
+
57
+ # The era-2 release card (spec 18 C2): every runtime package carries a
58
+ # builder-emitted `<package>.contract.yaml` sidecar (contract_era,
59
+ # image_layout, mount_root, built_from) that manifest_entry folds into
60
+ # the manifest.json entry. A package without it — or one declaring an
61
+ # era this pipeline does not speak — is refused by name (fail closed;
62
+ # S11/S16): a manifest entry never goes out under-declared.
63
+ CONTRACT_SIDECAR_SUFFIX = ".contract.yaml"
64
+ CONTRACT_ERA = 2
65
+
66
+ # Per-asset metadata, write-once, no shared name (the de-rendezvous —
67
+ # spec 13 §2a): the release's asset listing IS the package index.
68
+ # Every payload asset ships with a `<asset>.sha256` sidecar in the
69
+ # store's trust-anchor shape (spec 00 §8: "<sha256> <filename>\n"),
70
+ # and every package with a `<stem>.manifest.json` shard carrying
71
+ # exactly its manifest entry (on signing-enabled lines also declaring
72
+ # each artifact's `signature` block — spec 09 §5). The leg that built
73
+ # a package uploads its own assets and metadata and nothing else — the
74
+ # monolithic manifest.json / SHA256SUMS.txt are NEVER release assets
75
+ # (they are consumer-side derivations from the shards + the asset
76
+ # listing), the release notes are written once at creation, and no
77
+ # invocation ever read-modify-writes a name another leg owns (the
78
+ # 2026-08-29 partial-merge + 422-wedge incident; the 2026-09-12
79
+ # finalize wedge — same physics: aggregation forces mutation, mutation
80
+ # forces delete-then-replace, replace is the wedge). BACKFILL_METADATA
81
+ # is the one-shot migration that writes shards/sidecars onto a
82
+ # pre-shard release.
83
+ SHARD_SUFFIX = ".manifest.json"
84
+ SIDECAR_SUFFIX = ".sha256"
85
+
86
+ # Upload-sized request timeouts: the release assets are 50–200 MB and
87
+ # the runner→uploads.github.com link has written slower than the
88
+ # 60 s Faraday default twice in one publish day (the v0.16.3 gnu/musl
89
+ # retries). Ten minutes of write patience; open/read ride along. The
90
+ # retry budget above rides OUT the failures; this makes them rare.
91
+ CLIENT_CONNECTION_OPTIONS = {
92
+ request: { open_timeout: 30, timeout: 600, write_timeout: 600 }
93
+ }.freeze
94
+
95
+ def initialize(client: nil, config: nil)
96
+ @config = config || TebakoRelease.config
97
+ validate_environment
98
+ # A fresh copy per client: Octokit/Faraday mutate the request options
99
+ # mid-request ("can't modify frozen Hash" killed the musl publish).
100
+ @client = client || Octokit::Client.new(access_token: ENV.fetch("GITHUB_TOKEN"), auto_paginate: true,
101
+ connection_options: { request: CLIENT_CONNECTION_OPTIONS[:request].dup })
102
+ @version = ENV.fetch("TEBAKO_VERSION")
103
+ # The tag normally derives from the version; a line-shard
104
+ # republication (the 1,000-asset cap) decouples them — names stay
105
+ # version-branded, only the target tag moves.
106
+ @tag = ENV.fetch("TEBAKO_RELEASE_TAG", "v#{@version}")
107
+ @release_title = "#{@config.title_prefix} #{@tag}"
108
+ @contract_version = load_contract_version
109
+ end
110
+
111
+ # Fail closed: a release whose manifest cannot name the runtime contract
112
+ # version never ships (the bootstrap negotiates on this field).
113
+ def load_contract_version
114
+ data = YAML.load_file(@config.contract_yml)
115
+ version = data.is_a?(Hash) ? data["contract_version"] : nil
116
+ return version if version.is_a?(Integer) && version.positive?
117
+
118
+ raise "#{@config.contract_yml} does not define a positive integer contract_version"
119
+ end
120
+
121
+ # One manifest entry per runtime PACKAGE (the executable). A sibling
122
+ # filesystem image (<package>.tfs, item 30) is folded into the
123
+ # package's entry as an additive `image` key -- top-level entries stay
124
+ # one-per-package so existing consumers (which match on the language
125
+ # version / platform / filename) are unaffected, and a .tfs file never
126
+ # becomes a top-level entry of its own. The additive `contract_version`
127
+ # key follows the same compat rule, and so does the windows runtime
128
+ # DLL (<package>.dll) folded as `dll` with the PE name the store entry
129
+ # materializes (`install_as`).
130
+ # Bundle-era (spec 36 §3, `bundles:` given): the entry KEEPS the
131
+ # per-member pins (exe sha256, the image/dll facet blocks — they pin
132
+ # the UNPACKED members the store verifies post-unpack) and gains the
133
+ # additive `bundle` block naming the one served payload asset.
134
+ def build_manifest_entries(packages, bundles: {}) # rubocop:disable Metrics/AbcSize
135
+ executables, images, dlls = partition_packages(packages)
136
+ executables.sort_by { |package| package.basename.to_s }.map do |package|
137
+ image = images.find { |candidate| candidate.basename.to_s == image_name_for(package) }
138
+ dll = dlls.find { |candidate| candidate.basename.to_s == dll_name_for(package) }
139
+ bundle = bundles[package_stem(package.basename.to_s)] unless bundles.empty?
140
+ manifest_entry(package, image, dll, bundle: bundle)
141
+ end
142
+ end
143
+
144
+ # Three-way split: executables (manifest entries of their own), the
145
+ # .tfs filesystem images and the windows runtime DLLs (both facets of
146
+ # their package's entry). A DLL never parses as an executable.
147
+ def partition_packages(packages)
148
+ dlls, rest = packages.partition { |package| dll_file?(package) }
149
+ images, executables = rest.partition { |package| image_file?(package) }
150
+ [executables, images, dlls]
151
+ end
152
+
153
+ # The byte-immutable keep machinery's previous-entry source,
154
+ # shards-first (spec 13 §2a): the release's per-package shards ARE the
155
+ # authority, and this invocation reads only the shards THIS leg's
156
+ # expected matrix can name (an unscoped invocation — no matrix — reads
157
+ # them all). A stem no shard covers falls back to the monolithic
158
+ # manifest.json loudly (the pre-de-rendezvous migration window) —
159
+ # never an error: a stem neither source covers simply has no previous
160
+ # entry, and a first publish is exactly that.
161
+ def previous_manifest_entries # rubocop:disable Metrics/MethodLength
162
+ @previous_manifest_entries ||= begin
163
+ release = find_release
164
+ if release.nil?
165
+ []
166
+ else
167
+ entries = relevant_shard_entries(release)
168
+ entries.concat(monolith_fallback_entries(covered_stems(entries)))
169
+ entries
170
+ end
171
+ rescue StandardError => e
172
+ puts "::warning::could not read the release's previous entries (#{e.class}: #{e.message}) — merging nothing"
173
+ []
174
+ end
175
+ end
176
+
177
+ # This leg's shards, downloaded and symbolized. Scoped to the expected
178
+ # matrix BEFORE downloading (the shard's stem is in its asset name);
179
+ # an invocation without a matrix (ad-hoc) reads every shard.
180
+ def relevant_shard_entries(release)
181
+ assets = shard_assets(release)
182
+ expected = expected_package_names
183
+ unless expected.empty?
184
+ assets = assets.select do |asset|
185
+ expected.include?(asset.name.delete_suffix(SHARD_SUFFIX))
186
+ end
187
+ end
188
+ assets.map { |asset| download_shard_entry(asset) }
189
+ end
190
+
191
+ # The migration window: a package stem no shard covers can still have
192
+ # its previous entry in the monolithic manifest.json (the pre-shard
193
+ # index — never written anymore, immutable forever). Scoped to this
194
+ # invocation's expected matrix; an invocation WITHOUT a matrix covers
195
+ # every shard-less monolith stem (the pre-de-rendezvous behavior).
196
+ # Loud when used, never an error.
197
+ def monolith_fallback_entries(shard_covered) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
198
+ monolith = previous_monolith_entries.reject do |entry|
199
+ shard_covered.include?(package_stem(entry[:filename].to_s))
200
+ end
201
+ return [] if monolith.empty?
202
+
203
+ expected = expected_package_names
204
+ found = expected.empty? ? monolith : monolith.select { |e| expected.include?(package_stem(e[:filename].to_s)) }
205
+ return [] if found.empty?
206
+
207
+ puts "::warning::#{found.size} package(s) carry no #{SHARD_SUFFIX} shard — reading their previous entries from " \
208
+ "the monolithic manifest.json (the pre-de-rendezvous migration window): " \
209
+ "#{found.map { |entry| entry[:filename] }.sort.join(", ")}"
210
+ found
211
+ end
212
+
213
+ # The release's monolithic manifest.json, symbolized (entry[:image]
214
+ # included), [] when absent/unreadable (a named warning, never a
215
+ # crash — the completeness gate is the arbiter). The monolith is a
216
+ # PRE-de-rendezvous artifact: two readers remain — the migration-window
217
+ # fallback above and the BACKFILL_METADATA repair pass (its shard
218
+ # source). Nothing ever writes it again (spec 13 §2a).
219
+ def previous_monolith_entries
220
+ @previous_monolith_entries ||= begin
221
+ data = read_previous_manifest
222
+ data.is_a?(Array) ? data.map { |entry| deep_symbolize(entry) } : []
223
+ rescue StandardError => e
224
+ puts "::warning::could not read the previous manifest.json (#{e.class}: #{e.message}) — merging nothing"
225
+ []
226
+ end
227
+ end
228
+
229
+ # The release's existing manifest.json as parsed JSON; nil when the tag
230
+ # has no release or the release carries no manifest asset.
231
+ def read_previous_manifest
232
+ release = find_release
233
+ asset = release && find_asset(release, "manifest.json")
234
+ asset && with_transient_retries { download_asset_json(asset) }
235
+ end
236
+
237
+ def deep_symbolize(value)
238
+ case value
239
+ when Hash then value.each_with_object({}) { |(k, v), acc| acc[k.to_sym] = deep_symbolize(v) }
240
+ when Array then value.map { |item| deep_symbolize(item) }
241
+ else value
242
+ end
243
+ end
244
+
245
+ def download_asset_json(asset)
246
+ JSON.parse(with_transient_retries { @client.get(asset.browser_download_url) }.to_s)
247
+ end
248
+
249
+ def expected_package_names # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
250
+ env_json, version_json = ENV.values_at("EXPECTED_ENV_MATRIX", @config.version_matrix_env)
251
+ return [] unless env_json && version_json
252
+
253
+ versions = @config.adapter.expected_versions(JSON.parse(version_json))
254
+ JSON.parse(env_json).product(versions).filter_map do |env, version|
255
+ next unless @config.adapter.capable_pair?(env["os"], env["arch"], version)
256
+
257
+ platform = Platform.host_id_for(env["os"], env["arch"])
258
+ "tebako-runtime-#{@version}-#{version}-#{platform}"
259
+ end
260
+ rescue JSON::ParserError => e
261
+ puts "::warning::Could not compute expected package list: #{e.message}"
262
+ []
263
+ end
264
+
265
+ # Metadata files always REPLACE on drift (payload assets never do):
266
+ # unlike payload assets (byte-immutable per name), every metadata file
267
+ # is DERIVABLE — a pure function of the packages' served bytes — so a
268
+ # same-named metadata asset with different bytes is debris from an
269
+ # interrupted publish, never a keep. Already-current metadata (the
270
+ # listing's digest matches) is never re-uploaded.
271
+ #
272
+ # The metadata CONVERGENCE loop (2026-08-03, proven live): the release
273
+ # backend's mutation propagation flaps over minutes — a deleted name
274
+ # 422'd re-uploads for >25 min while replicas disagreed about the
275
+ # listing. Each cycle: read-first (served bytes already ours → done),
276
+ # delete when listed, wait for absence, upload with the short budget
277
+ # (422s resolve by content inside perform_upload), then verify what the
278
+ # edge serves. The loop repeats until the edge converges or the budget
279
+ # runs out — a metadata rewrite never dies on the first bad cycle.
280
+ # The convergence cycle sleeps, ~46 min of patience. Tonight's backend
281
+ # (2026-08-03) blocked a deleted name's re-upload for 4.5+ HOURS.
282
+ # Since the de-rendezvous (spec 13 §2a) a leg rewrites only
283
+ # its OWN packages' shards/sidecars — every name it touches is one it
284
+ # owns, so a convergence grind can never rendezvous with another leg;
285
+ # an incident night is an incident night, and the legs run concurrently.
286
+ METADATA_CONVERGENCE_DELAYS = [5, 15, 30, 60, 120, 240, 480, 600, 600, 600].freeze
287
+
288
+ def force_upload(release, file, delays: METADATA_CONVERGENCE_DELAYS)
289
+ filename = file.basename.to_s
290
+ sha = Digest::SHA256.file(file).hexdigest
291
+ converged = false
292
+ delays.each do |pause|
293
+ converged = metadata_converged?(release, file, filename, sha)
294
+ break if converged
295
+
296
+ puts "#{filename} has not converged on the release yet; cycling in #{pause}s"
297
+ sleep pause
298
+ end
299
+ raise "could not converge #{filename} on the release within the metadata budget" unless converged
300
+ end
301
+
302
+ # One convergence cycle: read-first (the edge already serves our bytes
303
+ # → done), replace when listed, upload with the short budget (422s
304
+ # resolve by content inside perform_upload), then verify what the edge
305
+ # serves. true when the release serves our bytes.
306
+ def metadata_converged?(release, file, filename, sha)
307
+ return true if served_content_matches?(release, filename, sha)
308
+
309
+ remove_existing_asset(release, filename) if find_asset(release, filename)
310
+ begin
311
+ perform_upload(release, file, filename, delays: [5, 10, 20])
312
+ rescue Octokit::UnprocessableEntity
313
+ # the name is still taken server-side; the next cycle re-reads
314
+ end
315
+ served_content_matches?(release, filename, sha)
316
+ end
317
+
318
+ # Does the release already hold these exact bytes? The listing's
319
+ # server-computed digest is the authority (never edge-lagged); the
320
+ # download-edge read is the fallback for digest-less listings (an
321
+ # unreadable edge proves nothing → false → the mutation path runs).
322
+ def served_content_matches?(release, filename, sha)
323
+ digest = listed_digest(release, filename)
324
+ unless digest.nil?
325
+ matched = digest == sha
326
+ if matched
327
+ puts "#{filename} is already current on the release — canonical metadata unchanged — skipping refresh"
328
+ end
329
+ return matched
330
+ end
331
+
332
+ edge_content_matches?(filename, sha)
333
+ end
334
+
335
+ # The download-edge fallback for digest-less listings: an unreadable
336
+ # edge proves nothing → false → the mutation path runs.
337
+ def edge_content_matches?(filename, sha)
338
+ served = safely_served_content(filename)
339
+ return false if served.nil? || served.empty?
340
+
341
+ if Digest::SHA256.hexdigest(served) == sha
342
+ puts "#{filename} is already current on the release — canonical metadata unchanged — skipping refresh"
343
+ true
344
+ else
345
+ false
346
+ end
347
+ end
348
+
349
+ # The canonical download URL's currently-served bytes; nil when the edge
350
+ # has no such asset. The download edge lags behind the API on mutations,
351
+ # so this is a hint, never an authority — matching bytes are always a
352
+ # correct accept (content is content); absent/stale bytes only mean
353
+ # "take the mutation path".
354
+ def served_content(filename)
355
+ with_transient_retries { @client.get(download_url(filename)) }.to_s
356
+ rescue Octokit::NotFound
357
+ nil
358
+ end
359
+
360
+ # served_content that never raises: an unreadable edge just means the
361
+ # mutation path runs.
362
+ def safely_served_content(filename)
363
+ served_content(filename)
364
+ rescue StandardError
365
+ nil
366
+ end
367
+
368
+ def download_url(filename)
369
+ "https://github.com/#{@config.repo}/releases/download/#{@tag}/#{filename}"
370
+ end
371
+
372
+ # The release notes are written ONCE at release creation and never
373
+ # rewritten (spec 13 §2a — no shared mutable name; an asset-derived
374
+ # body would be the retired finalize pass's read-modify-write in
375
+ # disguise). They point at the per-package shards + sidecars (the
376
+ # authority), the consumer-side index derivation, and the in-repo
377
+ # registry — nothing in them enumerates the release's assets.
378
+ def release_notes
379
+ <<~BODY
380
+ ## #{@config.title_prefix}
381
+
382
+ Release version: #{@tag}
383
+
384
+ Every runtime package (the interpreter executable + its `.tfs` env image + the
385
+ windows runtime DLL) ships with its own metadata, written once by the build leg that
386
+ produced it (spec 13 §2a): the `<asset>.sha256` sidecar next to every asset is the
387
+ trust anchor (the tebako store's own sidecar shape, spec 00 §8), and the
388
+ `<package>.manifest.json` shard next to every package carries exactly its
389
+ release-index entry. On signing-enabled lines every served name also carries its
390
+ own detached `.asc` (spec 09 §5).
391
+
392
+ There is no monolithic `manifest.json` / `SHA256SUMS.txt` release asset: both are
393
+ derivable conveniences, computed consumer-side from the shards + the asset listing.
394
+ The machine-readable resolution index is the publishing repo's
395
+ `tpkg-registry.yaml` (spec 04 §2).
396
+ BODY
397
+ end
398
+
399
+ # The release is created by the FIRST leg that finds it missing — the
400
+ # matrix legs of one publish run race here (spec 13 §2a's
401
+ # de-rendezvous: no leg waits on another). The loser's create 422s on
402
+ # the taken tag; it polls for the winner's release to become visible
403
+ # and rides it. Creation is write-once, never a wedge.
404
+ RELEASE_CREATE_POLL_DELAYS = [5, 5, 5, 5, 5, 5].freeze
405
+
406
+ def get_or_create_release # rubocop:disable Naming/AccessorMethodName
407
+ puts "Looking for release with tag: #{@tag}"
408
+ @client.release_for_tag(@config.repo, @tag)
409
+ rescue Octokit::NotFound
410
+ create_release_race_safe
411
+ end
412
+
413
+ # A rescue clause's own exceptions never re-enter the sibling rescues —
414
+ # the create's 422 handling lives in its own method.
415
+ def create_release_race_safe # rubocop:disable Metrics/MethodLength
416
+ puts "Creating new release for tag: #{@tag}"
417
+ @client.create_release(@config.repo, @tag,
418
+ name: @release_title,
419
+ body: release_notes)
420
+ rescue Octokit::UnprocessableEntity
421
+ # A concurrent leg won the create. Poll for its release to become
422
+ # visible, then ride it — never re-attempt the create.
423
+ RELEASE_CREATE_POLL_DELAYS.each do |pause|
424
+ sleep pause
425
+ release = find_release
426
+ return release if release
427
+ end
428
+ raise "the release for #{@tag} rejected the create (tag already exists) but never became visible — " \
429
+ "another leg's creation is wedged; re-run this leg"
430
+ end
431
+
432
+ # The read-only lookup (the previous-entry reads + the audit read the
433
+ # existing release): nil when the tag has no release, never creates one.
434
+ def find_release
435
+ with_transient_retries { @client.release_for_tag(@config.repo, @tag) }
436
+ rescue Octokit::NotFound
437
+ nil
438
+ end
439
+
440
+ def manifest_entry(package, image = nil, dll = nil, bundle: nil) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
441
+ runtime_version, platform = parse_package_filename(package.basename.to_s)
442
+ contract = contract_sidecar(package)
443
+ filename = package.basename.to_s
444
+ sha256 = Digest::SHA256.file(package).hexdigest
445
+ register_current_shas(package, image, dll, bundle)
446
+ {
447
+ tebako_version: @version,
448
+ contract_era: contract.fetch("contract_era"),
449
+ contract_version: @contract_version,
450
+ @config.version_key => runtime_version,
451
+ platform: platform,
452
+ filename: filename,
453
+ sha256: sha256,
454
+ size_bytes: package.size,
455
+ mount_root: contract.fetch("mount_root"),
456
+ image_layout: contract.fetch("image_layout"),
457
+ built_from: contract.fetch("built_from")
458
+ }.tap do |entry|
459
+ # The additive abi line (spec 05 §5): the runtime's own platform
460
+ # string, emitted by build_runtime as <package>.abi. Consumers that
461
+ # predate the key ignore it (the compat window).
462
+ sidecar = Pathname.new("#{package.sub(/\.exe\z/, "")}.abi")
463
+ entry[:abi] = sidecar.read.strip if sidecar.file?
464
+ # The additive capabilities line (versions catalog plan 04): display
465
+ # metadata owned by the factory that compiled the runtime — never a
466
+ # selector axis. Sourced from the factory's adapter (the same truth
467
+ # its boot smoke asserts), so manifest and smoke can never disagree.
468
+ entry[:capabilities] = @config.adapter.capabilities(version: runtime_version, platform_id: platform)
469
+ entry[:image] = image_entry(image) if image
470
+ entry[:dll] = dll_entry(dll, runtime_version, platform) if dll
471
+ entry[:bundle] = bundle_entry(bundle) if bundle
472
+ declare_signatures(entry)
473
+ end
474
+ end
475
+
476
+ # The additive bundle metadata (spec 36 §3): the ONE served payload
477
+ # asset of a bundle-era leg. The entry's exe/image/dll pins stay —
478
+ # they pin the unpacked members; this block names what the release
479
+ # actually serves.
480
+ def bundle_entry(bundle)
481
+ {
482
+ filename: bundle.basename.to_s,
483
+ sha256: Digest::SHA256.file(bundle).hexdigest,
484
+ size_bytes: bundle.size
485
+ }
486
+ end
487
+
488
+ # The idempotent upload skip reads these (same name + same sha = kept):
489
+ # the exe, its facets, and the bundle (spec 36's bundle-era asset).
490
+ def register_current_shas(package, image, dll, bundle) # rubocop:disable Metrics/AbcSize
491
+ current_shas[package.basename.to_s] = Digest::SHA256.file(package).hexdigest
492
+ current_shas[image_name_for(package)] = Digest::SHA256.file(image).hexdigest if image
493
+ current_shas[dll_name_for(package)] = Digest::SHA256.file(dll).hexdigest if dll
494
+ current_shas[bundle.basename.to_s] = Digest::SHA256.file(bundle).hexdigest if bundle
495
+ end
496
+
497
+ # Spec 13 §2a / spec 09 §5: on signing-enabled lines every artifact the
498
+ # entry SERVES declares its own `signature` block — {keyid, asc}: the
499
+ # signer's 16-lowercase-hex PRIMARY keyid (spec 09 §9) and the exact
500
+ # `.asc` asset name within this release, declared by the factory and
501
+ # flowed verbatim by consumers (never synthesized — the same SSOT rule
502
+ # as `filename`). A declaration the in-leg sign pass does not fulfill
503
+ # fails the leg (the signer asserts coverage), never ships.
504
+ def declare_signatures(entry)
505
+ return unless signing_enabled?
506
+
507
+ keyid = signing_keyid
508
+ return declare_bundle_signature(entry, keyid) if entry[:bundle]
509
+
510
+ entry[:signature] = { keyid: keyid, asc: "#{entry[:filename]}.asc" }
511
+ %i[image dll].each do |facet|
512
+ block = entry[facet]
513
+ block[:signature] = { keyid: keyid, asc: "#{block[:filename]}.asc" } if block
514
+ end
515
+ end
516
+
517
+ # Bundle-era (spec 36 §3): the only served payload is the bundle —
518
+ # three signatures per leg (bundle, its sidecar, the shard). The
519
+ # exe/image/dll member pins are NOT served assets; declaring their
520
+ # .asc would be an invalid signing state (spec 09 §4).
521
+ def declare_bundle_signature(entry, keyid)
522
+ entry[:bundle][:signature] = { keyid: keyid, asc: "#{entry[:bundle][:filename]}.asc" }
523
+ end
524
+
525
+ # The signing arm (spec 09 §5's house style): TEBAKO_RELEASE_SIGNING_ENABLED=true
526
+ # marks the line signing-enabled — the publish declares the signature
527
+ # blocks and the leg's sign pass produces the `.asc` assets.
528
+ def signing_enabled?
529
+ ENV["TEBAKO_RELEASE_SIGNING_ENABLED"] == "true"
530
+ end
531
+
532
+ # The declared keyid: the signer's PRIMARY keyid, 16 lowercase hex —
533
+ # validated, never guessed (a malformed keyid in a shard is an invalid
534
+ # signing state shipped).
535
+ def signing_keyid
536
+ keyid = ENV.fetch("TEBAKO_RELEASE_SIGNING_KEYID", "").strip
537
+ return keyid if keyid.match?(/\A[0-9a-f]{16}\z/)
538
+
539
+ raise "TEBAKO_RELEASE_SIGNING_KEYID must be the signer's 16-lowercase-hex PRIMARY keyid " \
540
+ "(spec 09 §9), got #{keyid.inspect}"
541
+ end
542
+
543
+ def current_shas
544
+ @current_shas ||= {}
545
+ end
546
+
547
+ # The package's builder-emitted contract sidecar (the era-2 release
548
+ # card's provenance half), fail-closed: a missing file, missing keys,
549
+ # or a declared era this pipeline does not speak are named refusals —
550
+ # never a silently under-declared manifest entry (spec 18 C2/S11/S16).
551
+ def contract_sidecar(package) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
552
+ path = Pathname.new("#{package.sub(/\.exe\z/, "")}#{CONTRACT_SIDECAR_SUFFIX}")
553
+ unless path.file?
554
+ raise "runtime package #{package.basename} carries no #{CONTRACT_SIDECAR_SUFFIX} contract sidecar — " \
555
+ "it was built by a pre-era factory; rebuild it with the current #{@config.repo} (spec 18 C2)"
556
+ end
557
+
558
+ data = YAML.load_file(path)
559
+ data = nil unless data.is_a?(Hash)
560
+ missing = %w[contract_era mount_root image_layout built_from] - (data || {}).keys
561
+ unless missing.empty?
562
+ raise "contract sidecar #{path.basename} is missing #{missing.join(", ")} — " \
563
+ "rebuild the package with the current #{@config.repo} (spec 18 C2)"
564
+ end
565
+
566
+ era = data.fetch("contract_era")
567
+ unless era == CONTRACT_ERA
568
+ raise "contract sidecar #{path.basename} declares contract_era #{era.inspect} but this release pipeline " \
569
+ "speaks #{CONTRACT_ERA} — upgrade #{@config.repo}"
570
+ end
571
+
572
+ data
573
+ end
574
+
575
+ # The additive image metadata: name, sha256, size (consumers ignoring the
576
+ # `image` key keep working; item 30's compat rule).
577
+ def image_entry(image)
578
+ {
579
+ filename: image.basename.to_s,
580
+ sha256: Digest::SHA256.file(image).hexdigest,
581
+ size_bytes: image.size
582
+ }
583
+ end
584
+
585
+ # The additive runtime-DLL metadata (windows): the asset rides under
586
+ # the package's unique name; `install_as` is the PE name the store
587
+ # entry materializes next to the exe so the exe's imports resolve (the
588
+ # single owner of that name is the factory's version model, behind the
589
+ # adapter — consumers ignore the `dll` key in the compat window).
590
+ def dll_entry(dll, runtime_version, host_id)
591
+ {
592
+ filename: dll.basename.to_s,
593
+ install_as: @config.adapter.dll_install_name(runtime_version, host_id),
594
+ sha256: Digest::SHA256.file(dll).hexdigest,
595
+ size_bytes: dll.size
596
+ }
597
+ end
598
+
599
+ def image_file?(package)
600
+ package.basename.to_s.end_with?(".tfs")
601
+ end
602
+
603
+ def dll_file?(package)
604
+ package.basename.to_s.end_with?(".dll")
605
+ end
606
+
607
+ def image_name_for(package)
608
+ "#{package.basename.to_s.sub(/\.exe\z/, "")}.tfs"
609
+ end
610
+
611
+ def dll_name_for(package)
612
+ "#{package.basename.to_s.sub(/\.exe\z/, "")}.dll"
613
+ end
614
+
615
+ def parse_package_filename(filename)
616
+ grammar = @config.adapter.version_grammar_source
617
+ match = /\Atebako-runtime-#{Regexp.escape(@version)}-(#{grammar})-(.+?)(?:\.exe)?\z/.match(filename)
618
+ unless match
619
+ puts "::warning::Cannot infer version/platform from package filename: #{filename}"
620
+ return [nil, nil]
621
+ end
622
+
623
+ [match[1], match[2]]
624
+ end
625
+
626
+ # The upload retry budget: escalating delays, ~4.5 minutes of patience.
627
+ # Flat 5 s retries cannot ride out the release backend's propagation
628
+ # lag (observed 2026-08-03: the upload validator 422d a name for
629
+ # minutes after the delete committed on the primary).
630
+ UPLOAD_RETRY_DELAYS = [5, 10, 20, 40, 80, 120].freeze
631
+
632
+ # The per-asset wall-clock cap: one wedged asset must never consume the
633
+ # whole job. On the 2026-08-20 publish a single wedged exe/.tfs pair
634
+ # burned the full 150-minute step timeout across the per-platform
635
+ # invocations — every retry cycle's delete-wait and backoff re-armed
636
+ # with no overall bound. The cap is checked BETWEEN attempts: an
637
+ # in-flight POST (a healthy 200 MB upload can legitimately write for
638
+ # minutes against the 600 s request timeout) is never cut off.
639
+ PER_ASSET_UPLOAD_BUDGET = 300
640
+
641
+ def perform_upload(release, package, filename, delays: UPLOAD_RETRY_DELAYS.dup, # rubocop:disable Metrics/MethodLength
642
+ budget: PER_ASSET_UPLOAD_BUDGET)
643
+ deadline = monotonic_now + budget
644
+ loop do
645
+ puts "Uploading #{filename}"
646
+ begin
647
+ upload_once(release, package, filename)
648
+ return
649
+ rescue Octokit::UnprocessableEntity, *TRANSIENT_ERRORS => e
650
+ # A 422 means the asset name is taken. Two distinct causes: the
651
+ # eventual-consistency race after a same-name delete (the
652
+ # content-changed recovery paths), or a previous attempt's
653
+ # POST that timed out but LANDED server-side — the retry then 422s
654
+ # (the v0.16.1 publish died on exactly this). Resolve by content,
655
+ # first: a name-only check would misread the delete-race, where
656
+ # the STALE asset is still listed.
657
+ return if landed_duplicate?(release, filename, package, e)
658
+
659
+ # A 422 whose landed bytes DISAGREE with ours is a partial (a
660
+ # timed-out POST that landed incomplete) or a stale same-name
661
+ # asset: retrying the POST blindly can never win — delete the
662
+ # conflict so the retry lands (the v0.16.3 publish exhausted its
663
+ # budget exactly here). The delete's listing-propagation lag rides
664
+ # the retry budget below.
665
+ delete_conflicting_asset(release, filename) if e.is_a?(Octokit::UnprocessableEntity)
666
+
667
+ delay = delays.shift
668
+ raise if delay.nil?
669
+
670
+ if monotonic_now >= deadline
671
+ puts "#{filename}: the per-asset upload budget (#{budget}s) is exhausted — " \
672
+ "the replace cannot land tonight"
673
+ raise
674
+ end
675
+
676
+ backoff(e, filename, delay)
677
+ end
678
+ end
679
+ end
680
+
681
+ # Remove the same-name asset whose content disagrees with ours (a
682
+ # partial upload or a stale build). No listed asset means our own delete
683
+ # already left the listing — yet the POST 422'd already_exists: the name
684
+ # stays blocked on the upload validator's lagging replica past the
685
+ # listing's truth (the 0.16.8 wedge — the name freed ~15 s after the
686
+ # absence showed). Re-run the delete+poll cycle once more: the delete is
687
+ # a no-op and the poll is trivially green, so the operative half is the
688
+ # post-absence grace — then the retry's POST lands.
689
+ def delete_conflicting_asset(release, filename)
690
+ asset = find_asset(release, filename)
691
+ if asset.nil?
692
+ name_release_grace(filename)
693
+ return
694
+ end
695
+
696
+ puts "#{filename}: the landed asset's content disagrees — deleting the partial/stale asset before the retry"
697
+ with_transient_retries { @client.delete_release_asset(asset.id) }
698
+ drop_asset_from_memo(asset.id) # the deleted asset leaves the listing
699
+ # The name stays 422-blocked until the delete propagates — poll for
700
+ # the absence so the retry's POST actually lands (the v0.16.3 gnu
701
+ # publish looped blind POSTs into the held name).
702
+ wait_for_absence_best_effort(release, filename, asset)
703
+ end
704
+
705
+ # Did an earlier attempt's POST land despite the error? Accepts (loudly)
706
+ # only when the landed asset's content matches ours byte-for-byte.
707
+ def landed_duplicate?(release, filename, package, error)
708
+ return false unless error.is_a?(Octokit::UnprocessableEntity)
709
+ return false unless landed_with_same_content?(release, filename, package)
710
+
711
+ puts "#{filename} is already on the release with matching content (a timed-out attempt landed it)"
712
+ true
713
+ end
714
+
715
+ # Truthful by content: the landed asset's sha256 vs the local file's.
716
+ # The API listing's digest is the authority (the download edge lags
717
+ # behind mutations — it served a deleted partial for hours on the
718
+ # v0.16.3 publish, and a stale read must never delete a good upload);
719
+ # the byte read is only the pre-digest fallback. Anything unreadable
720
+ # proves nothing: treat as not landed and keep backing off.
721
+ def landed_with_same_content?(release, filename, package)
722
+ sha = Digest::SHA256.file(package).hexdigest
723
+ digest = listed_digest(release, filename)
724
+ return digest == sha unless digest.nil?
725
+
726
+ landed = landed_content(release, filename)
727
+ return false if landed.nil? || landed.empty?
728
+
729
+ Digest::SHA256.hexdigest(landed) == sha
730
+ rescue StandardError
731
+ false
732
+ end
733
+
734
+ # The API listing's server-computed sha256 (the asset `digest` field) —
735
+ # the authority that never lags the way the download edge does. nil
736
+ # when the listing carries no digest (a pre-digest API or a fake).
737
+ def listed_digest(release, filename)
738
+ asset = find_asset(release, filename)
739
+ digest = asset.respond_to?(:digest) ? asset.digest : nil
740
+ digest&.start_with?("sha256:") ? digest.delete_prefix("sha256:") : nil
741
+ end
742
+
743
+ def landed_content(release, filename)
744
+ asset = find_asset(release, filename)
745
+ return with_transient_retries { @client.get(asset.browser_download_url) }.to_s if asset
746
+
747
+ served_content(filename)
748
+ rescue Octokit::NotFound
749
+ served_content(filename)
750
+ end
751
+
752
+ def backoff(error, filename, delay)
753
+ puts "#{error.class} uploading #{filename}; retrying in #{delay}s"
754
+ sleep delay
755
+ end
756
+
757
+ # The POST rides rate-limit windows out like every other call; its
758
+ # TRANSIENT retries (transport drops included — octokit's internal
759
+ # release GET inside upload_asset died to a stale-keep-alive SSL EOF on
760
+ # 2026-08-30) stay with perform_upload (escalating delays, 422-by-content
761
+ # resolution, the per-asset budget) — ONE retry layer, not two.
762
+ def upload_once(release, package, filename)
763
+ asset = with_rate_limit_rideout do
764
+ @client.upload_asset(release.url, package.to_s,
765
+ content_type: "application/octet-stream",
766
+ name: filename)
767
+ end
768
+ record_asset_upload(asset) # the landed asset joins the listing
769
+ end
770
+
771
+ def process_release
772
+ return audit_release if audit_only?
773
+ return backfill_release if backfill_only?
774
+
775
+ release = get_or_create_release
776
+ puts "Working with release ID: #{release.id}"
777
+
778
+ packages = validate_packages_directory
779
+ report_missing_packages(packages)
780
+ process_era_release(release, packages)
781
+ # The leg signs AFTER its own publish (the .asc assets land in the
782
+ # same leg — spec 13 §2a), so the publish-time gate cannot require
783
+ # them; the coordinator's release-job audit does.
784
+ verify_completeness(release)
785
+ end
786
+
787
+ # The era branch (spec 36): the bundle shape publishes each package's
788
+ # ONE bundle then its metadata; the per-file shape publishes the
789
+ # enumeration. Entries are built BEFORE uploads either way — the
790
+ # sha256s they compute feed the idempotent upload skip (same name +
791
+ # same sha = no re-upload). This leg's entries only — nothing merges.
792
+ def process_era_release(release, packages)
793
+ return process_bundle_release(release, packages) if bundle_publish?
794
+
795
+ entries = build_manifest_entries(packages)
796
+ publish_release(release, packages, entries)
797
+ end
798
+
799
+ # Spec 36's publish shape: build each package's bundle FIRST (its sha
800
+ # feeds the entry's bundle block and the idempotent skip), upload the
801
+ # bundles as the legs' only payload assets, then the metadata (shard
802
+ # with the bundle block + the bundle's sidecar). The member files
803
+ # themselves never become release assets — the bundle is the unit.
804
+ def process_bundle_release(release, packages)
805
+ bundles = build_bundles(packages)
806
+ entries = build_manifest_entries(packages, bundles: bundles)
807
+ bundles.each_value { |bundle| upload_package(release, bundle) }
808
+ entries.each { |entry| ensure_package_metadata(release, entry) }
809
+ print_settled_summary
810
+ end
811
+
812
+ # One bundle per executable, built into a dot-subdir of runtime-packages
813
+ # (the packages glob never descends into it — a bundle is never mistaken
814
+ # for a staged package on a later validation). Keyed by package stem.
815
+ def build_bundles(packages)
816
+ executables, images, dlls = partition_packages(packages)
817
+ dir = Pathname.new("runtime-packages/.bundles").tap(&:mkpath)
818
+ executables.sort_by { |package| package.basename.to_s }.to_h do |package|
819
+ [package_stem(package.basename.to_s), build_bundle(dir, package, images, dlls)]
820
+ end
821
+ end
822
+
823
+ # One package's bundle.
824
+ def build_bundle(dir, package, images, dlls)
825
+ dll = dlls.find { |candidate| candidate.basename.to_s == dll_name_for(package) }
826
+ Bundler.new.build(dir, package_stem(package.basename.to_s),
827
+ exe: package, image: bundle_image!(package, images), dlls: Array(dll))
828
+ end
829
+
830
+ # A bundle without its env image is not a runtime (spec 36 §2): the
831
+ # per-file shape tolerates the gap so the completeness gate can name it
832
+ # at the end; the bundle cannot — fail here naming it.
833
+ def bundle_image!(package, images)
834
+ image = images.find { |candidate| candidate.basename.to_s == image_name_for(package) }
835
+ return image if image
836
+
837
+ raise Error, "runtime package #{package.basename} has no env image (#{image_name_for(package)}) — " \
838
+ "a bundle cannot ship without it (spec 36 §2)"
839
+ end
840
+
841
+ # Spec 36's era gate, factory-declared through the adapter (never an
842
+ # env knob — the publish shape is the factory's policy, spec 00 §10).
843
+ def bundle_publish?
844
+ @config.adapter.bundle_publish?
845
+ end
846
+
847
+ # AUDIT_ONLY (the coordinator's release job / a publish dry run):
848
+ # strictly read-only — finds the release (never creates one), needs no
849
+ # local packages, uploads nothing, touches no notes; the release's
850
+ # assets are verified against the expected matrix. The release IS the
851
+ # truth. On signing-enabled lines the audit also requires every served
852
+ # name's `.asc` (spec 09 §5's no-fold rule).
853
+ def audit_release
854
+ release = find_release
855
+ raise "AUDIT: no release found for tag #{@tag} — nothing to audit" unless release
856
+
857
+ puts "Working with release ID: #{release.id} (AUDIT mode: no uploads, no notes)"
858
+ verify_completeness(release, require_signatures: signing_enabled?)
859
+ nil
860
+ end
861
+
862
+ # A leg's publish writes ONLY names this leg owns (the de-rendezvous —
863
+ # spec 13 §2a): the payload assets (byte-immutable per name,
864
+ # absolutely) and each package's own metadata — its
865
+ # `<asset>.sha256` sidecars and its `<stem>.manifest.json` shard. No
866
+ # shared file exists: the monoliths are consumer-side derivations and
867
+ # the release notes are written once at creation.
868
+ def publish_release(release, packages, entries)
869
+ packages.each { |package| upload_package(release, package) }
870
+ entries.each { |entry| ensure_package_metadata(release, entry) }
871
+ print_settled_summary
872
+ end
873
+
874
+ # The package's metadata assets, byte-truthful for the SERVED bytes: a
875
+ # settled package (the byte-immutable keep, or a wedged recovery
876
+ # replace) publishes the PREVIOUS entry's shard and sidecars — the
877
+ # metadata describes what the release serves, never the fresh bytes
878
+ # that did not land.
879
+ def ensure_package_metadata(release, entry)
880
+ effective = effective_entry(entry)
881
+ ensure_metadata_asset(release, shard_name_for(effective), "#{JSON.pretty_generate(effective)}\n")
882
+ metadata_assets(effective).each do |name, sha|
883
+ ensure_metadata_asset(release, "#{name}#{SIDECAR_SUFFIX}", "#{sha} #{name}\n")
884
+ end
885
+ end
886
+
887
+ # The asset name -> sha256 pairs a package's metadata covers. Per-file
888
+ # era: the exe, its .tfs image and its .dll facet. Bundle era (spec 36
889
+ # §3): the bundle is the ONE served payload asset — the entry's
890
+ # exe/image/dll fields are member pins, not served names, so they earn
891
+ # no sidecar.
892
+ def metadata_assets(entry)
893
+ return { entry[:bundle][:filename] => entry[:bundle][:sha256] } if entry[:bundle]
894
+
895
+ { entry[:filename] => entry[:sha256] }
896
+ .merge(facet_metadata(entry, :image))
897
+ .merge(facet_metadata(entry, :dll))
898
+ end
899
+
900
+ def facet_metadata(entry, facet)
901
+ block = entry[facet]
902
+ block ? { block[:filename] => block[:sha256] } : {}
903
+ end
904
+
905
+ def shard_name_for(entry)
906
+ "#{package_stem(entry[:filename])}#{SHARD_SUFFIX}"
907
+ end
908
+
909
+ # A settled package (any of its assets kept their previous bytes) speaks
910
+ # with the previous entry's voice; a never-published settle has nothing
911
+ # truthful to keep and never reaches here (upload_package re-raised).
912
+ def effective_entry(entry)
913
+ names = [entry[:filename], entry.dig(:image, :filename), entry.dig(:dll, :filename)].compact
914
+ return entry unless names.any? { |name| settled?(name) }
915
+
916
+ previous_entry_for(entry[:filename]) || entry
917
+ end
918
+
919
+ # One metadata asset, converged: already-current bytes (the listing's
920
+ # digest matches) never re-upload; drift replaces through the
921
+ # convergence loop. Metadata uploads are tiny — the scratch file only
922
+ # exists to give force_upload a basename.
923
+ def ensure_metadata_asset(release, name, content)
924
+ if listed_digest(release, name) == Digest::SHA256.hexdigest(content)
925
+ puts "#{name} is already current on the release — skipping"
926
+ return
927
+ end
928
+
929
+ Dir.mktmpdir do |dir|
930
+ file = Pathname.new(dir).join(name).tap { |path| path.write(content) }
931
+ force_upload(release, file)
932
+ end
933
+ end
934
+
935
+ def audit_only?
936
+ ENV["AUDIT_ONLY"] == "true"
937
+ end
938
+
939
+ def backfill_only?
940
+ ENV["BACKFILL_METADATA"] == "true"
941
+ end
942
+
943
+ def shard_assets(release)
944
+ all_assets(release).select { |asset| asset.name.end_with?(SHARD_SUFFIX) }
945
+ end
946
+
947
+ def download_shard_entry(asset)
948
+ deep_symbolize(JSON.parse(with_transient_retries { @client.get(asset.browser_download_url) }.to_s))
949
+ end
950
+
951
+ # The package stems a set of entries covers (exe + .tfs/.dll facets).
952
+ def covered_stems(entries)
953
+ entries.flat_map { |entry| metadata_assets(entry).keys }
954
+ .map { |name| package_stem(name) }.uniq
955
+ end
956
+
957
+ # BACKFILL_METADATA=true — the one-shot migration / repair pass for a
958
+ # release published before per-asset metadata: every listed payload
959
+ # asset gets its sidecar (the listing's server-computed digest is the
960
+ # served bytes' truth; the monolith's recorded sha is the digest-less
961
+ # fallback, and a disagreement is named loudly) and every package its
962
+ # shard (synthesized from the monolithic manifest.json — the only other
963
+ # place the non-derivable fields live). It never touches a monolith or
964
+ # the notes (spec 13 §2a: nothing derives them server-side anymore).
965
+ def backfill_release
966
+ release = find_release
967
+ raise "BACKFILL: no release found for tag #{@tag}" unless release
968
+
969
+ entries = previous_monolith_entries
970
+ raise "BACKFILL needs the release's monolithic manifest.json as the shard source — none found" if entries.empty?
971
+
972
+ backfill_sidecars(release, entries)
973
+ backfill_shards(release, entries)
974
+ end
975
+
976
+ def backfill_sidecars(release, entries)
977
+ all_assets(release).map(&:name)
978
+ .select { |name| name.start_with?("tebako-runtime-") }
979
+ .reject { |name| name.end_with?(SIDECAR_SUFFIX, SHARD_SUFFIX) }
980
+ .each { |name| backfill_sidecar(release, entries, name) }
981
+ end
982
+
983
+ def backfill_sidecar(release, entries, name)
984
+ return if find_asset(release, "#{name}#{SIDECAR_SUFFIX}")
985
+
986
+ entry = previous_entry_covering_in(entries, name)
987
+ raise "BACKFILL: no manifest.json entry covers #{name}" unless entry
988
+
989
+ sha = backfilled_sha(release, entry, name)
990
+ ensure_metadata_asset(release, "#{name}#{SIDECAR_SUFFIX}", "#{sha} #{name}\n")
991
+ end
992
+
993
+ # The sha a backfilled sidecar carries: the listing's server-computed
994
+ # digest (the served bytes' truth) wins; the monolith's record is the
995
+ # digest-less fallback; a disagreement between them is named loudly.
996
+ def backfilled_sha(release, entry, name)
997
+ recorded = previous_sha_for(entry, name)
998
+ digest = listed_digest(release, name)
999
+ if digest && recorded && digest != recorded
1000
+ puts "::warning::#{name}: the served bytes (#{digest[0, 12]}…) disagree with the manifest.json " \
1001
+ "record (#{recorded[0, 12]}…) — the sidecar follows the served bytes"
1002
+ end
1003
+ sha = digest || recorded
1004
+ raise "BACKFILL: no sha256 available for #{name} (no listing digest, no manifest.json record)" unless sha
1005
+
1006
+ sha
1007
+ end
1008
+
1009
+ def backfill_shards(release, entries)
1010
+ entries.each do |entry|
1011
+ next if find_asset(release, shard_name_for(entry))
1012
+
1013
+ shard = "#{JSON.pretty_generate(digest_truthed_entry(release, entry))}\n"
1014
+ ensure_metadata_asset(release, shard_name_for(entry), shard)
1015
+ end
1016
+ end
1017
+
1018
+ # The monolith entry with every sha field re-anchored to the listing's
1019
+ # server-computed digest (the served bytes' truth) when the two
1020
+ # disagree — the incident's hand repair proved the record can lie; the
1021
+ # digest never does. backfill_sidecars has already named every
1022
+ # disagreement loudly by the time this runs.
1023
+ def digest_truthed_entry(release, entry)
1024
+ truthed = entry.dup
1025
+ truthed[:sha256] = listed_digest(release, entry[:filename]) || entry[:sha256]
1026
+ %i[image dll].each do |facet|
1027
+ block = entry[facet]
1028
+ next unless block
1029
+
1030
+ truthed[facet] = block.merge(sha256: listed_digest(release, block[:filename]) || block[:sha256])
1031
+ end
1032
+ truthed
1033
+ end
1034
+
1035
+ # previous_entry_covering over a given entry list (the backfill's
1036
+ # monolith read), same facet-aware lookup.
1037
+ def previous_entry_covering_in(entries, filename)
1038
+ entries.find do |entry|
1039
+ metadata_assets(entry).keys.include?(filename)
1040
+ end
1041
+ end
1042
+
1043
+ def report_missing_packages(packages)
1044
+ executables, images, dlls = package_names(packages)
1045
+ report_missing_executables(executables)
1046
+ report_image_gaps(executables, images)
1047
+ report_dll_gaps(executables, dlls)
1048
+ end
1049
+
1050
+ def package_names(packages)
1051
+ executables, images, dlls = partition_packages(packages)
1052
+ [
1053
+ executables.map { |package| package.basename.to_s.sub(/\.exe\z/, "") },
1054
+ images.map { |package| package.basename.to_s.sub(/\.tfs\z/, "") },
1055
+ dlls.map { |package| package.basename.to_s.sub(/\.dll\z/, "") }
1056
+ ]
1057
+ end
1058
+
1059
+ def report_missing_executables(found)
1060
+ missing = expected_package_names - found
1061
+ return if missing.empty?
1062
+
1063
+ puts "::warning::Release incomplete: #{missing.size} expected runtime package(s) are missing"
1064
+ missing.sort.each { |name| puts "::warning::Missing runtime package: #{name}" }
1065
+ puts "Continuing with #{found.size} available package(s); the completeness check at " \
1066
+ "the end of the publish fails the run if these are still missing"
1067
+ end
1068
+
1069
+ # The per-leg gate (also the audit's): a failed build leg simply never
1070
+ # lands its package — the release must not LOOK complete when it is
1071
+ # not. After the uploads, re-list the release assets (paginated) and
1072
+ # compare against this leg's expected set: every matrix package
1073
+ # (windows names may carry .exe), its filesystem image, its windows
1074
+ # runtime DLL, each landed file's .sha256 sidecar and the package's
1075
+ # .manifest.json shard.
1076
+ # Any gap fails the run loudly; without an expected matrix there is
1077
+ # nothing to verify against (warn and pass). With require_signatures
1078
+ # (the coordinator's audit on a signing-enabled line), every landed
1079
+ # name additionally owes its own `.asc` (spec 09 §5's no-fold rule) —
1080
+ # the publish-time gate passes false: the leg signs AFTER its upload.
1081
+ def verify_completeness(release, require_signatures: false)
1082
+ packages = expected_package_names
1083
+ if packages.empty?
1084
+ puts "::warning::No expected matrix available; release completeness is not verifiable"
1085
+ return
1086
+ end
1087
+
1088
+ missing = missing_assets(release, packages, require_signatures: require_signatures)
1089
+ return if missing.empty?
1090
+
1091
+ puts "::error::Release #{@tag} is incomplete: #{missing.size} expected asset(s) missing"
1092
+ missing.sort.each { |name| puts "::error::Missing asset: #{name}" }
1093
+ raise "Release #{@tag} is incomplete (#{missing.size} missing asset(s))"
1094
+ end
1095
+
1096
+ def missing_assets(release, packages, require_signatures: false)
1097
+ present = all_assets(release).map(&:name)
1098
+ packages.flat_map { |name| missing_package_assets(present, name, require_signatures: require_signatures) }
1099
+ end
1100
+
1101
+ # One expected package's gaps. Windows executables may or may not carry
1102
+ # the .exe suffix (the artifact naming is still settling), so the exe
1103
+ # expectation matches both. Metadata expectations ride on what actually
1104
+ # landed — a package whose exe never landed reports its own name only,
1105
+ # never a cascade of secondary sidecar/shard gaps (one error per gap).
1106
+ def missing_package_assets(present, name, require_signatures: false)
1107
+ return missing_bundle_assets(present, name, require_signatures: require_signatures) if bundle_publish?
1108
+
1109
+ exe = [name, "#{name}.exe"].find { |candidate| present.include?(candidate) }
1110
+ landed, missing = expected_facets(name).partition { |facet| present.include?(facet) }
1111
+ missing.unshift(name) unless exe
1112
+ return missing if exe.nil?
1113
+
1114
+ missing + missing_metadata(present, name, [exe] + landed, require_signatures: require_signatures)
1115
+ end
1116
+
1117
+ # Spec 36's completeness shape: the bundle is the leg's ONE payload
1118
+ # asset; a landed bundle owes its sidecar and the package's shard
1119
+ # (plus an .asc of each on signing-enabled audits).
1120
+ def missing_bundle_assets(present, name, require_signatures: false)
1121
+ bundle = "#{name}#{Bundler::BUNDLE_SUFFIX}"
1122
+ return [bundle] unless present.include?(bundle)
1123
+
1124
+ missing_metadata(present, name, [bundle], require_signatures: require_signatures)
1125
+ end
1126
+
1127
+ # The non-executable artifacts a package is expected to carry: the
1128
+ # filesystem image everywhere, the runtime DLL on windows.
1129
+ def expected_facets(name)
1130
+ facets = ["#{name}.tfs"]
1131
+ facets << "#{name}.dll" if name.include?("windows")
1132
+ facets
1133
+ end
1134
+
1135
+ # The metadata a landed package owes: one sidecar per landed asset plus
1136
+ # its shard — and, when signatures are required (spec 09 §5: no
1137
+ # artifact is ever "covered by" another's signature), one `.asc` per
1138
+ # landed asset, per sidecar, and per shard.
1139
+ def missing_metadata(present, name, landed, require_signatures: false)
1140
+ expected = landed.map { |asset| "#{asset}#{SIDECAR_SUFFIX}" } + ["#{name}#{SHARD_SUFFIX}"]
1141
+ expected += (landed + expected).map { |asset| "#{asset}.asc" } if require_signatures
1142
+ expected.reject { |asset| present.include?(asset) }
1143
+ end
1144
+
1145
+ # The windows legs ship the runtime DLL as a third artifact: a windows
1146
+ # executable without its DLL cannot load any native extension, and an
1147
+ # unowned DLL means a leg's upload never landed.
1148
+ def report_dll_gaps(executables, dlls)
1149
+ windows = executables.select { |name| name.include?("windows") }
1150
+ (windows - dlls).sort.each do |name|
1151
+ puts "::warning::Runtime package #{name} has no runtime DLL (#{name}.dll); " \
1152
+ "the windows runtime cannot load native extensions without it"
1153
+ end
1154
+ (dlls - executables).sort.each do |name|
1155
+ puts "::warning::Runtime DLL #{name}.dll has no matching runtime package; " \
1156
+ "it is uploaded but carries no manifest entry"
1157
+ end
1158
+ end
1159
+
1160
+ def report_image_gaps(executables, images)
1161
+ (executables - images).sort.each do |name|
1162
+ puts "::warning::Runtime package #{name} has no filesystem image (#{name}.tfs); " \
1163
+ "the package stays consumable but the image-era lean flow cannot use it"
1164
+ end
1165
+ (images - executables).sort.each do |name|
1166
+ puts "::warning::Filesystem image #{name}.tfs has no matching runtime package; " \
1167
+ "it is uploaded but carries no manifest entry"
1168
+ end
1169
+ end
1170
+
1171
+ def remove_existing_asset(release, filename)
1172
+ puts "Deleting existing asset #{filename}"
1173
+ existing = find_asset(release, filename)
1174
+ return unless existing
1175
+
1176
+ with_transient_retries { @client.delete_release_asset(existing.id) }
1177
+ drop_asset_from_memo(existing.id) # the deleted asset leaves the listing
1178
+ wait_for_absence_best_effort(release, filename, existing)
1179
+ end
1180
+
1181
+ # GitHub asset deletion is only eventually consistent: a same-name
1182
+ # re-upload 422s until the delete propagates (the v0.16.1 windows
1183
+ # publish lost SHA256SUMS.txt to exactly this — four retries inside
1184
+ # ~20 s never saw the absence). Poll for the absence, SLEEPING between
1185
+ # polls, under an overall wall-clock deadline; when propagation outlasts
1186
+ # the deadline the named error fires — the wait never spins and never
1187
+ # silently gives up (the 2026-08-20 publish burned its whole job timeout
1188
+ # on a wait whose outcome nobody could act on).
1189
+ # The deadline matches the OBSERVED propagation window: the 2026-08-29
1190
+ # publish watched a deleted name stay 422-blocked well past the old
1191
+ # 60 s — past the per-asset 300 s budget once. Three minutes of polling
1192
+ # (single-asset reads, never a re-listing) covers the incident class; a
1193
+ # name still held after that is wedged, and the caller's
1194
+ # retry/convergence budget rides it out.
1195
+ DELETION_PROPAGATION_POLL_INTERVAL = 2
1196
+ DELETION_PROPAGATION_DEADLINE = 180
1197
+ # The listing's truth frees the name BEFORE the upload validator's
1198
+ # replica does: on the 0.16.8 publish a same-name POST kept 422ing
1199
+ # (Validation Failed / code: already_exists / field: name) ~15 s PAST
1200
+ # the deletion's visible absence — the manual repair that worked was
1201
+ # "gone, grace, then upload". Every confirmed absence pays this grace
1202
+ # before the next POST.
1203
+ DELETION_PROPAGATION_GRACE = 15
1204
+
1205
+ def wait_for_absence(release, filename, asset, deadline: DELETION_PROPAGATION_DEADLINE) # rubocop:disable Metrics/MethodLength
1206
+ started = monotonic_now
1207
+ loop do
1208
+ if asset_deleted?(release, asset)
1209
+ name_release_grace(filename)
1210
+ return
1211
+ end
1212
+
1213
+ if monotonic_now - started >= deadline
1214
+ raise DeletionPropagationTimeout,
1215
+ "the deletion of #{filename} has not propagated within #{deadline}s — " \
1216
+ "the asset name stays 422-blocked server-side"
1217
+ end
1218
+
1219
+ puts "Waiting for the deletion of #{filename} to propagate..."
1220
+ sleep DELETION_PROPAGATION_POLL_INTERVAL
1221
+ end
1222
+ end
1223
+
1224
+ # The settle between "the deletion shows in the listing" and "the upload
1225
+ # validator lets the name go" (0.16.8: ~15 s past the script's own wait).
1226
+ def name_release_grace(filename)
1227
+ puts "#{filename} left the listing; giving the name #{DELETION_PROPAGATION_GRACE}s to free up server-side"
1228
+ sleep DELETION_PROPAGATION_GRACE
1229
+ end
1230
+
1231
+ # The propagation poll is a bounded single-asset existence read by id:
1232
+ # one API call per poll. Re-listing ~4 asset pages per poll (the
1233
+ # previous shape) is what, at catalog size, drained the token's hourly
1234
+ # request window mid-publish. The real API always hands listed assets an
1235
+ # api url; the fallback rebuilds it from the release url.
1236
+ def asset_deleted?(release, asset)
1237
+ with_transient_retries { @client.release_asset(asset.url || "#{release.url}/assets/#{asset.id}") }
1238
+ false
1239
+ rescue Octokit::NotFound
1240
+ true
1241
+ end
1242
+
1243
+ # The bounded wait after a delete, best-effort at the call sites that
1244
+ # have a retry budget behind them: over-deadline propagation is a loud
1245
+ # warning, and the upload's 422-by-content handler + per-asset budget
1246
+ # absorb the still-held name.
1247
+ def wait_for_absence_best_effort(release, filename, asset)
1248
+ wait_for_absence(release, filename, asset)
1249
+ rescue DeletionPropagationTimeout => e
1250
+ puts "::warning::#{e.message}"
1251
+ end
1252
+
1253
+ # Wall-clock reads for the deadline accounting — monotonic, immune to
1254
+ # clock smear on the runner.
1255
+ def monotonic_now
1256
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
1257
+ end
1258
+
1259
+ # release.assets is an embedded array capped at 30 entries, and a raw
1260
+ # rels[:assets].get is NOT auto-paginated either (auto_paginate covers
1261
+ # client methods, not Sawyer rel gets) — with 100+ assets most lookups
1262
+ # silently miss. Walk the pages explicitly, MEMOIZED per process: a full
1263
+ # publish fetches the listing once instead of ~3 pages per file (~1000
1264
+ # calls at catalog size — the reads that drained the tebako-ci token's
1265
+ # hourly window on the 0.16.6 publish). Our own mutations update the
1266
+ # memo IN PLACE (an upload appends the response's asset record, a delete
1267
+ # drops by id) — never a re-listing; another actor's mutations are
1268
+ # invisible by design — the global publish serialization means none
1269
+ # exist within a run.
1270
+ def all_assets(release)
1271
+ @all_assets ||= begin
1272
+ page = with_transient_retries { release.rels[:assets].get }
1273
+ assets = page.data
1274
+ while (nxt = page.rels[:next])
1275
+ page = with_transient_retries { nxt.get }
1276
+ assets += page.data
1277
+ end
1278
+ assets
1279
+ end
1280
+ end
1281
+
1282
+ # In-place memo updates for our own mutations. A nil memo stays nil:
1283
+ # the next read fetches the listing fresh.
1284
+ def record_asset_upload(asset)
1285
+ @all_assets&.push(asset)
1286
+ end
1287
+
1288
+ def drop_asset_from_memo(asset_id)
1289
+ @all_assets&.delete_if { |asset| asset.id == asset_id }
1290
+ end
1291
+
1292
+ def find_asset(release, filename)
1293
+ all_assets(release).find { |a| a.name == filename }
1294
+ end
1295
+
1296
+ # GET/DELETE/PUT calls other than the asset upload share the same
1297
+ # transient network failure modes; retry them (they are idempotent).
1298
+ # A 403 rate-limit response is not one of those modes: it rides the
1299
+ # window out and never consumes the transient attempts. Transport-level
1300
+ # drops (SSL EOF, TCP reset) belong here too: a stale keep-alive
1301
+ # connection answered with an SSL EOF is indistinguishable from a fresh
1302
+ # one that works — net-http retries those on EOFError but NOT on
1303
+ # OpenSSL::SSL::SSLError, so we do (the 2026-08-30 backfill crashed on
1304
+ # exactly that, on the verification GET right after a long upload POST).
1305
+ TRANSIENT_ERRORS = [
1306
+ Net::WriteTimeout, Net::ReadTimeout,
1307
+ Faraday::TimeoutError, Faraday::ConnectionFailed,
1308
+ OpenSSL::SSL::SSLError, EOFError, SystemCallError
1309
+ ].freeze
1310
+
1311
+ def with_transient_retries(attempts: 4)
1312
+ with_rate_limit_rideout do
1313
+ yield
1314
+ rescue *TRANSIENT_ERRORS => e
1315
+ attempts -= 1
1316
+ raise if attempts <= 0
1317
+
1318
+ delay = (5 * (4 - attempts)) + rand(5)
1319
+ puts "#{e.class}; retrying in #{delay}s (#{attempts} attempt(s) left)"
1320
+ sleep delay
1321
+ retry
1322
+ end
1323
+ end
1324
+
1325
+ # A 403 rate-limit response must never kill the publish: the uploader
1326
+ # is one serialized actor, and sleeping until the window resets is the
1327
+ # CORRECT behavior (the 0.16.6 publish burned the tebako-ci token's
1328
+ # 5000-request hourly window in ~30 minutes and died at the finalize —
1329
+ # GET .../assets 403 — after all 334 payload assets had landed). The
1330
+ # upload POST rides this out too; its transient retries (escalating
1331
+ # delays, 422-by-content resolution, the per-asset budget) stay with
1332
+ # perform_upload.
1333
+ RATE_LIMIT_SETTLE = 5
1334
+ RATE_LIMIT_DEFAULT_WAIT = 60
1335
+ RATE_LIMIT_BUDGET = (2 * 3600) + 300
1336
+
1337
+ def with_rate_limit_rideout
1338
+ yield
1339
+ rescue Octokit::TooManyRequests => e
1340
+ wait = rate_limit_wait(e)
1341
+ puts "#{e.class}; rate-limited — sleeping #{wait}s until the window resets"
1342
+ sleep wait
1343
+ retry
1344
+ end
1345
+
1346
+ # The seconds to sleep before the next call, budget-checked: one full
1347
+ # hourly window is a legitimate wait; a wait that would push the process
1348
+ # past two windows means something is systemically wrong — give up
1349
+ # loudly instead of blocking the runner forever.
1350
+ def rate_limit_wait(error)
1351
+ wait = rate_limit_seconds(error)
1352
+ return wait if monotonic_now + wait <= rate_limit_deadline
1353
+
1354
+ raise RateLimitBudgetExhausted,
1355
+ "the next GitHub rate-limit window is #{wait}s out but this publish has a #{RATE_LIMIT_BUDGET}s " \
1356
+ "ride-out budget — two full windows spent; giving up loudly instead of blocking forever"
1357
+ end
1358
+
1359
+ def rate_limit_deadline
1360
+ @rate_limit_deadline ||= monotonic_now + RATE_LIMIT_BUDGET
1361
+ end
1362
+
1363
+ # The reset header names the window's end as a wall-clock epoch (plus a
1364
+ # small settle); a stale or absent reset falls back to Retry-After,
1365
+ # then to a default minute. Faraday's real headers are case-insensitive;
1366
+ # read both spellings so a plain hash (the spec fake) serves the same
1367
+ # values.
1368
+ def rate_limit_seconds(error)
1369
+ headers = error.response_headers || {}
1370
+ reset = (headers["x-ratelimit-reset"] || headers["X-RateLimit-Reset"]).to_i
1371
+ return reset - Time.now.to_i + RATE_LIMIT_SETTLE if reset > Time.now.to_i
1372
+
1373
+ retry_after = (headers["retry-after"] || headers["Retry-After"]).to_i
1374
+ return retry_after + RATE_LIMIT_SETTLE if retry_after.positive?
1375
+
1376
+ RATE_LIMIT_DEFAULT_WAIT
1377
+ end
1378
+
1379
+ def run
1380
+ process_release
1381
+ rescue StandardError => e
1382
+ puts "Error: #{e.message}"
1383
+ puts e.backtrace
1384
+ exit 1
1385
+ end
1386
+
1387
+ def upload_package(release, package) # rubocop:disable Metrics/MethodLength
1388
+ filename = package.basename.to_s
1389
+ puts "Processing #{filename}..."
1390
+ if settled?(filename)
1391
+ puts "#{filename} kept its previous bytes earlier in this publish run — " \
1392
+ "the settled asset is never re-attempted; the refresh lands on a republish onto a recreated release object"
1393
+ return nil
1394
+ end
1395
+ return filename if skip_existing_asset?(release, filename)
1396
+
1397
+ perform_upload(release, package, filename)
1398
+ filename
1399
+ rescue Octokit::UnprocessableEntity
1400
+ # The wedged-name class: a delete+recreate tonight 422s for hours —
1401
+ # the replace cannot land within the budget. Only the recovery
1402
+ # replaces reach this rescue now (byte-differing assets warn-keep in
1403
+ # skip_existing_asset? before any delete — byte-immutable per name,
1404
+ # absolutely: a forced republication recreates the release OBJECT
1405
+ # coordinator-side, never an asset here). Keep
1406
+ # the release's existing asset AND its previous manifest entry
1407
+ # (byte-truthful, never a mismatch) and complete the publish; the
1408
+ # refreshed bytes land on a later republish onto a recreated release
1409
+ # object. A
1410
+ # never-published asset has nothing to keep — that re-raises by name.
1411
+ # Facets count: the manifest keys a .tfs/.dll under its package's
1412
+ # entry, so the facet's previous bytes live in the package entry's
1413
+ # facet block (the 2026-08-20 publish died here — a wedged .tfs
1414
+ # re-raised, killing the platform's invocation and re-attempting the
1415
+ # same pair on the next).
1416
+ raise if previous_entry_covering(filename).nil?
1417
+
1418
+ puts "::warning::#{filename} could not replace the wedged asset — " \
1419
+ "keeping the previous asset + manifest entry (byte-truthful); " \
1420
+ "the refresh lands on a recreated-object republish"
1421
+ settle_asset!(filename)
1422
+ nil
1423
+ end
1424
+
1425
+ # The settled ledger — the durable half of warn-and-keep-previous. The
1426
+ # publish step runs the uploader once per platform (sequential
1427
+ # processes, one workspace); an asset settled in one invocation (the
1428
+ # byte-immutable keep, or a wedged recovery replace) must NEVER be
1429
+ # re-attempted by a later one (the 2026-08-20 publish re-attempted the
1430
+ # same wedged exe once per platform invocation and burned the whole
1431
+ # 150-minute job timeout doing it). The ledger file in the workspace
1432
+ # carries the settled package stems across the per-platform processes;
1433
+ # each job's fresh workspace starts it empty.
1434
+ SETTLED_LEDGER_ENV = "TEBAKO_PUBLISH_SETTLED_PATH"
1435
+ SETTLED_LEDGER_DEFAULT = ".tebako-publish-settled"
1436
+
1437
+ def settled_ledger_path
1438
+ Pathname.new(ENV.fetch(SETTLED_LEDGER_ENV, SETTLED_LEDGER_DEFAULT))
1439
+ end
1440
+
1441
+ # The settled package stems: this process's settles plus every earlier
1442
+ # invocation's, loaded once from the workspace ledger.
1443
+ def settled_stems
1444
+ @settled_stems ||= begin
1445
+ path = settled_ledger_path
1446
+ path.file? ? path.read.lines.map(&:chomp).reject(&:empty?).uniq : []
1447
+ end
1448
+ end
1449
+
1450
+ # The package stem an asset name belongs to: the exe (with or without
1451
+ # .exe), its .tfs image, its .dll facet and its .tar.gz bundle (spec 36)
1452
+ # share one stem — settling is package-scoped so a wedged exe also
1453
+ # stands down its facets (a fresh facet over previous package bytes
1454
+ # would be a mixed-version package).
1455
+ def package_stem(filename)
1456
+ filename.sub(/\.tar\.gz\z/, "").sub(/\.(exe|tfs|dll)\z/, "")
1457
+ end
1458
+
1459
+ def settled?(filename)
1460
+ settled_stems.include?(package_stem(filename))
1461
+ end
1462
+
1463
+ def settle_asset!(filename)
1464
+ stem = package_stem(filename)
1465
+ return if settled_stems.include?(stem)
1466
+
1467
+ settled_stems << stem
1468
+ settled_ledger_path.open("a") { |file| file.puts(stem) }
1469
+ end
1470
+
1471
+ # The end-of-step summary: every package that kept its previous bytes
1472
+ # this run, in one loud block. The step still exits green — the design
1473
+ # is byte-truthful keep-previous, refresh on a recreated-object republish.
1474
+ def print_settled_summary
1475
+ return if settled_stems.empty?
1476
+
1477
+ puts "=" * 78
1478
+ puts "Publish summary: #{settled_stems.size} package(s) kept their previous bytes this run"
1479
+ puts "(byte-immutable per name — byte-truthful keep-previous; the refresh lands on a recreated-object republish):"
1480
+ settled_stems.sort.each { |stem| puts " - #{stem} (executable and its .tfs/.dll facets)" }
1481
+ puts "=" * 78
1482
+ end
1483
+
1484
+ # A kept asset (the byte-immutable keep, or a wedged recovery
1485
+ # replace) keeps the release's previous bytes, so its published metadata
1486
+ # speaks with the previous ENTRY's voice (the served bytes' sha, never
1487
+ # the fresh one that did not land) — for the exe and its .tfs/.dll
1488
+ # facets alike. The ledger (not just this process's settles) decides: a
1489
+ # later per-platform invocation builds fresh entries for the kept
1490
+ # platform and must revert them too, or the shard/sidecars would
1491
+ # describe bytes the release does not serve. The revert itself lives in
1492
+ # effective_entry; this lookup is its source.
1493
+ def previous_entry_for(filename)
1494
+ previous_manifest_entries.find { |entry| entry[:filename] == filename }
1495
+ end
1496
+
1497
+ # The warn-keep gate's lookup: the previous manifest entry whose bytes
1498
+ # cover this asset — the entry itself, or the entry whose image/dll
1499
+ # facet block names it (the manifest keys facets under their package).
1500
+ def previous_entry_covering(filename)
1501
+ previous_entry_covering_in(previous_manifest_entries, filename)
1502
+ end
1503
+
1504
+ # An asset with the same name AND the same bytes is kept — an
1505
+ # unchanged artifact never re-uploads. A same-named asset whose bytes
1506
+ # DIFFER is kept too, loudly — a published release's payload assets
1507
+ # are byte-immutable per name, absolutely (owner-locked): the build
1508
+ # is not bit-reproducible, and the delete+re-upload of a differing
1509
+ # same-name asset is exactly what wedged names server-side on the
1510
+ # 0.16.6 re-publish — and the 0.16.28 republish (tebako-runtime-ruby#189):
1511
+ # a FORCE_REBUILD delete→re-upload wedged 51 legs at once, and only
1512
+ # recreating the release OBJECT freed the names. Force republication
1513
+ # therefore happens coordinator-side (the publish workflow deletes and
1514
+ # re-creates the release object before any leg fans out), never
1515
+ # per-asset here. Presence in the listing alone still proves
1516
+ # nothing (the v0.16.3 publish kept a never-committed "starter" stub
1517
+ # as "unchanged"): uncommitted stubs force the replace first, and a
1518
+ # digest-mismatched asset the previous manifest does not cover takes
1519
+ # the recovery replace (there is nothing truthful to keep).
1520
+ def skip_existing_asset?(release, filename)
1521
+ return false unless find_asset(release, filename)
1522
+ return false if uncommitted_asset?(release, filename)
1523
+ return true if keep_published_asset?(release, filename)
1524
+ return false if digest_mismatch_without_previous_entry?(release, filename)
1525
+
1526
+ puts "Skipping upload of existing asset #{filename} (unchanged)"
1527
+ true
1528
+ end
1529
+
1530
+ # A listed asset whose upload never committed (state "starter" — the
1531
+ # stub an interrupted publisher leaves behind) holds the name but
1532
+ # serves no bytes: delete it so the caller re-uploads.
1533
+ def uncommitted_asset?(release, filename)
1534
+ asset = find_asset(release, filename)
1535
+ return false unless asset.respond_to?(:state) && asset.state == "starter"
1536
+
1537
+ puts "Re-uploading #{filename}: the listed asset never committed (state \"starter\") — it serves no bytes"
1538
+ remove_existing_asset(release, filename)
1539
+ true
1540
+ end
1541
+
1542
+ # A name the previous manifest carries no entry for (a first publish,
1543
+ # an unreadable manifest, or a .tfs/.dll facet the manifest keys under
1544
+ # its package) is verified against the listing's server-computed
1545
+ # digest instead of trusted on presence; a digest-less listing keeps
1546
+ # (conservative, as before). Runs behind the byte-immutable keep: a
1547
+ # differing asset WITH previous coverage never reaches this replace.
1548
+ def digest_mismatch_without_previous_entry?(release, filename)
1549
+ return false unless previous_entry_for(filename).nil?
1550
+
1551
+ digest = listed_digest(release, filename)
1552
+ current = current_shas[filename]
1553
+ return false unless digest && current && digest != current
1554
+
1555
+ puts "Re-uploading #{filename}: no previous manifest entry and the listed digest " \
1556
+ "differs (#{digest[0, 12]}… → #{current[0, 12]}…)"
1557
+ remove_existing_asset(release, filename)
1558
+ true
1559
+ end
1560
+
1561
+ # Byte-immutability keep (owner-locked): the release already carries an
1562
+ # asset under this name and its published bytes differ from the local
1563
+ # package's — the listing's server-computed digest is the authority,
1564
+ # the previous manifest's recorded sha the digest-less fallback. Keep
1565
+ # the published asset: warn with both shas and settle the package stem
1566
+ # so effective_entry reverts the published metadata to the previous
1567
+ # entry (byte-truthful for the published bytes) and no later
1568
+ # per-platform invocation re-attempts the replace. A name the previous
1569
+ # manifest does not cover has nothing truthful to keep — the digest
1570
+ # recovery gate above handles it; same bytes (or an unreadable
1571
+ # manifest) fail conservative, as before.
1572
+ def keep_published_asset?(release, filename)
1573
+ previous = previous_entry_covering(filename)
1574
+ return false if previous.nil?
1575
+
1576
+ published = listed_digest(release, filename) || previous_sha_for(previous, filename)
1577
+ current = current_shas[filename]
1578
+ return false unless published && current && published != current
1579
+
1580
+ puts "::warning::#{filename} exists on the release with different bytes " \
1581
+ "(published #{published[0, 12]}…, local #{current[0, 12]}…) — byte-immutable per name; keeping the " \
1582
+ "previous asset + manifest entry (byte-truthful); the refresh lands on a recreated-object republish"
1583
+ settle_asset!(filename)
1584
+ true
1585
+ end
1586
+
1587
+ # The sha256 the previous manifest records for this asset: the entry's
1588
+ # own sha for the executable, the image/dll facet block's sha for a
1589
+ # facet (facets key under their package's entry), the bundle block's
1590
+ # sha for the bundle (spec 36 — the bundle-era served asset).
1591
+ def previous_sha_for(entry, filename)
1592
+ if entry.dig(:image, :filename) == filename
1593
+ entry.dig(:image, :sha256)
1594
+ elsif entry.dig(:dll, :filename) == filename
1595
+ entry.dig(:dll, :sha256)
1596
+ elsif entry.dig(:bundle, :filename) == filename
1597
+ entry.dig(:bundle, :sha256)
1598
+ else
1599
+ entry[:sha256]
1600
+ end
1601
+ end
1602
+
1603
+ def validate_environment
1604
+ %w[GITHUB_TOKEN TEBAKO_VERSION].each do |var|
1605
+ raise "#{var} environment variable is required" unless ENV[var]
1606
+ end
1607
+ # Armed signing on a publish leg: the keyid must be present and sane
1608
+ # BEFORE any mutation — a shard's `signature` declaration names it
1609
+ # (spec 09 §5/§9), and a leg that cannot declare must fail before
1610
+ # uploading, never ship an under-declared shard. Audits/backfills
1611
+ # declare nothing, so they never gate on it.
1612
+ signing_keyid if signing_enabled? && !audit_only? && !backfill_only?
1613
+ end
1614
+
1615
+ def validate_packages_directory
1616
+ packages_dir = Pathname.new("runtime-packages")
1617
+ raise "No runtime packages directory found" unless packages_dir.directory?
1618
+
1619
+ packages = packages_dir.glob("*").reject { |p| support_file?(p) }
1620
+ raise "No packages found in runtime-packages directory" if packages.empty?
1621
+
1622
+ puts "Found packages:\n#{packages.map(&:basename).join("\n")}"
1623
+ packages
1624
+ end
1625
+
1626
+ # `.abi` sidecars (the runtime's platform string), `.contract.yaml`
1627
+ # sidecars (the era-2 release card provenance) and `.sha256` markers
1628
+ # (the image's store-layout trust anchor, spec 22 §6 — the boot
1629
+ # smoke's image-key input) are manifest inputs / build outputs read
1630
+ # in place — never packages of their own.
1631
+ def support_file?(path)
1632
+ path.extname == ".abi" || path.extname == ".sha256" ||
1633
+ path.basename.to_s.end_with?(CONTRACT_SIDECAR_SUFFIX)
1634
+ end
1635
+ end
1636
+ end