diff --git a/Arcade.sln b/Arcade.sln
index 13a9337a627..b34b3272bfd 100644
--- a/Arcade.sln
+++ b/Arcade.sln
@@ -95,6 +95,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.DotNet.NuGetRepac
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.DotNet.Build.Tasks.Feed.Tests", "src\Microsoft.DotNet.Build.Tasks.Feed.Tests\Microsoft.DotNet.Build.Tasks.Feed.Tests.csproj", "{6E19C6B6-4ADF-4DD6-86CC-6C1624BCDB71}"
EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.DotNet.Build.Tasks.Templating", "src\Microsoft.DotNet.Build.Tasks.Templating\src\Microsoft.DotNet.Build.Tasks.Templating.csproj", "{AED823B2-2167-408E-9732-ECAD854FDCA5}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.DotNet.Build.Tasks.Templating.Tests", "src\Microsoft.DotNet.Build.Tasks.Templating\test\Microsoft.DotNet.Build.Tasks.Templating.Tests.csproj", "{FB4168D5-6EA6-4777-AD4F-95758C177FE8}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -593,6 +597,30 @@ Global
{6E19C6B6-4ADF-4DD6-86CC-6C1624BCDB71}.Release|x64.Build.0 = Release|Any CPU
{6E19C6B6-4ADF-4DD6-86CC-6C1624BCDB71}.Release|x86.ActiveCfg = Release|Any CPU
{6E19C6B6-4ADF-4DD6-86CC-6C1624BCDB71}.Release|x86.Build.0 = Release|Any CPU
+ {AED823B2-2167-408E-9732-ECAD854FDCA5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {AED823B2-2167-408E-9732-ECAD854FDCA5}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {AED823B2-2167-408E-9732-ECAD854FDCA5}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {AED823B2-2167-408E-9732-ECAD854FDCA5}.Debug|x64.Build.0 = Debug|Any CPU
+ {AED823B2-2167-408E-9732-ECAD854FDCA5}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {AED823B2-2167-408E-9732-ECAD854FDCA5}.Debug|x86.Build.0 = Debug|Any CPU
+ {AED823B2-2167-408E-9732-ECAD854FDCA5}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {AED823B2-2167-408E-9732-ECAD854FDCA5}.Release|Any CPU.Build.0 = Release|Any CPU
+ {AED823B2-2167-408E-9732-ECAD854FDCA5}.Release|x64.ActiveCfg = Release|Any CPU
+ {AED823B2-2167-408E-9732-ECAD854FDCA5}.Release|x64.Build.0 = Release|Any CPU
+ {AED823B2-2167-408E-9732-ECAD854FDCA5}.Release|x86.ActiveCfg = Release|Any CPU
+ {AED823B2-2167-408E-9732-ECAD854FDCA5}.Release|x86.Build.0 = Release|Any CPU
+ {FB4168D5-6EA6-4777-AD4F-95758C177FE8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {FB4168D5-6EA6-4777-AD4F-95758C177FE8}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {FB4168D5-6EA6-4777-AD4F-95758C177FE8}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {FB4168D5-6EA6-4777-AD4F-95758C177FE8}.Debug|x64.Build.0 = Debug|Any CPU
+ {FB4168D5-6EA6-4777-AD4F-95758C177FE8}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {FB4168D5-6EA6-4777-AD4F-95758C177FE8}.Debug|x86.Build.0 = Debug|Any CPU
+ {FB4168D5-6EA6-4777-AD4F-95758C177FE8}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {FB4168D5-6EA6-4777-AD4F-95758C177FE8}.Release|Any CPU.Build.0 = Release|Any CPU
+ {FB4168D5-6EA6-4777-AD4F-95758C177FE8}.Release|x64.ActiveCfg = Release|Any CPU
+ {FB4168D5-6EA6-4777-AD4F-95758C177FE8}.Release|x64.Build.0 = Release|Any CPU
+ {FB4168D5-6EA6-4777-AD4F-95758C177FE8}.Release|x86.ActiveCfg = Release|Any CPU
+ {FB4168D5-6EA6-4777-AD4F-95758C177FE8}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -614,6 +642,7 @@ Global
{4626A7D1-7AC0-4E21-9FED-083EF2A8194C} = {C53DD924-C212-49EA-9BC4-1827421361EF}
{41F3EF12-6062-44CE-A1DB-1DCA76122AF8} = {C53DD924-C212-49EA-9BC4-1827421361EF}
{6E19C6B6-4ADF-4DD6-86CC-6C1624BCDB71} = {C53DD924-C212-49EA-9BC4-1827421361EF}
+ {FB4168D5-6EA6-4777-AD4F-95758C177FE8} = {C53DD924-C212-49EA-9BC4-1827421361EF}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {32B9C883-432E-4FC8-A1BF-090EB033DD5B}
diff --git a/src/Microsoft.DotNet.Build.Tasks.Templating/src/GenerateFileFromTemplate.cs b/src/Microsoft.DotNet.Build.Tasks.Templating/src/GenerateFileFromTemplate.cs
new file mode 100644
index 00000000000..8eb7e5db08d
--- /dev/null
+++ b/src/Microsoft.DotNet.Build.Tasks.Templating/src/GenerateFileFromTemplate.cs
@@ -0,0 +1,152 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+using Microsoft.Build.Framework;
+using Microsoft.Build.Utilities;
+
+namespace Microsoft.DotNet.Build.Tasks.Templating
+{
+ ///
+ ///
+ /// Generates a new file at .
+ ///
+ ///
+ /// The can define variables for substitution using .
+ ///
+ ///
+ /// The input file might look like this:
+ ///
+ /// 2 + 2 = ${Sum}
+ ///
+ /// When the task is invoked like this, it will produce "2 + 2 = 4"
+ ///
+ /// <GenerateFileFromTemplate Properties="Sum=4;OtherValue=123;" ... >
+ ///
+ ///
+ ///
+ public class GenerateFileFromTemplate : Task
+ {
+ ///
+ /// The template file using the variable syntax ${VarName}.
+ /// If your template file needs to output this format, you can escape the dollar sign with a backtick e.g. `${NotReplaced}.
+ ///
+ [Required]
+ public string TemplateFile { get; set; }
+
+ ///
+ /// The destination for the generated file.
+ ///
+ [Required]
+ public string OutputPath { get; set; }
+
+ ///
+ /// Key=Value pairs of values, separated by semicolons e.g. Properties="Sum=4;OtherValue=123;".
+ ///
+ [Required]
+ public string[] Properties { get; set; }
+
+ ///
+ /// The destination for the generated file resolved by this task.
+ ///
+ [Output]
+ public string ResolvedOutputPath { get; set; }
+
+ public override bool Execute()
+ {
+ ResolvedOutputPath = Path.GetFullPath(OutputPath.Replace('\\', '/'));
+
+ if (!File.Exists(TemplateFile))
+ {
+ Log.LogError($"File {TemplateFile} does not exist");
+ return false;
+ }
+
+ IDictionary values = MSBuildListSplitter.GetNamedProperties(Properties, Log);
+ string template = File.ReadAllText(TemplateFile);
+
+ string result = Replace(template, values);
+ Directory.CreateDirectory(Path.GetDirectoryName(ResolvedOutputPath));
+ File.WriteAllText(ResolvedOutputPath, result);
+
+ return !Log.HasLoggedErrors;
+ }
+
+ public string Replace(string template, IDictionary values)
+ {
+ StringBuilder sb = new StringBuilder();
+ StringBuilder varNameSb = new StringBuilder();
+ int line = 1;
+ for (int i = 0; i < template.Length; i++)
+ {
+ char templateChar = template[i];
+ char nextTemplateChar = i + 1 >= template.Length
+ ? '\0'
+ : template[i + 1];
+
+ // count lines in the template file
+ if (templateChar == '\n')
+ {
+ line++;
+ }
+
+ if (templateChar == '`' && (nextTemplateChar == '$' || nextTemplateChar == '`'))
+ {
+ // skip the backtick for known escape characters
+ i++;
+ sb.Append(nextTemplateChar);
+ continue;
+ }
+
+ if (templateChar != '$' || nextTemplateChar != '{')
+ {
+ // variables begin with ${. Moving on.
+ sb.Append(templateChar);
+ continue;
+ }
+
+ varNameSb.Clear();
+ i += 2;
+ for (; i < template.Length; i++)
+ {
+ templateChar = template[i];
+ if (templateChar != '}')
+ {
+ varNameSb.Append(templateChar);
+ }
+ else
+ {
+ // Found the end of the variable substitution
+ string varName = varNameSb.ToString();
+ if (values.TryGetValue(varName, out string value))
+ {
+ sb.Append(value);
+ }
+ else
+ {
+ Log.LogWarning(null, null, null, TemplateFile,
+ line, 0, 0, 0,
+ message: $"No property value is available for '{varName}'");
+ }
+
+ varNameSb.Clear();
+ break;
+ }
+ }
+
+ if (varNameSb.Length > 0)
+ {
+ Log.LogWarning(null, null, null, TemplateFile,
+ line, 0, 0, 0,
+ message: "Expected closing bracket for variable placeholder. No substitution will be made.");
+ sb.Append("${").Append(varNameSb.ToString());
+ }
+ }
+
+ return sb.ToString();
+ }
+ }
+}
+
diff --git a/src/Microsoft.DotNet.Build.Tasks.Templating/src/MSBuildListSplitter.cs b/src/Microsoft.DotNet.Build.Tasks.Templating/src/MSBuildListSplitter.cs
new file mode 100644
index 00000000000..92b552ded70
--- /dev/null
+++ b/src/Microsoft.DotNet.Build.Tasks.Templating/src/MSBuildListSplitter.cs
@@ -0,0 +1,43 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Collections.Generic;
+using Microsoft.Build.Utilities;
+
+namespace Microsoft.DotNet.Build.Tasks.Templating
+{
+ internal static class MSBuildListSplitter
+ {
+ public static IDictionary GetNamedProperties(string[] input, TaskLoggingHelper log)
+ {
+ Dictionary values = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ if (input == null)
+ {
+ return values;
+ }
+
+ foreach (string item in input)
+ {
+ int splitIdx = item.IndexOf('=');
+ if (splitIdx < 0)
+ {
+ log.LogWarning($"Property: {item} does not have a valid '=' separator");
+ continue;
+ }
+
+ string key = item.Substring(0, splitIdx).Trim();
+ if (string.IsNullOrEmpty(key))
+ {
+ log.LogWarning($"Property: {item} does not have a valid property name");
+ continue;
+ }
+
+ string value = item.Substring(splitIdx + 1);
+ values[key] = value;
+ }
+
+ return values;
+ }
+ }
+}
diff --git a/src/Microsoft.DotNet.Build.Tasks.Templating/src/Microsoft.DotNet.Build.Tasks.Templating.csproj b/src/Microsoft.DotNet.Build.Tasks.Templating/src/Microsoft.DotNet.Build.Tasks.Templating.csproj
new file mode 100644
index 00000000000..5ff51a445ad
--- /dev/null
+++ b/src/Microsoft.DotNet.Build.Tasks.Templating/src/Microsoft.DotNet.Build.Tasks.Templating.csproj
@@ -0,0 +1,29 @@
+
+
+
+
+ netstandard2.0
+ Templating task package
+ Arcade Build Tool Templating
+ false
+ false
+ true
+ tools\
+ true
+ true
+ false
+
+
+
+
+ build
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Microsoft.DotNet.Build.Tasks.Templating/src/build/Microsoft.DotNet.Build.Tasks.Templating.props b/src/Microsoft.DotNet.Build.Tasks.Templating/src/build/Microsoft.DotNet.Build.Tasks.Templating.props
new file mode 100644
index 00000000000..4baeb5b308f
--- /dev/null
+++ b/src/Microsoft.DotNet.Build.Tasks.Templating/src/build/Microsoft.DotNet.Build.Tasks.Templating.props
@@ -0,0 +1,10 @@
+
+
+
+
+ $(MSBuildThisFileDirectory)..\tools\netstandard2.0\Microsoft.DotNet.Build.Tasks.Templating.dll
+
+
+
+
+
diff --git a/src/Microsoft.DotNet.Build.Tasks.Templating/test/GenerateFileFromTemplateTests.cs b/src/Microsoft.DotNet.Build.Tasks.Templating/test/GenerateFileFromTemplateTests.cs
new file mode 100644
index 00000000000..085a4c7e750
--- /dev/null
+++ b/src/Microsoft.DotNet.Build.Tasks.Templating/test/GenerateFileFromTemplateTests.cs
@@ -0,0 +1,102 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.IO;
+using Microsoft.DotNet.Arcade.Sdk.Tests.Utilities;
+using Xunit;
+
+namespace Microsoft.DotNet.Build.Tasks.Templating.Tests
+{
+ public class GenerateFileFromTemplateTests
+ {
+ [Fact]
+ public void GenerateFileFromTemplate_SubstitutesValidProperties()
+ {
+ string tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ string filePath = Path.Combine(tempDir, "Directory.Build.props");
+
+ try
+ {
+ GenerateFileFromTemplate task = new GenerateFileFromTemplate();
+ task.TemplateFile = GetFullPath("Directory.Build.props.in");
+ task.OutputPath = filePath;
+ task.Properties = new[] { "DefaultNetCoreTargetFramework=net6.0" };
+
+ Assert.True(task.Execute());
+ Assert.Equal(ReadAllText("Directory.Build.props.in").Replace("${DefaultNetCoreTargetFramework}", "net6.0"), File.ReadAllText(filePath));
+ }
+ finally
+ {
+ Directory.Delete(tempDir, true);
+ }
+ }
+
+ [Theory]
+ [InlineData("DefaultNetCoreTargetFramework=")]
+ [InlineData("=net6.0")]
+ [InlineData("net6.0")]
+ [InlineData("DefaultNetCoreTargetFramework:net6.0")]
+ [InlineData("Default_NetCore_Target_Framework=net6.0")]
+ public void GenerateFileFromTemplate_RemovesInvalidProperties(string invalidProperty)
+ {
+ string tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ string filePath = Path.Combine(tempDir, "Directory.Build.props");
+
+ try
+ {
+ GenerateFileFromTemplate task = new GenerateFileFromTemplate();
+ task.BuildEngine = new MockEngine();
+ task.TemplateFile = GetFullPath("Directory.Build.props.in");
+ task.OutputPath = filePath;
+ task.Properties = new[] { invalidProperty };
+
+ Assert.True(task.Execute());
+ Assert.Equal(ReadAllText("Directory.Build.props.in").Replace("${DefaultNetCoreTargetFramework}", string.Empty), File.ReadAllText(filePath));
+ }
+ finally
+ {
+ Directory.Delete(tempDir, true);
+ }
+ }
+
+ [Theory]
+ [InlineData("Directory.Build.props.malformedbraces.in")]
+ [InlineData("Directory.Build.props.nobraces.in")]
+ public void GenerateFileFromTemplate_IgnoresMalformedTemplate(string filename)
+ {
+ string tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ string filePath = Path.Combine(tempDir, "Directory.Build.props");
+
+ try
+ {
+ GenerateFileFromTemplate task = new GenerateFileFromTemplate();
+ task.BuildEngine = new MockEngine();
+ task.TemplateFile = GetFullPath(filename);
+ task.OutputPath = filePath;
+ task.Properties = new[] { "DefaultNetCoreTargetFramework=net6.0" };
+
+ Assert.True(task.Execute());
+ Assert.Equal(ReadAllText(filename), File.ReadAllText(filePath));
+ }
+ finally
+ {
+ Directory.Delete(tempDir, true);
+ }
+ }
+
+ public static string GetFullPath(string relativeTestInputPath)
+ {
+ return Path.Combine(
+ Path.GetDirectoryName(typeof(GenerateFileFromTemplateTests).Assembly.Location),
+ "testassets",
+ relativeTestInputPath);
+ }
+
+ public static string ReadAllText(string relativeTestInputPath)
+ {
+ string path = GetFullPath(relativeTestInputPath);
+ return File.ReadAllText(path);
+ }
+ }
+}
diff --git a/src/Microsoft.DotNet.Build.Tasks.Templating/test/Microsoft.DotNet.Build.Tasks.Templating.Tests.csproj b/src/Microsoft.DotNet.Build.Tasks.Templating/test/Microsoft.DotNet.Build.Tasks.Templating.Tests.csproj
new file mode 100644
index 00000000000..8ddb77dd434
--- /dev/null
+++ b/src/Microsoft.DotNet.Build.Tasks.Templating/test/Microsoft.DotNet.Build.Tasks.Templating.Tests.csproj
@@ -0,0 +1,26 @@
+
+
+
+ netcoreapp2.1;net472
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Always
+
+
+
+
+
+
+
+
diff --git a/src/Microsoft.DotNet.Build.Tasks.Templating/test/testassets/Directory.Build.props.in b/src/Microsoft.DotNet.Build.Tasks.Templating/test/testassets/Directory.Build.props.in
new file mode 100644
index 00000000000..53438eec736
--- /dev/null
+++ b/src/Microsoft.DotNet.Build.Tasks.Templating/test/testassets/Directory.Build.props.in
@@ -0,0 +1,6 @@
+
+
+ ${DefaultNetCoreTargetFramework}
+ true
+
+
\ No newline at end of file
diff --git a/src/Microsoft.DotNet.Build.Tasks.Templating/test/testassets/Directory.Build.props.malformedbraces.in b/src/Microsoft.DotNet.Build.Tasks.Templating/test/testassets/Directory.Build.props.malformedbraces.in
new file mode 100644
index 00000000000..ae2a552f86e
--- /dev/null
+++ b/src/Microsoft.DotNet.Build.Tasks.Templating/test/testassets/Directory.Build.props.malformedbraces.in
@@ -0,0 +1,6 @@
+
+
+ ${DefaultNetCoreTargetFramework
+ true
+
+
\ No newline at end of file
diff --git a/src/Microsoft.DotNet.Build.Tasks.Templating/test/testassets/Directory.Build.props.nobraces.in b/src/Microsoft.DotNet.Build.Tasks.Templating/test/testassets/Directory.Build.props.nobraces.in
new file mode 100644
index 00000000000..fd0defc397e
--- /dev/null
+++ b/src/Microsoft.DotNet.Build.Tasks.Templating/test/testassets/Directory.Build.props.nobraces.in
@@ -0,0 +1,6 @@
+
+
+ DefaultNetCoreTargetFramework
+ true
+
+
\ No newline at end of file