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