diff --git a/docs/cli-schema.json b/docs/cli-schema.json
index abf6eb63b..85bacd254 100644
--- a/docs/cli-schema.json
+++ b/docs/cli-schema.json
@@ -4629,6 +4629,63 @@
"summary": "Skip cloning private repositories"
}
]
+ },
+ {
+ "path": [
+ "changelog"
+ ],
+ "name": "validate-onboarding",
+ "summary": "Validate that every product registered with features.release-notes: prestage has the required onboarding files in its repository.",
+ "notes": "Probes each Prestage product\u0027s repository (resolved from products.yml: the product key\nor its repository: override) via the GitHub contents API for the changelog\nconfiguration (docs/changelog.yml or changelog.yml) and the\nchangelog-validate, changelog-submit, changelog-upload, and\nchangelog-bundle-stage workflows. Exits non-zero when anything is missing, so CI can\ngate on registration drift. Set GITHUB_TOKEN to probe private repositories.",
+ "usage": "docs-builder changelog validate-onboarding [options]",
+ "examples": [],
+ "parameters": [
+ {
+ "role": "flag",
+ "name": "owner",
+ "type": "string",
+ "required": false,
+ "summary": "GitHub owner (org) the product repositories live under.",
+ "defaultValue": "elastic"
+ },
+ {
+ "role": "flag",
+ "name": "log-level",
+ "shortName": "l",
+ "type": "enum",
+ "required": false,
+ "summary": "Minimum log level. Default: information",
+ "enumValues": [
+ "trace",
+ "debug",
+ "information",
+ "warning",
+ "error",
+ "critical",
+ "none"
+ ]
+ },
+ {
+ "role": "flag",
+ "name": "config-source",
+ "shortName": "c",
+ "type": "enum",
+ "required": false,
+ "summary": "Override the configuration source: local, remote",
+ "enumValues": [
+ "local",
+ "remote",
+ "embedded"
+ ]
+ },
+ {
+ "role": "flag",
+ "name": "skip-private-repositories",
+ "type": "boolean",
+ "required": false,
+ "summary": "Skip cloning private repositories"
+ }
+ ]
}
],
"namespaces": []
diff --git a/src/services/Elastic.Changelog/Onboarding/ChangelogOnboardingValidationService.cs b/src/services/Elastic.Changelog/Onboarding/ChangelogOnboardingValidationService.cs
new file mode 100644
index 000000000..ac28b26b3
--- /dev/null
+++ b/src/services/Elastic.Changelog/Onboarding/ChangelogOnboardingValidationService.cs
@@ -0,0 +1,139 @@
+// 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 System.Net;
+using Elastic.Changelog.GitHub;
+using Elastic.Documentation.Configuration;
+using Elastic.Documentation.Configuration.Products;
+using Elastic.Documentation.Diagnostics;
+using Elastic.Documentation.Services;
+using Microsoft.Extensions.Logging;
+
+namespace Elastic.Changelog.Onboarding;
+
+/// Arguments for validating release-notes onboarding.
+public record ValidateOnboardingArguments
+{
+ /// GitHub owner (org) the product repositories live under.
+ public string Owner { get; init; } = "elastic";
+}
+
+///
+/// Validates that every product registered with features.release-notes: prestage in
+/// products.yml actually has the scaffolding the Prestage path requires in its repository:
+/// the changelog configuration plus the entry-generation, upload, and bundle-stage workflows.
+/// A Prestage product without them would silently be skipped by the Prestage Release Orchestrator
+/// (or fail at freeze), so drift is surfaced here as a CI-gateable error.
+///
+public class ChangelogOnboardingValidationService(
+ ILoggerFactory logFactory,
+ IConfigurationContext configurationContext,
+ GitHubApiTransport? transport = null) : IService
+{
+ /// Workflow files every Prestage repository must carry (RFC onboarding steps).
+ internal static readonly string[] RequiredWorkflows =
+ [
+ ".github/workflows/changelog-validate.yml",
+ ".github/workflows/changelog-submit.yml",
+ ".github/workflows/changelog-upload.yml",
+ ".github/workflows/changelog-bundle-stage.yml"
+ ];
+
+ /// Accepted changelog configuration locations, in discovery order.
+ internal static readonly string[] ChangelogConfigCandidates =
+ [
+ "docs/changelog.yml",
+ "changelog.yml"
+ ];
+
+ private readonly ILogger _logger = logFactory.CreateLogger();
+ private readonly GitHubApiTransport _transport = transport ?? new GitHubApiTransport();
+
+ public async Task ValidateOnboardingAsync(IDiagnosticsCollector collector, ValidateOnboardingArguments args, Cancel ctx)
+ {
+ var prestageProducts = configurationContext.ProductsConfiguration.Products.Values
+ .Where(p => p.Features.ReleaseNotes == ReleaseNotesPath.Prestage)
+ .OrderBy(p => p.Id, StringComparer.Ordinal)
+ .ToList();
+
+ if (prestageProducts.Count == 0)
+ {
+ _logger.LogInformation("No products declare 'features.release-notes: prestage' in products.yml; nothing to validate.");
+ return true;
+ }
+
+ _logger.LogInformation("Validating release-notes onboarding for {Count} Prestage product(s)", prestageProducts.Count);
+
+ var valid = true;
+ foreach (var product in prestageProducts)
+ {
+ ctx.ThrowIfCancellationRequested();
+ var repo = product.Repository ?? product.Id;
+ if (!await ValidateProduct(collector, product.Id, args.Owner, repo, ctx))
+ valid = false;
+ }
+
+ return valid;
+ }
+
+ private async Task ValidateProduct(IDiagnosticsCollector collector, string productId, string owner, string repo, Cancel ctx)
+ {
+ var missing = new List();
+ foreach (var workflow in RequiredWorkflows)
+ {
+ var exists = await FileExistsAsync(collector, owner, repo, workflow, ctx);
+ if (exists == null)
+ return false;
+ if (exists == false)
+ missing.Add(workflow);
+ }
+
+ var hasConfig = false;
+ foreach (var candidate in ChangelogConfigCandidates)
+ {
+ var exists = await FileExistsAsync(collector, owner, repo, candidate, ctx);
+ if (exists == null)
+ return false;
+ if (exists == true)
+ {
+ hasConfig = true;
+ break;
+ }
+ }
+
+ if (!hasConfig)
+ missing.Add(string.Join(" or ", ChangelogConfigCandidates));
+
+ if (missing.Count > 0)
+ {
+ collector.EmitError(string.Empty,
+ $"Product '{productId}' declares 'features.release-notes: prestage' but {owner}/{repo} is missing required onboarding file(s): {string.Join(", ", missing)}. " +
+ "See the Prestage onboarding steps in the release-notes documentation, or change the product's release-notes path in products.yml.");
+ return false;
+ }
+
+ _logger.LogInformation("Product '{ProductId}' ({Owner}/{Repo}): Prestage onboarding files present", productId, owner, repo);
+ return true;
+ }
+
+ ///
+ /// Probes a repository path via the GitHub contents API. Returns null after emitting an error
+ /// for any response other than found/not-found — an unreadable repo (bad credentials, rate
+ /// limiting) must fail the validation run rather than pass it vacuously.
+ ///
+ private async Task FileExistsAsync(IDiagnosticsCollector collector, string owner, string repo, string path, Cancel ctx)
+ {
+ var url = $"https://api.github.com/repos/{Uri.EscapeDataString(owner)}/{Uri.EscapeDataString(repo)}/contents/{path}";
+ using var response = await _transport.GetAsync(url, ctx);
+ if (response.StatusCode == HttpStatusCode.OK)
+ return true;
+ if (response.StatusCode == HttpStatusCode.NotFound)
+ return false;
+
+ collector.EmitError(string.Empty,
+ $"Could not probe {owner}/{repo} for '{path}': {(int)response.StatusCode} {response.ReasonPhrase}. " +
+ "Ensure GITHUB_TOKEN is set and can read the repository.");
+ return null;
+ }
+}
diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs
index 1d4cd6df3..3cc4e0d6f 100644
--- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs
+++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs
@@ -18,6 +18,7 @@
using Elastic.Changelog.GitHub;
using Elastic.Changelog.GithubRelease;
using Elastic.Changelog.Migration;
+using Elastic.Changelog.Onboarding;
using Elastic.Changelog.Rendering;
using Elastic.Changelog.Uploading;
using Elastic.Changelog.Utilities;
@@ -1711,6 +1712,29 @@ static async (s, c, state, ct) => await s.Upload(c, state, ct)
return await serviceInvoker.InvokeAsync(ctx);
}
+ /// Validate that every product registered with features.release-notes: prestage has the required onboarding files in its repository.
+ ///
+ /// Probes each Prestage product's repository (resolved from products.yml: the product key
+ /// or its repository: override) via the GitHub contents API for the changelog
+ /// configuration (docs/changelog.yml or changelog.yml) and the
+ /// changelog-validate, changelog-submit, changelog-upload, and
+ /// changelog-bundle-stage workflows. Exits non-zero when anything is missing, so CI can
+ /// gate on registration drift. Set GITHUB_TOKEN to probe private repositories.
+ ///
+ /// GitHub owner (org) the product repositories live under.
+ /// Cancellation token
+ [NoOptionsInjection]
+ public async Task ValidateOnboarding(string owner = "elastic", CancellationToken ct = default)
+ {
+ var ctx = ct;
+ await using var serviceInvoker = new ServiceInvoker(collector);
+ var service = new ChangelogOnboardingValidationService(logFactory, configurationContext);
+ var args = new ValidateOnboardingArguments { Owner = owner };
+ serviceInvoker.AddCommand(service, args,
+ static async (s, c, state, ct) => await s.ValidateOnboardingAsync(c, state, ct));
+ return await serviceInvoker.InvokeAsync(ctx);
+ }
+
/// Resolve the link allowlist identity of the deployed changelog scrubber.
///
/// The scrubber Lambda embeds its link allowlist from config/assembler.yml at build time, so the
diff --git a/tests/Elastic.Changelog.Tests/Onboarding/OnboardingValidationTests.cs b/tests/Elastic.Changelog.Tests/Onboarding/OnboardingValidationTests.cs
new file mode 100644
index 000000000..df10babe3
--- /dev/null
+++ b/tests/Elastic.Changelog.Tests/Onboarding/OnboardingValidationTests.cs
@@ -0,0 +1,169 @@
+// 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 System.Collections.Frozen;
+using System.Net;
+using AwesomeAssertions;
+using Elastic.Changelog.GitHub;
+using Elastic.Changelog.Onboarding;
+using Elastic.Changelog.Tests.Changelogs;
+using Elastic.Documentation.Configuration;
+using Elastic.Documentation.Configuration.Products;
+using Elastic.Documentation.Diagnostics;
+using FakeItEasy;
+
+namespace Elastic.Changelog.Tests.Onboarding;
+
+///
+/// Tests for changelog validate-onboarding: every product registered as
+/// features.release-notes: prestage must carry the Prestage scaffolding in its repository.
+///
+public class OnboardingValidationTests(ITestOutputHelper output) : ChangelogTestBase(output)
+{
+ private static IConfigurationContext ContextWith(params Product[] products)
+ {
+ var map = products.ToDictionary(p => p.Id, p => p).ToFrozenDictionary();
+ var configuration = new ProductsConfiguration
+ {
+ Products = map,
+ PublicReferenceProducts = map,
+ ProductDisplayNames = products.ToDictionary(p => p.Id, p => p.DisplayName).ToFrozenDictionary()
+ };
+ var context = A.Fake();
+ _ = A.CallTo(() => context.ProductsConfiguration).Returns(configuration);
+ return context;
+ }
+
+ private static Product PrestageProduct(string id, string? repository = null) => new()
+ {
+ Id = id,
+ DisplayName = id,
+ Repository = repository ?? id,
+ Features = new ProductFeatures { PublicReference = true, ReleaseNotes = ReleaseNotesPath.Prestage }
+ };
+
+ private ChangelogOnboardingValidationService Service(IConfigurationContext context, StubHandler handler) =>
+ new(LoggerFactory, context, new GitHubApiTransport(handler, "test-token"));
+
+ /// Responds 200 for the given repo paths, 404 for everything else.
+ private static StubHandler RepoWith(string repo, params string[] existingPaths) => new(req =>
+ {
+ var path = req.RequestUri!.AbsolutePath;
+ var prefix = $"/repos/elastic/{repo}/contents/";
+ if (path.StartsWith(prefix, StringComparison.Ordinal) &&
+ existingPaths.Contains(path[prefix.Length..], StringComparer.Ordinal))
+ return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}") };
+ return new HttpResponseMessage(HttpStatusCode.NotFound);
+ });
+
+ private static readonly string[] AllScaffolding =
+ [
+ ".github/workflows/changelog-validate.yml",
+ ".github/workflows/changelog-submit.yml",
+ ".github/workflows/changelog-upload.yml",
+ ".github/workflows/changelog-bundle-stage.yml",
+ "docs/changelog.yml"
+ ];
+
+ [Fact]
+ public async Task PrestageProductWithAllFiles_Passes()
+ {
+ var handler = RepoWith("widget", AllScaffolding);
+ var service = Service(ContextWith(PrestageProduct("widget")), handler);
+
+ var result = await service.ValidateOnboardingAsync(Collector, new ValidateOnboardingArguments(), TestContext.Current.CancellationToken);
+
+ result.Should().BeTrue();
+ Collector.Errors.Should().Be(0);
+ handler.RequestedPaths.Should().Contain("/repos/elastic/widget/contents/.github/workflows/changelog-bundle-stage.yml");
+ }
+
+ [Fact]
+ public async Task PrestageProductMissingWorkflow_FailsListingTheFile()
+ {
+ var handler = RepoWith("widget",
+ ".github/workflows/changelog-validate.yml",
+ ".github/workflows/changelog-submit.yml",
+ ".github/workflows/changelog-upload.yml",
+ "docs/changelog.yml");
+ var service = Service(ContextWith(PrestageProduct("widget")), handler);
+
+ var result = await service.ValidateOnboardingAsync(Collector, new ValidateOnboardingArguments(), TestContext.Current.CancellationToken);
+
+ result.Should().BeFalse();
+ Collector.Diagnostics.Should().Contain(d =>
+ d.Severity == Severity.Error &&
+ d.Message.Contains("widget") &&
+ d.Message.Contains("changelog-bundle-stage.yml"));
+ }
+
+ [Fact]
+ public async Task RootChangelogConfig_IsAcceptedAsFallback()
+ {
+ var handler = RepoWith("widget",
+ ".github/workflows/changelog-validate.yml",
+ ".github/workflows/changelog-submit.yml",
+ ".github/workflows/changelog-upload.yml",
+ ".github/workflows/changelog-bundle-stage.yml",
+ "changelog.yml");
+ var service = Service(ContextWith(PrestageProduct("widget")), handler);
+
+ var result = await service.ValidateOnboardingAsync(Collector, new ValidateOnboardingArguments(), TestContext.Current.CancellationToken);
+
+ result.Should().BeTrue();
+ Collector.Errors.Should().Be(0);
+ }
+
+ [Fact]
+ public async Task RepositoryOverride_IsProbedInsteadOfProductId()
+ {
+ var handler = RepoWith("widget-src", AllScaffolding);
+ var service = Service(ContextWith(PrestageProduct("widget", repository: "widget-src")), handler);
+
+ var result = await service.ValidateOnboardingAsync(Collector, new ValidateOnboardingArguments(), TestContext.Current.CancellationToken);
+
+ result.Should().BeTrue();
+ handler.RequestedPaths.Should().OnlyContain(p => p.StartsWith("/repos/elastic/widget-src/", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public async Task NoPrestageProducts_PassesWithoutAnyRequest()
+ {
+ var onRelease = PrestageProduct("widget") with { Features = ProductFeatures.All };
+ var handler = RepoWith("widget");
+ var service = Service(ContextWith(onRelease), handler);
+
+ var result = await service.ValidateOnboardingAsync(Collector, new ValidateOnboardingArguments(), TestContext.Current.CancellationToken);
+
+ result.Should().BeTrue();
+ handler.RequestedPaths.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task UnreadableRepository_FailsWithCredentialsHint()
+ {
+ var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.Forbidden));
+ var service = Service(ContextWith(PrestageProduct("widget")), handler);
+
+ var result = await service.ValidateOnboardingAsync(Collector, new ValidateOnboardingArguments(), TestContext.Current.CancellationToken);
+
+ result.Should().BeFalse();
+ Collector.Diagnostics.Should().Contain(d =>
+ d.Severity == Severity.Error && d.Message.Contains("GITHUB_TOKEN"));
+ }
+
+ internal sealed class StubHandler(Func responder) : HttpMessageHandler
+ {
+ public List RequestedPaths { get; } = [];
+
+ protected override HttpResponseMessage Send(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ RequestedPaths.Add(request.RequestUri!.AbsolutePath);
+ return responder(request);
+ }
+
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) =>
+ Task.FromResult(Send(request, cancellationToken));
+ }
+}