From 7ab6b0556ac6a01e102f827d8db3f92dd1acaa26 Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Thu, 13 Aug 2026 06:56:47 -0300 Subject: [PATCH 1/2] Add changelog validate-onboarding for Prestage registration drift A product registered as features.release-notes: prestage without the Prestage scaffolding in its repository would silently be skipped by the Prestage Release Orchestrator or fail at feature freeze. The new command probes each Prestage product's repository (contents API) for the changelog configuration and the validate/submit/upload/bundle-stage workflows, exiting non-zero on drift so CI can gate on it (RFC B4, docs-eng-team#698). Co-Authored-By: Claude Sonnet 4.6 (1M context) --- docs/cli-schema.json | 57 ++++++ .../ChangelogOnboardingValidationService.cs | 139 ++++++++++++++ .../docs-builder/Commands/ChangelogCommand.cs | 24 +++ .../Onboarding/OnboardingValidationTests.cs | 169 ++++++++++++++++++ 4 files changed, 389 insertions(+) create mode 100644 src/services/Elastic.Changelog/Onboarding/ChangelogOnboardingValidationService.cs create mode 100644 tests/Elastic.Changelog.Tests/Onboarding/OnboardingValidationTests.cs 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 409057eb2..fccc46bed 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; @@ -1710,6 +1711,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)); + } +} From 8eade62df0995cf6f5533391a4b2e9bf6e19d15f Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Thu, 13 Aug 2026 14:28:25 -0300 Subject: [PATCH 2/2] Merge main; adapt ProductFeaturesTests to ConfigurationFileProvider's IAppDataFileSystem signature --- .../ProductFeaturesTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Elastic.Documentation.Configuration.Tests/ProductFeaturesTests.cs b/tests/Elastic.Documentation.Configuration.Tests/ProductFeaturesTests.cs index 2b4b24149..111e49598 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/ProductFeaturesTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/ProductFeaturesTests.cs @@ -179,7 +179,7 @@ public void PublicReferenceFeature_InvalidValue_Throws() private static ProductsConfiguration ParseProducts(string yaml) { - var provider = new ConfigurationFileProvider(new NullLoggerFactory(), new FileSystem()); + var provider = new ConfigurationFileProvider(new NullLoggerFactory(), new ConfigurationFileSystem()); var versionsConfig = provider.CreateVersionConfiguration(); using var reader = new StringReader(yaml); return ProductExtensions.CreateProducts(reader, versionsConfig);