Artifacts

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-devops extension
  • 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

  1. Use platform-specific package names for multi-platform binaries. Name packages my-tool-linux, my-tool-windows, my-tool-macos instead of publishing all platforms in one package. This reduces download size and simplifies per-platform consumption.

  2. Include a version file or --version flag 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.

  3. 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.

  4. 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.

  5. 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.

  6. Version with SemVer, not build numbers alone. Build numbers like 4567 do not communicate breaking changes. Use MAJOR.MINOR.BUILDID at minimum so consumers know when an upgrade might break their workflow.

  7. 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.

  8. 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.

  9. 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.

  10. 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.

References

Powered by Contentful