Skip to content

Parameter And Secret Injection

BrewingCoder edited this page May 13, 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 member name maps to a kebab-case flag.
  2. Environment variable if the CLI didn't supply one. Default mapping is UPPER_SNAKE_CASE. Override via EnvironmentVariable = "...".
  3. Property default — whatever you assigned in the declaration.

Name → CLI flag → env var mapping (case rules)

The binder converts PascalCase / camelCase member names to kebab-case for CLI flags and UPPER_SNAKE_CASE for environment variables. Flag matching is case-sensitive — only the canonical kebab-case form binds.

Member declaration CLI flag Env variable
Configuration --configuration CONFIGURATION
MaxParallelism --max-parallelism MAX_PARALLELISM
DeployEnvironment --deploy-environment DEPLOY_ENVIRONMENT
RegistryUrl --registry-url REGISTRY_URL
IPv6Enabled --i-pv6-enabled I_PV6_ENABLED
awsRegion (camelCase) --aws-region AWS_REGION

Single-word names lose no informationConfiguration becomes --configuration (lower-cased, no separator inserted). Multi-word names get hyphens (CLI) or underscores (env) at every camelCase boundary.

Case-sensitivity gotchas to know:

  • --registry foo ✅ binds Registry (canonical kebab-case lowercased)
  • --Registry foo ❌ does NOT bind — the CLI parser is case-sensitive and --Registry doesn't match the registered flag name registry
  • --CONFIGURATION foo ❌ does NOT bind for the same reason — only the kebab-case form is registered
  • Environment variables on Windows: Set CONFIGURATION=Release (Windows env-vars are case-insensitive at lookup time, but the binder only checks the UPPER_SNAKE_CASE form via Environment.GetEnvironmentVariable)

If you want a different CLI shape — short flag, all-lowercase, etc. — override:

[Parameter(Name = "env", EnvironmentVariable = "DEPLOY_ENV")]
string Environment = "dev";
// Now: --env prod  AND  DEPLOY_ENV=prod
//      --environment prod  ❌ does NOT bind (the override replaces the default)

Both space-separated (--configuration Release) and =-joined (--configuration=Release) forms work. Bare flags (--quiet) bind to true.

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. This is what string interpolation, structured loggers, and most debugger displays call by default, so accidental "leaks" to those surfaces appear as <Secret:Name> rather than cleartext.
  • The cleartext is reachable via Reveal(). As of Tamp.Core 1.6.0 this method is public, gated by the TAMP004 Roslyn analyzer which flags Reveal() calls outside an approved context (classes ending in *Settings / *SettingsBase, Tamp framework internals, test code).
  • 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.

Pre-1.6.0 history: Reveal() was internal, gated by per-satellite [InternalsVisibleTo] entries in Tamp.Core/AssemblyInfo.cs. Every new satellite handling a service-principal secret / cert password / API key required a Tamp.Core minor bump and a nuget propagation wait before it could ship. The IVT gate was never load-bearing for security — the real masking lives in ToString() + CommandPlan.Secrets. 1.6.0 made Reveal() public + replaced the IVT gate with TAMP004; net-new satellites no longer need any Tamp.Core change.

[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: Current Tamp implements step 3 (env-var resolution). Steps 1, 2, 4 are scheduled. CI vendors that expose secrets via env vars (Azure DevOps, GitHub Actions, TeamCity) are already covered transparently by step 3.

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