hammerkit

.NET

Restore NuGet packages, test and publish a .NET project in containers, with the restore cached by the lock files.

This tutorial restores the NuGet packages of a small .NET solution once, then runs dotnet test and dotnet publish against them without network access, each in an mcr.microsoft.com/dotnet/sdk:10.0 container and each skipped when its inputs haven't changed. You need hammerkit and Docker; the .NET SDK doesn't have to be installed on your machine.

The finished project is examples/tutorial-dotnet.

The project

A console app and an xUnit test project:

tutorial-dotnet/
├── Directory.Build.props
├── src/Greet/
│   ├── Greet.csproj
│   ├── packages.lock.json
│   ├── Greeter.cs
│   └── Program.cs
└── tests/Greet.Tests/
    ├── Greet.Tests.csproj
    ├── packages.lock.json
    └── GreeterTests.cs

Directory.Build.props applies to both projects. It sets up three things the build file relies on:

Directory.Build.props
<Project>
  <PropertyGroup>
    <!-- bin and obj go to artifacts/ instead of into the project folders -->
    <UseArtifactsOutput>true</UseArtifactsOutput>
    <!-- packages are restored into the repository, where the restore task keeps them -->
    <RestorePackagesPath>$(MSBuildThisFileDirectory).nuget/packages</RestorePackagesPath>
    <!-- packages.lock.json pins every package, transitive ones included -->
    <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
  </PropertyGroup>
</Project>
  • UseArtifactsOutput writes bin and obj to artifacts/ instead of into each project folder. The project folders are the tasks' sources, and they're mounted read-write: build output written into them would change the tasks' own inputs, and the next run would miss the cache.
  • RestorePackagesPath restores packages into .nuget/packages in the repository instead of ~/.nuget/packages, where a task can keep them as its output.
  • RestorePackagesWithLockFile writes packages.lock.json next to each project: the full list of packages with their hashes, transitive ones included. Commit them; they're what the restore is cached against.
tests/Greet.Tests/Greet.Tests.csproj
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <IsPackable>false</IsPackable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
    <PackageReference Include="xunit" Version="2.9.3" />
    <PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="../../src/Greet/Greet.csproj" />
  </ItemGroup>

</Project>
tests/Greet.Tests/GreeterTests.cs
using Greet;
using Xunit;

namespace Greet.Tests;

public class GreeterTests
{
    [Fact]
    public void GreetsByName()
    {
        Assert.Equal("Hello, hammerkit!", Greeter.Hello("hammerkit"));
    }
}

1. Restore packages

A task names the image it runs in, the files it reads (src) and the files it produces (generates). The restore task reads only the project and lock files:

.hammerkit.yaml
tasks:
  restore:
    description: download the locked NuGet packages
    image: mcr.microsoft.com/dotnet/sdk:10.0
    src:
      - Directory.Build.props
      - src/Greet/Greet.csproj
      - src/Greet/packages.lock.json
      - tests/Greet.Tests/Greet.Tests.csproj
      - tests/Greet.Tests/packages.lock.json
    generates: [.nuget]
    cmds:
      - dotnet restore tests/Greet.Tests --locked-mode
hammerkit restore

Restoring the test project restores the app it references too. Run it again and hammerkit skips it: no project or lock file changed. Editing a .cs file doesn't invalidate it either, since restore doesn't read them. --locked-mode makes the restore fail instead of quietly updating a lock file that no longer matches its project.

2. Test and publish

Both tasks depend on restore, which mounts its .nuget into their containers:

.hammerkit.yaml
tasks:
  test:
    description: run the unit tests
    image: mcr.microsoft.com/dotnet/sdk:10.0
    deps: [restore]
    src: [src, tests]
    cmds:
      - dotnet test tests/Greet.Tests --disable-build-servers

  publish:
    description: publish the app
    image: mcr.microsoft.com/dotnet/sdk:10.0
    deps: [restore]
    src: [src]
    generates:
      - path: out
        export: true
    cmds:
      - dotnet publish src/Greet --configuration Release --output out --disable-build-servers

Neither lists Directory.Build.props: a task also sees the sources of the tasks it depends on, and a change to them reruns it. dotnet test and dotnet publish restore again before they build, but every package is already in .nuget/packages, so that step finishes locally without the network.

--disable-build-servers stops the SDK from leaving its compiler server running after the command. Hammerkit keeps a task's container around for the next run, and the server would keep running in it for nothing.

A container task's outputs stay in a volume hammerkit manages; export: true also copies out into your project. The published app runs with dotnet out/Greet.dll.

test and publish don't depend on each other, so they run in parallel.

3. One task for CI

A task without cmds groups other tasks. ci names everything a change has to pass. The complete build file:

.hammerkit.yaml
tasks:
  restore:
    description: download the locked NuGet packages
    image: mcr.microsoft.com/dotnet/sdk:10.0
    src:
      - Directory.Build.props
      - src/Greet/Greet.csproj
      - src/Greet/packages.lock.json
      - tests/Greet.Tests/Greet.Tests.csproj
      - tests/Greet.Tests/packages.lock.json
    generates: [.nuget]
    cmds:
      - dotnet restore tests/Greet.Tests --locked-mode

  test:
    description: run the unit tests
    image: mcr.microsoft.com/dotnet/sdk:10.0
    deps: [restore]
    src: [src, tests]
    cmds:
      - dotnet test tests/Greet.Tests --disable-build-servers

  publish:
    description: publish the app
    image: mcr.microsoft.com/dotnet/sdk:10.0
    deps: [restore]
    src: [src]
    generates:
      - path: out
        export: true
    cmds:
      - dotnet publish src/Greet --configuration Release --output out --disable-build-servers

  ci:
    description: everything CI checks
    deps: [test, publish]
hammerkit run ci
Summary:
  ci       executed   7.3s
  publish  executed   5.7s
  restore  executed   3.7s
  test     executed   7.3s
  4 executed, 0 cached (0% cache hit), 7.3s total

Run it again without changing anything:

Summary:
  ci       cached     15ms
  publish  cached     0ms
  restore  skipped    0ms
  test     cached     0ms
  0 executed, 3 cached, 1 skipped (100% cache hit), 35ms total

restore is skipped: every task that needs it was a cache hit, so the packages aren't needed either. Edit a file in src and test and publish run again, against the packages restore already has.

Ignore what hammerkit and the SDK write into the project:

.gitignore
.hammerkit
.nuget
artifacts
out

Run it in CI

CI runs the same command: install hammerkit and run hammerkit run ci. A fresh runner starts with an empty cache; to reuse what your laptop or the previous run already built, share the cache as described in caching in CI.

Next steps

On this page