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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ad2eee4e23f9daaad4c1aeb441f57b85abbbfe1562323028e04e17881a3fb6bd
4
+ data.tar.gz: b1bf5034edee04138c17f0baa72d394218259d83ce8acdc9bf9b79b81f8cb898
5
+ SHA512:
6
+ metadata.gz: c30cd182e8717d7e896ce2924f7e6d3cda1f1dea73973695559be1d25e7881af8a4471642bebabaff660393c2e377fbaa454f9ab462750c787d166cc685555ed
7
+ data.tar.gz: 7941f806e53aef207c3e6bce3d98abf083430579281ea52f3c13d01e6212afd83e2e76cbeb2aa59fbdabf4a4a5b63e34d83476d1768597d0e1fd049313a5abeb
data/LICENSE.md ADDED
@@ -0,0 +1,19 @@
1
+ Copyright (c) 2014-2025
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in
11
+ all copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,148 @@
1
+ # truby
2
+
3
+ **truby** packages Ruby apps with [Traveling Ruby](../README.md), a portable Ruby, so people can run them on Linux, macOS and Windows without installing Ruby. It ships in the `traveling-ruby` gem.
4
+
5
+ ```console
6
+ $ gem install traveling-ruby
7
+ $ truby package
8
+ Packaging hello 1.0.0 with Ruby 3.4.12 (Traveling Ruby 3.4.12-1)
9
+ ✓ linux-x86_64 12 gems, 2 native pkg/hello-1.0.0-linux-x86_64.tar.gz 14.1 MB
10
+ ✓ linux-arm64 12 gems, 2 native pkg/hello-1.0.0-linux-arm64.tar.gz 13.8 MB
11
+ ✓ macos-x86_64 12 gems, 2 native pkg/hello-1.0.0-macos-x86_64.tar.gz 14.6 MB
12
+ ✓ macos-arm64 12 gems, 2 native pkg/hello-1.0.0-macos-arm64.tar.gz 14.4 MB
13
+ ✓ windows-x86_64 12 gems, 2 native pkg/hello-1.0.0-windows-x86_64.tar.gz 20.2 MB
14
+
15
+ Done in 9.8s. Launcher: hello
16
+ ```
17
+
18
+ Each package holds a launcher per program, the app, and a Ruby with the app's locked gems installed:
19
+
20
+ ```
21
+ hello-1.0.0-linux-x86_64/
22
+ hello launcher (hello.bat on Windows)
23
+ lib/app/ the app
24
+ lib/ruby/ Ruby and the app's gems
25
+ lib/bundle/config Bundler settings for the app
26
+ ```
27
+
28
+ Nothing is compiled. Gems are installed from their `ruby` platform `.gem` files, and native gems get the extensions Traveling Ruby built for the release, so you can package for every platform from any machine. `traveling-ruby` also works as the command's name.
29
+
30
+ ## Get started
31
+
32
+ ```sh
33
+ bundle lock --add-platform ruby # lock gems truby can install for every target
34
+ truby init # write truby.yml
35
+ truby check # see that every gem works on every target
36
+ truby package
37
+ ```
38
+
39
+ `truby check` builds nothing. It shows each launcher and each native gem, and says what to do about a gem with no extension built for a target:
40
+
41
+ ```
42
+ Gems
43
+ ✓ 65 locked for the ruby platform
44
+ ✓ json-3.1.0 built for all targets
45
+ ✗ prism-2.1.0 no extension built for all targets
46
+ → 3.4.12-1 builds prism 1.9.0: pin it with gem "prism", "1.9.0"
47
+
48
+ Not ready to package.
49
+ ```
50
+
51
+ `truby package` stops on the same problem, since a gem without its extension fails when the app loads it. Pin a version the release builds, or pass `--allow-missing-extensions` to package without it.
52
+
53
+ ## truby.yml
54
+
55
+ The app's packaging settings live in `truby.yml` (or `.truby.yml`), and command line options win over it:
56
+
57
+ ```yaml
58
+ name: pact
59
+ version: 2.5.0
60
+ ruby: 3.4.12
61
+ targets: [linux-x86_64, linux-arm64, macos-arm64, windows-x86_64]
62
+ bin:
63
+ pact-broker: pact-broker.rb # a script in the app
64
+ pact-mock-service: # bin/, exe/, pact-mock-service.rb, or a gem's executable
65
+ ruby_opts: [-EUTF-8]
66
+ keep: [pact_broker/public] # gem files that pruning would remove
67
+ ```
68
+
69
+ | Setting | Option | Default |
70
+ | --- | --- | --- |
71
+ | `name` | `--name` | the app directory's name |
72
+ | `version` | `--app-version` | none; when set it goes in package names |
73
+ | `bin` | `--bin NAME[=SCRIPT]` | `bin/NAME` or `exe/NAME` |
74
+ | `targets` | `--targets` | `linux-x86_64, linux-arm64, macos-x86_64, macos-arm64, windows-x86_64` (`truby targets` lists them all) |
75
+ | `ruby` | `--ruby` | the latest release's Ruby |
76
+ | `revision` | `--revision` | the newest release of that Ruby |
77
+ | `runtime` | `--full` | `slim`; `full` adds Ruby's headers and static libraries |
78
+ | `without` | `--without` | `development, test` |
79
+ | `ruby_opts` | `--ruby-opt` | none; passed to `ruby` by each launcher |
80
+ | `keep` | `--keep` | none; a gem, or a path in one, that pruning keeps |
81
+ | `prune` | `--[no-]prune` | on: removes gems' tests, docs and extension sources |
82
+ | `archive` | `--[no-]archive` | on: a `.tar.gz` per package, and `SHA256SUMS` |
83
+ | `allow_missing_extensions` | `--allow-missing-extensions` | off |
84
+ | `source` | `--source` | `https://github.com/trubygems/traveling-ruby`, or a directory of release assets |
85
+ | `out` | `--out` | `pkg` |
86
+
87
+ A `bin` entry with no script runs the first of `bin/NAME`, `exe/NAME`, `NAME.rb` and `NAME` in the app, or else the `NAME` executable of a locked gem, so an app can ship `rackup` or `puma` without a script of its own.
88
+
89
+ Downloads are verified against each release's `SHA256SUMS` and the lockfile's `CHECKSUMS`, and cached in `~/.cache/traveling-ruby`. Pass `-v` to list each gem as it is installed.
90
+
91
+ ## Package a gem
92
+
93
+ `truby gem` packages a gem from rubygems.org with a launcher for each of its executables:
94
+
95
+ ```console
96
+ $ truby gem rubocop --targets macos-arm64,linux-x86_64
97
+ $ ./pkg/rubocop-1.92.2-macos-arm64/rubocop --version
98
+ 1.92.2
99
+ ```
100
+
101
+ Give a version as `rubocop:1.92.2`, and pick executables with `--bin`.
102
+
103
+ ## Rake task
104
+
105
+ ```ruby
106
+ # Rakefile
107
+ require "traveling_ruby/rake_task"
108
+
109
+ TravelingRuby::RakeTask.new do |t|
110
+ t.app_version = "1.0.0"
111
+ end
112
+ ```
113
+
114
+ `rake package` builds every target, `rake package:linux-arm64` builds one and `rake package:check` checks the app. The task reads `truby.yml`, and settings given to it win over the file.
115
+
116
+ ## How a launcher runs the app
117
+
118
+ A launcher runs `lib/ruby/bin/ruby -rbundler/setup` with the program's script, with `BUNDLE_GEMFILE` pointing at the app's Gemfile and any `BUNDLE_PATH` from the user's environment cleared. It follows symlinks, to itself or to a directory above it, so you can link it onto the `PATH`.
119
+
120
+ The packaged lockfile locks only the `ruby` platform, with the checksums of the gems installed, so Bundler finds exactly what is in the package.
121
+
122
+ Gems from git sources are not supported yet. Gems from path sources must be inside the app, and ship with it.
123
+
124
+ ## YJIT
125
+
126
+ `ruby_opts: [--yjit]` turns YJIT on in the launchers. Linux and macOS runtimes are built with YJIT from 3.4.12-1 and 4.0.9-1. Windows runtimes are not yet, so there Ruby prints `warning: Ruby was built without YJIT support` and runs without it.
127
+
128
+ ## Development
129
+
130
+ ```sh
131
+ cd packager
132
+ bundle install
133
+ bundle exec rake test
134
+ bundle exec rake test:end_to_end # package the fixture apps with a real release and run them
135
+ ```
136
+
137
+ The end-to-end tests package real-world apps too: [the Pact CLI](test/fixtures/pact-cli), as pact-standalone ships it, and [a Pact Broker](test/fixtures/pact_broker) on SQLite or PostgreSQL under Puma, which the test publishes a pact to.
138
+
139
+ ## Releasing
140
+
141
+ The gem is published by the [Packager workflow](../.github/workflows/packager.yml) with trusted publishing, so no rubygems.org API key is stored. Bump `TravelingRuby::VERSION`, merge, then tag the merge:
142
+
143
+ ```sh
144
+ git tag gem-v0.1.0
145
+ git push origin gem-v0.1.0
146
+ ```
147
+
148
+ The workflow tests the gem, checks the tag matches its version, and pushes it from the `rubygems` environment. It also publishes a `gem-v0.1.0` GitHub release with the gem, its SHA-256 and a build provenance attestation. The release is never marked latest, since `cli.sh` installs the latest release as a Ruby runtime.
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+ require_relative "../lib/traveling_ruby"
3
+ require "traveling_ruby/cli"
4
+
5
+ exit TravelingRuby::CLI.new.run(ARGV)
data/exe/truby ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+ require_relative "../lib/traveling_ruby"
3
+ require "traveling_ruby/cli"
4
+
5
+ exit TravelingRuby::CLI.new.run(ARGV)
@@ -0,0 +1,171 @@
1
+ require "bundler"
2
+
3
+ module TravelingRuby
4
+ # An app's Gemfile and lockfile: which locked gems a target needs, and the
5
+ # lockfile to ship, which locks the ruby platform variant of every gem so it
6
+ # matches what the package installs.
7
+ class Bundle
8
+ GEMFILES = %w[Gemfile gems.rb].freeze
9
+
10
+ LockedGem = Struct.new(:name, :version, :remotes, :checksum, keyword_init: true) do
11
+ def full_name
12
+ "#{name}-#{version}"
13
+ end
14
+ end
15
+
16
+ attr_reader :gemfile, :lockfile
17
+
18
+ def self.find(app_dir, **options)
19
+ gemfile = GEMFILES.map { |file| File.join(app_dir, file) }.find { |path| File.file?(path) }
20
+ gemfile && new(gemfile, **options)
21
+ end
22
+
23
+ def initialize(gemfile, without: %w[development test])
24
+ @gemfile = File.expand_path(gemfile)
25
+ @lockfile = @gemfile.end_with?(".rb") ? @gemfile.sub(/\.rb\z/, ".locked") : "#{@gemfile}.lock"
26
+ raise Error, "#{@lockfile} not found; run bundle lock first" unless File.file?(@lockfile)
27
+ @without = without.map(&:to_sym)
28
+ @lock_text = File.read(@lockfile)
29
+ @lock = Bundler::LockfileParser.new(@lock_text)
30
+ @dependencies = evaluate_gemfile
31
+ end
32
+
33
+ def app_dir
34
+ File.dirname(@gemfile)
35
+ end
36
+
37
+ # The locked gems to install for target, outside the groups left out.
38
+ # Gems from path sources inside the app ship with it and are not included.
39
+ def gems(target)
40
+ by_name = @lock.specs.group_by(&:name)
41
+ queue = @dependencies
42
+ .select { |dep| (dep.groups - @without).any? && platform?(dep.platforms, target) }
43
+ .map(&:name)
44
+ selected = {}
45
+ while (name = queue.shift)
46
+ next if selected.key?(name)
47
+ variants = by_name[name] or next # bundler itself is not locked as a spec
48
+ spec = variants.find { |s| s.platform.to_s == "ruby" } or raise Error,
49
+ "#{name} is locked only for #{variants.map { |s| s.platform.to_s }.uniq.join(", ")}; " \
50
+ "add the ruby platform with: bundle lock --add-platform ruby"
51
+ selected[name] = spec
52
+ queue.concat(spec.dependencies.map(&:name))
53
+ end
54
+ selected.values.filter_map { |spec| locked_gem(spec) }.sort_by(&:name)
55
+ end
56
+
57
+ # The lockfile with only ruby platform specs, the given gem checksums and
58
+ # bundler_version as the Bundler it was bundled with
59
+ def packaged_lockfile(checksums:, bundler_version:)
60
+ out = []
61
+ section = nil
62
+ seen = {}
63
+ skipping = false
64
+ @lock_text.each_line do |line|
65
+ if line.match?(/\A\S/)
66
+ section = line.strip
67
+ out << line
68
+ out << " ruby\n" if section == "PLATFORMS"
69
+ next
70
+ end
71
+
72
+ case section
73
+ when "PLATFORMS"
74
+ out << line if line.strip.empty?
75
+ when "BUNDLED WITH"
76
+ out << (bundler_version ? line.sub(/\S+/, bundler_version) : line)
77
+ when "CHECKSUMS"
78
+ name, version = parse_spec_line(line)
79
+ next out << line unless name
80
+ key = "#{name}-#{version}"
81
+ next if seen.key?([section, key])
82
+ seen[[section, key]] = true
83
+ if (sha256 = checksums[key])
84
+ out << " #{name} (#{version}) sha256=#{sha256}\n"
85
+ elsif line.include?("(#{version})")
86
+ out << line
87
+ else
88
+ out << " #{name} (#{version})\n"
89
+ end
90
+ when "GEM", "PATH", "GIT"
91
+ if line.start_with?(" ") && !line.start_with?(" ")
92
+ name, version = parse_spec_line(line)
93
+ skipping = seen.key?([section, name, version])
94
+ next if skipping
95
+ seen[[section, name, version]] = true
96
+ out << " #{name} (#{version})\n"
97
+ elsif line.start_with?(" ")
98
+ out << line unless skipping
99
+ else
100
+ skipping = false
101
+ out << line
102
+ end
103
+ else
104
+ out << line
105
+ end
106
+ end
107
+ out.join
108
+ end
109
+
110
+ private
111
+
112
+ def evaluate_gemfile
113
+ previous = ENV["BUNDLE_GEMFILE"]
114
+ ENV["BUNDLE_GEMFILE"] = @gemfile
115
+ Dir.chdir(app_dir) { Bundler::Dsl.new.tap { |dsl| dsl.eval_gemfile(@gemfile) }.dependencies }
116
+ ensure
117
+ ENV["BUNDLE_GEMFILE"] = previous
118
+ end
119
+
120
+ # Whether a dependency limited to platforms (Bundler's names, such as :ruby,
121
+ # :mri, :windows) is used on target
122
+ def platform?(platforms, target)
123
+ return true if platforms.empty?
124
+ platforms.any? do |platform|
125
+ case platform.to_s
126
+ when /\A(mri|ruby)(_\d+)?\z/ then platform.to_s.start_with?("mri") || !TravelingRuby.windows?(target)
127
+ when /\A(windows|mswin|mswin64|mingw|x64_mingw)(_\d+)?\z/ then TravelingRuby.windows?(target)
128
+ else false
129
+ end
130
+ end
131
+ end
132
+
133
+ def locked_gem(spec)
134
+ case (source = spec.source)
135
+ when Bundler::Source::Git
136
+ raise Error, "#{spec.name} comes from git (#{source}); git gems are not supported yet"
137
+ when Bundler::Source::Path
138
+ path = File.expand_path(source.path.to_s, app_dir)
139
+ return nil if path == app_dir || path.start_with?("#{app_dir}/")
140
+ raise Error, "#{spec.name} comes from #{path}, outside the app; path gems must be inside the app"
141
+ when Bundler::Source::Rubygems
142
+ version = spec.version.to_s
143
+ LockedGem.new(
144
+ name: spec.name,
145
+ version: version,
146
+ remotes: source.remotes.map(&:to_s),
147
+ checksum: locked_checksums["#{spec.name}-#{version}"]
148
+ )
149
+ else
150
+ raise Error, "#{spec.name} comes from an unsupported source (#{source})"
151
+ end
152
+ end
153
+
154
+ # sha256 checksums of ruby platform gems, from the lockfile's CHECKSUMS
155
+ def locked_checksums
156
+ @locked_checksums ||= begin
157
+ body = @lock_text[/^CHECKSUMS\n((?: .*\n)*)/, 1].to_s
158
+ body.each_line.filter_map do |line|
159
+ match = line.match(/\A\s+(\S+) \(([^)-]+)\) .*sha256=(\h{64})/) or next
160
+ ["#{match[1]}-#{match[2]}", match[3]]
161
+ end.to_h
162
+ end
163
+ end
164
+
165
+ # [name, version] from " name (version[-platform])", or nil
166
+ def parse_spec_line(line)
167
+ match = line.match(/\A\s+(\S+) \(([^)]+)\)/) or return nil
168
+ [match[1], match[2].split("-", 2).first]
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,210 @@
1
+ require "optparse"
2
+ require "bundler"
3
+ require "open3"
4
+ require "tmpdir"
5
+
6
+ module TravelingRuby
7
+ # truby (also installed as traveling-ruby): the command line
8
+ class CLI
9
+ PROGRAM = "truby".freeze
10
+
11
+ # Raised once --help has printed a command's help
12
+ class HelpShown < StandardError; end
13
+
14
+ def initialize(out: $stdout, err: $stderr)
15
+ @out = out
16
+ @err = err
17
+ @ui = UI.new(err)
18
+ end
19
+
20
+ def run(argv)
21
+ argv = argv.dup
22
+ case (command = argv.shift)
23
+ when "package" then package(argv)
24
+ when "check" then check(argv)
25
+ when "gem" then package_gem(argv)
26
+ when "init" then init(argv)
27
+ when "targets" then @out.puts(TARGETS) || 0
28
+ when "version", "--version" then @out.puts("#{PROGRAM} #{VERSION}") || 0
29
+ when nil, "help", "--help", "-h" then @out.puts(usage) || 0
30
+ else
31
+ @err.puts "#{PROGRAM}: unknown command #{command.inspect}\n\n#{usage}"
32
+ 1
33
+ end
34
+ rescue HelpShown
35
+ 0
36
+ rescue Error, OptionParser::ParseError => e
37
+ @err.puts "#{@ui.paint("#{PROGRAM}:", :red, :bold)} #{e.message}"
38
+ 1
39
+ rescue Interrupt
40
+ @err.puts
41
+ 130
42
+ end
43
+
44
+ private
45
+
46
+ def usage
47
+ <<~USAGE
48
+ #{PROGRAM} packages Ruby apps with Traveling Ruby, a portable Ruby, so they run
49
+ on Linux, macOS and Windows without Ruby installed.
50
+
51
+ Usage: #{PROGRAM} <command> [options]
52
+
53
+ Commands:
54
+ package [APP_DIR] Package an app for each target
55
+ check [APP_DIR] Check an app is ready to package, without building it
56
+ gem NAME[:VERSION] Package a gem's executables, such as rubocop
57
+ init [APP_DIR] Write a truby.yml with the app's settings
58
+ targets List the targets
59
+ version Show the version
60
+
61
+ Settings come from truby.yml in the app, and options win over it.
62
+ Run #{PROGRAM} <command> --help for a command's options.
63
+ USAGE
64
+ end
65
+
66
+ def package(argv)
67
+ options = {}
68
+ args = parser(options, "package [options] [APP_DIR]",
69
+ "Packages the app in APP_DIR (default .) for each target, with its Gemfile.lock's gems.").parse(argv)
70
+ app_dir = app_dir(args)
71
+ print_paths(packager(app_dir, options).run)
72
+ 0
73
+ end
74
+
75
+ def check(argv)
76
+ options = {}
77
+ args = parser(options, "check [options] [APP_DIR]",
78
+ "Checks the app in APP_DIR (default .) is ready to package: its launchers resolve, its gems " \
79
+ "are locked for the ruby platform and every native gem has an extension built for each target.")
80
+ .parse(argv)
81
+ packager(app_dir(args), options).check ? 0 : 1
82
+ end
83
+
84
+ def package_gem(argv)
85
+ options = {}
86
+ args = parser(options, "gem [options] NAME[:VERSION]",
87
+ "Packages a gem from rubygems.org with a launcher for each of its executables " \
88
+ "(or the ones given with --bin), into ./pkg.", app: false).parse(argv)
89
+ raise OptionParser::InvalidArgument, "give one gem, as in #{PROGRAM} gem rubocop:1.70.0" unless args.size == 1
90
+ gem_name, version = args.first.split(/[:@]/, 2)
91
+
92
+ Dir.mktmpdir("truby-gem-") do |dir|
93
+ File.write(File.join(dir, "Gemfile"), <<~RUBY)
94
+ source "https://rubygems.org"
95
+ gem #{gem_name.inspect}#{", #{version.inspect}" if version}
96
+ RUBY
97
+ @ui.say @ui.paint(" Resolving #{gem_name}#{" #{version}" if version}", :dim)
98
+ output, status = Bundler.with_unbundled_env do
99
+ Open3.capture2e("bundle", "lock", "--add-platform", "ruby", chdir: dir)
100
+ end
101
+ raise Error, "could not resolve #{args.first}:\n#{output}" unless status.success?
102
+ locked = Bundler::LockfileParser.new(File.read(File.join(dir, "Gemfile.lock"))).specs
103
+ .find { |spec| spec.name == gem_name }
104
+ options[:out] = File.expand_path(options[:out] || "pkg")
105
+ options[:name] ||= gem_name
106
+ packager = Packager.new(app_dir: dir, app_version: locked.version.to_s, gem_bins: gem_name, ui: @ui, **options)
107
+ print_paths(packager.run)
108
+ end
109
+ 0
110
+ end
111
+
112
+ def init(argv)
113
+ force = false
114
+ args = OptionParser.new do |o|
115
+ o.banner = "Usage: #{PROGRAM} init [options] [APP_DIR]"
116
+ o.separator ""
117
+ o.separator "Writes APP_DIR/truby.yml (default ./truby.yml) with the app's name and programs."
118
+ o.separator ""
119
+ o.on("-f", "--force", "Overwrite an existing truby.yml") { force = true }
120
+ o.on("-h", "--help", "Show this help") do
121
+ @out.puts o
122
+ raise HelpShown
123
+ end
124
+ end.parse(argv)
125
+ app_dir = File.expand_path(app_dir(args))
126
+ path = File.join(app_dir, "truby.yml")
127
+ existing = Config.find(app_dir)
128
+ raise Error, "#{existing} already exists; pass --force to overwrite it" if existing && !force
129
+
130
+ name = File.basename(app_dir)
131
+ bins = (Dir.glob("{bin,exe}/*", base: app_dir) - %w[bin/setup bin/console bin/bundle bin/rake bin/rails])
132
+ .select { |file| File.file?(File.join(app_dir, file)) }
133
+ .sort
134
+ version_file = File.join(app_dir, "VERSION")
135
+ lines = ["# Packaging settings for #{PROGRAM} (gem install traveling-ruby)", "name: #{name}"]
136
+ lines << "version: #{File.read(version_file).strip}" if File.file?(version_file)
137
+ lines << "targets: [#{DEFAULT_TARGETS.join(", ")}]"
138
+ if bins.empty?
139
+ lines << "# bin: # a launcher per program: name: script in the app"
140
+ lines << "# #{name}: #{name}.rb"
141
+ else
142
+ lines << "bin:"
143
+ bins.each { |file| lines << " #{File.basename(file)}: #{file}" }
144
+ end
145
+ lines << "# ruby: 3.4.12 # default: the latest release's Ruby"
146
+ lines << "# ruby_opts: [-EUTF-8]"
147
+ lines << "# keep: [some_gem/docs] # gem files that pruning would remove"
148
+ File.write(path, lines.join("\n") + "\n")
149
+ @ui.done "wrote #{path}"
150
+ @ui.hint "next: #{PROGRAM} check, then #{PROGRAM} package"
151
+ 0
152
+ end
153
+
154
+ # Package paths for scripts, when stdout is piped; a terminal already has
155
+ # them in the report
156
+ def print_paths(paths)
157
+ paths.each { |path| @out.puts path } unless @out.respond_to?(:tty?) && @out.tty?
158
+ end
159
+
160
+ def app_dir(args)
161
+ raise OptionParser::InvalidArgument, "one app directory at most" if args.size > 1
162
+ args.first || "."
163
+ end
164
+
165
+ # The app's truby.yml, with the command line's options over it
166
+ def packager(app_dir, options)
167
+ settings = Config.load(app_dir).merge(options)
168
+ Packager.new(app_dir: app_dir, ui: @ui, **settings)
169
+ end
170
+
171
+ def parser(options, banner, description, app: true)
172
+ OptionParser.new do |o|
173
+ o.banner = "Usage: #{PROGRAM} #{banner}"
174
+ o.separator ""
175
+ o.separator description
176
+ o.separator ""
177
+ if app
178
+ o.on("--name NAME", "Package name (default: the app directory's name)") { |v| options[:name] = v }
179
+ o.on("--app-version VERSION", "Version to put in package names") { |v| options[:app_version] = v }
180
+ end
181
+ o.on("--bin NAME[=SCRIPT]", "A launcher: NAME runs SCRIPT in the app, or else bin/NAME, exe/NAME,",
182
+ "NAME.rb or a gem's NAME executable (repeatable; default: bin/NAME or exe/NAME)") do |v|
183
+ name, script = Config.parse_bin(v)
184
+ (options[:bins] ||= {})[name] = script
185
+ end
186
+ o.on("--targets LIST", Array, "Comma-separated targets (default: #{DEFAULT_TARGETS.join(",")})") { |v| options[:targets] = v }
187
+ o.on("--ruby VERSION", "Ruby version (default: the latest release's)") { |v| options[:ruby] = v }
188
+ o.on("--revision N", "Release revision (default: the newest for the Ruby version)") { |v| options[:revision] = v }
189
+ o.on("--full", "Use the full runtime, with Ruby's headers and static libraries") { options[:runtime] = "full" }
190
+ o.on("--out DIR", "Output directory (default: pkg)") { |v| options[:out] = v }
191
+ o.on("--without GROUPS", Array, "Gem groups to leave out (default: development,test)") { |v| options[:without] = v }
192
+ o.on("--ruby-opt OPT", "Option for ruby in the launchers, e.g. -EUTF-8 (repeatable)") { |v| (options[:ruby_opts] ||= []) << v }
193
+ o.on("--keep GEM[/PATH]", "Keep a gem's files that pruning would remove (repeatable)") { |v| (options[:keep] ||= []) << v }
194
+ o.on("--allow-missing-extensions", "Package native gems with no extension built, rather than fail") do
195
+ options[:allow_missing_extensions] = true
196
+ end
197
+ o.on("--[no-]prune", "Remove gems' tests, docs and extension sources (default: on)") { |v| options[:prune] = v }
198
+ o.on("--[no-]archive", "Make a .tar.gz of each package and SHA256SUMS (default: on)") { |v| options[:archive] = v }
199
+ o.on("--source URL_OR_DIR", "GitHub repository or directory of releases (default: #{Release::DEFAULT_SOURCE})") { |v| options[:source] = v }
200
+ o.on("--cache DIR", "Download cache (default: #{Fetcher.default_cache_dir})") { |v| options[:cache_dir] = v }
201
+ o.on("--entry PATH", "Same as --bin NAME=PATH, for the package's name") { |v| options[:entry] = v } if app
202
+ o.on("-v", "--verbose", "List each gem as it is installed") { @ui = UI.new(@err, verbose: true) }
203
+ o.on("-h", "--help", "Show this help") do
204
+ @out.puts o
205
+ raise HelpShown
206
+ end
207
+ end
208
+ end
209
+ end
210
+ end
@@ -0,0 +1,84 @@
1
+ require "yaml"
2
+
3
+ module TravelingRuby
4
+ # An app's packaging settings, from truby.yml (or .truby.yml) at its root:
5
+ #
6
+ # name: pact
7
+ # version: 2.5.0
8
+ # ruby: 3.4.12
9
+ # targets: [linux-x86_64, linux-arm64, macos-arm64, windows-x86_64]
10
+ # bin:
11
+ # pact-broker: pact-broker.rb # a script in the app
12
+ # rackup: # the app's bin/rackup, or a gem's rackup
13
+ # ruby_opts: [-EUTF-8]
14
+ # keep: [pact_broker/public] # gem files that pruning would remove
15
+ #
16
+ # Settings given on the command line or to the Rake task win over the file.
17
+ module Config
18
+ FILES = %w[truby.yml .truby.yml].freeze
19
+
20
+ # Setting in the file => Packager option
21
+ KEYS = {
22
+ "name" => :name,
23
+ "version" => :app_version,
24
+ "entry" => :entry,
25
+ "bin" => :bins,
26
+ "bins" => :bins,
27
+ "targets" => :targets,
28
+ "ruby" => :ruby,
29
+ "revision" => :revision,
30
+ "runtime" => :runtime,
31
+ "source" => :source,
32
+ "out" => :out,
33
+ "without" => :without,
34
+ "ruby_opts" => :ruby_opts,
35
+ "keep" => :keep,
36
+ "prune" => :prune,
37
+ "archive" => :archive,
38
+ "allow_missing_extensions" => :allow_missing_extensions
39
+ }.freeze
40
+
41
+ module_function
42
+
43
+ def find(app_dir)
44
+ FILES.map { |file| File.join(app_dir, file) }.find { |path| File.file?(path) }
45
+ end
46
+
47
+ # Packager options from the app's config file; {} when it has none
48
+ def load(app_dir)
49
+ path = find(app_dir) or return {}
50
+ data = YAML.safe_load_file(path) || {}
51
+ raise Error, "#{path} must hold a mapping of settings" unless data.is_a?(Hash)
52
+ data.each_with_object({}) do |(key, value), options|
53
+ option = KEYS[key.to_s] or raise Error, "#{File.basename(path)}: unknown setting #{key.inspect} (known: #{KEYS.keys.uniq.join(", ")})"
54
+ options[option] = normalize(option, value)
55
+ end
56
+ end
57
+
58
+ def normalize(option, value)
59
+ case option
60
+ when :bins then bins(value)
61
+ when :targets, :without, :ruby_opts, :keep then value.is_a?(String) ? value.split(/[\s,]+/) : Array(value).map(&:to_s)
62
+ when :prune, :archive, :allow_missing_extensions then value == true
63
+ else value&.to_s
64
+ end
65
+ end
66
+
67
+ # bin: as a mapping of launcher => script, or a list of launcher names
68
+ def bins(value)
69
+ case value
70
+ when Hash then value.to_h { |name, script| [name.to_s, script&.to_s] }
71
+ when Array then value.to_h { |name| [name.to_s, nil] }
72
+ when String then {value => nil}
73
+ else raise Error, "bin: must be a mapping of launcher names to scripts"
74
+ end
75
+ end
76
+
77
+ # NAME or NAME=SCRIPT, as --bin takes
78
+ def parse_bin(arg)
79
+ name, script = arg.split("=", 2)
80
+ raise Error, "--bin needs a name, as in --bin NAME or --bin NAME=SCRIPT" if name.to_s.empty?
81
+ [name, script.to_s.empty? ? nil : script]
82
+ end
83
+ end
84
+ end