diff --git a/Directory.Packages.props b/Directory.Packages.props
index 60ee8243..80a25cc2 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -50,5 +50,9 @@
+
+
+
+
\ No newline at end of file
diff --git a/PostSharp.Engineering.sln b/PostSharp.Engineering.sln
index a9799681..a61a355e 100644
--- a/PostSharp.Engineering.sln
+++ b/PostSharp.Engineering.sln
@@ -13,36 +13,99 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PostSharp.Engineering.Syste
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PostSharp.Engineering.McpApprovalServer", "src\PostSharp.Engineering.McpApprovalServer\PostSharp.Engineering.McpApprovalServer.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}"
EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PostSharp.Engineering.BuildTools.Tests", "src\PostSharp.Engineering.BuildTools.Tests\PostSharp.Engineering.BuildTools.Tests.csproj", "{500C0F23-7CC8-4B59-9792-E0040EAEC806}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
+ Debug|x64 = Debug|x64
+ Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
+ Release|x64 = Release|x64
+ Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{79C98592-B228-448D-B590-95FD9C8E3123}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{79C98592-B228-448D-B590-95FD9C8E3123}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {79C98592-B228-448D-B590-95FD9C8E3123}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {79C98592-B228-448D-B590-95FD9C8E3123}.Debug|x64.Build.0 = Debug|Any CPU
+ {79C98592-B228-448D-B590-95FD9C8E3123}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {79C98592-B228-448D-B590-95FD9C8E3123}.Debug|x86.Build.0 = Debug|Any CPU
{79C98592-B228-448D-B590-95FD9C8E3123}.Release|Any CPU.ActiveCfg = Release|Any CPU
{79C98592-B228-448D-B590-95FD9C8E3123}.Release|Any CPU.Build.0 = Release|Any CPU
+ {79C98592-B228-448D-B590-95FD9C8E3123}.Release|x64.ActiveCfg = Release|Any CPU
+ {79C98592-B228-448D-B590-95FD9C8E3123}.Release|x64.Build.0 = Release|Any CPU
+ {79C98592-B228-448D-B590-95FD9C8E3123}.Release|x86.ActiveCfg = Release|Any CPU
+ {79C98592-B228-448D-B590-95FD9C8E3123}.Release|x86.Build.0 = Release|Any CPU
{E7879F3E-FEE6-4E89-A265-52A1297BBCBC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E7879F3E-FEE6-4E89-A265-52A1297BBCBC}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {E7879F3E-FEE6-4E89-A265-52A1297BBCBC}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {E7879F3E-FEE6-4E89-A265-52A1297BBCBC}.Debug|x64.Build.0 = Debug|Any CPU
+ {E7879F3E-FEE6-4E89-A265-52A1297BBCBC}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {E7879F3E-FEE6-4E89-A265-52A1297BBCBC}.Debug|x86.Build.0 = Debug|Any CPU
{E7879F3E-FEE6-4E89-A265-52A1297BBCBC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E7879F3E-FEE6-4E89-A265-52A1297BBCBC}.Release|Any CPU.Build.0 = Release|Any CPU
+ {E7879F3E-FEE6-4E89-A265-52A1297BBCBC}.Release|x64.ActiveCfg = Release|Any CPU
+ {E7879F3E-FEE6-4E89-A265-52A1297BBCBC}.Release|x64.Build.0 = Release|Any CPU
+ {E7879F3E-FEE6-4E89-A265-52A1297BBCBC}.Release|x86.ActiveCfg = Release|Any CPU
+ {E7879F3E-FEE6-4E89-A265-52A1297BBCBC}.Release|x86.Build.0 = Release|Any CPU
{53B2C52E-9095-4D49-8C34-5507BB941EE6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{53B2C52E-9095-4D49-8C34-5507BB941EE6}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {53B2C52E-9095-4D49-8C34-5507BB941EE6}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {53B2C52E-9095-4D49-8C34-5507BB941EE6}.Debug|x64.Build.0 = Debug|Any CPU
+ {53B2C52E-9095-4D49-8C34-5507BB941EE6}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {53B2C52E-9095-4D49-8C34-5507BB941EE6}.Debug|x86.Build.0 = Debug|Any CPU
{53B2C52E-9095-4D49-8C34-5507BB941EE6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{53B2C52E-9095-4D49-8C34-5507BB941EE6}.Release|Any CPU.Build.0 = Release|Any CPU
+ {53B2C52E-9095-4D49-8C34-5507BB941EE6}.Release|x64.ActiveCfg = Release|Any CPU
+ {53B2C52E-9095-4D49-8C34-5507BB941EE6}.Release|x64.Build.0 = Release|Any CPU
+ {53B2C52E-9095-4D49-8C34-5507BB941EE6}.Release|x86.ActiveCfg = Release|Any CPU
+ {53B2C52E-9095-4D49-8C34-5507BB941EE6}.Release|x86.Build.0 = Release|Any CPU
{B6DB99A8-CF03-44CB-9C5E-2FEC7888DA3E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B6DB99A8-CF03-44CB-9C5E-2FEC7888DA3E}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {B6DB99A8-CF03-44CB-9C5E-2FEC7888DA3E}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {B6DB99A8-CF03-44CB-9C5E-2FEC7888DA3E}.Debug|x64.Build.0 = Debug|Any CPU
+ {B6DB99A8-CF03-44CB-9C5E-2FEC7888DA3E}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {B6DB99A8-CF03-44CB-9C5E-2FEC7888DA3E}.Debug|x86.Build.0 = Debug|Any CPU
{B6DB99A8-CF03-44CB-9C5E-2FEC7888DA3E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B6DB99A8-CF03-44CB-9C5E-2FEC7888DA3E}.Release|Any CPU.Build.0 = Release|Any CPU
+ {B6DB99A8-CF03-44CB-9C5E-2FEC7888DA3E}.Release|x64.ActiveCfg = Release|Any CPU
+ {B6DB99A8-CF03-44CB-9C5E-2FEC7888DA3E}.Release|x64.Build.0 = Release|Any CPU
+ {B6DB99A8-CF03-44CB-9C5E-2FEC7888DA3E}.Release|x86.ActiveCfg = Release|Any CPU
+ {B6DB99A8-CF03-44CB-9C5E-2FEC7888DA3E}.Release|x86.Build.0 = Release|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x64.Build.0 = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x86.Build.0 = Debug|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x64.ActiveCfg = Release|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x64.Build.0 = Release|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x86.ActiveCfg = Release|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x86.Build.0 = Release|Any CPU
+ {500C0F23-7CC8-4B59-9792-E0040EAEC806}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {500C0F23-7CC8-4B59-9792-E0040EAEC806}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {500C0F23-7CC8-4B59-9792-E0040EAEC806}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {500C0F23-7CC8-4B59-9792-E0040EAEC806}.Debug|x64.Build.0 = Debug|Any CPU
+ {500C0F23-7CC8-4B59-9792-E0040EAEC806}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {500C0F23-7CC8-4B59-9792-E0040EAEC806}.Debug|x86.Build.0 = Debug|Any CPU
+ {500C0F23-7CC8-4B59-9792-E0040EAEC806}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {500C0F23-7CC8-4B59-9792-E0040EAEC806}.Release|Any CPU.Build.0 = Release|Any CPU
+ {500C0F23-7CC8-4B59-9792-E0040EAEC806}.Release|x64.ActiveCfg = Release|Any CPU
+ {500C0F23-7CC8-4B59-9792-E0040EAEC806}.Release|x64.Build.0 = Release|Any CPU
+ {500C0F23-7CC8-4B59-9792-E0040EAEC806}.Release|x86.ActiveCfg = Release|Any CPU
+ {500C0F23-7CC8-4B59-9792-E0040EAEC806}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
+ GlobalSection(NestedProjects) = preSolution
+ {500C0F23-7CC8-4B59-9792-E0040EAEC806} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {EE2C48C5-7AD7-4852-A39D-A3C5C94000A9}
EndGlobalSection
diff --git a/src/PostSharp.Engineering.BuildTools.Tests/ParametrizedDependencyAliasTests.cs b/src/PostSharp.Engineering.BuildTools.Tests/ParametrizedDependencyAliasTests.cs
new file mode 100644
index 00000000..441cbc29
--- /dev/null
+++ b/src/PostSharp.Engineering.BuildTools.Tests/ParametrizedDependencyAliasTests.cs
@@ -0,0 +1,139 @@
+// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details.
+
+using PostSharp.Engineering.BuildTools.Build;
+using PostSharp.Engineering.BuildTools.ContinuousIntegration;
+using PostSharp.Engineering.BuildTools.ContinuousIntegration.Model;
+using PostSharp.Engineering.BuildTools.Dependencies.Definitions;
+using PostSharp.Engineering.BuildTools.Dependencies.Model;
+using System.Linq;
+using Xunit;
+
+namespace PostSharp.Engineering.BuildTools.Tests;
+
+public class ParametrizedDependencyAliasTests
+{
+ [Fact]
+ public void NoAlias_KeyEqualsName()
+ {
+ // Use an existing definition to avoid scaffolding ProductFamily registration.
+ var definition = MetalamaDependencies.V2026_1.Metalama;
+ var dependency = definition.ToDependency();
+
+ Assert.Null( dependency.Alias );
+ Assert.Equal( definition.Name, dependency.Key );
+ Assert.Equal( definition.NameWithoutDot, dependency.KeyWithoutDot );
+ Assert.Equal( DependencyArtifactPickup.Snapshot, dependency.ArtifactPickup );
+ }
+
+ [Fact]
+ public void WithAlias_KeyAndKeyWithoutDotUseAlias()
+ {
+ var definition = MetalamaDependencies.V2026_0.Metalama;
+ var dependency = definition.WithAlias( "Metalama20260" );
+
+ Assert.Equal( "Metalama20260", dependency.Alias );
+ Assert.Equal( "Metalama20260", dependency.Key );
+ Assert.Equal( "Metalama20260", dependency.KeyWithoutDot );
+ Assert.Equal( definition.Name, dependency.Name ); // Name accessor still surfaces the definition's Name
+ }
+
+ [Fact]
+ public void WithAlias_StripsDots()
+ {
+ var definition = MetalamaDependencies.V2026_0.Metalama;
+ var dependency = definition.WithAlias( "Foo.Bar.Baz" );
+
+ Assert.Equal( "Foo.Bar.Baz", dependency.Alias );
+ Assert.Equal( "Foo.Bar.Baz", dependency.Key );
+ Assert.Equal( "FooBarBaz", dependency.KeyWithoutDot );
+ }
+
+ [Fact]
+ public void WithLastSuccessfulOnly_SetsArtifactPickup()
+ {
+ var definition = MetalamaDependencies.V2026_0.Metalama;
+ var dependency = definition.WithAlias( "Metalama20260" ).WithLastSuccessfulOnly();
+
+ Assert.Equal( DependencyArtifactPickup.LastSuccessful, dependency.ArtifactPickup );
+ Assert.Equal( "Metalama20260", dependency.Alias );
+ }
+
+ [Fact]
+ public void Composes_ConfigurationMappingPlusAliasPlusLastSuccessful()
+ {
+ var definition = MetalamaDependencies.V2026_0.Metalama;
+ var publicMapping = new ConfigurationSpecific(
+ BuildConfiguration.Public,
+ BuildConfiguration.Public,
+ BuildConfiguration.Public );
+
+ var dependency = definition
+ .ToDependency( publicMapping )
+ .WithAlias( "Metalama20260" )
+ .WithLastSuccessfulOnly();
+
+ Assert.Equal( BuildConfiguration.Public, dependency.ConfigurationMapping[BuildConfiguration.Debug] );
+ Assert.Equal( BuildConfiguration.Public, dependency.ConfigurationMapping[BuildConfiguration.Release] );
+ Assert.Equal( BuildConfiguration.Public, dependency.ConfigurationMapping[BuildConfiguration.Public] );
+ Assert.Equal( "Metalama20260", dependency.Alias );
+ Assert.Equal( DependencyArtifactPickup.LastSuccessful, dependency.ArtifactPickup );
+ }
+
+ [Fact]
+ public void DependencyConfiguration_KeyFallsBackToDefinitionNameWhenNoParametrized()
+ {
+ var definition = MetalamaDependencies.V2026_1.Metalama;
+ var configuration = new DependencyConfiguration( definition, BuildConfiguration.Debug );
+
+ Assert.Null( configuration.Parametrized );
+ Assert.Equal( definition.Name, configuration.Key );
+ Assert.Equal( definition.NameWithoutDot, configuration.KeyWithoutDot );
+ Assert.Equal( DependencyArtifactPickup.Snapshot, configuration.ArtifactPickup );
+ }
+
+ [Fact]
+ public void DependencyConfiguration_KeyUsesAliasFromParametrized()
+ {
+ var definition = MetalamaDependencies.V2026_0.Metalama;
+ var parametrizedDependency = definition.WithAlias( "Metalama20260" ).WithLastSuccessfulOnly();
+ var configuration = new DependencyConfiguration( definition, BuildConfiguration.Public ) { Parametrized = parametrizedDependency };
+
+ Assert.Equal( "Metalama20260", configuration.Key );
+ Assert.Equal( "Metalama20260", configuration.KeyWithoutDot );
+ Assert.Equal( DependencyArtifactPickup.LastSuccessful, configuration.ArtifactPickup );
+ }
+
+ [Fact]
+ public void TwoAliasedRefsToSameDefinitionNameAreLookedUpByKeyWithoutThrowing()
+ {
+ // Reproduces the configuration that triggered the Copilot review's first comment: two ParametrizedDependency
+ // entries with the same Definition.Name but different Aliases. Looking them up by Key must succeed unambiguously
+ // for each. A naive Name-based SingleOrDefault would throw — the array-level assertion at the end documents
+ // why the Product lookup methods only filter on Key.
+ var definition = MetalamaDependencies.V2026_0.Metalama;
+ var first = definition.WithAlias( "First" );
+ var second = definition.WithAlias( "Second" );
+
+ var dependencies = new[] { first, second };
+
+ Assert.Same( first, dependencies.SingleOrDefault( d => d.Key == "First" ) );
+ Assert.Same( second, dependencies.SingleOrDefault( d => d.Key == "Second" ) );
+ Assert.Null( dependencies.SingleOrDefault( d => d.Key == "Other" ) );
+
+ // Documents the throw that the Name fallback (now removed) would have produced.
+ Assert.Throws( () => dependencies.SingleOrDefault( d => d.Name == definition.Name ) );
+ }
+
+ [Fact]
+ public void DependencyConfiguration_EqualityIgnoresParametrized()
+ {
+ // (Definition, Configuration) tuple is the equality key. Two configurations with the same Definition+Configuration
+ // but different Parametrized references must compare equal so HashSet deduplication in GetAllDependencies stays correct.
+ var definition = MetalamaDependencies.V2026_1.Metalama;
+ var first = new DependencyConfiguration( definition, BuildConfiguration.Debug );
+ var second = new DependencyConfiguration( definition, BuildConfiguration.Debug ) { Parametrized = definition.ToDependency() };
+
+ Assert.Equal( first, second );
+ Assert.Equal( first.GetHashCode(), second.GetHashCode() );
+ }
+}
diff --git a/src/PostSharp.Engineering.BuildTools.Tests/PostSharp.Engineering.BuildTools.Tests.csproj b/src/PostSharp.Engineering.BuildTools.Tests/PostSharp.Engineering.BuildTools.Tests.csproj
new file mode 100644
index 00000000..3022c6a6
--- /dev/null
+++ b/src/PostSharp.Engineering.BuildTools.Tests/PostSharp.Engineering.BuildTools.Tests.csproj
@@ -0,0 +1,24 @@
+
+
+
+ net8.0
+ preview
+ enable
+ false
+ true
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
diff --git a/src/PostSharp.Engineering.BuildTools.Tests/TransformVersionPropsForAliasTests.cs b/src/PostSharp.Engineering.BuildTools.Tests/TransformVersionPropsForAliasTests.cs
new file mode 100644
index 00000000..a6f95287
--- /dev/null
+++ b/src/PostSharp.Engineering.BuildTools.Tests/TransformVersionPropsForAliasTests.cs
@@ -0,0 +1,177 @@
+// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details.
+
+using PostSharp.Engineering.BuildTools.Dependencies;
+using System.IO;
+using System.Xml.Linq;
+using Xunit;
+
+namespace PostSharp.Engineering.BuildTools.Tests;
+
+public class TransformVersionPropsForAliasTests
+{
+ [Fact]
+ public void RenamesProducerPrefixedProperties()
+ {
+ using var directory = new TempDirectory();
+ var sourceFile = directory.WriteFile( "source.props", """
+
+
+ 0.5.220
+ 0.5
+ artifacts/x
+
+
+ """ );
+
+ var destinationFile = Path.Combine( directory.Path, "destination.props" );
+
+ DependenciesHelper.TransformVersionPropsForAlias( sourceFile, destinationFile, "Metalama", "Metalama20260" );
+
+ var document = XDocument.Load( destinationFile );
+ var propertyGroup = document.Root!.Element( "PropertyGroup" )!;
+
+ Assert.Equal( "0.5.220", propertyGroup.Element( "Metalama20260Version" )?.Value );
+ Assert.Equal( "0.5", propertyGroup.Element( "Metalama20260MainVersion" )?.Value );
+ Assert.Equal( "artifacts/x", propertyGroup.Element( "Metalama20260ArtifactsDirectory" )?.Value );
+ Assert.Null( propertyGroup.Element( "MetalamaVersion" ) );
+ }
+
+ [Fact]
+ public void DoesNotRenameTransitiveDependencyProperties()
+ {
+ // Transitive Feed dependency version properties (e.g. ) and properties whose suffix
+ // is not in the curated list () must NOT be renamed.
+ using var directory = new TempDirectory();
+ var sourceFile = directory.WriteFile( "source.props", """
+
+
+ 0.5.220
+ 1.0.0
+ 2026.0.500
+
+
+ """ );
+
+ var destinationFile = Path.Combine( directory.Path, "destination.props" );
+
+ DependenciesHelper.TransformVersionPropsForAlias( sourceFile, destinationFile, "Metalama", "Metalama20260" );
+
+ var document = XDocument.Load( destinationFile );
+ var propertyGroup = document.Root!.Element( "PropertyGroup" )!;
+
+ Assert.Equal( "0.5.220", propertyGroup.Element( "Metalama20260Version" )?.Value );
+ Assert.Equal( "1.0.0", propertyGroup.Element( "MetalamaCompilerVersion" )?.Value ); // unchanged
+ Assert.Equal( "2026.0.500", propertyGroup.Element( "PostSharpEngineeringVersion" )?.Value ); // unchanged
+ Assert.Null( propertyGroup.Element( "Metalama20260CompilerVersion" ) );
+ }
+
+ [Fact]
+ public void RenamesItemTypes()
+ {
+ using var directory = new TempDirectory();
+ var sourceFile = directory.WriteFile( "source.props", """
+
+
+
+ Feed
+ 2026.0.500
+
+
+
+ """ );
+
+ var destinationFile = Path.Combine( directory.Path, "destination.props" );
+
+ DependenciesHelper.TransformVersionPropsForAlias( sourceFile, destinationFile, "Metalama", "Metalama20260" );
+
+ var document = XDocument.Load( destinationFile );
+ var item = document.Root!.Element( "ItemGroup" )!.Element( "Metalama20260Dependencies" );
+
+ Assert.NotNull( item );
+ Assert.Equal( "PostSharp.Engineering", item.Attribute( "Include" )?.Value );
+
+ // Item metadata (SourceKind, Version) must NOT be renamed.
+ Assert.Equal( "Feed", item.Element( "SourceKind" )?.Value );
+ Assert.Equal( "2026.0.500", item.Element( "Version" )?.Value );
+ }
+
+ [Fact]
+ public void AbsolutizesRelativeImportPaths()
+ {
+ using var directory = new TempDirectory();
+ var nestedDirectory = Path.Combine( directory.Path, "sub" );
+ Directory.CreateDirectory( nestedDirectory );
+
+ var sourceFile = Path.Combine( nestedDirectory, "source.props" );
+ File.WriteAllText( sourceFile, """
+
+
+
+ """ );
+
+ var destinationFile = Path.Combine( directory.Path, "out", "destination.props" );
+
+ DependenciesHelper.TransformVersionPropsForAlias( sourceFile, destinationFile, "Metalama", "Metalama20260" );
+
+ var document = XDocument.Load( destinationFile );
+ var importElement = document.Root!.Element( "Import" )!;
+ var projectAttribute = importElement.Attribute( "Project" )!.Value;
+
+ Assert.True( Path.IsPathRooted( projectAttribute ), $"Expected absolute path, got '{projectAttribute}'" );
+ Assert.EndsWith( "target.props", projectAttribute, System.StringComparison.Ordinal );
+
+ // Condition's path is also absolutized.
+ var condition = importElement.Attribute( "Condition" )!.Value;
+ Assert.DoesNotContain( "../target.props", condition, System.StringComparison.Ordinal );
+ }
+
+ [Fact]
+ public void IsIdempotentOnAlreadyTransformedFile()
+ {
+ using var directory = new TempDirectory();
+ var sourceFile = directory.WriteFile( "source.props", """
+
+
+ 0.5.220
+
+
+ """ );
+
+ var destinationFile = Path.Combine( directory.Path, "destination.props" );
+
+ // Running with prefix "Metalama" should not double-rename "Metalama20260Version" because
+ // the remainder "20260Version" is not in the curated suffix list.
+ DependenciesHelper.TransformVersionPropsForAlias( sourceFile, destinationFile, "Metalama", "Metalama20260" );
+
+ var document = XDocument.Load( destinationFile );
+ var propertyGroup = document.Root!.Element( "PropertyGroup" )!;
+
+ Assert.NotNull( propertyGroup.Element( "Metalama20260Version" ) );
+ }
+}
+
+internal sealed class TempDirectory : System.IDisposable
+{
+ public string Path { get; }
+
+ public TempDirectory()
+ {
+ this.Path = System.IO.Path.Combine( System.IO.Path.GetTempPath(), "ps-eng-tests-" + System.Guid.NewGuid().ToString( "N" ) );
+ Directory.CreateDirectory( this.Path );
+ }
+
+ public string WriteFile( string name, string content )
+ {
+ var path = System.IO.Path.Combine( this.Path, name );
+ File.WriteAllText( path, content );
+ return path;
+ }
+
+ public void Dispose()
+ {
+ if ( Directory.Exists( this.Path ) )
+ {
+ try { Directory.Delete( this.Path, true ); } catch { /* best-effort */ }
+ }
+ }
+}
diff --git a/src/PostSharp.Engineering.BuildTools.Tests/runtimeconfig.template.json b/src/PostSharp.Engineering.BuildTools.Tests/runtimeconfig.template.json
new file mode 100644
index 00000000..7a1f5e47
--- /dev/null
+++ b/src/PostSharp.Engineering.BuildTools.Tests/runtimeconfig.template.json
@@ -0,0 +1,3 @@
+{
+ "rollForward": "Major"
+}
diff --git a/src/PostSharp.Engineering.BuildTools/Build/Files/ArtifactManifestFile.cs b/src/PostSharp.Engineering.BuildTools/Build/Files/ArtifactManifestFile.cs
index d43ba351..46c80f2b 100644
--- a/src/PostSharp.Engineering.BuildTools/Build/Files/ArtifactManifestFile.cs
+++ b/src/PostSharp.Engineering.BuildTools/Build/Files/ArtifactManifestFile.cs
@@ -1,289 +1,289 @@
-// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details.
-
-using Microsoft.Build.Evaluation;
-using PostSharp.Engineering.BuildTools.Build.Model;
-using PostSharp.Engineering.BuildTools.Build.MSBuild;
-using PostSharp.Engineering.BuildTools.Dependencies.Model;
-using PostSharp.Engineering.BuildTools.Utilities;
-using System;
-using System.Diagnostics.CodeAnalysis;
-using System.IO;
-using System.Linq;
-using System.Xml.Linq;
-
-namespace PostSharp.Engineering.BuildTools.Build.Files;
-
-///
-/// Generates the MyProduct.versions.props file under the artifacts/publish/private directory.
-///
-internal static class ArtifactManifestFile
-{
- public static bool TryWrite(
- VersionComponents version,
- BuildConfiguration configuration,
- DependenciesConfigurationFile dependenciesConfigurationFile,
- BuildContext context,
- BuildSettings buildSettings,
- string buildDate )
- {
- var success = true;
- var product = context.Product;
-
- var manifestFileContent = $@"
-
-
-
- <{product.ProductNameWithoutDot}MainVersion>{version.MainVersion}{product.ProductNameWithoutDot}MainVersion>";
-
- if ( product.GenerateArcadeProperties )
- {
- // Metalama.Compiler, because of Arcade, requires the version number to be decomposed in a prefix, patch number, and suffix.
- // In Arcade, the package naming scheme is different because the patch number is not a part of the package name.
-
- manifestFileContent += $@"
-
- <{product.ProductNameWithoutDot}VersionPrefix>{version.VersionPrefix}{product.ProductNameWithoutDot}VersionPrefix>
- <{product.ProductNameWithoutDot}VersionSuffix>{version.ArcadeSuffix}{product.ProductNameWithoutDot}VersionSuffix>
- <{product.ProductNameWithoutDot}VersionPatchNumber>{version.PatchNumber}{product.ProductNameWithoutDot}VersionPatchNumber>
- <{product.ProductNameWithoutDot}VersionWithoutSuffix>{version.PackageVersionWithoutSuffix}{product.ProductNameWithoutDot}VersionWithoutSuffix>
- <{product.ProductNameWithoutDot}Version>{version.PackageVersion}{product.ProductNameWithoutDot}Version>
- <{product.ProductNameWithoutDot}PreviewVersion>{version.PackagePreviewVersion}{product.ProductNameWithoutDot}PreviewVersion>
- <{product.ProductNameWithoutDot}AssemblyVersion>{version.AssemblyVersion}{product.ProductNameWithoutDot}AssemblyVersion>";
- }
- else
- {
- manifestFileContent += $@"
- <{product.ProductNameWithoutDot}Version>{version.PackageVersion}{product.ProductNameWithoutDot}Version>
- <{product.ProductNameWithoutDot}PreviewVersion>{version.PackagePreviewVersion}{product.ProductNameWithoutDot}PreviewVersion>
- <{product.ProductNameWithoutDot}AssemblyVersion>{version.AssemblyVersion}{product.ProductNameWithoutDot}AssemblyVersion>";
- }
-
- manifestFileContent += $@"
- <{product.ProductNameWithoutDot}BuildConfiguration>{configuration}{product.ProductNameWithoutDot}BuildConfiguration>
- <{product.ProductNameWithoutDot}Dependencies>{string.Join( ";", product.ParametrizedDependencies.Select( x => x.Name ) )}{product.ProductNameWithoutDot}Dependencies>
- <{product.ProductNameWithoutDot}PublicArtifactsDirectory>{product.PublicArtifactsDirectory}{product.ProductNameWithoutDot}PublicArtifactsDirectory>
- <{product.ProductNameWithoutDot}PrivateArtifactsDirectory>{product.GetPrivateArtifactsRelativeDirectory( configuration )}{product.ProductNameWithoutDot}PrivateArtifactsDirectory>
- <{product.ProductNameWithoutDot}EngineeringVersion>{VersionHelper.EngineeringVersion}{product.ProductNameWithoutDot}EngineeringVersion>
- <{product.ProductNameWithoutDot}VersionFilePath>{product.VersionsFilePath}{product.ProductNameWithoutDot}VersionFilePath>
- <{product.ProductNameWithoutDot}BuildNumber>{buildSettings.BuildNumber}{product.ProductNameWithoutDot}BuildNumber>
- <{product.ProductNameWithoutDot}BuildType>{buildSettings.BuildType}{product.ProductNameWithoutDot}BuildType>
- <{product.ProductNameWithoutDot}BuildDate>{buildDate}{product.ProductNameWithoutDot}BuildDate>
- <{product.ProductNameWithoutDot}ArtifactsDirectory>$(MSBuildThisFileDirectory){product.ProductNameWithoutDot}ArtifactsDirectory>
- $(RestoreAdditionalProjectSources);$(MSBuildThisFileDirectory)
-
- ";
-
- foreach ( var dependency in dependenciesConfigurationFile.Dependencies )
- {
- var buildSpec = dependency.Value.BuildServerSource;
-
- manifestFileContent += $@"
- <{product.ProductNameWithoutDot}Dependencies Include=""{dependency.Key}"">
- {dependency.Value.SourceKind}";
-
- if ( dependency.Value.Version != null )
- {
- manifestFileContent += $@"
- {dependency.Value.Version}";
- }
-
- switch ( buildSpec )
- {
- case CiBuildId buildId:
- manifestFileContent += $@"
- {buildId.BuildNumber}
- {buildId.BuildTypeId}";
-
- break;
-
- case CiLatestBuildOfBranch branch:
- manifestFileContent += manifestFileContent + $@"
- {branch.Name}";
-
- break;
- }
-
- manifestFileContent
- += $@"
- {product.ProductNameWithoutDot}Dependencies>";
- }
-
- manifestFileContent += @"
-
-
-";
-
- foreach ( var dependency in dependenciesConfigurationFile.Dependencies.Where( d => d.Value.SourceKind == DependencySourceKind.Feed ) )
- {
- var nameWithoutDot = dependency.Key.Replace( ".", "", StringComparison.OrdinalIgnoreCase );
-
- manifestFileContent += $@"
- <{nameWithoutDot}Version Condition=""'$({nameWithoutDot}Version)'==''"">{dependency.Value.Version}{nameWithoutDot}Version>";
- }
-
- // Process exported properties.
- foreach ( var kvp in product.ExportedProperties )
- {
- var propsFilePath = Path.Combine( context.RepoDirectory, kvp.Key );
- var propsFile = Project.FromFile( propsFilePath, MSBuildLoadOptions.IgnoreImportErrors );
-
- foreach ( var exportedPropertyName in kvp.Value )
- {
- var exportedPropertyValue = propsFile
- .Properties
- .SingleOrDefault( p => string.Equals( p.Name, exportedPropertyName, StringComparison.OrdinalIgnoreCase ) )
- ?.EvaluatedValue;
-
- if ( string.IsNullOrWhiteSpace( exportedPropertyValue ) )
- {
- context.Console.WriteError( $"The exported property '{exportedPropertyName}' in '{propsFilePath}' is not defined." );
- success = false;
- }
-
- manifestFileContent += $@"
- <{exportedPropertyName} Condition=""'$({exportedPropertyName})'==''"">{exportedPropertyValue}{exportedPropertyName}>";
- }
- }
-
- ProjectCollection.GlobalProjectCollection.UnloadAllProjects();
-
- manifestFileContent += @"
-
-
-";
-
- if ( success )
- {
- var propsFilePath = GetPath( context, buildSettings.BuildConfiguration );
- TextFileHelper.WriteIfDifferent( propsFilePath, manifestFileContent, context );
-
- return true;
- }
- else
- {
- return false;
- }
- }
-
- internal static string GetPath( BuildContext context, BuildConfiguration configuration )
- {
- var product = context.Product;
-
- var privateArtifactsRelativeDir = product.GetPrivateArtifactsRelativeDirectory( configuration );
-
- var artifactsDir = Path.Combine( context.RepoDirectory, privateArtifactsRelativeDir );
-
- var propsFileName = $"{product.ProductName}.version.props";
- var propsFilePath = Path.Combine( artifactsDir, propsFileName );
-
- return propsFilePath;
- }
-
- ///
- /// Reads the MyProduct.version.props file from the artifacts directory generated by the Prepare step.
- ///
- public static bool TryRead(
- BuildContext context,
- BuildConfiguration configuration,
- [NotNullWhen( true )] out ArtifactManifestVersionInfo? artifactManifestVersionInfo )
- {
- var product = context.Product;
- var artifactVersionFile = context.GetManifestFilePath( configuration );
-
- var document = XDocument.Load( artifactVersionFile );
- var project = document.Root;
- var properties = project?.Element( "PropertyGroup" );
- var mainVersionPropertyName = $"{product.ProductNameWithoutDot}MainVersion";
- var mainVersion = properties?.Element( mainVersionPropertyName )?.Value;
-
- if ( mainVersion == null )
- {
- context.Console.WriteError(
- $"Cannot load '{product.MainVersionFilePath}': the property '{mainVersionPropertyName}' in '{artifactVersionFile}' is not defined." );
-
- artifactManifestVersionInfo = null;
-
- return false;
- }
-
- var packageVersionPropertyName = $"{product.ProductNameWithoutDot}Version";
- var packageVersion = properties?.Element( packageVersionPropertyName )?.Value;
-
- if ( packageVersion == null )
- {
- context.Console.WriteError(
- $"Cannot load '{product.MainVersionFilePath}': the property '{packageVersionPropertyName}' in '{artifactVersionFile}' is not defined." );
-
- artifactManifestVersionInfo = null;
-
- return false;
- }
-
- artifactManifestVersionInfo = new ArtifactManifestVersionInfo( new Version( mainVersion ), packageVersion );
-
- return true;
- }
-
- public static BuildArguments CreateParametricStringArguments( BuildContext context, BuildConfiguration buildConfiguration )
- {
- var product = context.Product;
- var versionFilePath = context.GetManifestFilePath( buildConfiguration );
-
- if ( !File.Exists( versionFilePath ) )
- {
- throw new FileNotFoundException( $"The file '{versionFilePath}' should exist before calling this method." );
- }
-
- var versionFile = Project.FromFile( versionFilePath, MSBuildLoadOptions.IgnoreImportErrors );
-
- string packageVersion;
-
- if ( product.GenerateArcadeProperties )
- {
- packageVersion = versionFile.Properties
- .Single( p => p.Name == product.ProductNameWithoutDot + "VersionPrefix" )
- .EvaluatedValue;
-
- var suffix = versionFile
- .Properties
- .Single( p => p.Name == product.ProductNameWithoutDot + "VersionSuffix" )
- .EvaluatedValue;
-
- if ( !string.IsNullOrWhiteSpace( suffix ) )
- {
- packageVersion = packageVersion + "-" + suffix;
- }
- }
- else
- {
- packageVersion = versionFile
- .Properties
- .Single( p => p.Name == product.ProductNameWithoutDot + "Version" )
- .EvaluatedValue;
- }
-
- if ( string.IsNullOrEmpty( packageVersion ) )
- {
- throw new InvalidOperationException( "PackageVersion should not be null." );
- }
-
- var configuration = versionFile
- .Properties
- .Single( p => p.Name == product.ProductNameWithoutDot + "BuildConfiguration" )
- .EvaluatedValue;
-
- var packagePreviewVersion = versionFile
- .Properties
- .Single( p => p.Name == product.ProductNameWithoutDot + "PreviewVersion" )
- .EvaluatedValue;
-
- if ( string.IsNullOrEmpty( configuration ) )
- {
- throw new InvalidOperationException( "BuildConfiguration should not be null." );
- }
-
- ProjectCollection.GlobalProjectCollection.UnloadAllProjects();
-
- return new BuildArguments( packageVersion, Enum.Parse( configuration ), product, packagePreviewVersion );
- }
+// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details.
+
+using Microsoft.Build.Evaluation;
+using PostSharp.Engineering.BuildTools.Build.Model;
+using PostSharp.Engineering.BuildTools.Build.MSBuild;
+using PostSharp.Engineering.BuildTools.Dependencies.Model;
+using PostSharp.Engineering.BuildTools.Utilities;
+using System;
+using System.Diagnostics.CodeAnalysis;
+using System.IO;
+using System.Linq;
+using System.Xml.Linq;
+
+namespace PostSharp.Engineering.BuildTools.Build.Files;
+
+///
+/// Generates the MyProduct.versions.props file under the artifacts/publish/private directory.
+///
+internal static class ArtifactManifestFile
+{
+ public static bool TryWrite(
+ VersionComponents version,
+ BuildConfiguration configuration,
+ DependenciesConfigurationFile dependenciesConfigurationFile,
+ BuildContext context,
+ BuildSettings buildSettings,
+ string buildDate )
+ {
+ var success = true;
+ var product = context.Product;
+
+ var manifestFileContent = $@"
+
+
+
+ <{product.ProductNameWithoutDot}MainVersion>{version.MainVersion}{product.ProductNameWithoutDot}MainVersion>";
+
+ if ( product.GenerateArcadeProperties )
+ {
+ // Metalama.Compiler, because of Arcade, requires the version number to be decomposed in a prefix, patch number, and suffix.
+ // In Arcade, the package naming scheme is different because the patch number is not a part of the package name.
+
+ manifestFileContent += $@"
+
+ <{product.ProductNameWithoutDot}VersionPrefix>{version.VersionPrefix}{product.ProductNameWithoutDot}VersionPrefix>
+ <{product.ProductNameWithoutDot}VersionSuffix>{version.ArcadeSuffix}{product.ProductNameWithoutDot}VersionSuffix>
+ <{product.ProductNameWithoutDot}VersionPatchNumber>{version.PatchNumber}{product.ProductNameWithoutDot}VersionPatchNumber>
+ <{product.ProductNameWithoutDot}VersionWithoutSuffix>{version.PackageVersionWithoutSuffix}{product.ProductNameWithoutDot}VersionWithoutSuffix>
+ <{product.ProductNameWithoutDot}Version>{version.PackageVersion}{product.ProductNameWithoutDot}Version>
+ <{product.ProductNameWithoutDot}PreviewVersion>{version.PackagePreviewVersion}{product.ProductNameWithoutDot}PreviewVersion>
+ <{product.ProductNameWithoutDot}AssemblyVersion>{version.AssemblyVersion}{product.ProductNameWithoutDot}AssemblyVersion>";
+ }
+ else
+ {
+ manifestFileContent += $@"
+ <{product.ProductNameWithoutDot}Version>{version.PackageVersion}{product.ProductNameWithoutDot}Version>
+ <{product.ProductNameWithoutDot}PreviewVersion>{version.PackagePreviewVersion}{product.ProductNameWithoutDot}PreviewVersion>
+ <{product.ProductNameWithoutDot}AssemblyVersion>{version.AssemblyVersion}{product.ProductNameWithoutDot}AssemblyVersion>";
+ }
+
+ manifestFileContent += $@"
+ <{product.ProductNameWithoutDot}BuildConfiguration>{configuration}{product.ProductNameWithoutDot}BuildConfiguration>
+ <{product.ProductNameWithoutDot}Dependencies>{string.Join( ";", product.ParametrizedDependencies.Select( x => x.Key ) )}{product.ProductNameWithoutDot}Dependencies>
+ <{product.ProductNameWithoutDot}PublicArtifactsDirectory>{product.PublicArtifactsDirectory}{product.ProductNameWithoutDot}PublicArtifactsDirectory>
+ <{product.ProductNameWithoutDot}PrivateArtifactsDirectory>{product.GetPrivateArtifactsRelativeDirectory( configuration )}{product.ProductNameWithoutDot}PrivateArtifactsDirectory>
+ <{product.ProductNameWithoutDot}EngineeringVersion>{VersionHelper.EngineeringVersion}{product.ProductNameWithoutDot}EngineeringVersion>
+ <{product.ProductNameWithoutDot}VersionFilePath>{product.VersionsFilePath}{product.ProductNameWithoutDot}VersionFilePath>
+ <{product.ProductNameWithoutDot}BuildNumber>{buildSettings.BuildNumber}{product.ProductNameWithoutDot}BuildNumber>
+ <{product.ProductNameWithoutDot}BuildType>{buildSettings.BuildType}{product.ProductNameWithoutDot}BuildType>
+ <{product.ProductNameWithoutDot}BuildDate>{buildDate}{product.ProductNameWithoutDot}BuildDate>
+ <{product.ProductNameWithoutDot}ArtifactsDirectory>$(MSBuildThisFileDirectory){product.ProductNameWithoutDot}ArtifactsDirectory>
+ $(RestoreAdditionalProjectSources);$(MSBuildThisFileDirectory)
+
+ ";
+
+ foreach ( var dependency in dependenciesConfigurationFile.Dependencies )
+ {
+ var buildSpec = dependency.Value.BuildServerSource;
+
+ manifestFileContent += $@"
+ <{product.ProductNameWithoutDot}Dependencies Include=""{dependency.Key}"">
+ {dependency.Value.SourceKind}";
+
+ if ( dependency.Value.Version != null )
+ {
+ manifestFileContent += $@"
+ {dependency.Value.Version}";
+ }
+
+ switch ( buildSpec )
+ {
+ case CiBuildId buildId:
+ manifestFileContent += $@"
+ {buildId.BuildNumber}
+ {buildId.BuildTypeId}";
+
+ break;
+
+ case CiLatestBuildOfBranch branch:
+ manifestFileContent += manifestFileContent + $@"
+ {branch.Name}";
+
+ break;
+ }
+
+ manifestFileContent
+ += $@"
+ {product.ProductNameWithoutDot}Dependencies>";
+ }
+
+ manifestFileContent += @"
+
+
+";
+
+ foreach ( var dependency in dependenciesConfigurationFile.Dependencies.Where( d => d.Value.SourceKind == DependencySourceKind.Feed ) )
+ {
+ var nameWithoutDot = dependency.Key.Replace( ".", "", StringComparison.OrdinalIgnoreCase );
+
+ manifestFileContent += $@"
+ <{nameWithoutDot}Version Condition=""'$({nameWithoutDot}Version)'==''"">{dependency.Value.Version}{nameWithoutDot}Version>";
+ }
+
+ // Process exported properties.
+ foreach ( var kvp in product.ExportedProperties )
+ {
+ var propsFilePath = Path.Combine( context.RepoDirectory, kvp.Key );
+ var propsFile = Project.FromFile( propsFilePath, MSBuildLoadOptions.IgnoreImportErrors );
+
+ foreach ( var exportedPropertyName in kvp.Value )
+ {
+ var exportedPropertyValue = propsFile
+ .Properties
+ .SingleOrDefault( p => string.Equals( p.Name, exportedPropertyName, StringComparison.OrdinalIgnoreCase ) )
+ ?.EvaluatedValue;
+
+ if ( string.IsNullOrWhiteSpace( exportedPropertyValue ) )
+ {
+ context.Console.WriteError( $"The exported property '{exportedPropertyName}' in '{propsFilePath}' is not defined." );
+ success = false;
+ }
+
+ manifestFileContent += $@"
+ <{exportedPropertyName} Condition=""'$({exportedPropertyName})'==''"">{exportedPropertyValue}{exportedPropertyName}>";
+ }
+ }
+
+ ProjectCollection.GlobalProjectCollection.UnloadAllProjects();
+
+ manifestFileContent += @"
+
+
+";
+
+ if ( success )
+ {
+ var propsFilePath = GetPath( context, buildSettings.BuildConfiguration );
+ TextFileHelper.WriteIfDifferent( propsFilePath, manifestFileContent, context );
+
+ return true;
+ }
+ else
+ {
+ return false;
+ }
+ }
+
+ internal static string GetPath( BuildContext context, BuildConfiguration configuration )
+ {
+ var product = context.Product;
+
+ var privateArtifactsRelativeDir = product.GetPrivateArtifactsRelativeDirectory( configuration );
+
+ var artifactsDir = Path.Combine( context.RepoDirectory, privateArtifactsRelativeDir );
+
+ var propsFileName = $"{product.ProductName}.version.props";
+ var propsFilePath = Path.Combine( artifactsDir, propsFileName );
+
+ return propsFilePath;
+ }
+
+ ///
+ /// Reads the MyProduct.version.props file from the artifacts directory generated by the Prepare step.
+ ///
+ public static bool TryRead(
+ BuildContext context,
+ BuildConfiguration configuration,
+ [NotNullWhen( true )] out ArtifactManifestVersionInfo? artifactManifestVersionInfo )
+ {
+ var product = context.Product;
+ var artifactVersionFile = context.GetManifestFilePath( configuration );
+
+ var document = XDocument.Load( artifactVersionFile );
+ var project = document.Root;
+ var properties = project?.Element( "PropertyGroup" );
+ var mainVersionPropertyName = $"{product.ProductNameWithoutDot}MainVersion";
+ var mainVersion = properties?.Element( mainVersionPropertyName )?.Value;
+
+ if ( mainVersion == null )
+ {
+ context.Console.WriteError(
+ $"Cannot load '{product.MainVersionFilePath}': the property '{mainVersionPropertyName}' in '{artifactVersionFile}' is not defined." );
+
+ artifactManifestVersionInfo = null;
+
+ return false;
+ }
+
+ var packageVersionPropertyName = $"{product.ProductNameWithoutDot}Version";
+ var packageVersion = properties?.Element( packageVersionPropertyName )?.Value;
+
+ if ( packageVersion == null )
+ {
+ context.Console.WriteError(
+ $"Cannot load '{product.MainVersionFilePath}': the property '{packageVersionPropertyName}' in '{artifactVersionFile}' is not defined." );
+
+ artifactManifestVersionInfo = null;
+
+ return false;
+ }
+
+ artifactManifestVersionInfo = new ArtifactManifestVersionInfo( new Version( mainVersion ), packageVersion );
+
+ return true;
+ }
+
+ public static BuildArguments CreateParametricStringArguments( BuildContext context, BuildConfiguration buildConfiguration )
+ {
+ var product = context.Product;
+ var versionFilePath = context.GetManifestFilePath( buildConfiguration );
+
+ if ( !File.Exists( versionFilePath ) )
+ {
+ throw new FileNotFoundException( $"The file '{versionFilePath}' should exist before calling this method." );
+ }
+
+ var versionFile = Project.FromFile( versionFilePath, MSBuildLoadOptions.IgnoreImportErrors );
+
+ string packageVersion;
+
+ if ( product.GenerateArcadeProperties )
+ {
+ packageVersion = versionFile.Properties
+ .Single( p => p.Name == product.ProductNameWithoutDot + "VersionPrefix" )
+ .EvaluatedValue;
+
+ var suffix = versionFile
+ .Properties
+ .Single( p => p.Name == product.ProductNameWithoutDot + "VersionSuffix" )
+ .EvaluatedValue;
+
+ if ( !string.IsNullOrWhiteSpace( suffix ) )
+ {
+ packageVersion = packageVersion + "-" + suffix;
+ }
+ }
+ else
+ {
+ packageVersion = versionFile
+ .Properties
+ .Single( p => p.Name == product.ProductNameWithoutDot + "Version" )
+ .EvaluatedValue;
+ }
+
+ if ( string.IsNullOrEmpty( packageVersion ) )
+ {
+ throw new InvalidOperationException( "PackageVersion should not be null." );
+ }
+
+ var configuration = versionFile
+ .Properties
+ .Single( p => p.Name == product.ProductNameWithoutDot + "BuildConfiguration" )
+ .EvaluatedValue;
+
+ var packagePreviewVersion = versionFile
+ .Properties
+ .Single( p => p.Name == product.ProductNameWithoutDot + "PreviewVersion" )
+ .EvaluatedValue;
+
+ if ( string.IsNullOrEmpty( configuration ) )
+ {
+ throw new InvalidOperationException( "BuildConfiguration should not be null." );
+ }
+
+ ProjectCollection.GlobalProjectCollection.UnloadAllProjects();
+
+ return new BuildArguments( packageVersion, Enum.Parse( configuration ), product, packagePreviewVersion );
+ }
}
\ No newline at end of file
diff --git a/src/PostSharp.Engineering.BuildTools/Build/Files/DependenciesConfigurationFile.cs b/src/PostSharp.Engineering.BuildTools/Build/Files/DependenciesConfigurationFile.cs
index 6fe2bdfc..9d41b303 100644
--- a/src/PostSharp.Engineering.BuildTools/Build/Files/DependenciesConfigurationFile.cs
+++ b/src/PostSharp.Engineering.BuildTools/Build/Files/DependenciesConfigurationFile.cs
@@ -204,9 +204,21 @@ bool TryGetBuildId( out string? versionFile1, out ICiBuildSpec? ciBuildSpec )
var localPath = item.Element( "Path" )?.Value;
var dependencySource = DependencySource.CreateLocalDependency( origin, localPath );
- dependencySource.VersionFile = Path.Combine(
- dependencySource.GetResolvedLocalPath( context, name ),
- name + ".Import.props" );
+ if ( product.TryGetDependency( name, out var parametrizedDependency ) && parametrizedDependency.Alias != null )
+ {
+ // Aliased Local: import the transformed copy under dependencies/{Key}/, written at fetch time.
+ dependencySource.VersionFile = Path.Combine(
+ TeamCityHelper.GetRestoredDependencyDirectory( context.RepoDirectory, parametrizedDependency.Key ),
+ parametrizedDependency.Key + ".Import.props" );
+ }
+ else
+ {
+ var producerName = parametrizedDependency?.Definition.Name ?? name;
+
+ dependencySource.VersionFile = Path.Combine(
+ dependencySource.GetResolvedLocalPath( context, producerName ),
+ producerName + ".Import.props" );
+ }
file._dependencies[name] = dependencySource;
@@ -403,6 +415,10 @@ void AddImport( string file, bool required = true, string? label = null, bool ad
var dependencySource = dependency.Value;
var dependencyDefinition = product.GetDependencyDefinition( dependency.Key );
+ var keyWithoutDot = product.TryGetDependency( dependency.Key, out var parametrizedDependency )
+ ? parametrizedDependency.KeyWithoutDot
+ : dependencyDefinition.NameWithoutDot;
+
var item = new XElement(
"LocalDependencySource",
new XAttribute( "Include", dependency.Key ),
@@ -461,8 +477,23 @@ void WriteBuildServerSource()
AddMetadataToItemIfNotNull( "Path", TransformPath( dependencySource.LocalPath ) );
}
- var importProjectFile = Path.GetFullPath(
- Path.Combine( dependencySource.GetResolvedLocalPath( context, dependency.Key ), dependency.Key + ".Import.props" ) );
+ string importProjectFile;
+
+ if ( parametrizedDependency?.Alias != null )
+ {
+ // Aliased Local: import the transformed copy under dependencies/{Key}/, written by Phase 7's transform.
+ importProjectFile = Path.GetFullPath(
+ Path.Combine(
+ TeamCityHelper.GetRestoredDependencyDirectory( context.RepoDirectory, parametrizedDependency.Key ),
+ parametrizedDependency.Key + ".Import.props" ) );
+ }
+ else
+ {
+ importProjectFile = Path.GetFullPath(
+ Path.Combine(
+ dependencySource.GetResolvedLocalPath( context, dependencyDefinition.Name ),
+ dependencyDefinition.Name + ".Import.props" ) );
+ }
AddImport( importProjectFile );
}
@@ -491,7 +522,7 @@ void WriteBuildServerSource()
if ( !string.IsNullOrEmpty( dependencySource.Version ) )
{
- propertyGroup.Add( new XElement( $"{dependencyDefinition.NameWithoutDot}Version", dependencySource.Version ) );
+ propertyGroup.Add( new XElement( $"{keyWithoutDot}Version", dependencySource.Version ) );
}
}
@@ -561,24 +592,24 @@ public void Print( BuildContext context )
// Add direct dependencies.
for ( var i = 0; i < context.Product.ParametrizedDependencies.Length; i++ )
{
- var name = context.Product.ParametrizedDependencies[i].Name;
+ var key = context.Product.ParametrizedDependencies[i].Key;
var rowNumber = (i + 1).ToString( CultureInfo.InvariantCulture );
- if ( !this.Dependencies.TryGetValue( name, out var source ) )
+ if ( !this.Dependencies.TryGetValue( key, out var source ) )
{
- table.AddRow( rowNumber, name, "", "" );
+ table.AddRow( rowNumber, key, "", "" );
}
else
{
- table.AddRow( rowNumber, name, source.ToString(), source.VersionFile ?? "" );
+ table.AddRow( rowNumber, key, source.ToString(), source.VersionFile ?? "" );
}
}
// Add implicit dependencies (if previously fetched).
foreach ( var dependency in this.Dependencies )
{
- if ( context.Product.ParametrizedDependencies.Any( d => d.Name == dependency.Key ) )
+ if ( context.Product.ParametrizedDependencies.Any( d => d.Key == dependency.Key ) )
{
continue;
}
diff --git a/src/PostSharp.Engineering.BuildTools/Build/Files/VersionFile.cs b/src/PostSharp.Engineering.BuildTools/Build/Files/VersionFile.cs
index 8723fc8f..e628407d 100644
--- a/src/PostSharp.Engineering.BuildTools/Build/Files/VersionFile.cs
+++ b/src/PostSharp.Engineering.BuildTools/Build/Files/VersionFile.cs
@@ -55,14 +55,14 @@ public static bool TryRead(
var defaultDependencyProperties = context.Product.ParametrizedDependencies
.ToDictionary(
- d => d.Name,
+ d => d.Key,
d =>
{
- var property = versionsProject.Properties.SingleOrDefault( p => p.Name == d.NameWithoutDot + "Version" );
+ var property = versionsProject.Properties.SingleOrDefault( p => p.Name == d.KeyWithoutDot + "Version" );
if ( property == null )
{
- property = centralPackageManagementVersionsProject?.Properties.SingleOrDefault( p => p.Name == d.NameWithoutDot + "Version" );
+ property = centralPackageManagementVersionsProject?.Properties.SingleOrDefault( p => p.Name == d.KeyWithoutDot + "Version" );
}
if ( property == null )
@@ -79,14 +79,14 @@ public static bool TryRead(
foreach ( var dependencyDefinition in context.Product.ParametrizedDependencies )
{
- var dependencyVersion = defaultDependencyProperties[dependencyDefinition.Name];
+ var dependencyVersion = defaultDependencyProperties[dependencyDefinition.Key];
if ( dependencyVersion == default )
{
// A property is required because we update it during the release process.
context.Console.WriteError(
- $"A property named '{dependencyDefinition.NameWithoutDot}Version' must be defined, typically in 'eng/AutoUpdatedVersions.props', even with empty value." );
+ $"A property named '{dependencyDefinition.KeyWithoutDot}Version' must be defined, typically in 'eng/AutoUpdatedVersions.props', even with empty value." );
continue;
}
@@ -97,7 +97,7 @@ public static bool TryRead(
if ( dependencyVersion.Version != "" && !Regex.IsMatch( dependencyVersion.Version, @"^\d+.*$" ) )
{
context.Console.WriteError(
- $"{dependencyVersion.File}: invalid value '{dependencyVersion}' for property '{dependencyDefinition.Name}Version': the value is neither empty nor a valid version number." );
+ $"{dependencyVersion.File}: invalid value '{dependencyVersion}' for property '{dependencyDefinition.Key}Version': the value is neither empty nor a valid version number." );
versionFile = null;
@@ -111,7 +111,7 @@ public static bool TryRead(
{
if ( dependencyVersion.Version == "" )
{
- context.Console.WriteError( $"{dependencyVersion.File}: missing value for property '{dependencyDefinition.NameWithoutDot}Version'." );
+ context.Console.WriteError( $"{dependencyVersion.File}: missing value for property '{dependencyDefinition.KeyWithoutDot}Version'." );
versionFile = null;
@@ -138,7 +138,7 @@ public static bool TryRead(
DependencyConfigurationOrigin.Default );
}
- dependenciesBuilder[dependencyDefinition.Name] = dependencySource;
+ dependenciesBuilder[dependencyDefinition.Key] = dependencySource;
}
versionFile = new VersionFile( dependenciesBuilder.ToImmutable() );
@@ -154,9 +154,12 @@ internal static bool Validate( BuildContext context, DependenciesConfigurationFi
foreach ( var dependency in dependenciesConfigurationFile.Dependencies.Keys )
{
- var dependencyDefinition = context.Product.ProductFamily.GetDependencyDefinition( dependency );
+ // Look up the parametrized dependency to get the consumer-side key (which equals Name when no alias is set).
+ var keyWithoutDot = context.Product.TryGetDependency( dependency, out var parametrizedDependency )
+ ? parametrizedDependency.KeyWithoutDot
+ : context.Product.ProductFamily.GetDependencyDefinition( dependency ).NameWithoutDot;
- var propertyName = $"{dependencyDefinition.NameWithoutDot}Version";
+ var propertyName = $"{keyWithoutDot}Version";
var elements = document.Root!.XPathSelectElements( $"/Project/PropertyGroup/{propertyName}" ).ToList();
diff --git a/src/PostSharp.Engineering.BuildTools/Build/Model/Product.cs b/src/PostSharp.Engineering.BuildTools/Build/Model/Product.cs
index 3511cc47..5f04865f 100644
--- a/src/PostSharp.Engineering.BuildTools/Build/Model/Product.cs
+++ b/src/PostSharp.Engineering.BuildTools/Build/Model/Product.cs
@@ -278,11 +278,17 @@ public DockerSpec? DockerSpec
public bool TryGetDependency( string name, [NotNullWhen( true )] out ParametrizedDependency? dependency )
{
- dependency = this.ParametrizedDependencies.SingleOrDefault( d => d.Name == name );
+ // Lookup is by Key. For unaliased entries Key == Definition.Name, so legacy callers that pass a Name
+ // continue to resolve correctly. Multiple ParametrizedDependency entries that share Definition.Name but
+ // declare different Aliases are valid (e.g., two product-family versions referenced under different
+ // aliases) — they have distinct Keys; the caller must disambiguate by Alias. A Name-based fallback would
+ // throw on that legitimate configuration; SingleOrDefault on Key still surfaces a true configuration
+ // error (two entries declared with the same Alias) by throwing.
// We do NOT attempt to get a ParametrizedDependency from a DependencyDefinition because we basically
// don't know what the parameters are, and returning default parameters may delay the moment when a design
// issue is visible.
+ dependency = this.ParametrizedDependencies.SingleOrDefault( d => d.Key == name );
return dependency != null;
}
@@ -299,7 +305,8 @@ public DependencyDefinition GetDependencyDefinition( string name )
public bool TryGetDependencyDefinition( string name, [NotNullWhen( true )] out DependencyDefinition? dependencyDefinition )
{
- dependencyDefinition = this.ParametrizedDependencies.SingleOrDefault( d => d.Name == name )?.Definition;
+ // See TryGetDependency for the rationale of looking up only by Key.
+ dependencyDefinition = this.ParametrizedDependencies.SingleOrDefault( d => d.Key == name )?.Definition;
if ( dependencyDefinition != null )
{
diff --git a/src/PostSharp.Engineering.BuildTools/ContinuousIntegration/Model/PowershellAdditionalCiBuildConfiguration.cs b/src/PostSharp.Engineering.BuildTools/ContinuousIntegration/Model/PowershellAdditionalCiBuildConfiguration.cs
index 3634c3c1..beb05aaf 100644
--- a/src/PostSharp.Engineering.BuildTools/ContinuousIntegration/Model/PowershellAdditionalCiBuildConfiguration.cs
+++ b/src/PostSharp.Engineering.BuildTools/ContinuousIntegration/Model/PowershellAdditionalCiBuildConfiguration.cs
@@ -63,7 +63,7 @@ internal override TeamCityBuildConfiguration TeamCityBuildConfiguration(
dependencies.Select( d => new TeamCitySnapshotDependency(
d.Definition.CiConfiguration.BuildTypes[d.Configuration],
true,
- $"+:{d.Definition.GetPrivateArtifactsDirectory( d.Configuration ).Replace( Path.DirectorySeparatorChar, '/' )}/**/*=>dependencies/{d.Definition.Name}",
+ $"+:{d.Definition.GetPrivateArtifactsDirectory( d.Configuration ).Replace( Path.DirectorySeparatorChar, '/' )}/**/*=>dependencies/{d.Key}",
ReuseBuilds: reuseBuilds ) ) );
// If we have a build snapshot dependency, copy nuget.restored.config to nuget.config
@@ -88,7 +88,7 @@ internal override TeamCityBuildConfiguration TeamCityBuildConfiguration(
foreach ( var dependency in dependencies )
{
versionImports +=
- $"";
+ $"";
}
var createVersionsFileCommand =
diff --git a/src/PostSharp.Engineering.BuildTools/ContinuousIntegration/TeamCity/Generation/ConfigurationProperties.cs b/src/PostSharp.Engineering.BuildTools/ContinuousIntegration/TeamCity/Generation/ConfigurationProperties.cs
index 46c3df5d..2c876aa8 100644
--- a/src/PostSharp.Engineering.BuildTools/ContinuousIntegration/TeamCity/Generation/ConfigurationProperties.cs
+++ b/src/PostSharp.Engineering.BuildTools/ContinuousIntegration/TeamCity/Generation/ConfigurationProperties.cs
@@ -1,47 +1,49 @@
-// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details.
-
-using PostSharp.Engineering.BuildTools.Build;
-using PostSharp.Engineering.BuildTools.Build.Model;
-using System;
-using System.IO;
-using System.Linq;
-
-namespace PostSharp.Engineering.BuildTools.ContinuousIntegration.TeamCity.Generation;
-
-internal class ConfigurationProperties
-{
- private readonly Product _product;
-
- public BuildConfiguration Configuration { get; }
-
- public TeamCitySnapshotDependency[] SnapshotDependenciesForBuildConfiguration { get; }
-
- public string PrivateArtifactsDirectory { get; }
-
- public BuildConfigurationInfo BuildConfigurationInfo => this._product.Configurations[this.Configuration];
-
- public ConfigurationProperties( Product product, BuildConfiguration configuration )
- {
- this._product = product;
- this.Configuration = configuration;
-
- // Calculate configuration-specific artifact directory
- this.PrivateArtifactsDirectory = product.GetPrivateArtifactsRelativeDirectory( configuration ).Replace( "\\", "/", StringComparison.Ordinal );
-
- var dependencies = product.DependencyDefinition.GetAllDependencies( configuration )
- .Where( d => d.Definition.GenerateSnapshotDependency )
- .ToList();
-
- var snapshotDependencies = dependencies
- .Select( d => new TeamCitySnapshotDependency(
- d.Definition.CiConfiguration.BuildTypes[d.Configuration],
- true,
- $"+:{d.Definition.GetPrivateArtifactsDirectory( d.Configuration ).Replace( Path.DirectorySeparatorChar, '/' )}/**/*=>dependencies/{d.Definition.Name}" ) )
- .ToList();
-
- var sourceSnapshotDependencies = product.SourceDependencies.Where( d => d.GenerateSnapshotDependency )
- .Select( d => new TeamCitySnapshotDependency( d.CiConfiguration.BuildTypes[configuration], true ) );
-
- this.SnapshotDependenciesForBuildConfiguration = snapshotDependencies.Concat( sourceSnapshotDependencies ).OrderBy( d => d.ObjectId ).ToArray();
- }
+// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details.
+
+using PostSharp.Engineering.BuildTools.Build;
+using PostSharp.Engineering.BuildTools.Build.Model;
+using PostSharp.Engineering.BuildTools.Dependencies.Model;
+using System;
+using System.IO;
+using System.Linq;
+
+namespace PostSharp.Engineering.BuildTools.ContinuousIntegration.TeamCity.Generation;
+
+internal class ConfigurationProperties
+{
+ private readonly Product _product;
+
+ public BuildConfiguration Configuration { get; }
+
+ public TeamCitySnapshotDependency[] SnapshotDependenciesForBuildConfiguration { get; }
+
+ public string PrivateArtifactsDirectory { get; }
+
+ public BuildConfigurationInfo BuildConfigurationInfo => this._product.Configurations[this.Configuration];
+
+ public ConfigurationProperties( Product product, BuildConfiguration configuration )
+ {
+ this._product = product;
+ this.Configuration = configuration;
+
+ // Calculate configuration-specific artifact directory
+ this.PrivateArtifactsDirectory = product.GetPrivateArtifactsRelativeDirectory( configuration ).Replace( "\\", "/", StringComparison.Ordinal );
+
+ var dependencies = product.DependencyDefinition.GetAllDependencies( configuration )
+ .Where( d => d.Definition.GenerateSnapshotDependency )
+ .ToList();
+
+ var snapshotDependencies = dependencies
+ .Select( d => new TeamCitySnapshotDependency(
+ d.Definition.CiConfiguration.BuildTypes[d.Configuration],
+ true,
+ $"+:{d.Definition.GetPrivateArtifactsDirectory( d.Configuration ).Replace( Path.DirectorySeparatorChar, '/' )}/**/*=>dependencies/{d.Key}",
+ ReuseBuilds: d.ArtifactPickup == DependencyArtifactPickup.LastSuccessful ? ReuseBuilds.LastSuccessful : ReuseBuilds.Default ) )
+ .ToList();
+
+ var sourceSnapshotDependencies = product.SourceDependencies.Where( d => d.GenerateSnapshotDependency )
+ .Select( d => new TeamCitySnapshotDependency( d.CiConfiguration.BuildTypes[configuration], true ) );
+
+ this.SnapshotDependenciesForBuildConfiguration = snapshotDependencies.Concat( sourceSnapshotDependencies ).OrderBy( d => d.ObjectId ).ToArray();
+ }
}
\ No newline at end of file
diff --git a/src/PostSharp.Engineering.BuildTools/ContinuousIntegration/TeamCity/Generation/TeamCitySettingsFile.cs b/src/PostSharp.Engineering.BuildTools/ContinuousIntegration/TeamCity/Generation/TeamCitySettingsFile.cs
index 037539a9..64de5b1c 100644
--- a/src/PostSharp.Engineering.BuildTools/ContinuousIntegration/TeamCity/Generation/TeamCitySettingsFile.cs
+++ b/src/PostSharp.Engineering.BuildTools/ContinuousIntegration/TeamCity/Generation/TeamCitySettingsFile.cs
@@ -175,8 +175,14 @@ private static TeamCityBuildConfiguration CreateDeployConfiguration(
if ( !isStandalone )
{
+ // Aliased + LastSuccessful deps must be excluded: we don't snapshot-depend on them for the consumer's build,
+ // and the same applies to deployment.
+ var parametrizedDeploymentDependencies = product.ParametrizedDependencies
+ .Where( d => d.ArtifactPickup == Dependencies.Model.DependencyArtifactPickup.Snapshot )
+ .Select( d => d.Definition );
+
snapshotDependencies = snapshotDependencies.Concat(
- product.ParametrizedDependencies.Select( d => d.Definition )
+ parametrizedDeploymentDependencies
.Union( product.SourceDependencies )
.Where( d => d is { GenerateSnapshotDependency: true, CiConfiguration.DeploymentBuildType: not null } )
.Select( d => new TeamCitySnapshotDependency( d.CiConfiguration.DeploymentBuildType!, true ) ) );
@@ -218,7 +224,9 @@ private static TeamCityBuildConfiguration CreateUpstreamMergeConfiguration( Prod
// deduplicated by build type.
snapshotDependencies =
product.ParametrizedDependencies
- .Where( d => d.Definition.GenerateSnapshotDependency && d.Definition.ProductFamily.UpstreamProductFamily != null )
+ .Where( d => d.ArtifactPickup == Dependencies.Model.DependencyArtifactPickup.Snapshot
+ && d.Definition.GenerateSnapshotDependency
+ && d.Definition.ProductFamily.UpstreamProductFamily != null )
.Select( d => d.Definition )
.Concat( product.SourceDependencies.Where( d => d.GenerateSnapshotDependency && d.ProductFamily.UpstreamProductFamily != null ) )
.DistinctBy( d => d.CiConfiguration.UpstreamMergeBuildType )
diff --git a/src/PostSharp.Engineering.BuildTools/Dependencies/Definitions/MetalamaVsxDependencies.V2026_1.cs b/src/PostSharp.Engineering.BuildTools/Dependencies/Definitions/MetalamaVsxDependencies.V2026_1.cs
index b7fc131e..0833c37f 100644
--- a/src/PostSharp.Engineering.BuildTools/Dependencies/Definitions/MetalamaVsxDependencies.V2026_1.cs
+++ b/src/PostSharp.Engineering.BuildTools/Dependencies/Definitions/MetalamaVsxDependencies.V2026_1.cs
@@ -70,6 +70,14 @@ public MetalamaVsxDependencyDefinition(
[
DevelopmentDependencies.PostSharpEngineering,
MetalamaDependencies.V2026_1.Metalama,
+ MetalamaDependencies.V2026_0.Metalama
+ .ToDependency(
+ new ConfigurationSpecific(
+ BuildConfiguration.Public,
+ BuildConfiguration.Public,
+ BuildConfiguration.Public ) )
+ .WithAlias( "Metalama20260" )
+ .WithLastSuccessfulOnly(),
PostSharpDependencies.V2026_0.PostSharp.ToDependency(
new ConfigurationSpecific(
BuildConfiguration.Release,
diff --git a/src/PostSharp.Engineering.BuildTools/Dependencies/DependenciesHelper.cs b/src/PostSharp.Engineering.BuildTools/Dependencies/DependenciesHelper.cs
index 55b1c86b..84a68bcf 100644
--- a/src/PostSharp.Engineering.BuildTools/Dependencies/DependenciesHelper.cs
+++ b/src/PostSharp.Engineering.BuildTools/Dependencies/DependenciesHelper.cs
@@ -27,22 +27,29 @@ public static bool UpdateOrFetchDependencies(
DependenciesConfigurationFile dependenciesConfigurationFile,
bool update )
{
- DependencyDefinition? GetDependencyDefinition( KeyValuePair dependencyPair )
+ (DependencyDefinition? Definition, ParametrizedDependency? Parametrized) GetDependencyInfo( KeyValuePair dependencyPair )
{
- if ( !context.Product.TryGetDependencyDefinition( dependencyPair.Key, out var dependency ) )
+ if ( context.Product.TryGetDependency( dependencyPair.Key, out var parametrizedDependency ) )
{
- context.Console.WriteWarning( $"The dependency '{dependencyPair.Key}' is not configured. Ignoring." );
+ return (parametrizedDependency.Definition, parametrizedDependency);
}
- return dependency;
+ if ( context.Product.TryGetDependencyDefinition( dependencyPair.Key, out var dependency ) )
+ {
+ return (dependency, null);
+ }
+
+ context.Console.WriteWarning( $"The dependency '{dependencyPair.Key}' is not configured. Ignoring." );
+
+ return (null, null);
}
var dependencies = dependenciesConfigurationFile
.Dependencies
.Where( d => d.Value.Origin != DependencyConfigurationOrigin.Transitive )
- .Select( d => (d.Value, GetDependencyDefinition( d )) )
- .Where( d => d.Item2 != null )
- .Select( d => new ResolvedDependency( d.Value, d.Item2! ) )
+ .Select( d => (Pair: d, Info: GetDependencyInfo( d )) )
+ .Where( d => d.Info.Definition != null )
+ .Select( d => new ResolvedDependency( d.Pair.Value, d.Info.Definition!, d.Info.Parametrized ) )
.ToList();
if ( dependencies.Count == 0 )
@@ -53,8 +60,8 @@ public static bool UpdateOrFetchDependencies(
}
TeamCityClient? tc = null;
- var iterationDependencies = dependencies.ToImmutableDictionary( d => d.Dependency.Name, d => d );
- var dependencyDictionary = dependencies.ToImmutableDictionary( d => d.Dependency.Name, d => d );
+ var iterationDependencies = dependencies.ToImmutableDictionary( d => d.Key, d => d );
+ var dependencyDictionary = dependencies.ToImmutableDictionary( d => d.Key, d => d );
while ( iterationDependencies.Count > 0 )
{
@@ -127,7 +134,8 @@ private static bool TryGetTransitiveDependencies(
var versionFile = Project.FromFile( directDependency.Source.VersionFile!, MSBuildLoadOptions.IgnoreImportErrors );
- var transitiveDependencies = versionFile.Items.Where( i => i.ItemType == directDependency.Dependency.NameWithoutDot + "Dependencies" );
+ // Item type uses KeyWithoutDot — for aliased deps the transform renamed `` to ``.
+ var transitiveDependencies = versionFile.Items.Where( i => i.ItemType == directDependency.KeyWithoutDot + "Dependencies" );
foreach ( var transitiveDependency in transitiveDependencies )
{
@@ -153,7 +161,15 @@ private static bool TryGetTransitiveDependencies(
continue;
}
- if ( !context.Product.TryGetDependencyDefinition( name, out var dependencyDefinition ) )
+ // Resolve the transitive dep's definition starting from the direct dep's product family. For aliased direct
+ // deps (e.g., Metalama 2026.0 aliased into a Metalama.Vsx 2026.1 build) this is essential — the consumer's
+ // family chain only includes the *current* version of the same logical product family (V2026_1), so a
+ // consumer-rooted lookup of "Metalama.Compiler" would find V2026_1.MetalamaCompiler whose CiConfiguration
+ // has 2026.1 build type IDs that don't match the 2026.0 buildId stored in the producer's version.props.
+ // For unaliased direct deps the direct dep's family is typically the same as (or relative-included by) the
+ // consumer's, so this preserves existing behavior.
+ if ( !directDependency.Dependency.ProductFamily.TryGetDependencyDefinition( name, out var dependencyDefinition )
+ && !context.Product.TryGetDependencyDefinition( name, out dependencyDefinition ) )
{
context.Console.WriteError(
$"Cannot find the dependency definition for '{name}' referenced by '{directDependency.Dependency.Name}'. The dependency must be defined in PostSharp.Engineering." );
@@ -250,8 +266,9 @@ bool TryGetBuildId( [NotNullWhen( true )] out CiBuildId? ciBuildId )
throw new InvalidOperationException();
}
- var newDependency = new ResolvedDependency( dependencySource, dependencyDefinition );
- newDependenciesBuilder.Add( newDependency.Dependency.Name, newDependency );
+ // Transitive deps are not declared at the consumer's use site, so they have no ParametrizedDependency / alias.
+ var newDependency = new ResolvedDependency( dependencySource, dependencyDefinition, Parametrized: null );
+ newDependenciesBuilder.Add( newDependency.Key, newDependency );
dependenciesConfigurationFile.Dependencies[name] = dependencySource;
}
@@ -301,13 +318,11 @@ private static bool ResolveBuildNumbersFromBranches(
if ( buildSpec is CiLatestBuildOfBranch branch )
{
- BuildConfiguration dependencyConfiguration;
-
- if ( context.Product.TryGetDependency( dependency.Dependency.Name, out var parametrizedDependency ) )
- {
- dependencyConfiguration = parametrizedDependency.ConfigurationMapping[configuration];
- }
- else
+ // CiLatestBuildOfBranch is only ever set for direct deps (transitives carry CiBuildId).
+ // Direct deps already have ResolvedDependency.Parametrized populated, so use it directly —
+ // a Name-based lookup would return the wrong ParametrizedDependency when the consumer has
+ // multiple aliased refs to the same Definition.Name.
+ if ( dependency.Parametrized == null )
{
context.Console.WriteError(
$"The source of the transitive dependency '{dependency.Dependency.Name}' is set to CiLatestBuildOfBranch. This is allowed only for direct dependencies." );
@@ -315,6 +330,8 @@ private static bool ResolveBuildNumbersFromBranches(
return false;
}
+ var dependencyConfiguration = dependency.Parametrized.ConfigurationMapping[configuration];
+
ciBuildType = dependency.Dependency.CiConfiguration.BuildTypes[dependencyConfiguration];
branchName = branch.Name;
}
@@ -416,7 +433,8 @@ private static bool DownloadArtifacts(
dependency.Dependency.Name,
buildId.BuildTypeId,
buildId.BuildNumber,
- artifactsDirectory ) )
+ artifactsDirectory,
+ dependency.IsAliased ? dependency : null ) )
{
return false;
}
@@ -429,19 +447,61 @@ private static bool ResolveLocalDependencies( BuildContext context, ImmutableDic
{
foreach ( var dependency in dependencies.Values.Where( d => d.Source.SourceKind is DependencySourceKind.Local ) )
{
- if ( dependency.Source.VersionFile == null )
+ // Producer's local .Import.props lives in the producer's repo root, named after the producer's product.
+ var producerName = dependency.Dependency.Name;
+
+ var producerImportPath = Path.Combine(
+ dependency.Source.GetResolvedLocalPath( context, producerName ),
+ producerName + ".Import.props" );
+
+ if ( !File.Exists( producerImportPath ) )
{
- dependency.Source.VersionFile =
- Path.Combine(
- dependency.Source.GetResolvedLocalPath( context, dependency.Dependency.Name ),
- dependency.Dependency.Name + ".Import.props" );
+ context.Console.WriteError( $"The file '{producerImportPath}' does not exist. Check that the product has been built." );
+
+ return false;
}
- if ( !File.Exists( dependency.Source.VersionFile ) )
+ if ( dependency.IsAliased )
{
- context.Console.WriteError( $"The file '{dependency.Source.VersionFile}' does not exist. Check that the product has been built." );
+ // Resolve the .version.props that the producer's .Import.props points at, then transform it
+ // into an alias-prefixed copy under dependencies/{Key}/.
+ var importDocument = XDocument.Load( producerImportPath );
+ var importElement = importDocument.Descendants( "Import" ).FirstOrDefault();
+ var producerVersionPropsRelative = importElement?.Attribute( "Project" )?.Value;
- return false;
+ if ( producerVersionPropsRelative == null )
+ {
+ context.Console.WriteError( $"Cannot read from '{producerImportPath}'." );
+
+ return false;
+ }
+
+ var producerVersionPropsAbsolute = Path.GetFullPath(
+ Path.Combine( Path.GetDirectoryName( producerImportPath )!, producerVersionPropsRelative ) );
+
+ if ( !File.Exists( producerVersionPropsAbsolute ) )
+ {
+ context.Console.WriteError( $"The file '{producerVersionPropsAbsolute}' does not exist." );
+
+ return false;
+ }
+
+ var aliasDirectory = TeamCityHelper.GetRestoredDependencyDirectory( context.RepoDirectory, dependency.Key );
+ var aliasedVersionPropsPath = Path.Combine( aliasDirectory, dependency.Key + ".version.props" );
+
+ TransformVersionPropsForAlias(
+ producerVersionPropsAbsolute,
+ aliasedVersionPropsPath,
+ dependency.Dependency.NameWithoutDot,
+ dependency.KeyWithoutDot );
+
+ WriteAliasImportFile( aliasDirectory, dependency.Key );
+
+ dependency.Source.VersionFile = Path.Combine( aliasDirectory, dependency.Key + ".Import.props" );
+ }
+ else
+ {
+ dependency.Source.VersionFile = producerImportPath;
}
}
@@ -457,9 +517,34 @@ private static bool ResolveRestoredDependencies( BuildContext context, Immutable
continue;
}
- if ( dependency.Source.VersionFile == null )
+ // For aliased deps, TeamCity's artifact rule (generated by ConfigurationProperties.cs) restores the producer's
+ // {Name}.version.props to dependencies/{Key}/. We then transform it in place to {Key}.version.props.
+ if ( dependency.IsAliased )
{
- var path = TeamCityHelper.GetRestoredDependencyVersionFile( context.RepoDirectory, dependency.Dependency.Name );
+ var aliasDirectory = TeamCityHelper.GetRestoredDependencyDirectory( context.RepoDirectory, dependency.Key );
+ var producerRestoredPath = Path.Combine( aliasDirectory, dependency.Dependency.Name + ".version.props" );
+ var aliasedVersionPropsPath = Path.Combine( aliasDirectory, dependency.Key + ".version.props" );
+
+ // Always re-transform when the producer file exists: TeamCity may have restored a fresher
+ // {Name}.version.props over an existing dependencies/{Key}/ directory, in which case a stale
+ // {Key}.version.props from a previous build would cause the consumer to read outdated metadata.
+ if ( File.Exists( producerRestoredPath ) )
+ {
+ TransformVersionPropsForAlias(
+ producerRestoredPath,
+ aliasedVersionPropsPath,
+ dependency.Dependency.NameWithoutDot,
+ dependency.KeyWithoutDot );
+ }
+
+ if ( dependency.Source.VersionFile == null )
+ {
+ dependency.Source.VersionFile = aliasedVersionPropsPath;
+ }
+ }
+ else if ( dependency.Source.VersionFile == null )
+ {
+ var path = TeamCityHelper.GetRestoredDependencyVersionFile( context.RepoDirectory, dependency.Key );
dependency.Source.VersionFile = path;
}
@@ -472,10 +557,11 @@ private static bool ResolveRestoredDependencies( BuildContext context, Immutable
var document = XDocument.Load( dependency.Source.VersionFile );
- var buildNumber = document.Root!.XPathSelectElement( $"/Project/PropertyGroup/{dependency.Dependency.NameWithoutDot}BuildNumber" )
+ // BuildNumber/BuildType use KeyWithoutDot — for aliased deps the transform renamed them.
+ var buildNumber = document.Root!.XPathSelectElement( $"/Project/PropertyGroup/{dependency.KeyWithoutDot}BuildNumber" )
?.Value;
- var buildType = document.Root!.XPathSelectElement( $"/Project/PropertyGroup/{dependency.Dependency.NameWithoutDot}BuildType" )?.Value;
+ var buildType = document.Root!.XPathSelectElement( $"/Project/PropertyGroup/{dependency.KeyWithoutDot}BuildType" )?.Value;
if ( !string.IsNullOrEmpty( buildNumber ) && !string.IsNullOrEmpty( buildType ) )
{
@@ -536,7 +622,8 @@ private static bool DownloadDependency(
string dependencyName,
string ciBuildTypeId,
int buildNumber,
- string artifactsPath )
+ string artifactsPath,
+ ResolvedDependency? aliasedDependency )
{
if ( !DownloadBuild( context, teamCity, dependencyName, ciBuildTypeId, buildNumber, artifactsPath, out var restoreDirectory ) )
{
@@ -553,7 +640,25 @@ private static bool DownloadDependency(
return false;
}
- dependencySource.VersionFile = versionFile;
+ if ( aliasedDependency != null )
+ {
+ // Write a transformed copy alongside the original; the import target is the transformed one.
+ var aliasedVersionFile = Path.Combine(
+ Path.GetDirectoryName( versionFile )!,
+ aliasedDependency.Key + ".version.props" );
+
+ TransformVersionPropsForAlias(
+ versionFile,
+ aliasedVersionFile,
+ aliasedDependency.Dependency.NameWithoutDot,
+ aliasedDependency.KeyWithoutDot );
+
+ dependencySource.VersionFile = aliasedVersionFile;
+ }
+ else
+ {
+ dependencySource.VersionFile = versionFile;
+ }
return true;
}
@@ -580,5 +685,139 @@ private static bool DownloadDependency(
return null;
}
- private record ResolvedDependency( DependencySource Source, DependencyDefinition Dependency );
+ private record ResolvedDependency( DependencySource Source, DependencyDefinition Dependency, ParametrizedDependency? Parametrized )
+ {
+ ///
+ /// Gets the consumer-side key: if available, otherwise .
+ ///
+ public string Key => this.Parametrized?.Key ?? this.Dependency.Name;
+
+ ///
+ /// Gets with dots removed.
+ ///
+ public string KeyWithoutDot => this.Parametrized?.KeyWithoutDot ?? this.Dependency.NameWithoutDot;
+
+ ///
+ /// Whether the consumer-side key differs from the definition name (i.e., an alias is in effect).
+ ///
+ public bool IsAliased => this.Parametrized?.Alias != null;
+ }
+
+ ///
+ /// Element-name suffixes emitted by ArtifactManifestFile.TryWrite as producer-prefixed properties or item types.
+ /// Used by to identify which elements to rename when an aliased dep
+ /// is consumed. Other elements (transitive Feed dep version properties, item metadata, etc.) are left alone.
+ ///
+ internal static readonly string[] ProducerPropertySuffixes =
+ {
+ "", // bare prefix (rarely used; included for completeness)
+ "Version",
+ "MainVersion",
+ "PreviewVersion",
+ "AssemblyVersion",
+ "VersionPrefix",
+ "VersionSuffix",
+ "VersionPatchNumber",
+ "VersionWithoutSuffix",
+ "BuildConfiguration",
+ "BuildNumber",
+ "BuildType",
+ "BuildDate",
+ "Dependencies",
+ "ArtifactsDirectory",
+ "PublicArtifactsDirectory",
+ "PrivateArtifactsDirectory",
+ "EngineeringVersion",
+ "VersionFilePath"
+ };
+
+ ///
+ /// Transforms a producer-published {ProducerName}.version.props (or .Import.props) into a copy whose
+ /// producer-prefixed property/item names are replaced with the consumer's alias prefix. Used so that two references
+ /// to the same logical product (e.g., Metalama 2026.1 and Metalama 2026.0) under different aliases can coexist in
+ /// the consumer's MSBuild scope without colliding on properties like $(MetalamaVersion).
+ ///
+ /// Source file path.
+ /// Destination file path. Parent directory is created if missing.
+ /// Producer's NameWithoutDot (e.g., Metalama).
+ /// Consumer's KeyWithoutDot (e.g., Metalama20260).
+ ///
+ /// Renames only elements whose local name matches {oldPrefix}{Suffix} where Suffix is in
+ /// . This conservative rule avoids accidentally renaming transitive dep version
+ /// properties such as MetalamaCompilerVersion (whose suffix CompilerVersion is not in the list).
+ /// Also absolutizes any relative <Import Project="..."/> path against the source file's directory so the
+ /// relocated copy still resolves.
+ ///
+ internal static void TransformVersionPropsForAlias( string sourceFile, string destinationFile, string oldPrefix, string newPrefix )
+ {
+ var document = XDocument.Load( sourceFile );
+ var sourceDirectory = Path.GetDirectoryName( sourceFile )!;
+
+ foreach ( var element in document.Descendants().ToList() )
+ {
+ var localName = element.Name.LocalName;
+
+ if ( localName.StartsWith( oldPrefix, StringComparison.Ordinal ) )
+ {
+ var remainder = localName.Substring( oldPrefix.Length );
+
+ if ( Array.IndexOf( ProducerPropertySuffixes, remainder ) >= 0 )
+ {
+ element.Name = element.Name.Namespace + (newPrefix + remainder);
+ }
+ }
+
+ if ( element.Name.LocalName == "Import" )
+ {
+ var projectAttribute = element.Attribute( "Project" );
+
+ if ( projectAttribute != null && !Path.IsPathRooted( projectAttribute.Value ) )
+ {
+ projectAttribute.Value = Path.GetFullPath( Path.Combine( sourceDirectory, projectAttribute.Value ) );
+ }
+
+ var conditionAttribute = element.Attribute( "Condition" );
+
+ if ( conditionAttribute != null )
+ {
+ // Conditions like "Exists('../foo.props')" need the path absolutized too.
+ var match = System.Text.RegularExpressions.Regex.Match( conditionAttribute.Value, @"Exists\s*\(\s*'([^']+)'\s*\)" );
+
+ if ( match.Success )
+ {
+ var pathInCondition = match.Groups[1].Value;
+
+ if ( !Path.IsPathRooted( pathInCondition ) )
+ {
+ var absolutePath = Path.GetFullPath( Path.Combine( sourceDirectory, pathInCondition ) );
+ conditionAttribute.Value = conditionAttribute.Value.Replace( pathInCondition, absolutePath, StringComparison.Ordinal );
+ }
+ }
+ }
+ }
+ }
+
+ Directory.CreateDirectory( Path.GetDirectoryName( destinationFile )! );
+ document.Save( destinationFile );
+ }
+
+ ///
+ /// Writes a thin {Key}.Import.props file at that imports the alongside
+ /// {Key}.version.props. This keeps the consumer's import-file convention (one .Import.props entry point
+ /// per dep) while still pointing at the transformed version.props.
+ ///
+ private static void WriteAliasImportFile( string aliasDirectory, string key )
+ {
+ var importFilePath = Path.Combine( aliasDirectory, key + ".Import.props" );
+ var versionPropsRelative = key + ".version.props";
+
+ var content = $@"
+
+
+
+";
+
+ Directory.CreateDirectory( aliasDirectory );
+ File.WriteAllText( importFilePath, content );
+ }
}
\ No newline at end of file
diff --git a/src/PostSharp.Engineering.BuildTools/Dependencies/Model/DependencyArtifactPickup.cs b/src/PostSharp.Engineering.BuildTools/Dependencies/Model/DependencyArtifactPickup.cs
new file mode 100644
index 00000000..4d148489
--- /dev/null
+++ b/src/PostSharp.Engineering.BuildTools/Dependencies/Model/DependencyArtifactPickup.cs
@@ -0,0 +1,22 @@
+// Copyright (c) SharpCrafters s.r.o. See the LICENSE.md file in the root directory of this repository root for details.
+
+namespace PostSharp.Engineering.BuildTools.Dependencies.Model;
+
+///
+/// Specifies how a referenced dependency's artifacts should be picked up by the consumer's CI build.
+///
+public enum DependencyArtifactPickup
+{
+ ///
+ /// Default: a TeamCity snapshot dependency is generated, chaining the producer's build before the consumer's.
+ /// The consumer always builds against artifacts from a freshly-built (or reused) producer build.
+ ///
+ Snapshot,
+
+ ///
+ /// Only an artifact dependency is generated, with buildRule = lastSuccessful(). The producer is not
+ /// chained as a snapshot dependency. Use this when the producer is a frozen / released line and the consumer
+ /// only wants the last published artifacts.
+ ///
+ LastSuccessful
+}
diff --git a/src/PostSharp.Engineering.BuildTools/Dependencies/Model/DependencyConfiguration.cs b/src/PostSharp.Engineering.BuildTools/Dependencies/Model/DependencyConfiguration.cs
index 38691ca3..966392e1 100644
--- a/src/PostSharp.Engineering.BuildTools/Dependencies/Model/DependencyConfiguration.cs
+++ b/src/PostSharp.Engineering.BuildTools/Dependencies/Model/DependencyConfiguration.cs
@@ -4,4 +4,40 @@
namespace PostSharp.Engineering.BuildTools.Dependencies.Model;
-public record DependencyConfiguration( DependencyDefinition Definition, BuildConfiguration Configuration );
\ No newline at end of file
+public record DependencyConfiguration( DependencyDefinition Definition, BuildConfiguration Configuration )
+{
+ ///
+ /// Gets the at the consumer's use site, when this configuration was produced
+ /// from one. null when the configuration was constructed without parametrized info.
+ ///
+ ///
+ /// Excluded from record equality via the and
+ /// overrides below. Two instances with the same
+ /// and compare equal regardless of the parametrized reference,
+ /// preserving HashSet-based deduplication semantics in DependencyDefinition.GetAllDependencies.
+ ///
+ public ParametrizedDependency? Parametrized { get; init; }
+
+ ///
+ /// Gets the consumer-side key: when is set,
+ /// otherwise .
+ ///
+ public string Key => this.Parametrized?.Key ?? this.Definition.Name;
+
+ ///
+ /// Gets with dots removed, for use in MSBuild property names.
+ ///
+ public string KeyWithoutDot => this.Parametrized?.KeyWithoutDot ?? this.Definition.NameWithoutDot;
+
+ ///
+ /// Gets the artifact pickup mode at the consumer's use site, defaulting to .
+ ///
+ public DependencyArtifactPickup ArtifactPickup => this.Parametrized?.ArtifactPickup ?? DependencyArtifactPickup.Snapshot;
+
+ public virtual bool Equals( DependencyConfiguration? other )
+ => other is not null
+ && ReferenceEquals( this.Definition, other.Definition )
+ && this.Configuration == other.Configuration;
+
+ public override int GetHashCode() => System.HashCode.Combine( this.Definition, this.Configuration );
+}
diff --git a/src/PostSharp.Engineering.BuildTools/Dependencies/Model/DependencyDefinition.cs b/src/PostSharp.Engineering.BuildTools/Dependencies/Model/DependencyDefinition.cs
index b41da1ed..71cfa75f 100644
--- a/src/PostSharp.Engineering.BuildTools/Dependencies/Model/DependencyDefinition.cs
+++ b/src/PostSharp.Engineering.BuildTools/Dependencies/Model/DependencyDefinition.cs
@@ -86,7 +86,7 @@ void PopulateRecursive( DependencyDefinition dependency, BuildConfiguration conf
{
var childConfiguration = child.ConfigurationMapping[configuration];
- var dependencyConfiguration = new DependencyConfiguration( child, childConfiguration );
+ var dependencyConfiguration = new DependencyConfiguration( child, childConfiguration ) { Parametrized = child };
if ( !dependencies.Add( dependencyConfiguration ) )
{
@@ -158,6 +158,11 @@ public string[] PackagePatterns
public ParametrizedDependency ToDependency( ConfigurationSpecific configurationMapping )
=> new( this ) { ConfigurationMapping = configurationMapping };
+ ///
+ /// Returns a that references this definition under a consumer-side .
+ ///
+ public ParametrizedDependency WithAlias( string alias ) => this.ToDependency() with { Alias = alias };
+
public DependencyDefinition(
ProductFamily productFamily,
string dependencyName,
diff --git a/src/PostSharp.Engineering.BuildTools/Dependencies/Model/DependencySource.cs b/src/PostSharp.Engineering.BuildTools/Dependencies/Model/DependencySource.cs
index 09ed9ea3..1ec11a15 100644
--- a/src/PostSharp.Engineering.BuildTools/Dependencies/Model/DependencySource.cs
+++ b/src/PostSharp.Engineering.BuildTools/Dependencies/Model/DependencySource.cs
@@ -49,18 +49,38 @@ public static DependencySource CreateLocalDependency( DependencyConfigurationOri
///
/// Creates a that represents a build server artifact dependency that has been restored,
- /// and that exists under the 'dependencies' directory.
+ /// and that exists under the 'dependencies' directory. Uses the consumer-side
+ /// for the path and the property prefix, so this method is alias-aware.
+ ///
+ public static DependencySource CreateRestoredDependency(
+ BuildContext context,
+ ParametrizedDependency dependency,
+ DependencyConfigurationOrigin origin )
+ => CreateRestoredDependencyCore( context, dependency.Key, dependency.KeyWithoutDot, origin );
+
+ ///
+ /// Creates a that represents a build server artifact dependency that has been restored,
+ /// and that exists under the 'dependencies' directory. Uses as the key, so this
+ /// overload is suitable only for unaliased references. Prefer the overload at use sites
+ /// that may involve aliases.
///
public static DependencySource CreateRestoredDependency(
BuildContext context,
DependencyDefinition dependencyDefinition,
DependencyConfigurationOrigin origin )
+ => CreateRestoredDependencyCore( context, dependencyDefinition.Name, dependencyDefinition.NameWithoutDot, origin );
+
+ private static DependencySource CreateRestoredDependencyCore(
+ BuildContext context,
+ string key,
+ string keyWithoutDot,
+ DependencyConfigurationOrigin origin )
{
- var path = TeamCityHelper.GetRestoredDependencyVersionFile( context.RepoDirectory, dependencyDefinition.Name );
+ var path = TeamCityHelper.GetRestoredDependencyVersionFile( context.RepoDirectory, key );
var document = XDocument.Load( path );
- var buildNumber = document.Root!.XPathSelectElement( $"/Project/PropertyGroup/{dependencyDefinition.NameWithoutDot}BuildNumber" )?.Value;
- var buildType = document.Root!.XPathSelectElement( $"/Project/PropertyGroup/{dependencyDefinition.NameWithoutDot}BuildType" )?.Value;
+ var buildNumber = document.Root!.XPathSelectElement( $"/Project/PropertyGroup/{keyWithoutDot}BuildNumber" )?.Value;
+ var buildType = document.Root!.XPathSelectElement( $"/Project/PropertyGroup/{keyWithoutDot}BuildType" )?.Value;
CiBuildId? buildId;
diff --git a/src/PostSharp.Engineering.BuildTools/Dependencies/Model/ParametrizedDependency.cs b/src/PostSharp.Engineering.BuildTools/Dependencies/Model/ParametrizedDependency.cs
index aa0da97b..50226dff 100644
--- a/src/PostSharp.Engineering.BuildTools/Dependencies/Model/ParametrizedDependency.cs
+++ b/src/PostSharp.Engineering.BuildTools/Dependencies/Model/ParametrizedDependency.cs
@@ -28,8 +28,56 @@ public ParametrizedDependency( DependencyDefinition definition )
public string NameWithoutDot => this.Definition.NameWithoutDot;
+ ///
+ /// Gets an optional consumer-side alias that disambiguates this reference from other references to the same
+ /// . When set, the alias is used in place of the definition name as the key
+ /// for MSBuild property names, generated config-file dictionary keys, and the per-dependency directory under dependencies/.
+ ///
+ ///
+ /// Aliases are only needed when a referencing product depends on two dependencies whose
+ /// would otherwise collide (e.g., the same logical product across two different versions).
+ /// When null, behavior is identical to having no alias and falls back to .
+ ///
+ public string? Alias { get; init; }
+
+ ///
+ /// Gets the consumer-side key for this dependency: when set, otherwise .
+ ///
+ public string Key => this.Alias ?? this.Definition.Name;
+
+ ///
+ /// Gets with all dots removed, for use in MSBuild property names.
+ ///
+ public string KeyWithoutDot => this.Alias != null
+ ? this.Alias.Replace( ".", "", StringComparison.Ordinal )
+ : this.Definition.NameWithoutDot;
+
+ ///
+ /// Gets the artifact pickup mode for this reference. Defaults to .
+ ///
+ public DependencyArtifactPickup ArtifactPickup { get; init; } = DependencyArtifactPickup.Snapshot;
+
///
public DependencyDefinition Definition { get; init; }
public static implicit operator ParametrizedDependency( DependencyDefinition definition ) => new( definition );
-}
\ No newline at end of file
+}
+
+///
+/// Fluent helpers for building values at the use site.
+///
+public static class ParametrizedDependencyExtensions
+{
+ ///
+ /// Returns a copy of with the specified consumer-side .
+ ///
+ public static ParametrizedDependency WithAlias( this ParametrizedDependency dependency, string alias )
+ => dependency with { Alias = alias };
+
+ ///
+ /// Returns a copy of with set to
+ /// : artifact-only TeamCity dependency, no snapshot.
+ ///
+ public static ParametrizedDependency WithLastSuccessfulOnly( this ParametrizedDependency dependency )
+ => dependency with { ArtifactPickup = DependencyArtifactPickup.LastSuccessful };
+}
diff --git a/src/PostSharp.Engineering.BuildTools/PostSharp.Engineering.BuildTools.csproj b/src/PostSharp.Engineering.BuildTools/PostSharp.Engineering.BuildTools.csproj
index 0db0a5fd..cd59e6ba 100644
--- a/src/PostSharp.Engineering.BuildTools/PostSharp.Engineering.BuildTools.csproj
+++ b/src/PostSharp.Engineering.BuildTools/PostSharp.Engineering.BuildTools.csproj
@@ -1,53 +1,57 @@
-
-
-
- Library
- net8.0
- PostSharp.Engineering.BuildTools
- false
- preview
- enable
- True
-
- $(NoWarn);SA0001
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+ Library
+ net8.0
+ PostSharp.Engineering.BuildTools
+ false
+ preview
+ enable
+ True
+
+ $(NoWarn);SA0001
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+