native-packages 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "project"
4
+
5
+ module NativePackages
6
+ class Configuration
7
+ include Support
8
+ FORMATS = %w[deb rpm archlinux apk ipk msix srpm].freeze
9
+ NFPM_VERSION = "2.47.0"
10
+ NAMES = %w[native-packages.yaml native-packages.yml].freeze
11
+ KEYS = %w[schema tool nfpm targets release assets templates repositories revisions libraries version_file version_section].freeze
12
+ attr_reader :root, :path, :data
13
+
14
+ def self.discover(root, explicit = nil)
15
+ return Pathname.new(explicit).expand_path(root) if explicit
16
+ paths = NAMES.map { |name| Pathname.new(root) / name }.select(&:exist?)
17
+ raise Error, "both #{NAMES.join(' and ')} exist; use --config" if paths.length > 1
18
+ paths.first
19
+ end
20
+
21
+ def initialize(path)
22
+ @path = Pathname.new(path).expand_path
23
+ @root = @path.dirname.realpath
24
+ @data = YAML.safe_load_file(@path, permitted_classes: [], aliases: false)
25
+ raise Error, "#{path}: expected a configuration mapping" unless data.is_a?(Hash)
26
+ raise Error, "unsupported schema; expected schema: 1" unless data["schema"] == 1
27
+ unknown = data.keys - KEYS
28
+ raise Error, "unknown configuration fields: #{unknown.join(', ')}" unless unknown.empty?
29
+ %w[tool targets].each { |key| mapping(data.fetch(key), key) }
30
+ data["nfpm"] = definition(data.fetch("nfpm", {}))
31
+ %w[assets templates repositories revisions libraries release].each { |key| data[key] = mapping(data.fetch(key, {}), key) }
32
+ data["targets"].each do |name, target|
33
+ mapping(target, "targets.#{name}")
34
+ target["nfpm"] = definition(target.fetch("nfpm", {}))
35
+ end
36
+ end
37
+
38
+ def mapping(value, label)
39
+ raise Error, "#{label}: expected a mapping" unless value.is_a?(Hash)
40
+ value
41
+ end
42
+
43
+ def definition(value)
44
+ value = YAML.safe_load_file(root / relative_path(value), permitted_classes: [], aliases: false) if value.is_a?(String)
45
+ mapping(value, "nfpm")
46
+ end
47
+
48
+ def merge(base, override)
49
+ base.merge(override) { |_, left, right| left.is_a?(Hash) && right.is_a?(Hash) ? merge(left, right) : right }
50
+ end
51
+
52
+ def name = data.fetch("nfpm").fetch("name")
53
+ def targets = data.fetch("targets")
54
+ def digest = OpenSSL::Digest::SHA256.hexdigest(JSON.generate(data))
55
+ def package(target) = merge(data.fetch("nfpm"), target.fetch("nfpm", {}))
56
+
57
+ def project
58
+ config = { "version" => 1, "name" => name, "repository" => data.fetch("release").fetch("repository", ""),
59
+ "assets" => data.fetch("assets"), "templates" => data.fetch("templates"), "revisions" => data.fetch("revisions") }
60
+ %w[version_file version_section].each { |key| config[key] = data[key] if data.key?(key) }
61
+ Project.new(root, config: config, registries: data.fetch("repositories"))
62
+ end
63
+
64
+ def validate
65
+ raise Error, "nfpm.name: use letters, digits, dots, underscores or hyphens" unless /\A[a-zA-Z0-9][a-zA-Z0-9._-]*\z/.match?(name)
66
+ %w[version nfpm].each { |key| version_arg(data.fetch("tool").fetch(key)) }
67
+ if data.fetch("tool").fetch("version") != VERSION
68
+ wanted = data.fetch("tool").fetch("version")
69
+ raise Error, "configuration needs native-packages #{wanted}; run gem install native-packages -v #{wanted}, then native-packages _#{wanted}_ COMMAND"
70
+ end
71
+ raise Error, "unsupported nFPM version; use #{NFPM_VERSION}" unless data.fetch("tool").fetch("nfpm") == NFPM_VERSION
72
+ metadata = tokens("9.8.7", epoch: 1_767_225_600)
73
+ targets.each do |id, target|
74
+ raise Error, "invalid target name: #{id}" unless /\A[a-z0-9][a-z0-9-]*\z/.match?(id)
75
+ unknown = target.keys - %w[platform arch libc abi kind formats input nfpm before_build after_package compiler_target]
76
+ raise Error, "targets.#{id}: unknown fields #{unknown.join(', ')}" unless unknown.empty?
77
+ formats = target.fetch("formats")
78
+ unless formats.is_a?(Array) && !formats.empty? && formats.uniq == formats && (formats - FORMATS).empty?
79
+ raise Error, "targets.#{id}.formats: choose from #{FORMATS.join(', ')}"
80
+ end
81
+ raise Error, "targets.#{id}.platform: use linux or windows" unless %w[linux windows].include?(target.fetch("platform"))
82
+ raise Error, "targets.#{id}.arch: expected an architecture" unless /\A[a-zA-Z0-9_+-]+\z/.match?(target.fetch("arch"))
83
+ kind = target.fetch("kind", "binary")
84
+ raise Error, "targets.#{id}.kind: use binary, data or source" unless %w[binary data source].include?(kind)
85
+ raise Error, "#{id}: MSIX requires a Windows target with only msix format" if formats.include?("msix") && (target["platform"] != "windows" || formats != ["msix"])
86
+ raise Error, "#{id}: Windows targets require msix" if target["platform"] == "windows" && formats != ["msix"]
87
+ raise Error, "#{id}: SRPM requires a separate source target" if (formats.include?("srpm") && (kind != "source" || formats != ["srpm"])) || (kind == "source" && formats != ["srpm"])
88
+ if kind == "binary" && target["platform"] == "linux"
89
+ raise Error, "#{id}.libc: declare glibc, musl or static" unless %w[glibc musl static].include?(target["libc"])
90
+ end
91
+ raise Error, "#{id}: IPK needs an explicit abi (device/distribution baseline)" if formats.include?("ipk") && target.fetch("abi", "").empty?
92
+ input = mapping(target.fetch("input"), "#{id}.input")
93
+ raise Error, "#{id}.input: declare local or release_asset" unless input["local"] || input["release_asset"]
94
+ raise Error, "#{id}.input.kind: use file, directory or archive" unless %w[file directory archive].include?(input.fetch("kind", "archive"))
95
+ %w[before_build after_package].each do |key|
96
+ hook = target[key]
97
+ raise Error, "#{id}.#{key}: use a nonempty array of command arguments" if hook && (!hook.is_a?(Array) || hook.empty? || !hook.all? { |part| part.is_a?(String) })
98
+ end
99
+ rendered = render_tree(package(target), target_tokens(metadata, id, target, "/payload"))
100
+ raise Error, "#{id}: nfpm.contents must be an array" unless rendered["contents"].is_a?(Array)
101
+ %w[maintainer description license].each do |key|
102
+ raise Error, "#{id}: fill in nfpm.#{key}" unless rendered[key].is_a?(String) && !rendered[key].strip.empty?
103
+ end
104
+ if formats.include?("msix")
105
+ msix = mapping(rendered.fetch("msix"), "#{id}.nfpm.msix")
106
+ raise Error, "#{id}: supply msix.publisher" unless msix["publisher"].is_a?(String) && !msix["publisher"].empty?
107
+ raise Error, "#{id}: supply msix.applications" unless msix["applications"].is_a?(Array) && !msix["applications"].empty?
108
+ end
109
+ %w[name arch platform version].each do |key|
110
+ expected = { "name" => name, "arch" => target["arch"], "platform" => target["platform"], "version" => "9.8.7" }.fetch(key)
111
+ raise Error, "#{id}: conflicting nfpm.#{key}; declare it once" if rendered.key?(key) && rendered[key] != expected
112
+ end
113
+ render_tree(input, target_tokens(metadata, id, target, "/payload"))
114
+ render_tree(target["before_build"], target_tokens(metadata, id, target, "/payload")) if target["before_build"]
115
+ render_tree(target["after_package"], target_tokens(metadata, id, target, "/payload").merge("PACKAGE" => "/output/package", "FORMAT" => formats.first)) if target["after_package"]
116
+ end
117
+ data.fetch("templates").each do |destination, source|
118
+ relative_path(render(destination, metadata))
119
+ render((root / relative_path(source)).binread, metadata)
120
+ end
121
+ project.repositories
122
+ true
123
+ end
124
+
125
+ def tokens(version, epoch: nil)
126
+ epoch ||= Integer(ENV.fetch("SOURCE_DATE_EPOCH", Time.now.to_i.to_s))
127
+ result = { "NAME" => name, "VERSION" => version, "TAG" => "v#{version}", "DATE" => Time.at(epoch).utc.iso8601,
128
+ "SOURCE_DATE_EPOCH" => epoch, "ROOT" => root.to_s, "PKGREL" => 1,
129
+ "UPSTREAM" => "https://github.com/#{data.fetch('release').fetch('repository', '')}", "GIT_VERSION" => "r0.unknown" }
130
+ data.fetch("assets").each do |key, asset|
131
+ raise Error, "invalid asset key: #{key}" unless /\A[A-Z][A-Z0-9_]*\z/.match?(key)
132
+ result["#{key}_FILE"] = render(asset.fetch("file"), result)
133
+ result["#{key}_URL"] = render(asset.fetch("url", "#{result['UPSTREAM']}/releases/download/@TAG@/@#{key}_FILE@"), result)
134
+ result["#{key}_SHA256"] = "0" * 64
135
+ end
136
+ result
137
+ end
138
+
139
+ def target_tokens(metadata, id, target, payload)
140
+ metadata.merge("ARCH" => target.fetch("arch"), "PLATFORM" => target.fetch("platform"),
141
+ "TARGET" => target.fetch("compiler_target", id), "TARGET_ID" => id, "PAYLOAD" => payload.to_s)
142
+ end
143
+
144
+ def select(ids: [], formats: [])
145
+ raise Error, "unknown targets: #{(ids - targets.keys).join(', ')}" unless (ids - targets.keys).empty?
146
+ raise Error, "unknown formats: #{(formats - FORMATS).join(', ')}" unless (formats - FORMATS).empty?
147
+ chosen = targets.select { |id, _| ids.empty? || ids.include?(id) }.filter_map do |id, target|
148
+ selected = formats.empty? ? target.fetch("formats") : target.fetch("formats") & formats
149
+ [id, target.merge("formats" => selected)] unless selected.empty?
150
+ end.to_h
151
+ raise Error, "no configured targets match the selection" if chosen.empty? && (!targets.empty? || !ids.empty? || !formats.empty?)
152
+ chosen
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NativePackages
4
+ class Inspection
5
+ include Support
6
+ ELF_ARCHES = { "386" => [3, 1, 1], "amd64" => [62, 2, 1], "arm64" => [183, 2, 1],
7
+ "arm5" => [40, 1, 1], "arm6" => [40, 1, 1], "arm7" => [40, 1, 1],
8
+ "mips" => [8, 1, 2], "mipsle" => [8, 1, 1], "mips64" => [8, 2, 2], "mips64le" => [8, 2, 1],
9
+ "ppc64" => [21, 2, 2], "ppc64le" => [21, 2, 1], "s390x" => [22, 2, 2],
10
+ "riscv64" => [243, 2, 1], "loong64" => [258, 2, 1] }.freeze
11
+ PE_ARCHES = { "386" => 0x14c, "amd64" => 0x8664, "arm64" => 0xaa64 }.freeze
12
+ LIBRARIES = {
13
+ "libgcc_s.so.1" => { "deb" => "libgcc-s1", "rpm" => "libgcc" },
14
+ "libstdc++.so.6" => { "deb" => "libstdc++6", "rpm" => "libstdc++" },
15
+ "libasound.so.2" => { "deb" => "libasound2", "rpm" => "alsa-lib" },
16
+ "libpulse.so.0" => { "deb" => "libpulse0", "rpm" => "pulseaudio-libs" },
17
+ "libpulse-simple.so.0" => { "deb" => "libpulse0", "rpm" => "pulseaudio-libs" },
18
+ "libpipewire-0.3.so.0" => { "deb" => "libpipewire-0.3-0", "rpm" => "pipewire-libs" },
19
+ "libssl.so.3" => { "deb" => "libssl3", "rpm" => "openssl-libs" },
20
+ "libcrypto.so.3" => { "deb" => "libssl3", "rpm" => "openssl-libs" }
21
+ }.freeze
22
+ attr_reader :root
23
+
24
+ def initialize(root, libraries = {})
25
+ @root = root
26
+ @libraries = LIBRARIES.merge(libraries)
27
+ end
28
+
29
+ def selected_files(package, format)
30
+ # nFPM overrides replace the contents list when one is supplied.
31
+ contents = package.fetch("overrides", {}).fetch(format, {}).fetch("contents", package.fetch("contents"))
32
+ contents.flat_map do |item|
33
+ next [] if item["packager"] && item["packager"] != format
34
+ next [] if %w[symlink ghost dir].include?(item["type"]) || !item["src"]
35
+ source = File.expand_path(item.fetch("src"), root)
36
+ paths = package["disable_globbing"] ? [Pathname.new(source)].select(&:exist?) : Dir.glob(source).map { |path| Pathname.new(path) }
37
+ raise Error, "missing package content: #{source}" if paths.empty?
38
+ paths.flat_map { |path| path.directory? ? files(path) : path }
39
+ end.uniq
40
+ end
41
+
42
+ def check(package, target, format)
43
+ paths = selected_files(package, format)
44
+ kind = target.fetch("kind", "binary")
45
+ if kind != "binary"
46
+ binaries = paths.select { |path| ["\x7fELF".b, "MZ".b].any? { |magic| path.binread(magic.bytesize) == magic } }
47
+ raise Error, "#{kind} target contains executable binaries; declare a binary target" unless binaries.empty?
48
+ raise Error, "source target requires an RPM spec" if kind == "source" && paths.none? { |path| path.extname == ".spec" }
49
+ return { "kind" => kind, "files" => paths.length, "installation" => "not-tested" }
50
+ end
51
+ target.fetch("platform") == "windows" ? pe(paths, target) : elf(paths, package, target, format)
52
+ end
53
+
54
+ def pe(paths, target)
55
+ expected = PE_ARCHES[target.fetch("arch")]
56
+ raise Error, "PE inspection does not support #{target.fetch('arch')}" unless expected
57
+ binaries = paths.select { |path| path.binread(2) == "MZ" }
58
+ raise Error, "Windows target contains no PE binaries" if binaries.empty?
59
+ binaries.each do |path|
60
+ File.open(path, "rb") do |file|
61
+ file.seek(0x3c)
62
+ offset = file.read(4)&.unpack1("L<")
63
+ raise Error, "invalid PE header: #{path}" unless offset && offset >= 0x40 && offset + 24 <= file.size
64
+ file.seek(offset)
65
+ raise Error, "wrong PE architecture or signature: #{path}" unless file.read(4) == "PE\0\0" && file.read(2).unpack1("S<") == expected
66
+ end
67
+ end
68
+ { "kind" => "pe", "binaries" => binaries.length, "architecture" => target.fetch("arch"), "dependencies" => "explicit", "installation" => "not-tested" }
69
+ end
70
+
71
+ def elf(paths, package, target, format)
72
+ expected = ELF_ARCHES[target.fetch("arch")]
73
+ raise Error, "ELF inspection does not support #{target.fetch('arch')}" unless expected
74
+ binaries = paths.select { |path| path.binread(4) == "\x7fELF" }
75
+ raise Error, "Linux target contains no ELF binaries" if binaries.empty?
76
+ required, versions, interpreters = [], [], []
77
+ bundled = binaries.map { |path| path.basename.to_s }
78
+ binaries.each do |path|
79
+ header = path.binread(20)
80
+ machine = header.byteslice(18, 2)&.unpack1(header.getbyte(5) == 2 ? "S>" : "S<")
81
+ raise Error, "wrong ELF architecture: #{path}" unless [machine, header.getbyte(4), header.getbyte(5)] == expected
82
+ dynamic = capture("readelf", "-d", path)
83
+ required.concat(dynamic.scan(/Shared library: \[([^\]]+)\]/).flatten)
84
+ bundled.concat(dynamic.scan(/Library soname: \[([^\]]+)\]/).flatten)
85
+ versions.concat(capture("readelf", "--version-info", path).scan(/GLIBC_(\d+\.\d+)/).flatten)
86
+ interpreters.concat(capture("readelf", "-l", path).scan(/Requesting program interpreter: ([^\]]+)/).flatten)
87
+ end
88
+ libc = target.fetch("libc")
89
+ if libc == "static" && (!required.empty? || !interpreters.empty?)
90
+ raise Error, "target declares static but contains dynamic ELF binaries"
91
+ end
92
+ if libc == "musl" && (!versions.empty? || interpreters.any? { |value| !value.include?("musl") })
93
+ raise Error, "target declares musl but contains a different libc ABI"
94
+ end
95
+ if libc == "glibc" && interpreters.any? { |value| value.include?("musl") }
96
+ raise Error, "target declares glibc but contains a musl loader"
97
+ end
98
+ raise Error, "APK binary targets need musl/static inputs; glibc compatibility is not inferred" if format == "apk" && libc == "glibc"
99
+ external = required.uniq - bundled
100
+ glibc = versions.max_by { |version| Gem::Version.new(version) }
101
+ if %w[deb rpm].include?(format)
102
+ overrides = package["overrides"] ||= {}
103
+ override = overrides[format] ||= {}
104
+ depends = override.fetch("depends", package.fetch("depends", [])).dup
105
+ external.each do |library|
106
+ next if /\A(?:lib(?:c|m|dl|rt|pthread|resolv)\.so\.|ld-linux)/.match?(library)
107
+ dependency = @libraries[library]&.[](format)
108
+ raise Error, "add libraries.#{library}.#{format} and declare runtime-loaded dependencies" unless dependency
109
+ depends << dependency
110
+ end
111
+ if glibc
112
+ previous = depends.grep(format == "deb" ? /\Alibc6(?: |$)/ : /\Aglibc(?: |$)/)
113
+ floor = ([glibc] + previous.flat_map { |value| value.scan(/\d+\.\d+/) }).max_by { |value| Gem::Version.new(value) }
114
+ depends -= previous
115
+ depends << (format == "deb" ? "libc6 (>= #{floor})" : "glibc >= #{floor}")
116
+ end
117
+ override["depends"] = depends.uniq
118
+ elsif !external.empty? && package.fetch("overrides", {}).fetch(format, {}).fetch("depends", package.fetch("depends", [])).empty?
119
+ raise Error, "#{format}: declare dependencies for #{external.join(', ')}"
120
+ end
121
+ { "kind" => "elf", "binaries" => binaries.length, "architecture" => target.fetch("arch"), "libc" => libc,
122
+ "required_libraries" => external.sort, "glibc_floor" => glibc, "installation" => "not-tested" }
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,258 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "support"
4
+ require_relative "repositories"
5
+ require_relative "inspection"
6
+
7
+ module NativePackages
8
+ class Project
9
+ include Support
10
+ attr_reader :root, :config
11
+
12
+ def initialize(root, config: nil, registries: nil)
13
+ @root = Pathname.new(root).realpath
14
+ @config = config || YAML.safe_load_file(@root / "packaging/project.yml", permitted_classes: [], aliases: false)
15
+ @registries = registries
16
+ raise Error, "unsupported packaging configuration" unless @config.fetch("version") == 1
17
+ raise Error, "invalid package name" unless /\A[a-zA-Z0-9][a-zA-Z0-9._-]*\z/.match?(package_name)
18
+ @config.fetch("assets").each_key do |key|
19
+ raise Error, "invalid asset key: #{key}" unless /\A[A-Z][A-Z0-9_]*\z/.match?(key)
20
+ end
21
+ end
22
+
23
+ def package_name = config.fetch("name")
24
+ def repository = config.fetch("release_repository", config.fetch("repository"))
25
+ def upstream = "https://github.com/#{repository}"
26
+ def cache(version) = root / ".cache/packaging" / version
27
+ def repositories = Repositories.new(project: self, entries: @registries)
28
+
29
+ def validate
30
+ metadata = { "NAME" => package_name, "VERSION" => "9.8.7", "TAG" => "v9.8.7", "UPSTREAM" => upstream,
31
+ "DATE" => "2026-01-01T00:00:00Z", "SOURCE_DATE_EPOCH" => 1_767_225_600, "GIT_VERSION" => "r123.abcdef0" }
32
+ render_tree(config.fetch("assets"), metadata).each do |key, asset|
33
+ metadata["#{key}_FILE"] = asset.fetch("file")
34
+ metadata["#{key}_URL"] = asset.fetch("url", "#{upstream}/releases/download/v9.8.7/#{asset.fetch('file')}")
35
+ metadata["#{key}_SHA256"] = "0" * 64
36
+ end
37
+ if config["binary"]
38
+ render_tree(nfpm_definition, metadata.merge("ROOT" => root.to_s, "PAYLOAD" => "/payload",
39
+ "ARCH" => "amd64", "TARGET" => "x86_64-unknown-linux-gnu"))
40
+ end
41
+ repositories
42
+ Dir.mktmpdir("native-packages-validate-") do |temporary|
43
+ output = Pathname.new(temporary)
44
+ generate(output, metadata)
45
+ check(output)
46
+ end
47
+ end
48
+
49
+ def release_metadata(version)
50
+ version = version_arg(version)
51
+ tag = "v#{version}"
52
+ commit = "#{tag}^{commit}"
53
+ date = capture("git", "show", "-s", "--format=%cI", commit)
54
+ metadata = { "NAME" => package_name, "VERSION" => version, "TAG" => tag, "UPSTREAM" => upstream,
55
+ "DATE" => date, "SOURCE_DATE_EPOCH" => Time.iso8601(date).to_i,
56
+ "GIT_VERSION" => "r#{capture('git', 'rev-list', '--count', commit)}.#{capture('git', 'rev-parse', '--short', commit)}" }
57
+ assets = render_tree(config.fetch("assets"), metadata)
58
+ checksums = cache(version) / "checksums.txt"
59
+ download("#{upstream}/releases/download/#{tag}/checksums.txt", checksums)
60
+ expected = checksums.readlines.to_h do |line|
61
+ match = /\A([a-fA-F0-9]{64})\s+\*?(.+?)\s*\z/.match(line) or raise Error, "invalid release checksum entry"
62
+ [match[2], match[1].downcase]
63
+ end
64
+ assets.each do |key, asset|
65
+ file = asset.fetch("file")
66
+ raise Error, "asset must be a filename" unless File.basename(file) == file
67
+ path = cache(version) / file
68
+ url = asset.fetch("url", "#{upstream}/releases/download/#{tag}/#{file}")
69
+ download(url, path)
70
+ digest = sha256(path)
71
+ if asset.fetch("checksummed", true)
72
+ raise Error, "missing checksum for #{file}" unless expected.key?(file)
73
+ raise Error, "checksum mismatch for #{file}" unless expected.fetch(file) == digest
74
+ end
75
+ metadata["#{key}_FILE"] = file
76
+ metadata["#{key}_URL"] = url
77
+ metadata["#{key}_SHA256"] = digest
78
+ end
79
+ metadata
80
+ end
81
+
82
+ def srcinfo(directory)
83
+ if available?("makepkg")
84
+ capture("makepkg", "--printsrcinfo", chdir: directory) + "\n"
85
+ elsif available?("docker")
86
+ capture("docker", "run", "--rm", "-v", "#{directory}:/recipe:ro", "archlinux:base-devel", "bash", "-ec",
87
+ 'useradd -m builder; cp -r /recipe /tmp/package; chown -R builder:builder /tmp/package; runuser -u builder -- bash -ec "cd /tmp/package; makepkg --printsrcinfo"') + "\n"
88
+ else
89
+ raise Error, "AUR metadata generation needs makepkg or Docker"
90
+ end
91
+ end
92
+
93
+ def generate(output, metadata)
94
+ config.fetch("templates", {}).each do |destination, source|
95
+ local = metadata.merge("PKGREL" => config.fetch("revisions", {}).fetch(metadata.fetch("VERSION"), {}).fetch(destination, 1))
96
+ target = output / relative_path(render(destination, local))
97
+ template = root / relative_path(source)
98
+ write(target, render(template.binread, local), executable: template.executable?)
99
+ end
100
+ Pathname.glob(output / "arch/*/PKGBUILD").each do |recipe|
101
+ write(recipe.dirname / ".SRCINFO", srcinfo(recipe.dirname))
102
+ end
103
+ write(output / "release.json", JSON.pretty_generate(metadata.sort.to_h) + "\n")
104
+ end
105
+
106
+ def check(output)
107
+ output = Pathname.new(output).expand_path
108
+ metadata = JSON.parse((output / "release.json").read)
109
+ raise Error, "prepared recipes belong to another project" unless metadata.fetch("NAME") == package_name
110
+ version_arg(metadata.fetch("VERSION"))
111
+ Dir.mktmpdir("native-packages-check-") do |temporary|
112
+ expected = Pathname.new(temporary)
113
+ generate(expected, metadata)
114
+ extra = files(output).map { |file| file.relative_path_from(output) } - files(expected).map { |file| file.relative_path_from(expected) }
115
+ raise Error, "unexpected files in prepared recipes: #{extra.join(', ')}" unless extra.empty?
116
+ files(expected).each do |reference|
117
+ actual = output / reference.relative_path_from(expected)
118
+ raise Error, "generated file is stale: #{actual}" unless actual.file? && actual.binread == reference.binread
119
+ end
120
+ end
121
+ files(output).each do |file|
122
+ raise Error, "unexpanded packaging token: #{file}" if Support::TOKEN.match?(file.read)
123
+ run "bash", "-n", file if %w[PKGBUILD APKBUILD template].include?(file.basename.to_s) || %w[.install .ebuild .SlackBuild].include?(file.extname)
124
+ run "ruby", "-c", file if file.extname == ".rb"
125
+ end
126
+ puts "Checked #{package_name} #{metadata.fetch('VERSION')}"
127
+ end
128
+
129
+ def prepare(version, output: nil)
130
+ version = version_arg(version)
131
+ output = Pathname.new(output || root / "dist/packaging" / version).expand_path
132
+ raise Error, "output exists: #{output}; choose --output with a fresh path" if output.exist?
133
+ metadata = release_metadata(version)
134
+ output.dirname.mkpath
135
+ Dir.mktmpdir(".packaging-", output.dirname) do |temporary|
136
+ staging = Pathname.new(temporary) / "recipes"
137
+ generate(staging, metadata)
138
+ check(staging)
139
+ staging.rename(output)
140
+ end
141
+ puts "Prepared #{output}"
142
+ end
143
+
144
+ def extract(archive, destination)
145
+ capture("bsdtar", "-tf", archive).each_line { |path| relative_path(path.strip) }
146
+ destination.mkpath
147
+ run "bsdtar", "-xf", archive, "-C", destination
148
+ Pathname.glob(destination / "**/*", File::FNM_DOTMATCH).each do |path|
149
+ raise Error, "unexpected archive symlink: #{path}" if path.symlink?
150
+ end
151
+ end
152
+
153
+ def nfpm_definition
154
+ definition = config.fetch("binary").fetch("nfpm")
155
+ definition = YAML.safe_load_file(root / relative_path(definition), permitted_classes: [], aliases: false) if definition.is_a?(String)
156
+ raise Error, "nFPM configuration must contain a contents array" unless definition.is_a?(Hash) && definition["contents"].is_a?(Array)
157
+ definition
158
+ end
159
+
160
+ def binary_packages(metadata, output)
161
+ return unless config["binary"]
162
+ raise Error, "install nfpm 2.47.0 to build binary packages" unless available?("nfpm")
163
+ config.fetch("binary").fetch("architectures").each do |architecture, asset_key|
164
+ archive = cache(metadata.fetch("VERSION")) / metadata.fetch("#{asset_key}_FILE")
165
+ raise Error, "release archive checksum changed: #{archive}" unless sha256(archive) == metadata.fetch("#{asset_key}_SHA256")
166
+ Dir.mktmpdir("native-package-payload-") do |temporary|
167
+ payload = Pathname.new(temporary) / "payload"
168
+ extract(archive, payload)
169
+ local = metadata.merge("ARCH" => architecture, "PAYLOAD" => payload.to_s, "ROOT" => root.to_s,
170
+ "TARGET" => { "amd64" => "x86_64-unknown-linux-gnu", "arm64" => "aarch64-unknown-linux-gnu" }.fetch(architecture))
171
+ package = render_tree(nfpm_definition, local)
172
+ package.merge!("name" => package_name, "version" => metadata.fetch("VERSION"), "arch" => architecture,
173
+ "platform" => "linux", "mtime" => metadata.fetch("DATE"))
174
+ runtime_dependencies(payload, architecture, package)
175
+ package.fetch("contents").each do |item|
176
+ next unless item["src"]
177
+ raise Error, "missing package content: #{item.fetch('src')}" if Dir.glob(item.fetch("src")).empty?
178
+ end
179
+ %w[deb rpm].each do |format|
180
+ path = Pathname.new(temporary) / "nfpm.yml"
181
+ write(path, YAML.dump(package))
182
+ run "nfpm", "package", "--config", path, "--packager", format,
183
+ "--target", output / "#{package_name}_#{metadata.fetch('VERSION')}_#{architecture}.#{format}",
184
+ env: { "SOURCE_DATE_EPOCH" => metadata.fetch("SOURCE_DATE_EPOCH").to_s }
185
+ end
186
+ end
187
+ end
188
+ end
189
+
190
+ # Inspect release binaries without running them, including when packaging ARM on x86.
191
+ def runtime_dependencies(payload, architecture, package)
192
+ libraries = Inspection::LIBRARIES.merge(config.fetch("binary").fetch("libraries", {}))
193
+ elfs = files(payload).select { |file| file.binread(4) == "\x7fELF" }
194
+ raise Error, "release archive contains no ELF binaries" if elfs.empty?
195
+ bundled = elfs.map { |file| file.basename.to_s }
196
+ required = []
197
+ versions = []
198
+ elfs.each do |file|
199
+ header = file.binread(20)
200
+ machine = header.byteslice(18, 2).unpack1("S<")
201
+ raise Error, "wrong binary architecture in #{file}" unless machine == { "amd64" => 62, "arm64" => 183 }.fetch(architecture)
202
+ required.concat(capture("readelf", "-d", file).scan(/Shared library: \[([^\]]+)\]/).flatten)
203
+ versions.concat(capture("readelf", "--version-info", file).scan(/GLIBC_(\d+\.\d+)/).flatten)
204
+ end
205
+ glibc = versions.max_by { |version| version.split(".").map(&:to_i) }
206
+ required.uniq!
207
+ external = required - bundled
208
+ unknown = external.reject { |name| libraries.key?(name) || /\A(?:lib(?:c|m|dl|rt|pthread|resolv)\.so\.|ld-linux)/.match?(name) }
209
+ raise Error, "add binary.libraries mappings for: #{unknown.join(', ')}" unless unknown.empty?
210
+ %w[deb rpm].each do |format|
211
+ package["overrides"] ||= {}
212
+ package["overrides"][format] ||= {}
213
+ depends = package["overrides"][format]["depends"] ||= []
214
+ depends.concat(external.filter_map { |name| libraries[name]&.fetch(format) })
215
+ if glibc
216
+ previous = depends.grep(format == "deb" ? /\Alibc6(?: |$)/ : /\Aglibc(?: |$)/)
217
+ floor = ([glibc] + previous.flat_map { |dependency| dependency.scan(/\d+\.\d+/) }).max_by { |version| version.split(".").map(&:to_i) }
218
+ depends -= previous
219
+ depends << (format == "deb" ? "libc6 (>= #{floor})" : "glibc >= #{floor}")
220
+ end
221
+ package["overrides"][format]["depends"] = depends.uniq
222
+ end
223
+ end
224
+
225
+ def artifacts(recipes, output: root / "dist/package-assets")
226
+ recipes = Pathname.new(recipes).expand_path
227
+ output = Pathname.new(output).expand_path
228
+ raise Error, "artifact output exists: #{output}" if output.exist?
229
+ check(recipes)
230
+ metadata = JSON.parse((recipes / "release.json").read)
231
+ output.dirname.mkpath
232
+ Dir.mktmpdir(".package-assets-", output.dirname) do |temporary|
233
+ staging = Pathname.new(temporary) / "assets"
234
+ staging.mkpath
235
+ binary_packages(metadata, staging)
236
+ recipe_archive(recipes, staging, name: package_name, version: metadata.fetch("VERSION"), epoch: metadata.fetch("SOURCE_DATE_EPOCH"))
237
+ staging.rename(output)
238
+ end
239
+ puts "Built release assets in #{output}"
240
+ end
241
+
242
+ def publish_release(version, output)
243
+ upload_assets(repository, version, output)
244
+ end
245
+
246
+ def check_version(tag)
247
+ version = version_arg(tag)
248
+ if config["version_file"]
249
+ source = (root / relative_path(config.fetch("version_file"))).read
250
+ section = config.fetch("version_section", "package")
251
+ body = source.split(/^\[#{Regexp.escape(section)}\]\s*$/)[1]&.split(/^\[/)&.first
252
+ actual = body&.match(/^version\s*=\s*"([^"]+)"/)&.captures&.first
253
+ raise Error, "#{config.fetch('version_file')} reports #{actual.inspect}, tag reports #{version}" unless actual == version
254
+ end
255
+ puts version
256
+ end
257
+ end
258
+ end