diff --git a/docs/documentation/catalog/products.md b/docs/documentation/catalog/products.md index b4d3a89cf0..bf8b39f8e2 100644 --- a/docs/documentation/catalog/products.md +++ b/docs/documentation/catalog/products.md @@ -28,7 +28,15 @@ products: * `repository`: The repository name for the product. It's optional and primarily intended for handling edge cases where there is a mismatch between the repository name and the product identifier. * `features`: An optional mapping that controls which docs-builder subsystems the product participates in. When omitted, all features are enabled (backward compatible). When present, all features default to `true` and individual features can be opted out by setting them to `false`. The available features are: * `public-reference`: The product can be referenced in `applies_to` blocks, page frontmatter `products`, and gets `{{ product. }}` substitutions. This is what "being a documentation product" means today. - * `release-notes`: The product participates in the changelog and release notes system. + * `release-notes`: The product's participation in the changelog and release notes system, and which onboarding path it follows (see the [release-notes onboarding decision flowchart](https://github.com/elastic/docs-eng-team/blob/main/docs/rfcs/release-notes-onboarding.md#decision-flowchart)). Accepts booleans and path names: + + | Value | Meaning | + |---|---| + | Omitted | Defaults to `on-release`, preserving the default participation behavior | + | `true` | Backward-compatible alias for `on-release` | + | `false` | Product does not participate in release notes automation | + | `prestage` | Release bundles are reviewed and committed to the repository before release | + | `on-release` | Final bundles are built and uploaded at release time | :::{note} Products without a `features` mapping behave exactly as before -- they participate in all subsystems. The `features` mapping uses opt-out semantics: all features are enabled by default, and you only need to set a feature to `false` to disable it. For example, internal tools that need release notes but don't have public-facing documentation can set `public-reference: false`. diff --git a/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs b/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs index d7f56eb8a7..f3c4ec9d08 100644 --- a/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs +++ b/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs @@ -418,7 +418,7 @@ private static string[] ParseReleaseNotesProducts( continue; } - if (!resolved.Features.ReleaseNotes) + if (!resolved.Features.ParticipatesInReleaseNotes) { context.EmitError(context.ConfigurationPath, $"Product '{product}' declared in 'release_notes' does not participate in the release-notes system (it lacks the 'release-notes' feature in products.yml)."); diff --git a/src/Elastic.Documentation.Configuration/Elastic.Documentation.Configuration.csproj b/src/Elastic.Documentation.Configuration/Elastic.Documentation.Configuration.csproj index 0639e36105..af4525b571 100644 --- a/src/Elastic.Documentation.Configuration/Elastic.Documentation.Configuration.csproj +++ b/src/Elastic.Documentation.Configuration/Elastic.Documentation.Configuration.csproj @@ -20,6 +20,9 @@ <_Parameter1>Elastic.Markdown.Tests + + <_Parameter1>Elastic.Documentation.Configuration.Tests + diff --git a/src/Elastic.Documentation.Configuration/Products/Product.cs b/src/Elastic.Documentation.Configuration/Products/Product.cs index b05ece7adc..d4e0bb0407 100644 --- a/src/Elastic.Documentation.Configuration/Products/Product.cs +++ b/src/Elastic.Documentation.Configuration/Products/Product.cs @@ -74,17 +74,42 @@ public record ProductLink public string Id { get; set; } = string.Empty; } +/// +/// The release-notes onboarding path a product follows, declared via features.release-notes +/// in products.yml. See the release-notes onboarding RFC: a product either commits its final +/// release bundles before release () or cuts them at release time +/// (, the default). +/// +public enum ReleaseNotesPath +{ + /// Product does not participate in release notes automation (release-notes: false). + None, + + /// Final bundles are built and uploaded at release time (release-notes omitted, true, or on-release). + OnRelease, + + /// Release bundles are reviewed and committed to the repository before release (release-notes: prestage). + Prestage +} + /// Declares which docs-builder subsystems a product participates in. public record ProductFeatures { /// Product can be referenced in applies_to blocks, page frontmatter, and gets display-name substitutions. public bool PublicReference { get; init; } - /// Product participates in the changelog / release-notes system. - public bool ReleaseNotes { get; init; } + /// + /// The product's release-notes onboarding path. when + /// features.release-notes is omitted or true (preserving the historical boolean + /// participation default), when false. + /// + public ReleaseNotesPath ReleaseNotes { get; init; } + + /// Whether the product participates in the changelog / release-notes system at all. + public bool ParticipatesInReleaseNotes => ReleaseNotes != ReleaseNotesPath.None; /// All features enabled -- the implicit default when no features map is present in YAML. - public static ProductFeatures All => new() { PublicReference = true, ReleaseNotes = true }; + public static ProductFeatures All => new() { PublicReference = true, ReleaseNotes = ReleaseNotesPath.OnRelease }; public static readonly FrozenSet KnownKeys = FrozenSet.ToFrozenSet(["public-reference", "release-notes"], StringComparer.OrdinalIgnoreCase); } diff --git a/src/Elastic.Documentation.Configuration/Products/ProductExtensions.cs b/src/Elastic.Documentation.Configuration/Products/ProductExtensions.cs index 0c825afb2d..f581f92ade 100644 --- a/src/Elastic.Documentation.Configuration/Products/ProductExtensions.cs +++ b/src/Elastic.Documentation.Configuration/Products/ProductExtensions.cs @@ -12,9 +12,13 @@ public static class ProductExtensions { public static ProductsConfiguration CreateProducts(this ConfigurationFileProvider provider, VersionsConfiguration versionsConfiguration) { - var productsFilePath = provider.ProductsFile; + using var reader = provider.ProductsFile.OpenText(); + return CreateProducts(reader, versionsConfiguration); + } - var productsDto = ConfigurationFileProvider.Deserializer.Deserialize(productsFilePath.OpenText()); + internal static ProductsConfiguration CreateProducts(TextReader reader, VersionsConfiguration versionsConfiguration) + { + var productsDto = ConfigurationFileProvider.Deserializer.Deserialize(reader); var products = productsDto.Products.ToDictionary( kvp => kvp.Key, @@ -59,7 +63,7 @@ public static ProductsConfiguration CreateProducts(this ConfigurationFileProvide ? versionsConfiguration.GetVersioningSystem(versioningSystemId) : null; - private static ProductFeatures ResolveFeatures(string productId, Dictionary? featuresDto) + private static ProductFeatures ResolveFeatures(string productId, Dictionary? featuresDto) { if (featuresDto is null) return ProductFeatures.All; @@ -78,8 +82,40 @@ private static ProductFeatures ResolveFeatures(string productId, Dictionary featuresDto, string key) + { + if (!featuresDto.TryGetValue(key, out var value) || string.IsNullOrWhiteSpace(value)) + return true; + if (bool.TryParse(value, out var enabled)) + return enabled; + throw new InvalidOperationException( + $"Product '{productId}' has invalid '{key}' value '{value}'. Allowed values: true, false."); + } + + /// + /// Resolves features.release-notes into an onboarding path. Backward compatible with the + /// historical boolean flag: omitted/true mean on-release participation, false opts + /// out; the strings prestage and on-release select the path explicitly. + /// + private static ReleaseNotesPath ResolveReleaseNotesPath(string productId, Dictionary featuresDto) + { + if (!featuresDto.TryGetValue("release-notes", out var value) || string.IsNullOrWhiteSpace(value)) + return ReleaseNotesPath.OnRelease; + + if (bool.TryParse(value, out var enabled)) + return enabled ? ReleaseNotesPath.OnRelease : ReleaseNotesPath.None; + + return value.ToLowerInvariant() switch + { + "prestage" => ReleaseNotesPath.Prestage, + "on-release" => ReleaseNotesPath.OnRelease, + _ => throw new InvalidOperationException( + $"Product '{productId}' has invalid 'release-notes' value '{value}'. Allowed values: true, false, prestage, on-release.") }; } } @@ -101,6 +137,10 @@ internal sealed record ProductDto public string? Repository { get; set; } + /// + /// Feature values are strings so release-notes accepts both the historical booleans and + /// the prestage/on-release path names; parsing happens in . + /// [YamlMember(Alias = "features")] - public Dictionary? Features { get; set; } + public Dictionary? Features { get; set; } } diff --git a/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileReleaseNotesTests.cs b/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileReleaseNotesTests.cs index b541ccb920..296d3b3293 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileReleaseNotesTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/ConfigurationFileReleaseNotesTests.cs @@ -136,7 +136,7 @@ private static ProductsConfiguration CreateProductsConfiguration() { Id = "reference-only", DisplayName = "Reference Only", - Features = new ProductFeatures { PublicReference = true, ReleaseNotes = false } + Features = new ProductFeatures { PublicReference = true, ReleaseNotes = ReleaseNotesPath.None } } }; diff --git a/tests/Elastic.Documentation.Configuration.Tests/ProductFeaturesTests.cs b/tests/Elastic.Documentation.Configuration.Tests/ProductFeaturesTests.cs index 9984c43381..111e495986 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/ProductFeaturesTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/ProductFeaturesTests.cs @@ -20,7 +20,8 @@ public void ProductWithNoFeaturesKey_GetsAllFeaturesEnabled() var elasticsearch = config.Products["elasticsearch"]; elasticsearch.Features.PublicReference.Should().BeTrue(); - elasticsearch.Features.ReleaseNotes.Should().BeTrue(); + elasticsearch.Features.ReleaseNotes.Should().Be(ReleaseNotesPath.OnRelease); + elasticsearch.Features.ParticipatesInReleaseNotes.Should().BeTrue(); } [Fact] @@ -30,7 +31,7 @@ public void ProductWithPublicReferenceDisabled_HasCorrectFeatures() var docsBuilder = config.Products["docs-builder"]; docsBuilder.Features.PublicReference.Should().BeFalse(); - docsBuilder.Features.ReleaseNotes.Should().BeTrue(); + docsBuilder.Features.ReleaseNotes.Should().Be(ReleaseNotesPath.OnRelease); } [Fact] @@ -77,7 +78,8 @@ public void ProductFeatures_All_HasBothFeaturesEnabled() var all = ProductFeatures.All; all.PublicReference.Should().BeTrue(); - all.ReleaseNotes.Should().BeTrue(); + all.ReleaseNotes.Should().Be(ReleaseNotesPath.OnRelease); + all.ParticipatesInReleaseNotes.Should().BeTrue(); } [Fact] @@ -106,6 +108,83 @@ public void GetProductByRepositoryName_WorksForProductsWithDisabledFeatures() product.Id.Should().Be("docs-builder"); } + [Theory] + [InlineData("true", ReleaseNotesPath.OnRelease)] + [InlineData("false", ReleaseNotesPath.None)] + [InlineData("prestage", ReleaseNotesPath.Prestage)] + [InlineData("Prestage", ReleaseNotesPath.Prestage)] + [InlineData("on-release", ReleaseNotesPath.OnRelease)] + public void ReleaseNotesFeature_AcceptsBooleansAndPathStrings(string value, ReleaseNotesPath expected) + { + var config = ParseProducts($""" + products: + widget: + display: 'Widget' + versioning: 'stack' + features: + release-notes: {value} + """); + + config.Products["widget"].Features.ReleaseNotes.Should().Be(expected); + config.Products["widget"].Features.ParticipatesInReleaseNotes.Should().Be(expected != ReleaseNotesPath.None); + } + + [Fact] + public void ReleaseNotesFeature_OmittedInFeaturesMap_DefaultsToOnRelease() + { + var config = ParseProducts(""" + products: + widget: + display: 'Widget' + versioning: 'stack' + features: + public-reference: false + """); + + config.Products["widget"].Features.ReleaseNotes.Should().Be(ReleaseNotesPath.OnRelease); + config.Products["widget"].Features.PublicReference.Should().BeFalse(); + } + + [Fact] + public void ReleaseNotesFeature_InvalidValue_Throws() + { + var act = () => ParseProducts(""" + products: + widget: + display: 'Widget' + versioning: 'stack' + features: + release-notes: sideways + """); + + act.Should().Throw() + .WithMessage("*'release-notes' value 'sideways'*Allowed values: true, false, prestage, on-release*"); + } + + [Fact] + public void PublicReferenceFeature_InvalidValue_Throws() + { + var act = () => ParseProducts(""" + products: + widget: + display: 'Widget' + versioning: 'stack' + features: + public-reference: prestage + """); + + act.Should().Throw() + .WithMessage("*'public-reference' value 'prestage'*Allowed values: true, false*"); + } + + private static ProductsConfiguration ParseProducts(string yaml) + { + var provider = new ConfigurationFileProvider(new NullLoggerFactory(), new ConfigurationFileSystem()); + var versionsConfig = provider.CreateVersionConfiguration(); + using var reader = new StringReader(yaml); + return ProductExtensions.CreateProducts(reader, versionsConfig); + } + private static ProductsConfiguration LoadActualProductsConfiguration() { var provider = new ConfigurationFileProvider(new NullLoggerFactory(), new ConfigurationFileSystem());