From b4e8bb29b188e51da19bf818c2fc3b0272ce62ef Mon Sep 17 00:00:00 2001 From: PostSharpBot Date: Thu, 30 Apr 2026 12:11:21 +0200 Subject: [PATCH 1/2] Dependencies: add consumer-side Alias for multi-version references Adds a per-use-site `Alias` and `ArtifactPickup` (Snapshot/LastSuccessful) on `ParametrizedDependency` so a referencing product can list the same logical dependency under two different `ProductFamily` versions without MSBuild property-name collisions. The alias drives the `dependencies/{Key}/` directory layout, the generated MSBuild property prefix, and the TeamCity artifact-rule destination. A fetch-time XML transform projects the producer's `<{Name}.version.props>` into a `<{Alias}.version.props>` whose producer-prefixed property/item names are renamed (using a curated suffix list to avoid renaming transitive Feed dep properties). Transitive dependency resolution now starts from the direct dep's family, so an aliased Metalama 2026.0 routes its transitive `Metalama.Compiler` to the V2026_0 definition rather than the consumer's V2026_1 family. `Metalama.Vsx` 2026.1 now references Metalama 2026.0 alongside 2026.1, mapped to BuildConfiguration.Public with `LastSuccessful` artifact pickup (no snapshot dependency). Includes a new `PostSharp.Engineering.BuildTools.Tests` project with 13 tests covering the alias semantics and the version.props transform. Co-Authored-By: Claude Opus 4.7 --- Directory.Packages.props | 4 + PostSharp.Engineering.sln | 63 ++ .../ParametrizedDependencyAliasTests.cs | 117 ++++ ...tSharp.Engineering.BuildTools.Tests.csproj | 24 + .../TransformVersionPropsForAliasTests.cs | 177 ++++++ .../runtimeconfig.template.json | 3 + .../Build/Files/ArtifactManifestFile.cs | 576 +++++++++--------- .../Files/DependenciesConfigurationFile.cs | 53 +- .../Build/Files/VersionFile.cs | 23 +- .../Build/Model/Product.cs | 6 +- ...owershellAdditionalCiBuildConfiguration.cs | 4 +- .../Generation/ConfigurationProperties.cs | 94 +-- .../Generation/TeamCitySettingsFile.cs | 12 +- .../MetalamaVsxDependencies.V2026_1.cs | 8 + .../Dependencies/DependenciesHelper.cs | 294 ++++++++- .../Model/DependencyArtifactPickup.cs | 22 + .../Model/DependencyConfiguration.cs | 38 +- .../Model/DependencyDefinition.cs | 7 +- .../Dependencies/Model/DependencySource.cs | 28 +- .../Model/ParametrizedDependency.cs | 50 +- .../PostSharp.Engineering.BuildTools.csproj | 110 ++-- 21 files changed, 1263 insertions(+), 450 deletions(-) create mode 100644 src/PostSharp.Engineering.BuildTools.Tests/ParametrizedDependencyAliasTests.cs create mode 100644 src/PostSharp.Engineering.BuildTools.Tests/PostSharp.Engineering.BuildTools.Tests.csproj create mode 100644 src/PostSharp.Engineering.BuildTools.Tests/TransformVersionPropsForAliasTests.cs create mode 100644 src/PostSharp.Engineering.BuildTools.Tests/runtimeconfig.template.json create mode 100644 src/PostSharp.Engineering.BuildTools/Dependencies/Model/DependencyArtifactPickup.cs 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..27c0b4ec --- /dev/null +++ b/src/PostSharp.Engineering.BuildTools.Tests/ParametrizedDependencyAliasTests.cs @@ -0,0 +1,117 @@ +// 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 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 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}"; - - 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}VersionSuffix>{version.ArcadeSuffix} - <{product.ProductNameWithoutDot}VersionPatchNumber>{version.PatchNumber} - <{product.ProductNameWithoutDot}VersionWithoutSuffix>{version.PackageVersionWithoutSuffix} - <{product.ProductNameWithoutDot}Version>{version.PackageVersion} - <{product.ProductNameWithoutDot}PreviewVersion>{version.PackagePreviewVersion} - <{product.ProductNameWithoutDot}AssemblyVersion>{version.AssemblyVersion}"; - } - else - { - manifestFileContent += $@" - <{product.ProductNameWithoutDot}Version>{version.PackageVersion} - <{product.ProductNameWithoutDot}PreviewVersion>{version.PackagePreviewVersion} - <{product.ProductNameWithoutDot}AssemblyVersion>{version.AssemblyVersion}"; - } - - manifestFileContent += $@" - <{product.ProductNameWithoutDot}BuildConfiguration>{configuration} - <{product.ProductNameWithoutDot}Dependencies>{string.Join( ";", product.ParametrizedDependencies.Select( x => x.Name ) )} - <{product.ProductNameWithoutDot}PublicArtifactsDirectory>{product.PublicArtifactsDirectory} - <{product.ProductNameWithoutDot}PrivateArtifactsDirectory>{product.GetPrivateArtifactsRelativeDirectory( configuration )} - <{product.ProductNameWithoutDot}EngineeringVersion>{VersionHelper.EngineeringVersion} - <{product.ProductNameWithoutDot}VersionFilePath>{product.VersionsFilePath} - <{product.ProductNameWithoutDot}BuildNumber>{buildSettings.BuildNumber} - <{product.ProductNameWithoutDot}BuildType>{buildSettings.BuildType} - <{product.ProductNameWithoutDot}BuildDate>{buildDate} - <{product.ProductNameWithoutDot}ArtifactsDirectory>$(MSBuildThisFileDirectory) - $(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 - += $@" - "; - } - - 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}"; - } - - // 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}"; - } - } - - 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}"; + + 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}VersionSuffix>{version.ArcadeSuffix} + <{product.ProductNameWithoutDot}VersionPatchNumber>{version.PatchNumber} + <{product.ProductNameWithoutDot}VersionWithoutSuffix>{version.PackageVersionWithoutSuffix} + <{product.ProductNameWithoutDot}Version>{version.PackageVersion} + <{product.ProductNameWithoutDot}PreviewVersion>{version.PackagePreviewVersion} + <{product.ProductNameWithoutDot}AssemblyVersion>{version.AssemblyVersion}"; + } + else + { + manifestFileContent += $@" + <{product.ProductNameWithoutDot}Version>{version.PackageVersion} + <{product.ProductNameWithoutDot}PreviewVersion>{version.PackagePreviewVersion} + <{product.ProductNameWithoutDot}AssemblyVersion>{version.AssemblyVersion}"; + } + + manifestFileContent += $@" + <{product.ProductNameWithoutDot}BuildConfiguration>{configuration} + <{product.ProductNameWithoutDot}Dependencies>{string.Join( ";", product.ParametrizedDependencies.Select( x => x.Key ) )} + <{product.ProductNameWithoutDot}PublicArtifactsDirectory>{product.PublicArtifactsDirectory} + <{product.ProductNameWithoutDot}PrivateArtifactsDirectory>{product.GetPrivateArtifactsRelativeDirectory( configuration )} + <{product.ProductNameWithoutDot}EngineeringVersion>{VersionHelper.EngineeringVersion} + <{product.ProductNameWithoutDot}VersionFilePath>{product.VersionsFilePath} + <{product.ProductNameWithoutDot}BuildNumber>{buildSettings.BuildNumber} + <{product.ProductNameWithoutDot}BuildType>{buildSettings.BuildType} + <{product.ProductNameWithoutDot}BuildDate>{buildDate} + <{product.ProductNameWithoutDot}ArtifactsDirectory>$(MSBuildThisFileDirectory) + $(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 + += $@" + "; + } + + 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}"; + } + + // 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}"; + } + } + + 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..d1d1685c 100644 --- a/src/PostSharp.Engineering.BuildTools/Build/Model/Product.cs +++ b/src/PostSharp.Engineering.BuildTools/Build/Model/Product.cs @@ -278,7 +278,8 @@ public DockerSpec? DockerSpec public bool TryGetDependency( string name, [NotNullWhen( true )] out ParametrizedDependency? dependency ) { - dependency = this.ParametrizedDependencies.SingleOrDefault( d => d.Name == name ); + dependency = this.ParametrizedDependencies.SingleOrDefault( d => d.Key == name ) + ?? this.ParametrizedDependencies.SingleOrDefault( d => d.Name == name ); // 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 @@ -299,7 +300,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; + dependencyDefinition = (this.ParametrizedDependencies.SingleOrDefault( d => d.Key == name ) + ?? this.ParametrizedDependencies.SingleOrDefault( d => d.Name == 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..d0b2b96d 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; } @@ -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,31 @@ 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" ); + + if ( File.Exists( producerRestoredPath ) && !File.Exists( aliasedVersionPropsPath ) ) + { + 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 +554,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 +619,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 +637,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 +682,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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From b59c769c538c56e3c4da72c831d7fdb5525ccddb Mon Sep 17 00:00:00 2001 From: PostSharpBot Date: Thu, 30 Apr 2026 15:58:21 +0200 Subject: [PATCH 2/2] Dependencies: address PR #109 review feedback Three correctness fixes from Copilot review: - Product.TryGetDependency / TryGetDependencyDefinition: drop the ambiguous Definition.Name fallback that would throw whenever two ParametrizedDependency entries declare different aliases for the same Definition.Name. Lookup is now strictly by Key (which equals Name for unaliased entries, preserving legacy callers). - DependenciesHelper.ResolveBuildNumbersFromBranches: use ResolvedDependency.Parametrized directly instead of looking it up by Definition.Name. The Name-based lookup returned the wrong ParametrizedDependency (and its ConfigurationMapping) when the consumer had two refs to the same Definition under different aliases. The path is only reachable for direct deps, where Parametrized is always populated. - DependenciesHelper.ResolveRestoredDependencies: always re-transform the producer's restored {Name}.version.props rather than skipping when {Key}.version.props already exists. The previous gate left a stale file when TeamCity restored a fresher version over an existing dependencies/{Key}/ directory. Adds a test covering the throw-avoidance scenario (two aliased refs to the same Definition.Name). Co-Authored-By: Claude Opus 4.7 --- .../ParametrizedDependencyAliasTests.cs | 22 +++++++++++++++++++ .../Build/Model/Product.cs | 13 +++++++---- .../Dependencies/DependenciesHelper.cs | 19 +++++++++------- 3 files changed, 42 insertions(+), 12 deletions(-) diff --git a/src/PostSharp.Engineering.BuildTools.Tests/ParametrizedDependencyAliasTests.cs b/src/PostSharp.Engineering.BuildTools.Tests/ParametrizedDependencyAliasTests.cs index 27c0b4ec..441cbc29 100644 --- a/src/PostSharp.Engineering.BuildTools.Tests/ParametrizedDependencyAliasTests.cs +++ b/src/PostSharp.Engineering.BuildTools.Tests/ParametrizedDependencyAliasTests.cs @@ -5,6 +5,7 @@ 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; @@ -102,6 +103,27 @@ public void DependencyConfiguration_KeyUsesAliasFromParametrized() 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() { diff --git a/src/PostSharp.Engineering.BuildTools/Build/Model/Product.cs b/src/PostSharp.Engineering.BuildTools/Build/Model/Product.cs index d1d1685c..5f04865f 100644 --- a/src/PostSharp.Engineering.BuildTools/Build/Model/Product.cs +++ b/src/PostSharp.Engineering.BuildTools/Build/Model/Product.cs @@ -278,12 +278,17 @@ public DockerSpec? DockerSpec public bool TryGetDependency( string name, [NotNullWhen( true )] out ParametrizedDependency? dependency ) { - dependency = this.ParametrizedDependencies.SingleOrDefault( d => d.Key == name ) - ?? 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; } @@ -300,8 +305,8 @@ public DependencyDefinition GetDependencyDefinition( string name ) public bool TryGetDependencyDefinition( string name, [NotNullWhen( true )] out DependencyDefinition? dependencyDefinition ) { - dependencyDefinition = (this.ParametrizedDependencies.SingleOrDefault( d => d.Key == name ) - ?? 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/Dependencies/DependenciesHelper.cs b/src/PostSharp.Engineering.BuildTools/Dependencies/DependenciesHelper.cs index d0b2b96d..84a68bcf 100644 --- a/src/PostSharp.Engineering.BuildTools/Dependencies/DependenciesHelper.cs +++ b/src/PostSharp.Engineering.BuildTools/Dependencies/DependenciesHelper.cs @@ -318,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." ); @@ -332,6 +330,8 @@ private static bool ResolveBuildNumbersFromBranches( return false; } + var dependencyConfiguration = dependency.Parametrized.ConfigurationMapping[configuration]; + ciBuildType = dependency.Dependency.CiConfiguration.BuildTypes[dependencyConfiguration]; branchName = branch.Name; } @@ -525,7 +525,10 @@ private static bool ResolveRestoredDependencies( BuildContext context, Immutable var producerRestoredPath = Path.Combine( aliasDirectory, dependency.Dependency.Name + ".version.props" ); var aliasedVersionPropsPath = Path.Combine( aliasDirectory, dependency.Key + ".version.props" ); - if ( File.Exists( producerRestoredPath ) && !File.Exists( aliasedVersionPropsPath ) ) + // 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,