Skip to content

Allow publishing file-based apps #49310

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Jun 13, 2025
Merged
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
12 changes: 9 additions & 3 deletions documentation/general/dotnet-run-file.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,14 @@ For example, the remaining command-line arguments after the first argument (the
(except for the arguments recognized by `dotnet run` unless they are after the `--` separator)
and working directory is not changed (e.g., `cd /x/ && dotnet run /y/file.cs` runs the program in directory `/x/`).

### Other commands

Commands `dotnet restore file.cs` and `dotnet build file.cs` are needed for IDE support and hence work for file-based programs.

Command `dotnet publish file.cs` is also supported for file-based programs.
Note that file-based apps have implicitly set `PublishAot=true`, so publishing uses Native AOT (and building reports AOT warnings).
To opt out, use `#:property PublishAot=false` directive in your `.cs` file.

## Entry points

If a file is given to `dotnet run`, it has to be an *entry-point file*, otherwise an error is reported.
Expand Down Expand Up @@ -302,9 +310,8 @@ which is needed if one wants to use `/usr/bin/env` to find the `dotnet` executab
We could also consider making `dotnet file.cs` work because `dotnet file.dll` also works today
but that would require changes to the native dotnet host.

### Other commands
### Other possible commands

Commands `dotnet restore file.cs` and `dotnet build file.cs` are needed for IDE support and hence work for file-based programs.
We can consider supporting other commands like `dotnet pack`, `dotnet watch`,
however the primary scenario is `dotnet run` and we might never support additional commands.

Expand All @@ -319,7 +326,6 @@ We could also add `dotnet compile` command that would be the equivalent of `dotn
e.g., via `dotnet clean --file-based-program <path-to-entry-point>`
or `dotnet clean --all-file-based-programs`.


Adding package references via `dotnet package add` could be supported for file-based programs as well,
i.e., the command would add a `#:package` directive to the top of a `.cs` file.

Expand Down
2 changes: 1 addition & 1 deletion src/Cli/dotnet/Commands/Build/BuildCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ public static CommandBase FromParseResult(ParseResult parseResult, string msbuil
{
NoRestore = noRestore,
NoCache = true,
NoIncremental = noIncremental,
BuildTarget = noIncremental ? "Rebuild" : "Build",
};
}
else
Expand Down
47 changes: 36 additions & 11 deletions src/Cli/dotnet/Commands/Publish/PublishCommand.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

#nullable disable

using System.CommandLine;
using Microsoft.DotNet.Cli.Commands.Restore;
using Microsoft.DotNet.Cli.Commands.Run;
using Microsoft.DotNet.Cli.Extensions;

namespace Microsoft.DotNet.Cli.Commands.Publish;
Expand All @@ -14,49 +13,75 @@ public class PublishCommand : RestoringCommand
private PublishCommand(
IEnumerable<string> msbuildArgs,
bool noRestore,
string msbuildPath = null)
string? msbuildPath = null)
: base(msbuildArgs, noRestore, msbuildPath)
{
}

public static PublishCommand FromArgs(string[] args, string msbuildPath = null)
public static CommandBase FromArgs(string[] args, string? msbuildPath = null)
{
var parser = Parser.Instance;
var parseResult = parser.ParseFrom("dotnet publish", args);
return FromParseResult(parseResult);
}

public static PublishCommand FromParseResult(ParseResult parseResult, string msbuildPath = null)
public static CommandBase FromParseResult(ParseResult parseResult, string? msbuildPath = null)
{
parseResult.HandleDebugSwitch();
parseResult.ShowHelpOrErrorIfAppropriate();

var msbuildArgs = new List<string>()
{
"-target:Publish",
"--property:_IsPublishing=true" // This property will not hold true for MSBuild /t:Publish or in VS.
};

IEnumerable<string> slnOrProjectArgs = parseResult.GetValue(PublishCommandParser.SlnOrProjectArgument);
string[] args = parseResult.GetValue(PublishCommandParser.SlnOrProjectOrFileArgument) ?? [];

LoggerUtility.SeparateBinLogArguments(args, out var binLogArgs, out var nonBinLogArgs);

CommonOptions.ValidateSelfContainedOptions(parseResult.HasOption(PublishCommandParser.SelfContainedOption),
parseResult.HasOption(PublishCommandParser.NoSelfContainedOption));

msbuildArgs.AddRange(parseResult.OptionValuesToBeForwarded(PublishCommandParser.GetCommand()));

bool noBuild = parseResult.HasOption(PublishCommandParser.NoBuildOption);

bool noRestore = noBuild || parseResult.HasOption(PublishCommandParser.NoRestoreOption);

if (nonBinLogArgs is [{ } arg] && VirtualProjectBuildingCommand.IsValidEntryPointPath(arg))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@chsienki has a PR out to remove the need for the .cs suffix in the files. Think this needs to adjust to account for that.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It shouldn't need any adjusting - Chris's logic lives inside VirtualProjectBuildingCommand.IsValidEntryPointPath so this will pick it up automatically - and hence dotnet publish no-cs-extension will work (and similarly others like build/restore) - assuming we want that?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Def want it to work. I just want to make sure we're confident these PRs will converge correctly.

{
if (!parseResult.HasOption(PublishCommandParser.ConfigurationOption))
{
msbuildArgs.Add("-p:Configuration=Release");
}

msbuildArgs.AddRange(binLogArgs);

return new VirtualProjectBuildingCommand(
entryPointFileFullPath: Path.GetFullPath(arg),
msbuildArgs: msbuildArgs,
verbosity: parseResult.GetValue(CommonOptions.VerbosityOption),
interactive: parseResult.GetValue(CommonOptions.InteractiveMsBuildForwardOption))
{
NoBuild = noBuild,
NoRestore = noRestore,
NoCache = true,
BuildTarget = "Publish",
};
}

ReleasePropertyProjectLocator projectLocator = new(parseResult, MSBuildPropertyNames.PUBLISH_RELEASE,
new ReleasePropertyProjectLocator.DependentCommandOptions(
parseResult.GetValue(PublishCommandParser.SlnOrProjectArgument),
nonBinLogArgs,
parseResult.HasOption(PublishCommandParser.ConfigurationOption) ? parseResult.GetValue(PublishCommandParser.ConfigurationOption) : null,
parseResult.HasOption(PublishCommandParser.FrameworkOption) ? parseResult.GetValue(PublishCommandParser.FrameworkOption) : null
)
);
msbuildArgs.AddRange(projectLocator.GetCustomDefaultConfigurationValueIfSpecified());

msbuildArgs.AddRange(slnOrProjectArgs ?? []);
msbuildArgs.AddRange(args ?? []);

bool noRestore = parseResult.HasOption(PublishCommandParser.NoRestoreOption)
|| parseResult.HasOption(PublishCommandParser.NoBuildOption);
msbuildArgs.Insert(0, "-target:Publish");

return new PublishCommand(
msbuildArgs,
Expand Down
6 changes: 3 additions & 3 deletions src/Cli/dotnet/Commands/Publish/PublishCommandParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ internal static class PublishCommandParser
{
public static readonly string DocsLink = "https://aka.ms/dotnet-publish";

public static readonly Argument<IEnumerable<string>> SlnOrProjectArgument = new(CliStrings.SolutionOrProjectArgumentName)
public static readonly Argument<string[]> SlnOrProjectOrFileArgument = new(CliStrings.SolutionOrProjectOrFileArgumentName)
{
Description = CliStrings.SolutionOrProjectArgumentDescription,
Description = CliStrings.SolutionOrProjectOrFileArgumentDescription,
Arity = ArgumentArity.ZeroOrMore
};

Expand Down Expand Up @@ -67,7 +67,7 @@ private static Command ConstructCommand()
{
var command = new DocumentedCommand("publish", DocsLink, CliCommandStrings.PublishAppDescription);

command.Arguments.Add(SlnOrProjectArgument);
command.Arguments.Add(SlnOrProjectOrFileArgument);
RestoreCommandParser.AddImplicitRestoreOptions(command, includeRuntimeOption: false, includeNoDependenciesOption: true);

command.Options.Add(OutputOption);
Expand Down
2 changes: 1 addition & 1 deletion src/Cli/dotnet/Commands/Run/CommonRunHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ internal static class CommonRunHelpers
/// <param name="globalProperties">
/// Should have <see cref="StringComparer.OrdinalIgnoreCase"/>.
/// </param>
public static void AddUserPassedProperties(Dictionary<string, string> globalProperties, string[] args)
public static void AddUserPassedProperties(Dictionary<string, string> globalProperties, IReadOnlyList<string> args)
{
Debug.Assert(globalProperties.Comparer == StringComparer.OrdinalIgnoreCase);

Expand Down
13 changes: 7 additions & 6 deletions src/Cli/dotnet/Commands/Run/VirtualProjectBuildingCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ internal sealed class VirtualProjectBuildingCommand : CommandBase

public VirtualProjectBuildingCommand(
string entryPointFileFullPath,
string[] msbuildArgs,
IReadOnlyList<string> msbuildArgs,
VerbosityOptions? verbosity,
bool interactive)
{
Expand All @@ -77,12 +77,12 @@ public VirtualProjectBuildingCommand(

public string EntryPointFileFullPath { get; }
public Dictionary<string, string> GlobalProperties { get; }
public string[] BinaryLoggerArgs { get; }
public IReadOnlyList<string> BinaryLoggerArgs { get; }
public VerbosityOptions Verbosity { get; }
public bool NoRestore { get; init; }
public bool NoCache { get; init; }
public bool NoBuild { get; init; }
public bool NoIncremental { get; init; }
public string BuildTarget { get; init; } = "Build";

public override int Execute()
{
Expand Down Expand Up @@ -164,7 +164,7 @@ public override int Execute()
{
var buildRequest = new BuildRequestData(
CreateProjectInstance(projectCollection),
targetsToBuild: [NoIncremental ? "Rebuild" : "Build"]);
targetsToBuild: [BuildTarget]);
var buildResult = BuildManager.DefaultBuildManager.BuildRequest(buildRequest);
if (buildResult.OverallResult != BuildResultCode.Success)
{
Expand Down Expand Up @@ -195,10 +195,10 @@ public override int Execute()
consoleLogger.Shutdown();
}

static ILogger? GetBinaryLogger(string[] args)
static ILogger? GetBinaryLogger(IReadOnlyList<string> args)
{
// Like in MSBuild, only the last binary logger is used.
for (int i = args.Length - 1; i >= 0; i--)
for (int i = args.Count - 1; i >= 0; i--)
{
var arg = args[i];
if (LoggerUtility.IsBinLogArgument(arg))
Expand Down Expand Up @@ -534,6 +534,7 @@ public static void WriteProjectFile(
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<PublishAot>true</PublishAot>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we unconditionally do this? What if the user has the following code?

#:property PublishAot false

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The project will get <PublishAot>false</PublishAot> later in the file, overriding the default. There is also #49177 to remove the default in case the directive is present (just to make the project file cleaner but functionally nothing should change).

</PropertyGroup>
""");

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ public void MsbuildInvocationIsCorrect(string[] args, string[] expectedAdditiona
.ToArray();

var msbuildPath = "<msbuildpath>";
var command = PublishCommand.FromArgs(args, msbuildPath);
var command = (PublishCommand)PublishCommand.FromArgs(args, msbuildPath);

command.SeparateRestoreCommand
.Should()
Expand All @@ -67,7 +67,7 @@ public void MsbuildInvocationIsCorrect(string[] args, string[] expectedAdditiona
public void MsbuildInvocationIsCorrectForSeparateRestore(string[] args, string[] expectedAdditionalArgs)
{
var msbuildPath = "<msbuildpath>";
var command = PublishCommand.FromArgs(args, msbuildPath);
var command = (PublishCommand)PublishCommand.FromArgs(args, msbuildPath);

var restoreTokens =
command.SeparateRestoreCommand
Expand All @@ -92,7 +92,7 @@ public void MsbuildInvocationIsCorrectForSeparateRestore(string[] args, string[]
public void MsbuildInvocationIsCorrectForNoBuild()
{
var msbuildPath = "<msbuildpath>";
var command = PublishCommand.FromArgs(new[] { "--no-build" }, msbuildPath);
var command = (PublishCommand)PublishCommand.FromArgs(new[] { "--no-build" }, msbuildPath);

command.SeparateRestoreCommand
.Should()
Expand All @@ -107,7 +107,7 @@ public void MsbuildInvocationIsCorrectForNoBuild()
public void CommandAcceptsMultipleCustomProperties()
{
var msbuildPath = "<msbuildpath>";
var command = PublishCommand.FromArgs(new[] { "/p:Prop1=prop1", "/p:Prop2=prop2" }, msbuildPath);
var command = (PublishCommand)PublishCommand.FromArgs(new[] { "/p:Prop1=prop1", "/p:Prop2=prop2" }, msbuildPath);

command.GetArgumentTokensToMSBuild()
.Should()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ public MSBuildArgumentCommandLineParserTests(ITestOutputHelper output)
public void MSBuildArgumentsAreForwardedCorrectly(string[] arguments, bool buildCommand)
{
RestoringCommand command = buildCommand ?
((RestoringCommand)BuildCommand.FromArgs(arguments)) :
PublishCommand.FromArgs(arguments);
(RestoringCommand)BuildCommand.FromArgs(arguments) :
(RestoringCommand)PublishCommand.FromArgs(arguments);
var expectedArguments = arguments.Select(a => a.Replace("-property:", "--property:").Replace("-p:", "--property:"));
var argString = command.MSBuildArguments;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,12 @@ public void SameAsTemplate()

var dotnetProjectConvertProjectText = File.ReadAllText(dotnetProjectConvertProject);
var dotnetNewConsoleProjectText = File.ReadAllText(dotnetNewConsoleProject);
dotnetProjectConvertProjectText.Should().Be(dotnetNewConsoleProjectText)

// There are some differences: we add PublishAot=true.
var patchedDotnetProjectConvertProjectText = dotnetProjectConvertProjectText
.Replace(" <PublishAot>true</PublishAot>" + Environment.NewLine, string.Empty);

patchedDotnetProjectConvertProjectText.Should().Be(dotnetNewConsoleProjectText)
.And.StartWith("""<Project Sdk="Microsoft.NET.Sdk">""");
}

Expand Down Expand Up @@ -315,6 +320,7 @@ public void ProcessingSucceeds()
<TargetFramework>{ToolsetInfo.CurrentTargetFramework}</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<PublishAot>true</PublishAot>
</PropertyGroup>

</Project>
Expand Down Expand Up @@ -345,6 +351,7 @@ public void Directives()
<TargetFramework>{ToolsetInfo.CurrentTargetFramework}</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<PublishAot>true</PublishAot>
</PropertyGroup>

<PropertyGroup>
Expand Down Expand Up @@ -380,6 +387,7 @@ public void Directives_Variable()
<TargetFramework>{ToolsetInfo.CurrentTargetFramework}</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<PublishAot>true</PublishAot>
</PropertyGroup>

<PropertyGroup>
Expand Down Expand Up @@ -421,6 +429,7 @@ public void Directives_Separators()
<TargetFramework>{ToolsetInfo.CurrentTargetFramework}</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<PublishAot>true</PublishAot>
</PropertyGroup>

<PropertyGroup>
Expand Down Expand Up @@ -515,6 +524,7 @@ public void Directives_Escaping()
<TargetFramework>{ToolsetInfo.CurrentTargetFramework}</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<PublishAot>true</PublishAot>
</PropertyGroup>

<PropertyGroup>
Expand Down Expand Up @@ -551,6 +561,7 @@ public void Directives_Whitespace()
<TargetFramework>{ToolsetInfo.CurrentTargetFramework}</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<PublishAot>true</PublishAot>
</PropertyGroup>

<PropertyGroup>
Expand Down Expand Up @@ -608,6 +619,7 @@ public void Directives_AfterToken()
<TargetFramework>{ToolsetInfo.CurrentTargetFramework}</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<PublishAot>true</PublishAot>
</PropertyGroup>

<PropertyGroup>
Expand Down Expand Up @@ -656,6 +668,7 @@ public void Directives_AfterIf()
<TargetFramework>{ToolsetInfo.CurrentTargetFramework}</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<PublishAot>true</PublishAot>
</PropertyGroup>

<PropertyGroup>
Expand Down Expand Up @@ -701,6 +714,7 @@ public void Directives_Comments()
<TargetFramework>{ToolsetInfo.CurrentTargetFramework}</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<PublishAot>true</PublishAot>
</PropertyGroup>

<PropertyGroup>
Expand Down
Loading
Loading