tebako-release 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: da808fc0759260d4b29d60ffb1d51622cb07c3f8cf3125b4ea01025f1db4f927
4
- data.tar.gz: 8d21389348ae2bb086aaf17f3901f386e62598be12bee027e653b76b361e8d1b
3
+ metadata.gz: 5382811aa28e5730bd9a513c18f61cd0619d6586ec0089de1822d8283313399f
4
+ data.tar.gz: da87f7c9358a2c1b69e38f0986b42ddbe73c29cd835814657e1f8ade793f402b
5
5
  SHA512:
6
- metadata.gz: d73130a2cbd8d1de0df2bb1cf1d801bfe260db9b5569cc04e736eca3324d945576cf8ee9c13c22eb31000872e9229cfb5931c553a491e72188bf58d793206cba
7
- data.tar.gz: c111f4d684714a0b03fbc92891b9e2f6640f8cc05369b77870e73bc04f34847367692c89cbaf06c48138fbf8d6172fe675c4124add72be2da7ba8c2bcd674915
6
+ metadata.gz: 451fa6d51c2392f4739c0236dd5d72dfce5788cd07dc9e240d72d61a59a47e265dbc0d009f3e7f1b7175d15c371096490b9ff852999b3aab1116e9eaf7732aa9
7
+ data.tar.gz: 5ba106db23aa707ec41abe0629d06e16a6dd7a61156495bff9dc6e3134f6cdcbab9724d19fc5996d746f864519c6cef91c543b6de16ebc14cb6762e64d026ff6
data/README.md CHANGED
@@ -23,7 +23,12 @@ through a small adapter file and pin this gem — they never carry copies.
23
23
  in-bundle SHA256SUMS; fixed member order, mtime=0 — identical inputs
24
24
  yield identical bytes). A factory opts into the bundle-era publish shape
25
25
  through its adapter (`bundle_publish? → true`); the per-file shape stays
26
- the default for pre-bundle lines.
26
+ the default for pre-bundle lines. Bundle-era lines whose consumers
27
+ include the lazy arm additionally opt into co-publish
28
+ (`per_file_alongside_bundle? → true`, spec 36 §3): the staged members
29
+ are served as standalone assets beside the bundle, the shard carries
30
+ the `per_file_assets` witness (runtime-manifest MINOR 2), the facet
31
+ signature declarations return, and the audit expects the union.
27
32
  - **`TebakoRelease::Blksum`** — the spec 39 §3 block-group digest sidecar
28
33
  (`<image>.blksum.json`: one sha256 per 4 MiB group of env-image bytes,
29
34
  the lazy mount's range-GET trust anchor). The uploader derives it
@@ -55,5 +55,18 @@ module TebakoRelease
55
55
  def bundle_publish?
56
56
  false
57
57
  end
58
+
59
+ # Spec 36 §3's co-publish: when true (and bundle_publish? holds), each
60
+ # leg ALSO serves the per-file assets — the exe under its historical
61
+ # spelling, the env image, the windows DLL — as standalone release
62
+ # assets beside the bundle, each with its .sha256 sidecar. The lazy
63
+ # arm (spec 39 §7) range-fetches the image's groups over HTTP, which
64
+ # is impossible inside the gzip stream; the shard gains the additive
65
+ # per_file_assets witness (runtime-manifest MINOR 2) that gates the
66
+ # loaders' lazy arm on bundle-declaring shards. Default off: a
67
+ # factory opts in when its consumers include the lazy arm.
68
+ def per_file_alongside_bundle?
69
+ false
70
+ end
58
71
  end
59
72
  end
@@ -29,6 +29,7 @@ require "octokit"
29
29
  require "digest"
30
30
  require "json"
31
31
  require "pathname"
32
+ require "stringio"
32
33
  require "tmpdir"
33
34
  require "yaml"
34
35
 
@@ -437,7 +438,7 @@ module TebakoRelease
437
438
  nil
438
439
  end
439
440
 
440
- def manifest_entry(package, image = nil, dll = nil, bundle: nil) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
441
+ def manifest_entry(package, image = nil, dll = nil, bundle: nil) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity
441
442
  runtime_version, platform = parse_package_filename(package.basename.to_s)
442
443
  contract = contract_sidecar(package)
443
444
  filename = package.basename.to_s
@@ -470,6 +471,11 @@ module TebakoRelease
470
471
  entry[:image] = image_entry(image, blksum) if image
471
472
  entry[:dll] = dll_entry(dll, runtime_version, platform) if dll
472
473
  entry[:bundle] = bundle_entry(bundle) if bundle
474
+ # Spec 36 §3's co-publish witness (runtime-manifest MINOR 2):
475
+ # true declares the per-file assets are served standalone beside
476
+ # the bundle — the lazy arm's gate on bundle-declaring shards
477
+ # (spec 39 §7). Never emitted on bundle-only lines.
478
+ entry[:per_file_assets] = true if bundle && copublish?
473
479
  declare_signatures(entry)
474
480
  end
475
481
  end
@@ -530,7 +536,13 @@ module TebakoRelease
530
536
  return unless signing_enabled?
531
537
 
532
538
  keyid = signing_keyid
533
- return declare_bundle_signature(entry, keyid) if entry[:bundle]
539
+ declare_bundle_signature(entry, keyid) if entry[:bundle]
540
+ # Bundle-only lines serve no other payload asset — declaring the
541
+ # members' .ascs there would be an invalid signing state (spec 09
542
+ # §4). A co-published line (spec 36 §3, the shard's per_file_assets
543
+ # witness) serves the members standalone, so the every-served-name
544
+ # rule covers the exe/image/dll set again.
545
+ return if entry[:bundle] && !entry[:per_file_assets]
534
546
 
535
547
  entry[:signature] = { keyid: keyid, asc: "#{entry[:filename]}.asc" }
536
548
  %i[image dll].each do |facet|
@@ -539,10 +551,10 @@ module TebakoRelease
539
551
  end
540
552
  end
541
553
 
542
- # Bundle-era (spec 36 §3): the only served payload is the bundle —
543
- # three signatures per leg (bundle, its sidecar, the shard). The
544
- # exe/image/dll member pins are NOT served assets; declaring their
545
- # .asc would be an invalid signing state (spec 09 §4).
554
+ # Bundle-era (spec 36 §3): the bundle's own signature declaration.
555
+ # On bundle-only lines it is the ONLY payload signature — three
556
+ # signatures per leg (bundle, its sidecar, the shard); co-published
557
+ # lines add the per-file declarations on top (declare_signatures).
546
558
  def declare_bundle_signature(entry, keyid)
547
559
  entry[:bundle][:signature] = { keyid: keyid, asc: "#{entry[:bundle][:filename]}.asc" }
548
560
  end
@@ -832,11 +844,16 @@ module TebakoRelease
832
844
  # feeds the entry's bundle block and the idempotent skip), upload the
833
845
  # bundles as the legs' only payload assets, then the metadata (shard
834
846
  # with the bundle block + the bundle's sidecar). The member files
835
- # themselves never become release assets — the bundle is the unit.
847
+ # themselves never become release assets — the bundle is the unit —
848
+ # UNLESS the factory co-publishes (spec 36 §3): then the members are
849
+ # standalone served names beside the bundle (the lazy arm
850
+ # range-fetches the image), still ahead of the shard that witnesses
851
+ # them (a witnessed shard without its assets is the invalid publish).
836
852
  def process_bundle_release(release, packages)
837
853
  bundles = build_bundles(packages)
838
854
  entries = build_manifest_entries(packages, bundles: bundles)
839
855
  bundles.each_value { |bundle| upload_package(release, bundle) }
856
+ packages.each { |package| upload_package(release, package) } if copublish?
840
857
  upload_blksum_sidecars(release, entries)
841
858
  entries.each { |entry| ensure_package_metadata(release, entry) }
842
859
  print_settled_summary
@@ -877,6 +894,14 @@ module TebakoRelease
877
894
  @config.adapter.bundle_publish?
878
895
  end
879
896
 
897
+ # Spec 36 §3's co-publish mode: a bundle-era line that ALSO serves
898
+ # the per-file assets as standalone release assets (the lazy arm's
899
+ # serving requirement — spec 39 §7). Factory-declared through the
900
+ # adapter, like the era.
901
+ def copublish?
902
+ bundle_publish? && @config.adapter.per_file_alongside_bundle?
903
+ end
904
+
880
905
  # AUDIT_ONLY (the coordinator's release job / a publish dry run):
881
906
  # strictly read-only — finds the release (never creates one), needs no
882
907
  # local packages, uploads nothing, touches no notes; the release's
@@ -932,7 +957,7 @@ module TebakoRelease
932
957
  # metadata describes what the release serves, never the fresh bytes
933
958
  # that did not land.
934
959
  def ensure_package_metadata(release, entry)
935
- effective = effective_entry(entry)
960
+ effective = effective_entry(release, entry)
936
961
  ensure_metadata_asset(release, shard_name_for(effective), "#{JSON.pretty_generate(effective)}\n")
937
962
  metadata_assets(effective).each do |name, sha|
938
963
  ensure_metadata_asset(release, "#{name}#{SIDECAR_SUFFIX}", "#{sha} #{name}\n")
@@ -943,20 +968,27 @@ module TebakoRelease
943
968
  # era: the exe, its .tfs image and its .dll facet. Bundle era (spec 36
944
969
  # §3): the bundle is the ONE served payload asset — the entry's
945
970
  # exe/image/dll fields are member pins, not served names, so they earn
946
- # no sidecar. Both eras: the derived blksum sidecar (spec 39 §3) IS a
947
- # served standalone asset, so it earns its own .sha256 sidecar like
971
+ # no sidecar — UNLESS the entry carries the co-publish witness, when
972
+ # the members ARE served standalone beside the bundle and earn theirs.
973
+ # Both eras: the derived blksum sidecar (spec 39 §3) IS a served
974
+ # standalone asset, so it earns its own .sha256 sidecar like
948
975
  # every served name.
949
976
  def metadata_assets(entry)
950
- served = if entry[:bundle]
951
- { entry[:bundle][:filename] => entry[:bundle][:sha256] }
952
- else
953
- { entry[:filename] => entry[:sha256] }
954
- .merge(facet_metadata(entry, :image))
955
- .merge(facet_metadata(entry, :dll))
956
- end
977
+ served = bundle_metadata(entry)
978
+ served.merge!(per_file_metadata(entry)) unless entry[:bundle] && !entry[:per_file_assets]
957
979
  served.merge(blksum_metadata(entry))
958
980
  end
959
981
 
982
+ def bundle_metadata(entry)
983
+ entry[:bundle] ? { entry[:bundle][:filename] => entry[:bundle][:sha256] } : {}
984
+ end
985
+
986
+ def per_file_metadata(entry)
987
+ { entry[:filename] => entry[:sha256] }
988
+ .merge(facet_metadata(entry, :image))
989
+ .merge(facet_metadata(entry, :dll))
990
+ end
991
+
960
992
  def facet_metadata(entry, facet)
961
993
  block = entry[facet]
962
994
  block ? { block[:filename] => block[:sha256] } : {}
@@ -977,12 +1009,57 @@ module TebakoRelease
977
1009
  # A settled package (any of its assets kept their previous bytes) speaks
978
1010
  # with the previous entry's voice; a never-published settle has nothing
979
1011
  # truthful to keep and never reaches here (upload_package re-raised).
980
- def effective_entry(entry)
1012
+ # The co-publish adoption exception (spec 36 §3): the previous bundle-era
1013
+ # entry predates the witness, and this run repaired the standalone
1014
+ # members out of the settled bundle — the entry keeps the previous pins
1015
+ # (they pin exactly those bytes) and gains the witness, the served
1016
+ # blksum pin, and the members' signature declarations, each only when
1017
+ # the asset it names verifiably landed.
1018
+ def effective_entry(release, entry)
981
1019
  names = [entry[:filename], entry.dig(:image, :filename), entry.dig(:dll, :filename),
982
1020
  entry.dig(:image, :blksum, :filename)].compact
983
1021
  return entry unless names.any? { |name| settled?(name) }
984
1022
 
985
- previous_entry_for(entry[:filename]) || entry
1023
+ previous = previous_entry_for(entry[:filename]) || entry
1024
+ return previous unless entry[:per_file_assets] && !previous[:per_file_assets]
1025
+ return previous unless copublish_members_landed?(release, previous)
1026
+
1027
+ witnessed_repair_entry(release, previous, entry)
1028
+ end
1029
+
1030
+ # The witness gate: the standalone members the previous entry pins are
1031
+ # all on the release (landed this run through the repair, or served all
1032
+ # along). A witness without its assets is the invalid publish.
1033
+ def copublish_members_landed?(release, entry)
1034
+ names = [entry[:filename], entry.dig(:image, :filename), entry.dig(:dll, :filename)].compact
1035
+ names.all? { |name| find_asset(release, name) }
1036
+ end
1037
+
1038
+ # The adoption shard: the previous entry verbatim (its pins describe
1039
+ # the served bytes exactly) plus the co-publish witness, the blksum pin
1040
+ # read off the landed sidecar's listing digest (byte-truthful by
1041
+ # construction — never the fresh build's derivation), and the members'
1042
+ # signature declarations on signing-enabled lines (spec 09 §5's
1043
+ # every-served-name rule; the in-leg sign pass fulfills them).
1044
+ def witnessed_repair_entry(release, previous, fresh)
1045
+ repaired = Marshal.load(Marshal.dump(previous))
1046
+ repaired[:per_file_assets] = true
1047
+ blksum_name = fresh.dig(:image, :blksum, :filename)
1048
+ blksum_sha = blksum_name && listed_digest(release, blksum_name)
1049
+ repaired[:image][:blksum] = { filename: blksum_name, sha256: blksum_sha } if blksum_sha && repaired[:image]
1050
+ declare_repaired_member_signatures(repaired)
1051
+ repaired
1052
+ end
1053
+
1054
+ def declare_repaired_member_signatures(entry)
1055
+ return unless signing_enabled?
1056
+
1057
+ keyid = signing_keyid
1058
+ entry[:signature] = { keyid: keyid, asc: "#{entry[:filename]}.asc" }
1059
+ %i[image dll].each do |facet|
1060
+ block = entry[facet]
1061
+ block[:signature] = { keyid: keyid, asc: "#{block[:filename]}.asc" } if block
1062
+ end
986
1063
  end
987
1064
 
988
1065
  # One metadata asset, converged: already-current bytes (the listing's
@@ -1183,7 +1260,7 @@ module TebakoRelease
1183
1260
 
1184
1261
  exe = [name, "#{name}.exe"].find { |candidate| present.include?(candidate) }
1185
1262
  landed, missing = expected_facets(name).partition { |facet| present.include?(facet) }
1186
- landed, missing = split_blksum_expectation(release, present, name, landed, missing)
1263
+ landed, missing = split_blksum_expectation(served_shard(release, present, name), present, landed, missing)
1187
1264
  missing.unshift(name) unless exe
1188
1265
  return missing if exe.nil?
1189
1266
 
@@ -1194,12 +1271,17 @@ module TebakoRelease
1194
1271
  # asset; a landed bundle owes its sidecar and the package's shard
1195
1272
  # (plus an .asc of each on signing-enabled audits). The declared
1196
1273
  # blksum sidecar (spec 39 §3) is a standalone served asset in this
1197
- # era too — expected, with its own metadata, when the shard pins it.
1274
+ # era too — expected, with its own metadata, when the shard pins it —
1275
+ # and a shard carrying the co-publish witness (spec 36 §3's
1276
+ # per_file_assets) owes the standalone per-file assets beside the
1277
+ # bundle.
1198
1278
  def missing_bundle_assets(release, present, name, require_signatures: false)
1199
1279
  bundle = "#{name}#{Bundler::BUNDLE_SUFFIX}"
1200
1280
  return [bundle] unless present.include?(bundle)
1201
1281
 
1202
- landed, missing = split_blksum_expectation(release, present, name, [bundle], [])
1282
+ shard = served_shard(release, present, name)
1283
+ landed, missing = split_blksum_expectation(shard, present, [bundle], [])
1284
+ landed, missing = split_copublish_expectation(shard, present, name, landed, missing)
1203
1285
  missing + missing_metadata(present, name, landed, require_signatures: require_signatures)
1204
1286
  end
1205
1287
 
@@ -1212,8 +1294,8 @@ module TebakoRelease
1212
1294
  # the shard's own gap is the gate's business). A DECLARED sidecar that
1213
1295
  # never landed reports its own name only — its sidecar/.asc cascade
1214
1296
  # rides the landed set, one error per gap.
1215
- def split_blksum_expectation(release, present, name, landed, missing)
1216
- declared = declared_blksum_name(release, present, name)
1297
+ def split_blksum_expectation(shard, present, landed, missing)
1298
+ declared = shard&.dig("image", "blksum", "filename")
1217
1299
  return [landed, missing] unless declared
1218
1300
 
1219
1301
  if present.include?(declared)
@@ -1223,17 +1305,35 @@ module TebakoRelease
1223
1305
  end
1224
1306
  end
1225
1307
 
1226
- # The sidecar name the package's shard declares (`image.blksum`),
1227
- # nil when undeclared, when the shard never landed, or when it cannot
1228
- # be read (loudly — the audit never guesses an expectation).
1229
- def declared_blksum_name(release, present, name)
1308
+ # The shard-witnessed co-publish expectation (spec 36 §3): a shard
1309
+ # carrying per_file_assets: true owes the standalone per-file assets —
1310
+ # the exe (either spelling), the image, the windows DLL — beside the
1311
+ # bundle; each landed member earns its metadata through the landed
1312
+ # set. An unwitnessed shard expects nothing (bundle-only lines audit
1313
+ # green), and a missing exe reports its own gap only, never a
1314
+ # cascade (the one-error-per-gap rule).
1315
+ def split_copublish_expectation(shard, present, name, landed, missing)
1316
+ return [landed, missing] unless shard && shard["per_file_assets"] == true
1317
+
1318
+ exe = [name, "#{name}.exe"].find { |candidate| present.include?(candidate) }
1319
+ facets_landed, facets_missing = expected_facets(name).partition { |facet| present.include?(facet) }
1320
+ gaps = facets_missing
1321
+ gaps = [name] + gaps unless exe
1322
+ [landed + [exe, *facets_landed].compact, missing + gaps]
1323
+ end
1324
+
1325
+ # The package's served shard as parsed JSON — the declarations' only
1326
+ # authority (the additive keys gate the audit's expectations). nil
1327
+ # when the shard never landed (its own gap is the gate's business) or
1328
+ # cannot be read (loudly — the audit never guesses an expectation).
1329
+ def served_shard(release, present, name)
1230
1330
  shard = "#{name}#{SHARD_SUFFIX}"
1231
1331
  return nil unless present.include?(shard)
1232
1332
 
1233
- download_asset_json(find_asset(release, shard)).dig("image", "blksum", "filename")
1333
+ download_asset_json(find_asset(release, shard))
1234
1334
  rescue StandardError => e
1235
- puts "::warning::could not read #{shard} for the blksum expectation (#{e.class}: #{e.message}) — " \
1236
- "no blksum sidecar is expected of this package"
1335
+ puts "::warning::could not read #{shard} for the additive expectations (#{e.class}: #{e.message}) — " \
1336
+ "no additive assets are expected of this package"
1237
1337
  nil
1238
1338
  end
1239
1339
 
@@ -1497,10 +1597,16 @@ module TebakoRelease
1497
1597
  exit 1
1498
1598
  end
1499
1599
 
1500
- def upload_package(release, package) # rubocop:disable Metrics/MethodLength
1600
+ def upload_package(release, package) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
1501
1601
  filename = package.basename.to_s
1502
1602
  puts "Processing #{filename}..."
1503
1603
  if settled?(filename)
1604
+ # A settled stem stands its facets down ONLY when they are on the
1605
+ # release — a facet that never landed has nothing to keep, and on a
1606
+ # co-publish line the byte-truthful source is the settled bundle
1607
+ # itself (its members ARE the previous entry's pins).
1608
+ return repair_settled_absent_asset(release, filename) if copublish? && !find_asset(release, filename)
1609
+
1504
1610
  puts "#{filename} kept its previous bytes earlier in this publish run — " \
1505
1611
  "the settled asset is never re-attempted; the refresh lands on a republish onto a recreated release object"
1506
1612
  return nil
@@ -1535,6 +1641,155 @@ module TebakoRelease
1535
1641
  nil
1536
1642
  end
1537
1643
 
1644
+ # The settled-stem co-publish repair (spec 36 §3 adoption on a
1645
+ # bundle-era release): the stem settled (the byte-immutable keep — the
1646
+ # build is not bit-reproducible), so the package speaks with the
1647
+ # previous entry's voice — but the previous bundle-era publish never
1648
+ # served the standalone members. The settled bundle on the release
1649
+ # holds exactly the bytes the previous entry pins (that is what the
1650
+ # bundle IS), so the members are served from it: extract, verify
1651
+ # against the pin, upload. A member absent with nothing truthful to
1652
+ # repair from (no previous entry, no pin, no bundle asset) is a named
1653
+ # error — never a silent skip, never a lying upload.
1654
+ def repair_settled_absent_asset(release, filename)
1655
+ if Blksum.blksum_file?(Pathname.new(filename))
1656
+ repair_settled_blksum(release, filename)
1657
+ else
1658
+ repair_settled_member(release, filename)
1659
+ end
1660
+ end
1661
+
1662
+ def repair_settled_member(release, filename)
1663
+ entry = previous_entry_for_stem(filename)
1664
+ bundle_asset = repair_bundle_asset(release, entry, filename)
1665
+ member = verified_bundle_member(release, bundle_asset, entry, filename)
1666
+ puts "Repairing #{filename} from the settled bundle #{bundle_asset.name} " \
1667
+ "(the co-publish adoption serves the bundle's own members standalone — byte-identical with the pin)"
1668
+ perform_upload(release, member, filename)
1669
+ filename
1670
+ end
1671
+
1672
+ # The settled bundle asset the previous entry pins this member over, or
1673
+ # a named error: an absent member with nothing truthful to repair from
1674
+ # (no previous entry, no pin, no bundle asset) is never silently
1675
+ # skipped and never freshly uploaded over a settled voice.
1676
+ def repair_bundle_asset(release, entry, filename)
1677
+ pin = entry && member_pin_for(entry, filename)
1678
+ bundle_asset = entry && find_asset(release, entry&.dig(:bundle, :filename))
1679
+ return bundle_asset if pin && bundle_asset
1680
+
1681
+ raise Error, "#{filename} never landed on the release and its settled package has nothing truthful " \
1682
+ "to repair from (no previous entry pinning it over a served bundle) — " \
1683
+ "the refresh lands on a republish onto a recreated release object"
1684
+ end
1685
+
1686
+ # The member's extraction, verified against the previous entry's pin —
1687
+ # the bundle's contents and the shard must agree, or neither is served.
1688
+ def verified_bundle_member(release, bundle_asset, entry, filename)
1689
+ member = extracted_bundle_members(release, bundle_asset)[filename]
1690
+ actual = member && Digest::SHA256.file(member).hexdigest
1691
+ return member if member && actual == member_pin_for(entry, filename)
1692
+
1693
+ raise Error, "#{filename} extracted from the settled bundle #{bundle_asset.name} does not hash to the " \
1694
+ "package's published pin (#{actual || "not a member"}) — the bundle and the shard " \
1695
+ "disagree; refusing to serve either reading (byte-truthful, never a guess)"
1696
+ end
1697
+
1698
+ # The derived blksum sidecar of a settled package derives from the
1699
+ # SERVED image bytes — never the fresh build's (they did not land).
1700
+ # The served bytes are the just-repaired extraction when this run
1701
+ # landed the image, else the release's own image asset.
1702
+ def repair_settled_blksum(release, filename)
1703
+ image_name = filename.delete_suffix(Blksum::SIDECAR_SUFFIX)
1704
+ image = served_image_path(release, image_name)
1705
+ blksum = Blksum.for_image(image)
1706
+ sidecar = Pathname.new(Dir.mktmpdir).join(filename).tap { |path| path.write(blksum.render) }
1707
+ puts "Repairing #{filename} derived from the served image #{image_name} (spec 39 §3)"
1708
+ perform_upload(release, sidecar, filename)
1709
+ filename
1710
+ end
1711
+
1712
+ def served_image_path(release, image_name)
1713
+ extracted = repaired_image_extraction(release, image_name)
1714
+ return extracted if extracted
1715
+
1716
+ asset = find_asset(release, image_name)
1717
+ unless asset
1718
+ raise Error, "the served image #{image_name} is not on the release — cannot derive its blksum sidecar"
1719
+ end
1720
+
1721
+ bytes = with_transient_retries { @client.get(asset.browser_download_url) }.to_s
1722
+ Pathname.new(Dir.mktmpdir).join(image_name).tap { |path| path.binwrite(bytes) }
1723
+ end
1724
+
1725
+ # The image's extraction when this run repaired it (the just-landed
1726
+ # bytes), nil when the image was never extracted here.
1727
+ def repaired_image_extraction(release, image_name)
1728
+ entry = previous_entry_for_stem(image_name)
1729
+ bundle_asset = entry && find_asset(release, entry&.dig(:bundle, :filename))
1730
+ bundle_asset && extracted_bundle_members(release, bundle_asset)[image_name]
1731
+ end
1732
+
1733
+ # The previous manifest entry of the package stem an asset name belongs
1734
+ # to (the exe, its facets, the derived sidecars, the bundle — one
1735
+ # stem). previous_entry_covering's metadata-assets walk does not serve
1736
+ # here: an UNWITNESSED bundle-era entry's metadata covers the bundle
1737
+ # alone, while the repair needs the member pins it still carries.
1738
+ def previous_entry_for_stem(filename)
1739
+ stem = package_stem(filename)
1740
+ previous_manifest_entries.find { |entry| package_stem(entry[:filename]) == stem }
1741
+ end
1742
+
1743
+ # The previous entry's pin for one member name: the exe at the top
1744
+ # level, the image/dll in their facet blocks. nil when the entry does
1745
+ # not pin the name (nothing truthful to verify an extraction against).
1746
+ def member_pin_for(entry, filename)
1747
+ return entry[:sha256] if entry[:filename] == filename
1748
+
1749
+ %i[image dll].each do |facet|
1750
+ block = entry[facet]
1751
+ return block[:sha256] if block && block[:filename] == filename
1752
+ end
1753
+ nil
1754
+ end
1755
+
1756
+ # The settled bundle's members, extracted once per bundle into a
1757
+ # process-lifetime scratch dir (the exe/image/dll repairs of one stem
1758
+ # share the one download). The bundle grammar is flat bare basenames
1759
+ # (Bundler writes them so); anything else is refused, never extracted.
1760
+ def extracted_bundle_members(release, bundle_asset)
1761
+ @extracted_bundles ||= {}
1762
+ @extracted_bundles[bundle_asset.name] ||= extract_bundle(release, bundle_asset)
1763
+ end
1764
+
1765
+ def extract_bundle(_release, bundle_asset)
1766
+ bytes = with_transient_retries { @client.get(bundle_asset.browser_download_url) }.to_s
1767
+ dir = Pathname.new(Dir.mktmpdir("tebako-release-bundle-"))
1768
+ Zlib::GzipReader.wrap(StringIO.new(bytes)) { |gzip| extract_tar_members(bundle_asset, gzip, dir) }
1769
+ end
1770
+
1771
+ def extract_tar_members(bundle_asset, gzip, dir)
1772
+ members = {}
1773
+ Gem::Package::TarReader.new(gzip) do |tar|
1774
+ tar.each do |tar_entry|
1775
+ next unless tar_entry.file?
1776
+
1777
+ name = flat_member_name(bundle_asset, tar_entry.full_name)
1778
+ members[name] = dir.join(name).tap { |path| path.binwrite(tar_entry.read) }
1779
+ end
1780
+ end
1781
+ members
1782
+ end
1783
+
1784
+ # The bundle grammar is flat bare basenames (Bundler writes them so);
1785
+ # anything else is refused, never extracted.
1786
+ def flat_member_name(bundle_asset, name)
1787
+ return name if name == File.basename(name)
1788
+
1789
+ raise Error, "bundle #{bundle_asset.name} carries the non-flat member #{name} — " \
1790
+ "the spec 36 §2 grammar is bare basenames only; refusing to extract"
1791
+ end
1792
+
1538
1793
  # The settled ledger — the durable half of warn-and-keep-previous. The
1539
1794
  # publish step runs the uploader once per platform (sequential
1540
1795
  # processes, one workspace); an asset settled in one invocation (the
@@ -5,5 +5,5 @@
5
5
  # This file is a part of tamatebako
6
6
 
7
7
  module TebakoRelease
8
- VERSION = "0.3.0"
8
+ VERSION = "0.4.1"
9
9
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tebako-release
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc