.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.csDirectory.Build.props applies to both projects. It sets up three things the
build file relies on:
<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>
UseArtifactsOutputwritesbinandobjtoartifacts/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.RestorePackagesPathrestores packages into.nuget/packagesin the repository instead of~/.nuget/packages, where a task can keep them as its output.RestorePackagesWithLockFilewritespackages.lock.jsonnext to each project: the full list of packages with their hashes, transitive ones included. Commit them; they're what the restore is cached against.
<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>
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:
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-modehammerkit restoreRestoring 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:
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-serversNeither 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:
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 ciSummary:
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 totalRun 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 totalrestore 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:
.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
- Tests that need a database: start it as a service and let the
task
needit, see services & networking. - Several projects in one repository: monorepos.
- A task reruns and you don't know why:
hammerkit explain.