From fa5c9268bac817f603fe79a4b2564ef5867af0f5 Mon Sep 17 00:00:00 2001 From: Akash Anand Date: Thu, 30 Oct 2025 10:45:43 +0000 Subject: [PATCH] feat(ruby): Add native gem workflow for Ruby wrapper --- .github/workflows/build-native-binaries.yml | 124 ++++++++++++++++++ .../integration-tests-on-emulator.yml | 2 +- spannerlib/wrappers/spannerlib-ruby/Rakefile | 84 +++++++++--- .../spannerlib-ruby/lib/spannerlib/ffi.rb | 56 +++++++- .../spannerlib-ruby/spannerlib-ruby.gemspec | 27 ++++ 5 files changed, 275 insertions(+), 18 deletions(-) create mode 100644 .github/workflows/build-native-binaries.yml diff --git a/.github/workflows/build-native-binaries.yml b/.github/workflows/build-native-binaries.yml new file mode 100644 index 00000000..4d59d219 --- /dev/null +++ b/.github/workflows/build-native-binaries.yml @@ -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 }} + diff --git a/.github/workflows/integration-tests-on-emulator.yml b/.github/workflows/integration-tests-on-emulator.yml index 3ebf9c7b..7fe10bbd 100644 --- a/.github/workflows/integration-tests-on-emulator.yml +++ b/.github/workflows/integration-tests-on-emulator.yml @@ -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/ diff --git a/spannerlib/wrappers/spannerlib-ruby/Rakefile b/spannerlib/wrappers/spannerlib-ruby/Rakefile index 749fc119..f5a1d24f 100644 --- a/spannerlib/wrappers/spannerlib-ruby/Rakefile +++ b/spannerlib/wrappers/spannerlib-ruby/Rakefile @@ -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] diff --git a/spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/ffi.rb b/spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/ffi.rb index 61e190a1..34e09b92 100644 --- a/spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/ffi.rb +++ b/spannerlib/wrappers/spannerlib-ruby/lib/spannerlib/ffi.rb @@ -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 || "", "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//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 diff --git a/spannerlib/wrappers/spannerlib-ruby/spannerlib-ruby.gemspec b/spannerlib/wrappers/spannerlib-ruby/spannerlib-ruby.gemspec index 4f746b3d..78edc8c4 100644 --- a/spannerlib/wrappers/spannerlib-ruby/spannerlib-ruby.gemspec +++ b/spannerlib/wrappers/spannerlib-ruby/spannerlib-ruby.gemspec @@ -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// 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"]