ADR-0003 — Variables and ${…} substitution layer
- Status: Proposed
- Date: 2026-05-27
- Deciders: Fallout maintainers
- Relates to: ADR-0002 (secret model this layers on), issue #213 (feature ask), milestone v11 (delivery), milestone v12 (plugin SDK seam promotion)
Context
ADR-0002 codified how secrets flow through the build:
[Parameter, Secret] readonly string OctopusApiKey;
…with a six-step resolution chain (CLI → env → value-provider → params-file → keychain → prompt) and one canonical wire name per declaration. That covers the sensitive half of the story well.
What it does not cover, and what consumers keep working around in their own build code, is the non-sensitive half:
- Named non-secret values per environment —
apiBaseUrl,slackChannel,feedUrl. Today modelled as[Parameter] readonly string ApiBaseUrl = "https://dev.api.example.com"with C# expressions for any per-environment switch (IsServerBuild ? prod : dev). - Composition between named values —
apiBaseUrl = "https://${env}.api.example.com". Today every consumer rolls their own string interpolation in C#, often computed lazily on a property getter. Each build re-invents the wheel. - Reference from tool argument strings —
DockerTasks.DockerRun(_ => _.SetImage("$(Registry)/myapp:$(Version)")). Today this is plain C# string interpolation at the call site; nothing in the framework resolves a templating syntax for the tool runner. - Reference from CI-config emit — the
[GitHubActions]generator hardcodes env-var injection for secrets but has no story for variables. If a consumer wantsimage: ${{ vars.IMAGE_PREFIX }}-${{ vars.ENV }}in an emitted workflow step, they hand-write the workflow. - Layered configuration with explicit precedence —
parameters.json(defaults) +parameters.<profile>.json(overrides) + CLI args + env vars. The mechanism exists, but only for[Parameter]-declared fields with concrete C# names. There's no consumer-extensible "configuration variables" namespace that doesn't require a C# field declaration per entry.
Three concrete signals that the gap matters:
Build.csfiles in the wild grow string-interpolation boilerplate. Search any sizeable Fallout (formerly NUKE) consumer'sBuild.csand you'll find ten variants of$"{baseUrl}/{path}"andIsServerBuild ? ... : ...constructing what is, structurally, layered configuration.- CI-config generators leave variables on the table. ADR-0002 covered
ImportSecrets. There's noImportVariablesand no substitution into the generated YAML. Consumers regenerate workflows and then hand-edit, which is what[AutoGenerate(false)]and hand-writtenrelease.ymlare: escape hatches around the gap. - Plugin SDK foreshadowing (#213, milestone v12). When plugins ship, they need a clean way to consume both secrets and non-secret configuration without each plugin reinventing the resolver. The cleanest path is to extend ADR-0002's resolution chain to non-secret values now, then promote the seam to the public SDK in v12.
Variables and secrets are not separate features. They're the same feature with a Sensitive=true flag:
- Both are named-value declarations consumers manage per repo / profile / environment.
- Both want the same substitution syntax in the same places (
${name}in tool args, in CI config, in artifact paths). - Both flow through the same resolution chain (CLI > env > file > defaults).
- The only meaningful runtime difference is "is this value sensitive": secrets get registered with the masking layer ADR-0002 §6 calls out, never echoed into committed YAML, and stored encrypted in
parameters.json. Variables don't.
Treating them as one feature with a Sensitive toggle keeps the consumer mental model small and reuses ADR-0002's resolution infrastructure end-to-end.
Decision
Core rules
-
Substitution syntax is
${Name}. Single delimiter for the whole framework. Escaped via$${literal}(the engine emits${literal}and does not recurse). Inspired by Bash and Docker Compose — the two formats consumers most often already know. Mustache ({{name}}) was considered and rejected (see Alternatives §A). -
Substitution targets are any string-valued parameter and any tool argument string. The substitution engine runs in two places:
- Parameter injection time. When a
[Parameter] readonly string ApiBaseUrlis being resolved and the resolved value is"https://${Env}.api.example.com", the engine substitutes${Env}before the value lands on the field. Recursion is depth-limited (default 5; tunable via the engine for tests) with cycle detection. - Tool argument construction time. Tool wrappers (
DockerTasks,MSBuildTasks, etc.) run any string argument through the same engine before passing it to the subprocess. This is the path that letsDockerTasks.DockerRun(_ => _.SetImage("$(Registry)/${App}:${Version}"))work without consumer-side interpolation.
CI-config-emit substitution is explicitly not in scope for v11 — the generator emits the raw
${VarName}string into the YAML for the CI engine itself to resolve (wherevars.VARNAMEis supported), or substitutes at emit time when targeting an engine that doesn't support runtime variables. See Open Question §1. - Parameter injection time. When a
-
Variables live alongside parameters in
parameters.jsonunder aVariablesblock. New top-level key, parallel to the existing flat[Parameter]map:{"$schema": "./build.schema.json","Configuration": "Release", // existing [Parameter] resolution, unchanged"Variables": {"Env": "dev","Registry": "ghcr.io/example","App": "myapp","ApiBaseUrl": "https://${Env}.api.example.com"},"NuGetApiKey": "v2:..." // secret, ADR-0002}Variables are named-value entries with substitution support. They differ from
[Parameter]in three ways:- No C# field required. Variables exist purely as named entries in
parameters.json/ profile files. Consumers reference them via${Name}in other strings. - They participate in substitution chains. Variable values themselves can contain
${...}references to other variables and to[Parameter]values, resolved at first-use with cycle detection. - They never appear as method-callable build properties. Access from C# code is via
FalloutBuild.GetVariable(name)or a strongly-typedVariablesindexer; not a generated property. (Strongly-typed access could ship as a v12-era source generator — see Open Question §3.)
- No C# field required. Variables exist purely as named entries in
-
The resolution chain extends ADR-0002, in this order:
- CLI argument (
-EnvVar valueor--var Env=devfor top-level variables — syntax under Open Question §2) - Environment variable (
FALLOUT_VAR_<NAME>for variables to avoid colliding with[Parameter]'s<NAME>) - Profile-layered
parameters.<profile>.jsonVariables block (in profile-stack order, last wins) - Default
parameters.jsonVariables block - (For secret-flagged entries only) the value-provider attribute output (e.g.
[AzureKeyVaultSecret]) - (For secret-flagged entries only)
CredentialStorelookup - (For secret-flagged entries only) Interactive prompt
Non-secret variables stop at step 4. Secret variables continue down the chain exactly as ADR-0002 specifies for
[Parameter, Secret]. This means the chain is unified but truncated for non-secret values — the same code path with different terminal conditions based on theSensitiveflag. - CLI argument (
-
Sensitiveis a first-class property on variable declarations. A consumer marks a variable sensitive via the same JSON shape used for parameters today (v2:prefix in the value; encrypted at rest; registered with the masking layer; redacted in logs and emitted YAML):"Variables": {"ApiBaseUrl": "https://${Env}.api.example.com","DeployKey": "v2:base64(salt||nonce||tag||ciphertext)" // ADR-0002 §2 v2 format}The engine treats
v2:-prefixed (orv1:-prefixed legacy) values as sensitive automatically. No separateSensitive=truetoggle in the JSON — the encryption envelope IS the toggle. (Equivalent to how today's encrypted[Parameter, Secret]values self-identify by theirv2:prefix inparameters.json.) -
Public surface for v11 is read-only, narrow, and stable. Consumers get:
FalloutBuild.GetVariable(string name)→string— returns the resolved (substituted) value, or throws if the variable is not defined and no default was provided.FalloutBuild.TryGetVariable(string name, out string value)→bool— non-throwing variant.[Variable("Env")] readonly string Env— declarative injection for variables that have a C# field, parallel to[Parameter]. Optional; the indexer/GetVariable forms work without it.${Name}substitution in anystring-typed[Parameter]value, any[Variable]value, and any tool argument. Engine is internal in v11.
The substitution engine (
ISubstitutionEngine), the variable source chain (IVariableSource), and any future plugin-author seams stay internal for v11, wired viaInternalsVisibleToto first-party assemblies and tests only. They become candidates for public promotion in v12 alongsideFallout.Plugin.Sdk(see §7). -
Plugin SDK seams are reserved for v12 but designed in v11. The internal interfaces shipped in v11 are written as if they were public — XML docs, stable signatures, no leaky abstractions over MEF / reflection / DI primitives — so that v12's plugin SDK work is purely "promote internal to public via attribute changes" rather than "redesign the API first":
// Internal in v11; public in v12 as Fallout.Plugin.Sdk.IVariableResolverinternal interface IVariableResolver{/// <summary>Returns null when the variable is unknown to this resolver.</summary>string TryResolve(string variableName, ResolutionContext context);}ADR-0002 §5 already locked in that plugins receive resolved values, not raw stores. The same rule applies here: a plugin can implement
IVariableResolverto source values from an external system (Vault, 1Password) but cannot readparameters.jsonorCredentialStoredirectly.
Worked example
A consumer's Build.cs:
[Variable] readonly string Env;
[Parameter] readonly string ApiBaseUrl;
[Parameter, Secret] readonly string DeployKey;
Target Deploy => _ => _
.Requires(() => DeployKey)
.Executes(() =>
{
DockerTasks.DockerRun(_ => _
.SetImage($"ghcr.io/example/myapp:${{Version}}") // substituted at tool call time
.SetEnv("API_BASE_URL", ApiBaseUrl) // substituted at parameter injection time
.SetEnv("DEPLOY_KEY", DeployKey)); // resolved + masked end-to-end
});
parameters.json:
{
"Variables": {
"Env": "prod",
"Version": "10.4.0",
"ApiBaseUrl": "https://${Env}.api.example.com"
},
"DeployKey": "v2:..."
}
At injection time:
Envresolves to"prod".Versionresolves to"10.4.0".ApiBaseUrlresolves to"https://${Env}.api.example.com", which the substitution engine reduces to"https://prod.api.example.com"(depth 1).DeployKeyresolves via ADR-0002's chain, registered with the masking layer.
At tool call time:
SetImage($"ghcr.io/example/myapp:${{Version}}")— the literal${Version}string is substituted to"ghcr.io/example/myapp:10.4.0"before the Docker subprocess sees it.
Single declaration per concept. Substitution is consistent across both injection-time and call-time paths.
CI-config generator interaction
For v11, the [GitHubActions] generator (and siblings) emits two behaviours, distinguished by the Sensitive flag:
- Non-sensitive variables that are statically known at generation time (i.e., the value is literal — no nested
${...}references that aren't yet bound) are substituted at emit time and baked into the YAML. The generated workflow contains the literal value. - Sensitive values are never substituted at emit time. ADR-0002 §6's masking discipline applies — the workflow YAML emits
${{ secrets.DEPLOY_KEY }}-style placeholders that the CI engine resolves at runtime. - Variables with unresolved
${...}references at emit time (because they depend on a runtime-resolved input) are passed through verbatim into the YAML. The expectation is that the consumer wires those into the CI's own variable system. v12 may grow first-classImportVariableson[GitHubActions]parallel to today'sImportSecrets; out of scope for v11.
Anti-patterns
Same shape as ADR-0002 §"Anti-patterns" but specific to the variable layer:
- Hardcoded environment-dependent strings in
Build.cs.var baseUrl = IsServerBuild ? "https://prod" : "https://dev";— the variable layer exists exactly to replace this. Move intoparameters.json/ profiles. ${...}in committed YAML for a variable that has no CI-engine equivalent. Emit-time substitution covers static cases; for runtime cases the consumer needs to ensure the CI provides the variable. Otherwise the workflow has a dangling reference.- Plugins reading
parameters.jsondirectly to harvest variables. Same rule as secrets (ADR-0002 §5) — plugins consume resolved values via the SDK-exposed build context. Plugins that need a new variable source register anIVariableResolver(v12 public surface). - Substitution cycles.
A = ${B},B = ${A}. The engine fails fast on detected cycles with a clear error including the cycle path. Banned in source review; caught at runtime. ${UNDEFINED_VAR}references. Default behaviour: throw with a clear "variable not defined; declared in: …" error. Escape hatch:${VarName:-fallback}Bash-style default (see Open Question §2).
Open questions
These don't block accepting the ADR; they're follow-ups during implementation.
-
CI-config emit-time substitution scope. v11 emits literals where possible and passes
${...}through verbatim where not. v12 grows[GitHubActions(ImportVariables = ...)]parallel toImportSecrets. Action: track v12 work under #213 once this ADR lands; v11 ships the emit-time-substitution behaviour as the floor. -
CLI override syntax for variables.
[Parameter]overrides use-Name value. For variables, options:--var Env=prod(explicit prefix, no collision risk)-Env prod(same shape as[Parameter], but means we need to dispatch by-name across both name spaces — feasible since variables and parameters can't share a name, but adds resolution complexity)- Reject CLI overrides for variables; profiles are the override mechanism.
Recommendation:
--var Env=prodfor v11. Explicit, no collisions, future-proof. Reject the third option (consumers want CLI overrides for one-off runs). -
Strongly-typed access generator. Today
[Parameter] readonly string Env;injects via reflection. A source generator could emitpartial class Build { public string Env { get; } }for every variable declared inparameters.json. Action: v12+. Out of scope for v11; the[Variable]attribute +GetVariable(name)indexer is the public surface. -
${VarName:-default}Bash-style fallback syntax. Convenient; risks complexity creep. Recommendation: ship the simple${Name}syntax in v11, add:-fallback in a follow-up if demand exists. The substitution-fails-with-clear-error behaviour is friendlier than silent fallback for the common case. -
Variable visibility across plugin boundaries. A v12 plugin might declare its own variables (e.g.,
Fallout.Plugin.OctopusexposesOctopusEnvironment). Convention: plugin variables are prefixed by plugin name (Octopus_Environment) to avoid collisions. Enforced by the plugin loader. Action: v12 plugin SDK design. -
Where does the substitution engine live in the assembly graph? Today, parameter resolution is in
Fallout.Common.ValueInjection. Adding substitution there avoids cross-assembly deps. Plugin SDK promotion in v12 may want it in a smaller seam-only assembly (Fallout.Plugin.Sdk.Abstractions). Action: ship inFallout.Common.ValueInjectionfor v11; revisit when the v12 SDK assembly carve-out happens.
Consequences
Positive
- One mental model across secrets and variables. Consumers learn ADR-0002's resolution chain once; the variable layer reuses the same chain with a sensitivity flag controlling the bottom four steps. Reduces concept surface.
- Eliminates per-consumer string-interpolation boilerplate. The "I have a base URL and an env" pattern stops being a
Build.csproblem and becomes aparameters.jsonproblem. Same data in one place. - CI-portable configuration.
Variablesinparameters.jsonworks identically locally, in GitHub Actions, in any future CI provider — same way[Parameter]already does. No CI-specific config files. - Plugin-SDK-ready in v11. The internal
IVariableResolver/ISubstitutionEngineseams are designed for promotion. v12 turns them public via attribute changes, not a redesign. Concretely de-risks v12. - Tool-argument substitution is "free" for tool wrappers. Wrappers don't need to opt in — the substitution engine runs over their
stringarguments before subprocess invocation. One pass; covers all 70+ tools.
Negative
- More features means more failure modes. Substitution cycles, undefined references, depth limits — each is a new error class consumers can hit. The error messages need to be excellent; a confusing "Could not resolve
${Foo}" is a worse footgun than the C# string interpolation it replaces. Mitigated by clear error messages with the cycle path / resolution chain attempted. - JSON schema for
parameters.jsongrows. Todaybuild.schema.jsonis generated from[Parameter]declarations. The schema generator now also emitsVariablesas a nested object, with sensitivity-aware entries. The generator (SchemaUtility.cs) needs an extension; tracked as implementation work. - Two ways to declare a value.
[Parameter](existing) vsVariablesblock (new). Consumers must learn when to use which. Guidance: if the value needs strong-typed C# access from aTarget, declare it as[Parameter]. If it's purely a configuration string referenced from other config or tool args, put it inVariables. Both compose via substitution. - Tool-argument substitution is a behavior change. Any tool argument string a consumer passes today that happens to contain a literal
${...}substring gets eagerly substituted in v11. Mitigation: the engine substitutes only${Name}whereNameis a known variable — unknown names throw. Literal-${...}strings in tool args that don't match a variable are a pre-existing typo, not a new failure mode. Escape via$${Name}if the literal is intentional.
Neutral
- Schema migration is automatic. Consumers who don't add a
Variablesblock see no behaviour change. The block is opt-in.
Alternatives considered
A. {{name}} Mustache syntax instead of ${name}
Rejected. Same reasoning ADR-0002 used for SCREAMING_SNAKE: pick the form consumers already know. Bash and Docker Compose's ${name} is more familiar to the build-automation audience than Mustache/Handlebars-style {{name}}. ${...} also avoids collision with the C# raw-string-literal {{}} escape and Razor / Liquid templates that consumers may have committed elsewhere in their repo.
B. Treat variables as a separate subsystem with its own resolution chain
I.e., variables have a different chain (e.g., no CredentialStore terminal step, no encrypted storage) and a separate substitution engine.
Rejected. Doubles the surface area for consumers and plugins. The "secrets are variables with Sensitive=true" framing is the integration that actually pays off — one chain, one engine, one mental model. The terminal steps that don't apply to non-sensitive entries simply don't fire; the chain is unified, not bifurcated.
C. Use Microsoft.Extensions.Configuration (IConfiguration)
Lean on Microsoft.Extensions.Configuration's layered providers and ${} substitution via custom binders.
Deferred. IConfiguration is a strong fit for long-running hosted apps; build automation has different lifetime / interactivity needs (e.g., the CLI-level :secrets flow, the encrypted parameters file format). ADR-0002's deferral on the same grounds applies. Reevaluate if maintenance cost rises or if v12 plugin work surfaces a configuration shape that maps cleanly. For v11, keep ownership of the engine in Fallout.Common.ValueInjection.
D. Source-generator-only public surface
Skip the IVariableResolver interface; rely on a source generator to bake variables into compile-time symbols.
Rejected for v11. Source generators can't reach values that live in parameters.json at consumer repos — only the generator runs at the framework build, not at the consumer build. To support parameters.<profile>.json at consumer build time we need runtime resolution, which is what IVariableResolver is for. The strongly-typed source generator from Open Question §3 layers on top of runtime resolution; it's not a replacement.
References
- ADR-0002 — the secret resolution chain this layers on
- Issue #213 — feature ask + design questions that motivated this ADR
- Issue #212 — corrected
v2:encryption format that underpins sensitive variable storage src/Fallout.Build/ParameterAttribute.cs:34— existing[Parameter]attribute (extended, not replaced)src/Fallout.Common/ValueInjection/— current parameter-resolution code path; substitution engine lands alongsidesrc/Fallout.Build/Utilities/SchemaUtility.cs—build.schema.jsongenerator; gains aVariablesblock emitter as part of implementation- Future SDK work tracked under milestone v12 —
IVariableResolver/ISubstitutionEnginepublic promotion, plugin-authorFallout.Plugin.Sdk.Abstractionscarve-out