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
6 changes: 6 additions & 0 deletions config/changelog.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,9 @@ bundle:
# repo: elasticsearch
# Optional: default GitHub owner applied to all profiles that do not specify their own.
# owner: elastic
# Optional: control auto-population of release-date for all profiles by default.
# When true (default), auto-populate release dates. Profiles can override this setting.
# release_dates: true

# Named bundle profiles for different release scenarios.
# Profiles can be used with both 'changelog bundle' and 'changelog remove':
Expand All @@ -259,6 +262,9 @@ bundle:
# # - Bug fixes and stability enhancements
# #
# # Download the release binaries: https://github.com/{owner}/{repo}/releases/tag/v{version}
# # Optional: control auto-population of release-date for this profile.
# # When true (default), auto-populate release dates. When false, equivalent to --no-release-date.
# # release_dates: true
# Example: GitHub release profile (fetches PR list directly from a GitHub release)
# Use when you want to bundle or remove changelogs based on a published GitHub release.
# elasticsearch-gh-release:
Expand Down
12 changes: 12 additions & 0 deletions docs/cli/changelog/bundle.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,18 @@ The `--input-products` option determines which changelog files are gathered for
: This value replaces information that would otherwise be derived from changelogs.
: When `rules.bundle.products` per-product overrides are configured, `--output-products` also supplies the product IDs used to choose the **rule context product** (first alphabetically) for Mode 3. To use a different product's rules, run a separate bundle with only that product in `--output-products`. For details, refer to [Product-specific bundle rules](/contribute/configure-changelogs.md#rules-bundle-products).

`--no-release-date`
: Optional: Skip auto-population of release date in the bundle.
: By default, bundles are created with a `release-date` field set to today's date (UTC) or the GitHub release published date when using `--release-version`.
: Mutually exclusive with `--release-date`.
: **Not available in profile mode** — use bundle configuration instead.

`--release-date <string?>`
: Optional: Explicit release date for the bundle in YYYY-MM-DD format.
: Overrides the default auto-population behavior (today's date or GitHub release published date).
: Mutually exclusive with `--no-release-date`.
: **Not available in profile mode** — use bundle configuration instead.

`--owner <string?>`
: Optional: The GitHub repository owner, required when pull requests or issues are specified as numbers.
: Precedence: `--owner` flag > `bundle.owner` in `changelog.yml` > `elastic`.
Expand Down
5 changes: 5 additions & 0 deletions docs/cli/changelog/gh-release.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ docs-builder changelog gh-release <repo> [version] [options...] [-h|--help]
`--output <string?>`
: Optional: Output directory for the generated changelog files. Falls back to `bundle.directory` in `changelog.yml` when not specified. Defaults to `./changelogs`.

`--release-date <string?>`
: Optional: Explicit release date for the bundle in YYYY-MM-DD format.
: By default, the bundle uses the GitHub release's published date. This option overrides that behavior.
: If the GitHub release has no published date, falls back to today's date (UTC).

`--strip-title-prefix`
: Optional: Remove square brackets and the text within them from the beginning of pull request titles, and also remove a colon if it follows the closing bracket.
: For example, `"[Inference API] New embedding model support"` becomes `"New embedding model support"`.
Expand Down
13 changes: 7 additions & 6 deletions docs/syntax/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ The directive supports the following options:
| `:type: value` | Filter entries by type | Excludes separated types |
| `:subsections:` | Group entries by area/component | false |
| `:link-visibility: value` | Visibility of pull request (PR) and issue links | `auto` |
| `:config: path` | Path to `changelog.yml` configuration (reserved for future use) | auto-discover |
| `:config: path` | Path to `changelog.yml` configuration | auto-discover |

### Example with options

Expand Down Expand Up @@ -122,11 +122,12 @@ If a changelog has multiple area values, only the first one is used.

#### `:config:`

Explicit path to a `changelog.yml` configuration file. If not specified, the directive auto-discovers from:
1. `changelog.yml` in the docset root
2. `docs/changelog.yml` relative to docset root
Explicit path to a `changelog.yml` or `changelog.yaml` configuration file, relative to the documentation source directory. If not specified, the directive auto-discovers from these locations (first match wins):

Reserved for future configuration use. The directive does not currently load or apply configuration from this file.
1. `changelog.yml` or `changelog.yaml` in the documentation source directory
2. `changelog.yml` or `changelog.yaml` in the parent directory (typically the repository root)

Both explicit and auto-discovered paths must resolve within the repository checkout directory and must not traverse symlinks.

## Filtering entries with bundle rules

Expand Down Expand Up @@ -249,7 +250,7 @@ Download the release binaries: https://github.com/elastic/elasticsearch/releases
...
```

When present, the `release-date` field is rendered immediately after the version heading as italicized text (e.g., `_Released: 2026-04-09_`). This is purely informative for end-users and is especially useful for components released outside the usual stack lifecycle, such as APM agents and EDOT agents.
When present, the `release-date` field is rendered immediately after the version heading as italicized text (e.g., `_Released: April 9, 2026_`). This is purely informative for end-users and is especially useful for components released outside the usual stack lifecycle, such as APM agents and EDOT agents. If the `release-date` field is present in a bundle, it is always displayed. To control release dates, set `release_dates: false` at the bundle or profile level in the configuration (see [profile configuration](/cli/changelog/bundle.md)); when false, this prevents the date from being written to the bundle during bundling. Defaults to true when omitted.

Bundle descriptions are rendered when present in the bundle YAML file. The description appears after the release date (if any) but before any entry sections. Descriptions support Markdown formatting including links, lists, and multiple paragraphs.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ public record BundleConfiguration
/// </summary>
public IReadOnlyList<string>? LinkAllowRepos { get; init; }

/// <summary>
/// When true, auto-populate release date in bundle output. Defaults to true when omitted.
/// </summary>
public bool? ReleaseDates { get; init; }

/// <summary>
/// Named bundle profiles for different release scenarios.
/// </summary>
Expand Down Expand Up @@ -111,6 +116,11 @@ public record BundleProfile
/// </summary>
public IReadOnlyList<string>? HideFeatures { get; init; }

/// <summary>
/// When true, auto-populate release date in bundle output. Defaults to true when omitted.
/// </summary>
public bool? ReleaseDates { get; init; }

/// <summary>
/// Profile source type. When set to <c>"github_release"</c>, the profile fetches
/// PR references directly from a GitHub release and uses them as the bundle filter.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,9 @@ private static LoadedBundle MergeBundleGroup(IGrouping<string, LoadedBundle> gro
_ => releaseDates[0]
};

var mergedData = first.Data with { Description = mergedDescription, ReleaseDate = mergedReleaseDate };
var mergedData = first.Data != null
? first.Data with { Description = mergedDescription, ReleaseDate = mergedReleaseDate }
: new Bundle { Description = mergedDescription, ReleaseDate = mergedReleaseDate };

return new LoadedBundle(
first.Version,
Expand Down
99 changes: 77 additions & 22 deletions src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogBlock.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
// See the LICENSE file in the project root for more information

using System.IO.Abstractions;
using Elastic.Documentation;
using Elastic.Documentation.Configuration;
using Elastic.Documentation.Configuration.Assembler;
Expand Down Expand Up @@ -287,37 +288,91 @@ private void ExtractBundlesFolderPath()
}

/// <summary>
/// Reserved for future config loading (e.g., bundle.directory). The directive no longer applies rules.publish.
/// Emits a warning when an explicit :config: path is specified but the file is not found.
/// Loads changelog configuration settings from the config file.
/// Uses the explicit :config: path if specified, otherwise auto-discovers changelog.yml.
/// Reserved for future directive-relevant settings.
/// </summary>
private void LoadConfiguration()
{
if (string.IsNullOrWhiteSpace(ConfigPath))
return;
private void LoadConfiguration() =>
// Config file resolution is kept so the path validation infrastructure
// stays exercised; settings are currently handled at bundle time.
_ = ResolveConfigPath();

var trimmedPath = ConfigPath.TrimStart('/');
if (Path.IsPathRooted(trimmedPath))
/// <summary>
/// The trust boundary for changelog config file resolution: checkout (git) root
/// when available, otherwise the documentation source directory.
/// Both explicit <c>:config:</c> paths and auto-discovered candidates are validated
/// against this same root.
/// </summary>
private IDirectoryInfo ConfigTrustRoot =>
Build.DocumentationCheckoutDirectory ?? Build.DocumentationSourceDirectory;

private string? ResolveConfigPath()
{
if (!string.IsNullOrWhiteSpace(ConfigPath))
{
this.EmitError("Changelog config path must not be an absolute path.");
return;
// A leading '/' or '\' is treated as relative to docset root
var trimmedPath = ConfigPath.TrimStart('/', '\\');
if (Path.IsPathRooted(trimmedPath))
{
this.EmitError("Changelog config path must not be an absolute path.");
return null;
}

var explicitPath = Path.GetFullPath(Build.DocumentationSourceDirectory.ResolvePathFrom(trimmedPath));
return ValidateConfigCandidate(explicitPath, emitDiagnostics: true);
Comment thread
cotti marked this conversation as resolved.
}

var explicitPath = Path.GetFullPath(Build.DocumentationSourceDirectory.ResolvePathFrom(ConfigPath));
var file = Build.ReadFileSystem.FileInfo.New(explicitPath);
if (!file.IsSubPathOf(Build.DocumentationSourceDirectory))
// Auto-discover: try .yml and .yaml in each candidate location.
string[] relativePaths =
[
"changelog.yml", "changelog.yaml",
"../changelog.yml", "../changelog.yaml"
];

return relativePaths
.Select(rel => Path.GetFullPath(Build.DocumentationSourceDirectory.ResolvePathFrom(rel)))
.Select(abs => ValidateConfigCandidate(abs, emitDiagnostics: false))
.FirstOrDefault(p => p != null);
}

/// <summary>
/// Validates a config file candidate against the shared trust rules:
/// must be within <see cref="ConfigTrustRoot"/>, must not be/traverse symlinks,
/// and must exist on the (scoped) filesystem.
/// </summary>
private string? ValidateConfigCandidate(string fullPath, bool emitDiagnostics)
{
try
{
this.EmitError("Changelog config path must resolve within the documentation source directory.");
return;
}
var file = Build.ReadFileSystem.FileInfo.New(fullPath);

if (!file.IsSubPathOf(ConfigTrustRoot))
{
if (emitDiagnostics)
this.EmitError("Changelog config path must resolve within the documentation directory.");
return null;
}

if (SymlinkValidator.ValidateFileAccess(file, ConfigTrustRoot) is { } accessError)
{
if (emitDiagnostics)
this.EmitError(accessError);
return null;
}

if (SymlinkValidator.ValidateFileAccess(file, Build.DocumentationSourceDirectory) is { } accessError)
if (!Build.ReadFileSystem.File.Exists(fullPath))
{
if (emitDiagnostics)
this.EmitWarning($"Specified changelog config path '{ConfigPath}' not found.");
return null;
}

return fullPath;
}
catch
{
this.EmitError(accessError);
return;
return null;
}

if (!Build.ReadFileSystem.File.Exists(explicitPath))
this.EmitWarning($"Specified changelog config path '{ConfigPath}' not found.");
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// 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 YamlDotNet.Serialization;

namespace Elastic.Markdown.Myst.Directives.Changelog;

/// <summary>
/// Minimal YAML DTO for reading changelog.yml settings needed by the {changelog} directive.
/// Only the fields relevant to directive rendering are included; everything else is ignored.
/// </summary>
[YamlSerializable]
internal sealed record ChangelogDirectiveConfigYaml
{
public ChangelogDirectiveBundleConfigYaml? Bundle { get; set; }
}

/// <summary>
/// Minimal bundle section from changelog.yml, containing only directive-relevant settings.
/// Reserved for future directive-relevant settings.
/// </summary>
[YamlSerializable]
internal sealed record ChangelogDirectiveBundleConfigYaml;
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,6 @@ private static string GenerateMarkdown(

_ = sb.AppendLine(CultureInfo.InvariantCulture, $"## {title}");

// Add release date if present
if (releaseDate is { } date)
{
_ = sb.AppendLine();
Expand Down
3 changes: 3 additions & 0 deletions src/Elastic.Markdown/Myst/YamlSerialization.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

using Elastic.Documentation.AppliesTo;
using Elastic.Documentation.Configuration.Products;
using Elastic.Markdown.Myst.Directives.Changelog;
using Elastic.Markdown.Myst.Directives.Contributors;
using Elastic.Markdown.Myst.Directives.Settings;
using Elastic.Markdown.Myst.FrontMatter;
Expand Down Expand Up @@ -39,4 +40,6 @@ public static T Deserialize<T>(string yaml, ProductsConfiguration products)
[YamlSerializable(typeof(SettingMutability))]
[YamlSerializable(typeof(ApplicableTo))]
[YamlSerializable(typeof(ContributorEntry))]
[YamlSerializable(typeof(ChangelogDirectiveConfigYaml))]
[YamlSerializable(typeof(ChangelogDirectiveBundleConfigYaml))]
public partial class DocsBuilderYamlStaticContext;
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,18 @@ public record BundleChangelogsArguments
/// </summary>
public string? Description { get; init; }

/// <summary>
/// Optional explicit release date for the bundle in YYYY-MM-DD format.
/// When provided, overrides auto-population behavior.
/// </summary>
public string? ReleaseDate { get; init; }

/// <summary>
/// When true, skips auto-population of release date (respects --no-release-date).
/// Existing dates in bundle YAML files are still preserved.
/// </summary>
public bool SuppressReleaseDate { get; init; }

/// <summary>
/// When non-null (including empty), PR/issue links are filtered to this <c>owner/repo</c> allowlist (from changelog.yml <c>bundle.link_allow_repos</c>).
/// </summary>
Expand Down Expand Up @@ -354,6 +366,29 @@ public async Task<bool> BundleChangelogs(IDiagnosticsCollector collector, Bundle
}
}

// Apply release date: CLI override → existing bundle date → auto-populate (unless suppressed)
var finalReleaseDate = bundleData.ReleaseDate; // Preserve existing date if present
if (!string.IsNullOrEmpty(input.ReleaseDate))
{
// Explicit CLI override
if (DateOnly.TryParseExact(input.ReleaseDate, "yyyy-MM-dd", out var parsedDate))
{
finalReleaseDate = parsedDate;
}
else
{
collector.EmitError(string.Empty, $"Invalid release date format '{input.ReleaseDate}'. Expected YYYY-MM-DD format.");
return false;
}
}
else if (finalReleaseDate == null && !input.SuppressReleaseDate)
{
// Auto-populate with today's date (UTC) if no existing date
finalReleaseDate = DateOnly.FromDateTime(DateTime.UtcNow);
}

bundleData = bundleData with { ReleaseDate = finalReleaseDate };

// Write bundle file
await WriteBundleFileAsync(bundleData, outputPath, ctx);

Expand Down Expand Up @@ -395,6 +430,7 @@ public async Task<bool> BundleChangelogs(IDiagnosticsCollector collector, Bundle
string? owner = null;
string[]? mergedHideFeatures = null;
string? profileDescription = null;
var profileSuppressReleaseDate = false;

if (config?.Bundle?.Profiles != null && config.Bundle.Profiles.TryGetValue(input.Profile!, out var profile))
{
Expand Down Expand Up @@ -436,6 +472,7 @@ public async Task<bool> BundleChangelogs(IDiagnosticsCollector collector, Bundle
repo = profile.Repo ?? config.Bundle.Repo;
owner = profile.Owner ?? config.Bundle.Owner;
mergedHideFeatures = profile.HideFeatures?.Count > 0 ? [.. profile.HideFeatures] : null;
profileSuppressReleaseDate = !(profile.ReleaseDates ?? config.Bundle.ReleaseDates ?? true);

Comment thread
coderabbitai[bot] marked this conversation as resolved.
// Handle profile-specific description with placeholder substitution
var descriptionTemplate = profile.Description ?? config.Bundle.Description;
Expand Down Expand Up @@ -480,7 +517,8 @@ public async Task<bool> BundleChangelogs(IDiagnosticsCollector collector, Bundle
Repo = repo,
Owner = owner,
HideFeatures = mergedHideFeatures,
Description = profileDescription
Description = profileDescription,
SuppressReleaseDate = profileSuppressReleaseDate
};
}

Expand All @@ -507,6 +545,12 @@ private BundleChangelogsArguments ApplyConfigDefaults(BundleChangelogsArguments
// Apply description: CLI takes precedence; fall back to bundle-level config default
var description = input.Description ?? config.Bundle.Description;

// Apply release date suppression: CLI takes precedence; config can enable suppression when CLI didn't
// In profile mode, profile has already resolved inheritance, so skip bundle logic
var suppressReleaseDate = !string.IsNullOrWhiteSpace(input.Profile)
? input.SuppressReleaseDate
: input.SuppressReleaseDate || !(config.Bundle.ReleaseDates ?? true);

return input with
{
Directory = directory,
Expand All @@ -515,6 +559,7 @@ private BundleChangelogsArguments ApplyConfigDefaults(BundleChangelogsArguments
Repo = repo,
Owner = owner,
Description = description,
SuppressReleaseDate = suppressReleaseDate,
LinkAllowRepos = config.Bundle.LinkAllowRepos
};
}
Expand Down
Loading
Loading