traveling-ruby 0.1.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,103 @@
1
+ require "digest"
2
+ require "fileutils"
3
+ require "net/http"
4
+ require "uri"
5
+
6
+ module TravelingRuby
7
+ # Downloads over HTTP(S), or reads local paths and file:// URLs. Files fetched
8
+ # with a checksum are cached by name and verified, both when downloaded and
9
+ # when reused.
10
+ class Fetcher
11
+ MAX_REDIRECTS = 10
12
+
13
+ def initialize(cache_dir: self.class.default_cache_dir, ui: UI.new)
14
+ @cache_dir = cache_dir
15
+ @ui = ui
16
+ end
17
+
18
+ def self.default_cache_dir
19
+ base = ENV["XDG_CACHE_HOME"]
20
+ base = File.join(Dir.home, ".cache") if base.nil? || base.empty?
21
+ File.join(base, "traveling-ruby")
22
+ end
23
+
24
+ # The body at url, or nil when it does not exist
25
+ def read(url)
26
+ if local?(url)
27
+ path = local_path(url)
28
+ File.exist?(path) ? File.binread(path) : nil
29
+ else
30
+ get(url) { |response| response.body }
31
+ end
32
+ end
33
+
34
+ # A local copy of url, kept at <cache dir>/<cache_key> and verified when
35
+ # sha256 is given
36
+ def fetch(url, sha256:, cache_key:)
37
+ path = File.join(@cache_dir, cache_key)
38
+ return path if File.exist?(path) && (sha256.nil? || Digest::SHA256.file(path).hexdigest == sha256)
39
+
40
+ FileUtils.mkdir_p(File.dirname(path))
41
+ tmp = "#{path}.#{Process.pid}.tmp"
42
+ begin
43
+ if local?(url)
44
+ source = local_path(url)
45
+ raise Error, "#{source} does not exist" unless File.exist?(source)
46
+ FileUtils.cp(source, tmp)
47
+ else
48
+ @ui.fetching(url)
49
+ found = get(url) do |response|
50
+ File.open(tmp, "wb") { |f| response.read_body { |chunk| f.write(chunk) } }
51
+ end
52
+ raise Error, "#{url} not found" unless found
53
+ end
54
+ actual = Digest::SHA256.file(tmp).hexdigest
55
+ raise Error, "checksum mismatch for #{url}: expected #{sha256}, got #{actual}" unless sha256.nil? || actual == sha256
56
+ File.rename(tmp, path)
57
+ ensure
58
+ FileUtils.rm_f(tmp)
59
+ end
60
+ path
61
+ end
62
+
63
+ # Where url redirects to, without following it
64
+ def redirect_location(url)
65
+ uri = URI(url)
66
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
67
+ http.request(Net::HTTP::Head.new(uri))
68
+ end
69
+ response["location"] if response.is_a?(Net::HTTPRedirection)
70
+ end
71
+
72
+ def local?(url)
73
+ !url.match?(%r{\A[a-z][a-z0-9+.-]*://}i) || url.start_with?("file://")
74
+ end
75
+
76
+ private
77
+
78
+ def local_path(url)
79
+ url.start_with?("file://") ? URI(url).path : url
80
+ end
81
+
82
+ # Yields the response, following redirects; nil when not found
83
+ def get(url, redirects = 0, &block)
84
+ raise Error, "too many redirects fetching #{url}" if redirects > MAX_REDIRECTS
85
+
86
+ uri = URI(url)
87
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
88
+ http.request(Net::HTTP::Get.new(uri)) do |response|
89
+ case response
90
+ when Net::HTTPRedirection
91
+ return get(URI.join(url, response["location"]).to_s, redirects + 1, &block)
92
+ when Net::HTTPNotFound
93
+ return nil
94
+ when Net::HTTPSuccess
95
+ return yield(response)
96
+ else
97
+ raise Error, "GET #{url}: #{response.code} #{response.message}"
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,65 @@
1
+ module TravelingRuby
2
+ # A script at the top of a package that runs one of its programs with the
3
+ # packaged Ruby and the app's bundle. The script to run is relative to lib/:
4
+ # app/bin/hello for the app's own, or a gem's executable in ruby/lib/ruby/gems.
5
+ module Launcher
6
+ module_function
7
+
8
+ def file_name(name, target)
9
+ TravelingRuby.windows?(target) ? "#{name}.bat" : name
10
+ end
11
+
12
+ def render(target, script:, gemfile: nil, ruby_opts: [])
13
+ TravelingRuby.windows?(target) ? bat(script, gemfile, ruby_opts) : sh(script, gemfile, ruby_opts)
14
+ end
15
+
16
+ def sh(script, gemfile, ruby_opts)
17
+ args = [*ruby_opts, *("-rbundler/setup" if gemfile)].map { |arg| "#{sh_quote(arg)} " }.join
18
+ bundle = <<~SH if gemfile
19
+ export BUNDLE_GEMFILE="$LIBDIR/app/#{gemfile}"
20
+ export BUNDLE_APP_CONFIG="$LIBDIR/bundle"
21
+ unset BUNDLE_PATH BUNDLE_IGNORE_CONFIG RUBYGEMS_GEMDEPS
22
+ SH
23
+ <<~SH
24
+ #!/bin/sh
25
+ set -e
26
+ SELF="$0"
27
+ while [ -h "$SELF" ]; do
28
+ LINK=$(readlink "$SELF")
29
+ case "$LINK" in
30
+ /*) SELF="$LINK" ;;
31
+ *) SELF="$(dirname "$SELF")/$LINK" ;;
32
+ esac
33
+ done
34
+ # The physical path: Ruby resolves its own files through symlinks, and
35
+ # its load path must name them the same way
36
+ LIBDIR=$(cd "$(dirname "$SELF")/lib" && pwd -P)
37
+ #{bundle}exec "$LIBDIR/ruby/bin/ruby" #{args}"$LIBDIR/#{script}" "$@"
38
+ SH
39
+ end
40
+
41
+ def bat(script, gemfile, ruby_opts)
42
+ args = [*ruby_opts, *("-rbundler/setup" if gemfile)].map { |arg| "#{arg} " }.join
43
+ script = script.tr("/", "\\")
44
+ bundle = <<~BAT if gemfile
45
+ set "BUNDLE_GEMFILE=%LIBDIR%\\app\\#{gemfile}"
46
+ set "BUNDLE_APP_CONFIG=%LIBDIR%\\bundle"
47
+ set BUNDLE_PATH=
48
+ set BUNDLE_IGNORE_CONFIG=
49
+ set RUBYGEMS_GEMDEPS=
50
+ BAT
51
+ <<~BAT.gsub("\n", "\r\n")
52
+ @echo off
53
+ setlocal
54
+ set "LIBDIR=%~dp0lib"
55
+ #{bundle&.chomp}
56
+ "%LIBDIR%\\ruby\\bin\\ruby.bat" #{args}"%LIBDIR%\\#{script}" %*
57
+ exit /b %ERRORLEVEL%
58
+ BAT
59
+ end
60
+
61
+ def sh_quote(arg)
62
+ arg.match?(%r{\A[\w@%+=:,./-]+\z}) ? arg : "'#{arg.gsub("'", %q('\\\\''))}'"
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,433 @@
1
+ require "digest"
2
+ require "fileutils"
3
+ require "pathname"
4
+ require "rubygems/package"
5
+
6
+ module TravelingRuby
7
+ # Packages an app with a Traveling Ruby runtime for each target. A package is
8
+ # a directory, and a .tar.gz of it:
9
+ #
10
+ # <name>[-<app version>]-<target>/
11
+ # <bin> a launcher per program (<bin>.bat on Windows)
12
+ # lib/app/ the app
13
+ # lib/ruby/ the runtime, with the app's gems installed in it
14
+ # lib/bundle/config Bundler settings for the app
15
+ #
16
+ # Gems are installed from their ruby platform .gem files, and native gems
17
+ # get the extensions built for the release, so nothing is compiled.
18
+ class Packager
19
+ PRUNE_DIRS = %w[test tests spec features benchmark doc docs example examples sample].freeze
20
+ PRUNE_EXT_FILES = %w[*.c *.h *.o Makefile extconf.rb mkmf.log].freeze
21
+ RUNTIMES = %w[slim full].freeze
22
+
23
+ # A program to make a launcher for: a script in the app, or a gem's executable
24
+ Program = Struct.new(:name, :script, :spec, keyword_init: true) do
25
+ def gem?
26
+ !spec.nil?
27
+ end
28
+
29
+ def to_s
30
+ gem? ? "#{spec.name}'s #{name}" : script
31
+ end
32
+ end
33
+
34
+ # How a locked gem with a native extension fares on each target
35
+ Extension = Struct.new(:gem, :built, :runtime, :missing, :versions, keyword_init: true) do
36
+ def ok?
37
+ missing.empty?
38
+ end
39
+ end
40
+
41
+ attr_reader :name, :targets, :ui
42
+
43
+ def initialize(app_dir: ".", name: nil, entry: nil, bins: nil, gem_bins: nil, targets: DEFAULT_TARGETS,
44
+ ruby: nil, revision: nil, runtime: "slim", source: Release::DEFAULT_SOURCE, out: "pkg",
45
+ app_version: nil, without: %w[development test], ruby_opts: [], keep: [], prune: true,
46
+ archive: true, allow_missing_extensions: false, cache_dir: nil, ui: UI.new)
47
+ @app_dir = File.expand_path(app_dir)
48
+ raise Error, "#{@app_dir} is not a directory" unless File.directory?(@app_dir)
49
+ @name = name || File.basename(@app_dir)
50
+ @bins = bins&.to_h || (entry ? {@name => entry} : {})
51
+ @gem_bins = gem_bins
52
+ @targets = Array(targets)
53
+ raise Error, "no targets given" if @targets.empty?
54
+ unknown = @targets - TARGETS
55
+ raise Error, "unknown targets: #{unknown.join(", ")} (known: #{TARGETS.join(", ")})" if unknown.any?
56
+ @runtime = runtime.to_s
57
+ raise Error, "runtime must be one of #{RUNTIMES.join(", ")}, not #{@runtime}" unless RUNTIMES.include?(@runtime)
58
+ @ruby = ruby
59
+ @revision = revision
60
+ @source = source
61
+ @out = File.expand_path(out, @app_dir)
62
+ @app_version = app_version
63
+ @without = without
64
+ @ruby_opts = ruby_opts
65
+ @keep = keep
66
+ @prune = prune
67
+ @archive = archive
68
+ @allow_missing_extensions = allow_missing_extensions
69
+ @ui = ui
70
+ @fetcher = cache_dir ? Fetcher.new(cache_dir: cache_dir, ui: ui) : Fetcher.new(ui: ui)
71
+ @gem_files = {}
72
+ @specs = {}
73
+ end
74
+
75
+ def release
76
+ @release ||= Release.resolve(fetcher: @fetcher, source: @source, ruby: @ruby, revision: @revision)
77
+ end
78
+
79
+ def bundle
80
+ return @bundle if defined?(@bundle)
81
+ @bundle = Bundle.find(@app_dir, without: @without)
82
+ end
83
+
84
+ def full?
85
+ @runtime == "full"
86
+ end
87
+
88
+ def label
89
+ [@name, @app_version].compact.join(" ")
90
+ end
91
+
92
+ # Builds every target's package; returns the package directories, or the
93
+ # archives when archiving
94
+ def run
95
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
96
+ @ui.title "Packaging #{label} with Ruby #{release.ruby} (Traveling Ruby #{release}#{", full runtime" if full?})"
97
+ programs
98
+ verify_extensions
99
+ FileUtils.mkdir_p(@out)
100
+ results = @targets.map { |target| package(target) }
101
+ write_checksums(results) if @archive
102
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
103
+ @ui.say
104
+ @ui.say "#{@ui.paint("Done", :green, :bold)} in #{UI.duration(elapsed)}. " \
105
+ "#{programs.size == 1 ? "Launcher" : "Launchers"}: #{programs.map(&:name).join(", ")}"
106
+ results
107
+ end
108
+
109
+ # Checks that the app can be packaged for every target without building
110
+ # anything: its launchers resolve, and every native gem has an extension.
111
+ # Prints a report; returns whether all is well.
112
+ def check
113
+ @ui.title "Checking #{label} with Ruby #{release.ruby} (Traveling Ruby #{release}) for #{@targets.join(", ")}"
114
+ ok = true
115
+
116
+ @ui.say
117
+ @ui.say "Launchers"
118
+ begin
119
+ width = programs.map { |program| program.name.size }.max
120
+ programs.each { |program| @ui.done(program.name.ljust(width), program.to_s) }
121
+ rescue Error => e
122
+ @ui.failed e.message
123
+ ok = false
124
+ end
125
+
126
+ @ui.say
127
+ @ui.say "Gems"
128
+ if bundle.nil?
129
+ @ui.done "no Gemfile; the app uses only Ruby's own gems"
130
+ return ok
131
+ end
132
+ gems = @targets.flat_map { |target| bundle.gems(target) }.uniq(&:full_name)
133
+ @ui.done "#{gems.size} locked for the ruby platform"
134
+ report = extensions
135
+ if report.empty?
136
+ @ui.done "none have native extensions"
137
+ else
138
+ width = report.map { |extension| extension.gem.full_name.size }.max
139
+ report.each do |extension|
140
+ name = extension.gem.full_name.ljust(width)
141
+ if extension.ok?
142
+ where = [("built for #{targets_text(extension.built)}" if extension.built.any?),
143
+ ("comes with Ruby on #{targets_text(extension.runtime)}" if extension.runtime.any?)].compact
144
+ @ui.done name, where.join("; ")
145
+ else
146
+ ok = false
147
+ @ui.failed name, "no extension built for #{targets_text(extension.missing)}"
148
+ @ui.hint extension_hint(extension)
149
+ end
150
+ end
151
+ end
152
+ @ui.say
153
+ @ui.say(ok ? @ui.paint("Ready to package.", :green, :bold) : @ui.paint("Not ready to package.", :red, :bold))
154
+ ok
155
+ end
156
+
157
+ # Native gems' extensions across the targets
158
+ def extensions
159
+ return [] unless bundle
160
+ @extensions ||= begin
161
+ by_gem = {}
162
+ @targets.each do |target|
163
+ bundle.gems(target).each do |gem|
164
+ next if spec(gem).extensions.empty?
165
+ extension = by_gem[gem.full_name] ||= Extension.new(gem: gem, built: [], runtime: [], missing: [], versions: [])
166
+ if release.built_versions(target, gem.name).include?(gem.version)
167
+ extension.built << target
168
+ elsif release.runtime_gems(target, full: full?).include?(gem.full_name)
169
+ extension.runtime << target
170
+ else
171
+ extension.missing << target
172
+ extension.versions |= release.built_versions(target, gem.name)
173
+ end
174
+ end
175
+ end
176
+ by_gem.values.sort_by { |extension| extension.gem.name }
177
+ end
178
+ end
179
+
180
+ # The programs to make launchers for, from bins; or, with none, the gem's
181
+ # executables when packaging a gem, or bin/NAME or exe/NAME
182
+ def programs
183
+ @programs ||= begin
184
+ bins = @bins
185
+ bins = gem_executables(@gem_bins).to_h { |exe| [exe, nil] } if bins.empty? && @gem_bins
186
+ if bins.empty?
187
+ entry = default_entry or raise Error,
188
+ "nothing to launch: add bin/#{@name} or exe/#{@name}, or name the programs with --bin or bin: in truby.yml"
189
+ bins = {@name => entry}
190
+ end
191
+ bins.map { |name, script| program(name, script) }
192
+ end
193
+ end
194
+
195
+ def package_name(target)
196
+ [@name, @app_version, target].compact.join("-")
197
+ end
198
+
199
+ def package(target)
200
+ dir = File.join(@out, package_name(target))
201
+ lib = File.join(dir, "lib")
202
+ ruby_dir = File.join(lib, "ruby")
203
+ FileUtils.rm_rf(dir)
204
+ FileUtils.mkdir_p(ruby_dir)
205
+
206
+ untar(release.runtime(target, full: full?), ruby_dir)
207
+ copy_app(File.join(lib, "app"))
208
+ gems = bundle ? install_bundle(target, ruby_dir, lib) : []
209
+ gemfile = bundle && File.basename(bundle.gemfile)
210
+ programs.each do |program|
211
+ launcher = File.join(dir, Launcher.file_name(program.name, target))
212
+ File.write(launcher, Launcher.render(target, script: script_path(program, ruby_dir, lib), gemfile: gemfile,
213
+ ruby_opts: @ruby_opts))
214
+ File.chmod(0o755, launcher)
215
+ end
216
+
217
+ native = gems.count { |gem| !spec(gem).extensions.empty? }
218
+ summary = "#{gems.size} #{gems.size == 1 ? "gem" : "gems"}#{", #{native} native" if native.positive?}"
219
+ result = dir
220
+ if @archive
221
+ result = "#{dir}.tar.gz"
222
+ run!("tar", "-czf", result, "-C", @out, File.basename(dir))
223
+ end
224
+ @ui.done target.ljust(@targets.map(&:size).max), "#{summary.ljust(18)} #{display_path(result)}" \
225
+ "#{" #{UI.size(File.size(result))}" if @archive}"
226
+ result
227
+ end
228
+
229
+ private
230
+
231
+ def default_entry
232
+ %W[bin/#{@name} exe/#{@name} bin/#{File.basename(@app_dir)} exe/#{File.basename(@app_dir)}]
233
+ .find { |path| File.file?(File.join(@app_dir, path)) }
234
+ end
235
+
236
+ def program(name, script)
237
+ if script
238
+ raise Error, "#{name}: #{script} not found in #{@app_dir}" unless File.file?(File.join(@app_dir, script))
239
+ return Program.new(name: name, script: script)
240
+ end
241
+ script = %W[bin/#{name} exe/#{name} #{name}.rb #{name}].find { |path| File.file?(File.join(@app_dir, path)) }
242
+ return Program.new(name: name, script: script) if script
243
+ spec = locked_specs.find { |s| s.executables.include?(name) } or raise Error,
244
+ "#{name}: no bin/#{name}, exe/#{name} or #{name}.rb in the app, and no locked gem has a #{name} executable; " \
245
+ "give its script, as in --bin #{name}=path/to/script"
246
+ Program.new(name: name, script: name, spec: spec)
247
+ end
248
+
249
+ def gem_executables(gem_name)
250
+ spec = locked_specs.find { |s| s.name == gem_name } or raise Error, "#{gem_name} is not in the lockfile"
251
+ raise Error, "#{gem_name} has no executables; name the programs with --bin" if spec.executables.empty?
252
+ spec.executables
253
+ end
254
+
255
+ def locked_specs
256
+ return [] unless bundle
257
+ @targets.flat_map { |target| bundle.gems(target) }.uniq(&:full_name).map { |gem| spec(gem) }
258
+ end
259
+
260
+ # The script a launcher runs, relative to lib/
261
+ def script_path(program, ruby_dir, lib)
262
+ return "app/#{program.script}" unless program.gem?
263
+ spec = program.spec
264
+ path = File.join(gem_home(ruby_dir), "gems", spec.full_name, spec.bindir, program.name)
265
+ Pathname(path).relative_path_from(Pathname(lib)).to_s
266
+ end
267
+
268
+ def verify_extensions
269
+ missing = extensions.reject(&:ok?)
270
+ return if missing.empty?
271
+ missing.each do |extension|
272
+ @ui.send(@allow_missing_extensions ? :warn : :failed,
273
+ "#{extension.gem.full_name} has a native extension that #{release} does not build for " \
274
+ "#{targets_text(extension.missing)}")
275
+ @ui.hint extension_hint(extension)
276
+ end
277
+ return if @allow_missing_extensions
278
+ raise Error, "#{missing.size} native #{missing.size == 1 ? "gem has" : "gems have"} no extension for some " \
279
+ "targets; pin a built version, or pass --allow-missing-extensions to package without them"
280
+ end
281
+
282
+ def extension_hint(extension)
283
+ gem = extension.gem
284
+ if extension.versions.any?
285
+ latest = extension.versions.max_by { |version| Gem::Version.new(version) }
286
+ "#{release} builds #{gem.name} #{extension.versions.join(", ")}: pin it with gem \"#{gem.name}\", \"#{latest}\""
287
+ else
288
+ "#{release} does not build #{gem.name}; ask for it at https://github.com/trubygems/traveling-ruby/issues"
289
+ end
290
+ end
291
+
292
+ def targets_text(list)
293
+ list.sort == @targets.sort && list.size > 1 ? "all targets" : list.join(", ")
294
+ end
295
+
296
+ def display_path(path)
297
+ relative = Pathname(path).relative_path_from(Pathname(Dir.pwd)).to_s
298
+ relative.start_with?("..") ? path : relative
299
+ rescue ArgumentError
300
+ path
301
+ end
302
+
303
+ # Installs the target's gems; returns them
304
+ def install_bundle(target, ruby_dir, lib)
305
+ gem_home = gem_home(ruby_dir)
306
+ checksums = {}
307
+ installed = []
308
+ gems = bundle.gems(target)
309
+ gems.each do |gem|
310
+ path = gem_file(gem)
311
+ checksums[gem.full_name] = Digest::SHA256.file(path).hexdigest
312
+ if installed?(gem_home, gem.full_name)
313
+ @ui.detail "#{gem.full_name} (comes with Ruby)"
314
+ next
315
+ end
316
+ install_gem(target, gem_home, path, gem)
317
+ installed << gem
318
+ end
319
+ prune(gem_home, installed) if @prune
320
+
321
+ bundler = Dir.children(File.join(gem_home, "gems")).grep(/\Abundler-/).map { |dir| dir.delete_prefix("bundler-") }
322
+ .max_by { |version| Gem::Version.new(version) }
323
+ lockfile = bundle.packaged_lockfile(checksums: checksums, bundler_version: bundler)
324
+ File.write(File.join(lib, "app", File.basename(bundle.lockfile)), lockfile)
325
+ FileUtils.mkdir_p(File.join(lib, "bundle"))
326
+ File.write(File.join(lib, "bundle", "config"), <<~YAML)
327
+ ---
328
+ BUNDLE_FROZEN: "true"
329
+ BUNDLE_WITHOUT: "#{@without.join(":")}"
330
+ YAML
331
+ gems
332
+ end
333
+
334
+ # The runtime's gem home, lib/ruby/gems/<Ruby compat version>
335
+ def gem_home(ruby_dir)
336
+ compat_file = File.join(ruby_dir, "info", "RUBY_COMPAT_VERSION")
337
+ compat = File.read(compat_file).strip if File.exist?(compat_file)
338
+ compat ||= Dir.children(File.join(ruby_dir, "lib", "ruby", "gems")).grep(/\A\d+\.\d+\.\d+\z/).max
339
+ File.join(ruby_dir, "lib", "ruby", "gems", compat.to_s)
340
+ end
341
+
342
+ def installed?(gem_home, full_name)
343
+ File.exist?(File.join(gem_home, "specifications", "#{full_name}.gemspec")) ||
344
+ File.exist?(File.join(gem_home, "specifications", "default", "#{full_name}.gemspec"))
345
+ end
346
+
347
+ # The gem's ruby platform .gem file, downloaded once for every target
348
+ def gem_file(gem)
349
+ @gem_files[gem.full_name] ||= begin
350
+ remote = (gem.remotes.first || "https://rubygems.org/").chomp("/")
351
+ @fetcher.fetch("#{remote}/gems/#{gem.full_name}.gem", sha256: gem.checksum,
352
+ cache_key: File.join("gems", "#{gem.full_name}.gem"))
353
+ end
354
+ end
355
+
356
+ def spec(gem)
357
+ @specs[gem.full_name] ||= Gem::Package.new(gem_file(gem)).spec
358
+ end
359
+
360
+ def install_gem(target, gem_home, path, gem)
361
+ spec = spec(gem)
362
+ Gem::Package.new(path).extract_files(File.join(gem_home, "gems", spec.full_name))
363
+ FileUtils.mkdir_p(File.join(gem_home, "specifications"))
364
+ File.write(File.join(gem_home, "specifications", "#{spec.full_name}.gemspec"), spec.to_ruby_for_cache)
365
+ if spec.extensions.empty?
366
+ @ui.detail spec.full_name
367
+ elsif (extension = release.gem_extension(target, gem.name, gem.version))
368
+ untar(extension, File.dirname(gem_home))
369
+ @ui.detail "#{spec.full_name} (native extension from #{release.tag(target)})"
370
+ else
371
+ @ui.detail "#{spec.full_name} (no native extension)"
372
+ end
373
+ end
374
+
375
+ # Removes tests, docs and extension sources from the installed gems, except
376
+ # what keep names: a gem, or a path in one (pact_broker/public)
377
+ def prune(gem_home, gems)
378
+ gems.each do |gem|
379
+ next if @keep.include?(gem.name)
380
+ dir = File.join(gem_home, "gems", gem.full_name)
381
+ kept = @keep.filter_map { |path| path.delete_prefix("#{gem.name}/") if path.start_with?("#{gem.name}/") }
382
+ prunable = PRUNE_DIRS.reject { |name| kept.any? { |path| path == name || path.start_with?("#{name}/") } }
383
+ prunable.each { |name| FileUtils.rm_rf(File.join(dir, name)) }
384
+ next if kept.any? { |path| path == "ext" || path.start_with?("ext/") }
385
+ PRUNE_EXT_FILES.each { |pattern| FileUtils.rm_f(Dir.glob(File.join(dir, "ext", "**", pattern))) }
386
+ end
387
+ end
388
+
389
+ def copy_app(dest)
390
+ app_files.each do |file|
391
+ target = File.join(dest, file)
392
+ FileUtils.mkdir_p(File.dirname(target))
393
+ FileUtils.cp(File.join(@app_dir, file), target, preserve: true)
394
+ end
395
+ end
396
+
397
+ # Files in a git work tree that git does not ignore, otherwise every file;
398
+ # never the output directory or local bundle state
399
+ def app_files
400
+ files =
401
+ if system("git", "-C", @app_dir, "rev-parse", "--is-inside-work-tree", out: File::NULL, err: File::NULL)
402
+ IO.popen(["git", "-C", @app_dir, "ls-files", "-z", "--cached", "--others", "--exclude-standard"], &:read)
403
+ .split("\0")
404
+ else
405
+ Dir.glob("**/*", File::FNM_DOTMATCH, base: @app_dir)
406
+ end
407
+ excluded = %w[.git .bundle vendor/bundle]
408
+ out = @out.delete_prefix("#{@app_dir}/")
409
+ excluded << out unless out == @out
410
+ files.uniq.reject do |file|
411
+ excluded.any? { |dir| file == dir || file.start_with?("#{dir}/") } ||
412
+ !File.file?(File.join(@app_dir, file))
413
+ end
414
+ end
415
+
416
+ def untar(archive, dir)
417
+ run!("tar", "-xzf", archive, "-C", dir)
418
+ end
419
+
420
+ def write_checksums(archives)
421
+ lines = archives.map { |path| "#{Digest::SHA256.file(path).hexdigest} #{File.basename(path)}\n" }
422
+ sums = File.join(@out, "SHA256SUMS")
423
+ existing = File.exist?(sums) ? File.readlines(sums) : []
424
+ names = archives.map { |path| File.basename(path) }
425
+ kept = existing.reject { |line| names.include?(line.split(/ [ *]/, 2).last.to_s.strip) }
426
+ File.write(sums, (kept + lines).sort_by { |line| line.split(/ [ *]/, 2).last }.join)
427
+ end
428
+
429
+ def run!(*command)
430
+ system(*command) or raise Error, "#{command.join(" ")} failed"
431
+ end
432
+ end
433
+ end
@@ -0,0 +1,56 @@
1
+ require "rake"
2
+ require "rake/tasklib"
3
+ require "traveling_ruby"
4
+
5
+ module TravelingRuby
6
+ # Defines a task that packages the app for its targets, and a task per target:
7
+ #
8
+ # require "traveling_ruby/rake_task"
9
+ # TravelingRuby::RakeTask.new do |t|
10
+ # t.bins = {"hello" => "bin/hello"}
11
+ # t.ruby_opts = ["-EUTF-8"]
12
+ # end
13
+ #
14
+ # rake package # every target
15
+ # rake package:linux-arm64 # one target
16
+ # rake package:check # check the app is ready to package
17
+ #
18
+ # Settings left unset come from truby.yml, when the app has one.
19
+ class RakeTask < Rake::TaskLib
20
+ SETTINGS = %i[name entry bins targets ruby revision runtime source out app_version without ruby_opts keep
21
+ prune archive allow_missing_extensions cache_dir].freeze
22
+
23
+ attr_accessor(*SETTINGS, :app_dir, :verbose)
24
+
25
+ def initialize(task_name = :package)
26
+ super()
27
+ @task_name = task_name
28
+ @app_dir = Rake.application.original_dir
29
+ yield self if block_given?
30
+ define
31
+ end
32
+
33
+ private
34
+
35
+ def define
36
+ desc "Package the app with Traveling Ruby"
37
+ task(@task_name) { packager.run }
38
+
39
+ namespace @task_name do
40
+ desc "Check the app is ready to package with Traveling Ruby"
41
+ task(:check) { packager.check or abort }
42
+
43
+ TARGETS.each do |target|
44
+ desc "Package the app with Traveling Ruby for #{target}"
45
+ task(target) { packager(targets: [target]).run }
46
+ end
47
+ end
48
+ end
49
+
50
+ def packager(**overrides)
51
+ settings = SETTINGS.to_h { |setting| [setting, public_send(setting)] }.compact
52
+ Packager.new(app_dir: app_dir, ui: UI.new(verbose: verbose == true),
53
+ **Config.load(app_dir).merge(settings, overrides))
54
+ end
55
+ end
56
+ end