vcdeps

vcpkg-powered native dependencies for Ruby C extensions on Windows — declare ports in a manifest, get correct mkmf flags and loadable runtime DLLs, no global state.

Building a Ruby C extension against zlib, bzip2, or libxml2 on a native MSVC (mswin) Ruby has two classic walls. First the compiler can't find the headers:

fatal error C1083: Cannot open include file: 'zlib.h': No such file or directory

You fix that, link, build a .so — and then it still won't load:

LoadError: 126: The specified module could not be found - .../foo.so

because Windows resolves a .so's dependent DLLs through the standard search order, which never looks in the .so's own directory. vcdeps closes both gaps: you declare your native dependencies in a vcpkg.json under ext/, call Vcdeps.mkmf! from extconf.rb, and vcdeps locates (or bootstraps) vcpkg, installs the ports out of tree with the dynamic-CRT triplet that matches an -MD Ruby, prepends the include/lib paths so they win over Ruby's own opt-dir, and vendors the runtime DLLs with a generated Fiddle-preload shim so the built extension actually loads.

It is first of its kind. rake-compiler-dock is author-side MinGW-only cross-compilation; rb_sys and ffi-compiler are GCC-oriented; nothing wired vcpkg into mkmf before. vcdeps is the companion to vcvars (which loads the toolchain) the way vcpkg is the companion to MSVC.

API summary

What API
extconf entry point (install + wire + vendor) Vcdeps.mkmf!(vendor:)
install a manifest's ports out of tree Vcdeps.install!(manifest:)
sync DLLs + licenses + preload shim into a gem Vcdeps.vendor!(installed, into:)
add/refresh builtin-baseline Vcdeps.baseline!(manifest:)
locate vcpkg (or raise with remedies) Vcdeps.tool / Vcdeps.tool!
bootstrap a private vcpkg Vcdeps.bootstrap!
CLI vcdeps doctor / where / install / vendor / baseline / bootstrap

Requirements

  • Windows with a native MSVC (mswin) Ruby (RbConfig::CONFIG["target_os"] matches /mswin/). Not supported on MinGW/UCRT.
  • Visual Studio 2017+ or Build Tools with the Desktop development with C++ workload (for cl.exe / nmake).
  • A vcpkg instance. vcdeps finds one in this order: an explicit VCPKG_ROOT, the VS-bundled vcpkg (the vcpkg package manager component — Microsoft.VisualStudio.Component.Vcpkg, Recommended in that workload), a validated VCPKG_INSTALLATION_ROOT (GitHub Actions), or a private instance created by vcdeps bootstrap.
  • vcvars — installed automatically as a runtime dependency.

Install

gem install vcdeps

Quick start

Four files turn a C extension into a vcpkg-backed gem.

// ext/foo/vcpkg.json — builtin-baseline is MANDATORY (the VS-bundled and
// standalone vcpkg are git-registry instances; vcdeps hard-fails without it).
// Get a SHA with: vcdeps baseline --manifest ext/foo
{
  "name": "foo",
  "version": "0.1.0",
  "dependencies": ["zlib", "bzip2"],
  "builtin-baseline": "e5a1490e1409d175932ef6014519e9ae149ddb7c"
}
# ext/foo/extconf.rb
# frozen_string_literal: true
require "mkmf"

unless RbConfig::CONFIG["target_os"] =~ /mswin/
  abort <<~MSG
    foo requires a native Windows MSVC (mswin) Ruby — it links vcpkg-built
    native libraries and is built with cl.exe. Your Ruby is "#{RbConfig::CONFIG['arch']}".
  MSG
end

require "vcdeps/mkmf"
Vcdeps.mkmf!(vendor: File.expand_path("../../lib/foo/vendor", __dir__))

have_header("zlib.h")  or abort "zlib.h not found — run `vcdeps doctor`"
have_library("zlib")   or abort "zlib.lib not found"   # bare name; LIBARG adds .lib
have_header("bzlib.h") or abort "bzlib.h not found"
have_library("bz2")    or abort "bz2.lib not found"    # import-lib name != port name
create_makefile("foo/foo")
# lib/foo.rb
# frozen_string_literal: true
require "foo/version"
preload = File.expand_path("foo/vendor/preload.rb", __dir__)
require preload if File.exist?(preload)   # shim absent for static-md / no-DLL builds
require "foo/foo"                          # the compiled extension
# Rakefile — unchanged suite shape; vcdeps needs no Rake hook (extconf does it all)
require "vcvars/rake"        # load the MSVC build env so `rake compile` just works
require "rake/extensiontask"
require "rake/testtask"
spec = Gem::Specification.load("foo.gemspec")
Rake::ExtensionTask.new("foo", spec) { |ext| ext.lib_dir = "lib/foo" }
Rake::TestTask.new(test: :compile) do |t|
  t.libs << "test" << "lib"
  t.test_files = FileList["test/**/test_*.rb"]
  t.warning = false
end
task default: :test

Then rake compile && rake test. End users override at install time with standard mkmf semantics:

gem install foo -- --with-vcdeps-triplet=x64-windows-static-md  # link static, ship no DLLs
gem install foo -- --with-vcdeps-dir=C:\prebuilt\foo-deps       # bypass vcpkg entirely
set VCDEPS_BOOTSTRAP=1 && gem install foo                       # consent to private bootstrap

How loading works

Beside-the-.so does not work: Windows resolves an extension's dependent DLLs as if loaded by module name only, and the search order never checks the .so's own directory. "It worked when I ran it from that folder" is the current directory (search step 11) fooling you — move the cwd and it breaks. vcdeps generates a preload.rb shim that Fiddle.dlopens each vendored DLL by absolute path before the extension loads, so the loaded-module list (search step 4, consulted before PATH) satisfies the imports. The x64-windows-static-md triplet sidesteps the whole problem by linking the libraries statically into the .so (no DLLs to vendor). One caveat: Ruby's own bin ships zlib1.dll / ffi-8.dll / yaml.dll; if one is already loaded under that name, it wins process-wide — vcdeps doctor and vendor! warn on base-name collisions.

Precompiled platform gems

The primary audience is gem authors and their Windows CI shipping precompiled x64-mswin64-140 gems so end users never compile. Build on Windows CI, run vcdeps vendor --into lib/<gem>/vendor, then flip spec.platform = Gem::Platform.local, drop spec.extensions, and include lib/<gem>/vendor/**/* (the DLLs + preload.rb shim) in spec.files. The source path (gem install → extconf → vcdeps → vcpkg) still works — it is the author's dev loop and the fallback for exotic setups — but a cold libxml2-class build at end-user install time is an accepted-but-discouraged slow path: vcdeps streams vcpkg output live and leans on vcpkg's binary cache, so it is slow once, never silent.

License note: vendored ports' copyright files ship in vendor/licenses/ for DLL builds and static-md builds (under static-md the port code is statically linked into the shipped .so, so its copyright files are still redistributed alongside it). Mind relink obligations for LGPL ports.

CI recipe

windows-latest, a self-provided mswin Ruby (ruby/setup-ruby's mswin build is x64 ruby-master; there are no RubyInstaller mswin builds). Invoke vcpkg by explicit path, let vcvars/rake + vcdeps do the rest, and cache the binary archives:

- uses: actions/cache@v4
  with:
    path: ~/AppData/Local/vcpkg/archives          # or $VCPKG_DEFAULT_BINARY_CACHE
    key: vcpkg-${{ hashFiles('ext/**/vcpkg.json') }}-x64-windows-${{ env.VCPKG_TOOL_VERSION }}
- run: bundle exec rake compile

The default files binary-cache provider needs no secrets and is immune to provider churn (the x-gha provider was removed upstream in June 2025 — vcdeps never references it). The NuGet-on-GitHub-Packages alternative is also viable; note that Microsoft's own docs currently contradict themselves on whether the workflow GITHUB_TOKEN (with packages: write) suffices or a classic PAT is required — try GITHUB_TOKEN first and fall back to a PAT if pushes return 401.

Library API

require "vcdeps"

Vcdeps.default_triplet            # => "x64-windows"  (arm64 -> "arm64-windows")
Vcdeps.home                       # => "C:\\Users\\me\\AppData\\Local\\vcdeps"

Vcdeps.tool                       # => #<struct Vcdeps::Tool ... source=:devenv> or nil
Vcdeps.tool!                      # raises Vcdeps::ToolNotFound (with remedies) on a miss
Vcdeps.bootstrap!                 # private vcpkg under <home>\vcpkg (consent-gated)

inst = Vcdeps.install!(manifest: "ext/foo")
inst.prefix                       # => "...\\installed\\<key>\\x64-windows"
inst.ports                        # => [#<struct Vcdeps::Port name="zlib" version="1.3.1" ...>]
inst.dlls                         # => ["...\\bin\\zlib1.dll", ...] (release only, never debug)

Vcdeps.vendor!(inst, into: "lib/foo/vendor")  # DLLs + licenses + preload.rb (a SYNC)
Vcdeps.install!(manifest: "ext/foo")          # 2nd call: marker hit, returns in <100 ms
Vcdeps.baseline!(manifest: "ext/foo")         # => "9f3dca…" (writes builtin-baseline)

Configuration

Env var Effect
VCDEPS_HOME State root (default %LOCALAPPDATA%\vcdeps); keep it short + ASCII.
VCDEPS_TRIPLET Triplet override (between --with-vcdeps-triplet and the derived default).
VCDEPS_BOOTSTRAP =1 consents to a private bootstrap from a non-interactive gem install.
VCDEPS_METRICS =1 re-enables vcpkg telemetry (disabled by default — a gem install must not phone home).

Honored vcpkg vars pass through untouched: VCPKG_ROOT, VCPKG_DEFAULT_BINARY_CACHE, VCPKG_BINARY_SOURCES, VCPKG_DOWNLOADS. Ignored on purpose: VCPKG_DEFAULT_TRIPLET — a machine-global default for unrelated C++ projects must never silently change a Ruby extension's ABI.

Errors

Vcdeps::Error                 (root; everything vcdeps raises)
├── Vcdeps::ToolNotFound      (no usable vcpkg anywhere — message lists remedies)
├── Vcdeps::BootstrapError    (private bootstrap failed: download / bootstrap-standalone)
├── Vcdeps::ManifestError     (vcpkg.json missing/unparsable/missing builtin-baseline)
├── Vcdeps::TripletError      (unknown arch, static-CRT triplet, arch mismatch)
└── Vcdeps::InstallError      (`vcpkg install` exited nonzero; carries #command/#status/#log_tail)

API-misuse uses Ruby's own ArgumentError/TypeError. No error ever prints a multi-screen dump; InstallError#log_tail is capped at 4000 chars.

Limitations

  • Manifest mode only — the VS-bundled and standalone vcpkg instances have no classic mode; vcdeps cannot offer one.
  • x64 support. arm64-mswin is expected to work (the code is arch-neutral and triplets derive from RbConfig) but is untested and unsupported until an arm64-mswin Ruby distribution exists.
  • Cold builds are slow — libxml2-class ports are tens of minutes the first time; the binary cache amortizes it. The first use of a baseline needs network for the registry git-tree fetch.
  • DLL base-name collisions across gems, and against Ruby's own bin\zlib1.dll et al.: first-loaded wins. vcdeps warns; it cannot arbitrate.
  • Ctrl-C during an install kills vcpkg.exe (so its install-root lock is released) but not the helper build processes it spawned (git/cmake/ninja/ cl); they exit on their own. Wait a moment, then rerun.

License

MIT.