From e46d982188ffcc09ef1dcd04acf28da832eace4e Mon Sep 17 00:00:00 2001 From: Loic Nageleisen Date: Tue, 5 May 2026 17:51:34 +0200 Subject: [PATCH 1/3] Add CI `package` jobs --- .github/workflows/build.yml | 18 ++- tasks/gem.rake | 249 ++++++++++++++++++++++++++++++++++++ 2 files changed, 262 insertions(+), 5 deletions(-) create mode 100644 tasks/gem.rake diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a06d80f..ca75294 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -150,7 +150,15 @@ jobs: path: vendor-${{ matrix.platform.ruby_platform }}.tar package: - name: Package + strategy: + fail-fast: false + matrix: + gem_platform: + - x86_64-linux + - aarch64-linux + - arm64-darwin + - ruby + name: Package (${{ matrix.gem_platform }}) needs: [build-linux, build-macos] runs-on: ubuntu-24.04 steps: @@ -169,10 +177,10 @@ jobs: for tarball in vendor-*.tar; do tar xf "${tarball}" done - - name: Package gems - run: bundle exec rake package - - name: Upload gems + - name: Package gem + run: bundle exec rake "gem:package[${{ matrix.gem_platform }}]" + - name: Upload gem uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: gems + name: gem-${{ matrix.gem_platform }} path: pkg/*.gem diff --git a/tasks/gem.rake b/tasks/gem.rake new file mode 100644 index 0000000..a3d8521 --- /dev/null +++ b/tasks/gem.rake @@ -0,0 +1,249 @@ +# frozen_string_literal: true + +require "pathname" +require "rubygems/package" +require "rubygems/package/tar_reader" +require "zlib" +require "stringio" + +# Builds .gem packages from vendored libdatadog artifacts. +# +# Intentionally duplicates some Rakefile logic so both can coexist during +# the migration; the Rakefile originals will be removed once the new tasks +# are fully adopted. +module GemPackaging + module Platform + # Mapping from gem platform names to the vendor platform directories + # included in that gem. We bundle glibc and musl variants together + # for Linux to work around https://github.com/rubygems/rubygems/issues/3174 + GEM_TO_VENDOR = { + "x86_64-linux" => ["x86_64-linux", "x86_64-linux-musl"], + "aarch64-linux" => ["aarch64-linux", "aarch64-linux-musl"], + "arm64-darwin" => ["arm64-darwin"] + }.freeze + + ALL_VENDOR = GEM_TO_VENDOR.values.flatten.freeze + + class << self + # Resolve a RUBY_PLATFORM string (or gem platform name) to a known + # gem platform key. + # + # resolve("x86_64-linux") #=> "x86_64-linux" + # resolve("x86_64-linux-musl") #=> "x86_64-linux" + # resolve("arm64-darwin24") #=> "arm64-darwin" + def resolve(platform_string) + return platform_string if GEM_TO_VENDOR.key?(platform_string) + + # musl suffix (x86_64-linux-musl -> x86_64-linux) + without_musl = platform_string.sub(/-musl\z/, "") + return without_musl if GEM_TO_VENDOR.key?(without_musl) + + # macOS Darwin version suffix (arm64-darwin24 -> arm64-darwin) + GEM_TO_VENDOR.each_key do |gp| + return gp if platform_string.start_with?(gp) + end + + raise "Could not resolve platform '#{platform_string}' to a supported gem platform. " \ + "Supported: #{GEM_TO_VENDOR.keys.join(", ")}, ruby" + end + + # Vendor platform directories needed for a given gem platform. + def vendor_platforms(gem_platform) + if gem_platform == "ruby" + ALL_VENDOR + else + GEM_TO_VENDOR.fetch(gem_platform) do + raise "Unknown gem platform: #{gem_platform}. " \ + "Supported: #{GEM_TO_VENDOR.keys.join(", ")}, ruby" + end + end + end + end + end + + module Paths + class << self + # Project root + def root + @root ||= (Pathname.new(__dir__) / "..").expand_path + end + + # Vendor tree for the current library version + def vendor + root / "vendor" / "libdatadog-#{Libdatadog::LIB_VERSION}" + end + + # Vendor directory for a specific platform + def vendor_platform(name) + vendor / name + end + + # Output directory for built gems + def pkg + root / "pkg" + end + + # The gemspec file + def gemspec + root / "libdatadog.gemspec" + end + + # Expected .gem filename for a given gem platform + def gem_file_name(gem_platform) + spec = Gem::Specification.new do |s| + s.name = "libdatadog" + s.version = Libdatadog::VERSION + s.platform = gem_platform unless gem_platform == "ruby" + end + spec.file_name + end + + # Full path to the .gem for a given gem platform + def gem(gem_platform) + pkg / gem_file_name(gem_platform) + end + end + end + + module Packager + # Files that must have executable permissions (0755); everything else gets 0644. + EXECUTABLE_FILES = %w[ + libdatadog-crashtracking-receiver + libdatadog_profiling.so + libdatadog_profiling.dylib + ].freeze + + # Vendored files excluded from gems (not needed at runtime). + EXCLUDED_FILES = %w[ + datadog_profiling.pc + libdatadog_profiling.a + datadog_profiling-static.pc + libdatadog_profiling.debug + DatadogConfig.cmake + ].freeze + + class << self + # Collect vendored files for one or more vendor platform directories, + # filtering out tarballs and files not needed at runtime. + # Returns paths relative to the project root (what gemspec.files expects). + def vendor_files(*vendor_platforms) + vendor_platforms.flat_map { |vp| + Paths.vendor_platform(vp).glob("**/*") + .select(&:file?) + .map { |p| p.relative_path_from(Paths.root).to_s } + .reject { |p| p.end_with?(".tar.gz") } + .reject { |p| EXCLUDED_FILES.include?(File.basename(p)) } + } + end + + # Verify that required vendor directories exist and contain files. + def check_vendor!(*vendor_platforms) + missing = vendor_platforms.reject { |vp| + dir = Paths.vendor_platform(vp) + dir.exist? && dir.glob("**/*").any?(&:file?) + } + + return if missing.empty? + + raise "Missing vendor artifacts for: #{missing.join(", ")}. " \ + "Expected under #{Paths.vendor}/. " \ + "Run `rake libdatadog:build` or download vendor artifacts first." + end + + # Build a single .gem into pkg/. + def build_gem(gem_platform:, vendor_platforms:) + check_vendor!(*vendor_platforms) + + spec = eval(Paths.gemspec.read, nil, Paths.gemspec.to_s) # standard:disable Security/Eval + spec.files += vendor_files(*vendor_platforms) + spec.platform = gem_platform unless gem_platform == "ruby" + + Paths.pkg.mkpath + + puts "Building gem for platform=#{gem_platform} including: (this can take a while)" + pp spec.files.select { |f| f.start_with?("vendor/") } + + Gem::Package.build(spec) + # build creates the .gem in cwd; move it into pkg/ + gem_path = Paths.gem(gem_platform) + Pathname.new(spec.file_name).rename(gem_path) + puts("-" * 80) + + gem_path + end + + # Inspect the built .gem and raise on unexpected file permissions. + def validate_permissions!(gem_path) + puts "Validating permissions in #{gem_path}..." + + Gem::Package::TarReader.new(gem_path.open("rb")) do |tar| + data_entry = tar.find { |entry| entry.header.name == "data.tar.gz" } + next unless data_entry + + Gem::Package::TarReader.new(Zlib::GzipReader.new(StringIO.new(data_entry.read))) do |data_tar| + data_tar.each do |entry| + next if entry.directory? + + filename = File.basename(entry.header.name) + actual = entry.header.mode.to_s(8)[-3..-1] + expected = EXECUTABLE_FILES.include?(filename) ? "755" : "644" + + next if actual == expected + + raise "Bad permissions for #{filename} in #{gem_path}: " \ + "got #{actual}, expected #{expected}" + end + end + end + + puts "Permissions OK." + end + + # Package a single gem platform (a GEM_TO_VENDOR key, or "ruby"). + def package(platform) + if Platform::GEM_TO_VENDOR.key?(platform) || platform == "ruby" + build_gem( + gem_platform: platform, + vendor_platforms: Platform.vendor_platforms(platform) + ) + else + resolved = Platform.resolve(platform) + puts "Resolved '#{platform}' -> gem platform '#{resolved}'" + package(resolved) + end + end + + # Package every supported binary gem platform plus the ruby fallback gem. + def package_all + Platform::GEM_TO_VENDOR.each_key { |gp| package(gp) } + package("ruby") + end + end + end +end + +namespace :gem do + desc "Package gem(s). No argument: all platforms + ruby. " \ + "With argument: a specific gem platform, 'ruby', or a RUBY_PLATFORM value.\n" \ + " Examples: rake gem:package # all\n" \ + " rake gem:package[x86_64-linux] # one binary platform\n" \ + " rake gem:package[ruby] # ruby platform gem\n" \ + " rake gem:package[arm64-darwin24] # resolved to arm64-darwin" + task :package, [:platform] do |_t, args| + platform = args[:platform] + + if platform.nil? || platform.strip.empty? + GemPackaging::Packager.package_all + else + GemPackaging::Packager.package(platform) + end + end + + desc "Validate file permissions in all gems under pkg/" + task :validate do + gems = GemPackaging::Paths.pkg.glob("*.gem") + raise "No .gem files found in #{GemPackaging::Paths.pkg}" if gems.empty? + + gems.each { |gem_path| GemPackaging::Packager.validate_permissions!(gem_path) } + end +end From 64a4d72e40f353694097323aa03f446bc0f77bf3 Mon Sep 17 00:00:00 2001 From: Loic Nageleisen Date: Tue, 5 May 2026 17:57:58 +0200 Subject: [PATCH 2/3] Add CI `validate` jobs --- .github/workflows/build.yml | 87 +++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ca75294..61b9a75 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -184,3 +184,90 @@ jobs: with: name: gem-${{ matrix.gem_platform }} path: pkg/*.gem + + validate-linux: + strategy: + fail-fast: false + matrix: + gem_source: [binary, ruby] + platform: + - ruby_platform: x86_64-linux + gem_platform: x86_64-linux + base: ubuntu-24.04 + image: ghcr.io/datadog/images-rb/engines/ruby:4.0-gnu-gcc + - ruby_platform: x86_64-linux-musl + gem_platform: x86_64-linux + base: ubuntu-24.04 + image: ghcr.io/datadog/images-rb/engines/ruby:4.0-musl-gcc + - ruby_platform: aarch64-linux + gem_platform: aarch64-linux + base: ubuntu-24.04-arm + image: ghcr.io/datadog/images-rb/engines/ruby:4.0-gnu-gcc + - ruby_platform: aarch64-linux-musl + gem_platform: aarch64-linux + base: ubuntu-24.04-arm + image: ghcr.io/datadog/images-rb/engines/ruby:4.0-musl-gcc + name: Validate (${{ matrix.platform.ruby_platform }}, ${{ matrix.gem_source }}) + needs: [package] + runs-on: ${{ matrix.platform.base }} + steps: + - name: Download gem + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: gem-${{ matrix.gem_source == 'ruby' && 'ruby' || matrix.platform.gem_platform }} + path: pkg + - name: Start container + run: docker run --rm --detach --name validate --volume "${PWD}:${PWD}" -w "${PWD}" ${{ matrix.platform.image }} sleep 86400 + - name: Install gem + run: docker exec validate gem install pkg/*.gem + - name: Validate gem + run: | + docker exec validate ruby -e ' + require "libdatadog" + puts "version: #{Libdatadog::VERSION}" + puts "platform: #{RUBY_PLATFORM}" + puts "binaries: #{Libdatadog.available_binaries}" + puts "pkgconfig: #{Libdatadog.pkgconfig_folder}" + puts "receiver: #{Libdatadog.path_to_crashtracking_receiver_binary}" + raise "pkgconfig_folder is nil" unless Libdatadog.pkgconfig_folder + raise "crashtracking receiver not found" unless Libdatadog.path_to_crashtracking_receiver_binary + ' + - name: Stop container + if: always() + run: docker stop validate || true + + validate-macos: + strategy: + fail-fast: false + matrix: + gem_source: [binary, ruby] + platform: + - ruby_platform: arm64-darwin + gem_platform: arm64-darwin + base: macos-15 + name: Validate (${{ matrix.platform.ruby_platform }}, ${{ matrix.gem_source }}) + needs: [package] + runs-on: ${{ matrix.platform.base }} + steps: + - uses: ruby/setup-ruby@7372622e62b60b3cb750dcd2b9e32c247ffec26a # v1.302.0 + with: + ruby-version: "4.0" + - name: Download gem + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: gem-${{ matrix.gem_source == 'ruby' && 'ruby' || matrix.platform.gem_platform }} + path: pkg + - name: Install gem + run: gem install pkg/*.gem + - name: Validate gem + run: | + ruby -e ' + require "libdatadog" + puts "version: #{Libdatadog::VERSION}" + puts "platform: #{RUBY_PLATFORM}" + puts "binaries: #{Libdatadog.available_binaries}" + puts "pkgconfig: #{Libdatadog.pkgconfig_folder}" + puts "receiver: #{Libdatadog.path_to_crashtracking_receiver_binary}" + raise "pkgconfig_folder is nil" unless Libdatadog.pkgconfig_folder + raise "crashtracking receiver not found" unless Libdatadog.path_to_crashtracking_receiver_binary + ' From cf18bad08695eaf86d93010872cb8a8c14223208 Mon Sep 17 00:00:00 2001 From: Loic Nageleisen Date: Tue, 5 May 2026 18:10:34 +0200 Subject: [PATCH 3/3] Add C smoke test --- .github/workflows/build.yml | 10 ++++++++++ .gitignore | 3 +++ spec/smoke/run.rb | 28 ++++++++++++++++++++++++++++ spec/smoke/smoke_test.c | 25 +++++++++++++++++++++++++ 4 files changed, 66 insertions(+) create mode 100644 spec/smoke/run.rb create mode 100644 spec/smoke/smoke_test.c diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 61b9a75..82a50f4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -232,6 +232,11 @@ jobs: raise "pkgconfig_folder is nil" unless Libdatadog.pkgconfig_folder raise "crashtracking receiver not found" unless Libdatadog.path_to_crashtracking_receiver_binary ' + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + sparse-checkout: spec/smoke + - name: Smoke test (compile and run C program against libdatadog) + run: docker exec validate ruby spec/smoke/run.rb - name: Stop container if: always() run: docker stop validate || true @@ -271,3 +276,8 @@ jobs: raise "pkgconfig_folder is nil" unless Libdatadog.pkgconfig_folder raise "crashtracking receiver not found" unless Libdatadog.path_to_crashtracking_receiver_binary ' + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + sparse-checkout: spec/smoke + - name: Smoke test (compile and run C program against libdatadog) + run: ruby spec/smoke/run.rb diff --git a/.gitignore b/.gitignore index 42a6ea0..293e3fa 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,9 @@ /.envrc /.direnv +# smoke test compiled binary +/spec/smoke/smoke_test + # temporary files *~ *.swp diff --git a/spec/smoke/run.rb b/spec/smoke/run.rb new file mode 100644 index 0000000..b0b4ff9 --- /dev/null +++ b/spec/smoke/run.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require "libdatadog" + +pkgconfig_folder = Libdatadog.pkgconfig_folder +abort "FAIL: Libdatadog.pkgconfig_folder returned nil (no binaries for #{RUBY_PLATFORM}?)" unless pkgconfig_folder + +libdir = File.expand_path("../../lib", pkgconfig_folder) +includedir = File.expand_path("../../include", pkgconfig_folder) + +src = File.expand_path("smoke_test.c", __dir__) +out = File.expand_path("smoke_test", __dir__) + +# Compile +cc = ENV.fetch("CC", "cc") +compile_cmd = [cc, "-o", out, src, "-I#{includedir}", "-L#{libdir}", "-ldatadog_profiling", "-Wl,-rpath,#{libdir}"] +puts "Compiling: #{compile_cmd.join(" ")}" +unless system(*compile_cmd) + abort "FAIL: compilation failed" +end + +# Run +puts "Running: #{out}" +unless system(out) + abort "FAIL: smoke test binary exited with non-zero status" +end + +puts "PASS" diff --git a/spec/smoke/smoke_test.c b/spec/smoke/smoke_test.c new file mode 100644 index 0000000..20cde1a --- /dev/null +++ b/spec/smoke/smoke_test.c @@ -0,0 +1,25 @@ +// Minimal smoke test for the libdatadog Rust↔C FFI. +// Creates a tag vector, pushes a tag, and verifies the round-trip works. + +#include +#include + +int main(void) { + ddog_Vec_Tag tags = ddog_Vec_Tag_new(); + + ddog_Vec_Tag_PushResult result = + ddog_Vec_Tag_push(&tags, DDOG_CHARSLICE_C("test.key"), DDOG_CHARSLICE_C("test.value")); + + if (result.tag != DDOG_VEC_TAG_PUSH_RESULT_OK) { + ddog_CharSlice msg = ddog_Error_message(&result.err); + fprintf(stderr, "FAIL: ddog_Vec_Tag_push error: %.*s\n", (int)msg.len, msg.ptr); + ddog_Error_drop(&result.err); + ddog_Vec_Tag_drop(tags); + return 1; + } + + ddog_Vec_Tag_drop(tags); + + printf("OK: libdatadog FFI smoke test passed\n"); + return 0; +}