Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .fallout/build.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@
"type": "boolean",
"description": "Indicates to continue a previously failed build attempt"
},
"Describe": {
"type": "boolean",
"description": "Prints the build model as JSON and exits without executing any target"
},
"Help": {
"type": "boolean",
"description": "Shows the help text for this build assembly"
Expand All @@ -70,6 +74,10 @@
"description": "Host for execution. Default is 'automatic'",
"$ref": "#/definitions/Host"
},
"Json": {
"type": "boolean",
"description": "Emits machine-readable JSON on standard output for read-only requests (--plan)"
},
"NoLogo": {
"type": "boolean",
"description": "Disables displaying the Fallout logo"
Expand Down
9 changes: 6 additions & 3 deletions build.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ Param(
[string[]]$BuildArguments
)

Write-Output "PowerShell $($PSVersionTable.PSEdition) version $($PSVersionTable.PSVersion)"
# Provisioning chatter goes to standard error, so standard output carries only what the build
# itself writes. That is what lets `./build.ps1 --describe | ConvertFrom-Json` work.
[Console]::Error.WriteLine("PowerShell $($PSVersionTable.PSEdition) version $($PSVersionTable.PSVersion)")

Set-StrictMode -Version 2.0; $ErrorActionPreference = "Stop"; $ConfirmPreference = "None"; trap { Write-Error $_ -ErrorAction Continue; exit 1 }
$PSScriptRoot = Split-Path $MyInvocation.MyCommand.Path -Parent
Expand Down Expand Up @@ -60,7 +62,8 @@ else {
$env:PATH = "$DotNetDirectory;$env:PATH"
}

Write-Output "Microsoft (R) .NET SDK version $(& $env:DOTNET_EXE --version)"
[Console]::Error.WriteLine("Microsoft (R) .NET SDK version $(& $env:DOTNET_EXE --version)")

ExecSafe { & $env:DOTNET_EXE tool restore }
# PowerShell has no `1>&2`, so the restore's output is pumped to standard error explicitly.
ExecSafe { & $env:DOTNET_EXE tool restore 2>&1 | ForEach-Object { [Console]::Error.WriteLine($_) } }
ExecSafe { & $env:DOTNET_EXE fallout $BuildArguments }
6 changes: 4 additions & 2 deletions build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,9 @@ else
export PATH="$DOTNET_DIRECTORY:$PATH"
fi

echo "Microsoft (R) .NET SDK version $("$DOTNET_EXE" --version)"
# Provisioning chatter goes to standard error, so standard output carries only what the build
# itself writes. That is what lets `./build.sh --describe | jq` work.
echo "Microsoft (R) .NET SDK version $("$DOTNET_EXE" --version)" >&2

"$DOTNET_EXE" tool restore
"$DOTNET_EXE" tool restore >&2
exec "$DOTNET_EXE" fallout "$@"
9 changes: 8 additions & 1 deletion src/Fallout.Build/Execution/BuildExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ namespace Fallout.Common.Execution;
/// </summary>
internal static class BuildExecutor
{
/// <summary>
/// Reason recorded when <c>--skip</c> names a target. Shared with
/// <see cref="BuildIntrospectionService" /> so the predicted plan and the executed one describe
/// a skip identically.
/// </summary>
internal const string SkippedViaParameterReason = "via parameter";

// NOTE: no IFalloutBuild because of BuildAttemptFile + WriteTarget
private static AbsolutePath BuildAttemptFile => Constants.GetBuildAttemptFile(FalloutBuild.RootDirectory);

Expand All @@ -25,7 +32,7 @@ public static void Execute(FalloutBuild build, IReadOnlyCollection<string> skipp
skippedTargets = skippedTargets.Select(x => x.Replace("-", string.Empty)).ToArray();
build.ExecutionPlan
.Where(x => skippedTargets.Count == 0 || skippedTargets.Contains(x.Name, StringComparer.OrdinalIgnoreCase))
.ForEach(x => MarkTargetSkipped(build, x, reason: "via parameter"));
.ForEach(x => MarkTargetSkipped(build, x, reason: SkippedViaParameterReason));
}

build.ExecutionPlan.ForEach(x => CheckConditions(build, x, x.StaticConditions));
Expand Down
181 changes: 181 additions & 0 deletions src/Fallout.Build/Execution/BuildIntrospectionService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Fallout.Build.Execution.Extensions;
using Fallout.Common.Utilities;
using Fallout.Common.ValueInjection;

namespace Fallout.Common.Execution;

/// <summary>
/// Owns the read-only introspection requests — <c>--describe</c> and <c>--plan --json</c> — which
/// print the build model on standard output and execute nothing.
/// <para>
/// <see cref="BuildManager" /> calls this immediately after the execution plan is resolved and
/// <em>before</em> <see cref="ToolRequirementService.EnsureToolRequirements" />. That ordering is the
/// feature, not an implementation detail: <c>EnsureToolRequirements</c> writes into the temporary
/// directory and shells out to <c>dotnet restore</c>, so an <see cref="IOnBuildInitialized" />
/// extension — where <c>--help</c> and <c>--plan</c> live — could not honour "runs no external tool".
/// </para>
/// </summary>
/// <remarks>
/// One instance per run, resolved once by <see cref="For" />, rather than a static class. The
/// request is asked three times during a run — at the gate, on the failure path, and before the
/// outcome tables — and answering it from ambient <see cref="ParameterService" /> state on each
/// call made those three independent reads of process-global state that nothing kept in agreement.
/// Holding the version on the instance also lets a caller supply it, so a spec asserts the document
/// production emits rather than a parallel one assembled for the test.
/// </remarks>
internal sealed class BuildIntrospectionService
{
/// <summary>
/// Version of the <c>--plan --json</c> document. Deliberately its own constant rather than
/// <see cref="BuildGraphUtility.SchemaVersion" />: the plan and <c>build-graph.json</c> are
/// different shapes, and one number could not tell a consumer which contract it received —
/// nor could it be bumped for one without falsely signalling a break in the other.
/// </summary>
internal const int PlanSchemaVersion = 1;

/// <summary>Version of the error envelope, separate for the same reason.</summary>
internal const int ErrorSchemaVersion = 1;

private readonly bool describe;
private readonly bool planAsJson;
private readonly string falloutVersion;

/// <param name="describe">Whether <c>--describe</c> was requested.</param>
/// <param name="planAsJson">Whether <c>--plan</c> and <c>--json</c> were both requested.</param>
/// <param name="falloutVersion">
/// The version stamped into the describe document. Passed in rather than read inside, so a spec
/// can assert an exact document without the running assembly's version leaking into it.
/// </param>
internal BuildIntrospectionService(bool describe, bool planAsJson, string falloutVersion)
{
this.describe = describe;
this.planAsJson = planAsJson;
this.falloutVersion = falloutVersion;
}

/// <summary>Resolves the request for a run, once.</summary>
/// <remarks>
/// Each flag is read from the injected property OR straight from the arguments, because this is
/// asked <em>before</em> value injection has run: InjectParameterValuesAttribute is itself an
/// IOnBuildCreated extension, and the gate has to fire before any extension does. Reading only
/// the property here would make every request look like an ordinary build.
/// </remarks>
// --plan alone keeps its existing meaning (the HTML graph); only --json redirects it here.
internal static BuildIntrospectionService For(FalloutBuild build)
=> new(
Flag(build.Describe, nameof(FalloutBuild.Describe)),
Flag(build.Plan, nameof(FalloutBuild.Plan)) && Flag(build.Json, nameof(FalloutBuild.Json)),
BuildGraphUtility.GetFalloutVersion());

private static bool Flag(bool injected, string parameterName)
=> injected || ParameterService.GetParameter<bool>(parameterName);

/// <summary>
/// Whether raw command-line arguments request introspection, for callers that must decide
/// before a build process exists — the CLI, which has to know where to send the build step's
/// own output. Static because there is no run yet to hang an instance off, and it lives on this
/// type so the two entry points cannot disagree about what counts as a read-only request.
/// </summary>
internal static bool IsRequested(IReadOnlyCollection<string> arguments)
=> HasFlag(arguments, nameof(FalloutBuild.Describe)) ||
(HasFlag(arguments, nameof(FalloutBuild.Plan)) && HasFlag(arguments, nameof(FalloutBuild.Json)));

// Accepts every spelling the parameter parser does: --describe, -describe, --DESCRIBE.
private static bool HasFlag(IReadOnlyCollection<string> arguments, string parameterName)
=> arguments.Any(x =>
x.StartsWith("-", StringComparison.Ordinal) &&
x.TrimStart('-').Replace("-", string.Empty).EqualsOrdinalIgnoreCase(parameterName));

/// <summary>Whether this invocation is a read-only introspection request rather than a build.</summary>
internal bool IsRequestedForRun => describe || planAsJson;

/// <summary>The document for whichever request <see cref="IsRequestedForRun" /> matched.</summary>
internal string GetDocument(
FalloutBuild build,
IReadOnlyCollection<ExecutableTarget> targets,
IReadOnlyCollection<ExecutableTarget> plan,
IReadOnlyCollection<string> invokedTargets,
IReadOnlyCollection<string> skippedTargets)
=> describe
? GetDescribeJson(build, targets)
: GetPlanJson(invokedTargets ?? new string[0], plan, skippedTargets);

/// <summary>
/// The resolved execution plan: what <em>would</em> run, in order, and what gates each entry.
/// Conditions are reported as their declared text and never evaluated — they are user delegates,
/// and running them would contradict "invokes no target".
/// </summary>
internal string GetPlanJson(
IReadOnlyCollection<string> invokedTargets,
IReadOnlyCollection<ExecutableTarget> plan,
IReadOnlyCollection<string> skippedTargets)
{
// Mirrors BuildExecutor: dashes are stripped before matching, and an empty --skip list
// means "skip everything".
var skipped = (skippedTargets ?? new string[0])
.Select(x => x.Replace("-", string.Empty)).ToList();

var entries = plan
.Select((target, index) => new PlanEntryModel(
target.Name,
index,
target.Invoked,
// MarkTargetSkipped only skips when !Invoked, so an explicitly invoked target runs
// even when --skip names it. The prediction has to agree with the executor.
!target.Invoked &&
skippedTargets != null &&
(skipped.Count == 0 || skipped.Contains(target.Name, StringComparer.OrdinalIgnoreCase))
? BuildExecutor.SkippedViaParameterReason
: null,
target.StaticConditions.Select(x => x.Text).ToList(),
target.DynamicConditions.Select(x => x.Text).ToList()))
.ToList();

return new PlanModel(
PlanSchemaVersion,
invokedTargets.ToList(),
entries)
.ToJson(BuildGraphUtility.SerializerOptions);
}

/// <summary>The whole build model: targets, dependency edges, tool requirements, parameters.</summary>
internal string GetDescribeJson(
FalloutBuild build,
IReadOnlyCollection<ExecutableTarget> targets)
=> BuildGraphUtility.GetJsonString(build, targets, falloutVersion);

/// <summary>
/// The failure form of both documents, so a consumer parsing standard output gets JSON whether
/// the request succeeded or the build threw on its way to being described.
/// </summary>
internal string GetErrorJson(Exception exception)
=> new ErrorModel(
ErrorSchemaVersion,
new ErrorDetailModel(exception.GetType().Name, exception.Message))
.ToJson(BuildGraphUtility.SerializerOptions);

internal sealed record ErrorModel(int Version, ErrorDetailModel Error);

internal sealed record ErrorDetailModel(string Kind, string Message);

internal sealed record PlanModel(
int Version,
IReadOnlyList<string> InvokedTargets,
IReadOnlyList<PlanEntryModel> Plan);

/// <summary>
/// One entry of the resolved plan. <paramref name="Order" /> is its position in the run,
/// <paramref name="Invoked" /> distinguishes an explicitly requested target from one pulled in
/// as a dependency, and <paramref name="Skip" /> is null unless <c>--skip</c> names it.
/// </summary>
internal sealed record PlanEntryModel(
string Name,
int Order,
bool Invoked,
string Skip,
IReadOnlyList<string> StaticConditions,
IReadOnlyList<string> DynamicConditions);
}
46 changes: 45 additions & 1 deletion src/Fallout.Build/Execution/BuildManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,41 @@ public static int Execute<T>(Expression<Func<T, Target>>[] defaultTargetExpressi
using var context = BuildContext.Activate();
var build = new T();

// Resolved once, then reused by the gate, the failure path and Finish(). Declared out here
// so those last two still have it; null means the run failed before the request could even
// be determined, which is an ordinary failure and reported as one.
BuildIntrospectionService introspection = null;

try
{
introspection = BuildIntrospectionService.For(build);

Logging.Configure(build);

build.ExecutableTargets = ExecutableTargetFactory.CreateAll(build, defaultTargetExpressions);

// A read-only introspection request short-circuits before ANY extension runs and long
// before EnsureToolRequirements. That single gate is what makes "changes nothing, runs
// nothing" structural: IOnBuildCreated alone rewrites .fallout/build.schema.json
// (HandleShellCompletionAttribute) and can block on Console.ReadKey
// (UpdateNotificationAttribute), which would deadlock a piped consumer.
if (introspection.IsRequestedForRun)
{
var invokedTargets = ParameterService.GetParameter<string[]>(() => build.InvokedTargets);
build.ExecutionPlan = ExecutionPlanner.GetExecutionPlan(build.ExecutableTargets, invokedTargets);

// Newline-terminated, matching SchemaUtility's emitted JSON and the usual
// expectation that a document written to a pipe ends with one.
Console.Out.WriteLine(
introspection.GetDocument(
build,
build.ExecutableTargets,
build.ExecutionPlan,
invokedTargets,
ParameterService.GetParameter<string[]>(() => build.SkippedTargets)));
return build.ExitCode ??= 0;
}

build.ExecuteExtension<IOnBuildCreated>(x => x.OnBuildCreated(build.ExecutableTargets));

NuGetToolPathResolver.EmbeddedPackagesDirectory = build.EmbeddedPackagesDirectory;
Expand Down Expand Up @@ -77,6 +107,18 @@ public static int Execute<T>(Expression<Func<T, Target>>[] defaultTargetExpressi
catch (Exception exception)
{
exception = exception.Unwrap();

// An introspection request promised JSON on standard output; a failure on the way to
// emitting the document has to keep that promise rather than switch to human prose.
if (introspection is { IsRequestedForRun: true })
{
// Nothing is logged here on purpose: Serilog's console sink writes to standard
// output, so a log line would land inside the document a consumer is parsing. The
// envelope carries the kind and message instead.
Console.Out.WriteLine(introspection.GetErrorJson(exception));
return build.ExitCode ??= ErrorExitCode;
}

if (exception is not TargetExecutionException)
{
Log.Verbose(exception, "Target-unrelated exception was thrown");
Expand All @@ -95,7 +137,9 @@ public static int Execute<T>(Expression<Func<T, Target>>[] defaultTargetExpressi

void Finish()
{
if (build.ExecutionPlan == null)
// The plan is resolved before the introspection short-circuit returns, so guarding on
// it alone would print the outcome tables over the emitted document.
if (build.ExecutionPlan == null || introspection is { IsRequestedForRun: true })
return;

foreach (var target in build.ExecutionPlan)
Expand Down
Loading