Skip to content

Tamp NetCli

Scott Singleton edited this page May 15, 2026 · 4 revisions

Tamp.NetCli

Wrapper for the dotnet SDK CLI. Three sibling packages — Tamp.NetCli.V8, Tamp.NetCli.V9, Tamp.NetCli.V10 — each pinned to its respective .NET major. Pick the package that matches the SDK version you're driving. The wrapper code today is byte-for-byte identical across the three; the version pin in the package name is the consumer-facing contract per ADR 0002.

The namespace mirrors the package: using Tamp.NetCli.V10;.

Verbs

DotNet.Restore(s =>)   // dotnet restore
DotNet.Build(s =>)     // dotnet build
DotNet.Test(s =>)      // dotnet test
DotNet.Pack(s =>)      // dotnet pack
DotNet.Publish(s =>)   // dotnet publish

Each takes an optional Action<TSettings> configurer and returns a CommandPlan. Calling with no configurer is permitted — produces dotnet <verb> with no arguments.

Common knobs (every verb)

Inherited from DotNetSettingsBase:

Setter Effect
SetProject(string?) The .csproj / .sln / directory the command targets. Null lets dotnet auto-discover.
SetVerbosity(DotNetVerbosity) Quiet / Minimal / Normal / Detailed / Diagnostic--verbosity <level>.
SetWorkingDirectory(string?) Sets the spawned process's CWD.
EnvironmentVariables[k] = v Per-invocation env vars on top of the inherited environment.

Every plan automatically sets DOTNET_NOLOGO=1 and DOTNET_CLI_TELEMETRY_OPTOUT=1.

DotNet.Restore

DotNet.Restore(s => s
    .SetProject("./src/MyApp.csproj")
    .SetUseLockFile(true)
    .SetLockedMode(true)                    // CI-friendly: fail if lock file is out of date
    .AddSource("https://api.nuget.org/v3/index.json")
    .SetForce(true));

Settings: Project, NoCache, Force, Sources (list, repeated as multiple --source), PackagesDirectory, Runtime, ConfigFile, DisableParallel, ForceEvaluate, UseLockFile, LockedMode, LockFilePath.

DotNet.Build

DotNet.Build(s => s
    .SetConfiguration(Configuration.Release)
    .SetNoRestore(true)                     // pair with explicit Restore target
    .SetFramework("net10.0")
    .SetProperty("Version", "1.2.3")
    .SetProperty("ContinuousIntegrationBuild", "true"));

Settings: Project, Configuration, NoRestore, NoIncremental, NoDependencies, Output, Runtime, Framework, VersionSuffix, plus arbitrary MSBuild properties via SetProperty(name, value) → emitted as -p:Name=value pairs.

DotNet.Test

DotNet.Test(s => s
    .SetConfiguration(Configuration.Release)
    .SetNoBuild(true)
    .SetFilter("Category!=Integration")
    .AddLogger("trx;LogFileName=results.trx")
    .AddLogger("console;verbosity=normal")
    .SetResultsDirectory("./TestResults")
    .SetBlameHang(true)
    .SetBlameHangTimeout(TimeSpan.FromMinutes(5)));

Settings: Project, Configuration, NoBuild, NoRestore, Filter, Loggers (list, repeated), ResultsDirectory, Settings, Runtime, Framework, BlameHang, BlameHangTimeout, MSBuild properties.

DotNet.Pack

DotNet.Pack(s => s
    .SetConfiguration(Configuration.Release)
    .SetNoBuild(true)
    .SetOutput(ArtifactsDirectory)
    .SetVersionSuffix(Git.Commit[..7])
    .SetIncludeSymbols(true));

Settings: Project, Configuration, NoBuild, NoRestore, NoDependencies, Output, VersionSuffix, IncludeSymbols, IncludeSource, Serviceable, Runtime, MSBuild properties.

DotNet.Publish

DotNet.Publish(s => s
    .SetConfiguration(Configuration.Release)
    .SetOutput("./out")
    .SetRuntime("linux-x64")
    .SetSelfContained(true)                 // emits --self-contained or --no-self-contained
    .SetPublishSingleFile(true)
    .SetPublishTrimmed(true));

Settings: Project, Configuration, NoBuild, NoRestore, NoDependencies, Output, Runtime, Framework, SelfContained (nullable bool — null omits the flag), PublishSingleFile, PublishTrimmed, PublishReadyToRun, VersionSuffix, MSBuild properties.

DotNet.NuGetPush

Publish a .nupkg (or a glob of them) to a NuGet feed. Wrapper-symmetric across local-dev and CI: the same target call works whether you're running tamp push from your laptop or the equivalent step in TeamCity / Azure DevOps / GitHub Actions. No need for vendor-specific publish plugins.

[Secret("NuGet API key", EnvironmentVariable = "NUGET_API_KEY")]
Secret NuGetApiKey = null!;

Target Push => _ => _
    .DependsOn(nameof(Pack))
    .OnlyWhen(() => Git.Branch == "main")
    .Requires(() => NuGetApiKey != null)
    .Executes(() => Artifacts.GlobFiles("*.nupkg")
        .Select(p => DotNet.NuGetPush(s => s
            .SetPackagePath(p)
            .SetSource("https://api.nuget.org/v3/index.json")
            .SetApiKey(NuGetApiKey)
            .SetSkipDuplicate(true))));

Settings: PackagePath (required, supports globs like artifacts/*.nupkg), Source (defaults to nuget.org if unset), ApiKey (typed Secret — value is registered with the runner's redaction table so any echo in the log is scrubbed), SymbolSource and SymbolApiKey (Secret) for separate symbol feeds, NoSymbols, SkipDuplicate (recommended for CI re-runs — makes the push idempotent), Timeout, DisableBuffering, NoServiceEndpoint, ForceEnglishOutput.

Key handling

The --api-key flag has to be on the command line — dotnet nuget push doesn't accept the value via stdin. So the API key is briefly visible to the OS process table while the child process runs (a standard OS-level limitation). Tamp's redaction layer scrubs the value from any log output that flows through the executor's writer.

For CI publishes, prefer trusted publishing over long-lived API keys. NuGet's Trusted Publishing on GitHub Actions uses OIDC to mint a 1-hour API key per workflow run — no secret in CI configuration, automatic rotation, scoped to a specific workflow file. The same DotNet.NuGetPush wrapper handles both modes; only the source of the API key differs.

A worked GitHub Actions release workflow using trusted publishing is in this repo's .github/workflows/release.yml.

Recipes

Standard restore → build → test → pack pipeline

Target Restore => _ => _.Executes(() => DotNet.Restore());

Target Compile => _ => _
    .DependsOn(nameof(Restore))
    .Executes(() => DotNet.Build(s => s
        .SetConfiguration(Configuration)
        .SetNoRestore(true)));

Target Test => _ => _
    .DependsOn(nameof(Compile))
    .Executes(() => DotNet.Test(s => s
        .SetConfiguration(Configuration)
        .SetNoBuild(true)
        .AddLogger("trx;LogFileName=results.trx")));

Target Pack => _ => _
    .DependsOn(nameof(Test))
    .Executes(() => DotNet.Pack(s => s
        .SetConfiguration(Configuration)
        .SetNoBuild(true)
        .SetOutput(Artifacts)));

Deterministic CI build with version from git

[GitRepository] readonly GitRepository Git;

Target Compile => _ => _.Executes(() => DotNet.Build(s => s
    .SetConfiguration(Configuration.Release)
    .SetProperty("Version", "1.0.0")
    .SetProperty("VersionSuffix", $"ci.{Git.Commit[..7]}")
    .SetProperty("ContinuousIntegrationBuild", "true")
    .SetProperty("Deterministic", "true")));

Multi-target test logger split

Target Test => _ => _.Executes(() => DotNet.Test(s => s
    .SetNoBuild(true)
    .AddLogger("trx;LogFileName=test-results.trx")
    .AddLogger($"console;verbosity={(IsServerBuild ? "minimal" : "normal")}")));

V8 / V9 / V10 differences

The wrapper surface is identical today — adding Tamp.NetCli.V8 and Tamp.NetCli.V9 was a sed from V10 modulo namespace. Where the wrapped CLI surface diverges in the future (a new flag in V11, a removed flag in V12), the version-pinned packages will branch independently per ADR 0002.

Pick the V matching your build script's <TargetFramework>. Mixing — e.g., a net10.0 build script that uses Tamp.NetCli.V8 to drive dotnet 8 builds — is also fine; the wrapper just emits commands, it doesn't run them itself.

See also

Settings authoring style

The example(s) above use the fluent Set*-chain shape. Every wrapper verb on this page also accepts a new XxxSettings { ... } object-init form. Both produce identical CommandPlans; the fluent shape stays canonical in docs and the tamp init template. See Build Script Authoring → Two authoring styles for the side-by-side comparison.

Clone this wiki locally