Post

Creating a Hierarchical Local NuGet Feed on macOS with dotnet

How to get 'dotnet nuget push' to lay out a local NuGet feed on macOS hierarchically, with one lowercase folder per package and version, by seeding it with a single package.

A local NuGet feed is just a folder, but if you want it laid out properly (one folder per package, one subfolder per version) dotnet nuget push won’t do that for you out of the box. Seed the folder with one correctly laid-out package and every push after that follows suit.

I wanted a local feed on my Mac for testing packages before publishing them. The obvious command was:

dotnet nuget push "*.nupkg" --source ~/Development/NuGet

It worked, but every package landed flat at the top of the folder. What I wanted was the hierarchical layout NuGet itself uses, which resolves faster once the feed grows and is much tidier to browse.

Why the feed comes out flat

dotnet nuget push doesn’t pick a layout of its own. When the source is a local folder, it looks at what is already there and copies the new package to match. An empty folder is treated as a flat feed, so the first package goes in at the top level, and so does every one after it.

The hierarchical layout it can produce looks like this, with package IDs and versions lowercased:

~/Development/NuGet/
  mycompany.mylib/
    1.0.0/
      mycompany.mylib.1.0.0.nupkg
      mycompany.mylib.1.0.0.nupkg.sha512
      mycompany.mylib.nuspec

If the folder already contains one package in that shape, push recognises it and lays out everything else the same way.

Seeding the feed

The fix is to lay out the first package by hand. This script does it for you. It reads the ID and version from the package’s own .nuspec, lowercases them to match NuGet convention, and writes the three files push expects. Save it as seed-feed.sh:

#!/bin/bash
set -euo pipefail

if [ $# -lt 1 ]; then
  echo "Usage: $0 <package.nupkg> [feed-folder]" >&2
  exit 1
fi

PKG="$1"
FEED="${2:-$HOME/Development/NuGet}"

NUSPEC=$(unzip -p "$PKG" '*.nuspec' | tr -d '\r')
lower() { tr '[:upper:]' '[:lower:]'; }

ID=$(printf '%s\n' "$NUSPEC" | sed -n 's:.*<id>[[:space:]]*\([^<]*\)[[:space:]]*</id>.*:\1:p' | head -1 | lower)
VER=$(printf '%s\n' "$NUSPEC" | sed -n 's:.*<version>[[:space:]]*\([^<]*\)[[:space:]]*</version>.*:\1:p' | head -1 | lower)

if [ -z "$ID" ] || [ -z "$VER" ]; then
  echo "Couldn't read the package ID and version from $PKG" >&2
  exit 1
fi

DEST="$FEED/$ID/$VER"
mkdir -p "$DEST"
cp "$PKG" "$DEST/$ID.$VER.nupkg"
openssl dgst -sha512 -binary "$PKG" | base64 > "$DEST/$ID.$VER.nupkg.sha512"
printf '%s\n' "$NUSPEC" > "$DEST/$ID.nuspec"

echo "Seeded $DEST"

Run it against any one of your packages:

chmod +x seed-feed.sh
./seed-feed.sh MyCompany.MyLib.1.0.0.nupkg

The feed path defaults to ~/Development/NuGet; pass another path as the second argument if yours lives elsewhere. The lowercasing uses tr rather than Bash’s ${var,,} because macOS still ships Bash 3.2, which doesn’t support it.

The script uses the version exactly as the .nuspec gives it. NuGet normalises versions for folder names, padding 1.0 to 1.0.0 and dropping any +build metadata. Packages built with dotnet pack are normally already in that form, but if yours aren’t, seed with one that is.

Pushing the rest

With the feed seeded, the plain dotnet workflow does the right thing:

dotnet pack -c Release -o ./artifacts
dotnet nuget push "./artifacts/*.nupkg" --source ~/Development/NuGet

Each package now gets its own lowercase folder. Keep the quotes around the wildcard so dotnet expands it rather than the shell. A local folder needs no API key.

To restore from the feed in your projects, register it as a source once:

dotnet nuget add source "$HOME/Development/NuGet" --name Local

Use $HOME or a full path here, because a literal ~ saved into NuGet.Config isn’t always expanded. Check it took with dotnet nuget list source.

Gotchas

  • Clear out flat packages first. If any .nupkg files sit at the top level of the folder, it’s still detected as flat. Move them out before seeding.
  • Re-pushing a version. Pushing a version that’s already in the feed fails. Add --skip-duplicate to skip it quietly, or better, bump the version.
  • Stale restores. If you do replace a package without changing its version, projects may keep restoring the old copy from the global cache. Clear it with dotnet nuget locals all --clear.

Wrapping up

It’s a one-off step. Seed the folder with a single properly laid-out package, and from then on dotnet nuget push keeps the feed hierarchical and lowercase, with no extra tools to install.

If a package you’ve pushed still doesn’t show up in your projects, see Troubleshooting a Local NuGet Feed on macOS.

← Back to all posts