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,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
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "native_packages/project"
4
+ require_relative "native_packages/cli"