Skip to content
Draft
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
7 changes: 4 additions & 3 deletions Directory.Build.targets
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
PackageId.targets file which brings the ConfigurationSchema.json file into the Json Schema.
-->
<PropertyGroup>
<ConfigurationSchemaGeneratorEnabled Condition="'$(ConfigurationSchemaGeneratorEnabled)' == ''">true</ConfigurationSchemaGeneratorEnabled>
<ConfigurationSchemaPath>$(MSBuildProjectDirectory)\ConfigurationSchema.json</ConfigurationSchemaPath>
<ConfigurationSchemaExists Condition="Exists('$(ConfigurationSchemaPath)')">true</ConfigurationSchemaExists>
</PropertyGroup>
Expand All @@ -39,15 +40,15 @@
<!--
Logic for generating and comparing the ConfigurationSchema.json file
-->
<PropertyGroup Condition="'$(IsPackable)' == 'true'">
<PropertyGroup Condition="'$(IsPackable)' == 'true' AND '$(ConfigurationSchemaGeneratorEnabled)' == 'true'">
<TargetsTriggeredByCompilation Condition="'$(DesignTimeBuild)' != 'true'">$(TargetsTriggeredByCompilation);GenerateConfigurationSchema</TargetsTriggeredByCompilation>

<ConfigurationSchemaGeneratorProjectPath>$(MSBuildThisFileDirectory)src\Tools\src\ConfigurationSchemaGenerator\ConfigurationSchemaGenerator.csproj</ConfigurationSchemaGeneratorProjectPath>
<ConfigurationSchemaGeneratorRspPath>$(IntermediateOutputPath)$(AsemblyName).configschema.rsp</ConfigurationSchemaGeneratorRspPath>
<ConfigurationSchemaGeneratorRspPath>$(IntermediateOutputPath)$(AssemblyName).configschema.rsp</ConfigurationSchemaGeneratorRspPath>
<GeneratedConfigurationSchemaOutputPath>$(IntermediateOutputPath)ConfigurationSchema.json</GeneratedConfigurationSchemaOutputPath>
</PropertyGroup>

<ItemGroup Condition="'$(IsPackable)' == 'true'">
<ItemGroup Condition="'$(IsPackable)' == 'true' AND '$(ConfigurationSchemaGeneratorEnabled)' == 'true'">
<!-- ensure the config generator is built -->
<ProjectReference Include="$(ConfigurationSchemaGeneratorProjectPath)"
Private="false"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ internal sealed partial class GitInfoContributor : ConfigurationContributor, IIn
private readonly ILogger _logger;

public GitInfoContributor(ILogger<GitInfoContributor> logger)
: this($"{Directory.GetCurrentDirectory()}{Path.DirectorySeparatorChar}{GitPropertiesFileName}", logger)
: this(ResolveDefaultPropertiesPath(), logger)
{
}

Expand All @@ -33,6 +33,30 @@ public GitInfoContributor(string propertiesPath, ILogger<GitInfoContributor> log
_logger = logger;
}

private static string ResolveDefaultPropertiesPath()
{
return ResolveDefaultPropertiesPath(AppContext.BaseDirectory, Directory.GetCurrentDirectory());
}

/// <summary>
/// Prefers the directory the running assembly was loaded from (where a build tool like Steeltoe.Management.GitProperties.Build copies git.properties to)
/// over the process's current working directory, since the latter depends entirely on how the application was launched (for example, `dotnet run` and
/// directly invoking a built DLL both leave the current directory pointed at the project directory, not the output directory the assembly - and
/// git.properties - actually live in) and can't be relied on to match. Takes both directories as parameters purely so tests can exercise this resolution
/// logic against isolated temporary directories, without touching either of this process's real ones.
/// </summary>
internal static string ResolveDefaultPropertiesPath(string baseDirectory, string currentDirectory)
{
string baseDirectoryPath = Path.Combine(baseDirectory, GitPropertiesFileName);

if (File.Exists(baseDirectoryPath))
{
return baseDirectoryPath;
}

return Path.Combine(currentDirectory, GitPropertiesFileName);
}

public async Task ContributeAsync(InfoBuilder builder, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(builder);
Expand Down
119 changes: 119 additions & 0 deletions src/Management/src/GitProperties.Build/ComposeGitPropertiesTask.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the Apache 2.0 License.
// See the LICENSE file in the project root for more information.

using System.Globalization;
using Microsoft.Build.Framework;
using Microsoft.Build.Utilities;

namespace Steeltoe.Management.GitProperties.Build;

/// <summary>
/// Merges the shared cache (see <see cref="GenerateGitPropertiesCacheTask" />) with the fields that can never be cached across projects: the live
/// working-tree dirty state, the per-project $(Version), and the current build's own timestamp. Runs once per project, every build - unlike the cache
/// generation, this is deliberately not skippable via Inputs/Outputs, since editing a tracked file doesn't touch any file timestamp this task could key
/// incrementality off, and a cached build time would go stale the moment it's reused by a second build.
/// </summary>
// ReSharper disable once UnusedType.Global
public sealed class ComposeGitPropertiesTask : Task
{
/// <summary>
/// Gets or sets the resolved git repository root directory.
/// </summary>
[Required]
public string RepositoryRoot { get; set; } = string.Empty;

/// <summary>
/// Gets or sets the git executable to invoke.
/// </summary>
[Required]
public string GitExecutable { get; set; } = string.Empty;

/// <summary>
/// Gets or sets the shared cache file to read from.
/// </summary>
[Required]
public string CacheFile { get; set; } = string.Empty;

/// <summary>
/// Gets or sets the per-project output file to write.
/// </summary>
[Required]
public string OutputFile { get; set; } = string.Empty;

/// <summary>
/// Gets or sets the consuming project's $(Version), written as git.build.version.
/// </summary>
public string? Version { get; set; }

/// <summary>
/// Gets or sets an optional additional path to copy the same composed content to - the durable fallback file that
/// Steeltoe.Management.GitProperties.Build.targets' IncludeGitPropertiesInOutput target later falls back to when a build has no usable git repository at
/// all (e.g. a source-based `cf push`, where .git is excluded from the pushed tree by default). Empty (the default) is a no-op; only non-empty when the
/// consumer opted into $(GitPropertiesWriteToProjectDirectory).
/// </summary>
public string? FallbackFile { get; set; }

/// <inheritdoc />
public override bool Execute()
{
int exitCode = GitProcessRunner.Run(GitExecutable, RepositoryRoot, "status --porcelain", out string stdout, out string stderr);

if (exitCode != 0)
{
Log.LogError("git.properties: failed to determine working tree status: {0}", stderr);
return false;
}

bool isDirty = stdout.Length > 0;
List<string> lines = File.ReadAllLines(CacheFile).ToList();

for (int index = 0; index < lines.Count; index++)
{
if (isDirty && lines[index].StartsWith($"{GitPropertiesFileWriter.CommitIdDescribeKey}=", StringComparison.Ordinal))
{
lines[index] += "-dirty";
}
}

lines.Add($"git.dirty={(isDirty ? "true" : "false")}");
lines.Add($"git.build.version={GitPropertiesFileWriter.EscapeLineBreaks(Version)}");

// S6354 (use an injectable time provider): not practical here, for the same reason as
// GitPropertiesFileWriter.TryAcquireExclusiveLock - see that method's remarks. Matches the
// ISO-8601-with-offset style git itself uses for git.commit.time (%cI), rather than
// normalizing to UTC - this is "when this build ran, in the build machine's own local time",
// not a value that needs to compare directly against the commit's own timestamp.
#pragma warning disable S6354
string buildTime = DateTimeOffset.Now.ToString("yyyy-MM-ddTHH:mm:sszzz", CultureInfo.InvariantCulture);
#pragma warning restore S6354
lines.Add($"git.build.time={buildTime}");

try
{
GitPropertiesFileWriter.WriteAtomic(OutputFile, lines);
}
catch (IOException exception)
{
Log.LogError($"git.properties: failed to write {OutputFile}: {exception.Message}");
return false;
}

if (FallbackFile is null or "")
{
return true;
}

try
{
GitPropertiesFileWriter.WriteAtomic(FallbackFile, lines);
}
catch (IOException exception)
{
Log.LogError($"git.properties: failed to write fallback file {FallbackFile}: {exception.Message}");
return false;
}

return true;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the Apache 2.0 License.
// See the LICENSE file in the project root for more information.

using Microsoft.Build.Framework;
using Microsoft.Build.Utilities;

namespace Steeltoe.Management.GitProperties.Build;

/// <summary>
/// Determines whether this project's own fully-resolved dependency graph includes any of <see cref="PackageIds" /> - used to smart-default
/// $(GenerateGitProperties) so the vast majority of projects in a large solution (class libraries, test projects, anything that can't possibly expose an
/// actuator) skip generation entirely, without requiring each of them to opt out individually.
/// </summary>
/// <remarks>
/// Reads <see cref="ProjectAssetsFile" /> (project.assets.json) rather than this project's own @(PackageReference) items deliberately: NuGet flattens
/// the *entire* transitive graph - through both PackageReference and ProjectReference chains - into every consuming project's own assets file, the same
/// mechanism that already lets a shared base library's own dependencies "just work" for whoever references it, without redeclaring them. That means this
/// also correctly detects the common pattern of a shared library wrapping actuator registration on behalf of many host apps, as long as that library's
/// own reference isn't PrivateAssets="All" - which would also strip the actuator assembly from every host's own runtime output, breaking the feature
/// outright, so it's not a viable pattern for a library that actually activates actuators in the first place. The file is written by a prior, separate
/// restore pass (implicit or explicit) - never by a target within the current build - so there's no ordering dependency on any of our own targets/hooks:
/// it's either already on disk with the final, fully-resolved graph, or it doesn't exist yet (a fresh clone with no restore at all), in which case this
/// safely reports no match instead of failing the build.
/// </remarks>
// ReSharper disable once UnusedType.Global
public sealed class DetectConsumingPackageReferenceTask : Task
{
/// <summary>
/// Gets or sets the semicolon-separated list of package IDs to look for.
/// </summary>
/// <remarks>
/// Deliberately not [Required], unlike every other string parameter on tasks in this project: MSBuild's required-parameter check treats an empty string
/// the same as "not supplied" at all, which would turn $(GitPropertiesConsumingPackageIds) explicitly set to blank (e.g. via
/// "-p:GitPropertiesConsumingPackageIds=") into a build error instead of the well-defined, graceful "no package ID ever matches" outcome
/// <see cref="ContainsAnyPackage" /> already produces for it.
/// </remarks>
public string PackageIds { get; set; } = string.Empty;

/// <summary>
/// Gets or sets the project's resolved assets file (typically $(ProjectAssetsFile)), or empty/nonexistent when the project has never been restored.
/// </summary>
public string ProjectAssetsFile { get; set; } = string.Empty;

/// <summary>
/// Gets or sets a value indicating whether any of <see cref="PackageIds" /> was found.
/// </summary>
[Output]
public bool HasReference { get; set; }

/// <inheritdoc />
public override bool Execute()
{
bool hasReference = false;

if (ProjectAssetsFile.Length > 0 && File.Exists(ProjectAssetsFile))
{
try
{
string content = File.ReadAllText(ProjectAssetsFile);
hasReference = ContainsAnyPackage(content);
}
catch
{
hasReference = false;
}
}

HasReference = hasReference;
return true;
}

/// <summary>
/// A plain substring search for the quoted package ID plus a trailing slash - the shape every "libraries" entry key in project.assets.json takes
/// ("PackageId/Version") - rather than a full JSON parse. Deliberately not scoped to the "libraries" object specifically, and deliberately not guarding
/// against a configured ID that happens to collide with an unrelated NuGet content-folder name (e.g. "lib", "tools", "analyzers", which show up as path
/// prefixes inside every package's own file list): Steeltoe's own consumers are enterprise teams that namespace-qualify their packages (e.g.
/// "Contoso.Actuators"), so a configured ID colliding with a short, generic folder name isn't a realistic scenario worth the complexity of a bounded
/// parse to rule out.
/// </summary>
private bool ContainsAnyPackage(string assetsFileContent)
{
foreach (string rawPackageId in PackageIds.Split(';'))
{
string packageId = rawPackageId.Trim();

if (packageId.Length > 0 && assetsFileContent.IndexOf($"\"{packageId}/", StringComparison.OrdinalIgnoreCase) >= 0)
{
return true;
}
}

return false;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the Apache 2.0 License.
// See the LICENSE file in the project root for more information.

using Microsoft.Build.Framework;
using Microsoft.Build.Utilities;

namespace Steeltoe.Management.GitProperties.Build;

/// <summary>
/// Walks up from <see cref="StartDirectory" /> looking for a ".git" directory. A ".git" file (worktree or submodule pointer) is deliberately reported
/// back via <see cref="IsUnsupportedGitFile" /> rather than treated as a match - full worktree/submodule support is out of scope.
/// </summary>
// ReSharper disable once UnusedType.Global
public sealed class FindGitRepositoryRootTask : Task
{
/// <summary>
/// Gets or sets the directory to start walking up from.
/// </summary>
[Required]
public string StartDirectory { get; set; } = string.Empty;

/// <summary>
/// Gets or sets the resolved repository root directory, or empty when none was found.
/// </summary>
[Output]
public string RepositoryRoot { get; set; } = string.Empty;

/// <summary>
/// Gets or sets a value indicating whether a ".git" file (rather than a directory) was found.
/// </summary>
[Output]
public bool IsUnsupportedGitFile { get; set; }

/// <inheritdoc />
public override bool Execute()
{
string repositoryRoot = string.Empty;
bool unsupportedGitFile = false;

try
{
var current = new DirectoryInfo(StartDirectory);

while (current != null)
{
string gitPath = Path.Combine(current.FullName, ".git");

if (Directory.Exists(gitPath))
{
repositoryRoot = current.FullName;

if (repositoryRoot.Length > 0 && repositoryRoot[repositoryRoot.Length - 1] != Path.DirectorySeparatorChar)
{
repositoryRoot = string.Concat(repositoryRoot, Path.DirectorySeparatorChar);
}

break;
}

if (File.Exists(gitPath))
{
unsupportedGitFile = true;
break;
}

current = current.Parent;
}
}
catch
{
repositoryRoot = string.Empty;
unsupportedGitFile = false;
}

RepositoryRoot = repositoryRoot;
IsUnsupportedGitFile = unsupportedGitFile;
return true;
}
}
Loading
Loading