diff --git a/docs/syntax/changelog.md b/docs/syntax/changelog.md index 2038eed926..9a3c704119 100644 --- a/docs/syntax/changelog.md +++ b/docs/syntax/changelog.md @@ -221,6 +221,16 @@ The CDN base URL is build configuration, not authored per page: it defaults to t Bundles are fetched **once at build startup** for every declared product, not per directive. If a declared product's registry cannot be fetched the build fails; an individual bundle that is missing from the CDN is skipped with a warning. For the full design — including the manifest format and infrastructure — see [Changelog bundle registry and CDN delivery](/development/changelog-bundle-registry.md). +##### Unreleased-version visibility [unreleased-version-visibility] + +Prestage products commit and upload their release bundles **before** release day, so the CDN can hold bundles for versions that are not yet released. CDN-mode rendering filters those out based on the build's content source: + +- On **production** (the `current` content source), a bundle whose target version is newer than the product's current release in `versions.yml` is hidden (with a hint diagnostic). +- On **staging** (the `next` content source), staged bundles remain visible, enabling pre-release review. +- Local and isolated builds render everything. + +The `versions.yml` bump on release day automatically makes staged bundles visible on production. Products without a semver versioning system (date-based targets such as `cloud-serverless`) are never filtered. + ##### Declaring CDN-backed products [declaring-cdn-backed-products] List each CDN-sourced product under `release_notes` in `docset.yml`. Every entry must reference a product ID from `products.yml` that participates in the release notes system: diff --git a/src/Elastic.Documentation.Configuration/BuildContext.cs b/src/Elastic.Documentation.Configuration/BuildContext.cs index 1d0a285bda..2c2393be33 100644 --- a/src/Elastic.Documentation.Configuration/BuildContext.cs +++ b/src/Elastic.Documentation.Configuration/BuildContext.cs @@ -54,6 +54,13 @@ public record BuildContext : IDocumentationSetContext, IDocumentationConfigurati public BuildType BuildType { get; init; } = BuildType.Isolated; + /// + /// The content source this build publishes (assembler builds only): + /// for production, for staging. Null for isolated/local + /// builds, which have no publish target and render everything. + /// + public ContentSource? ContentSource { get; init; } + // This property is used to determine if the site should be indexed by search engines public bool AllowIndexing { get; init; } diff --git a/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogBlock.cs b/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogBlock.cs index 97f2794f90..4fc81ac47f 100644 --- a/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogBlock.cs @@ -534,7 +534,7 @@ private void LoadCdnBundles(string product) private void ApplyLoadedBundles(IReadOnlyList loadedBundles) { - var filteredBundles = FilterByVersion(loadedBundles); + var filteredBundles = FilterUnreleasedVersions(FilterByVersion(loadedBundles)); // Sort by version (descending - newest first) // Supports both semver (e.g., "9.3.0") and date-based (e.g., "2025-08-05") versions @@ -554,6 +554,43 @@ private void ApplyLoadedBundles(IReadOnlyList loadedBundles) } } + /// + /// Prestage visibility filtering (release-notes onboarding RFC, B1): Prestage bundles are + /// uploaded to S3 weeks before release day, so CDN-mode rendering must not show bundles whose + /// target version the published content source has not released yet. Production publishes the + /// content source, where only versions at or below the + /// versioning system's current release render; staging publishes + /// and keeps them visible, enabling pre-release review. The versions.yml bump on release day + /// then makes staged bundles visible on production automatically. Local/isolated builds (no + /// content source) and date-based/versionless products are never filtered. + /// + private IReadOnlyList FilterUnreleasedVersions(IReadOnlyList bundles) + { + if (CdnProduct is null || Build.ContentSource != ContentSource.Current) + return bundles; + + var versioningSystem = Build.ProductsConfiguration.Products.TryGetValue(CdnProduct, out var product) + ? product.VersioningSystem + : null; + if (versioningSystem is null || versioningSystem.IsVersionless) + return bundles; + + var visible = new List(bundles.Count); + foreach (var bundle in bundles) + { + if (SemVersion.TryParse(bundle.Version, out var target) && target > versioningSystem.Current) + { + this.EmitHint( + $"Hiding changelog bundle '{CdnProduct} {bundle.Version}': it targets a version newer than the current release ({versioningSystem.Current}) and this build publishes the 'current' content source."); + continue; + } + + visible.Add(bundle); + } + + return visible; + } + /// Filters bundles by the optional :version: value; warns and renders empty when nothing matches. private IReadOnlyList FilterByVersion(IReadOnlyList bundles) { diff --git a/src/services/Elastic.Documentation.Assembler/Navigation/AssemblerDocumentationSet.cs b/src/services/Elastic.Documentation.Assembler/Navigation/AssemblerDocumentationSet.cs index 4ddc90eeba..a3c157df3d 100644 --- a/src/services/Elastic.Documentation.Assembler/Navigation/AssemblerDocumentationSet.cs +++ b/src/services/Elastic.Documentation.Assembler/Navigation/AssemblerDocumentationSet.cs @@ -79,7 +79,8 @@ IReadOnlySet availableExporters Id = env.Optimizely.Id }, CanonicalBaseUrl = new Uri("https://www.elastic.co"), // Always use the production URL. In case a page is leaked to a search engine, it should point to the production site. - BuildType = BuildType.Assembler + BuildType = BuildType.Assembler, + ContentSource = env.ContentSource }; BuildContext = buildContext; diff --git a/tests/Elastic.Markdown.Tests/Directives/ChangelogVersionVisibilityTests.cs b/tests/Elastic.Markdown.Tests/Directives/ChangelogVersionVisibilityTests.cs new file mode 100644 index 0000000000..2f1473955d --- /dev/null +++ b/tests/Elastic.Markdown.Tests/Directives/ChangelogVersionVisibilityTests.cs @@ -0,0 +1,147 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using AwesomeAssertions; +using Elastic.Documentation.Configuration.Assembler; +using Elastic.Documentation.Configuration.ReleaseNotes; +using Elastic.Documentation.Diagnostics; +using Elastic.Markdown.Myst.Directives.Changelog; + +namespace Elastic.Markdown.Tests.Directives; + +/// +/// Prestage visibility filtering (release-notes onboarding RFC, B1): CDN-mode {changelog} +/// hides bundles targeting versions newer than the versioning system's current release when the +/// build publishes the current content source (production), keeps them on next +/// (staging), and never filters local/isolated builds or products without a semver versioning +/// system. The test versions configuration pins stack current to 8.0.0. +/// +public abstract class ChangelogVersionVisibilityTestBase(ITestOutputHelper output) : DirectiveTest(output, + // language=markdown + """ + :::{changelog} + :cdn: elasticsearch + ::: + """) +{ + protected const string ReleasedTitle = "Released change"; + protected const string UnreleasedTitle = "Unreleased staged change"; + + protected override IReleaseNotesResolver GetReleaseNotesResolver() => + ChangelogCdnTestResolver.For("elasticsearch", + ("7.9.0.yaml", + // language=yaml + """ + products: + - product: elasticsearch + target: 7.9.0 + repo: elasticsearch + owner: elastic + entries: + - title: Released change + type: enhancement + products: + - product: elasticsearch + target: 7.9.0 + prs: + - "100" + """), + ("8.1.0.yaml", + // language=yaml + """ + products: + - product: elasticsearch + target: 8.1.0 + repo: elasticsearch + owner: elastic + entries: + - title: Unreleased staged change + type: enhancement + products: + - product: elasticsearch + target: 8.1.0 + prs: + - "200" + """)); +} + +public class ChangelogVisibilityOnProductionTests(ITestOutputHelper output) : ChangelogVersionVisibilityTestBase(output) +{ + protected override ContentSource? GetContentSource() => ContentSource.Current; + + [Fact] + public void HidesBundlesTargetingUnreleasedVersions() + { + Html.Should().Contain(ReleasedTitle); + Html.Should().NotContain(UnreleasedTitle, "8.1.0 is newer than the current release (8.0.0) and production publishes 'current'"); + } + + [Fact] + public void EmitsHintForHiddenBundle() => + Collector.Diagnostics.Should().Contain(d => + d.Severity == Severity.Hint && d.Message.Contains("elasticsearch 8.1.0")); +} + +public class ChangelogVisibilityOnStagingTests(ITestOutputHelper output) : ChangelogVersionVisibilityTestBase(output) +{ + protected override ContentSource? GetContentSource() => ContentSource.Next; + + [Fact] + public void ShowsUnreleasedBundlesForPreReleaseReview() + { + Html.Should().Contain(ReleasedTitle); + Html.Should().Contain(UnreleasedTitle, "staging publishes 'next' so staged bundles are reviewable before release day"); + } +} + +public class ChangelogVisibilityOnIsolatedBuildTests(ITestOutputHelper output) : ChangelogVersionVisibilityTestBase(output) +{ + // No override: isolated/local builds have no content source and render everything. + + [Fact] + public void ShowsAllBundles() + { + Html.Should().Contain(ReleasedTitle); + Html.Should().Contain(UnreleasedTitle); + } +} + +/// +/// Products without a registered semver versioning system (date-promotion products, products not in +/// products.yml) are never filtered — their targets are dates or unknown schemes, not stack versions. +/// +public class ChangelogVisibilityUnversionedProductTests(ITestOutputHelper output) : DirectiveTest(output, + // language=markdown + """ + :::{changelog} + :cdn: cdn-visibility-unversioned + ::: + """) +{ + protected override ContentSource? GetContentSource() => ContentSource.Current; + + protected override IReleaseNotesResolver GetReleaseNotesResolver() => + ChangelogCdnTestResolver.For("cdn-visibility-unversioned", + ("9.9.0.yaml", + // language=yaml + """ + products: + - product: cdn-visibility-unversioned + target: 9.9.0 + repo: widget + owner: elastic + entries: + - title: Future-looking change + type: enhancement + products: + - product: cdn-visibility-unversioned + target: 9.9.0 + prs: + - "300" + """)); + + [Fact] + public void DoesNotFilterProductsWithoutVersioningSystem() => + Html.Should().Contain("Future-looking change"); +} diff --git a/tests/Elastic.Markdown.Tests/Directives/DirectiveBaseTests.cs b/tests/Elastic.Markdown.Tests/Directives/DirectiveBaseTests.cs index 28c57c3d8d..69a1f313bd 100644 --- a/tests/Elastic.Markdown.Tests/Directives/DirectiveBaseTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/DirectiveBaseTests.cs @@ -5,6 +5,7 @@ using AwesomeAssertions; using Elastic.Documentation; using Elastic.Documentation.Configuration; +using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Configuration.ReleaseNotes; using Elastic.Markdown.IO; using Elastic.Markdown.Myst.Directives; @@ -75,7 +76,11 @@ protected DirectiveTest(ITestOutputHelper output, [LanguageInjection("markdown") var configurationContext = TestHelpers.CreateConfigurationContext(FileSystem); // ReSharper disable once VirtualMemberCallInConstructor var environment = GetEnvironment(); - var context = new BuildContext(Collector, FileSystemFactory.ScopeCurrentWorkingDirectory(FileSystem), configurationContext, environment); + // ReSharper disable once VirtualMemberCallInConstructor + var context = new BuildContext(Collector, FileSystemFactory.ScopeCurrentWorkingDirectory(FileSystem), configurationContext, environment) + { + ContentSource = GetContentSource() + }; var linkResolver = new TestCrossLinkResolver(); // ReSharper disable once VirtualMemberCallInConstructor Set = new DocumentationSet(context, logger, linkResolver, GetReleaseNotesResolver()); @@ -103,6 +108,12 @@ protected virtual void AddToFileSystem(MockFileSystem fileSystem) { } /// Override to inject a deterministic environment for env-dependent config (e.g. storybook.registry). protected virtual IEnvironmentVariables? GetEnvironment() => null; + /// + /// Override to simulate an assembler build publishing a specific content source + /// (current = production, next = staging). Null (default) mimics isolated builds. + /// + protected virtual ContentSource? GetContentSource() => null; + public virtual async ValueTask InitializeAsync() { _ = Collector.StartAsync(TestContext.Current.CancellationToken);