Expressions
The expression system (namespace Invex.StructuredText.Expressions) is the most distinctive feature of this library.
Instead of embedding platform-specific expression strings (${{ github.ref }}, $(Build.SourceBranch), …) directly in
your models, you build platform-agnostic expression trees out of TextExpression records. A platform-specific
formatter then renders the tree into the correct syntax for the target file.
The same expression can produce:
${{ contains(github.ref, 'release') }}for GitHub Actions, viaGithubExpressionFormattercontains(variables['Build.SourceBranch'], 'release')for Azure DevOps, viaDevopsExpressionFormatter
The building blocks
Value expressions
| Expression | Meaning | GitHub rendering |
|---|---|---|
RawExpression("text") |
Verbatim text, written exactly as-is | text |
StringExpression("text") |
A string literal inside an expression | 'text' |
NumberExpression(3.14) |
A numeric literal | 3.14 |
BooleanExpression(true) |
A boolean literal | true |
NullExpression() |
Null / empty | (empty) |
Important
RawExpression vs StringExpression is the key distinction: Raw is uninterpreted text (use it for plain YAML
values and context references like github.ref); String is a quoted string literal within an expression.
Implicit conversions make most of this invisible. TextExpression converts implicitly from string (to
RawExpression), bool, and all numeric types:
TextExpression name = "build"; // RawExpression("build")
TextExpression count = 3; // NumberExpression(3)
TextExpression flag = true; // BooleanExpression(true)
The static TextExpressions class offers explicit factories: TextExpressions.Raw(...), TextExpressions.From(...)
(string → StringExpression, bool/number → typed literal), TextExpressions.True, TextExpressions.False,
TextExpressions.Null.
Evaluate — entering “expression context”
Most YAML values are plain text; expressions must be explicitly wrapped to be evaluated. Evaluate() produces an
EvaluateExpression, which GitHub renders as ${{ ... }}:
TextExpression runNumber = new RawExpression("github.run_number").Evaluate();
// GitHub: ${{ github.run_number }}
Condition-like properties (If on jobs/steps, Condition on Azure DevOps steps) are already in expression context, so
you usually don't need Evaluate() there.
Property and index access
TextExpression github = "github";
var refName = github["ref_name"]; // github.ref_name (PropertyAccessExpression)
var firstLabel = github["event"]["labels"][0]; // github.event.labels[0] (IndexAccessExpression)
var matrixOs = new RawExpression("matrix")[new StringExpression("os")]; // matrix['os']
Logic operators
All comparison and logic combinators are available as fluent methods and C# operators:
TextExpression branch = new RawExpression("github.ref");
var isMain = branch.EqualToString("refs/heads/main"); // github.ref == 'refs/heads/main'
var notMain = branch.NotEqualToString("refs/heads/main");
var both = isMain & new RawExpression("success()"); // ... && success()
var either = isMain | notMain; // ... || ...
var negated = !isMain; // !(...)
var ordered = new NumberExpression(5).GreaterThan(3); // 5 > 3
Available combinators: And / &, Or / |, Not / !, EqualTo, NotEqualTo, LessThan, GreaterThan,
LessThanOrEqualTo, GreaterThanOrEqualTo (each with …String variants that quote the right-hand side as a string
literal).
Functions
TextExpression branch = new RawExpression("github.ref");
branch.Contains("release"); // contains(github.ref, 'release')
branch.StartsWith("refs/tags/"); // startsWith(github.ref, 'refs/tags/')
branch.EndsWith("-rc"); // endsWith(github.ref, '-rc')
branch.Coalesce("fallback"); // coalesce(github.ref, 'fallback')
branch.Join(", "); // join(github.ref, ', ')
branch.ToJson(); // toJSON(github.ref)
new RawExpression("'**/*.csproj'").HashFiles(); // hashFiles(...) (GitHub-only)
Reversed-argument helpers are provided where it reads better: ContainedIn, IsStartOf, IsEndOf.
String interpolation with Format
TextExpressions.Format accepts an interpolated string whose holes can be other expressions. Literal parts become a
format string; holes become arguments:
var version = new RawExpression("github.run_number").Evaluate();
var releaseName = TextExpressions.Format($"Release-{version}");
// GitHub: format('Release-{0}', ${{ github.run_number }})
You can also concatenate with +, or use ConcatExpression for raw concatenation:
var combined = TextExpressions.Concat([new RawExpression("v"), version]);
Workflow-run expressions
These records reference outputs and outcomes of other steps/jobs, and each platform formatter renders them with the appropriate context path:
| Expression | Purpose |
|---|---|
StepOutputExpression |
Output of a step (steps.<id>.outputs.<name>) |
StepOutcomeExpression |
Outcome of a step (steps.<id>.outcome) |
StepOutcomeTypeExpression |
An outcome literal: success/failure/cancelled/skipped |
TargetOutputExpression |
Output of a job/stage (needs.<job>.outputs.<name>) |
TargetOutcomeExpression |
Outcome of a job/stage |
TargetOutcomeTypeExpression |
An outcome literal for jobs/stages |
var artifact = new StepOutputExpression
{
StepName = "build",
OutputName = "artifact-path",
};
var buildSucceeded = new StepOutcomeExpression { StepName = "build" }
.EqualTo(new StepOutcomeTypeExpression { Type = StepOutcomeTypeExpression.OutcomeType.Success });
Typed wrappers: WorkflowExpression<T> and collections
Many model properties are declared as WorkflowExpression<T> rather than plain TextExpression. This is a
zero-overhead struct wrapper whose type parameter documents the expected result type (string, bool,
double, …). It converts implicitly to and from TextExpression, so you can assign expressions or strings directly:
// DisplayName is WorkflowExpression<string>
DisplayName = new RawExpression("Build everything"),
// Condition is WorkflowExpression<bool>
Condition = new RawExpression("succeeded()"),
Similarly:
TextExpressionCollection— a list of expressions with implicit conversions fromstring,string[],List<string>,TextExpression,TextExpression[], andList<TextExpression>. This is why you can writeRun = ["dotnet build"]orBranches = ["main"].WorkflowExpressionCollection<T>— the typed equivalent.Cast<TTo>()— reinterprets an expression's compile-time result type without changing the tree.
Platform-specific expressions
GitHub Actions (GithubExpressionFormatter)
| Expression | Output |
|---|---|
EvaluateExpression |
${{ inner }} |
AndExpression(a, b) |
a && b |
ContainsExpression |
contains(a, b) |
FormatExpression |
format('...', args) |
HashFilesExpression |
hashFiles(...) |
Azure DevOps (DevopsExpressionFormatter)
Azure DevOps has multiple expression syntaxes, and the package provides explicit wrappers for them:
| Expression | Output |
|---|---|
DevopsMacroExpression(variable) |
$(variableName) |
DevopsRuntimeExpression(expr) |
$[ expression ] |
using Invex.StructuredText.AzureDevopsPipelines;
var buildId = new DevopsMacroExpression(new RawExpression("Build.BuildId"));
// renders as: $(Build.BuildId)
var runtimeCondition = new DevopsRuntimeExpression(
new RawExpression("variables.isMain").EqualToString("true"));
// renders as: $[ eq(variables.isMain, 'true') ]
Writing your own formatter
To target a new platform, derive from TextExpressionFormatter and implement
protected override TextExpression? Resolve(TextExpression expression). The base class handles RawExpression,
ConcatExpression, and CastExpression<T> unwrapping; your Resolve method rewrites every other expression node into
simpler nodes (ultimately RawExpressions). Returning null for an unrecognized node produces a clear
InvalidOperationException naming the unhandled expression.