Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions config/products.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
79 changes: 79 additions & 0 deletions docs/cli-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
Expand Down
11 changes: 10 additions & 1 deletion docs/cli/changelog/cmd-bundle.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
10 changes: 7 additions & 3 deletions docs/cli/changelog/cmd-evaluate-pr.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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`.
28 changes: 28 additions & 0 deletions docs/cli/changelog/cmd-validate-labels.md
Original file line number Diff line number Diff line change
@@ -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"
```
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,13 @@ public record BundlePlanResult
/// filters). Consumed by the bundle-PR action to poll for and download the scrubbed copy.
/// </summary>
public string? CdnUrl { get; init; }

/// <summary>
/// Resolved release mode: <c>gh-release</c> when <c>bundle.profiles</c> is absent in the config
/// (the action should run <c>changelog gh-release</c>); <c>bundle</c> for profile-based bundling.
/// Null in legacy plan calls that do not query the config for mode resolution.
/// </summary>
public string? Mode { get; init; }
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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 <c>pull_request</c> events.
/// </summary>
public class ChangelogLabelValidationService(
ILoggerFactory logFactory,
IConfigurationContext configurationContext,
ICoreService coreService,
IRunnerTempFileSystem fileSystem
) : IService
{
private readonly ILogger _logger = logFactory.CreateLogger<ChangelogLabelValidationService>();
private readonly ChangelogConfigurationLoader _configLoader = new(logFactory, configurationContext, fileSystem);

/// <summary>
/// Validates that the PR's labels contain a recognised type label, optionally with product labels.
/// Exits non-zero only on <c>no-label</c>; all other paths (skipped, ok) return zero.
/// </summary>
public async Task<bool> 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<bool> 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,20 @@ public async Task<bool> 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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]";

/// <summary>
/// When true, a missing changelog entry file causes evaluation to fail with
/// <see cref="PrEvaluationResult.MissingEntry"/> instead of proceeding.
/// Passed as a workflow input (<c>require-changelog-file</c>) so repos can opt into the gate
/// without editing <c>changelog.yml</c>.
/// </summary>
public bool RequireChangelogFile { get; init; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ public enum PrEvaluationResult
[Display(Name = "manually-edited")]
ManuallyEdited,

/// <summary>The <c>require-changelog-file</c> gate is on but no changelog entry file was found for this PR.</summary>
[Display(Name = "missing-entry")]
MissingEntry,

/// <summary>An error occurred during artifact preparation (e.g., generate step failed or YAML missing).</summary>
[Display(Name = "error")]
Error
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>Arguments for the changelog validate-labels command.</summary>
public record ValidateLabelsArguments
{
public required string Config { get; init; }
public required string[] PrLabels { get; init; }
}
Loading
Loading