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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +119 -0
- data/docs/cli-design.md +161 -0
- data/docs/configuration.md +61 -0
- data/docs/legacy.md +123 -0
- data/docs/platforms.md +32 -0
- data/docs/releasing.md +33 -0
- data/examples/native-packages-all-formats.yaml +87 -0
- data/examples/native-packages.yaml +31 -0
- data/examples/packaging/arch/example-app-bin/PKGBUILD.in +18 -0
- data/examples/packaging/nfpm.yml +15 -0
- data/examples/packaging/project.yml +15 -0
- data/examples/packaging/repositories.yml +14 -0
- data/exe/native-packages +5 -0
- data/lib/native_packages/build.rb +306 -0
- data/lib/native_packages/cli.rb +128 -0
- data/lib/native_packages/configuration.rb +155 -0
- data/lib/native_packages/inspection.rb +125 -0
- data/lib/native_packages/project.rb +258 -0
- data/lib/native_packages/repositories.rb +427 -0
- data/lib/native_packages/scaffold.rb +88 -0
- data/lib/native_packages/support.rb +116 -0
- data/lib/native_packages.rb +4 -0
- metadata +65 -0
|
@@ -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
|