How to get MSBuild property values
fakhrulhilal opened this issue · 5 comments
Description
I want to transform placeholder in the web.config and populate it with MSBuild properties, either getting value from parameter, property attribute, or azure pipeline variables. The original goal is replacing placeholder (using format [CAPITALIZED_NAME]
) within config files. The main source for replacing placeholders are from azure pipeline variables. It works by using example code. But sometimes, I need to test the build in my local using MSBuild parameter, making sure it will not break the build.
Example code
<UsingTask TaskName="PopulatePlaceholders" TaskFactory="RoslynCodeTaskFactory" AssemblyFile="$(MSBuildToolsPath)\Microsoft.Build.Tasks.Core.dll" >
<ParameterGroup>
<SourceFile ParameterType="System.String" Required="true" />
<TargetFile ParameterType="System.String" Required="true" />
<Verbose ParameterType="System.Boolean" Required="false" />
</ParameterGroup>
<Task>
<Using Namespace="System.Text.RegularExpressions"/>
<Code Type="Fragment" Language="cs">
<![CDATA[
if (File.Exists(SourceFile))
{
Log.LogMessage($"Populating placeholder from MSBuild properties in '{SourceFile}'");
string sourceContent = File.ReadAllText(SourceFile);
string baseFolder = Path.GetFileName(Path.GetDirectoryName(SourceFile));
var matches = Regex.Matches(sourceContent, @"\[(?<placeholder>[A-Z_]+)\]", RegexOptions.Multiline);
string transformed = Regex.Replace(sourceContent, @"\[(?<placeholder>[A-Z_]+)\]", match =>
{
string placeholder = match.Groups["placeholder"].Value;
string envValue = Environment.GetEnvironmentVariable(placeholder);
if (Verbose) Log.LogMessage($"[{baseFolder}] Replacing {match.Value} with: {envValue}");
return !string.IsNullOrEmpty(envValue) ? envValue : match.Value;
}, RegexOptions.Multiline);
File.WriteAllText(TargetFile, transformed);
}
]]>
</Code>
</Task>
</UsingTask>
]]>
</Code>
</Task>
</UsingTask>
What I've tried:
- Transform task
Well, I need to define each transform XML in individual project (for file transformation). This will be harder to maintain for many projects. And the variable substitutions only support certain place. - Magic Chunks
This task suports more than transform task. But when I define single global source config for all projects, it adds new config value which's not defined in original web.config, breaking the config.
Expectation
All msbuild parameter prosessed from:
- From parameter (
/p:Name=Value
pair) - From property element (
<PropertyGroup><Name>Value</Name></PropertyGroup>
) - From azure pipeline variables
Actual
Only azure pipeline variables are parsed
This only works in Azure DevOps because it sets environment variables and not MSBuild properties.
If you want it to work for /p:Name=Value
or <PropertyGroup/>
you'll need to pass each property into the task.
<UsingTask TaskName="PopulatePlaceholders" TaskFactory="RoslynCodeTaskFactory" AssemblyFile="$(MSBuildToolsPath)\Microsoft.Build.Tasks.Core.dll" >
<ParameterGroup>
<SourceFile ParameterType="System.String" Required="true" />
<TargetFile ParameterType="System.String" Required="true" />
<Verbose ParameterType="System.Boolean" Required="false" />
<Property1 ParameterType="System.String" />
<Property2 ParameterType="System.String" />
...
</ParameterGroup>
...
</UsingTask>
The task code would then have to use reflection to attempt to look up a matching property for the place holder.
And the task call would look like:
<Target Name="MyTarget">
<MyTask Property1="$(Property1)" Property2="$(Property2)" />
</Target>
This is just how MSBuild works, environment variables become properties but tasks are only passed the properties you tell it. You're getting around this because you're calling Environment.GetEnvironmentVariable()
. But of course that won't work for MSBuild properties passed at the command-line.
We did this before using this task. The placeholder was registered manually and transform the value based on MSBuild property/azure pipeline variable. The problem is maintenance is less efficient (around 10 projects and 29 placeholders). Using this way is easy, I just need to register the variable in azure pipeline variable using the same name as placeholder. No need to edit the build task again. But it doesn't work when calling msbuild directly by specifying only parameter that I'm testing, not all placeholders.
I'm looking for something like MSBuild SDK/built-in property function to get the value. I've searched in docs but still don't get what I'm looking for.
I found this, but I couldn't find working example using this:
<Target Name="PopuplateAndReplaceConfig" BeforeTargets="Compile" AfterTargets="ConfigWebTransform;ConfigAppTransform" Condition="'$(DeployOnBuild)' == 'true'">
<PopulatePlaceholders SourceFile="$(MSBuildProjectDirectory)\Web.config" TargetFile="$(MSBuildProjectDirectory)\Web.config" Verbose="$(BuildVerbose)" ProjectFile="$(MSBuildProjectFullPath)" />
<PopulatePlaceholders SourceFile="$(MSBuildProjectDirectory)\App.config" TargetFile="$(MSBuildProjectDirectory)\App.config" Verbose="$(BuildVerbose)" ProjectFile="$(MSBuildProjectFullPath)" />
</Target>
<UsingTask TaskName="PopulatePlaceholders" TaskFactory="RoslynCodeTaskFactory" AssemblyFile="$(MSBuildToolsPath)\Microsoft.Build.Tasks.Core.dll" >
<ParameterGroup>
<SourceFile ParameterType="System.String" Required="true" />
<TargetFile ParameterType="System.String" Required="true" />
<ProjectFile ParameterType="System.String" Required="true" />
<Verbose ParameterType="System.Boolean" Required="false" />
</ParameterGroup>
<Task>
<Using Namespace="System.Text.RegularExpressions"/>
<Reference Include="Microsoft.Build"/>
<Code Type="Fragment" Language="cs">
<![CDATA[
if (File.Exists(SourceFile))
{
var project = new Microsoft.Build.Evaluation.Project(ProjectFile);
Log.LogMessage($"Populating placeholder from MSBuild properties in '{SourceFile}'");
string sourceContent = File.ReadAllText(SourceFile);
string baseFolder = Path.GetFileName(Path.GetDirectoryName(SourceFile));
var matches = Regex.Matches(sourceContent, @"\[(?<placeholder>[A-Z_]+)\]", RegexOptions.Multiline);
string transformed = Regex.Replace(sourceContent, @"\[(?<placeholder>[A-Z_]+)\]", match =>
{
string placeholder = match.Groups["placeholder"].Value;
var property = project.GetProperty(placeholder);
if (property != null)
{
string propertyValue = property.EvaluatedValue;
if (Verbose) Log.LogMessage($"[{baseFolder}] Replacing {match.Value} from build property: {propertyValue} ");
return property.EvaluatedValue;
}
string envValue = Environment.GetEnvironmentVariable(placeholder);
if (Verbose) Log.LogMessage($"[{baseFolder}] Replacing {match.Value} from environment variable: {envValue} ");
return !string.IsNullOrEmpty(envValue) ? envValue : match.Value;
}, RegexOptions.Multiline);
File.WriteAllText(TargetFile, transformed);
}
]]>
</Code>
</Task>
</UsingTask>
Unfortunately that will re-evaluate the project with different global properties than are currently set. I did recently add a feature to MSBuild to allow tasks to get all of the global properties:
dotnet/msbuild#4939
That would only let you enumerate all properties set via the command-line /Property:
(or /p:
) argument
I can finally get this working, getting value from 3 sources: environment variable (azure pipeline variable), MSBuild property element, MSBuild parameter. I found this solution after searching the whole MSBuild's source code, referenced from your PR.
<UsingTask TaskName="PopulatePlaceholders" TaskFactory="RoslynCodeTaskFactory" AssemblyFile="$(MSBuildToolsPath)\Microsoft.Build.Tasks.Core.dll" >
<ParameterGroup>
<SourceFile ParameterType="System.String" Required="true" />
<TargetFile ParameterType="System.String" Required="true" />
<Verbose ParameterType="System.Boolean" Required="false" />
</ParameterGroup>
<Task>
<Reference Include="Microsoft.Build"/>
<Using Namespace="System.Text.RegularExpressions"/>
<Using Namespace="System.Reflection"/>
<Using Namespace="Microsoft.Build.Execution"/>
<Code Type="Fragment" Language="cs">
<![CDATA[
ProjectInstance GetProjectInstance()
{
var buildEngine = ((IBuildEngine6)BuildEngine);
var requestEntryField = buildEngine.GetType().GetField("_requestEntry", BindingFlags.NonPublic | BindingFlags.Instance);
var requestEntry = requestEntryField.GetValue(buildEngine);
var requestConfigProperty = requestEntry.GetType().GetProperty("RequestConfiguration", BindingFlags.Instance | BindingFlags.Public);
var requestConfig = requestConfigProperty.GetValue(requestEntry);
var projectProperty = requestConfig.GetType().GetProperty("Project", BindingFlags.Public | BindingFlags.Instance);
return (ProjectInstance)projectProperty.GetValue(requestConfig);
}
string GetPlaceholderValue(string placeholder)
{
var project = GetProjectInstance();
var property = project.GetProperty(placeholder);
return property?.EvaluatedValue;
}
if (!File.Exists(SourceFile)) return true;
var project = GetProjectInstance();
Log.LogMessage($"Populating placeholder from MSBuild properties in '{SourceFile}'");
string sourceContent = File.ReadAllText(SourceFile);
string baseFolder = Path.GetFileName(Path.GetDirectoryName(SourceFile));
var matches = Regex.Matches(sourceContent, @"\[(?<placeholder>[A-Z_]+)\]", RegexOptions.Multiline);
string transformed = Regex.Replace(sourceContent, @"\[(?<placeholder>[A-Z_]+)\]", match =>
{
string placeholder = match.Groups["placeholder"].Value;
string value = GetPlaceholderValue(placeholder);
if (string.IsNullOrEmpty(value))
{
if (Verbose) Log.LogMessage($"[{baseFolder}] Keeping the placeholder {match.Value} because found no value either from MSBuild property or environment variable");
return match.Value;
}
else
{
if (Verbose) Log.LogMessage($"[{baseFolder}] Replacing {match.Value} using: {value}");
return value;
}
}, RegexOptions.Multiline);
File.WriteAllText(TargetFile, transformed);
]]>
</Code>
</Task>
</UsingTask>
I know it's a bit hacky by calling private field. It's good to add this feature.