diff --git a/config/products.yml b/config/products.yml index 6da31f010c..55e761db19 100644 --- a/config/products.yml +++ b/config/products.yml @@ -69,6 +69,16 @@ products: repository: 'docs-builder' features: public-reference: false + docs-playground-release-notes-changelogs: + display: 'Release Notes Playground (Changelogs)' + features: + public-reference: false + release-notes: prestage + docs-playground-release-notes-tagged: + display: 'Release Notes Playground (Tagged)' + features: + public-reference: false + release-notes: on-release ecs: display: 'Elastic Common Schema (ECS)' ecs-logging: diff --git a/docs/cli-schema.json b/docs/cli-schema.json index 8480811442..5ff7b2776b 100644 --- a/docs/cli-schema.json +++ b/docs/cli-schema.json @@ -3675,6 +3675,13 @@ "summary": "Remove square-bracket prefixes from the PR title", "defaultValue": "false" }, + { + "role": "flag", + "name": "require-changelog-file", + "type": "boolean", + "required": false, + "defaultValue": "false" + }, { "role": "flag", "name": "bot-name", @@ -4884,6 +4891,78 @@ } ] }, + { + "path": [ + "changelog" + ], + "name": "validate-labels", + "summary": "(CI) Validate PR labels against the changelog config without writing any files or calling the GitHub API.", + "notes": "A lightweight label-only gate intended for the pull_request event. Resolves\npivot.types, pivot.products, and rules.create skip labels against the PR\u0027s\nlabel set and exits non-zero on no-label. Does not perform title resolution, bot-loop\ndetection, or changelog-file lookup \u2014 use EvaluatePr when those are needed.\n\n\nOutputs: status (ok | no-label | skipped), type, products,\nlabel-table (shown on failure), product-label-table (shown on product failure),\nskip-labels.", + "usage": "docs-builder changelog validate-labels --config \u003Cfile\u003E --pr-labels \u003Cstring\u003E", + "examples": [], + "parameters": [ + { + "role": "flag", + "name": "config", + "type": "string", + "required": true, + "summary": "Path to the changelog.yml configuration file.", + "validations": [ + { + "kind": "fileExtensions", + "values": [ + "yml", + "yaml" + ] + } + ] + }, + { + "role": "flag", + "name": "pr-labels", + "type": "string", + "required": true, + "summary": "Comma-separated list of PR labels (use ${{ join(github.event.pull_request.labels.*.name, \u0027,\u0027) }} in actions)." + }, + { + "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" + } + ] + }, { "path": [ "changelog" diff --git a/docs/cli/changelog/cmd-bundle.md b/docs/cli/changelog/cmd-bundle.md index e8e356a6ae..d7541fb053 100644 --- a/docs/cli/changelog/cmd-bundle.md +++ b/docs/cli/changelog/cmd-bundle.md @@ -122,7 +122,16 @@ When you bundle from a PR list or GitHub release and the command is sourcing fro ## CI usage -Pass `--plan` to emit GitHub Actions step outputs (`needs_network`, `needs_github_token`, `output_path`) without generating the bundle. Use this in a planning step to decide whether subsequent steps require a GitHub token or network access. +Pass `--plan` to emit GitHub Actions step outputs without generating the bundle. Use this in a planning step to decide whether subsequent steps require a GitHub token or network access. + +| Output | Description | +|--------|-------------| +| `mode` | Resolved bundle mode: `gh-release` when no `bundle.profiles` are configured; `bundle` for profile-based bundling | +| `output_path` | Resolved output file path for the bundle | +| `needs_network` | `true` if the bundle step requires network access | +| `needs_github_token` | `true` if the bundle step requires a GitHub token | + +When `mode` is `gh-release` (no profiles configured), pass only `--config` and a version — no profile name or filter flags are needed. The plan step resolves `output_path` from `bundle.output_directory` so the bundle-upload step does not need to discover the file separately. For full configuration reference, see [Bundle changelogs](/data/release-notes/bundle.md). diff --git a/docs/cli/changelog/cmd-evaluate-pr.md b/docs/cli/changelog/cmd-evaluate-pr.md index 91ec983b34..48fb5abab3 100644 --- a/docs/cli/changelog/cmd-evaluate-pr.md +++ b/docs/cli/changelog/cmd-evaluate-pr.md @@ -10,16 +10,17 @@ Evaluate a pull request for changelog generation eligibility. Performs pre-fligh | Output | Description | |--------|-------------| -| `status` | Evaluation result: `skipped`, `manually-edited`, `no-title`, `no-label`, or `proceed` | +| `status` | Evaluation result: `skipped`, `manually-edited`, `no-title`, `no-label`, `missing-entry`, or `proceed` | | `should-generate` | `true` if `changelog add` should run | -| `should-upload` | `true` if the artifact should be uploaded | | `title` | Resolved PR title | | `description` | Release note extracted from the PR body (when `extract.release_notes` is enabled and a release note is found). Long or multi-line release notes (over 120 characters) are placed here. Passed downstream as `CHANGELOG_DESCRIPTION` for `changelog add`. | | `type` | Resolved changelog type | | `products` | Comma-separated product specs resolved from PR labels via `pivot.products` mappings | | `label-table` | Markdown table of configured label-to-type mappings | | `product-label-table` | Markdown table of configured label-to-product mappings | +| `changelog-dir` | Resolved changelog directory (from `bundle.directory` or default `docs/changelog`) | | `existing-changelog-filename` | Filename of a previously committed changelog for this PR (if any) | +| `skip-labels` | Comma-separated list of configured skip labels (from `rules.create` exclude rules) | ## Environment variables @@ -40,5 +41,8 @@ docs-builder changelog evaluate-pr \ --head-ref feature-branch \ --head-sha abc123 \ --event-action opened \ - --strip-title-prefix + --strip-title-prefix \ + --require-changelog-file ``` + +Pass `--require-changelog-file` to fail the PR (`missing-entry`) when no changelog entry file exists for the PR number. The entry file is looked up in `bundle.directory` (default `docs/changelog`). This flag is designed to be passed as a workflow input rather than hardcoded in `changelog.yml`. diff --git a/docs/cli/changelog/cmd-validate-labels.md b/docs/cli/changelog/cmd-validate-labels.md new file mode 100644 index 0000000000..c3d4ca85e0 --- /dev/null +++ b/docs/cli/changelog/cmd-validate-labels.md @@ -0,0 +1,28 @@ +## Description + +:::{note} +This command is intended for CI automation. It is used internally by the changelog GitHub Actions and is not typically invoked directly by users. +::: + +Validate that a pull request's labels contain a recognised changelog type label, and optionally a product label. Unlike `changelog evaluate-pr`, this command performs no GitHub API access, no title resolution, no bot-loop detection, and no manual-edit detection — it only resolves labels against the configured `pivot.types`, `pivot.products`, and `rules.create` settings. This makes it safe to run on `pull_request` events from forks without write permissions. + +Exits non-zero when `status` is `no-label`. All other statuses (`ok`, `skipped`) exit zero. + +## GitHub Actions outputs + +| Output | Description | +|--------|-------------| +| `status` | Validation result: `ok`, `no-label`, or `skipped` | +| `type` | Resolved changelog type (when `ok`) | +| `products` | Comma-separated product specs resolved from PR labels (when resolved) | +| `label-table` | Markdown table of configured label-to-type mappings (when `no-label`) | +| `product-label-table` | Markdown table of configured label-to-product mappings (when `no-label` due to missing product) | +| `skip-labels` | Comma-separated list of configured skip labels (from `rules.create` exclude rules) | + +## Examples + +```sh +docs-builder changelog validate-labels \ + --config docs/changelog.yml \ + --pr-labels "enhancement,Team:Core" +``` diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs index 3ff7589df6..ae8a22e638 100644 --- a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs +++ b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs @@ -158,6 +158,13 @@ public record BundlePlanResult /// filters). Consumed by the bundle-PR action to poll for and download the scrubbed copy. /// public string? CdnUrl { get; init; } + + /// + /// Resolved release mode: gh-release when bundle.profiles is absent in the config + /// (the action should run changelog gh-release); bundle for profile-based bundling. + /// Null in legacy plan calls that do not query the config for mode resolution. + /// + public string? Mode { get; init; } } /// diff --git a/src/services/Elastic.Changelog/Evaluation/ChangelogLabelValidationService.cs b/src/services/Elastic.Changelog/Evaluation/ChangelogLabelValidationService.cs new file mode 100644 index 0000000000..7bdbf4a0db --- /dev/null +++ b/src/services/Elastic.Changelog/Evaluation/ChangelogLabelValidationService.cs @@ -0,0 +1,132 @@ +// 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 Actions.Core.Services; +using Elastic.Changelog.Creation; +using Elastic.Changelog.Utilities; +using Elastic.Documentation.Configuration; +using Elastic.Documentation.Configuration.Changelog; +using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.FileSystems; +using Elastic.Documentation.Services; +using Microsoft.Extensions.Logging; + +namespace Elastic.Changelog.Evaluation; + +/// +/// Service implementing the changelog validate-labels CI command. +/// Validates only label/type/product resolution — no GitHub API access, no title, no entry-pool lookup. +/// Suitable as a label-only gate on pull_request events. +/// +public class ChangelogLabelValidationService( + ILoggerFactory logFactory, + IConfigurationContext configurationContext, + ICoreService coreService, + IRunnerTempFileSystem fileSystem +) : IService +{ + private readonly ILogger _logger = logFactory.CreateLogger(); + private readonly ChangelogConfigurationLoader _configLoader = new(logFactory, configurationContext, fileSystem); + + /// + /// Validates that the PR's labels contain a recognised type label, optionally with product labels. + /// Exits non-zero only on no-label; all other paths (skipped, ok) return zero. + /// + public async Task ValidateLabels(IDiagnosticsCollector collector, ValidateLabelsArguments input, Cancel ctx) + { + var config = await _configLoader.LoadChangelogConfiguration(collector, input.Config, ctx) ?? ChangelogConfiguration.Default; + + // Label-based skip check: all products blocked → skipped + var skipLabels = ChangelogPrEvaluationService.CollectExcludeLabels(config.Rules?.Create); + if (PrInfoProcessor.AreAllProductsBlocked(input.PrLabels, config.Rules?.Create)) + { + _logger.LogInformation("All products blocked by label rules; skipping"); + return await SetOutputs("skipped", skipLabels: skipLabels); + } + + // Resolve type + string? resolvedType = null; + if (config.LabelToType is { Count: > 0 }) + resolvedType = PrInfoProcessor.MapLabelsToType(input.PrLabels, config.LabelToType); + + // Resolve products + string? resolvedProducts = null; + string? productLabelTable = null; + if (config.LabelToProducts is { Count: > 0 } labelToProducts) + { + var products = PrInfoProcessor.MapLabelsToProducts(input.PrLabels, labelToProducts); + if (products.Count > 0) + { + resolvedProducts = ProductArgument.FormatProductSpecs(products); + } + else + { + var distinctSpecs = labelToProducts.Values.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + if (distinctSpecs.Count == 1) + resolvedProducts = ProductArgument.FormatProductSpecs(ProductArgument.ParseProductSpecs(distinctSpecs[0])); + else + productLabelTable = ChangelogPrEvaluationService.BuildProductLabelTable(labelToProducts); + } + } + + if (resolvedType == null) + { + _logger.LogInformation("No type label found on PR"); + collector.EmitError( + string.Empty, + "No matching changelog type label found on this PR. Add a label from your changelog.yml pivot.types, or a skip label." + ); + _ = await SetOutputs( + "no-label", + labelTable: ChangelogPrEvaluationService.BuildLabelTable(config.LabelToType), + productLabelTable: productLabelTable, + skipLabels: skipLabels + ); + return false; + } + + if (productLabelTable != null && (config.ProductsConfiguration?.Default is null or { Count: 0 })) + { + _logger.LogInformation("Multiple products configured but no matching product label on PR"); + collector.EmitError( + string.Empty, + "No matching product label found on this PR. Add a label from your changelog.yml pivot.products." + ); + _ = await SetOutputs("no-label", productLabelTable: productLabelTable, skipLabels: skipLabels); + return false; + } + + _logger.LogInformation("Label validation complete: type={Type}, products={Products}", resolvedType, resolvedProducts); + return await SetOutputs("ok", type: resolvedType, products: resolvedProducts, skipLabels: skipLabels); + } + + private async Task SetOutputs( + string status, + string? type = null, + string? products = null, + string? labelTable = null, + string? productLabelTable = null, + string? skipLabels = null + ) + { + await coreService.SetOutputAsync("status", status); + if (type != null) + await coreService.SetOutputAsync("type", OutputSanitizer.SanitizeForOutput(type, OutputSanitizer.TypeMaxLength)); + if (products != null) + await coreService.SetOutputAsync("products", OutputSanitizer.SanitizeForOutput(products, OutputSanitizer.LabelsMaxLength)); + if (labelTable != null) + await coreService.SetOutputAsync( + "label-table", + OutputSanitizer.SanitizeForOutput(labelTable, OutputSanitizer.LabelTableMaxLength) + ); + if (productLabelTable != null) + await coreService.SetOutputAsync( + "product-label-table", + OutputSanitizer.SanitizeForOutput(productLabelTable, OutputSanitizer.LabelTableMaxLength) + ); + if (skipLabels != null) + await coreService.SetOutputAsync("skip-labels", OutputSanitizer.SanitizeForOutput(skipLabels, OutputSanitizer.LabelsMaxLength)); + return true; + } +} diff --git a/src/services/Elastic.Changelog/Evaluation/ChangelogPrEvaluationService.cs b/src/services/Elastic.Changelog/Evaluation/ChangelogPrEvaluationService.cs index 113530f4ff..a5e54ad412 100644 --- a/src/services/Elastic.Changelog/Evaluation/ChangelogPrEvaluationService.cs +++ b/src/services/Elastic.Changelog/Evaluation/ChangelogPrEvaluationService.cs @@ -179,6 +179,20 @@ public async Task EvaluatePr(IDiagnosticsCollector collector, EvaluatePrAr return false; } + // Entry-required gate: fail when the flag is set and no file exists for this PR + if (input.RequireChangelogFile && existingFilename == null) + { + var expectedPath = $"{changelogDir}/{input.PrNumber}.yaml"; + _logger.LogInformation("Missing changelog file for PR #{PrNumber}; require-changelog-file is set", input.PrNumber); + collector.EmitError( + string.Empty, + $"No changelog entry file found for PR #{input.PrNumber}. " + $"Expected: {expectedPath}. " + + "Add a changelog entry file to the PR or disable the require-changelog-file gate." + ); + _ = await SetOutputs(PrEvaluationResult.MissingEntry, changelogDir: changelogDir); + return false; + } + _logger.LogInformation( "PR evaluation complete: title={Title}, type={Type}, products={Products}, existingFile={File}", title, diff --git a/src/services/Elastic.Changelog/Evaluation/EvaluatePrArguments.cs b/src/services/Elastic.Changelog/Evaluation/EvaluatePrArguments.cs index ef4ed059c2..c911cac569 100644 --- a/src/services/Elastic.Changelog/Evaluation/EvaluatePrArguments.cs +++ b/src/services/Elastic.Changelog/Evaluation/EvaluatePrArguments.cs @@ -21,4 +21,12 @@ public record EvaluatePrArguments public bool BodyChanged { get; init; } public bool StripTitlePrefix { get; init; } public string BotName { get; init; } = "github-actions[bot]"; + + /// + /// When true, a missing changelog entry file causes evaluation to fail with + /// instead of proceeding. + /// Passed as a workflow input (require-changelog-file) so repos can opt into the gate + /// without editing changelog.yml. + /// + public bool RequireChangelogFile { get; init; } } diff --git a/src/services/Elastic.Changelog/Evaluation/PrEvaluationResult.cs b/src/services/Elastic.Changelog/Evaluation/PrEvaluationResult.cs index ef14f67492..d658c87d92 100644 --- a/src/services/Elastic.Changelog/Evaluation/PrEvaluationResult.cs +++ b/src/services/Elastic.Changelog/Evaluation/PrEvaluationResult.cs @@ -31,6 +31,10 @@ public enum PrEvaluationResult [Display(Name = "manually-edited")] ManuallyEdited, + /// The require-changelog-file gate is on but no changelog entry file was found for this PR. + [Display(Name = "missing-entry")] + MissingEntry, + /// An error occurred during artifact preparation (e.g., generate step failed or YAML missing). [Display(Name = "error")] Error diff --git a/src/services/Elastic.Changelog/Evaluation/ValidateLabelsArguments.cs b/src/services/Elastic.Changelog/Evaluation/ValidateLabelsArguments.cs new file mode 100644 index 0000000000..d4acbbf2b7 --- /dev/null +++ b/src/services/Elastic.Changelog/Evaluation/ValidateLabelsArguments.cs @@ -0,0 +1,12 @@ +// 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 + +namespace Elastic.Changelog.Evaluation; + +/// Arguments for the changelog validate-labels command. +public record ValidateLabelsArguments +{ + public required string Config { get; init; } + public required string[] PrLabels { get; init; } +} diff --git a/src/services/Elastic.Changelog/Onboarding/ChangelogOnboardingValidationService.cs b/src/services/Elastic.Changelog/Onboarding/ChangelogOnboardingValidationService.cs index 818fe60add..d7d93bb34f 100644 --- a/src/services/Elastic.Changelog/Onboarding/ChangelogOnboardingValidationService.cs +++ b/src/services/Elastic.Changelog/Onboarding/ChangelogOnboardingValidationService.cs @@ -20,11 +20,9 @@ public record ValidateOnboardingArguments } /// -/// 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. +/// Validates that every product registered with features.release-notes: prestage or +/// on-release in products.yml has the required onboarding files in its repository. +/// A product without them would silently be skipped or fail at bundle time. /// public class ChangelogOnboardingValidationService( ILoggerFactory logFactory, @@ -32,8 +30,13 @@ public class ChangelogOnboardingValidationService( GitHubApiTransport? transport = null ) : IService { - /// Workflow files every Prestage repository must carry (RFC onboarding steps). - internal static readonly string[] RequiredWorkflows = + /// + /// Workflow files a Prestage repository must carry. Covers both the legacy + /// changelog-*.yml callers (existing onboarded repos) and the new + /// release-notes.yml shape introduced by the shared workflow consolidation. + /// Legacy names are checked first; the new names are the forward target. + /// + internal static readonly string[] RequiredWorkflowsPrestage = [ ".github/workflows/changelog-validate.yml", ".github/workflows/changelog-submit.yml", @@ -41,46 +44,88 @@ public class ChangelogOnboardingValidationService( ".github/workflows/changelog-bundle-stage.yml" ]; + /// + /// Workflow files a Prestage repository must carry using the new shared-workflow shape. + /// Checked when the legacy files are absent (i.e. the repo has been migrated). + /// + internal static readonly string[] RequiredWorkflowsPrestageNew = + [ + ".github/workflows/release-notes.yml", + ".github/workflows/release-notes-changelog-file.yml", + ".github/workflows/changelog-bundle-stage.yml" + ]; + + /// Workflow files an On-release repository must carry. + internal static readonly string[] RequiredWorkflowsOnRelease = [".github/workflows/release-notes.yml"]; + /// Accepted changelog configuration locations, in discovery order. internal static readonly string[] ChangelogConfigCandidates = ["docs/changelog.yml", "changelog.yml"]; + // Keep the legacy property name for test compatibility + internal static string[] RequiredWorkflows => RequiredWorkflowsPrestage; + 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 + var managedProducts = configurationContext .ProductsConfiguration .Products .Values - .Where(p => p.Features.ReleaseNotes == ReleaseNotesPath.Prestage) + .Where(p => p.Features.ReleaseNotes is ReleaseNotesPath.Prestage or ReleaseNotesPath.OnRelease) .OrderBy(p => p.Id, StringComparer.Ordinal) .ToList(); - if (prestageProducts.Count == 0) + if (managedProducts.Count == 0) { - _logger.LogInformation("No products declare 'features.release-notes: prestage' in products.yml; nothing to validate."); + _logger.LogInformation( + "No products declare 'features.release-notes: prestage' or 'on-release' in products.yml; nothing to validate." + ); return true; } - _logger.LogInformation("Validating release-notes onboarding for {Count} Prestage product(s)", prestageProducts.Count); + _logger.LogInformation("Validating release-notes onboarding for {Count} product(s)", managedProducts.Count); var valid = true; - foreach (var product in prestageProducts) + foreach (var product in managedProducts) { ctx.ThrowIfCancellationRequested(); var repo = product.Repository ?? product.Id; - if (!await ValidateProduct(collector, product.Id, args.Owner, repo, ctx)) + if (!await ValidateProduct(collector, product.Id, product.Features.ReleaseNotes, args.Owner, repo, ctx)) valid = false; } return valid; } - private async Task ValidateProduct(IDiagnosticsCollector collector, string productId, string owner, string repo, Cancel ctx) + private async Task ValidateProduct( + IDiagnosticsCollector collector, + string productId, + ReleaseNotesPath path, + string owner, + string repo, + Cancel ctx + ) { var missing = new List(); - foreach (var workflow in RequiredWorkflows) + + // Choose the workflow set to check based on path and whether legacy or new shape is present + string[] workflowsToCheck; + if (path == ReleaseNotesPath.OnRelease) + { + workflowsToCheck = RequiredWorkflowsOnRelease; + } + else + { + // Prestage: check new shape first; fall back to legacy names if legacy names are present + var legacyPrimaryExists = await FileExistsAsync(collector, owner, repo, RequiredWorkflowsPrestage[0], ctx); + if (legacyPrimaryExists == null) + return false; // probe error already emitted + workflowsToCheck = legacyPrimaryExists == true ? RequiredWorkflowsPrestage : RequiredWorkflowsPrestageNew; + } + + foreach (var workflow in workflowsToCheck) { var exists = await FileExistsAsync(collector, owner, repo, workflow, ctx); if (exists == null) @@ -107,15 +152,16 @@ private async Task ValidateProduct(IDiagnosticsCollector collector, string if (missing.Count > 0) { + var pathLabel = path == ReleaseNotesPath.Prestage ? "prestage" : "on-release"; 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." + $"Product '{productId}' declares 'features.release-notes: {pathLabel}' but {owner}/{repo} is missing required onboarding file(s): {string.Join(", ", missing)}. " + + "See the release-notes onboarding 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); + _logger.LogInformation("Product '{ProductId}' ({Owner}/{Repo}): {Path} onboarding files present", productId, owner, repo, path); return true; } diff --git a/src/services/Elastic.Changelog/Uploading/ChangelogUploadService.cs b/src/services/Elastic.Changelog/Uploading/ChangelogUploadService.cs index 1ab9a95b3b..0f39047c7f 100644 --- a/src/services/Elastic.Changelog/Uploading/ChangelogUploadService.cs +++ b/src/services/Elastic.Changelog/Uploading/ChangelogUploadService.cs @@ -20,7 +20,16 @@ namespace Elastic.Changelog.Uploading; public enum ArtifactType { Changelog, - Bundle + Bundle, + + /// + /// Amend sidecars only: files matching *.amend-{N}.yaml|yml in the bundle output directory. + /// The Lambda-reserved .amend-notes suffix is excluded. Keyed identically to + /// (product list comes from the sidecar, falling back to the parent bundle). + /// Use this on push to sync manual post-release overrides from main without + /// accidentally overwriting a freshly-published parent bundle. + /// + Amend } public enum UploadTargetKind @@ -89,7 +98,7 @@ public async Task Upload(IDiagnosticsCollector collector, ChangelogUploadA return true; } - var directory = args.ArtifactType == ArtifactType.Bundle + var directory = args.ArtifactType is ArtifactType.Bundle or ArtifactType.Amend ? await ResolveBundleDirectory(collector, args, ctx) : await ResolveChangelogDirectory(collector, args, ctx); @@ -102,9 +111,12 @@ public async Task Upload(IDiagnosticsCollector collector, ChangelogUploadA return true; } - var targets = args.ArtifactType == ArtifactType.Bundle - ? DiscoverBundleUploadTargets(collector, directory) - : DiscoverUploadTargets(collector, directory, args.Owner, args.Repo, args.Branch); + var targets = args.ArtifactType switch + { + ArtifactType.Bundle => DiscoverBundleUploadTargets(collector, directory), + ArtifactType.Amend => DiscoverAmendUploadTargets(collector, directory), + _ => DiscoverUploadTargets(collector, directory, args.Owner, args.Repo, args.Branch) + }; // Entry uploads abort (rather than no-op) when the repo cannot be resolved: the keys would be // unscoped and a silent skip would look like "nothing to upload". @@ -276,6 +288,72 @@ internal static (string CanonicalFileName, IReadOnlyList<(string FileName, strin return (canonicalFileName, markers); } + /// + /// Discovers numbered amend sidecars (*.amend-{N}.yaml|yml) in . + /// The Lambda-reserved .amend-notes.yaml sidecar is excluded; only user-authored amends + /// (those with a positive numeric suffix) are returned. Each sidecar is keyed identically to a + /// parent bundle: product list comes from the sidecar, falling back to the sibling parent bundle + /// file when the sidecar predates the products-copy feature. + /// + internal IReadOnlyList DiscoverAmendUploadTargets(IDiagnosticsCollector collector, string bundleDir) + { + var rootDir = _fileSystem.DirectoryInfo.New(bundleDir); + + var yamlFiles = _fileSystem + .Directory + .GetFiles(bundleDir, "*.yaml", SearchOption.TopDirectoryOnly) + .Concat(_fileSystem.Directory.GetFiles(bundleDir, "*.yml", SearchOption.TopDirectoryOnly)) + .ToList(); + + var targets = new List(); + + foreach (var filePath in yamlFiles) + { + // Only numbered amend sidecars; skip parent bundles and the Lambda-reserved .amend-notes sidecar + if (!BundleAmendMerger.IsAmendFile(filePath)) + continue; + if (BundleAmendMerger.GetAmendFileNumber(filePath) <= 0) + continue; + + var fileInfo = _fileSystem.FileInfo.New(filePath); + if (SymlinkValidator.ValidateFileAccess(fileInfo, rootDir) is { } accessError) + { + collector.EmitWarning(filePath, $"Skipping: {accessError}"); + continue; + } + + var products = ReadProductsFromBundle(filePath); + if (products.Count == 0) + { + products = ReadProductsFromParentBundle(filePath); + if (products.Count == 0) + { + collector.EmitWarning( + filePath, + "Amend bundle declares no products and its parent bundle is missing or has none; " + + "skipping upload. Re-create the amend with a current docs-builder so it carries the parent's products." + ); + continue; + } + } + + var fileName = _fileSystem.Path.GetFileName(filePath); + foreach (var product in products) + { + if (!ChangelogKeys.IsValidProduct(product)) + { + collector.EmitWarning(filePath, $"Skipping invalid product name \"{product}\" (must match [a-zA-Z0-9_-]+)"); + continue; + } + + var s3Key = ChangelogKeys.BundleFileKey(product, fileName); + targets.Add(new UploadTarget(filePath, s3Key)); + } + } + + return targets; + } + internal IReadOnlyList DiscoverBundleUploadTargets(IDiagnosticsCollector collector, string bundleDir) { var rootDir = _fileSystem.DirectoryInfo.New(bundleDir); diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index a7c67c273d..9742d84fdd 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -985,6 +985,39 @@ public async Task Bundle( if (specifiedFilters.Count == 0) { + // --plan with no filters and no profile: auto-resolve the release mode from the config. + // If the config has no profiles → gh-release mode; otherwise the caller must name a profile. + if (plan) + { + var bundleConfigLoader = new ChangelogConfigurationLoader(logFactory, configurationContext, _fileSystem); + var bundleConfig = await bundleConfigLoader.LoadChangelogConfiguration(collector, config?.FullName, ctx); + + var profiles = bundleConfig?.Bundle?.Profiles; + if (profiles is { Count: > 0 }) + { + var profileNames = string.Join(", ", profiles.Keys); + collector.EmitError( + string.Empty, + $"--plan without a profile: the config has {profiles.Count} profile(s) ({profileNames}). " + + "Pass the profile name as the first argument (e.g. 'bundle my-profile 9.2.0 --plan')." + ); + _ = collector.StartAsync(ctx); + await collector.WaitForDrain(); + await collector.StopAsync(ctx); + return 1; + } + + // No profiles → gh-release mode. Resolve the output path the same way gh-release would. + var ghReleaseOutput = bundleConfig?.Bundle?.OutputDirectory ?? bundleConfig?.Bundle?.Directory; + + await githubActionsService.SetOutputAsync("mode", "gh-release"); + await githubActionsService.SetOutputAsync("needs_network", "true"); + await githubActionsService.SetOutputAsync("needs_github_token", "true"); + if (ghReleaseOutput != null) + await githubActionsService.SetOutputAsync("output_path", ghReleaseOutput); + return 0; + } + collector.EmitError( string.Empty, "At least one filter option must be specified: --all, --input-products, --prs, --issues, --report, --files, --start-git-ref/--end-git-ref, or use a profile (e.g., 'bundle elasticsearch-release 9.2.0')" @@ -1120,6 +1153,7 @@ public async Task Bundle( if (planResult == null) return 1; + await githubActionsService.SetOutputAsync("mode", planResult.Mode ?? "bundle"); await githubActionsService.SetOutputAsync("needs_network", planResult.NeedsNetwork ? "true" : "false"); await githubActionsService.SetOutputAsync("needs_github_token", planResult.NeedsGithubToken ? "true" : "false"); if (planResult.OutputPath != null) @@ -1549,13 +1583,15 @@ public async Task GhRelease( var ctx = ct; await using var serviceInvoker = new ServiceInvoker(collector); - // --output CLI > bundle.directory config > ./changelogs (service default) + // --output CLI > bundle.output_directory > bundle.directory > ./changelogs (service default) var bundleConfig = await new ChangelogConfigurationLoader(logFactory, configurationContext, _fileSystem).LoadChangelogConfiguration( collector, config?.FullName, ctx ); - var resolvedOutput = !string.IsNullOrWhiteSpace(output) ? output : bundleConfig?.Bundle?.Directory; + var resolvedOutput = !string.IsNullOrWhiteSpace(output) + ? output + : (bundleConfig?.Bundle?.OutputDirectory ?? bundleConfig?.Bundle?.Directory); IGitHubReleaseService releaseService = new GitHubReleaseService(logFactory); IGitHubPrService prService = new GitHubPrService(logFactory); @@ -1691,6 +1727,7 @@ public async Task EvaluatePr( bool titleChanged = false, bool bodyChanged = false, bool stripTitlePrefix = false, + bool requireChangelogFile = false, string botName = "github-actions[bot]", CancellationToken ct = default ) @@ -1722,6 +1759,7 @@ public async Task EvaluatePr( TitleChanged = titleChanged, BodyChanged = bodyChanged, StripTitlePrefix = stripTitlePrefix, + RequireChangelogFile = requireChangelogFile, BotName = botName }; @@ -1730,6 +1768,45 @@ public async Task EvaluatePr( return await serviceInvoker.InvokeAsync(ctx); } + /// (CI) Validate PR labels against the changelog config without writing any files or calling the GitHub API. + /// + /// A lightweight label-only gate intended for the pull_request event. Resolves + /// pivot.types, pivot.products, and rules.create skip labels against the PR's + /// label set and exits non-zero on no-label. Does not perform title resolution, bot-loop + /// detection, or changelog-file lookup — use when those are needed. + /// + /// + /// Outputs: status (ok | no-label | skipped), type, products, + /// label-table (shown on failure), product-label-table (shown on product failure), + /// skip-labels. + /// + /// + /// Path to the changelog.yml configuration file. + /// Comma-separated list of PR labels (use ${{ join(github.event.pull_request.labels.*.name, ',') }} in actions). + /// Cancellation token + [NoOptionsInjection] + public async Task ValidateLabels( + [FileExtensions(Extensions = "yml,yaml")] FileInfo config, + string prLabels, + CancellationToken ct = default + ) + { + var ctx = ct; + await using var serviceInvoker = new ServiceInvoker(collector); + + var fileSystem = RunnerTempFileSystem.ForEvaluatePr(environmentVariables); + var service = new ChangelogLabelValidationService(logFactory, configurationContext, githubActionsService, fileSystem); + + var args = new ValidateLabelsArguments + { + Config = config.FullName, + PrLabels = prLabels.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + }; + + serviceInvoker.AddCommand(service, args, static async (s, collector, state, ctx) => await s.ValidateLabels(collector, state, ctx)); + return await serviceInvoker.InvokeAsync(ctx); + } + /// (CI) Package changelog artifact for cross-workflow transfer. /// /// Resolves final status from evaluate-pr + changelog add outcomes, copies generated YAML, @@ -1924,9 +2001,23 @@ public async Task Upload( ) { var ctx = ct; - if (!Enum.TryParse(artifactType, ignoreCase: true, out var parsedArtifactType)) + + // Accept a comma-separated list of artifact types (e.g. "changelog,amend") + var artifactTypeList = artifactType.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var parsedArtifactTypes = new List(artifactTypeList.Length); + foreach (var typeStr in artifactTypeList) { - collector.EmitError(string.Empty, $"Invalid artifact type '{artifactType}'. Valid values: changelog, bundle"); + if (!Enum.TryParse(typeStr, ignoreCase: true, out var parsed)) + { + collector.EmitError(string.Empty, $"Invalid artifact type '{typeStr}'. Valid values: changelog, bundle, amend"); + return 1; + } + parsedArtifactTypes.Add(parsed); + } + + if (parsedArtifactTypes.Count == 0) + { + collector.EmitError(string.Empty, "--artifact-type must not be empty"); return 1; } @@ -1959,19 +2050,25 @@ public async Task Upload( await using var serviceInvoker = new ServiceInvoker(collector); var service = new ChangelogUploadService(logFactory, _fileSystem, configurationContext); - var args = new ChangelogUploadArguments + + // Run one upload per requested artifact type; all failures are collected + foreach (var parsedArtifactType in parsedArtifactTypes) { - ArtifactType = parsedArtifactType, - Target = parsedTarget, - S3BucketName = s3BucketName, - Config = resolvedConfig, - Directory = resolvedDirectory, - Repo = resolvedRepo, - Owner = resolvedOwner, - Branch = resolvedBranch, - SkipEtagCheck = skipEtagCheck - }; - serviceInvoker.AddCommand(service, args, static async (s, c, state, ct) => await s.Upload(c, state, ct)); + var args = new ChangelogUploadArguments + { + ArtifactType = parsedArtifactType, + Target = parsedTarget, + S3BucketName = s3BucketName, + Config = resolvedConfig, + Directory = resolvedDirectory, + Repo = resolvedRepo, + Owner = resolvedOwner, + Branch = resolvedBranch, + SkipEtagCheck = skipEtagCheck + }; + serviceInvoker.AddCommand(service, args, static async (s, c, state, ct) => await s.Upload(c, state, ct)); + } + return await serviceInvoker.InvokeAsync(ctx); } diff --git a/tests/Elastic.Changelog.Tests/Onboarding/OnboardingValidationTests.cs b/tests/Elastic.Changelog.Tests/Onboarding/OnboardingValidationTests.cs index 4360759b9d..75ccc96898 100644 --- a/tests/Elastic.Changelog.Tests/Onboarding/OnboardingValidationTests.cs +++ b/tests/Elastic.Changelog.Tests/Onboarding/OnboardingValidationTests.cs @@ -149,11 +149,14 @@ public async Task RepositoryOverride_IsProbedInsteadOfProductId() } [Fact] - public async Task NoPrestageProducts_PassesWithoutAnyRequest() + public async Task NoManagedProducts_PassesWithoutAnyRequest() { - var onRelease = PrestageProduct("widget") with { Features = ProductFeatures.All }; + var unmanaged = PrestageProduct("widget") with + { + Features = new ProductFeatures { PublicReference = true, ReleaseNotes = ReleaseNotesPath.None } + }; var handler = RepoWith("widget"); - var service = Service(ContextWith(onRelease), handler); + var service = Service(ContextWith(unmanaged), handler); var result = await service.ValidateOnboardingAsync( Collector, @@ -165,6 +168,44 @@ public async Task NoPrestageProducts_PassesWithoutAnyRequest() handler.RequestedPaths.Should().BeEmpty(); } + [Fact] + public async Task OnReleaseProductWithWorkflow_Passes() + { + var product = PrestageProduct("widget") with { Features = ProductFeatures.All }; + var handler = RepoWith("widget", ".github/workflows/release-notes.yml", "docs/changelog.yml"); + var service = Service(ContextWith(product), 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/release-notes.yml"); + } + + [Fact] + public async Task OnReleaseProductMissingWorkflow_FailsListingTheFile() + { + var product = PrestageProduct("widget") with { Features = ProductFeatures.All }; + var handler = RepoWith("widget", "docs/changelog.yml"); + var service = Service(ContextWith(product), 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("release-notes.yml")); + } + [Fact] public async Task UnreadableRepository_FailsWithCredentialsHint() {