Skip to content

Commit e5b32a1

Browse files
authored
chore(ruby): Add native gem workflow for Ruby wrapper (#589)
chore(ruby): Add native gem workflow for Ruby wrapper
1 parent b542646 commit e5b32a1

File tree

5 files changed

+275
-18
lines changed

5 files changed

+275
-18
lines changed
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
name: Build and Publish Native Gem
2+
3+
on:
4+
workflow_dispatch:
5+
6+
jobs:
7+
build:
8+
strategy:
9+
matrix:
10+
os: [ubuntu-latest, macos-13, windows-latest]
11+
include:
12+
# Config for Linux
13+
- os: ubuntu-latest
14+
platform_tasks: "compile:aarch64-linux compile:x86_64-linux"
15+
artifact_name: "linux-binaries"
16+
artifact_path: "spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/*-linux/"
17+
18+
# Config for macOS (Apple Silicon + Intel)
19+
# Note: macos-13 is an Intel runner (x86_64) but can cross-compile to Apple Silicon (aarch64)
20+
- os: macos-13
21+
platform_tasks: "compile:aarch64-darwin compile:x86_64-darwin"
22+
artifact_name: "darwin-binaries"
23+
artifact_path: "spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/*-darwin/"
24+
25+
# Config for Windows
26+
- os: windows-latest
27+
platform_tasks: "compile:x64-mingw32"
28+
artifact_name: "windows-binaries"
29+
artifact_path: "spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/x64-mingw32/"
30+
31+
runs-on: ${{ matrix.os }}
32+
33+
steps:
34+
- name: Checkout code
35+
uses: actions/checkout@v4
36+
37+
- name: Set up Go
38+
uses: actions/setup-go@v5
39+
with:
40+
go-version: 1.25.x
41+
42+
- name: Set up Ruby
43+
uses: ruby/setup-ruby@v1
44+
with:
45+
ruby-version: '3.3'
46+
bundler-cache: true
47+
working-directory: 'spannerlib/wrappers/spannerlib-ruby'
48+
49+
- name: Install cross-compilers (Linux)
50+
if: matrix.os == 'ubuntu-latest'
51+
run: |
52+
sudo apt-get update
53+
# This installs the C compiler for aarch64-linux
54+
sudo apt-get install -y gcc-aarch64-linux-gnu
55+
56+
# The cross-compiler step for macOS is removed,
57+
# as the built-in Clang on macOS runners can handle both Intel and ARM.
58+
59+
- name: Compile Binaries
60+
working-directory: spannerlib/wrappers/spannerlib-ruby
61+
run: |
62+
# This runs the specific Rake tasks for this OS
63+
# e.g., "rake compile:aarch64-linux compile:x86_64-linux"
64+
bundle exec rake ${{ matrix.platform_tasks }}
65+
66+
- name: Upload Binaries as Artifact
67+
uses: actions/upload-artifact@v4
68+
with:
69+
name: ${{ matrix.artifact_name }}
70+
path: ${{ matrix.artifact_path }}
71+
72+
publish:
73+
name: Package and Publish Gem
74+
# This job runs only after all 'build' jobs have succeeded
75+
needs: build
76+
runs-on: ubuntu-latest
77+
78+
# This gives the job permission to publish to RubyGems
79+
permissions:
80+
id-token: write
81+
contents: read
82+
83+
steps:
84+
- name: Checkout code
85+
uses: actions/checkout@v4
86+
87+
- name: Set up Ruby
88+
uses: ruby/setup-ruby@v1
89+
with:
90+
ruby-version: '3.3'
91+
bundler-cache: true
92+
working-directory: 'spannerlib/wrappers/spannerlib-ruby'
93+
94+
- name: Download all binaries
95+
uses: actions/download-artifact@v4
96+
with:
97+
# No name means it downloads ALL artifacts from this workflow
98+
# The binaries will be placed in their original paths
99+
path: spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/
100+
101+
- name: List downloaded files (for debugging)
102+
run: ls -R spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/
103+
104+
- name: Build Gem
105+
working-directory: spannerlib/wrappers/spannerlib-ruby
106+
run: gem build spannerlib-ruby.gemspec
107+
108+
- name: Publish to RubyGems
109+
working-directory: spannerlib/wrappers/spannerlib-ruby
110+
run: |
111+
# Make all built .gem files available to be pushed
112+
mkdir -p $HOME/.gem
113+
touch $HOME/.gem/credentials
114+
chmod 0600 $HOME/.gem/credentials
115+
116+
# This uses the new "Trusted Publishing" feature.
117+
# https://guides.rubygems.org/publishing/#publishing-with-github-actions
118+
printf -- "---\n:rubygems_api_key: Bearer ${GEM_HOST_API_KEY}\n" > $HOME/.gem/credentials
119+
120+
# Push the gem
121+
gem push *.gem
122+
env:
123+
GEM_HOST_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }}
124+

.github/workflows/integration-tests-on-emulator.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,5 +54,5 @@ jobs:
5454
env:
5555
SPANNER_EMULATOR_HOST: localhost:9010
5656
run: |
57-
bundle exec rake compile
57+
bundle exec rake compile:x86_64-linux
5858
bundle exec rspec spec/integration/

spannerlib/wrappers/spannerlib-ruby/Rakefile

Lines changed: 68 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -17,28 +17,80 @@
1717
require "bundler/gem_tasks"
1818
require "rspec/core/rake_task"
1919
require "rubocop/rake_task"
20-
require "rbconfig"
20+
require "fileutils"
2121

2222
RSpec::Core::RakeTask.new(:spec)
23-
2423
RuboCop::RakeTask.new
2524

26-
task :compile do
27-
go_source_dir = File.expand_path("../../shared", __dir__)
28-
target_dir = File.expand_path("lib/spannerlib/#{RbConfig::CONFIG['arch']}", __dir__)
29-
output_file = File.join(target_dir, "spannerlib.#{RbConfig::CONFIG['SOEXT']}")
25+
# --- Configuration for Native Library Compilation ---
26+
27+
# The relative path to the Go source code
28+
GO_SOURCE_DIR = File.expand_path("../../shared", __dir__)
29+
30+
# The base directory where compiled libraries will be stored
31+
LIB_DIR = File.expand_path("lib/spannerlib", __dir__)
32+
33+
# Define all the platforms we want to build for.
34+
# The 'key' is the directory name (matches Ruby's `RbConfig::CONFIG['arch']`)
35+
# The 'goos' and 'goarch' are for Go's cross-compiler.
36+
# The 'ext' is the file extension for the shared library.
37+
PLATFORMS = {
38+
"aarch64-darwin" => { goos: "darwin", goarch: "arm64", ext: "dylib" },
39+
"x86_64-darwin" => { goos: "darwin", goarch: "amd64", ext: "dylib" },
40+
"aarch64-linux" => { goos: "linux", goarch: "arm64", ext: "so" },
41+
"x86_64-linux" => { goos: "linux", goarch: "amd64", ext: "so" },
42+
"x64-mingw32" => { goos: "windows", goarch: "amd64", ext: "dll" } # For Windows
43+
}.freeze
44+
45+
# --- Rake Tasks for Compilation ---
46+
47+
# Create a 'compile' namespace for all build tasks
48+
namespace :compile do
49+
desc "Remove all compiled native libraries"
50+
task :clean do
51+
PLATFORMS.each_key do |arch|
52+
target_dir = File.join(LIB_DIR, arch)
53+
puts "Cleaning #{target_dir}"
54+
rm_rf target_dir
55+
end
56+
end
57+
58+
# Dynamically create a build task for each platform
59+
PLATFORMS.each do |arch, config|
60+
desc "Compile native library for #{arch}"
61+
task arch do
62+
target_dir = File.join(LIB_DIR, arch)
63+
output_file = File.join(target_dir, "spannerlib.#{config[:ext]}")
64+
65+
mkdir_p target_dir
66+
67+
# Set environment variables for cross-compilation
68+
env = {
69+
"GOOS" => config[:goos],
70+
"GOARCH" => config[:goarch],
71+
"CGO_ENABLED" => "1" # Ensure CGO is enabled for c-shared
72+
}
73+
74+
command = [
75+
"go", "build",
76+
"-buildmode=c-shared",
77+
"-o", output_file,
78+
GO_SOURCE_DIR
79+
].join(" ")
80+
81+
puts "Building for #{arch}..."
82+
puts "[#{env.map { |k, v| "#{k}=#{v}" }.join(' ')}] #{command}"
3083

31-
mkdir_p target_dir
84+
# Execute the build command with the correct environment
85+
sh env, command
3286

33-
command = [
34-
"go", "build",
35-
"-buildmode=c-shared",
36-
"-o", output_file,
37-
go_source_dir
38-
].join(" ")
87+
puts "Successfully built #{output_file}"
88+
end
89+
end
3990

40-
puts command
41-
sh command
91+
desc "Compile native libraries for all platforms"
92+
task all: PLATFORMS.keys
4293
end
4394

44-
task default: %i[compile spec rubocop]
95+
desc "Run all build and test tasks"
96+
task default: ["compile:all", :spec, :rubocop]

spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/ffi.rb

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,63 @@
2828
module SpannerLib
2929
extend FFI::Library
3030

31+
ENV_OVERRIDE = ENV["SPANNERLIB_PATH"]
32+
33+
def self.platform_dir_from_host
34+
host_os = RbConfig::CONFIG["host_os"]
35+
host_cpu = RbConfig::CONFIG["host_cpu"]
36+
37+
case host_os
38+
when /darwin/
39+
host_cpu =~ /arm|aarch64/ ? "aarch64-darwin" : "x86_64-darwin"
40+
when /linux/
41+
host_cpu =~ /arm|aarch64/ ? "aarch64-linux" : "x86_64-linux"
42+
when /mswin|mingw|cygwin/
43+
"x64-mingw32"
44+
else
45+
nil
46+
end
47+
end
48+
49+
# Build list of candidate paths (ordered): env override, platform-specific, any packaged lib, system library
3150
def self.library_path
51+
if ENV_OVERRIDE && !ENV_OVERRIDE.empty?
52+
return ENV_OVERRIDE if File.file?(ENV_OVERRIDE)
53+
warn "SPANNERLIB_PATH set to #{ENV_OVERRIDE} but file not found"
54+
end
55+
3256
lib_dir = File.expand_path(__dir__)
33-
Dir.glob(File.join(lib_dir, "*/spannerlib.#{FFI::Platform::LIBSUFFIX}")).first
57+
ext = FFI::Platform::LIBSUFFIX
58+
59+
platform = platform_dir_from_host
60+
if platform
61+
candidate = File.join(lib_dir, platform, "spannerlib.#{ext}")
62+
return candidate if File.exist?(candidate)
63+
end
64+
65+
# 3) Any matching packaged binary (first match)
66+
glob_candidates = Dir.glob(File.join(lib_dir, "*", "spannerlib.#{ext}"))
67+
return glob_candidates.first unless glob_candidates.empty?
68+
69+
# 4) Try loading system-wide library (so users who installed shared lib separately can use it)
70+
begin
71+
# Attempt to open system lib name; if succeeds, return bare name so ffi_lib can resolve it
72+
FFI::DynamicLibrary.open("spannerlib", FFI::DynamicLibrary::RTLD_LAZY | FFI::DynamicLibrary::RTLD_GLOBAL)
73+
return "spannerlib"
74+
rescue StandardError
75+
end
76+
77+
searched = []
78+
searched << "ENV SPANNERLIB_PATH=#{ENV_OVERRIDE}" if ENV_OVERRIDE && !ENV_OVERRIDE.empty?
79+
searched << File.join(lib_dir, platform || "<detected-platform?>", "spannerlib.#{ext}")
80+
searched << File.join(lib_dir, "*", "spannerlib.#{ext}")
81+
82+
raise LoadError, <<~ERR
83+
Could not locate the spannerlib native library. Tried:
84+
- #{searched.join("\n - ")}
85+
If you are using the packaged gem, ensure the gem includes lib/spannerlib/<platform>/spannerlib.#{ext}.
86+
You can set SPANNERLIB_PATH to the absolute path of the library file, or install a platform-specific native gem.
87+
ERR
3488
end
3589

3690
ffi_lib library_path

spannerlib/wrappers/spannerlib-ruby/spannerlib-ruby.gemspec

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,33 @@ Gem::Specification.new do |spec|
1616

1717
spec.metadata["rubygems_mfa_required"] = "true"
1818

19+
# Include both git-tracked files (for local development) and any built native libraries
20+
# that exist on disk (for CI). We do this so:
21+
# - During local development, spec.files is driven by `git ls-files` (keeps the gem manifest clean).
22+
# - In CI we build native shared libraries into lib/spannerlib/<platform>/ and those files are
23+
# not checked into git; we therefore also glob lib/spannerlib/** to pick up the CI-built binaries
24+
# so the gem produced in CI actually contains the native libraries.
25+
# - We explicitly filter out common build artifacts and non-distributable files to avoid accidentally
26+
# packaging object files, headers, or temporary files.
27+
# This allows us to publish a single multi-platform gem that contains prebuilt shared libraries
28+
29+
spec.files = Dir.chdir(File.expand_path(__dir__)) do
30+
files = []
31+
# prefer git-tracked files when available (local dev), but also pick up built files present on disk (CI)
32+
if system('git rev-parse --is-inside-work-tree > /dev/null 2>&1')
33+
files += `git ls-files -z`.split("\x0")
34+
end
35+
36+
# include any built native libs (CI places them under lib/spannerlib/)
37+
files += Dir.glob('lib/spannerlib/**/*').select { |f| File.file?(f) }
38+
39+
# dedupe and reject unwanted entries
40+
files.map! { |f| f.sub(%r{\A\./}, '') }.uniq!
41+
files.reject do |f|
42+
f.match(%r{^(pkg|Gemfile\.lock|.*\.gem|Rakefile|spec/|.*\.o|.*\.h)$})
43+
end
44+
end
45+
1946
spec.bindir = "exe"
2047
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
2148
spec.require_paths = ["lib"]

0 commit comments

Comments
 (0)