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,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
|
data/exe/native-packages
ADDED
|
@@ -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
|