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()
{