Universal Packages for Binary Distribution
A practical guide to using Azure Artifacts Universal Packages for distributing binaries, tools, configuration bundles, and large files that do not fit into traditional package ecosystems like npm or NuGet.
Universal Packages for Binary Distribution
Overview
Universal Packages are Azure Artifacts' answer to a problem that npm, NuGet, and PyPI do not solve: distributing arbitrary files and binaries. If you need to distribute a compiled CLI tool, a machine learning model, a database migration bundle, a configuration archive, or any binary artifact that does not fit neatly into a language-specific package ecosystem, Universal Packages give you versioned, authenticated, feed-managed distribution with the same tooling you already use for code packages.
I use Universal Packages for distributing internal developer tools, sharing build outputs between pipelines, and packaging configuration bundles that multiple services consume. The az artifacts universal CLI is straightforward once you understand the publish/download model, and pipeline integration through the UniversalPackages@0 task handles authentication automatically. This article covers everything from basic usage through complex multi-platform binary distribution pipelines.
Prerequisites
- An Azure DevOps organization with Azure Artifacts enabled
- Azure CLI installed with the
azure-devopsextension - An Azure Artifacts feed (Universal Packages share feeds with other package types)
- An Azure DevOps Personal Access Token (PAT) with Packaging (Read & Write) scope
- Node.js 18+ for the automation examples
- Familiarity with Azure Pipelines YAML syntax
What Universal Packages Are (and Are Not)
Universal Packages are versioned zip archives stored in Azure Artifacts feeds. Unlike npm or NuGet packages, they have no dependency resolution, no install scripts, no package manifest schema beyond a name and version. They are intentionally simple: you publish a directory of files, you download a directory of files.
Good uses:
- Compiled CLI tools and executables
- Machine learning models and datasets
- Configuration bundles (Terraform modules, Kubernetes manifests)
- Build artifacts shared between pipelines
- Database migration scripts
- Signed binaries that need versioned distribution
- Docker context archives
- Large test fixtures
Bad uses (use a proper package manager instead):
- JavaScript libraries (use npm)
- .NET libraries (use NuGet)
- Python modules (use PyPI/pip)
- Anything with transitive dependencies
Installing the Azure CLI Extension
Universal Packages require the azure-devops extension for Azure CLI:
# Install the extension
az extension add --name azure-devops
# Verify
az extension show --name azure-devops --query version
# Output: "1.0.1"
# Login
az devops configure --defaults organization=https://dev.azure.com/my-organization project=my-project
az login
If you prefer not to use az login interactively, set a PAT:
export AZURE_DEVOPS_EXT_PAT="your-pat-here"
Publishing Universal Packages
From the Command Line
The basic publish command takes a directory and uploads its contents as a versioned package:
# Create a directory with your files
mkdir -p ./dist/my-tool
cp ./build/my-tool.exe ./dist/my-tool/
cp ./build/config.json ./dist/my-tool/
cp ./README.md ./dist/my-tool/
# Publish as a universal package
az artifacts universal publish \
--organization https://dev.azure.com/my-organization \
--project my-project \
--scope project \
--feed my-packages \
--name my-tool \
--version 1.0.0 \
--path ./dist/my-tool \
--description "Internal CLI tool for database management"
Output:
{
"name": "my-tool",
"version": "1.0.0",
"description": "Internal CLI tool for database management",
"publishDate": "2026-02-09T15:30:00.000Z",
"size": 4521984
}
Version Rules
Universal Package versions follow SemVer: MAJOR.MINOR.PATCH with optional pre-release suffix:
# Release version
az artifacts universal publish --name my-tool --version 1.0.0 --path ./dist
# Pre-release version
az artifacts universal publish --name my-tool --version 1.1.0-beta.1 --path ./dist
# Build metadata
az artifacts universal publish --name my-tool --version 1.0.0+build.4567 --path ./dist
Like other Azure Artifacts package types, Universal Package versions are immutable. You cannot overwrite a published version. Increment the version number for each publish.
Publishing Large Files
Universal Packages support files up to 4 TB in size. The CLI automatically handles chunked uploads for large files:
# Publishing a 2 GB machine learning model
az artifacts universal publish \
--feed data-packages \
--name sentiment-model \
--version 3.2.0 \
--path ./models/sentiment \
--description "Sentiment analysis model trained on customer reviews"
# Output shows upload progress:
# Uploading 1 of 1 files (2.1 GB)...
# [====================] 100%
# Published [email protected] (2.1 GB)
Downloading Universal Packages
From the Command Line
# Download to a specific directory
az artifacts universal download \
--organization https://dev.azure.com/my-organization \
--project my-project \
--scope project \
--feed my-packages \
--name my-tool \
--version 1.0.0 \
--path ./downloaded-tool
The --path directory is created if it does not exist. The package contents are extracted directly into it -- there is no wrapper directory.
Downloading the Latest Version
# Download the latest release version
az artifacts universal download \
--feed my-packages \
--name my-tool \
--version '*' \
--path ./downloaded-tool
The * wildcard resolves to the latest version. You can also use version ranges:
# Latest patch in the 1.x line
az artifacts universal download --name my-tool --version '1.*' --path ./tool
# Latest minor version in the 2.x line
az artifacts universal download --name my-tool --version '2.*' --path ./tool
Pipeline Integration
The UniversalPackages@0 Task
Azure Pipelines provides a dedicated task for Universal Packages that handles authentication automatically:
# Publishing in a pipeline
steps:
- task: UniversalPackages@0
inputs:
command: publish
publishDirectory: $(Build.ArtifactStagingDirectory)/my-tool
feedsToUsePublish: internal
vstsFeedPublish: my-packages
vstsFeedPackagePublish: my-tool
versionOption: custom
versionPublish: $(Build.BuildId)
displayName: Publish Universal Package
# Downloading in a pipeline
steps:
- task: UniversalPackages@0
inputs:
command: download
downloadDirectory: $(System.DefaultWorkingDirectory)/tools
feedsToUse: internal
vstsFeed: my-packages
vstsFeedPackage: my-tool
vstsPackageVersion: '*'
displayName: Download latest tool version
Multi-Platform Binary Build Pipeline
This is where Universal Packages really shine. Build binaries on multiple platforms and publish them as versioned packages:
trigger:
branches:
include:
- main
- release/*
variables:
toolVersion: 1.2.$(Build.BuildId)
feedName: internal-tools
stages:
- stage: Build
jobs:
- job: BuildWindows
pool:
vmImage: windows-latest
steps:
- script: |
npm ci
npm run build:win
displayName: Build Windows binary
- task: CopyFiles@2
inputs:
sourceFolder: $(System.DefaultWorkingDirectory)/build/win
contents: '**'
targetFolder: $(Build.ArtifactStagingDirectory)/win
- task: PublishBuildArtifacts@1
inputs:
pathToPublish: $(Build.ArtifactStagingDirectory)/win
artifactName: build-windows
- job: BuildLinux
pool:
vmImage: ubuntu-latest
steps:
- script: |
npm ci
npm run build:linux
displayName: Build Linux binary
- task: CopyFiles@2
inputs:
sourceFolder: $(System.DefaultWorkingDirectory)/build/linux
contents: '**'
targetFolder: $(Build.ArtifactStagingDirectory)/linux
- task: PublishBuildArtifacts@1
inputs:
pathToPublish: $(Build.ArtifactStagingDirectory)/linux
artifactName: build-linux
- job: BuildMacOS
pool:
vmImage: macOS-latest
steps:
- script: |
npm ci
npm run build:macos
displayName: Build macOS binary
- task: CopyFiles@2
inputs:
sourceFolder: $(System.DefaultWorkingDirectory)/build/macos
contents: '**'
targetFolder: $(Build.ArtifactStagingDirectory)/macos
- task: PublishBuildArtifacts@1
inputs:
pathToPublish: $(Build.ArtifactStagingDirectory)/macos
artifactName: build-macos
- stage: Publish
dependsOn: Build
jobs:
- job: PublishPackages
pool:
vmImage: ubuntu-latest
steps:
- task: DownloadBuildArtifacts@1
inputs:
buildType: current
downloadType: specific
downloadPath: $(System.ArtifactsDirectory)
- task: UniversalPackages@0
inputs:
command: publish
publishDirectory: $(System.ArtifactsDirectory)/build-windows
feedsToUsePublish: internal
vstsFeedPublish: $(feedName)
vstsFeedPackagePublish: my-tool-windows
versionOption: custom
versionPublish: $(toolVersion)
displayName: Publish Windows package
- task: UniversalPackages@0
inputs:
command: publish
publishDirectory: $(System.ArtifactsDirectory)/build-linux
feedsToUsePublish: internal
vstsFeedPublish: $(feedName)
vstsFeedPackagePublish: my-tool-linux
versionOption: custom
versionPublish: $(toolVersion)
displayName: Publish Linux package
- task: UniversalPackages@0
inputs:
command: publish
publishDirectory: $(System.ArtifactsDirectory)/build-macos
feedsToUsePublish: internal
vstsFeedPublish: $(feedName)
vstsFeedPackagePublish: my-tool-macos
versionOption: custom
versionPublish: $(toolVersion)
displayName: Publish macOS package
Consuming Universal Packages in Downstream Pipelines
When a pipeline needs a tool or binary from another team's build:
steps:
- task: UniversalPackages@0
inputs:
command: download
downloadDirectory: $(Agent.ToolsDirectory)/my-tool
feedsToUse: internal
vstsFeed: internal-tools
vstsFeedPackage: my-tool-linux
vstsPackageVersion: '*'
displayName: Download tool
- script: |
chmod +x $(Agent.ToolsDirectory)/my-tool/my-tool
$(Agent.ToolsDirectory)/my-tool/my-tool --version
displayName: Verify tool works
Complete Working Example
This example builds a Node.js CLI tool into standalone executables using pkg, publishes per-platform Universal Packages, and provides an installer script that downloads the right version:
The CLI Tool
// cli.js -- Simple database migration tool
var fs = require("fs");
var path = require("path");
var args = process.argv.slice(2);
var command = args[0];
function showHelp() {
console.log("db-migrate - Database Migration Tool v" + getVersion());
console.log("");
console.log("Usage:");
console.log(" db-migrate up Run pending migrations");
console.log(" db-migrate down Rollback last migration");
console.log(" db-migrate status Show migration status");
console.log(" db-migrate create <name> Create new migration file");
console.log(" db-migrate --version Show version");
}
function getVersion() {
try {
var pkg = JSON.parse(fs.readFileSync(
path.join(__dirname, "package.json"), "utf8"
));
return pkg.version;
} catch (e) {
return "unknown";
}
}
function createMigration(name) {
if (!name) {
console.error("Error: Migration name required");
process.exit(1);
}
var timestamp = new Date().toISOString().replace(/[-:T]/g, "").split(".")[0];
var filename = timestamp + "_" + name + ".sql";
var template = "-- Migration: " + name + "\n" +
"-- Created: " + new Date().toISOString() + "\n\n" +
"-- Up\n\n-- Down\n";
var migrationsDir = path.join(process.cwd(), "migrations");
if (!fs.existsSync(migrationsDir)) {
fs.mkdirSync(migrationsDir, { recursive: true });
}
fs.writeFileSync(path.join(migrationsDir, filename), template);
console.log("Created: migrations/" + filename);
}
switch (command) {
case "up":
console.log("Running pending migrations...");
console.log("All migrations applied.");
break;
case "down":
console.log("Rolling back last migration...");
console.log("Rollback complete.");
break;
case "status":
console.log("Migration status: all up to date");
break;
case "create":
createMigration(args[1]);
break;
case "--version":
console.log(getVersion());
break;
default:
showHelp();
}
Build and Publish Pipeline
trigger:
branches:
include:
- main
variables:
version: 1.0.$(Build.BuildId)
feedName: internal-tools
packageName: db-migrate
stages:
- stage: Build
jobs:
- job: BuildExecutables
pool:
vmImage: ubuntu-latest
steps:
- task: NodeTool@0
inputs:
versionSpec: 20.x
- script: |
npm ci
npm install -g pkg
displayName: Install dependencies
- script: |
mkdir -p dist/linux dist/macos dist/windows
pkg cli.js --target node18-linux-x64 --output dist/linux/db-migrate
pkg cli.js --target node18-macos-x64 --output dist/macos/db-migrate
pkg cli.js --target node18-win-x64 --output dist/windows/db-migrate.exe
displayName: Build executables
- script: |
echo "Linux binary:"
ls -lh dist/linux/
echo "macOS binary:"
ls -lh dist/macos/
echo "Windows binary:"
ls -lh dist/windows/
displayName: Show build sizes
- task: PublishBuildArtifacts@1
inputs:
pathToPublish: dist
artifactName: executables
- stage: Publish
dependsOn: Build
condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
jobs:
- job: PublishPackages
pool:
vmImage: ubuntu-latest
steps:
- task: DownloadBuildArtifacts@1
inputs:
buildType: current
downloadType: single
artifactName: executables
downloadPath: $(System.ArtifactsDirectory)
- task: UniversalPackages@0
inputs:
command: publish
publishDirectory: $(System.ArtifactsDirectory)/executables/linux
feedsToUsePublish: internal
vstsFeedPublish: $(feedName)
vstsFeedPackagePublish: $(packageName)-linux
versionOption: custom
versionPublish: $(version)
displayName: Publish Linux package
- task: UniversalPackages@0
inputs:
command: publish
publishDirectory: $(System.ArtifactsDirectory)/executables/windows
feedsToUsePublish: internal
vstsFeedPublish: $(feedName)
vstsFeedPackagePublish: $(packageName)-windows
versionOption: custom
versionPublish: $(version)
displayName: Publish Windows package
- task: UniversalPackages@0
inputs:
command: publish
publishDirectory: $(System.ArtifactsDirectory)/executables/macos
feedsToUsePublish: internal
vstsFeedPublish: $(feedName)
vstsFeedPackagePublish: $(packageName)-macos
versionOption: custom
versionPublish: $(version)
displayName: Publish macOS package
- script: |
echo "Published $(packageName) v$(version) for all platforms"
displayName: Summary
Installer Script
Distribute this script to developers for easy installation:
#!/bin/bash
# install-db-migrate.sh -- Download and install the latest db-migrate tool
set -e
ORG="my-organization"
PROJECT="my-project"
FEED="internal-tools"
VERSION="${1:-*}"
# Detect platform
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
case "$OS" in
linux) PACKAGE="db-migrate-linux" ;;
darwin) PACKAGE="db-migrate-macos" ;;
*) echo "Unsupported OS: $OS"; exit 1 ;;
esac
INSTALL_DIR="${HOME}/.local/bin"
mkdir -p "$INSTALL_DIR"
echo "Downloading $PACKAGE (version: $VERSION)..."
az artifacts universal download \
--organization "https://dev.azure.com/${ORG}" \
--project "$PROJECT" \
--scope project \
--feed "$FEED" \
--name "$PACKAGE" \
--version "$VERSION" \
--path "$INSTALL_DIR"
chmod +x "$INSTALL_DIR/db-migrate"
echo "Installed db-migrate to $INSTALL_DIR/db-migrate"
echo "Version: $(${INSTALL_DIR}/db-migrate --version)"
echo ""
echo "Ensure $INSTALL_DIR is in your PATH"
REST API Management Script
// universal-manager.js -- List and manage Universal Packages
var https = require("https");
var org = process.env.AZURE_DEVOPS_ORG || "my-organization";
var project = process.env.AZURE_DEVOPS_PROJECT || "my-project";
var feedId = process.env.AZURE_DEVOPS_FEED || "internal-tools";
var pat = process.env.AZURE_DEVOPS_PAT;
if (!pat) {
console.error("Error: AZURE_DEVOPS_PAT is required");
process.exit(1);
}
var auth = Buffer.from(":" + pat).toString("base64");
function apiRequest(hostname, path, callback) {
var options = {
hostname: hostname,
path: path,
method: "GET",
headers: {
"Authorization": "Basic " + auth,
"Accept": "application/json"
}
};
var req = https.request(options, function(res) {
var data = "";
res.on("data", function(chunk) { data += chunk; });
res.on("end", function() { callback(null, res.statusCode, data); });
});
req.on("error", function(err) { callback(err); });
req.end();
}
function listPackages() {
var path = "/" + org + "/" + project + "/_apis/packaging/feeds/" + feedId +
"/packages?api-version=7.1&protocolType=UPack&$top=50";
apiRequest("feeds.dev.azure.com", path, function(err, status, data) {
if (err) return console.error("Error:", err.message);
var result = JSON.parse(data);
console.log("Universal Packages in " + feedId + ":");
console.log("====================================");
(result.value || []).forEach(function(pkg) {
var latest = pkg.versions[0];
console.log(" " + pkg.name);
console.log(" Latest: " + latest.version);
console.log(" Published: " + new Date(latest.publishDate).toLocaleDateString());
console.log(" Versions: " + pkg.versions.length);
console.log("");
});
});
}
function getVersions(packageName) {
var path = "/" + org + "/" + project + "/_apis/packaging/feeds/" + feedId +
"/packages?api-version=7.1&protocolType=UPack&packageNameQuery=" + packageName;
apiRequest("feeds.dev.azure.com", path, function(err, status, data) {
if (err) return console.error("Error:", err.message);
var result = JSON.parse(data);
if (!result.value || result.value.length === 0) {
console.log("Package not found: " + packageName);
return;
}
console.log("Versions of " + packageName + ":");
result.value[0].versions.forEach(function(v) {
console.log(" " + v.version + " -- " +
new Date(v.publishDate).toLocaleDateString());
});
});
}
var command = process.argv[2];
switch (command) {
case "list":
listPackages();
break;
case "versions":
getVersions(process.argv[3]);
break;
default:
console.log("Usage:");
console.log(" node universal-manager.js list");
console.log(" node universal-manager.js versions <package-name>");
}
node universal-manager.js list
# Output:
# Universal Packages in internal-tools:
# ====================================
# db-migrate-linux
# Latest: 1.0.4567
# Published: 2/9/2026
# Versions: 12
#
# db-migrate-windows
# Latest: 1.0.4567
# Published: 2/9/2026
# Versions: 12
#
# db-migrate-macos
# Latest: 1.0.4567
# Published: 2/9/2026
# Versions: 12
Common Issues and Troubleshooting
1. Azure CLI Extension Not Found
Error:
ERROR: 'artifacts' is not in the 'az' command group.
The azure-devops extension is not installed. Install it:
az extension add --name azure-devops
If already installed, update it:
az extension update --name azure-devops
2. Publish Fails with 409 Conflict
Error:
The package 'my-tool' version '1.0.0' already exists in the feed.
Universal Package versions are immutable. Increment the version number. In pipelines, use $(Build.BuildId) as part of the version to ensure uniqueness.
3. Download Fails with 401 Unauthorized
Error:
ERROR: (Unauthorized) The user '' is not authorized to access this resource.
Set the AZURE_DEVOPS_EXT_PAT environment variable or run az login. In pipelines, the UniversalPackages@0 task handles authentication automatically -- verify the build service has Reader access on the feed.
4. Large File Upload Times Out
Error:
ERROR: Connection timed out during upload.
For files larger than 1 GB, the upload may time out on slow connections. The CLI automatically chunks large uploads, but network interruptions can cause failures. Retry the publish -- the CLI is idempotent for incomplete uploads. If the problem persists, check your network's upload speed and consider publishing from a build agent with better connectivity.
5. UniversalPackages Task Not Available
Error:
##[error]Task UniversalPackages@0 not found.
The task requires the Azure Artifacts extension in your organization. Navigate to Organization Settings > Extensions and verify Azure Artifacts is installed. On Azure DevOps Services (cloud), it should be available by default.
6. Version Wildcard Returns Wrong Version
Error: Using --version '*' downloads a pre-release version instead of the latest stable release.
Version wildcards include pre-release versions by default. To get only stable versions, use a specific major version wildcard: --version '1.*' instead of --version '*'.
Best Practices
Use platform-specific package names for multi-platform binaries. Name packages
my-tool-linux,my-tool-windows,my-tool-macosinstead of publishing all platforms in one package. This reduces download size and simplifies per-platform consumption.Include a version file or
--versionflag in your binaries. When a user reports a bug, you need to know which version they are running. Bake the version into the binary at build time.Use Universal Packages for build-to-build artifact sharing. When pipeline B needs outputs from pipeline A and those outputs are not language packages (binaries, generated code, compiled assets), Universal Packages provide versioned, authenticated transfer with automatic retention.
Set retention policies on tool feeds. Internal tools accumulate versions quickly if published from every CI build. Set feed retention to keep the last 10-20 versions and use longer retention for release-tagged versions.
Automate installation with scripts. Provide an install script that detects the platform, downloads the correct package, and places the binary in the user's PATH. This removes friction from adoption.
Version with SemVer, not build numbers alone. Build numbers like
4567do not communicate breaking changes. UseMAJOR.MINOR.BUILDIDat minimum so consumers know when an upgrade might break their workflow.Sign binaries before publishing. If your organization has code signing certificates, sign executables before publishing them as Universal Packages. This prevents tampering alerts on Windows and macOS.
Do not use Universal Packages for things that should be code packages. If the artifact is a JavaScript library, use npm. If it is a .NET library, use NuGet. Universal Packages lack dependency resolution, install scripts, and ecosystem tooling. Use them only when no language-specific package manager fits.
Document what each Universal Package contains. The package description field and a README inside the package directory should explain what the package is, how to use it, and what version of runtime it requires.
Test downloads in a clean environment before announcing new versions. Build agents accumulate cached versions. Verify that a fresh download of the latest version works correctly before notifying consumers.