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);