rubox 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 +21 -0
- data/data/Dockerfile.ruby-build +119 -0
- data/data/ext/stub.c +489 -0
- data/data/ext/write-footer.c +38 -0
- data/data/prune-list.conf +40 -0
- data/data/scripts/_common.sh +17 -0
- data/data/scripts/build-ruby.sh +214 -0
- data/data/scripts/fix-dylibs.sh +93 -0
- data/data/scripts/package.sh +544 -0
- data/data/stubs/stub-aarch64-darwin +0 -0
- data/data/stubs/stub-aarch64-linux +0 -0
- data/data/stubs/stub-x86_64-linux +0 -0
- data/data/stubs/write-footer-aarch64-darwin +0 -0
- data/data/stubs/write-footer-aarch64-linux +0 -0
- data/data/stubs/write-footer-x86_64-linux +0 -0
- data/exe/rubox +3 -0
- data/lib/rubox/builder.rb +130 -0
- data/lib/rubox/cli.rb +214 -0
- data/lib/rubox/detector.rb +105 -0
- data/lib/rubox/packager.rb +118 -0
- data/lib/rubox/platform.rb +40 -0
- data/lib/rubox/version.rb +3 -0
- data/lib/rubox.rb +31 -0
- metadata +68 -0
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# Package a Ruby gem or Gemfile-based app into a single self-extracting binary.
|
|
4
|
+
#
|
|
5
|
+
# Modes:
|
|
6
|
+
# Gem mode: --gem NAME --entry BIN
|
|
7
|
+
# Gemfile mode: --gemfile PATH --entry BIN
|
|
8
|
+
#
|
|
9
|
+
# Usage:
|
|
10
|
+
# # Single gem
|
|
11
|
+
# ./scripts/package.sh --ruby-dir build/ruby-4.0.0-aarch64-darwin \
|
|
12
|
+
# --gem herb --output build/herb
|
|
13
|
+
#
|
|
14
|
+
# # Gemfile-based app
|
|
15
|
+
# ./scripts/package.sh --ruby-dir build/ruby-4.0.0-aarch64-darwin \
|
|
16
|
+
# --gemfile /path/to/Gemfile --entry my_app --output build/my_app
|
|
17
|
+
#
|
|
18
|
+
set -euo pipefail
|
|
19
|
+
|
|
20
|
+
RUBY_DIR=""
|
|
21
|
+
GEM_NAME=""
|
|
22
|
+
GEMFILE=""
|
|
23
|
+
ENTRY_BIN=""
|
|
24
|
+
OUTPUT=""
|
|
25
|
+
STUB=""
|
|
26
|
+
PRUNE_LEVEL="default" # none, default, aggressive
|
|
27
|
+
KEEP_GEMS=""
|
|
28
|
+
REMOVE_GEMS=""
|
|
29
|
+
FIX_DYLIBS="yes"
|
|
30
|
+
|
|
31
|
+
while [[ $# -gt 0 ]]; do
|
|
32
|
+
case "$1" in
|
|
33
|
+
--ruby-dir) RUBY_DIR="$2"; shift 2 ;;
|
|
34
|
+
--gem) GEM_NAME="$2"; shift 2 ;;
|
|
35
|
+
--gemfile) GEMFILE="$2"; shift 2 ;;
|
|
36
|
+
--entry) ENTRY_BIN="$2"; shift 2 ;;
|
|
37
|
+
--output) OUTPUT="$2"; shift 2 ;;
|
|
38
|
+
--stub) STUB="$2"; shift 2 ;;
|
|
39
|
+
--prune) PRUNE_LEVEL="$2"; shift 2 ;;
|
|
40
|
+
--keep-gems) KEEP_GEMS="$2"; shift 2 ;;
|
|
41
|
+
--remove-gems) REMOVE_GEMS="$2"; shift 2 ;;
|
|
42
|
+
--no-fix-dylibs) FIX_DYLIBS="no"; shift ;;
|
|
43
|
+
*) echo "Unknown option: $1"; exit 1 ;;
|
|
44
|
+
esac
|
|
45
|
+
done
|
|
46
|
+
|
|
47
|
+
if [[ -z "$RUBY_DIR" || -z "$OUTPUT" ]]; then
|
|
48
|
+
echo "Usage: $0 --ruby-dir DIR --output FILE (--gem NAME | --gemfile PATH) [--entry BIN]"
|
|
49
|
+
exit 1
|
|
50
|
+
fi
|
|
51
|
+
if [[ -z "$GEM_NAME" && -z "$GEMFILE" ]]; then
|
|
52
|
+
echo "ERROR: Must specify --gem or --gemfile"
|
|
53
|
+
exit 1
|
|
54
|
+
fi
|
|
55
|
+
|
|
56
|
+
source "$(dirname "$0")/_common.sh"
|
|
57
|
+
RUBY_DIR="$(cd "$RUBY_DIR" && pwd)"
|
|
58
|
+
|
|
59
|
+
# Defaults
|
|
60
|
+
ENTRY_BIN="${ENTRY_BIN:-$GEM_NAME}"
|
|
61
|
+
STUB="${STUB:-${PROJECT_DIR}/build/stub}"
|
|
62
|
+
RUBY="${RUBY_DIR}/bin/ruby"
|
|
63
|
+
GEM_CMD="${RUBY_DIR}/bin/gem"
|
|
64
|
+
|
|
65
|
+
STAGING_DIR=$(mktemp -d)
|
|
66
|
+
PAYLOAD_FILE=$(mktemp)
|
|
67
|
+
trap 'rm -rf "$STAGING_DIR" "$PAYLOAD_FILE"' EXIT
|
|
68
|
+
|
|
69
|
+
MODE="gem"
|
|
70
|
+
[[ -n "$GEMFILE" ]] && MODE="gemfile"
|
|
71
|
+
|
|
72
|
+
echo "==> Packaging (mode: ${MODE}, entry: ${ENTRY_BIN})"
|
|
73
|
+
echo " Ruby: ${RUBY_DIR}"
|
|
74
|
+
|
|
75
|
+
# ===================================================================
|
|
76
|
+
# Stage 1: Copy Ruby
|
|
77
|
+
# ===================================================================
|
|
78
|
+
echo "==> Copying Ruby..."
|
|
79
|
+
mkdir -p "${STAGING_DIR}/bin"
|
|
80
|
+
cp "${RUBY_DIR}/bin/ruby" "${STAGING_DIR}/bin/ruby"
|
|
81
|
+
chmod +x "${STAGING_DIR}/bin/ruby"
|
|
82
|
+
|
|
83
|
+
mkdir -p "${STAGING_DIR}/lib"
|
|
84
|
+
cp -a "${RUBY_DIR}/lib/ruby" "${STAGING_DIR}/lib/ruby"
|
|
85
|
+
|
|
86
|
+
# Copy top-level shared libs (bundled musl loader, libgcc_s, etc.)
|
|
87
|
+
for f in "${RUBY_DIR}"/lib/*.so* "${RUBY_DIR}"/lib/ld-*; do
|
|
88
|
+
[[ -f "$f" ]] && cp -a "$f" "${STAGING_DIR}/lib/"
|
|
89
|
+
done
|
|
90
|
+
|
|
91
|
+
# ===================================================================
|
|
92
|
+
# Stage 2: Install gems (gem mode) or bundle (gemfile mode)
|
|
93
|
+
# ===================================================================
|
|
94
|
+
if [[ "$MODE" == "gemfile" ]]; then
|
|
95
|
+
echo "==> Installing gems via Bundler (standalone mode)..."
|
|
96
|
+
GEMFILE_ABS="$(cd "$(dirname "$GEMFILE")" && pwd)/$(basename "$GEMFILE")"
|
|
97
|
+
GEMFILE_DIR="$(dirname "$GEMFILE_ABS")"
|
|
98
|
+
|
|
99
|
+
# Ensure bundler is available
|
|
100
|
+
if [[ ! -f "${RUBY_DIR}/bin/bundle" ]]; then
|
|
101
|
+
"${GEM_CMD}" install bundler --no-document 2>&1 | tail -1
|
|
102
|
+
fi
|
|
103
|
+
|
|
104
|
+
BUNDLE="${RUBY_DIR}/bin/bundle"
|
|
105
|
+
BUNDLE_DIR="${STAGING_DIR}/bundle"
|
|
106
|
+
mkdir -p "$BUNDLE_DIR"
|
|
107
|
+
|
|
108
|
+
# Run bundle install --standalone
|
|
109
|
+
# This creates a self-contained bundle with a bundler/setup.rb
|
|
110
|
+
cd "$GEMFILE_DIR"
|
|
111
|
+
BUNDLE_PATH="$BUNDLE_DIR" \
|
|
112
|
+
BUNDLE_GEMFILE="$GEMFILE_ABS" \
|
|
113
|
+
BUNDLE_WITHOUT="development:test" \
|
|
114
|
+
BUNDLE_STANDALONE=true \
|
|
115
|
+
"$BUNDLE" install --standalone --jobs 4 2>&1 | sed 's/^/ /'
|
|
116
|
+
cd "$PROJECT_DIR"
|
|
117
|
+
|
|
118
|
+
echo " Checking standalone bundle..."
|
|
119
|
+
SETUP_RB=$(find "$BUNDLE_DIR" -name "setup.rb" -path "*/bundler/*" | head -1)
|
|
120
|
+
if [[ -n "$SETUP_RB" ]]; then
|
|
121
|
+
echo " OK: standalone setup.rb found"
|
|
122
|
+
# Bundler's standalone setup.rb has correct per-gem require_paths but
|
|
123
|
+
# uses absolute build-time paths with Ruby interpolations like:
|
|
124
|
+
# File.expand_path("#{__dir__}/../../../<tmp>/bundle/#{RUBY_ENGINE}/...")
|
|
125
|
+
# Rewrite to use RUBOX_ROOT, preserving the Ruby interpolations
|
|
126
|
+
# for RUBY_ENGINE and Gem.ruby_api_version.
|
|
127
|
+
echo " Rewriting load paths to use runtime root..."
|
|
128
|
+
|
|
129
|
+
# Find the bundle directory name inside staging (could be "bundle" or "vendor")
|
|
130
|
+
BUNDLE_SUBDIR=$(basename "$BUNDLE_DIR")
|
|
131
|
+
|
|
132
|
+
# Use Ruby to rewrite the setup.rb -- handles the path parsing cleanly
|
|
133
|
+
"${RUBY}" -e '
|
|
134
|
+
root_marker = ARGV[0] # e.g. "/tmp/.../bundle"
|
|
135
|
+
setup = File.read(ARGV[1])
|
|
136
|
+
lines = setup.lines
|
|
137
|
+
|
|
138
|
+
out = []
|
|
139
|
+
out << %(_root = ENV["RUBOX_ROOT"])
|
|
140
|
+
out << %(_bundle = File.join(_root, "#{ARGV[2]}"))
|
|
141
|
+
out << ""
|
|
142
|
+
|
|
143
|
+
lines.each do |line|
|
|
144
|
+
if line.start_with?("$:.unshift")
|
|
145
|
+
# Replace the File.expand_path("#{__dir__}/../../..<abs_path>/bundle/...")
|
|
146
|
+
# with File.join(_bundle, "...")
|
|
147
|
+
# The path after the bundle dir looks like: #{RUBY_ENGINE}/#{Gem...}/gems/foo/lib
|
|
148
|
+
if line.include?(root_marker)
|
|
149
|
+
suffix = line.split(root_marker, 2).last
|
|
150
|
+
# Clean up: remove leading /, trailing quote/paren/newline
|
|
151
|
+
suffix = suffix.sub(/^\//, "").sub(/[")\s]+$/, "")
|
|
152
|
+
out << %($:.unshift File.join(_bundle, "#{suffix}"))
|
|
153
|
+
end
|
|
154
|
+
elsif !line.include?("$:.unshift")
|
|
155
|
+
out << line.rstrip
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
File.write(ARGV[1], out.join("\n") + "\n")
|
|
160
|
+
' "$BUNDLE_DIR" "$SETUP_RB" "$BUNDLE_SUBDIR" 2>&1 | sed 's/^/ /'
|
|
161
|
+
else
|
|
162
|
+
echo " WARNING: standalone setup.rb not found, gems may not load correctly"
|
|
163
|
+
fi
|
|
164
|
+
|
|
165
|
+
# Copy the app's bin/ directory and any source files
|
|
166
|
+
echo " Copying application files..."
|
|
167
|
+
mkdir -p "${STAGING_DIR}/app"
|
|
168
|
+
if [[ -d "${GEMFILE_DIR}/bin" ]]; then
|
|
169
|
+
cp -a "${GEMFILE_DIR}/bin" "${STAGING_DIR}/app/bin"
|
|
170
|
+
fi
|
|
171
|
+
if [[ -d "${GEMFILE_DIR}/lib" ]]; then
|
|
172
|
+
cp -a "${GEMFILE_DIR}/lib" "${STAGING_DIR}/app/lib"
|
|
173
|
+
fi
|
|
174
|
+
# Copy the entry script itself if it lives outside bin/
|
|
175
|
+
if [[ -f "${GEMFILE_DIR}/${ENTRY_BIN}" ]]; then
|
|
176
|
+
cp "${GEMFILE_DIR}/${ENTRY_BIN}" "${STAGING_DIR}/app/"
|
|
177
|
+
fi
|
|
178
|
+
else
|
|
179
|
+
echo "==> Gem '${GEM_NAME}' should already be installed in Ruby dir"
|
|
180
|
+
# Check if gem exists by looking for its directory (works for cross-platform builds)
|
|
181
|
+
GEM_FOUND=$(find "${RUBY_DIR}/lib/ruby/gems" -maxdepth 3 -type d -name "${GEM_NAME}-*" | head -1)
|
|
182
|
+
if [[ -z "$GEM_FOUND" ]]; then
|
|
183
|
+
# Try running gem install (only works for native platform)
|
|
184
|
+
if "${RUBY}" --version >/dev/null 2>&1; then
|
|
185
|
+
echo " Not found, installing..."
|
|
186
|
+
"${GEM_CMD}" install "${GEM_NAME}" --no-document 2>&1 | sed 's/^/ /'
|
|
187
|
+
else
|
|
188
|
+
echo "ERROR: gem '${GEM_NAME}' not found in ${RUBY_DIR} and cannot run gem install (cross-platform build)."
|
|
189
|
+
echo " Install the gem first inside Docker using the target Ruby."
|
|
190
|
+
exit 1
|
|
191
|
+
fi
|
|
192
|
+
fi
|
|
193
|
+
echo " OK: ${GEM_NAME} found"
|
|
194
|
+
fi
|
|
195
|
+
|
|
196
|
+
# ===================================================================
|
|
197
|
+
# Stage 3: Prune
|
|
198
|
+
# ===================================================================
|
|
199
|
+
echo "==> Pruning (level: ${PRUNE_LEVEL})..."
|
|
200
|
+
|
|
201
|
+
# Always: remove gem cache, build artifacts, docs
|
|
202
|
+
rm -rf "${STAGING_DIR}/lib/ruby/gems"/*/cache
|
|
203
|
+
find "${STAGING_DIR}" -name "*.o" -delete 2>/dev/null || true
|
|
204
|
+
find "${STAGING_DIR}" \( -name "doc" -o -name "ri" -o -name ".rdoc" \) -type d | xargs rm -rf 2>/dev/null || true
|
|
205
|
+
# Remove include/ dir (C headers, never needed at runtime)
|
|
206
|
+
rm -rf "${STAGING_DIR}"/lib/ruby/gems/*/extensions/*/include 2>/dev/null || true
|
|
207
|
+
|
|
208
|
+
if [[ "$PRUNE_LEVEL" != "none" ]]; then
|
|
209
|
+
# Remove test/spec dirs from gems
|
|
210
|
+
find "${STAGING_DIR}/lib/ruby/gems" \
|
|
211
|
+
\( -name "test" -o -name "spec" -o -name "tests" -o -name "specs" \) \
|
|
212
|
+
-type d | xargs rm -rf 2>/dev/null || true
|
|
213
|
+
|
|
214
|
+
# Remove sig/ directories (RBS type signatures)
|
|
215
|
+
find "${STAGING_DIR}/lib/ruby/gems" -name "sig" -type d | xargs rm -rf 2>/dev/null || true
|
|
216
|
+
|
|
217
|
+
# Remove C source from installed gems
|
|
218
|
+
find "${STAGING_DIR}/lib/ruby/gems" \( -name "*.c" -o -name "*.h" \) -type f -delete 2>/dev/null || true
|
|
219
|
+
find "${STAGING_DIR}/lib/ruby/gems" -name "Makefile" -type f -delete 2>/dev/null || true
|
|
220
|
+
|
|
221
|
+
# Remove .bundle files for Ruby versions we don't need
|
|
222
|
+
# Get Ruby version - try running ruby, fall back to directory inspection
|
|
223
|
+
if "${RUBY}" --version >/dev/null 2>&1; then
|
|
224
|
+
RUBY_MAJOR_MINOR=$("${RUBY}" -e 'puts RUBY_VERSION.split(".")[0..1].join(".")')
|
|
225
|
+
else
|
|
226
|
+
# Cross-platform: infer from the gems directory name
|
|
227
|
+
RUBY_MAJOR_MINOR=$(ls -1 "${RUBY_DIR}/lib/ruby/gems/" 2>/dev/null | head -1 | sed 's/\.[0-9]*$//')
|
|
228
|
+
fi
|
|
229
|
+
echo " Keeping native extensions for Ruby ${RUBY_MAJOR_MINOR} only"
|
|
230
|
+
# Find versioned .bundle dirs like herb/3.3/, herb/3.4/, etc.
|
|
231
|
+
find "${STAGING_DIR}/lib/ruby/gems" -type d -regex '.*/[0-9]\.[0-9]$' | while read -r ver_dir; do
|
|
232
|
+
ver=$(basename "$ver_dir")
|
|
233
|
+
if [[ "$ver" != "$RUBY_MAJOR_MINOR" ]]; then
|
|
234
|
+
rm -rf "$ver_dir"
|
|
235
|
+
fi
|
|
236
|
+
done
|
|
237
|
+
|
|
238
|
+
# Apply prune list
|
|
239
|
+
PRUNE_LIST="${DATA_DIR}/prune-list.conf"
|
|
240
|
+
if [[ -f "$PRUNE_LIST" ]]; then
|
|
241
|
+
while IFS= read -r gem_pattern; do
|
|
242
|
+
# Skip comments and empty lines
|
|
243
|
+
[[ "$gem_pattern" =~ ^#.*$ || -z "$gem_pattern" ]] && continue
|
|
244
|
+
gem_pattern=$(echo "$gem_pattern" | tr -d '[:space:]')
|
|
245
|
+
|
|
246
|
+
# Check if this gem should be kept
|
|
247
|
+
if [[ -n "$KEEP_GEMS" ]] && echo "$KEEP_GEMS" | grep -qw "$gem_pattern"; then
|
|
248
|
+
continue
|
|
249
|
+
fi
|
|
250
|
+
|
|
251
|
+
# Remove from gems directory
|
|
252
|
+
find "${STAGING_DIR}/lib/ruby/gems" -maxdepth 3 -type d -name "${gem_pattern}-*" | while read -r d; do
|
|
253
|
+
echo " Pruning gem: $(basename "$d")"
|
|
254
|
+
rm -rf "$d"
|
|
255
|
+
done
|
|
256
|
+
|
|
257
|
+
# Remove from specifications
|
|
258
|
+
find "${STAGING_DIR}/lib/ruby/gems" -name "${gem_pattern}-*.gemspec" -delete 2>/dev/null || true
|
|
259
|
+
|
|
260
|
+
# Remove from extensions
|
|
261
|
+
find "${STAGING_DIR}/lib/ruby/gems" -path "*/extensions/*/${gem_pattern}-*" -type d | xargs rm -rf 2>/dev/null || true
|
|
262
|
+
|
|
263
|
+
# Remove from stdlib (lib/ruby/X.Y.Z/)
|
|
264
|
+
for stdlib_dir in "${STAGING_DIR}"/lib/ruby/[0-9]*/; do
|
|
265
|
+
rm -rf "${stdlib_dir}/${gem_pattern}" 2>/dev/null || true
|
|
266
|
+
rm -f "${stdlib_dir}/${gem_pattern}.rb" 2>/dev/null || true
|
|
267
|
+
done
|
|
268
|
+
done < "$PRUNE_LIST"
|
|
269
|
+
fi
|
|
270
|
+
|
|
271
|
+
# Remove extra gems specified by user
|
|
272
|
+
if [[ -n "$REMOVE_GEMS" ]]; then
|
|
273
|
+
IFS=',' read -ra EXTRA_REMOVE <<< "$REMOVE_GEMS"
|
|
274
|
+
for gem_name in "${EXTRA_REMOVE[@]}"; do
|
|
275
|
+
gem_name=$(echo "$gem_name" | tr -d '[:space:]')
|
|
276
|
+
find "${STAGING_DIR}/lib/ruby/gems" -maxdepth 3 -type d -name "${gem_name}-*" | while read -r d; do
|
|
277
|
+
echo " Pruning extra: $(basename "$d")"
|
|
278
|
+
rm -rf "$d"
|
|
279
|
+
done
|
|
280
|
+
done
|
|
281
|
+
fi
|
|
282
|
+
|
|
283
|
+
# Remove fiddle (links to Homebrew libffi, rarely needed)
|
|
284
|
+
if [[ -z "$KEEP_GEMS" ]] || ! echo "$KEEP_GEMS" | grep -qw "fiddle"; then
|
|
285
|
+
find "${STAGING_DIR}" -path "*fiddle*" -delete 2>/dev/null || true
|
|
286
|
+
fi
|
|
287
|
+
fi
|
|
288
|
+
|
|
289
|
+
STAGED_SIZE=$(du -sm "${STAGING_DIR}" | cut -f1)
|
|
290
|
+
echo " Staged size after pruning: ${STAGED_SIZE}MB"
|
|
291
|
+
|
|
292
|
+
# ===================================================================
|
|
293
|
+
# Stage 4: Fix dylib references (macOS)
|
|
294
|
+
# ===================================================================
|
|
295
|
+
# Determine binary format for platform-specific steps
|
|
296
|
+
BINARY_FORMAT_CHECK=$(file "${STAGING_DIR}/bin/ruby" | grep -o 'ELF' || true)
|
|
297
|
+
|
|
298
|
+
if [[ "$FIX_DYLIBS" == "yes" && "$BINARY_FORMAT_CHECK" != "ELF" && "$(uname -s)" == "Darwin" ]]; then
|
|
299
|
+
echo "==> Fixing dylib references (macOS)..."
|
|
300
|
+
"${DATA_DIR}/scripts/fix-dylibs.sh" "${STAGING_DIR}"
|
|
301
|
+
fi
|
|
302
|
+
|
|
303
|
+
# For Linux: verify the bundled loader and runtime libs are present.
|
|
304
|
+
# These are baked into the Ruby installation by the Dockerfile at build time.
|
|
305
|
+
if [[ "$BINARY_FORMAT_CHECK" == "ELF" ]]; then
|
|
306
|
+
LOADER=$(find "${STAGING_DIR}/lib" -name "ld-musl-*" -type f 2>/dev/null | head -1)
|
|
307
|
+
if [[ -n "$LOADER" ]]; then
|
|
308
|
+
chmod +x "$LOADER"
|
|
309
|
+
echo " Bundled musl loader: $(basename "$LOADER")"
|
|
310
|
+
# List all bundled .so files
|
|
311
|
+
find "${STAGING_DIR}/lib" -maxdepth 1 -name "*.so*" -type f | while read -r f; do
|
|
312
|
+
echo " Bundled lib: $(basename "$f")"
|
|
313
|
+
done
|
|
314
|
+
else
|
|
315
|
+
echo " WARNING: No bundled musl loader found in lib/"
|
|
316
|
+
echo " The binary may not work on non-musl Linux systems."
|
|
317
|
+
echo " Rebuild Ruby with the Dockerfile to bundle the loader."
|
|
318
|
+
fi
|
|
319
|
+
fi
|
|
320
|
+
|
|
321
|
+
# ===================================================================
|
|
322
|
+
# Stage 5: Verify portability
|
|
323
|
+
# ===================================================================
|
|
324
|
+
echo "==> Verifying portability..."
|
|
325
|
+
|
|
326
|
+
# Determine binary format
|
|
327
|
+
BINARY_FORMAT=$(file "${STAGING_DIR}/bin/ruby" | grep -o 'ELF\|Mach-O')
|
|
328
|
+
|
|
329
|
+
if [[ "$BINARY_FORMAT" == "ELF" ]]; then
|
|
330
|
+
# Linux: check with ldd (if available) or file
|
|
331
|
+
if command -v ldd &>/dev/null && ldd "${STAGING_DIR}/bin/ruby" 2>&1 | grep -q "statically linked"; then
|
|
332
|
+
echo " OK: ruby is statically linked"
|
|
333
|
+
elif file "${STAGING_DIR}/bin/ruby" | grep -q "statically linked"; then
|
|
334
|
+
echo " OK: ruby is statically linked"
|
|
335
|
+
else
|
|
336
|
+
echo " INFO: cannot verify static linking (cross-platform build)"
|
|
337
|
+
fi
|
|
338
|
+
# Check .so files for non-system deps
|
|
339
|
+
find "${STAGING_DIR}" -name "*.so" -type f | while read -r f; do
|
|
340
|
+
if command -v readelf &>/dev/null; then
|
|
341
|
+
NEEDED=$(readelf -d "$f" 2>/dev/null | grep NEEDED | grep -v 'libc\.\|libm\.\|libdl\.\|libpthread\.\|librt\.\|ld-linux' || true)
|
|
342
|
+
if [[ -n "$NEEDED" ]]; then
|
|
343
|
+
echo " WARN: ${f#${STAGING_DIR}/} has deps: $NEEDED"
|
|
344
|
+
fi
|
|
345
|
+
fi
|
|
346
|
+
done
|
|
347
|
+
elif [[ "$BINARY_FORMAT" == "Mach-O" ]]; then
|
|
348
|
+
# macOS: check with otool
|
|
349
|
+
PORTABLE=true
|
|
350
|
+
NON_SYS=$(otool -L "${STAGING_DIR}/bin/ruby" 2>/dev/null | tail -n +2 | grep -v '/usr/lib/' | grep -v '/System/' || true)
|
|
351
|
+
if [[ -n "$NON_SYS" ]]; then
|
|
352
|
+
echo " FAIL: bin/ruby has non-portable deps:"
|
|
353
|
+
echo "$NON_SYS" | sed 's/^/ /'
|
|
354
|
+
PORTABLE=false
|
|
355
|
+
fi
|
|
356
|
+
|
|
357
|
+
find "${STAGING_DIR}" -type f \( -name "*.bundle" -o -name "*.dylib" \) ! -path "*/DWARF/*" | while read -r f; do
|
|
358
|
+
NON_SYS=$(otool -L "$f" 2>/dev/null | tail -n +2 | grep -v '/usr/lib/' | grep -v '/System/' | grep -v '@loader_path' || true)
|
|
359
|
+
if [[ -n "$NON_SYS" ]]; then
|
|
360
|
+
echo " FAIL: ${f#${STAGING_DIR}/}"
|
|
361
|
+
echo "$NON_SYS" | sed 's/^/ /'
|
|
362
|
+
PORTABLE=false
|
|
363
|
+
fi
|
|
364
|
+
done
|
|
365
|
+
|
|
366
|
+
if [[ "$PORTABLE" == "false" ]]; then
|
|
367
|
+
echo ""
|
|
368
|
+
echo "WARNING: Non-portable dependencies detected."
|
|
369
|
+
echo ""
|
|
370
|
+
fi
|
|
371
|
+
fi
|
|
372
|
+
|
|
373
|
+
# ===================================================================
|
|
374
|
+
# Stage 6: Create entry script
|
|
375
|
+
# ===================================================================
|
|
376
|
+
echo "==> Creating entry script..."
|
|
377
|
+
|
|
378
|
+
GEM_VERSION_DIR=$(ls -1 "${STAGING_DIR}/lib/ruby/gems/" 2>/dev/null | head -1)
|
|
379
|
+
|
|
380
|
+
if [[ "$MODE" == "gemfile" ]]; then
|
|
381
|
+
# Gemfile mode: use standalone bundle + app's own scripts
|
|
382
|
+
SETUP_RB_REL=$(find "${STAGING_DIR}/bundle" -name "setup.rb" -path "*/bundler/*" 2>/dev/null | head -1)
|
|
383
|
+
SETUP_RB_REL="${SETUP_RB_REL#${STAGING_DIR}/}"
|
|
384
|
+
|
|
385
|
+
# Find the entry script in the app
|
|
386
|
+
ENTRY_PATH=""
|
|
387
|
+
for candidate in "app/bin/${ENTRY_BIN}" "app/${ENTRY_BIN}"; do
|
|
388
|
+
if [[ -f "${STAGING_DIR}/${candidate}" ]]; then
|
|
389
|
+
ENTRY_PATH="${candidate}"
|
|
390
|
+
break
|
|
391
|
+
fi
|
|
392
|
+
done
|
|
393
|
+
if [[ -z "$ENTRY_PATH" ]]; then
|
|
394
|
+
echo "ERROR: entry '${ENTRY_BIN}' not found in app/bin/ or app/"
|
|
395
|
+
exit 1
|
|
396
|
+
fi
|
|
397
|
+
echo " Entry point: ${ENTRY_PATH}"
|
|
398
|
+
|
|
399
|
+
cat > "${STAGING_DIR}/entry.rb" << EOF
|
|
400
|
+
# rubox entry (gemfile mode)
|
|
401
|
+
root = ENV["RUBOX_ROOT"]
|
|
402
|
+
|
|
403
|
+
# Cleanup handler for no-cache mode
|
|
404
|
+
if cleanup_dir = ENV["RUBOX_CLEANUP"]
|
|
405
|
+
at_exit { require "fileutils"; FileUtils.rm_rf(cleanup_dir) rescue nil }
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# Load standalone bundle (sets up \$LOAD_PATH for all gems)
|
|
409
|
+
require File.join(root, "${SETUP_RB_REL}")
|
|
410
|
+
|
|
411
|
+
# Add app's lib/ to load path
|
|
412
|
+
app_lib = File.join(root, "app", "lib")
|
|
413
|
+
\$LOAD_PATH.unshift(app_lib) if File.directory?(app_lib)
|
|
414
|
+
|
|
415
|
+
# Run the app's entry script
|
|
416
|
+
load File.join(root, "${ENTRY_PATH}")
|
|
417
|
+
EOF
|
|
418
|
+
else
|
|
419
|
+
# Gem mode: set up load paths manually to avoid rubygems dependency.
|
|
420
|
+
# Static Ruby builds may not have rubygems loaded by default.
|
|
421
|
+
cat > "${STAGING_DIR}/entry.rb" << 'ENTRY_EOF'
|
|
422
|
+
# rubox entry (gem mode)
|
|
423
|
+
root = ENV["RUBOX_ROOT"]
|
|
424
|
+
|
|
425
|
+
# Cleanup handler for no-cache mode
|
|
426
|
+
if cleanup_dir = ENV["RUBOX_CLEANUP"]
|
|
427
|
+
at_exit do
|
|
428
|
+
require "fileutils"
|
|
429
|
+
FileUtils.rm_rf(cleanup_dir) rescue nil
|
|
430
|
+
end
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
ENTRY_EOF
|
|
434
|
+
|
|
435
|
+
# Build $LOAD_PATH entries from the actual gem directories
|
|
436
|
+
cat >> "${STAGING_DIR}/entry.rb" << EOF
|
|
437
|
+
# Add Ruby stdlib to load path
|
|
438
|
+
ruby_lib = File.join(root, "lib", "ruby", "${GEM_VERSION_DIR}")
|
|
439
|
+
\$LOAD_PATH.unshift(ruby_lib)
|
|
440
|
+
|
|
441
|
+
# Add arch-specific stdlib
|
|
442
|
+
Dir.glob(File.join(ruby_lib, "*-*")).each do |arch_dir|
|
|
443
|
+
\$LOAD_PATH.unshift(arch_dir) if File.directory?(arch_dir)
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
# Add gem lib directories to load path
|
|
447
|
+
gem_base = File.join(root, "lib", "ruby", "gems", "${GEM_VERSION_DIR}")
|
|
448
|
+
Dir.glob(File.join(gem_base, "gems", "*", "lib")).each do |lib_dir|
|
|
449
|
+
\$LOAD_PATH.unshift(lib_dir)
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
# Add native extension directories
|
|
453
|
+
Dir.glob(File.join(gem_base, "extensions", "**", "*.{bundle,so}")).each do |ext|
|
|
454
|
+
ext_dir = File.dirname(ext)
|
|
455
|
+
\$LOAD_PATH.unshift(ext_dir) unless \$LOAD_PATH.include?(ext_dir)
|
|
456
|
+
end
|
|
457
|
+
EOF
|
|
458
|
+
|
|
459
|
+
# Invoke the gem's CLI. Two strategies:
|
|
460
|
+
#
|
|
461
|
+
# 1. If rubygems is available (macOS builds), use Gem.bin_path which
|
|
462
|
+
# handles all edge cases (complex exe scripts, require_relative, etc.)
|
|
463
|
+
#
|
|
464
|
+
# 2. If rubygems is not loaded (static Linux builds), fall back to
|
|
465
|
+
# require + grep approach for the CLI invocation.
|
|
466
|
+
#
|
|
467
|
+
cat >> "${STAGING_DIR}/entry.rb" << EOF
|
|
468
|
+
|
|
469
|
+
# Try rubygems first (handles complex exe scripts correctly)
|
|
470
|
+
begin
|
|
471
|
+
require "rubygems"
|
|
472
|
+
ENV["GEM_HOME"] = gem_base
|
|
473
|
+
ENV["GEM_PATH"] = gem_base
|
|
474
|
+
Gem.clear_paths
|
|
475
|
+
gem "${GEM_NAME}"
|
|
476
|
+
load Gem.bin_path("${GEM_NAME}", "${ENTRY_BIN}")
|
|
477
|
+
rescue LoadError, NoMethodError
|
|
478
|
+
# Rubygems not available (static build) -- manual require + CLI
|
|
479
|
+
require "${GEM_NAME}"
|
|
480
|
+
EOF
|
|
481
|
+
|
|
482
|
+
# Find the gem's exe script to extract the CLI invocation for the fallback path
|
|
483
|
+
GEM_EXE=$(find "${RUBY_DIR}/lib/ruby/gems" -path "*/gems/${GEM_NAME}-*/exe/${ENTRY_BIN}" -type f | head -1)
|
|
484
|
+
if [[ -z "$GEM_EXE" ]]; then
|
|
485
|
+
GEM_EXE=$(find "${RUBY_DIR}/lib/ruby/gems" -path "*/gems/${GEM_NAME}-*/bin/${ENTRY_BIN}" -type f | head -1)
|
|
486
|
+
fi
|
|
487
|
+
|
|
488
|
+
if [[ -n "$GEM_EXE" ]]; then
|
|
489
|
+
# Extract just the CLI invocation (last non-empty, non-comment, non-boilerplate lines)
|
|
490
|
+
echo " # Fallback CLI invocation from $(basename "$GEM_EXE")" >> "${STAGING_DIR}/entry.rb"
|
|
491
|
+
grep -v '^#' "$GEM_EXE" | \
|
|
492
|
+
grep -v 'frozen_string_literal' | \
|
|
493
|
+
grep -v 'require_relative' | \
|
|
494
|
+
grep -v 'require\b' | \
|
|
495
|
+
grep -v '^\s*$' | \
|
|
496
|
+
sed 's/^/ /' >> "${STAGING_DIR}/entry.rb"
|
|
497
|
+
fi
|
|
498
|
+
|
|
499
|
+
echo "end" >> "${STAGING_DIR}/entry.rb"
|
|
500
|
+
fi
|
|
501
|
+
|
|
502
|
+
echo " Entry script created (mode: ${MODE})"
|
|
503
|
+
|
|
504
|
+
# ===================================================================
|
|
505
|
+
# Stage 7: Compress and assemble
|
|
506
|
+
# ===================================================================
|
|
507
|
+
echo "==> Compressing payload..."
|
|
508
|
+
cd "${STAGING_DIR}"
|
|
509
|
+
|
|
510
|
+
# Use gzip -- universally available on all target systems (no zstd dependency).
|
|
511
|
+
# --no-mac-metadata avoids ._* resource fork files that confuse GNU tar on Linux.
|
|
512
|
+
tar czf "${PAYLOAD_FILE}" --no-mac-metadata . 2>/dev/null || \
|
|
513
|
+
tar czf "${PAYLOAD_FILE}" .
|
|
514
|
+
cd "${PROJECT_DIR}"
|
|
515
|
+
|
|
516
|
+
PAYLOAD_SIZE=$(stat -f%z "${PAYLOAD_FILE}" 2>/dev/null || stat -c%s "${PAYLOAD_FILE}" 2>/dev/null)
|
|
517
|
+
echo " Payload: $(echo "scale=1; ${PAYLOAD_SIZE}/1048576" | bc)MB"
|
|
518
|
+
|
|
519
|
+
echo "==> Assembling binary..."
|
|
520
|
+
|
|
521
|
+
if [[ ! -f "$STUB" ]]; then
|
|
522
|
+
echo "ERROR: Stub not found at ${STUB}. Run: make stub"
|
|
523
|
+
exit 1
|
|
524
|
+
fi
|
|
525
|
+
|
|
526
|
+
cp "${STUB}" "${OUTPUT}"
|
|
527
|
+
STUB_SIZE=$(stat -f%z "${OUTPUT}" 2>/dev/null || stat -c%s "${OUTPUT}" 2>/dev/null)
|
|
528
|
+
|
|
529
|
+
cat "${PAYLOAD_FILE}" >> "${OUTPUT}"
|
|
530
|
+
|
|
531
|
+
WRITE_FOOTER="${PROJECT_DIR}/build/write-footer"
|
|
532
|
+
if [[ ! -f "$WRITE_FOOTER" ]]; then
|
|
533
|
+
echo "ERROR: write-footer not found. Run: make stub"
|
|
534
|
+
exit 1
|
|
535
|
+
fi
|
|
536
|
+
"$WRITE_FOOTER" "${OUTPUT}" "${STUB_SIZE}" "${PAYLOAD_SIZE}"
|
|
537
|
+
|
|
538
|
+
chmod +x "${OUTPUT}"
|
|
539
|
+
|
|
540
|
+
FINAL_SIZE=$(stat -f%z "${OUTPUT}" 2>/dev/null || stat -c%s "${OUTPUT}" 2>/dev/null)
|
|
541
|
+
echo ""
|
|
542
|
+
echo "==> Done!"
|
|
543
|
+
echo " Binary: ${OUTPUT} ($(echo "scale=1; ${FINAL_SIZE}/1048576" | bc)MB)"
|
|
544
|
+
echo " Run: ./${OUTPUT}"
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
data/exe/rubox
ADDED