Skip to content

Parameter And Secret Injection

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

Parameter & Secret Injection

How the framework gets values into your build class — three attribute families, each with a different role.

[Parameter] — configuration

Non-sensitive values that vary per environment. Auto-bound from CLI args, environment variables, and the property's declared default.

[Parameter("Build configuration")]
Configuration Configuration = IsLocalBuild ? Configuration.Debug : Configuration.Release;

[Parameter("Target environment", EnvironmentVariable = "DEPLOY_ENVIRONMENT")]
string Environment = "development";

[Parameter] int Verbosity { get; set; } = 1;

Resolution order

  1. Command-line argument wins. By default the property name maps to a kebab-case flag — Configuration--configuration <value>. Both space-separated (--configuration Release) and = (--configuration=Release) forms work. Bare flags (--quiet) are treated as true.
  2. Environment variable if the CLI didn't supply one. Default mapping is UPPER_SNAKE_CASE — ConfigurationCONFIGURATION. Override via EnvironmentVariable = "...".
  3. Property default — whatever you assigned in the declaration.

Type conversion

ParameterBinder handles string, bool, enum (case-insensitive), nullable wrappers, and any IConvertible scalar (int, long, double, decimal, DateTime, etc.).

For bool:

Input Result
true, True, 1 true
false, False, 0 false
anything else error

Override mappings

[Parameter(Name = "env", EnvironmentVariable = "DEPLOY_ENV")]
string Environment = "dev";
// CLI:  --env prod
// Env:  DEPLOY_ENV=prod

[Secret] — sensitive values

The decorated member's type must be Secret, not string. The Secret class:

  • ToString() always returns <Secret:Name> — never the value
  • The value is reachable only via the internal Reveal() method (visible to the runner and tests; not to wrappers or build scripts)
  • Every secret in a CommandPlan.Secrets list is auto-registered with the runner's redaction table; matching values are scrubbed from log output, dry-run rendering, and child-process stdout/stderr passed through Tamp's writer
[Secret("Container registry password", EnvironmentVariable = "REGISTRY_PASSWORD")]
Secret RegistryPassword;

Secret resolution order

  1. CI vendor's secret store (when running in a recognised CI and the vendor exposes secrets via env vars — they typically do)
  2. Local secret store (DPAPI on Windows, libsecret on Linux, Keychain on macOS) — planned, not yet implemented
  3. Environment variable
  4. Interactive prompt if attached to a TTY — planned, not yet implemented

Status: v0 implements step 3 (env-var resolution). Steps 1, 2, 4 are scheduled for v0.x.

Passing secrets to wrappers

Wrapper APIs that take secrets accept the typed Secret parameter directly:

Docker.Login(s => s
    .SetServer("registry.example.com")
    .SetUsername("ci")
    .SetPassword(RegistryPassword));   // Secret, not string

Wrappers that accept secrets should prefer stdin or file-based passing where the underlying tool supports it (docker login --password-stdin, gh auth login --with-token, etc.) — keeps the secret out of the OS process table. CommandPlan.StandardInput is the framework primitive for that.

Value-injection attributes

Auto-compute a value from the build context and inject it into a property. Distinct from [Parameter] — these don't read from CLI/env, they construct from the repository state.

[Solution]

Loads the .slnx or .sln discovered at the repository root.

[Solution] readonly Solution Solution;

// Now you can read:
Solution.Path           // AbsolutePath to the solution file
Solution.Name           // Solution display name
Solution.Projects       // IReadOnlyList<SolutionProject>
Solution.GetProject("MyApp")?.Path   // case-insensitive lookup

Override the discovery with an explicit path:

[Solution(Path = "src/MySpecific.slnx")] readonly Solution Sln;

[GitRepository]

Reads .git/HEAD, .git/refs/heads/*, and .git/config directly — no shell-out to git. Works on machines without git installed, as long as the working tree was cloned.

[GitRepository] readonly GitRepository Git;

Git.Branch         // current branch name, null on detached HEAD
Git.Commit         // 40-char SHA
Git.RemoteUrl      // origin URL
Git.IsDetachedHead
Git.Root           // working-tree root (AbsolutePath)

[NuGetPackage]

Auto-resolves a .NET tool from NuGet. Decorated property must be Tool.

[NuGetPackage("dotnet-sonarscanner", Version = "9.0.0")] readonly Tool SonarScanner;

// later:
Target SonarBegin => _ => _.Executes(() =>
    ProcessRunner.Execute(SonarScanner.Plan("begin", "/k:my-project")));

First use installs into a Tamp-managed cache at TampBuild.TemporaryDirectory / "nuget-tools" / "{packageId}-{version}". Subsequent runs reuse the cache.

Three escape hatches for special cases:

Property When to use
Version = "..." Pin the version. Default = latest stable (one network round-trip on first use).
ExecutableName = "..." The installed binary name differs from the package id.
LocalCachePath = "..." Skip install entirely; resolve from a pre-staged directory. For locked-down environments that can't reach nuget.org.
UseSystemPath = true Legacy. Resolves against the OS PATH. Prefer [FromPath] (below) — it has the same intent with better naming and full Windows extension probing (.cmd / .exe / .bat / .ps1, not just .exe).

[FromPath] (Tamp.Core 1.0.8+)

Resolves a native executable from the OS PATH. The canonical way to inject non-NuGet tools (Docker, yarn, git, docker-compose, etc.).

[FromPath("docker")] readonly Tool Docker = null!;
[FromPath("yarn")]   readonly Tool Yarn   = null!;
[FromPath("gh", Optional = true)] readonly Tool? Gh = null;
Property Default Behavior
Optional = false throws at injection time Injects null when the executable can't be found; consumer can Requires(() => tool != null) or branch on it.

On Windows, the resolver probes extensions in order: .cmd, .exe, .bat, .ps1, then the bare name. On POSIX, only the bare name. PATH-order is preserved (first hit wins). For inline use without an attribute: Tool.FromPath("yarn") (throws) or Tool.TryFromPath("yarn") (returns null).

[FromNodeModules] (Tamp.Core 1.0.8+)

Resolves a tool installed under <projectRoot>/node_modules/.bin/<name> — the standard place workspace-local Node tools live (turbo, vite, vitest, tsc, eslint, etc.).

[FromNodeModules("turbo")]   readonly Tool Turbo  = null!;
[FromNodeModules("vite")]    readonly Tool Vite   = null!;
[FromNodeModules("vitest", Optional = true)] readonly Tool? Vitest = null;
Property Default Behavior
ProjectRoot TampBuild.RootDirectory Override for nested workspaces (e.g. ProjectRoot = "frontend"). Relative paths resolve against RootDirectory; absolute paths win as-is.
Optional false Same semantics as [FromPath].

Important: the binary doesn't exist until yarn install (or npm install) runs. Targets that consume the injected Tool must DependsOn(nameof(YarnInstall)):

Target FrontendBuild => _ => _
    .DependsOn(nameof(YarnInstall))
    .Executes(() => Turbo.Run(Turbo, s => s.AddTask("build")));

On Windows, the resolver probes .cmd (the standard yarn-installed shim) before .exe. For inline use: Tool.FromNodeModules("turbo", projectRoot) / Tool.TryFromNodeModules(...). The "not found" error message includes a yarn install hint.

Building your own injection attribute

Subclass ValueInjectionAttribute and override GetValue. The framework discovers it automatically — same code path as [Solution] and [GitRepository].

[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)]
public sealed class GitVersionAttribute : ValueInjectionAttribute
{
    public override object? GetValue(MemberInfo member, Type memberType)
    {
        // … resolve and return your value …
    }
}

This is the extensibility point for ecosystem authors who want to add [NpmPackage], [LatestNuGetVersion], [GitVersion], [Pulumi], etc. — drop a new package into the consumer's Build.csproj and the attribute Just Works.

Resolution timing

ParameterBinder runs once at the start of Execute<T>(args), in this order:

  1. ValueInjection attributes (Solution, GitRepository, NuGetPackage, FromPath, FromNodeModules, custom)
  2. [Parameter] bindings

Targets are then collected, the graph is built, and the executor takes over. Properties are stable for the lifetime of the build invocation.

[Secret] resolution is intentionally lazy — secrets resolve only when a target that requires them is about to run, so unrelated targets don't fail because an unrelated secret isn't available. (v0 currently resolves all secrets at bind time too; the lazy variant lands when interactive prompting + local stores arrive.)

Where next

Clone this wiki locally