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 validatedVCPKG_INSTALLATION_ROOT(GitHub Actions), or a private instance created byvcdeps 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.("../../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.("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.dllet 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.