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