TL;DR We distribute a set of CLI tools that power TwinCAT CI/CD pipelines. Getting those tools onto customer machines reliably, versioned, and authenticated was a problem we’d been solving badly with scripts. We evaluated Chocolatey, NuGet, and a few other options before landing on Scoop with a private bucket and a token-authenticated download API. The result: tools update themselves, every download is audited, and onboarding a new node takes minutes instead of an afternoon.


This post is about the other side: the teams running their own CI/CD infrastructure. They need our DevTools (the CLIs that drive builds, generate documentation, and manage packages) installed and up-to-date on every build node. We use Jenkins ourselves, but the problem and the solution are the same regardless of which CI/CD system you run. Getting that right turns out to be a more interesting problem than it first looks.


The Problem: Managing CI/CD Prerequisites on Windows Nodes

Any team running self-hosted CI/CD on Windows eventually hits the same question: how do you get the tools your pipelines need onto every build node, keep them at the right version, and not have to think about it again?

For most software stacks this is a solved problem. Linux agents have apt or yum. Container-based CI sidesteps it entirely. But on Windows build nodes, the answer is less obvious, and it compounds when the tools in question are proprietary and access-controlled.

In our case, the tools are commercial CLI binaries distributed to customers with an active subscription. That adds a few hard constraints on top of the usual prerequisites problem:

  • Authentication required. Not every tool should be downloadable by everyone. Different customers have access to different tools based on what they’ve licensed.
  • Versioning matters. Pipelines that pin to a specific tool version should continue working even after we release a new one. Pipelines that want the latest should get it automatically.
  • Audit trail. For any tool we distribute commercially, we want to know who downloaded what and when.
  • It has to be maintainable. A solution that requires manual steps every time we ship a new tool version isn’t a solution: it just moves the problem.

The naive answer is: put them on a server and hand out download links. That works once. It doesn’t scale, it provides no versioning, and the “authentication” usually ends up being security through obscurity or a shared password that never rotates.


What We Evaluated for Windows CI/CD Tool Distribution

Containers and Linux-native tooling

The first thing worth acknowledging: a large chunk of the modern package management and CI tooling ecosystem simply doesn’t apply here. TwinCAT requires Windows. The TwinCAT runtime, the build toolchain, the Automation Interface: all Windows-only. That means no Linux agents, no Docker containers, no apt, no brew. Any solution has to work on Windows build nodes. That constraint rules out a lot of the options that would be obvious choices in a pure-software context.

Chocolatey

Chocolatey is the obvious first answer for Windows software distribution. It’s mature, widely used, and has good CI/CD support.

The problem is the hosting model for private packages. The public Chocolatey registry is exactly that: public. For proprietary tools you need a private feed, typically Nexus, ProGet, or Chocolatey for Business. That’s real server infrastructure to run, maintain, and back up. It also means packaging every release as a .nupkg, which adds a step to the release process and another thing that can go wrong.

For a handful of proprietary tools distributed to a controlled set of customers, standing up and maintaining a Chocolatey server felt like the wrong trade. The operational overhead is sized for organisations managing hundreds of packages, not a small product team with five CLIs.

NuGet

NuGet is designed for code libraries, not binary tools. You can abuse it for the latter, but the ergonomics are poor and the tooling assumes a .NET development context that doesn’t exist on most CI nodes. Ruled out early.

winget

winget is Microsoft’s official package manager, ships with Windows 10 and 11, and does support private package sources. On paper it’s the obvious answer.

The catch is what “private source” means in practice. winget’s private source model requires hosting a server that implements Microsoft’s REST source specification. That’s more infrastructure to run than a Chocolatey feed, not less, and the ecosystem around self-hosted winget sources is still relatively thin. winget also has rough edges in non-interactive CI contexts that Chocolatey and Scoop have long since ironed out. It may be the right answer in a few years. For now it adds complexity without adding capability.

Configuration management tools

Ansible, Puppet, Chef, Salt: if you’re already running one of these across your build node fleet, using it to ensure “tool X at version Y is present” is a completely reasonable approach. These tools are designed for exactly this kind of state management.

The constraint is the assumption embedded in that sentence: “if you’re already running one of these.” For teams that are, it’s worth considering. For teams that aren’t, adopting a full configuration management stack to solve a tool distribution problem is a large dependency for a narrow use case. Our customers run a wide range of setups, and most don’t have Ansible in the picture.

Custom download scripts

We already had something like this, and if you look at it honestly, it was a half-built package manager. A PowerShell script in each pipeline that downloaded the right binary from our server via SFTP. It knew which tool to fetch, where to put it, and how to make it callable. Taken far enough, that is what a package manager does.

The problem is everything that surrounds the happy path. How do you distribute the script itself? When the script needed to change (and it did), every pipeline needed to be updated separately. Versioning was possible but manual: pipelines that needed a specific tool version had to ask for it explicitly, and there was no enforcement that the version they asked for was still available or hadn’t been superseded. Edge cases accumulated: what if the binary was already downloaded? What if the SFTP server was unreachable? Each of those needed special-casing, and the script kept growing.

It also had no visibility. No log of who had downloaded what, no way to tell whether a customer was still actively using a tool version we’d deprecated, no audit trail of any kind.


Why Scoop

Scoop is mostly known as a developer workstation tool: a convenient way to install git, python, or ffmpeg without touching an installer wizard. What’s less obvious is that it runs equally well in non-interactive CI contexts. No UAC prompts, no administrator requirement, no interactive confirmation dialogs. It’s PowerShell-native, and CI nodes are just Windows machines that happen to run unattended. Once you make that mental shift, Scoop becomes a surprisingly strong fit for managing build tool dependencies on CI nodes.

The core idea: a “bucket” is just a Git repository containing JSON manifest files. Each manifest describes a package: its current version, download URL, hash, and how to update. Scoop reads manifests, fetches the binary, verifies the hash, and puts it in the right place. There’s no central registry, no special server, and no packaging format. A bucket is just files in a Git repo.

This is appealing for several reasons.

No infrastructure to run. The bucket is just a private Git repository (GitHub, GitLab, a self-hosted Gitea instance, whatever you already have). There’s nothing new to stand up, and nothing to maintain beyond the JSON files in the bucket itself.

Versioning is built in. Scoop uses Git history to support pinning specific versions. A customer who needs zkmake 1.9.0 can install exactly that version and hold it. A customer who always wants the latest runs scoop update and gets it. Both cases are handled natively.

Authentication fits our model. Scoop has a private_hosts configuration that lets you attach HTTP headers to requests against a specific domain. In practice: each customer gets a token from us, configures it once on their CI nodes, and Scoop automatically sends it with every download request. No credential embedded in a manifest, no shared password, no environment variable juggling in pipeline scripts.

Download caching. Scoop maintains a local cache of downloaded binaries. In a CI context this matters: if a tool is already cached from a previous run, Scoop skips the download entirely. For teams running many builds per day on the same node, this eliminates redundant traffic to our API and keeps build times consistent regardless of network conditions.


The Authentication Layer

Scoop handles the mechanics of attaching a token to download requests. The other side of that is our download API, which validates the token, checks whether that client is authorised to download the requested tool, streams the binary, and writes an audit log entry for every request.

Each customer gets a unique token tied to their subscription, provided during onboarding or on request via our contact page. The token is scoped: it only grants access to the tools they’ve licensed. If a token is compromised, we rotate it for that customer without affecting anyone else. The audit log gives us a record of every download: when, by whom, and which version.

The version check endpoint that Scoop uses to detect new releases is also authenticated the same way, so even the metadata about what version is current isn’t public.


How It All Fits Together

The full picture has three moving parts:

The bucket. A private Git repository with one JSON manifest per tool. Each manifest records the current version, the download URL, and the SHA256 hash of the binary. When we release a new version, our Jenkins release pipeline automatically updates the manifest, commits it, and pushes. No manual step, no risk of forgetting.

The download API. A lightweight backend that handles authentication, authorisation, and logging. Scoop’s download requests hit this API; if the token is valid and the client is authorised, the file is served. If not, the request is rejected before anything is transferred.

Node setup. When we provision or reprovision a Jenkins build node, a housekeeping job runs automatically. It installs Scoop if it’s not already there, configures the token for that node from our credential store, and adds the Zeugwerk bucket. From that point, scoop install zkmake works, and scoop update * keeps everything current.


What We Got Right and What We Didn’t

Use private_hosts, not download_headers. If you’re building anything with Scoop that involves authenticated downloads, this is the most useful thing in this post. Scoop supports a download_headers field directly in the manifest JSON, which looks like the obvious place to put auth credentials: the manifest describes how to download the package, so it should describe the auth too. Our first implementation did exactly that. The problem is that it hard-codes a credential into a file that lives in a Git repository. Even in a private repo, that’s the wrong model. Scoop’s private_hosts configuration is the right answer: it attaches HTTP headers to any request matching a domain pattern, configured once on the node, entirely outside the manifest. The manifests stay clean and credential-free, and rotating a token means updating node config, not touching a file in version control. It’s not prominently documented, but it’s the correct approach for any authenticated Scoop bucket.

certutil output is locale-dependent. We compute the SHA256 hash of each binary during the release pipeline using Windows’ built-in certutil tool and write it into the manifest. On an English Windows system, certutil -hashfile file.tar.gz SHA256 outputs a header line reading “SHA256 Hash of file:” followed by the hex string. On a German Windows system the same header reads “SHA256-Hashvon…”, which our filter was not catching, causing the German header text to end up embedded in the hash value in the manifest. Scoop then rejected the binary because the hash didn’t match. The fix was trivial (case-insensitive filtering), but it only showed up on German-locale build nodes. Worth knowing if your CI runs on anything other than English Windows.

What we got right was keeping the bucket separate from everything else. The bucket is just data: it doesn’t contain secrets, doesn’t require special permissions to read (once you have a token), and can be inspected or audited without touching the rest of the system. That separation has made it easy to reason about and easy to extend.


What a TwinCAT CI/CD Pipeline Looks Like in Practice

There are two moving parts on the node side. The first is a housekeeping job that runs automatically when a new node comes online and once a night on existing nodes. It ensures Scoop is installed, configures the authentication token from the credential store, adds the Zeugwerk bucket, and installs or updates the tools. The job is idempotent: running it on a node that’s already set up correctly is a no-op. This means node provisioning is fully automated and a rebuilt node converges to the right state without any manual steps.

The core of that housekeeping script looks roughly like this (the bucket is at github.com/Zeugwerk/scoop-bucket). $Token is the per-client bearer token we provide during onboarding; in practice it comes from the CI credential store rather than being hardcoded:

# 1. Install Scoop if not already present
if (-not (Get-Command scoop -ErrorAction SilentlyContinue)) {
    Invoke-RestMethod get.scoop.sh | Invoke-Expression
}

# 2. Register the token with Scoop's private_hosts config.
#    This tells Scoop to attach an Authorization header to every
#    download request that matches https://api.zeugwerk.dev/*.
#    The result is that authentication happens automatically,
#    without any credentials appearing in pipeline scripts.
$configFile = "$HOME\.config\scoop\config.json"
New-Item (Split-Path $configFile) -ItemType Directory -Force | Out-Null
$config = if (Test-Path $configFile) {
    Get-Content $configFile -Raw | ConvertFrom-Json
} else {
    [PSCustomObject]@{}
}
$entry = [PSCustomObject]@{
    match   = "https://api.zeugwerk.dev/*"
    headers = "Authorization=Bearer $Token"
}
# Remove any existing entry for the same domain, then add the new one
$config.private_hosts = @($config.private_hosts | Where-Object { $_ -and $_.match -ne $entry.match }) + $entry
$config | ConvertTo-Json -Depth 10 | Set-Content $configFile

# 3. Add the Zeugwerk bucket and install the tools
scoop bucket add zeugwerk https://github.com/Zeugwerk/scoop-bucket
scoop install zkmake zkdoc zkinstall

The second part is the pipeline itself, which barely needs to think about tools at all. Here’s what a minimal GitLab CI job looks like for a customer running their own Windows runner with TwinCAT installed:

build:
  tags: [windows]
  script:
    # tools are pre-installed on the node via one-time setup
    - scoop update zkmake zkdoc
    - zkmake build --update-snapshots --kill-all
    - zkdoc --docfx reference --output documentation .
  artifacts:
    paths: ["*.library", "documentation/"]

That’s it. No download logic, no credential handling in the pipeline, no hardcoded paths. Scoop resolves the version, handles the authentication against our API transparently, and serves from the local cache if the binary hasn’t changed. The pipeline stays clean and the tool management stays where it belongs, outside the pipeline definition.


What This Enables

Before this setup, installing tools on a new Jenkins node meant someone working through a manual process: download the right binary, put it in the right place, update the pipeline configuration to point at it. Reproducibility depended on whoever did the setup remembering all the steps correctly.

For customers running our tools in their own pipelines, onboarding is a short script and a token. For us, releasing a new version means committing to a manifest, and distribution takes care of itself.

The setup is not TwinCAT-specific. If you’re distributing any proprietary Windows CLI tooling, whether commercial or internal, the same pattern applies: a private Git repository as a bucket, a lightweight authenticated download API in front of your binaries, and Scoop handling the client-side versioning and caching. The pieces fit together for any situation where you need controlled, versioned distribution to Windows CI nodes.

Questions about how we approach DevTool distribution, or curious whether a similar setup would fit your infrastructure? Get in touch.