Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion docs/documentation/catalog/products.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<id> }}` 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`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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).");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>Elastic.Markdown.Tests</_Parameter1>
</AssemblyAttribute>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>Elastic.Documentation.Configuration.Tests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>

<ItemGroup>
Expand Down
31 changes: 28 additions & 3 deletions src/Elastic.Documentation.Configuration/Products/Product.cs
Original file line number Diff line number Diff line change
Expand Up @@ -74,17 +74,42 @@ public record ProductLink
public string Id { get; set; } = string.Empty;
}

/// <summary>
/// The release-notes onboarding path a product follows, declared via <c>features.release-notes</c>
/// in <c>products.yml</c>. See the release-notes onboarding RFC: a product either commits its final
/// release bundles before release (<see cref="Prestage"/>) or cuts them at release time
/// (<see cref="OnRelease"/>, the default).
/// </summary>
public enum ReleaseNotesPath
{
/// <summary>Product does not participate in release notes automation (<c>release-notes: false</c>).</summary>
None,

/// <summary>Final bundles are built and uploaded at release time (<c>release-notes</c> omitted, <c>true</c>, or <c>on-release</c>).</summary>
OnRelease,

/// <summary>Release bundles are reviewed and committed to the repository before release (<c>release-notes: prestage</c>).</summary>
Prestage
}

/// <summary>Declares which docs-builder subsystems a product participates in.</summary>
public record ProductFeatures
{
/// <summary>Product can be referenced in applies_to blocks, page frontmatter, and gets display-name substitutions.</summary>
public bool PublicReference { get; init; }

/// <summary>Product participates in the changelog / release-notes system.</summary>
public bool ReleaseNotes { get; init; }
/// <summary>
/// The product's release-notes onboarding path. <see cref="ReleaseNotesPath.OnRelease"/> when
/// <c>features.release-notes</c> is omitted or <c>true</c> (preserving the historical boolean
/// participation default), <see cref="ReleaseNotesPath.None"/> when <c>false</c>.
/// </summary>
public ReleaseNotesPath ReleaseNotes { get; init; }

/// <summary>Whether the product participates in the changelog / release-notes system at all.</summary>
public bool ParticipatesInReleaseNotes => ReleaseNotes != ReleaseNotesPath.None;

/// <summary>All features enabled -- the implicit default when no <c>features</c> map is present in YAML.</summary>
public static ProductFeatures All => new() { PublicReference = true, ReleaseNotes = true };
public static ProductFeatures All => new() { PublicReference = true, ReleaseNotes = ReleaseNotesPath.OnRelease };

public static readonly FrozenSet<string> KnownKeys = FrozenSet.ToFrozenSet(["public-reference", "release-notes"], StringComparer.OrdinalIgnoreCase);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProductConfigDto>(productsFilePath.OpenText());
internal static ProductsConfiguration CreateProducts(TextReader reader, VersionsConfiguration versionsConfiguration)
{
var productsDto = ConfigurationFileProvider.Deserializer.Deserialize<ProductConfigDto>(reader);

var products = productsDto.Products.ToDictionary(
kvp => kvp.Key,
Expand Down Expand Up @@ -59,7 +63,7 @@ public static ProductsConfiguration CreateProducts(this ConfigurationFileProvide
? versionsConfiguration.GetVersioningSystem(versioningSystemId)
: null;

private static ProductFeatures ResolveFeatures(string productId, Dictionary<string, bool>? featuresDto)
private static ProductFeatures ResolveFeatures(string productId, Dictionary<string, string>? featuresDto)
{
if (featuresDto is null)
return ProductFeatures.All;
Expand All @@ -78,8 +82,40 @@ private static ProductFeatures ResolveFeatures(string productId, Dictionary<stri

return new ProductFeatures
{
PublicReference = featuresDto.GetValueOrDefault("public-reference", true),
ReleaseNotes = featuresDto.GetValueOrDefault("release-notes", true)
PublicReference = ResolveBooleanFeature(productId, featuresDto, "public-reference"),
ReleaseNotes = ResolveReleaseNotesPath(productId, featuresDto)
};
}

private static bool ResolveBooleanFeature(string productId, Dictionary<string, string> 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.");
}

/// <summary>
/// Resolves <c>features.release-notes</c> into an onboarding path. Backward compatible with the
/// historical boolean flag: omitted/<c>true</c> mean on-release participation, <c>false</c> opts
/// out; the strings <c>prestage</c> and <c>on-release</c> select the path explicitly.
/// </summary>
private static ReleaseNotesPath ResolveReleaseNotesPath(string productId, Dictionary<string, string> 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.")
};
}
}
Expand All @@ -101,6 +137,10 @@ internal sealed record ProductDto

public string? Repository { get; set; }

/// <summary>
/// Feature values are strings so <c>release-notes</c> accepts both the historical booleans and
/// the <c>prestage</c>/<c>on-release</c> path names; parsing happens in <see cref="ProductExtensions"/>.
/// </summary>
[YamlMember(Alias = "features")]
public Dictionary<string, bool>? Features { get; set; }
public Dictionary<string, string>? Features { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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]
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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<InvalidOperationException>()
.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<InvalidOperationException>()
.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());
Expand Down
Loading