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,427 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "yaml"
|
|
6
|
+
|
|
7
|
+
module NativePackages
|
|
8
|
+
# Each destination has an independent Git checkout and a small, ignored staging record.
|
|
9
|
+
class Repositories
|
|
10
|
+
attr_reader :entries, :cache, :project
|
|
11
|
+
|
|
12
|
+
def initialize(project:, registry: project.root / "packaging/repositories.yml", cache: project.root / ".cache/packaging", entries: nil)
|
|
13
|
+
@project = project
|
|
14
|
+
document = entries ? { "version" => 1, "repositories" => entries } : YAML.safe_load_file(registry, permitted_classes: [], aliases: false)
|
|
15
|
+
raise Error, "unsupported repository registry version" unless document.fetch("version") == 1
|
|
16
|
+
|
|
17
|
+
@entries = document.fetch("repositories")
|
|
18
|
+
@cache = Pathname.new(cache).expand_path
|
|
19
|
+
@entries.each do |name, entry|
|
|
20
|
+
raise Error, "invalid repository name: #{name}" unless /\A[a-z0-9-]+\z/.match?(name)
|
|
21
|
+
raise Error, "unknown publish method for #{name}" unless %w[push github-pr gitlab-mr manual].include?(entry.fetch("publish"))
|
|
22
|
+
entry.fetch("url")
|
|
23
|
+
next unless entry.key?("branch")
|
|
24
|
+
|
|
25
|
+
safe_relative(entry.fetch("package_path"))
|
|
26
|
+
entry.fetch("files").each { |source, target| safe_relative(source); safe_relative(target) }
|
|
27
|
+
entry.fetch("version_pattern")
|
|
28
|
+
raise Error, "missing version path for #{name}" unless entry["version_file"] || entry["version_files"]
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def select(selector)
|
|
33
|
+
return entries if selector == "all"
|
|
34
|
+
return { selector => entries.fetch(selector) } if entries.key?(selector)
|
|
35
|
+
|
|
36
|
+
group = entries.select { |_, entry| entry["group"] == selector }
|
|
37
|
+
raise Error, "unknown target: #{selector}; run repositories to list targets" if group.empty?
|
|
38
|
+
|
|
39
|
+
group
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def list
|
|
43
|
+
entries.each do |name, entry|
|
|
44
|
+
puts "#{name.ljust(14)} #{entry.fetch('publish').ljust(10)} #{entry.fetch('url')}"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def checkout(name) = cache / "repos" / name
|
|
49
|
+
def state_path(name) = cache / "state" / "#{name}.json"
|
|
50
|
+
def body_path(name) = cache / "submissions" / "#{name}.md"
|
|
51
|
+
def state(name) = state_path(name).file? ? JSON.parse(state_path(name).read) : nil
|
|
52
|
+
def fingerprint(entry) = OpenSSL::Digest::SHA256.hexdigest(JSON.generate(entry))
|
|
53
|
+
|
|
54
|
+
def git(name, *arguments, allow_failure: false)
|
|
55
|
+
directory = checkout(name)
|
|
56
|
+
directory = cache if !directory.directory?
|
|
57
|
+
env = { "GIT_TERMINAL_PROMPT" => "0", "GIT_SSH_COMMAND" => ENV.fetch("GIT_SSH_COMMAND", "ssh -o BatchMode=yes -o ConnectTimeout=20") }
|
|
58
|
+
output, error, status = Open3.capture3(env, "git", *arguments.map(&:to_s), chdir: directory.to_s)
|
|
59
|
+
return [output, status] if allow_failure
|
|
60
|
+
raise Error, "#{name}: git #{arguments.first} failed: #{error.strip}" unless status.success?
|
|
61
|
+
|
|
62
|
+
output.strip
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def refresh(name, entry)
|
|
66
|
+
cache.mkpath
|
|
67
|
+
path = checkout(name)
|
|
68
|
+
if path.exist?
|
|
69
|
+
raise Error, "#{path} is not a managed checkout" unless (path / ".git").directory?
|
|
70
|
+
unless git(name, "remote", "get-url", "upstream") == entry.fetch("url") &&
|
|
71
|
+
git(name, "remote", "get-url", "origin") == entry.fetch("fork_url", entry.fetch("url")) &&
|
|
72
|
+
git(name, "remote", "get-url", "--push", "origin") == entry.fetch("push_url", entry.fetch("url"))
|
|
73
|
+
raise Error, "#{name}: checkout remotes differ from the registry; inspect #{path}"
|
|
74
|
+
end
|
|
75
|
+
else
|
|
76
|
+
path.dirname.mkpath
|
|
77
|
+
git(name, "clone", "--filter=blob:none", "--depth=1", "--no-checkout", "--origin", "upstream",
|
|
78
|
+
"--branch", entry.fetch("branch"), entry.fetch("url"), path)
|
|
79
|
+
git(name, "remote", "add", "origin", entry.fetch("fork_url", entry.fetch("url")))
|
|
80
|
+
git(name, "remote", "set-url", "--push", "origin", entry.fetch("push_url", entry.fetch("url")))
|
|
81
|
+
unless entry.fetch("package_path") == "."
|
|
82
|
+
git(name, "sparse-checkout", "set", "--cone", entry.fetch("package_path"), ".github")
|
|
83
|
+
end
|
|
84
|
+
git(name, "checkout", "--detach", "upstream/#{entry.fetch('branch')}")
|
|
85
|
+
end
|
|
86
|
+
[entry.fetch("branch"), entry["status_branch"]].compact.uniq.each do |branch|
|
|
87
|
+
git(name, "fetch", "--depth=1", "upstream", "+refs/heads/#{branch}:refs/remotes/upstream/#{branch}")
|
|
88
|
+
end
|
|
89
|
+
path
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def safe_relative(path)
|
|
93
|
+
parts = path.to_s.split("/")
|
|
94
|
+
if path.to_s.empty? || Pathname.new(path).absolute? || parts.include?("..") || parts.include?(".git")
|
|
95
|
+
raise Error, "unsafe package path: #{path}"
|
|
96
|
+
end
|
|
97
|
+
path
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def contained_path(root, relative)
|
|
101
|
+
safe_relative(relative)
|
|
102
|
+
path = root
|
|
103
|
+
Pathname.new(relative).each_filename do |part|
|
|
104
|
+
path /= part
|
|
105
|
+
raise Error, "refusing to follow a package symlink: #{path}" if path.symlink?
|
|
106
|
+
end
|
|
107
|
+
path
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def payload(output, entry)
|
|
111
|
+
entry.fetch("files").each_with_object({}) do |(source, destination), result|
|
|
112
|
+
from = contained_path(output, source)
|
|
113
|
+
raise Error, "missing generated recipe: #{from}" unless from.exist?
|
|
114
|
+
|
|
115
|
+
paths = from.directory? ? project.files(from) : [from]
|
|
116
|
+
paths.each do |file|
|
|
117
|
+
relative = from.directory? ? (Pathname.new(destination) / file.relative_path_from(from)).cleanpath.to_s : destination
|
|
118
|
+
safe_relative(relative)
|
|
119
|
+
raise Error, "duplicate package destination: #{relative}" if result.key?(relative)
|
|
120
|
+
raise Error, "refusing a generated symlink: #{file}" if file.symlink?
|
|
121
|
+
|
|
122
|
+
result[relative] = file
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def merge_manifest(previous, current)
|
|
128
|
+
lines = previous.lines.reject { |line| line.strip.empty? }
|
|
129
|
+
current.each_line do |line|
|
|
130
|
+
next if line.strip.empty?
|
|
131
|
+
key = line.split.first(2)
|
|
132
|
+
raise Error, "invalid generated Manifest entry" unless key.first == "DIST" && key.length == 2
|
|
133
|
+
|
|
134
|
+
lines.reject! { |old| old.split.first(2) == key }
|
|
135
|
+
lines << line.chomp + "\n"
|
|
136
|
+
end
|
|
137
|
+
lines.sort.join
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def remote_head(name, branch)
|
|
141
|
+
git(name, "ls-remote", "--heads", "origin", "refs/heads/#{branch}").split.first
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def repository_version(name, entry, ref)
|
|
145
|
+
path = entry["version_file"] || entry.fetch("version_files")
|
|
146
|
+
exists = git(name, "ls-tree", ref, "--", path)
|
|
147
|
+
return nil if exists.empty?
|
|
148
|
+
|
|
149
|
+
content = if entry["version_files"]
|
|
150
|
+
git(name, "ls-tree", "--name-only", "#{ref}:#{path}")
|
|
151
|
+
else
|
|
152
|
+
git(name, "show", "#{ref}:#{path}")
|
|
153
|
+
end
|
|
154
|
+
content.scan(Regexp.new(entry.fetch("version_pattern"))).flatten.max_by { |version| version.scan(/\d+/).map(&:to_i) }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def prevent_downgrade(name, previous, version)
|
|
158
|
+
return unless previous && /\A\d+\.\d+\.\d+(?:-\d+|-r\d+)?\z/.match?(previous)
|
|
159
|
+
return unless (previous.scan(/\d+/).first(3).map(&:to_i) <=> version.split(".").map(&:to_i)) == 1
|
|
160
|
+
|
|
161
|
+
raise Error, "refusing to downgrade #{name} from #{previous} to #{version}"
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def stage(selector, output)
|
|
165
|
+
output = Pathname.new(output).expand_path
|
|
166
|
+
project.check(output)
|
|
167
|
+
metadata = JSON.parse((output / "release.json").read)
|
|
168
|
+
version = project.version_arg(metadata.fetch("VERSION"))
|
|
169
|
+
select(selector).each do |name, entry|
|
|
170
|
+
if entry.fetch("publish") == "manual"
|
|
171
|
+
puts "#{name}: manual workflow — #{entry.fetch('notes')}"
|
|
172
|
+
next
|
|
173
|
+
end
|
|
174
|
+
stage_one(name, entry, output, metadata, version)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def stage_one(name, entry, output, metadata, version)
|
|
179
|
+
sources = payload(output, entry)
|
|
180
|
+
digest = OpenSSL::Digest::SHA256.hexdigest(sources.sort.map { |path, file| "#{path}\0#{file.binread}\0#{file.stat.mode & 0o777}" }.join)
|
|
181
|
+
previous = state(name)
|
|
182
|
+
if previous && !previous["published"]
|
|
183
|
+
if previous.fetch("version") == version && previous.fetch("payload_digest") == digest && previous.fetch("registry_digest") == fingerprint(entry)
|
|
184
|
+
puts "#{name}: already prepared in #{checkout(name)}"
|
|
185
|
+
return
|
|
186
|
+
end
|
|
187
|
+
raise Error, "#{name}: an unpublished update exists; inspect it with diff and publish it before staging another"
|
|
188
|
+
end
|
|
189
|
+
if previous && (git(name, "branch", "--show-current") != previous.fetch("branch") || git(name, "rev-parse", "HEAD") != previous.fetch("head_commit", previous["remote_commit"]))
|
|
190
|
+
raise Error, "#{name}: local commits or a branch change exist; inspect #{checkout(name)} before staging"
|
|
191
|
+
end
|
|
192
|
+
path = refresh(name, entry)
|
|
193
|
+
raise Error, "#{name}: checkout has local changes; inspect #{path}" unless git(name, "status", "--porcelain").empty?
|
|
194
|
+
|
|
195
|
+
branch = project.render(entry.fetch("proposal_branch", entry.fetch("branch")), metadata)
|
|
196
|
+
existing = nil
|
|
197
|
+
if %w[github-pr gitlab-mr].include?(entry.fetch("publish"))
|
|
198
|
+
proposals = own_requests(entry)
|
|
199
|
+
existing = proposals.find { |request| request.fetch("branch") == branch }
|
|
200
|
+
existing ||= proposals.first if proposals.length == 1
|
|
201
|
+
raise Error, "#{name}: multiple open requests; set proposal_branch to the one to update" if !existing && proposals.length > 1
|
|
202
|
+
if existing
|
|
203
|
+
branch = existing.fetch("branch")
|
|
204
|
+
elsif remote_head(name, branch) && !entry.fetch("proposal_branch").include?("@VERSION@")
|
|
205
|
+
# An old, closed request must not dictate the base of a new submission.
|
|
206
|
+
branch = "#{branch}-#{version}"
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
git(name, "check-ref-format", "--branch", branch)
|
|
210
|
+
remote = remote_head(name, branch)
|
|
211
|
+
base = "upstream/#{entry.fetch('branch')}"
|
|
212
|
+
if remote && branch != entry.fetch("branch")
|
|
213
|
+
git(name, "fetch", "--depth=1", "origin", "+refs/heads/#{branch}:refs/remotes/origin/#{branch}")
|
|
214
|
+
base = "origin/#{branch}"
|
|
215
|
+
end
|
|
216
|
+
prevent_downgrade(name, repository_version(name, entry, base), version)
|
|
217
|
+
prevent_downgrade(name, repository_version(name, entry, "upstream/#{entry.fetch('branch')}"), version)
|
|
218
|
+
base_commit = git(name, "rev-parse", base)
|
|
219
|
+
local_branch = "packaging/#{name}/#{version}-#{SecureRandom.hex(4)}"
|
|
220
|
+
git(name, "switch", "-c", local_branch, base)
|
|
221
|
+
# Compute every destination before writing; a symlink must never redirect a copy.
|
|
222
|
+
destinations = sources.to_h { |relative, file| [contained_path(path, relative), file] }
|
|
223
|
+
destinations.each do |target, source|
|
|
224
|
+
content = source.binread
|
|
225
|
+
content = merge_manifest(target.read, content) if target.basename.to_s == "Manifest" && target.file?
|
|
226
|
+
project.write(target, content, executable: (source.stat.mode & 0o111).positive?)
|
|
227
|
+
end
|
|
228
|
+
git(name, "add", "--", *sources.keys)
|
|
229
|
+
record = {
|
|
230
|
+
"version" => version, "branch" => local_branch, "destination_branch" => branch,
|
|
231
|
+
"base_commit" => base_commit, "remote_commit" => remote, "paths" => sources.keys.sort,
|
|
232
|
+
"registry_digest" => fingerprint(entry), "payload_digest" => digest, "published" => false,
|
|
233
|
+
"request_url" => existing && existing.fetch("url"),
|
|
234
|
+
"title" => project.render(entry.fetch("title", "#{project.package_name}: update to @VERSION@"), metadata)
|
|
235
|
+
}
|
|
236
|
+
project.write(state_path(name), JSON.pretty_generate(record) + "\n")
|
|
237
|
+
if %w[github-pr gitlab-mr].include?(entry.fetch("publish"))
|
|
238
|
+
description = "Update #{project.package_name} to #{version}.\n\nUpstream release: #{project.upstream}/releases/tag/v#{version}\n\n"
|
|
239
|
+
template = %w[.github/pull_request_template.md .github/PULL_REQUEST_TEMPLATE.md].map { |file| path / file }.find(&:file?)
|
|
240
|
+
description << (existing && existing["body"] ? existing.fetch("body") : template ? template.read : "Validation:\n\nDescribe the native package checks performed before submitting.\n")
|
|
241
|
+
project.write(body_path(name), description)
|
|
242
|
+
puts "#{name}: edit the submission description at #{body_path(name)}"
|
|
243
|
+
end
|
|
244
|
+
puts "#{name}: staged #{version} in #{path}; run diff #{name} to review"
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def prepared(name, entry)
|
|
248
|
+
record = state(name) or raise Error, "#{name}: no prepared update; run stage first"
|
|
249
|
+
raise Error, "#{name}: registry changed since staging" unless record.fetch("registry_digest") == fingerprint(entry)
|
|
250
|
+
raise Error, "#{name}: checkout is on a different branch" unless git(name, "branch", "--show-current") == record.fetch("branch")
|
|
251
|
+
record
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def diff(selector)
|
|
255
|
+
select(selector).each do |name, entry|
|
|
256
|
+
next if entry.fetch("publish") == "manual"
|
|
257
|
+
record = prepared(name, entry)
|
|
258
|
+
puts "#{name} — #{record.fetch('version')} (#{checkout(name)})"
|
|
259
|
+
puts git(name, "diff", "--no-ext-diff", record.fetch("base_commit"), "--")
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def publish(selector, body_file: nil)
|
|
264
|
+
targets = select(selector)
|
|
265
|
+
if targets.length > 1 && targets.values.any? { |entry| entry.fetch("publish") != "push" }
|
|
266
|
+
raise Error, "publish PR/MR targets individually with their reviewed --body-file"
|
|
267
|
+
end
|
|
268
|
+
targets.each do |name, entry|
|
|
269
|
+
raise Error, "#{name}: #{entry.fetch('notes')}" if entry.fetch("publish") == "manual"
|
|
270
|
+
record = prepared(name, entry)
|
|
271
|
+
if %w[github-pr gitlab-mr].include?(entry.fetch("publish"))
|
|
272
|
+
raise Error, "#{name}: supply a reviewed --body-file (prepared at #{body_path(name)})" unless body_file&.file? && !body_file.read.strip.empty?
|
|
273
|
+
raise Error, "install gh to publish GitHub requests" if entry.fetch("publish") == "github-pr" && !project.available?("gh")
|
|
274
|
+
if entry.fetch("publish") == "gitlab-mr" && ENV.fetch(entry.fetch("token_env"), "").empty?
|
|
275
|
+
raise Error, "set #{entry.fetch('token_env')} to publish GitLab requests"
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
raise Error, "#{name}: unstaged edits exist; review and git add them first" unless git(name, "diff", "--name-only").empty?
|
|
279
|
+
raise Error, "#{name}: untracked files exist in the checkout" unless git(name, "ls-files", "--others", "--exclude-standard").empty?
|
|
280
|
+
head = git(name, "rev-parse", "HEAD")
|
|
281
|
+
remote = remote_head(name, record.fetch("destination_branch"))
|
|
282
|
+
unless remote == record["remote_commit"] || remote == head
|
|
283
|
+
_, ancestry = git(name, "merge-base", "--is-ancestor", remote || "", head, allow_failure: true)
|
|
284
|
+
unless remote && ancestry.success?
|
|
285
|
+
raise Error, "#{name}: destination advanced since staging; commit your reviewed changes, fetch and rebase onto origin/#{record.fetch('destination_branch')} in #{checkout(name)}, then retry"
|
|
286
|
+
end
|
|
287
|
+
# The maintainer reconciled the branch manually; exclude those remote changes from our diff.
|
|
288
|
+
record["base_commit"] = remote
|
|
289
|
+
record["remote_commit"] = remote
|
|
290
|
+
end
|
|
291
|
+
changed = git(name, "diff", "--name-only", record.fetch("base_commit"), "--").lines.map(&:strip)
|
|
292
|
+
raise Error, "#{name}: changes outside the prepared package paths" unless (changed - record.fetch("paths")).empty?
|
|
293
|
+
project.write(state_path(name), JSON.pretty_generate(record) + "\n")
|
|
294
|
+
end
|
|
295
|
+
targets.each { |name, entry| publish_one(name, entry, prepared(name, entry), body_file) }
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def publish_one(name, entry, record, body_file)
|
|
299
|
+
unless git(name, "diff", "--cached", "--name-only").empty?
|
|
300
|
+
flags = []
|
|
301
|
+
flags << "-S" if entry["sign_commit"]
|
|
302
|
+
flags << "--signoff" if entry["signoff"]
|
|
303
|
+
git(name, "commit", *flags, "-m", record.fetch("title"))
|
|
304
|
+
end
|
|
305
|
+
if git(name, "rev-parse", "HEAD") == record.fetch("base_commit") && (entry.fetch("publish") == "push" || !record["request_url"])
|
|
306
|
+
puts "#{name}: already up to date"
|
|
307
|
+
record["outcome"] = "unchanged"
|
|
308
|
+
else
|
|
309
|
+
flags = entry["sign_push"] ? ["--signed"] : []
|
|
310
|
+
git(name, "push", *flags, "origin", "HEAD:refs/heads/#{record.fetch('destination_branch')}")
|
|
311
|
+
record["request_url"] = publish_request(name, entry, record, body_file) unless entry.fetch("publish") == "push"
|
|
312
|
+
record["remote_commit"] = git(name, "rev-parse", "HEAD")
|
|
313
|
+
record["outcome"] = "pushed"
|
|
314
|
+
puts "#{name}: published #{record.fetch('version')}#{record['request_url'] && " — #{record['request_url']}"}"
|
|
315
|
+
end
|
|
316
|
+
record["published"] = true
|
|
317
|
+
record["head_commit"] = git(name, "rev-parse", "HEAD")
|
|
318
|
+
project.write(state_path(name), JSON.pretty_generate(record) + "\n")
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def gitlab(entry, path, method: :get, data: nil)
|
|
322
|
+
uri = URI("https://#{entry.fetch('host')}/api/v4/#{path}")
|
|
323
|
+
request = { get: Net::HTTP::Get, post: Net::HTTP::Post, put: Net::HTTP::Put }.fetch(method).new(uri)
|
|
324
|
+
token = ENV[entry.fetch("token_env")]
|
|
325
|
+
request["PRIVATE-TOKEN"] = token if token && !token.empty?
|
|
326
|
+
if data
|
|
327
|
+
request["Content-Type"] = "application/json"
|
|
328
|
+
request.body = JSON.generate(data)
|
|
329
|
+
end
|
|
330
|
+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 20, read_timeout: 60) { |http| http.request(request) }
|
|
331
|
+
raise Error, "GitLab API returned HTTP #{response.code} for #{uri.path}" unless response.is_a?(Net::HTTPSuccess)
|
|
332
|
+
JSON.parse(response.body)
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def project_path(project) = "projects/#{URI.encode_www_form_component(project)}"
|
|
336
|
+
|
|
337
|
+
def requests(entry)
|
|
338
|
+
case entry.fetch("publish")
|
|
339
|
+
when "github-pr"
|
|
340
|
+
rows = JSON.parse(project.capture("gh", "pr", "list", "--repo", entry.fetch("repository"), "--state", "open",
|
|
341
|
+
"--search", "#{project.package_name} in:title", "--limit", "100", "--json", "number,url,title,body,headRefName,headRepositoryOwner"))
|
|
342
|
+
rows.map { |row| { "number" => row.fetch("number"), "url" => row.fetch("url"), "title" => row.fetch("title"), "body" => row.fetch("body"), "branch" => row.fetch("headRefName"), "owner" => row.dig("headRepositoryOwner", "login") } }
|
|
343
|
+
when "gitlab-mr"
|
|
344
|
+
rows = gitlab(entry, "#{project_path(entry.fetch('repository'))}/merge_requests?state=opened&scope=all&search=#{URI.encode_www_form_component(project.package_name)}&per_page=100")
|
|
345
|
+
rows.map { |row| { "number" => row.fetch("iid"), "url" => row.fetch("web_url"), "title" => row.fetch("title"), "body" => row["description"], "branch" => row.fetch("source_branch"), "source_project_id" => row.fetch("source_project_id") } }
|
|
346
|
+
else
|
|
347
|
+
[]
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def own_requests(entry)
|
|
352
|
+
rows = requests(entry)
|
|
353
|
+
if entry.fetch("publish") == "github-pr"
|
|
354
|
+
rows.select { |row| row["owner"] == entry.fetch("fork_owner") }
|
|
355
|
+
else
|
|
356
|
+
source_id = gitlab(entry, project_path(entry.fetch("fork_repository"))).fetch("id")
|
|
357
|
+
rows.select { |row| row["source_project_id"] == source_id }
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def publish_request(name, entry, record, body_file)
|
|
362
|
+
branch = record.fetch("destination_branch")
|
|
363
|
+
if entry.fetch("publish") == "github-pr"
|
|
364
|
+
existing = requests(entry).find { |request| request.fetch("branch") == branch && request["owner"] == entry.fetch("fork_owner") }
|
|
365
|
+
if existing
|
|
366
|
+
project.capture("gh", "pr", "edit", existing.fetch("url"), "--title", record.fetch("title"), "--body-file", body_file)
|
|
367
|
+
existing.fetch("url")
|
|
368
|
+
else
|
|
369
|
+
project.capture("gh", "pr", "create", "--repo", entry.fetch("repository"), "--base", entry.fetch("branch"),
|
|
370
|
+
"--head", "#{entry.fetch('fork_owner')}:#{branch}", "--title", record.fetch("title"), "--body-file", body_file)
|
|
371
|
+
end
|
|
372
|
+
else
|
|
373
|
+
source_id = gitlab(entry, project_path(entry.fetch("fork_repository"))).fetch("id")
|
|
374
|
+
existing = requests(entry).find { |request| request.fetch("branch") == branch && request["source_project_id"] == source_id }
|
|
375
|
+
data = { "title" => record.fetch("title"), "description" => body_file.read }
|
|
376
|
+
response = if existing
|
|
377
|
+
gitlab(entry, "#{project_path(entry.fetch('repository'))}/merge_requests/#{existing.fetch('number')}", method: :put, data: data)
|
|
378
|
+
else
|
|
379
|
+
target_id = gitlab(entry, project_path(entry.fetch("repository"))).fetch("id")
|
|
380
|
+
gitlab(entry, "#{project_path(entry.fetch('fork_repository'))}/merge_requests", method: :post,
|
|
381
|
+
data: data.merge("source_branch" => branch, "target_branch" => entry.fetch("branch"), "target_project_id" => target_id))
|
|
382
|
+
end
|
|
383
|
+
response.fetch("web_url")
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def status(selector = "all", offline: false, json: false)
|
|
388
|
+
rows = select(selector).map do |name, entry|
|
|
389
|
+
row = { "target" => name, "url" => entry.fetch("url"), "method" => entry.fetch("publish"), "notes" => entry["notes"], "checked_at" => Time.now.utc.iso8601, "source" => offline ? "cached" : "live" }
|
|
390
|
+
record = state(name)
|
|
391
|
+
row["local_version"] = record && record["version"]
|
|
392
|
+
row["local_state"] = record ? (record["published"] ? record.fetch("outcome", "pushed") : "prepared") : "not prepared"
|
|
393
|
+
if entry["branch"]
|
|
394
|
+
begin
|
|
395
|
+
refresh(name, entry) unless offline
|
|
396
|
+
if (checkout(name) / ".git").directory?
|
|
397
|
+
row["upstream_branch"] = entry.fetch("status_branch", entry.fetch("branch"))
|
|
398
|
+
row["upstream_version"] = repository_version(name, entry, "upstream/#{row.fetch('upstream_branch')}")
|
|
399
|
+
row["local_changes"] = !git(name, "status", "--porcelain").empty?
|
|
400
|
+
else
|
|
401
|
+
row["remote_error"] = "not fetched (offline)"
|
|
402
|
+
end
|
|
403
|
+
rescue Error, SystemCallError => error
|
|
404
|
+
row["remote_error"] = error.message
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
begin
|
|
408
|
+
row["requests"] = requests(entry).map { |request| request.reject { |key, _| key == "body" } } unless offline
|
|
409
|
+
rescue Error, SystemCallError, IOError, JSON::ParserError, Timeout::Error, SocketError, OpenSSL::OpenSSLError => error
|
|
410
|
+
row["request_error"] = error.message
|
|
411
|
+
end
|
|
412
|
+
row
|
|
413
|
+
end
|
|
414
|
+
if json
|
|
415
|
+
puts JSON.pretty_generate(rows)
|
|
416
|
+
else
|
|
417
|
+
puts "TARGET UPSTREAM LOCAL OPEN REQUESTS"
|
|
418
|
+
rows.each do |row|
|
|
419
|
+
version = row["remote_error"] ? "unknown" : row["upstream_version"] || (entries.fetch(row.fetch("target"))["branch"] ? "absent" : "manual")
|
|
420
|
+
puts "#{row.fetch('target').ljust(14)} #{version.ljust(14)} #{[row['local_version'], row['local_state']].compact.join(' ').ljust(22)} #{Array(row['requests']).map { |request| request.fetch('url') }.join(' ')}"
|
|
421
|
+
%w[remote_error request_error notes].each { |key| puts " #{row[key]}" if row[key] }
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
!rows.any? { |row| row["remote_error"] || row["request_error"] }
|
|
425
|
+
end
|
|
426
|
+
end
|
|
427
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "configuration"
|
|
4
|
+
|
|
5
|
+
module NativePackages
|
|
6
|
+
class Scaffold
|
|
7
|
+
include Support
|
|
8
|
+
attr_reader :root
|
|
9
|
+
|
|
10
|
+
def initialize(root) = @root = Pathname.new(root).realpath
|
|
11
|
+
|
|
12
|
+
def init(interactive: false, name: nil, input: nil, formats: nil)
|
|
13
|
+
raise Error, "configuration exists" if Configuration.discover(root)
|
|
14
|
+
raise Error, "legacy packaging found; use native-packages migrate --dry-run" if (root / "packaging/project.yml").exist?
|
|
15
|
+
raise Error, "interactive init requires a terminal" if interactive && (!$stdin.tty? || ENV["CI"])
|
|
16
|
+
metadata = cargo_metadata
|
|
17
|
+
name ||= metadata.fetch("name", root.basename.to_s.downcase.gsub(/[^a-z0-9-]/, "-"))
|
|
18
|
+
maintainer = git_value("user.name")
|
|
19
|
+
email = git_value("user.email")
|
|
20
|
+
maintainer = "#{maintainer} <#{email}>" unless email.empty?
|
|
21
|
+
values = { "name" => name, "maintainer" => maintainer, "license" => metadata.fetch("license", ""),
|
|
22
|
+
"input" => input || "dist/#{name}-linux-amd64.tar.gz", "formats" => formats || "deb,rpm,archlinux" }
|
|
23
|
+
if interactive
|
|
24
|
+
values.each do |key, value|
|
|
25
|
+
print "#{key} [#{value}]: "
|
|
26
|
+
answer = $stdin.gets&.strip
|
|
27
|
+
raise Error, "input closed; configuration was not written" unless answer
|
|
28
|
+
values[key] = answer unless answer.empty?
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
document = { "schema" => 1, "tool" => { "version" => VERSION, "nfpm" => Configuration::NFPM_VERSION },
|
|
32
|
+
"nfpm" => { "name" => values.fetch("name"), "description" => metadata.fetch("description", "TODO: describe this application"),
|
|
33
|
+
"maintainer" => values.fetch("maintainer"), "license" => values.fetch("license"),
|
|
34
|
+
"contents" => [{ "src" => "@PAYLOAD@/#{values.fetch('name')}", "dst" => "/usr/bin/#{values.fetch('name')}", "file_info" => { "mode" => 0o755 } }] },
|
|
35
|
+
"targets" => { "linux-amd64" => { "platform" => "linux", "arch" => "amd64", "libc" => "glibc",
|
|
36
|
+
"formats" => values.fetch("formats").split(","), "input" => { "local" => values.fetch("input") } } } }
|
|
37
|
+
destination = root / Configuration::NAMES.first
|
|
38
|
+
write(destination, "# Review inputs, platform/ABI, dependencies and metadata before building.\n" + YAML.dump(document))
|
|
39
|
+
puts "Created #{destination}. Review the Linux amd64 template, then run native-packages doctor."
|
|
40
|
+
puts "Fill in nfpm.maintainer and nfpm.license." if values.values_at("maintainer", "license").any?(&:empty?)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def cargo_metadata
|
|
44
|
+
return {} unless (root / "Cargo.toml").file?
|
|
45
|
+
section = (root / "Cargo.toml").read.split(/^\[package\]\s*$/)[1]&.split(/^\[/)&.first.to_s
|
|
46
|
+
section.scan(/^(name|description|license)\s*=\s*"([^"\n]+)"\s*$/).to_h
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def git_value(key)
|
|
50
|
+
capture("git", "config", "--get", key)
|
|
51
|
+
rescue Error, Errno::ENOENT
|
|
52
|
+
""
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def migrate(dry_run: false)
|
|
56
|
+
raise Error, "configuration exists" if Configuration.discover(root)
|
|
57
|
+
legacy = Project.new(root)
|
|
58
|
+
original = legacy.config
|
|
59
|
+
supported = %w[version name repository release_repository assets templates revisions binary version_file version_section]
|
|
60
|
+
raise Error, "cannot automatically migrate fields: #{(original.keys - supported).join(', ')}" unless (original.keys - supported).empty?
|
|
61
|
+
document = { "schema" => 1, "tool" => { "version" => VERSION, "nfpm" => Configuration::NFPM_VERSION },
|
|
62
|
+
"nfpm" => { "name" => legacy.package_name }, "targets" => {},
|
|
63
|
+
"release" => { "repository" => legacy.repository, "checksums" => "checksums.txt" },
|
|
64
|
+
"assets" => original.fetch("assets"), "templates" => original.fetch("templates", {}),
|
|
65
|
+
"repositories" => legacy.repositories.entries }
|
|
66
|
+
%w[revisions version_file version_section].each { |key| document[key] = original[key] if original.key?(key) }
|
|
67
|
+
if original["binary"]
|
|
68
|
+
binary = original.fetch("binary")
|
|
69
|
+
raise Error, "cannot migrate unknown binary fields" unless (binary.keys - %w[architectures nfpm libraries]).empty?
|
|
70
|
+
document["nfpm"].merge!(legacy.nfpm_definition)
|
|
71
|
+
document["libraries"] = binary.fetch("libraries", {})
|
|
72
|
+
binary.fetch("architectures").each do |architecture, asset_key|
|
|
73
|
+
target = { "amd64" => "x86_64-unknown-linux-gnu", "arm64" => "aarch64-unknown-linux-gnu" }.fetch(architecture)
|
|
74
|
+
asset = original.fetch("assets").fetch(asset_key)
|
|
75
|
+
document["targets"]["linux-#{architecture}"] = { "platform" => "linux", "arch" => architecture, "libc" => "glibc",
|
|
76
|
+
"compiler_target" => target, "formats" => %w[deb rpm],
|
|
77
|
+
"input" => { "local" => "dist/#{asset.fetch('file')}", "release_asset" => asset.fetch("file") } }
|
|
78
|
+
document["targets"]["linux-#{architecture}"]["input"]["url"] = asset.fetch("url") if asset["url"]
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
content = YAML.dump(document)
|
|
82
|
+
return puts(content) if dry_run
|
|
83
|
+
path = root / Configuration::NAMES.first
|
|
84
|
+
write(path, content)
|
|
85
|
+
puts "Created #{path}. Existing recipes, wrappers and dependency files were preserved; compare outputs before removing them."
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "open3"
|
|
6
|
+
require "openssl"
|
|
7
|
+
require "optparse"
|
|
8
|
+
require "pathname"
|
|
9
|
+
require "shellwords"
|
|
10
|
+
require "time"
|
|
11
|
+
require "tmpdir"
|
|
12
|
+
require "yaml"
|
|
13
|
+
|
|
14
|
+
module NativePackages
|
|
15
|
+
VERSION = "0.2.0"
|
|
16
|
+
class Error < StandardError; end
|
|
17
|
+
|
|
18
|
+
module Support
|
|
19
|
+
TOKEN = /@([A-Z][A-Z0-9_]*)@/
|
|
20
|
+
|
|
21
|
+
def run(*arguments, env: {}, chdir: root)
|
|
22
|
+
output = capture(*arguments, env: env, chdir: chdir)
|
|
23
|
+
puts output unless output.empty?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def capture(*arguments, env: {}, chdir: root)
|
|
27
|
+
output, error, status = Open3.capture3(env, *arguments.map(&:to_s), chdir: chdir.to_s)
|
|
28
|
+
raise Error, "#{arguments.first} failed: #{error.strip}" unless status.success?
|
|
29
|
+
output.strip
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def available?(name)
|
|
33
|
+
extensions = Gem.win_platform? ? [""] + ENV.fetch("PATHEXT", ".EXE;.BAT;.CMD").split(";").map(&:downcase) : [""]
|
|
34
|
+
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |path|
|
|
35
|
+
extensions.any? { |extension| File.executable?(File.join(path, name + extension)) && File.file?(File.join(path, name + extension)) }
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def version_arg(value)
|
|
40
|
+
version = value.delete_prefix("v")
|
|
41
|
+
raise Error, "expected a stable version such as 1.2.3" unless /\A(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)\z/.match?(version)
|
|
42
|
+
version
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def render(value, metadata)
|
|
46
|
+
value.gsub(TOKEN) { metadata.fetch(Regexp.last_match(1)).to_s }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def render_tree(value, metadata)
|
|
50
|
+
case value
|
|
51
|
+
when Hash then value.to_h { |key, item| [key, render_tree(item, metadata)] }
|
|
52
|
+
when Array then value.map { |item| render_tree(item, metadata) }
|
|
53
|
+
when String then render(value, metadata)
|
|
54
|
+
else value
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def relative_path(value)
|
|
59
|
+
path = Pathname.new(value)
|
|
60
|
+
raise Error, "unsafe relative path: #{value}" if path.absolute? || path.each_filename.any? { |part| %w[.. .git].include?(part) }
|
|
61
|
+
path
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def files(path)
|
|
65
|
+
Pathname.glob(path / "**/*", File::FNM_DOTMATCH).select(&:file?).sort
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def write(path, content, executable: false)
|
|
69
|
+
path = Pathname.new(path)
|
|
70
|
+
path.dirname.mkpath
|
|
71
|
+
raise Error, "refusing to overwrite symlink: #{path}" if path.symlink?
|
|
72
|
+
path.binwrite(content)
|
|
73
|
+
path.chmod(executable ? 0o755 : 0o644)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def sha256(path) = OpenSSL::Digest::SHA256.file(path).hexdigest
|
|
77
|
+
|
|
78
|
+
def recipe_archive(recipes, output, name:, version:, epoch:)
|
|
79
|
+
archive = output / "#{name}-#{version}-packaging.tar.xz"
|
|
80
|
+
run "tar", "--sort=name", "--mtime=@#{epoch}", "--owner=0", "--group=0", "--numeric-owner",
|
|
81
|
+
"-cJf", archive, "-C", recipes, "."
|
|
82
|
+
write(output / "packaging-checksums.txt", files(output).reject { |file| file.basename.to_s == "packaging-checksums.txt" }.map { |file| "#{sha256(file)} #{file.basename}\n" }.join)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def upload_assets(repository, version, output)
|
|
86
|
+
version = version_arg(version)
|
|
87
|
+
output = Pathname.new(output).expand_path
|
|
88
|
+
expected = (output / "packaging-checksums.txt").readlines.to_h { |line| digest, name = line.split; [name, digest] }
|
|
89
|
+
raise Error, "no package assets to upload" if expected.empty?
|
|
90
|
+
paths = expected.map do |name, digest|
|
|
91
|
+
raise Error, "invalid package asset filename" unless File.basename(name) == name && name != "checksums.txt"
|
|
92
|
+
pattern = /\A#{Regexp.escape(package_name)}(?:_#{Regexp.escape(version)}_(?:amd64|arm64)\.(?:deb|rpm)|-#{Regexp.escape(version)}-packaging\.tar\.xz)\z/
|
|
93
|
+
raise Error, "package asset does not belong to #{package_name} #{version}: #{name}" unless pattern.match?(name)
|
|
94
|
+
path = output / name
|
|
95
|
+
raise Error, "package asset checksum changed: #{name}" unless path.file? && sha256(path) == digest
|
|
96
|
+
path
|
|
97
|
+
end
|
|
98
|
+
paths << output / "packaging-checksums.txt"
|
|
99
|
+
run "gh", "release", "upload", "v#{version}", *paths, "--repo", repository, "--clobber"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def download(url, path)
|
|
103
|
+
return if path.file?
|
|
104
|
+
raise Error, "downloads must use HTTPS" unless url.start_with?("https://")
|
|
105
|
+
path.dirname.mkpath
|
|
106
|
+
temporary = Pathname.new("#{path}.partial")
|
|
107
|
+
begin
|
|
108
|
+
run "curl", "--fail", "--location", "--silent", "--show-error", "--retry", "3",
|
|
109
|
+
"--connect-timeout", "20", "--max-time", "600", "--output", temporary, url
|
|
110
|
+
temporary.rename(path)
|
|
111
|
+
ensure
|
|
112
|
+
temporary.delete if temporary.exist?
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|