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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: cd3ef0747dda2fc3ea4d7fcd7f35fe9aeba66cec808604ff7933ba3f644e0191
4
+ data.tar.gz: 24374660ac6c58c5b0d226b67224d84b1f18f3f0f415c666657a6b9aeeebe015
5
+ SHA512:
6
+ metadata.gz: 15a427891a21fb83094aa5f6dd1970a5efe45cb9431b8ab62ad2e7b6faea108c26d7ca66b2743c332c8f4600b3072fa131f0b5002496c90448a5f60da7671305
7
+ data.tar.gz: 23ecefb7508b57e6d9c907e867b8825b54b0915a084a04ff7cf88d1c81f42a1c469b01cd4a6fc1ade5faddfa2a250860e94306c69738c37e8d58d4c33fdd0981
data/LICENSE.md ADDED
@@ -0,0 +1,24 @@
1
+ Copyright (c) 2026 [Ribose Inc](https://www.ribose.com).
2
+ All rights reserved.
3
+ This file is a part of tamatebako
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions
7
+ are met:
8
+ 1. Redistributions of source code must retain the above copyright
9
+ notice, this list of conditions and the following disclaimer.
10
+ 2. Redistributions in binary form must reproduce the above copyright
11
+ notice, this list of conditions and the following disclaimer in the
12
+ documentation and/or other materials provided with the distribution.
13
+
14
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
15
+ ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
16
+ TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
17
+ PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS
18
+ BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
19
+ CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
20
+ SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
21
+ INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
22
+ CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
23
+ ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
24
+ THE POSSIBILITY OF SUCH DAMAGE.
data/README.md ADDED
@@ -0,0 +1,66 @@
1
+ # tebako-release-tooling
2
+
3
+ The tebako factories' release machinery — the **single owner** of the per-leg
4
+ publish uploader and the no-fold OpenPGP release signer that every runtime
5
+ factory (ruby, python, openjdk, …) consumes. Ecosystem invariant 10: the
6
+ machinery exists exactly once; factories declare their identity and policy
7
+ through a small adapter file and pin this gem — they never carry copies.
8
+
9
+ ## What ships here
10
+
11
+ - **`TebakoRelease::Uploader`** (`tebako-release upload`) — the spec 13 §2a
12
+ de-rendezvoused publish: each build leg uploads only the write-once names
13
+ it owns (payloads byte-immutable per name, per-asset `.sha256` sidecars,
14
+ per-package `.manifest.json` shards), with the convergence, rate-limit
15
+ ride-out, and settled-ledger machinery earned against the named incidents
16
+ of the v0.16.x line.
17
+ - **`TebakoRelease::Signer`** (`tebako-release sign`) — spec 09 §5's no-fold
18
+ signing: every served name gets its own detached `.asc`, provenance-checked
19
+ against the release listing's digest, with the young-release-object
20
+ served-bytes convergence.
21
+ - **`TebakoRelease::Bundler`** — spec 36's deterministic bundle builder:
22
+ one `<stem>.tar.gz` per leg (exe + env image + support DLLs + a closing
23
+ in-bundle SHA256SUMS; fixed member order, mtime=0 — identical inputs
24
+ yield identical bytes). A factory opts into the bundle-era publish shape
25
+ through its adapter (`bundle_publish? → true`); the per-file shape stays
26
+ the default for pre-bundle lines.
27
+ - **`TebakoRelease::Platform`** — the release-side platform vocabulary
28
+ (the `(os, arch) → host_id` table; mirrors `tpkg::Platform`, drift fails
29
+ loudly in factory CI).
30
+
31
+ ## Consuming factory contract
32
+
33
+ A factory adds the gem, pinned through its `contract.yml` SSOT:
34
+
35
+ ```ruby
36
+ # Gemfile
37
+ gem "tebako-release",
38
+ git: "https://github.com/tamatebako/tebako-release-tooling.git",
39
+ tag: YAML.load_file("contract.yml").fetch("release_tooling")
40
+ ```
41
+
42
+ and declares itself once in `scripts/release_adapter.rb`:
43
+
44
+ ```ruby
45
+ TebakoRelease.configure(
46
+ repo: "tamatebako/tebako-runtime-ruby", # whose releases this run manages
47
+ language: "ruby", # EXPECTED_RUBY_MATRIX, ruby_version key
48
+ title_prefix: "Tebako runtime packages",
49
+ contract_yml: File.expand_path("../contract.yml", __dir__),
50
+ adapter: RubyReleaseAdapter.new # capable_pair? / capabilities / dll_install_name
51
+ )
52
+ ```
53
+
54
+ CI then calls `bundle exec tebako-release upload` (publish legs) and
55
+ `bundle exec tebako-release sign` (sign legs). Sign-only consumers can skip
56
+ the adapter and set `TEBAKO_RELEASE_REPO` instead.
57
+
58
+ ## force_rebuild, the release-object rule
59
+
60
+ Payload assets are byte-immutable per name — the uploader **never** deletes a
61
+ published payload asset to replace it (delete → re-upload of the same name is
62
+ the 422 wedge; tebako-runtime-ruby#189). A forced republication recreates the
63
+ **release object** once, coordinator-side, before any leg fans out
64
+ (`gh release delete <tag> --cleanup-tag false` … the first leg's
65
+ race-safe create re-makes it), turning every leg's publish into pure creates.
66
+ Operator-run re-uploads follow the same rule by hand.
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Copyright (c) 2026 [Ribose Inc](https://www.ribose.com).
5
+ # All rights reserved.
6
+ # This file is a part of tamatebako
7
+
8
+ # CI log truth: stdout to a pipe is block-buffered, and the runner stamps
9
+ # each line at FLUSH time — in the 2026-08-20 wedge incident's log, lines
10
+ # written minutes apart (sleeps included) appeared 2 ms apart. Flush every
11
+ # line so the log's timestamps are the writes' real times.
12
+ $stdout.sync = true
13
+
14
+ require "tebako_release"
15
+
16
+ command = ARGV.shift
17
+
18
+ # The consuming factory's adapter file declares TebakoRelease.configure(...)
19
+ # — repo, language, version grammar, and the policy callbacks. Sign-only
20
+ # invocations may run adapter-less on TEBAKO_RELEASE_REPO alone.
21
+ adapter = ENV.fetch("TEBAKO_RELEASE_ADAPTER", "scripts/release_adapter.rb")
22
+ require File.expand_path(adapter) if File.file?(adapter)
23
+
24
+ case command
25
+ when "upload"
26
+ TebakoRelease::Uploader.new.run
27
+ when "sign"
28
+ begin
29
+ TebakoRelease::Signer.new.sign_release
30
+ rescue TebakoRelease::Signer::SigningGateError, TebakoRelease::Error, KeyError => e
31
+ warn e.message
32
+ exit 1
33
+ end
34
+ else
35
+ warn "usage: tebako-release <upload|sign>"
36
+ exit 64
37
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright (c) 2026 [Ribose Inc](https://www.ribose.com).
4
+ # All rights reserved.
5
+ # This file is a part of tamatebako
6
+
7
+ module TebakoRelease
8
+ # The factory-policy surface — the one seam through which a consuming
9
+ # factory's own vocabulary reaches the release machinery. Factories
10
+ # subclass this (or pass any object responding to the same methods) in
11
+ # their adapter file; the defaults describe a runtime with no
12
+ # capability gates, no capabilities display metadata, no DLL facet,
13
+ # plain string version matrix rows, and a semver version grammar.
14
+ class Adapter
15
+ # (os id, arch id, runtime version) → false when the factory never
16
+ # builds that combination (the audit must never expect it). Default:
17
+ # every pair is capable.
18
+ def capable_pair?(_os, _arch, _version)
19
+ true
20
+ end
21
+
22
+ # The additive capabilities line of a manifest entry: display metadata
23
+ # owned by the factory that compiled the runtime — never a selector
24
+ # axis. Default: none.
25
+ def capabilities(version:, platform_id:)
26
+ _ = version
27
+ _ = platform_id
28
+ []
29
+ end
30
+
31
+ # The PE name the store materializes next to a windows exe so its
32
+ # imports resolve (nil → the runtime ships no DLL facet).
33
+ def dll_install_name(_version, _host_id)
34
+ nil
35
+ end
36
+
37
+ # The prepare job's version matrix rows → version strings. Default:
38
+ # rows ARE the version strings; factories whose rows carry extra keys
39
+ # (a src_sha256 cache key) override.
40
+ def expected_versions(rows)
41
+ rows.map { |row| row.is_a?(Hash) ? row.fetch("version") : row }
42
+ end
43
+
44
+ # The version capture of the package-name grammar, as a regex SOURCE
45
+ # (the consuming factory's version-line model owns it).
46
+ def version_grammar_source
47
+ "\\d+\\.\\d+\\.\\d+"
48
+ end
49
+
50
+ # Spec 36's publish shape: when true, each leg publishes ONE bundle
51
+ # (<stem>.tar.gz — exe + env image + DLLs + in-bundle SHA256SUMS) plus
52
+ # its sidecar and shard, instead of the per-file asset enumeration.
53
+ # Default off: a factory opts in deliberately, with the bundle-era
54
+ # resolver shipped downstream (spec 36 §4's compat window).
55
+ def bundle_publish?
56
+ false
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright (c) 2026 [Ribose Inc](https://www.ribose.com).
4
+ # All rights reserved.
5
+ # This file is a part of tamatebako
6
+
7
+ require "digest"
8
+ require "rubygems/package"
9
+ require "zlib"
10
+
11
+ module TebakoRelease
12
+ # Spec 36 §2 — the deterministic tar.gz runtime bundle: ONE distribution
13
+ # artifact per (version × triplet) leg carrying the interpreter exe, the
14
+ # env image, and any windows support DLLs, closed by an in-bundle
15
+ # SHA256SUMS. The bundle is the publish unit on bundle-era lines: the
16
+ # per-file asset enumeration (exe + image + per-asset sidecars, each
17
+ # signed) costs ~10–14 release assets per leg; the bundle costs 3–5.
18
+ #
19
+ # Determinism is a publish-economy property, not a promise to consumers:
20
+ # identical inputs must yield identical bytes so a re-run's digest-match
21
+ # skip stays cheap (fixed member order, mtime=0 everywhere, no gzip
22
+ # original-name field).
23
+ class Bundler
24
+ BUNDLE_SUFFIX = ".tar.gz"
25
+ SUMS_NAME = "SHA256SUMS"
26
+
27
+ # Member modes as the store materializes them (spec 05 §3): the exe is
28
+ # 0755, the env image 0444, support DLLs plain read-only data.
29
+ EXE_MODE = 0o755
30
+ IMAGE_MODE = 0o444
31
+ DLL_MODE = 0o444
32
+
33
+ def self.bundle_name_for(stem)
34
+ "#{stem}#{BUNDLE_SUFFIX}"
35
+ end
36
+
37
+ # Build <dir>/<stem>.tar.gz from the leg's staged members.
38
+ # members: exe Pathname (required), image Pathname (required — a
39
+ # bundle without its env image is not a runtime), dlls (zero or
40
+ # more). Returns the bundle Pathname.
41
+ def build(dir, stem, exe:, image:, dlls: [])
42
+ members = [[exe, EXE_MODE], [image, IMAGE_MODE]] + dlls.sort_by { |d| d.basename.to_s }.map { |d| [d, DLL_MODE] }
43
+ members.map(&:first).each { |path| validate_member!(path) }
44
+ bundle = Pathname.new(dir).join(self.class.bundle_name_for(stem))
45
+ write_bundle(bundle, members, sums_content(members))
46
+ bundle
47
+ end
48
+
49
+ private
50
+
51
+ # SHA256SUMS, coreutils shape ("<sha> <file>\n" per member), in member
52
+ # order — the bundle's last member, pinning every other (spec 36 §2).
53
+ def sums_content(members)
54
+ lines = members.map { |path, _| "#{Digest::SHA256.file(path).hexdigest} #{path.basename}" }
55
+ "#{lines.join("\n")}\n"
56
+ end
57
+
58
+ # Deterministic bytes (spec 36 §2): fixed member order, mtime=0 in the
59
+ # gzip header and every tar header — identical inputs yield identical
60
+ # bytes, so a re-run's digest-match skip stays cheap.
61
+ def write_bundle(bundle, members, sums)
62
+ File.open(bundle, "wb") do |io|
63
+ gz = Zlib::GzipWriter.new(io)
64
+ gz.mtime = 0
65
+ Gem::Package::TarWriter.new(gz) do |tar|
66
+ members.each { |path, mode| add_member(tar, path.basename.to_s, mode, path) }
67
+ tar.add_file_simple(SUMS_NAME, 0o444, sums.bytesize) { |io_member| io_member.write(sums) }
68
+ end
69
+ gz.close
70
+ end
71
+ end
72
+
73
+ def add_member(tar, name, mode, path)
74
+ tar.add_file_simple(name, mode, path.size) do |io_member|
75
+ File.open(path, "rb") { |f| IO.copy_stream(f, io_member) }
76
+ end
77
+ end
78
+
79
+ # The §2 member grammar: a plain staged regular file — never a
80
+ # symlink, directory, or device (members are written under their bare
81
+ # basenames, so no path traversal is possible by construction; what
82
+ # remains to assert is the kind).
83
+ def validate_member!(path)
84
+ return if path.file? && !path.symlink?
85
+
86
+ raise Error, "bundle member #{path} is not a plain staged file (spec 36 §2's member grammar)"
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright (c) 2026 [Ribose Inc](https://www.ribose.com).
4
+ # All rights reserved.
5
+ # This file is a part of tamatebako
6
+
7
+ module TebakoRelease
8
+ # The consuming factory's identity: which repo's releases this run
9
+ # manages, the language whose version strings ride the package-name
10
+ # grammar, the release title prefix, the contract.yml path (the release
11
+ # pipeline's SSOT for the bootstrap<->runtime contract version), and the
12
+ # policy adapter. `repo` is the one value a sign-only consumer must
13
+ # declare; everything else has an upload-side default.
14
+ class Config
15
+ DEFAULT_TOOL_REPO = "tamatebako/tebako"
16
+
17
+ attr_reader :repo, :language, :title_prefix, :contract_yml, :adapter, :tool_repo
18
+
19
+ def initialize(repo:, language: nil, title_prefix: nil, contract_yml: "contract.yml", # rubocop:disable Metrics/ParameterLists
20
+ adapter: Adapter.new, tool_repo: DEFAULT_TOOL_REPO)
21
+ @repo = repo
22
+ @language = language
23
+ @title_prefix = title_prefix || "Tebako runtime packages"
24
+ @contract_yml = contract_yml
25
+ @adapter = adapter
26
+ @tool_repo = tool_repo
27
+ end
28
+
29
+ # The minimal env-only declaration: the sign path's one repo value.
30
+ def self.from_env(env = ENV)
31
+ new(repo: env.fetch("TEBAKO_RELEASE_REPO") do
32
+ raise Error, "TEBAKO_RELEASE_REPO is not set and no release adapter was configured — " \
33
+ "the release machinery never guesses which repo it publishes to"
34
+ end)
35
+ end
36
+
37
+ # The manifest entry's version key for this runtime's language
38
+ # (ruby_version / python_version / …).
39
+ def version_key
40
+ :"#{language}_version"
41
+ end
42
+
43
+ # The env var carrying this leg's expected version matrix
44
+ # (EXPECTED_RUBY_MATRIX / EXPECTED_PYTHON_MATRIX / …).
45
+ def version_matrix_env
46
+ "EXPECTED_#{language.upcase}_MATRIX"
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright (c) 2026 [Ribose Inc](https://www.ribose.com).
4
+ # All rights reserved.
5
+ # This file is a part of tamatebako
6
+
7
+ module TebakoRelease
8
+ # Named error for every release-machinery failure (spec 00: named errors,
9
+ # never silent fallbacks).
10
+ class Error < StandardError; end
11
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright (c) 2026 [Ribose Inc](https://www.ribose.com).
4
+ # All rights reserved.
5
+ # This file is a part of tamatebako
6
+
7
+ require "rbconfig"
8
+
9
+ module TebakoRelease
10
+ # The release-side platform vocabulary: (os id, arch id) ↔ the release
11
+ # platform id used in runtime package names. Owned by tpkg::Platform
12
+ # (tamatebako/tebako, docs/spec/03 §3); NOT derivable by formula
13
+ # ("windows-ucrt64" carries no arch segment). windows/arm64 follows the
14
+ # product's RESERVED release-asset name ("windows-ucrt-arm64" — the
15
+ # aarch64-windows-ucrt triplet, which tpkg parses but rejects in payload
16
+ # manifests until the platform ships). This is the release tooling's
17
+ # mirror of that table; drift fails loudly in the factories' CI parity
18
+ # checks.
19
+ class Platform
20
+ HOST_IDS = {
21
+ %w[windows x86_64] => "windows-ucrt64",
22
+ %w[windows arm64] => "windows-ucrt-arm64",
23
+ %w[macos arm64] => "macos-arm64",
24
+ %w[macos x86_64] => "macos-x86_64",
25
+ %w[linux-gnu x86_64] => "linux-gnu-x86_64",
26
+ %w[linux-gnu arm64] => "linux-gnu-arm64",
27
+ %w[linux-musl x86_64] => "linux-musl-x86_64",
28
+ %w[linux-musl arm64] => "linux-musl-arm64"
29
+ }.freeze
30
+
31
+ # The lookup for callers with no detected host (the release pipeline's
32
+ # expected-asset model); the instance path shares the named failure.
33
+ def self.host_id_for(os_id, arch_id)
34
+ HOST_IDS.fetch([os_id, arch_id]) { raise Error, "no release platform id for #{os_id}/#{arch_id}" }
35
+ end
36
+
37
+ def initialize(ostype = RUBY_PLATFORM, arch = RbConfig::CONFIG["host_cpu"])
38
+ @ostype = ostype
39
+ @arch = arch
40
+ end
41
+
42
+ attr_reader :ostype
43
+
44
+ # Platform id as used by runtime package names (e.g. "macos-arm64",
45
+ # "windows-ucrt64").
46
+ def host_id
47
+ self.class.host_id_for(host_os_id, host_arch_id)
48
+ end
49
+
50
+ def exe_suffix
51
+ @ostype =~ /msys|mingw|cygwin/ ? ".exe" : ""
52
+ end
53
+
54
+ private
55
+
56
+ def host_os_id
57
+ case @ostype
58
+ when /msys|mingw|cygwin/ then "windows"
59
+ when /darwin/ then "macos"
60
+ when /linux-musl/ then "linux-musl"
61
+ when /linux/ then "linux-gnu"
62
+ else
63
+ raise Error, "no release os id for #{@ostype}"
64
+ end
65
+ end
66
+
67
+ def host_arch_id
68
+ case @arch
69
+ when /^(x86_64|amd64|x64)$/ then "x86_64"
70
+ when /^(aarch64|arm64)$/ then "arm64"
71
+ else
72
+ raise Error, "no release arch id for #{@arch}"
73
+ end
74
+ end
75
+ end
76
+ end