Skip to content

.NET CLI Tool Releases

This guide explains how to make a .NET CLI tool installable via GitHub releases using tag-based builds and a self-update command. The approach uses NuGet packages distributed through GitHub releases (not NuGet.org), with checksum verification for security.

  • .NET SDK that matches your TargetFramework (e.g., net8.0 -> 8.0.x, net9.0 -> 9.0.x)
  • GitHub repository
  • GitHub CLI (recommended for private repos)

The self-update command requires these NuGet packages:

<ItemGroup>
<PackageReference Include="Spectre.Console" Version="0.49.1" />
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
</ItemGroup>
PackagePurpose
Spectre.ConsoleColored console output (AnsiConsole.MarkupLine)
System.CommandLineCommand-line parsing and subcommand structure

Configure the .csproj file to package the application as a global .NET tool.

<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<!-- Tool Configuration -->
<PackAsTool>true</PackAsTool>
<ToolCommandName>mytool</ToolCommandName>
<!-- Package Metadata -->
<PackageId>mytool</PackageId>
<Title>My Tool CLI</Title>
<Description>Description of my tool</Description>
<Authors>Your Name</Authors>
<PackageProjectUrl>https://github.com/your-org/mytool</PackageProjectUrl>
<RepositoryUrl>https://github.com/your-org/mytool</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<!-- Version: Overridden by CI, defaults to 0.0.1 for local dev -->
<Version>0.0.1</Version>
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
</PropertyGroup>
</Project>

Key settings:

PropertyPurpose
PackAsToolEnables packaging as a global tool
ToolCommandNameThe command users type to run the tool
PackageIdThe package identifier (usually matches ToolCommandName)
VersionDefault version for local builds; CI overrides this

The IncludeSourceRevisionInInformationalVersion setting prevents git commit hashes from being appended to version strings.

If you set PackageReadmeFile, you must also include the file in the package:

<ItemGroup>
<None Include="../../README.md" Pack="true" PackagePath="/" />
</ItemGroup>

Create .github/workflows/release.yml to build and publish releases when tags are pushed.

name: Release
on:
push:
tags:
- 'v*.*.*'
- 'v*.*.*-*'
permissions:
contents: write
packages: write
env:
DOTNET_VERSION: '9.0.x' # Use the SDK that matches your TargetFramework
PROJECT_PATH: 'src/MyTool/MyTool.csproj'
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Extract version from tag
id: version
run: |
VERSION=${GITHUB_REF_NAME#v}
if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]]; then
echo "Error: Tag '$GITHUB_REF_NAME' does not contain a valid semantic version" >&2
exit 1
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Restore
run: dotnet restore ${{ env.PROJECT_PATH }}
- name: Build
run: dotnet build ${{ env.PROJECT_PATH }} -c Release --no-restore -p:Version=${{ steps.version.outputs.version }}
- name: Pack
run: dotnet pack ${{ env.PROJECT_PATH }} -c Release --no-build -o ./artifacts -p:Version=${{ steps.version.outputs.version }}
- name: Generate checksums
run: |
cd ./artifacts
for file in *.nupkg; do
sha256sum "$file" > "$file.sha256"
done
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
name: v${{ steps.version.outputs.version }}
body: |
## Install
Requires a .NET SDK that matches your target framework (e.g., 8.0 or 9.0).
```bash
gh release download --repo your-org/mytool --pattern 'install.sh' --output - | bash
```
## Update
```bash
mytool update
```
files: |
./artifacts/mytool.${{ steps.version.outputs.version }}.nupkg
./artifacts/mytool.${{ steps.version.outputs.version }}.nupkg.sha256
install.sh
generate_release_notes: true

The workflow:

  1. Triggers on semantic version tags (v1.0.0, v1.0.0-beta.1)
  2. Extracts and validates the version from the tag
  3. Builds with the version embedded in the assembly
  4. Creates a NuGet package
  5. Generates SHA256 checksums for integrity verification
  6. Creates a GitHub release with all artifacts

Create install.sh at the repository root. This script handles installation for users.

#!/usr/bin/env bash
set -euo pipefail
REPO="your-org/mytool"
TOOL_NAME="mytool"
RED='\033[0;31m'
GREEN='\033[0;32m'
NC='\033[0m'
info() { echo -e "${GREEN}[INFO]${NC} $1"; }
error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; }
command -v dotnet >/dev/null 2>&1 || error ".NET SDK not found. Install from https://dotnet.microsoft.com/download"
VERSION="${1:-latest}"
if [ "$VERSION" = "latest" ]; then
info "Fetching latest release..."
if command -v gh >/dev/null 2>&1; then
VERSION=$(gh release view --repo "$REPO" --json tagName -q '.tagName' 2>/dev/null | sed 's/^v//') || \
VERSION=$(curl -fsSL "https://api.github.com/repos/$REPO/releases/latest" | grep '"tag_name"' | sed -E 's/.*"v?([^"]+)".*/\1/')
else
VERSION=$(curl -fsSL "https://api.github.com/repos/$REPO/releases/latest" | grep '"tag_name"' | sed -E 's/.*"v?([^"]+)".*/\1/')
fi
if [ -z "$VERSION" ] || ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]]; then
error "Failed to determine latest version"
fi
else
if [ -z "$VERSION" ] || ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]]; then
error "Invalid version format. Expected semantic version (e.g., 1.2.3)"
fi
fi
info "Installing $TOOL_NAME v${VERSION}..."
TEMP_DIR=$(mktemp -d)
trap 'rm -rf "$TEMP_DIR"' EXIT
ASSET_NAME="${TOOL_NAME}.${VERSION}.nupkg"
CHECKSUM_NAME="${ASSET_NAME}.sha256"
info "Downloading ${ASSET_NAME} and checksum..."
if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then
gh release download "v${VERSION}" --repo "$REPO" --pattern "$ASSET_NAME" --pattern "$CHECKSUM_NAME" --dir "$TEMP_DIR" || \
error "Failed to download v${VERSION}"
else
DOWNLOAD_URL="https://github.com/$REPO/releases/download/v${VERSION}/${ASSET_NAME}"
CHECKSUM_URL="https://github.com/$REPO/releases/download/v${VERSION}/${CHECKSUM_NAME}"
if command -v curl >/dev/null 2>&1; then
curl -fsSL "$DOWNLOAD_URL" -o "$TEMP_DIR/$ASSET_NAME" || error "Failed to download $ASSET_NAME"
curl -fsSL "$CHECKSUM_URL" -o "$TEMP_DIR/$CHECKSUM_NAME" || error "Failed to download checksum"
elif command -v wget >/dev/null 2>&1; then
wget -q "$DOWNLOAD_URL" -O "$TEMP_DIR/$ASSET_NAME" || error "Failed to download $ASSET_NAME"
wget -q "$CHECKSUM_URL" -O "$TEMP_DIR/$CHECKSUM_NAME" || error "Failed to download checksum"
else
error "No download tool found (gh, curl, or wget)"
fi
fi
info "Verifying package integrity..."
EXPECTED_CHECKSUM=$(cat "$TEMP_DIR/$CHECKSUM_NAME" | awk '{print $1}')
if command -v sha256sum >/dev/null 2>&1; then
ACTUAL_CHECKSUM=$(sha256sum "$TEMP_DIR/$ASSET_NAME" | awk '{print $1}')
elif command -v shasum >/dev/null 2>&1; then
ACTUAL_CHECKSUM=$(shasum -a 256 "$TEMP_DIR/$ASSET_NAME" | awk '{print $1}')
else
error "No SHA256 tool found (sha256sum or shasum)"
fi
if [ "$EXPECTED_CHECKSUM" != "$ACTUAL_CHECKSUM" ]; then
error "Checksum verification failed"
fi
if dotnet tool list -g | grep -q "^$TOOL_NAME "; then
info "Updating existing installation..."
dotnet tool update -g --add-source "$TEMP_DIR" "$TOOL_NAME" >/dev/null
else
info "Installing..."
dotnet tool install -g --add-source "$TEMP_DIR" "$TOOL_NAME" >/dev/null
fi
TOOLS_PATH="$HOME/.dotnet/tools"
if [[ ":$PATH:" != *":$TOOLS_PATH:"* ]]; then
echo ""
echo -e "${RED}ACTION REQUIRED:${NC} Add .NET tools to your PATH"
echo ""
echo "Run this command, then restart your terminal:"
echo ""
if [[ "$SHELL" == *"zsh"* ]]; then
echo " echo 'export PATH=\"\$PATH:\$HOME/.dotnet/tools\"' >> ~/.zshrc"
elif [[ "$SHELL" == *"bash"* ]]; then
echo " echo 'export PATH=\"\$PATH:\$HOME/.dotnet/tools\"' >> ~/.bashrc"
else
echo " echo 'export PATH=\"\$PATH:\$HOME/.dotnet/tools\"' >> ~/.profile"
fi
echo ""
else
info "Done! Run '$TOOL_NAME --help' to get started."
fi

The script:

  1. Detects available download tools (prefers gh for private repos)
  2. Fetches the latest version or uses a specified version
  3. Downloads the package and checksum
  4. Verifies SHA256 integrity before installation
  5. Uses dotnet tool install/update from the local package
  6. Provides PATH configuration instructions if needed

Add an update command to your CLI following the BaseCommand<TRequest> pattern from the CLI Design Playbook. The command delegates all update logic to IUpdateService.

IUpdateService.cs
public interface IUpdateService
{
/// <summary>Gets the current installed version from assembly metadata.</summary>
string GetCurrentVersion();
/// <summary>Validates that prerequisites for update operations are met.</summary>
Task<Result<bool>> CheckPrerequisitesAsync(CancellationToken ct = default);
/// <summary>Fetches the latest release version from GitHub.</summary>
Task<Result<string>> GetLatestVersionAsync(CancellationToken ct = default);
/// <summary>Downloads and installs the specified version via dotnet tool update.</summary>
Task<Result<bool>> PerformUpdateAsync(
string version,
bool dryRun = false,
CancellationToken ct = default);
}
// Request record - ALWAYS FIRST in file
public record UpdateRequest(
bool CheckOnly,
bool Force,
GlobalOptions GlobalOptions) : RequestBase(GlobalOptions);
// Command class
public class UpdateCommand : BaseCommand<UpdateRequest>
{
private readonly IUpdateService updateService;
private Option<bool> checkOption = null!;
private Option<bool> forceOption = null!;
public UpdateCommand(IUpdateService updateService)
: base("update", "Check for and install updates")
{
this.updateService = updateService;
}
protected override void RegisterArgumentsAndOptions()
{
checkOption = new(
aliases: ["--check", "-c"],
description: "Check for updates without installing",
getDefaultValue: () => false);
forceOption = new(
aliases: ["--force", "-f"],
description: "Force reinstall even if already on latest version",
getDefaultValue: () => false);
AddOption(checkOption);
AddOption(forceOption);
}
protected override Task<UpdateRequest> ParseRequestAsync(InvocationContext context)
{
return Task.FromResult(new UpdateRequest(
CheckOnly: context.ParseResult.GetValueForOption(checkOption),
Force: context.ParseResult.GetValueForOption(forceOption),
GlobalOptions: GlobalOptions.Parse(context)));
}
protected override Task<int> ValidateAsync(UpdateRequest request, CancellationToken ct)
{
if (request.CheckOnly && request.Force)
{
AnsiConsole.MarkupLine("[red]Error:[/] --check and --force cannot be used together.");
return Task.FromResult(1);
}
return Task.FromResult(0);
}
protected override async Task<int> HandleAsync(UpdateRequest request, CancellationToken ct)
{
string currentVersion = updateService.GetCurrentVersion();
AnsiConsole.MarkupLine($"[dim]Current version:[/] v{currentVersion}");
Result<bool> prereqs = await updateService.CheckPrerequisitesAsync(ct);
if (prereqs.IsError)
{
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(prereqs.ErrorMessage!)}");
return 1;
}
Result<string> latestResult = await updateService.GetLatestVersionAsync(ct);
if (latestResult.IsError)
{
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(latestResult.ErrorMessage!)}");
return 1;
}
string latestVersion = latestResult.Value!;
AnsiConsole.MarkupLine($"[dim]Latest version:[/] v{latestVersion}");
bool isUpToDate = CompareSemanticVersions(currentVersion, latestVersion) >= 0;
if (isUpToDate && !request.Force)
{
AnsiConsole.MarkupLine("[green]Already up to date.[/]");
return 0;
}
if (request.CheckOnly)
{
AnsiConsole.MarkupLine($"[yellow]Update available:[/] v{currentVersion} → v{latestVersion}");
AnsiConsole.MarkupLine("[dim]Run 'mytool update' to install.[/]");
return 0;
}
if (request.DryRun)
{
AnsiConsole.MarkupLine($"[dim]Dry-run: Would install v{latestVersion}[/]");
return 0;
}
Result<bool> updateResult = await updateService.PerformUpdateAsync(latestVersion, dryRun: false, ct);
if (updateResult.IsError)
{
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(updateResult.ErrorMessage!)}");
return 1;
}
AnsiConsole.MarkupLine($"[green]Successfully updated to v{latestVersion}![/]");
AnsiConsole.MarkupLine("[dim]Restart the tool to use the new version.[/]");
return 0;
}
}

PerformUpdateAsync downloads the .nupkg, verifies the checksum, and installs via dotnet tool update -g:

UpdateService.cs
public class UpdateService : IUpdateService
{
private const string GitHubRepo = "your-org/mytool";
private const string ToolName = "mytool";
private readonly IShellService shell;
public UpdateService(IShellService shell)
{
this.shell = shell;
}
public async Task<Result<bool>> PerformUpdateAsync(
string version,
bool dryRun = false,
CancellationToken ct = default)
{
if (dryRun) return Result<bool>.Success(true);
string tempDir = Path.Combine(Path.GetTempPath(), $"{ToolName}-update-{Guid.NewGuid():N}");
string assetName = $"{ToolName}.{version}.nupkg";
string checksumName = $"{assetName}.sha256";
try
{
Directory.CreateDirectory(tempDir);
if (!DownloadRelease(version, assetName, tempDir))
return Result<bool>.Error($"Failed to download {assetName}.");
if (!DownloadRelease(version, checksumName, tempDir))
return Result<bool>.Error($"Failed to download checksum.");
if (!VerifyChecksum(tempDir, assetName, checksumName))
return Result<bool>.Error("Checksum verification failed.");
bool isInstalled = IsToolInstalled(ToolName);
string verb = isInstalled ? "update" : "install";
ShellResult result = await shell.ExecuteAsync(
"dotnet",
$"tool {verb} -g --add-source \"{tempDir}\" {ToolName}",
ct: ct);
if (!result.Success)
return Result<bool>.Error($"dotnet tool {verb} failed: {result.StandardError}");
return Result<bool>.Success(true);
}
finally
{
if (Directory.Exists(tempDir))
Directory.Delete(tempDir, recursive: true);
}
}
}

Version Detection (implement as GetCurrentVersion() on UpdateService):

public string GetCurrentVersion()
{
Assembly assembly = Assembly.GetExecutingAssembly();
AssemblyInformationalVersionAttribute? infoVersion =
assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>();
if (infoVersion?.InformationalVersion is not null)
{
string version = infoVersion.InformationalVersion;
// Remove +metadata suffix
int plusIndex = version.IndexOf('+');
if (plusIndex >= 0)
{
version = version[..plusIndex];
}
return version;
}
return assembly.GetName().Version?.ToString(3) ?? "0.0.0";
}

Validate tag strings before shelling out:

If you accept a version or tag name from GitHub (or user input), validate it before passing it to any shell commands to avoid injection or unexpected argument parsing. A simple SemVer regex is enough:

private static string? ValidateAndExtractVersion(string versionString)
{
// Called before passing any GitHub tag/version string to shell commands
string version = versionString.StartsWith('v') ? versionString[1..] : versionString;
if (!Regex.IsMatch(version, @"^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$"))
{
return null;
}
int plusIndex = version.IndexOf('+');
return plusIndex >= 0 ? version[..plusIndex] : version;
}

Semantic Version Comparison:

Compare versions properly, accounting for pre-release versions (e.g., 1.0.0-beta.1 < 1.0.0):

private static int CompareSemanticVersions(string version1, string version2)
{
// Parse base versions and pre-release identifiers
(Version? baseVer1, string? preRelease1) = ParseSemanticVersion(version1);
(Version? baseVer2, string? preRelease2) = ParseSemanticVersion(version2);
// Compare base versions first
int baseComparison = baseVer1.CompareTo(baseVer2);
if (baseComparison != 0) return baseComparison;
// Stable release > pre-release
if (preRelease1 is null && preRelease2 is null) return 0;
if (preRelease1 is null) return 1;
if (preRelease2 is null) return -1;
// Compare pre-release identifiers lexically
return ComparePreReleaseIdentifiers(preRelease1, preRelease2);
}

Download with Fallback:

Support multiple download tools with graceful fallback:

private static bool DownloadRelease(string version, string assetName, string tempDir)
{
// Try gh first if authenticated
if (CommandExists("gh") && IsGitHubCliAuthenticated())
{
// Use gh release download...
if (success) return true;
}
// Fallback to curl
if (CommandExists("curl"))
{
// Use curl -fsSL...
if (success) return true;
}
// Fallback to wget
if (CommandExists("wget"))
{
// Use wget -q...
if (success) return true;
}
return false;
}

Checksum Verification:

Always verify package integrity before installation:

private static bool VerifyChecksum(string directory, string fileName, string checksumFileName)
{
string checksumContent = File.ReadAllText(checksumPath).Trim();
string expectedChecksum = checksumContent.Split(' ')[0].ToLowerInvariant();
using SHA256 sha256 = SHA256.Create();
using FileStream stream = File.OpenRead(filePath);
byte[] hashBytes = sha256.ComputeHash(stream);
string actualChecksum = BitConverter.ToString(hashBytes)
.Replace("-", "").ToLowerInvariant();
return actualChecksum == expectedChecksum;
}

Register both the service and command in DI, then add the command from the service provider:

// Program.cs - DI registration
services.AddSingleton<IUpdateService, UpdateService>();
services.AddTransient<UpdateCommand>();
// ... build service provider ...
rootCommand.AddCommand(serviceProvider.GetRequiredService<UpdateCommand>());

Pre-flight check bypass: The update command should run even if pre-flight checks fail (e.g., a missing config file the update itself might fix). Skip pre-flight when the user is running update:

// Program.cs - before running pre-flight checks
bool isUpdateCommand = args.Length > 0 && args[0] == "update";
if (!isUpdateCommand)
{
IPreflightCheckService preflight = serviceProvider.GetRequiredService<IPreflightCheckService>();
if (!await preflight.RunChecksAsync(CancellationToken.None))
{
await serviceProvider.DisposeAsync();
return 1;
}
}

The snippets above omit helper methods for brevity. Key helpers to implement:

HelperPurpose
CommandExists(string)Check if a CLI tool is on PATH
IsGitHubCliAuthenticated()Check if gh auth status succeeds
IsToolInstalled(string)Check dotnet tool list -g output
GetLatestVersionAsync()Call gh release view --json tagName (or GitHub API via curl)
DownloadRelease(version, assetName, dir)Download with gh → curl → wget fallback chain
VerifyChecksum(dir, file, checksumFile)SHA256 verify using System.Security.Cryptography.SHA256
CompareSemanticVersions(v1, v2)Full SemVer comparison including pre-release identifiers

CheckPrerequisitesAsync should verify that dotnet is available and at least one download tool is present (gh with auth, or curl, or wget). Unlike most commands, the update command should not run pre-flight checks — it may be the fix for a broken installation.

These values must stay synchronized across files:

ValueLocations
Tool name.csproj (ToolCommandName, PackageId), install.sh (TOOL_NAME), UpdateCommand.cs (ToolName)
Repositoryinstall.sh (REPO), UpdateCommand.cs (Repo), .csproj (RepositoryUrl)

Add a comment in the .csproj as a reminder:

<!-- NOTE: PackageId and ToolCommandName must match TOOL_NAME in UpdateCommand.cs
and install.sh. Update all locations if changing this value. -->
<PackageId>mytool</PackageId>
Terminal window
# Tag and push
git tag v0.1.0
git push origin v0.1.0
  1. Check the Actions tab in GitHub for workflow completion
  2. Verify the release contains:
    • mytool.0.1.0.nupkg
    • mytool.0.1.0.nupkg.sha256
    • install.sh
Terminal window
# Download and run install script
gh release download --repo your-org/mytool --pattern 'install.sh' --output - | bash
# Verify installation
mytool --version
Terminal window
# Check for updates
mytool update --check
# Force reinstall current version
mytool update --force

For private repositories:

  1. Users must have gh installed and authenticated
  2. The install script automatically uses gh when authenticated
  3. Direct curl/wget downloads fail without authentication

Install instructions for private repos:

Terminal window
# Ensure gh is authenticated
gh auth login
# Then install
gh release download --repo your-org/mytool --pattern 'install.sh' --output - | bash