tebako-release 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE.md +24 -0
- data/README.md +66 -0
- data/exe/tebako-release +37 -0
- data/lib/tebako_release/adapter.rb +59 -0
- data/lib/tebako_release/bundler.rb +89 -0
- data/lib/tebako_release/config.rb +49 -0
- data/lib/tebako_release/error.rb +11 -0
- data/lib/tebako_release/platform.rb +76 -0
- data/lib/tebako_release/signer.rb +381 -0
- data/lib/tebako_release/uploader.rb +1636 -0
- data/lib/tebako_release/version.rb +9 -0
- data/lib/tebako_release.rb +41 -0
- metadata +72 -0
|
@@ -0,0 +1,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
|