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,87 @@
1
+ # Supply the listed build outputs before running this example.
2
+ schema: 1
3
+ tool:
4
+ version: '0.2.0'
5
+ nfpm: '2.47.0'
6
+ nfpm:
7
+ name: example-app
8
+ description: Example native application
9
+ maintainer: Example Maintainer <maintainer@example.org>
10
+ license: MIT
11
+ contents:
12
+ - src: '@PAYLOAD@/example-app'
13
+ dst: /usr/bin/example-app
14
+ file_info:
15
+ mode: 0755
16
+ overrides:
17
+ archlinux:
18
+ depends: [glibc]
19
+ targets:
20
+ linux-glibc-amd64:
21
+ platform: linux
22
+ arch: amd64
23
+ libc: glibc
24
+ formats: [deb, rpm, archlinux]
25
+ input:
26
+ kind: directory
27
+ local: dist/linux-glibc-amd64
28
+ linux-static-amd64:
29
+ platform: linux
30
+ arch: amd64
31
+ libc: static
32
+ formats: [apk]
33
+ input:
34
+ kind: directory
35
+ local: dist/linux-static-amd64
36
+ openwrt-amd64:
37
+ platform: linux
38
+ arch: amd64
39
+ libc: static
40
+ abi: OpenWrt x86_64; validate against your supported device image
41
+ formats: [ipk]
42
+ input:
43
+ kind: directory
44
+ local: dist/openwrt-amd64
45
+ windows-amd64:
46
+ platform: windows
47
+ arch: amd64
48
+ formats: [msix]
49
+ input:
50
+ kind: directory
51
+ local: dist/windows-amd64
52
+ # To sign, add an argument array calling your Windows SDK SignTool script:
53
+ # after_package: [pwsh, -File, scripts/sign-msix.ps1, '@PACKAGE@']
54
+ nfpm:
55
+ contents:
56
+ - src: '@PAYLOAD@/example-app.exe'
57
+ dst: /example-app.exe
58
+ - src: '@PAYLOAD@/logo150.png'
59
+ dst: /Assets/logo150.png
60
+ - src: '@PAYLOAD@/logo44.png'
61
+ dst: /Assets/logo44.png
62
+ msix:
63
+ publisher: CN=ExampleMaintainer
64
+ properties:
65
+ logo: Assets/logo150.png
66
+ applications:
67
+ - id: App
68
+ executable: example-app.exe
69
+ visual_elements:
70
+ display_name: Example App
71
+ description: Example native application
72
+ square150x150_logo: Assets/logo150.png
73
+ square44x44_logo: Assets/logo44.png
74
+ source:
75
+ kind: source
76
+ platform: linux
77
+ arch: all
78
+ formats: [srpm]
79
+ input:
80
+ kind: directory
81
+ local: dist/sources
82
+ nfpm:
83
+ contents:
84
+ - src: '@PAYLOAD@/example-app.spec'
85
+ dst: /example-app.spec
86
+ - src: '@PAYLOAD@/example-app-@VERSION@.tar.gz'
87
+ dst: /example-app-@VERSION@.tar.gz
@@ -0,0 +1,31 @@
1
+ schema: 1
2
+ tool:
3
+ version: '0.2.0'
4
+ nfpm: '2.47.0'
5
+ nfpm:
6
+ name: example-app
7
+ maintainer: Example Maintainer <maintainer@example.org>
8
+ description: Example native application
9
+ homepage: https://github.com/example/example-app
10
+ license: MIT
11
+ contents:
12
+ - src: '@PAYLOAD@/example-app'
13
+ dst: /usr/bin/example-app
14
+ file_info:
15
+ mode: 0755
16
+ - src: '@PAYLOAD@/LICENSE'
17
+ dst: /usr/share/licenses/example-app/LICENSE
18
+ file_info:
19
+ mode: 0644
20
+ targets:
21
+ linux-amd64:
22
+ platform: linux
23
+ arch: amd64
24
+ libc: static
25
+ formats: [deb, rpm, archlinux]
26
+ input:
27
+ local: dist/example-app_@VERSION@_linux_amd64.tar.gz
28
+ release_asset: example-app_@VERSION@_linux_amd64.tar.gz
29
+ release:
30
+ repository: example/example-app
31
+ checksums: checksums.txt
@@ -0,0 +1,18 @@
1
+ pkgname=example-app-bin
2
+ pkgver=@VERSION@
3
+ pkgrel=@PKGREL@
4
+ pkgdesc='Example native application'
5
+ arch=('x86_64' 'aarch64')
6
+ url='@UPSTREAM@'
7
+ license=('MIT')
8
+ provides=('example-app')
9
+ conflicts=('example-app')
10
+ source_x86_64=('@AMD64_URL@')
11
+ source_aarch64=('@ARM64_URL@')
12
+ sha256sums_x86_64=('@AMD64_SHA256@')
13
+ sha256sums_aarch64=('@ARM64_SHA256@')
14
+
15
+ package() {
16
+ install -Dm755 example-app "$pkgdir/usr/bin/example-app"
17
+ install -Dm644 LICENSE "$pkgdir/usr/share/licenses/example-app/LICENSE"
18
+ }
@@ -0,0 +1,15 @@
1
+ maintainer: Example Maintainer <maintainer@example.org>
2
+ description: Example native application
3
+ homepage: https://github.com/example/example-app
4
+ license: MIT
5
+ contents:
6
+ - src: '@PAYLOAD@/example-app'
7
+ dst: /usr/bin/example-app
8
+ file_info:
9
+ mode: 0755
10
+ - src: '@PAYLOAD@/LICENSE'
11
+ dst: /usr/share/licenses/example-app/LICENSE
12
+ file_info:
13
+ mode: 0644
14
+ # Add actual runtime dependencies under overrides.deb.depends and
15
+ # overrides.rpm.depends, including dynamically loaded libraries.
@@ -0,0 +1,15 @@
1
+ version: 1
2
+ name: example-app
3
+ repository: example/example-app
4
+ assets:
5
+ AMD64:
6
+ file: example-app_@VERSION@_linux_amd64.tar.gz
7
+ ARM64:
8
+ file: example-app_@VERSION@_linux_arm64.tar.gz
9
+ templates:
10
+ arch/example-app-bin/PKGBUILD: packaging/arch/example-app-bin/PKGBUILD.in
11
+ binary:
12
+ architectures:
13
+ amd64: AMD64
14
+ arm64: ARM64
15
+ nfpm: packaging/nfpm.yml
@@ -0,0 +1,14 @@
1
+ version: 1
2
+ repositories:
3
+ aur-bin:
4
+ group: aur
5
+ url: https://aur.archlinux.org/example-app-bin.git
6
+ push_url: ssh://aur@aur.archlinux.org/example-app-bin.git
7
+ branch: master
8
+ package_path: .
9
+ files:
10
+ arch/example-app-bin/PKGBUILD: PKGBUILD
11
+ arch/example-app-bin/.SRCINFO: .SRCINFO
12
+ version_file: PKGBUILD
13
+ version_pattern: '^pkgver=(\S+)'
14
+ publish: push
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "native_packages"
5
+ NativePackages::CLI.run(Dir.pwd, ARGV)
@@ -0,0 +1,306 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "configuration"
4
+ require_relative "inspection"
5
+
6
+ module NativePackages
7
+ class Build
8
+ include Support
9
+ attr_reader :configuration, :root, :project
10
+
11
+ def initialize(configuration)
12
+ @configuration = configuration
13
+ @root = configuration.root
14
+ @project = configuration.project
15
+ end
16
+
17
+ def nfpm_version
18
+ raise Error, "install nFPM #{Configuration::NFPM_VERSION}; https://nfpm.goreleaser.com/docs/install/" unless available?("nfpm")
19
+ version = capture("nfpm", "--version")[/(?:GitVersion|Version):\s*v?(\d+\.\d+\.\d+)/, 1]
20
+ if !version && available?("go")
21
+ executable = ENV.fetch("PATH").split(File::PATH_SEPARATOR).map { |path| File.join(path, Gem.win_platform? ? "nfpm.exe" : "nfpm") }.find { |path| File.file?(path) }
22
+ version = capture("go", "version", "-m", executable)[/\bmod\s+github\.com\/goreleaser\/nfpm\/v2\s+v(\d+\.\d+\.\d+)/, 1] if executable
23
+ end
24
+ wanted = configuration.data.fetch("tool").fetch("nfpm")
25
+ raise Error, "nFPM version is #{version || 'unknown'}; install #{wanted}" unless version == wanted
26
+ version
27
+ end
28
+
29
+ def doctor(ids: [], formats: [], release: false)
30
+ configuration.validate
31
+ selected = configuration.select(ids: ids, formats: formats)
32
+ nfpm_version unless selected.empty?
33
+ required = []
34
+ required << "readelf" if selected.values.any? { |target| target["platform"] == "linux" && target.fetch("kind", "binary") == "binary" }
35
+ required << "bsdtar" if selected.values.any? { |target| target.fetch("input").fetch("kind", "archive") == "archive" }
36
+ required << "curl" if release
37
+ required += %w[tar xz] unless configuration.data.fetch("templates").empty?
38
+ missing = required.uniq.reject { |tool| available?(tool) }
39
+ raise Error, "install required tools: #{missing.join(', ')}" unless missing.empty?
40
+ if configuration.data.fetch("templates").keys.any? { |path| path.match?(%r{\Aarch/[^/]+/PKGBUILD\z}) }
41
+ raise Error, "AUR metadata needs makepkg or accessible Docker" unless available?("makepkg") || available?("docker")
42
+ end
43
+ selected
44
+ end
45
+
46
+ def version(value, release)
47
+ if release && value && version_arg(value) != version_arg(release)
48
+ raise Error, "--release and --version disagree"
49
+ end
50
+ value ||= release
51
+ value ||= capture("git", "describe", "--exact-match", "--tags", "HEAD")
52
+ version_arg(value)
53
+ rescue Error => error
54
+ raise error if value
55
+ raise Error, "no exact release tag at HEAD; supply --version"
56
+ end
57
+
58
+ def metadata(version, release: false)
59
+ epoch = ENV["SOURCE_DATE_EPOCH"] && Integer(ENV.fetch("SOURCE_DATE_EPOCH"))
60
+ ref = release ? "v#{version}^{commit}" : "HEAD"
61
+ begin
62
+ epoch ||= Integer(capture("git", "show", "-s", "--format=%ct", ref))
63
+ git_version = "r#{capture('git', 'rev-list', '--count', ref)}.#{capture('git', 'rev-parse', '--short', ref)}"
64
+ rescue Error, Errno::ENOENT
65
+ git_version = "r0.unknown"
66
+ end
67
+ configuration.tokens(version, epoch: epoch).merge("GIT_VERSION" => git_version)
68
+ end
69
+
70
+ def run_build(value: nil, release: nil, ids: [], formats: [], output: nil, dry_run: false)
71
+ configuration.validate
72
+ selected = configuration.select(ids: ids, formats: formats)
73
+ number = version(value, release)
74
+ output = Pathname.new(output || root / "dist/packages" / number).expand_path(root)
75
+ if dry_run
76
+ puts JSON.pretty_generate("version" => number, "mode" => release ? "release" : "local", "output" => output.to_s,
77
+ "targets" => selected.transform_values { |target| target.slice("formats", "platform", "arch", "input", "before_build", "after_package") })
78
+ return
79
+ end
80
+ raise Error, "output exists: #{output}; choose a fresh --output" if output.exist?
81
+ doctor(ids: ids, formats: formats, release: !!release)
82
+ info = metadata(number, release: !!release)
83
+ sources = []
84
+ expected = release ? release_checksums(info) : nil
85
+ acquire_assets(info, expected, sources)
86
+ output.dirname.mkpath
87
+ Dir.mktmpdir(".native-packages-", output.dirname) do |temporary|
88
+ staging = Pathname.new(temporary) / "result"
89
+ staging.mkpath
90
+ records = []
91
+ selected.each do |id, target|
92
+ local = configuration.target_tokens(info, id, target, "")
93
+ hook = target["before_build"]
94
+ run(*render_tree(hook, local), env: { "NATIVE_PACKAGES_TARGET" => id, "NATIVE_PACKAGES_VERSION" => number }) if hook && !release
95
+ input = render_tree(target.fetch("input"), local)
96
+ source = release ? release_input(input, info, expected) : root / input.fetch("local")
97
+ raise Error, "input does not exist: #{source}" unless source.exist?
98
+ if source.directory? && (output.to_s == source.expand_path.to_s || output.to_s.start_with?(source.expand_path.to_s + File::SEPARATOR))
99
+ raise Error, "package output must be outside the input directory"
100
+ end
101
+ sources << { "target" => id, "path" => source.to_s, "sha256" => input_digest(source) }
102
+ Dir.mktmpdir("native-packages-input-") do |directory|
103
+ payload = Pathname.new(directory) / "payload"
104
+ copy_input(source, payload, input.fetch("kind", "archive"))
105
+ target.fetch("formats").each do |format|
106
+ package = render_tree(configuration.package(target), configuration.target_tokens(info, id, target, payload))
107
+ package.merge!("name" => configuration.name, "version" => number, "arch" => target.fetch("arch"),
108
+ "platform" => target.fetch("platform"), "mtime" => info.fetch("DATE"))
109
+ inspection = Inspection.new(root, configuration.data.fetch("libraries")).check(package, target, format)
110
+ config_path = Pathname.new(directory) / "nfpm.yaml"
111
+ write(config_path, YAML.dump(package))
112
+ destination = staging / "packages" / id / format
113
+ destination.mkpath
114
+ run "nfpm", "package", "--config", config_path, "--packager", format, "--target", destination,
115
+ env: { "SOURCE_DATE_EPOCH" => info.fetch("SOURCE_DATE_EPOCH").to_s }
116
+ packages = files(destination)
117
+ raise Error, "nFPM did not create exactly one #{format} package" unless packages.length == 1
118
+ if target["after_package"]
119
+ signing = configuration.target_tokens(info, id, target, payload).merge("PACKAGE" => packages.first.to_s, "FORMAT" => format)
120
+ run(*render_tree(target.fetch("after_package"), signing), env: { "NATIVE_PACKAGES_TARGET" => id, "NATIVE_PACKAGES_VERSION" => number })
121
+ raise Error, "after_package must preserve the package path and output set" unless files(destination) == packages
122
+ end
123
+ records << { "target" => id, "format" => format, "path" => packages.first.relative_path_from(staging).to_s,
124
+ "sha256" => sha256(packages.first), "validation" => inspection }
125
+ end
126
+ end
127
+ end
128
+ recipes = staging / "recipes"
129
+ project.generate(recipes, info)
130
+ project.check(recipes)
131
+ if !configuration.data.fetch("templates").empty?
132
+ project.recipe_archive(recipes, staging, name: configuration.name, version: number, epoch: info.fetch("SOURCE_DATE_EPOCH"))
133
+ (staging / "packaging-checksums.txt").delete
134
+ end
135
+ manifest = { "schema" => 1, "name" => configuration.name, "version" => number, "configuration" => configuration.digest,
136
+ "tool" => configuration.data.fetch("tool"), "epoch" => info.fetch("SOURCE_DATE_EPOCH"), "inputs" => sources,
137
+ "targets" => selected.transform_values { |target| target.fetch("formats") }, "packages" => records,
138
+ "files" => files(staging).to_h { |file| [file.relative_path_from(staging).to_s, sha256(file)] } }
139
+ write(staging / "build.json", JSON.pretty_generate(manifest) + "\n")
140
+ write(staging / "packaging-checksums.txt", files(staging).map { |file| "#{sha256(file)} #{file.relative_path_from(staging)}\n" }.join)
141
+ staging.rename(output)
142
+ end
143
+ puts "Built #{selected.values.sum { |target| target.fetch('formats').length }} packages in #{output}"
144
+ output
145
+ end
146
+
147
+ def release_cache(info)
148
+ repository = configuration.data.fetch("release").fetch("repository")
149
+ raise Error, "release.repository must be owner/repository" unless /\A[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\z/.match?(repository)
150
+ root / ".cache/native-packages/releases" / repository / info.fetch("VERSION")
151
+ end
152
+
153
+ def release_checksums(info)
154
+ name = configuration.data.fetch("release").fetch("checksums", "checksums.txt")
155
+ raise Error, "release.checksums must be a filename" unless File.basename(name) == name
156
+ path = release_cache(info) / name
157
+ download("#{info.fetch('UPSTREAM')}/releases/download/#{info.fetch('TAG')}/#{name}", path)
158
+ path.readlines.each_with_object({}) do |line, result|
159
+ match = /\A([a-fA-F0-9]{64})\s+\*?([^\r\n]+)\s*\z/.match(line) or raise Error, "invalid checksum entry"
160
+ filename = match[2].strip
161
+ raise Error, "duplicate checksum: #{filename}" if result.key?(filename)
162
+ result[filename] = match[1].downcase
163
+ end
164
+ end
165
+
166
+ def release_input(input, info, checksums, checksummed: true)
167
+ filename = input.fetch("release_asset")
168
+ raise Error, "release_asset must be a filename" unless File.basename(filename) == filename && !%w[. ..].include?(filename)
169
+ path = release_cache(info) / filename
170
+ url = input.fetch("url", "#{info.fetch('UPSTREAM')}/releases/download/#{info.fetch('TAG')}/#{filename}")
171
+ download(url, path)
172
+ if checksummed
173
+ raise Error, "missing release checksum for #{filename}" unless checksums.key?(filename)
174
+ raise Error, "checksum mismatch: #{filename}" unless sha256(path) == checksums.fetch(filename)
175
+ end
176
+ path
177
+ end
178
+
179
+ def acquire_assets(info, expected, sources)
180
+ configuration.data.fetch("assets").each do |key, definition|
181
+ asset = render_tree(definition, info)
182
+ path = if expected
183
+ release_input(asset.merge("release_asset" => asset.fetch("file")), info, expected, checksummed: asset.fetch("checksummed", true))
184
+ else
185
+ root / asset.fetch("local") { raise Error, "assets.#{key}: declare local input or use --release" }
186
+ end
187
+ digest = sha256(path)
188
+ info["#{key}_SHA256"] = digest
189
+ info["#{key}_FILE"] = asset.fetch("file")
190
+ info["#{key}_URL"] = asset.fetch("url", "#{info.fetch('UPSTREAM')}/releases/download/#{info.fetch('TAG')}/#{asset.fetch('file')}")
191
+ sources << { "asset" => key, "path" => path.to_s, "sha256" => digest }
192
+ end
193
+ end
194
+
195
+ def input_digest(source)
196
+ paths = source.directory? ? files(source) : [source]
197
+ raise Error, "input symlinks must be represented as nFPM symlink contents" if source.symlink? || paths.any?(&:symlink?)
198
+ return sha256(source) if source.file?
199
+ OpenSSL::Digest::SHA256.hexdigest(JSON.generate(paths.map { |path| [path.relative_path_from(source).to_s, path.stat.mode & 0o777, sha256(path)] }))
200
+ end
201
+
202
+ def copy_input(source, payload, kind)
203
+ case kind
204
+ when "archive" then project.extract(source, payload)
205
+ when "directory"
206
+ raise Error, "expected input directory: #{source}" unless source.directory?
207
+ FileUtils.cp_r(source, payload)
208
+ when "file"
209
+ raise Error, "expected input file: #{source}" unless source.file?
210
+ payload.mkpath
211
+ FileUtils.cp(source, payload / source.basename)
212
+ end
213
+ end
214
+
215
+ def verify(output, complete: true)
216
+ output = Pathname.new(output).expand_path(root)
217
+ manifest = JSON.parse((output / "build.json").read)
218
+ raise Error, "unsupported build manifest" unless manifest.fetch("schema") == 1
219
+ raise Error, "build belongs to another project/configuration" unless manifest.fetch("name") == configuration.name && manifest.fetch("configuration") == configuration.digest
220
+ version_arg(manifest.fetch("version"))
221
+ expected = configuration.targets.transform_values { |target| target.fetch("formats") }
222
+ raise Error, "incomplete target set; aggregate all configured targets before publishing" if complete && manifest.fetch("targets") != expected
223
+ expected_pairs = manifest.fetch("targets").flat_map { |id, formats| formats.map { |format| [id, format] } }.sort
224
+ actual_pairs = manifest.fetch("packages").map { |entry| entry.values_at("target", "format") }.sort
225
+ raise Error, "manifest packages do not match its target set" unless actual_pairs == expected_pairs
226
+ manifest.fetch("files").each do |relative, digest|
227
+ path = output / relative_path(relative)
228
+ inside = path.exist? && path.realpath.to_s.start_with?(output.realpath.to_s + File::SEPARATOR)
229
+ raise Error, "package output changed: #{relative}" unless inside && path.file? && !path.symlink? && sha256(path) == digest
230
+ end
231
+ extras = files(output).map { |path| path.relative_path_from(output).to_s } - manifest.fetch("files").keys - %w[build.json packaging-checksums.txt]
232
+ raise Error, "unexpected build files: #{extras.join(', ')}" unless extras.empty?
233
+ manifest.fetch("packages").each do |entry|
234
+ raise Error, "package hash does not match file manifest" unless entry.fetch("sha256") == manifest.fetch("files").fetch(entry.fetch("path"))
235
+ end
236
+ manifest
237
+ end
238
+
239
+ def publish(output, destinations, body_file: nil)
240
+ configuration.validate
241
+ output = Pathname.new(output).expand_path(root)
242
+ manifest = verify(output)
243
+ raise Error, "supply at least one --to destination" if destinations.empty?
244
+ destinations.each { |name| project.repositories.select(name) unless name == "github" }
245
+ destinations.each do |destination|
246
+ if destination == "github"
247
+ paths = manifest.fetch("packages").map { |entry| output / entry.fetch("path") }
248
+ paths += manifest.fetch("files").keys.grep(/-packaging\.tar\.xz\z/).map { |path| output / path }
249
+ names = paths.map { |path| path.basename.to_s }
250
+ raise Error, "duplicate release asset names; give variants distinct nFPM names/releases" unless names.uniq == names
251
+ raise Error, "release.repository is required" if project.repository.empty?
252
+ Dir.mktmpdir("native-packages-upload-") do |directory|
253
+ checksums = Pathname.new(directory) / "packaging-checksums.txt"
254
+ write(checksums, paths.map { |path| "#{sha256(path)} #{path.basename}\n" }.join)
255
+ run "gh", "release", "upload", "v#{manifest.fetch('version')}", *paths, checksums, "--repo", project.repository, "--clobber"
256
+ end
257
+ else
258
+ project.repositories.stage(destination, output / "recipes")
259
+ project.repositories.publish(destination, body_file: body_file)
260
+ end
261
+ end
262
+ end
263
+
264
+ def aggregate(inputs, output:)
265
+ configuration.validate
266
+ output = Pathname.new(output).expand_path(root)
267
+ raise Error, "output exists: #{output}" if output.exist?
268
+ roots = inputs.map { |path| Pathname.new(path).expand_path(root) }
269
+ manifests = roots.map { |path| verify(path, complete: false) }
270
+ raise Error, "supply builds to aggregate" if manifests.empty?
271
+ %w[version epoch tool].each do |key|
272
+ raise Error, "builds disagree on #{key}" unless manifests.map { |entry| entry.fetch(key) }.uniq.length == 1
273
+ end
274
+ result = Marshal.load(Marshal.dump(manifests.first))
275
+ result.merge!("targets" => {}, "packages" => [], "files" => {}, "inputs" => manifests.flat_map { |entry| entry.fetch("inputs") }.uniq)
276
+ output.dirname.mkpath
277
+ Dir.mktmpdir(".native-packages-aggregate-", output.dirname) do |directory|
278
+ staging = Pathname.new(directory) / "result"
279
+ staging.mkpath
280
+ manifests.zip(roots).each do |manifest, source|
281
+ manifest.fetch("targets").each do |id, formats|
282
+ result["targets"][id] ||= []
283
+ raise Error, "duplicate target/format: #{id}" unless (result["targets"][id] & formats).empty?
284
+ result["targets"][id] += formats
285
+ end
286
+ result["packages"] += manifest.fetch("packages")
287
+ manifest.fetch("files").each do |path, digest|
288
+ raise Error, "conflicting build file: #{path}" if result["files"][path] && result["files"][path] != digest
289
+ destination = staging / relative_path(path)
290
+ destination.dirname.mkpath
291
+ FileUtils.cp(source / path, destination)
292
+ result["files"][path] = digest
293
+ end
294
+ end
295
+ configuration.targets.each do |id, target|
296
+ result["targets"][id] = target.fetch("formats").select { |format| result["targets"].fetch(id, []).include?(format) }
297
+ end
298
+ write(staging / "build.json", JSON.pretty_generate(result) + "\n")
299
+ write(staging / "packaging-checksums.txt", files(staging).map { |file| "#{sha256(file)} #{file.relative_path_from(staging)}\n" }.join)
300
+ verify(staging)
301
+ staging.rename(output)
302
+ end
303
+ puts "Aggregated #{output}"
304
+ end
305
+ end
306
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "build"
4
+ require_relative "scaffold"
5
+
6
+ module NativePackages
7
+ module CLI
8
+ HELP = <<~TEXT
9
+ Usage: native-packages [--config FILE] COMMAND [options]
10
+ init [--interactive] Create native-packages.yaml
11
+ doctor Check configuration and packaging tools
12
+ validate Validate configuration and templates
13
+ build [--version VERSION | --release TAG] Build all configured packages
14
+ [--target ID] [--format FORMAT] Select targets/formats (repeatable)
15
+ [--output DIRECTORY] [--dry-run] Choose output or inspect the plan
16
+ aggregate DIR... --output DIRECTORY Combine target builds before publishing
17
+ publish --from DIRECTORY --to github,aur Publish a complete, verified build
18
+ migrate [--dry-run] Convert packaging/project.yml
19
+ repositories List downstream destinations
20
+ stage TARGET DIRECTORY Stage prepared recipes
21
+ diff TARGET Review pending changes
22
+ publish TARGET [--body-file FILE] Publish a staged downstream update
23
+ status [TARGET] [--json] [--offline] Check downstream versions and requests
24
+ check-version TAG Check the application's version
25
+ Legacy commands remain available:
26
+ prepare VERSION [--output DIRECTORY]
27
+ check DIRECTORY
28
+ artifacts DIRECTORY [--output DIRECTORY]
29
+ publish-release VERSION DIRECTORY
30
+ publish-aur DIRECTORY
31
+ TEXT
32
+
33
+ def self.run(root, arguments)
34
+ arguments = arguments.dup
35
+ config_path = nil
36
+ if arguments.first == "--config"
37
+ arguments.shift
38
+ config_path = arguments.shift or raise Error, "--config needs a path"
39
+ end
40
+ command = arguments.shift
41
+ return puts(HELP) if command.nil? || %w[--help -h].include?(command)
42
+ return puts(VERSION) if command == "--version"
43
+ options = { ids: [], formats: [], destinations: [] }
44
+ parser = OptionParser.new do |flags|
45
+ flags.on("--config FILE") { |value| config_path = value }
46
+ flags.on("--help", "-h") { puts HELP; return }
47
+ flags.on("--output DIRECTORY") { |value| options[:output] = Pathname.new(value).expand_path(root) } if %w[build aggregate artifacts prepare].include?(command)
48
+ flags.on("--body-file FILE") { |value| options[:body_file] = Pathname.new(value).expand_path(root) } if command == "publish"
49
+ if %w[build doctor].include?(command)
50
+ flags.on("--target ID") { |value| options[:ids] << value }
51
+ flags.on("--format FORMAT") { |value| options[:formats] << value }
52
+ flags.on("--release TAG") { |value| options[:release] = value }
53
+ end
54
+ if command == "build"
55
+ flags.on("--version VERSION") { |value| options[:value] = value }
56
+ flags.on("--dry-run") { options[:dry_run] = true }
57
+ end
58
+ if command == "init"
59
+ flags.on("--interactive") { options[:interactive] = true }
60
+ flags.on("--name NAME") { |value| options[:name] = value }
61
+ flags.on("--input PATH") { |value| options[:input] = value }
62
+ flags.on("--formats FORMATS") { |value| options[:init_formats] = value }
63
+ end
64
+ flags.on("--dry-run") { options[:dry_run] = true } if command == "migrate"
65
+ if command == "publish"
66
+ flags.on("--from DIRECTORY") { |value| options[:from] = value }
67
+ flags.on("--to DESTINATIONS") { |value| options[:destinations] += value.split(",") }
68
+ end
69
+ if command == "status"
70
+ flags.on("--offline") { options[:offline] = true }
71
+ flags.on("--json") { options[:json] = true }
72
+ end
73
+ end
74
+ parser.parse!(arguments)
75
+ arity = { "init" => 0..0, "migrate" => 0..0, "build" => 0..0, "doctor" => 0..0, "aggregate" => 1..,
76
+ "prepare" => 1..1, "check" => 1..1, "artifacts" => 1..1, "publish-release" => 2..2,
77
+ "repositories" => 0..0, "stage" => 2..2, "diff" => 1..1, "publish" => options[:from] ? 0..0 : 1..1,
78
+ "publish-aur" => 1..1, "status" => 0..1, "check-version" => 1..1, "validate" => 0..0 }[command]
79
+ raise Error, HELP unless arity&.cover?(arguments.length)
80
+ if command == "init"
81
+ raise Error, "init writes native-packages.yaml in the current directory" if config_path
82
+ return Scaffold.new(root).init(**options.slice(:interactive, :name, :input).merge(formats: options[:init_formats]))
83
+ end
84
+ return Scaffold.new(root).migrate(dry_run: options[:dry_run]) if command == "migrate"
85
+ path = Configuration.discover(root, config_path)
86
+ configuration = path && Configuration.new(path)
87
+ if %w[build doctor aggregate].include?(command) || options[:from]
88
+ raise Error, "create native-packages.yaml with init, or convert existing packaging with migrate" unless configuration
89
+ builder = Build.new(configuration)
90
+ case command
91
+ when "build" then return builder.run_build(**options.slice(:value, :release, :ids, :formats, :output, :dry_run))
92
+ when "doctor"
93
+ builder.doctor(**options.slice(:ids, :formats, :release))
94
+ return puts "Configuration and packaging tools are ready. Build checks input files and package contents."
95
+ when "aggregate"
96
+ raise Error, "aggregate requires --output" unless options[:output]
97
+ return builder.aggregate(arguments, output: options.fetch(:output))
98
+ when "publish" then return builder.publish(options.fetch(:from), options.fetch(:destinations), body_file: options[:body_file])
99
+ end
100
+ end
101
+ if configuration
102
+ configuration.validate
103
+ return puts "Configuration and templates are valid." if command == "validate"
104
+ end
105
+ project = configuration ? configuration.project : Project.new(root)
106
+ case command
107
+ when "validate" then project.validate
108
+ when "prepare" then project.prepare(arguments.first, **options.slice(:output))
109
+ when "check" then project.check(arguments.first)
110
+ when "artifacts" then project.artifacts(arguments.first, **options.slice(:output))
111
+ when "publish-release" then project.publish_release(*arguments)
112
+ when "repositories" then project.repositories.list
113
+ when "stage" then project.repositories.stage(*arguments)
114
+ when "diff" then project.repositories.diff(arguments.first)
115
+ when "publish" then project.repositories.publish(arguments.first, body_file: options[:body_file])
116
+ when "publish-aur"
117
+ project.repositories.stage("aur", arguments.first)
118
+ project.repositories.publish("aur")
119
+ when "status"
120
+ exit 1 unless project.repositories.status(arguments.first || "all", offline: options[:offline], json: options[:json])
121
+ when "check-version" then project.check_version(arguments.first)
122
+ end
123
+ rescue Error, SystemCallError, KeyError, ArgumentError, OptionParser::ParseError, JSON::ParserError,
124
+ OpenSSL::OpenSSLError, Psych::Exception, IOError, Timeout::Error => error
125
+ abort "native-packages: #{error.message}"
126
+ end
127
+ end
128
+ end