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.
- checksums.yaml +7 -0
- data/LICENSE.md +19 -0
- data/README.md +148 -0
- data/exe/traveling-ruby +5 -0
- data/exe/truby +5 -0
- data/lib/traveling_ruby/bundle.rb +171 -0
- data/lib/traveling_ruby/cli.rb +210 -0
- data/lib/traveling_ruby/config.rb +84 -0
- data/lib/traveling_ruby/fetcher.rb +103 -0
- data/lib/traveling_ruby/launcher.rb +65 -0
- data/lib/traveling_ruby/packager.rb +433 -0
- data/lib/traveling_ruby/rake_task.rb +56 -0
- data/lib/traveling_ruby/release.rb +136 -0
- data/lib/traveling_ruby/ui.rb +78 -0
- data/lib/traveling_ruby/version.rb +3 -0
- data/lib/traveling_ruby.rb +31 -0
- metadata +62 -0
|
@@ -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
|