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
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.
|
data/exe/traveling-ruby
ADDED
data/exe/truby
ADDED
|
@@ -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
|