Skip to content

Commit 79ff991

Browse files
committed
feat(ruby): Add native gem workflow and Ruby wrapper
1 parent 5ce2f0a commit 79ff991

File tree

5 files changed

+269
-32
lines changed

5 files changed

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

.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/
Lines changed: 68 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,82 @@
1-
# Copyright 2025 Google LLC
2-
#
3-
# Licensed under the Apache License, Version 2.0 (the "License");
4-
# you may not use this file except in compliance with the License.
5-
# You may obtain a copy of the License at
6-
#
7-
# https://www.apache.org/licenses/LICENSE-2.0
8-
#
9-
# Unless required by applicable law or agreed to in writing, software
10-
# distributed under the License is distributed on an "AS IS" BASIS,
11-
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12-
# See the License for the specific language governing permissions and
13-
# limitations under the License.
14-
151
# frozen_string_literal: true
162

173
require "bundler/gem_tasks"
184
require "rspec/core/rake_task"
195
require "rubocop/rake_task"
20-
require "rbconfig"
6+
require "fileutils"
217

228
RSpec::Core::RakeTask.new(:spec)
23-
249
RuboCop::RakeTask.new
2510

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

31-
mkdir_p target_dir
70+
# Execute the build command with the correct environment
71+
sh env, command
3272

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

40-
puts command
41-
sh command
77+
desc "Compile native libraries for all platforms"
78+
task all: PLATFORMS.keys
4279
end
4380

44-
task default: %i[compile spec rubocop]
81+
desc "Run all build and test tasks"
82+
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: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,28 @@ Gem::Specification.new do |spec|
1616

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

19+
spec.files = Dir.chdir(File.expand_path(__dir__)) do
20+
files = []
21+
# prefer git-tracked files when available (local dev), but also pick up built files present on disk (CI)
22+
if system('git rev-parse --is-inside-work-tree > /dev/null 2>&1')
23+
files += `git ls-files -z`.split("\x0")
24+
end
25+
26+
# include any built native libs (CI places them under lib/spannerlib/)
27+
files += Dir.glob('lib/spannerlib/**/*').select { |f| File.file?(f) }
28+
29+
# dedupe and reject unwanted entries
30+
files.map! { |f| f.sub(%r{\A\./}, '') }.uniq!
31+
files.reject do |f|
32+
f.match(%r{^(pkg|Gemfile\.lock|.*\.gem|Rakefile|spec/|.*\.o|.*\.h)$})
33+
end
34+
end
35+
1936
spec.bindir = "exe"
2037
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
2138
spec.require_paths = ["lib"]
2239

40+
2341
spec.add_dependency "ffi"
2442
spec.add_dependency "google-cloud-spanner-v1", "~> 1.7"
2543
spec.add_dependency "google-protobuf", "~> 3.19"

0 commit comments

Comments
 (0)