Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions .github/workflows/build-native-binaries.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
name: Build and Publish Native Gem

on:
workflow_dispatch:

jobs:
build:
strategy:
matrix:
os: [ubuntu-latest, macos-13, windows-latest]
include:
# Config for Linux
- os: ubuntu-latest
platform_tasks: "compile:aarch64-linux compile:x86_64-linux"
artifact_name: "linux-binaries"
artifact_path: "spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/*-linux/"

# Config for macOS (Apple Silicon + Intel)
# Note: macos-13 is an Intel runner (x86_64) but can cross-compile to Apple Silicon (aarch64)
- os: macos-13
platform_tasks: "compile:aarch64-darwin compile:x86_64-darwin"
artifact_name: "darwin-binaries"
artifact_path: "spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/*-darwin/"

# Config for Windows
- os: windows-latest
platform_tasks: "compile:x64-mingw32"
artifact_name: "windows-binaries"
artifact_path: "spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/x64-mingw32/"

runs-on: ${{ matrix.os }}

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: 1.25.x

- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
bundler-cache: true
working-directory: 'spannerlib/wrappers/spannerlib-ruby'

- name: Install cross-compilers (Linux)
if: matrix.os == 'ubuntu-latest'
run: |
sudo apt-get update
# This installs the C compiler for aarch64-linux
sudo apt-get install -y gcc-aarch64-linux-gnu

# The cross-compiler step for macOS is removed,
# as the built-in Clang on macOS runners can handle both Intel and ARM.

- name: Compile Binaries
working-directory: spannerlib/wrappers/spannerlib-ruby
run: |
# This runs the specific Rake tasks for this OS
# e.g., "rake compile:aarch64-linux compile:x86_64-linux"
bundle exec rake ${{ matrix.platform_tasks }}

- name: Upload Binaries as Artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact_name }}
path: ${{ matrix.artifact_path }}

publish:
name: Package and Publish Gem
# This job runs only after all 'build' jobs have succeeded
needs: build
runs-on: ubuntu-latest

# This gives the job permission to publish to RubyGems
permissions:
id-token: write
contents: read

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
bundler-cache: true
working-directory: 'spannerlib/wrappers/spannerlib-ruby'

- name: Download all binaries
uses: actions/download-artifact@v4
with:
# No name means it downloads ALL artifacts from this workflow
# The binaries will be placed in their original paths
path: spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/

- name: List downloaded files (for debugging)
run: ls -R spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/

- name: Build Gem
working-directory: spannerlib/wrappers/spannerlib-ruby
run: gem build spannerlib-ruby.gemspec

- name: Publish to RubyGems
working-directory: spannerlib/wrappers/spannerlib-ruby
run: |
# Make all built .gem files available to be pushed
mkdir -p $HOME/.gem
touch $HOME/.gem/credentials
chmod 0600 $HOME/.gem/credentials

# This uses the new "Trusted Publishing" feature.
# https://guides.rubygems.org/publishing/#publishing-with-github-actions
printf -- "---\n:rubygems_api_key: Bearer ${GEM_HOST_API_KEY}\n" > $HOME/.gem/credentials

# Push the gem
gem push *.gem
env:
GEM_HOST_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }}

2 changes: 1 addition & 1 deletion .github/workflows/integration-tests-on-emulator.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,5 +54,5 @@ jobs:
env:
SPANNER_EMULATOR_HOST: localhost:9010
run: |
bundle exec rake compile
bundle exec rake compile:x86_64-linux
bundle exec rspec spec/integration/
84 changes: 68 additions & 16 deletions spannerlib/wrappers/spannerlib-ruby/Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -17,28 +17,80 @@
require "bundler/gem_tasks"
require "rspec/core/rake_task"
require "rubocop/rake_task"
require "rbconfig"
require "fileutils"

RSpec::Core::RakeTask.new(:spec)

RuboCop::RakeTask.new

task :compile do
go_source_dir = File.expand_path("../../shared", __dir__)
target_dir = File.expand_path("lib/spannerlib/#{RbConfig::CONFIG['arch']}", __dir__)
output_file = File.join(target_dir, "spannerlib.#{RbConfig::CONFIG['SOEXT']}")
# --- Configuration for Native Library Compilation ---

# The relative path to the Go source code
GO_SOURCE_DIR = File.expand_path("../../shared", __dir__)

# The base directory where compiled libraries will be stored
LIB_DIR = File.expand_path("lib/spannerlib", __dir__)

# Define all the platforms we want to build for.
# The 'key' is the directory name (matches Ruby's `RbConfig::CONFIG['arch']`)
# The 'goos' and 'goarch' are for Go's cross-compiler.
# The 'ext' is the file extension for the shared library.
PLATFORMS = {
"aarch64-darwin" => { goos: "darwin", goarch: "arm64", ext: "dylib" },
"x86_64-darwin" => { goos: "darwin", goarch: "amd64", ext: "dylib" },
"aarch64-linux" => { goos: "linux", goarch: "arm64", ext: "so" },
"x86_64-linux" => { goos: "linux", goarch: "amd64", ext: "so" },
"x64-mingw32" => { goos: "windows", goarch: "amd64", ext: "dll" } # For Windows
}.freeze

# --- Rake Tasks for Compilation ---

# Create a 'compile' namespace for all build tasks
namespace :compile do
desc "Remove all compiled native libraries"
task :clean do
PLATFORMS.each_key do |arch|
target_dir = File.join(LIB_DIR, arch)
puts "Cleaning #{target_dir}"
rm_rf target_dir
end
end

# Dynamically create a build task for each platform
PLATFORMS.each do |arch, config|
desc "Compile native library for #{arch}"
task arch do
target_dir = File.join(LIB_DIR, arch)
output_file = File.join(target_dir, "spannerlib.#{config[:ext]}")

mkdir_p target_dir

# Set environment variables for cross-compilation
env = {
"GOOS" => config[:goos],
"GOARCH" => config[:goarch],
"CGO_ENABLED" => "1" # Ensure CGO is enabled for c-shared
}

command = [
"go", "build",
"-buildmode=c-shared",
"-o", output_file,
GO_SOURCE_DIR
].join(" ")

puts "Building for #{arch}..."
puts "[#{env.map { |k, v| "#{k}=#{v}" }.join(' ')}] #{command}"

mkdir_p target_dir
# Execute the build command with the correct environment
sh env, command

command = [
"go", "build",
"-buildmode=c-shared",
"-o", output_file,
go_source_dir
].join(" ")
puts "Successfully built #{output_file}"
end
end

puts command
sh command
desc "Compile native libraries for all platforms"
task all: PLATFORMS.keys
end

task default: %i[compile spec rubocop]
desc "Run all build and test tasks"
task default: ["compile:all", :spec, :rubocop]
56 changes: 55 additions & 1 deletion spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/ffi.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,63 @@
module SpannerLib
extend FFI::Library

ENV_OVERRIDE = ENV["SPANNERLIB_PATH"]

def self.platform_dir_from_host
host_os = RbConfig::CONFIG["host_os"]
host_cpu = RbConfig::CONFIG["host_cpu"]

case host_os
when /darwin/
host_cpu =~ /arm|aarch64/ ? "aarch64-darwin" : "x86_64-darwin"
when /linux/
host_cpu =~ /arm|aarch64/ ? "aarch64-linux" : "x86_64-linux"
when /mswin|mingw|cygwin/
"x64-mingw32"
else
nil
end
end

# Build list of candidate paths (ordered): env override, platform-specific, any packaged lib, system library
def self.library_path
if ENV_OVERRIDE && !ENV_OVERRIDE.empty?
return ENV_OVERRIDE if File.file?(ENV_OVERRIDE)
warn "SPANNERLIB_PATH set to #{ENV_OVERRIDE} but file not found"
end

lib_dir = File.expand_path(__dir__)
Dir.glob(File.join(lib_dir, "*/spannerlib.#{FFI::Platform::LIBSUFFIX}")).first
ext = FFI::Platform::LIBSUFFIX

platform = platform_dir_from_host
if platform
candidate = File.join(lib_dir, platform, "spannerlib.#{ext}")
return candidate if File.exist?(candidate)
end

# 3) Any matching packaged binary (first match)
glob_candidates = Dir.glob(File.join(lib_dir, "*", "spannerlib.#{ext}"))
return glob_candidates.first unless glob_candidates.empty?

# 4) Try loading system-wide library (so users who installed shared lib separately can use it)
begin
# Attempt to open system lib name; if succeeds, return bare name so ffi_lib can resolve it
FFI::DynamicLibrary.open("spannerlib", FFI::DynamicLibrary::RTLD_LAZY | FFI::DynamicLibrary::RTLD_GLOBAL)
return "spannerlib"
rescue StandardError
end

searched = []
searched << "ENV SPANNERLIB_PATH=#{ENV_OVERRIDE}" if ENV_OVERRIDE && !ENV_OVERRIDE.empty?
searched << File.join(lib_dir, platform || "<detected-platform?>", "spannerlib.#{ext}")
searched << File.join(lib_dir, "*", "spannerlib.#{ext}")

raise LoadError, <<~ERR
Could not locate the spannerlib native library. Tried:
- #{searched.join("\n - ")}
If you are using the packaged gem, ensure the gem includes lib/spannerlib/<platform>/spannerlib.#{ext}.
You can set SPANNERLIB_PATH to the absolute path of the library file, or install a platform-specific native gem.
ERR
end

ffi_lib library_path
Expand Down
27 changes: 27 additions & 0 deletions spannerlib/wrappers/spannerlib-ruby/spannerlib-ruby.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,33 @@ Gem::Specification.new do |spec|

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

# Include both git-tracked files (for local development) and any built native libraries
# that exist on disk (for CI). We do this so:
# - During local development, spec.files is driven by `git ls-files` (keeps the gem manifest clean).
# - In CI we build native shared libraries into lib/spannerlib/<platform>/ and those files are
# not checked into git; we therefore also glob lib/spannerlib/** to pick up the CI-built binaries
# so the gem produced in CI actually contains the native libraries.
# - We explicitly filter out common build artifacts and non-distributable files to avoid accidentally
# packaging object files, headers, or temporary files.
# This allows us to publish a single multi-platform gem that contains prebuilt shared libraries

spec.files = Dir.chdir(File.expand_path(__dir__)) do
files = []
# prefer git-tracked files when available (local dev), but also pick up built files present on disk (CI)
if system('git rev-parse --is-inside-work-tree > /dev/null 2>&1')
files += `git ls-files -z`.split("\x0")
end

# include any built native libs (CI places them under lib/spannerlib/)
files += Dir.glob('lib/spannerlib/**/*').select { |f| File.file?(f) }

# dedupe and reject unwanted entries
files.map! { |f| f.sub(%r{\A\./}, '') }.uniq!
files.reject do |f|
f.match(%r{^(pkg|Gemfile\.lock|.*\.gem|Rakefile|spec/|.*\.o|.*\.h)$})
end
end

spec.bindir = "exe"
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
spec.require_paths = ["lib"]
Expand Down
Loading