diff --git a/config/changelog.example.yml b/config/changelog.example.yml index 10c82a1391..bd61893d62 100644 --- a/config/changelog.example.yml +++ b/config/changelog.example.yml @@ -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': @@ -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: diff --git a/docs/cli/changelog/bundle.md b/docs/cli/changelog/bundle.md index 7d4c06423e..45934cc6fb 100644 --- a/docs/cli/changelog/bundle.md +++ b/docs/cli/changelog/bundle.md @@ -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 ` +: 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 ` : Optional: The GitHub repository owner, required when pull requests or issues are specified as numbers. : Precedence: `--owner` flag > `bundle.owner` in `changelog.yml` > `elastic`. diff --git a/docs/cli/changelog/gh-release.md b/docs/cli/changelog/gh-release.md index b48020ce33..1ed370efd0 100644 --- a/docs/cli/changelog/gh-release.md +++ b/docs/cli/changelog/gh-release.md @@ -38,6 +38,11 @@ docs-builder changelog gh-release [version] [options...] [-h|--help] `--output ` : Optional: Output directory for the generated changelog files. Falls back to `bundle.directory` in `changelog.yml` when not specified. Defaults to `./changelogs`. +`--release-date ` +: 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"`. diff --git a/docs/syntax/changelog.md b/docs/syntax/changelog.md index 559983b4a4..3d1ef59976 100644 --- a/docs/syntax/changelog.md +++ b/docs/syntax/changelog.md @@ -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 @@ -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 @@ -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. diff --git a/src/Elastic.Documentation.Configuration/Changelog/BundleConfiguration.cs b/src/Elastic.Documentation.Configuration/Changelog/BundleConfiguration.cs index 5282420b3f..3191914099 100644 --- a/src/Elastic.Documentation.Configuration/Changelog/BundleConfiguration.cs +++ b/src/Elastic.Documentation.Configuration/Changelog/BundleConfiguration.cs @@ -51,6 +51,11 @@ public record BundleConfiguration /// public IReadOnlyList? LinkAllowRepos { get; init; } + /// + /// When true, auto-populate release date in bundle output. Defaults to true when omitted. + /// + public bool? ReleaseDates { get; init; } + /// /// Named bundle profiles for different release scenarios. /// @@ -111,6 +116,11 @@ public record BundleProfile /// public IReadOnlyList? HideFeatures { get; init; } + /// + /// When true, auto-populate release date in bundle output. Defaults to true when omitted. + /// + public bool? ReleaseDates { get; init; } + /// /// Profile source type. When set to "github_release", the profile fetches /// PR references directly from a GitHub release and uses them as the bundle filter. diff --git a/src/Elastic.Documentation.Configuration/ReleaseNotes/BundleLoader.cs b/src/Elastic.Documentation.Configuration/ReleaseNotes/BundleLoader.cs index c3c1420fa9..7956d58190 100644 --- a/src/Elastic.Documentation.Configuration/ReleaseNotes/BundleLoader.cs +++ b/src/Elastic.Documentation.Configuration/ReleaseNotes/BundleLoader.cs @@ -242,7 +242,9 @@ private static LoadedBundle MergeBundleGroup(IGrouping 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, diff --git a/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogBlock.cs b/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogBlock.cs index 4744e8e79d..004bd904fb 100644 --- a/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogBlock.cs @@ -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; @@ -287,37 +288,91 @@ private void ExtractBundlesFolderPath() } /// - /// 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. /// - 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)) + /// + /// The trust boundary for changelog config file resolution: checkout (git) root + /// when available, otherwise the documentation source directory. + /// Both explicit :config: paths and auto-discovered candidates are validated + /// against this same root. + /// + 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); } - 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); + } + + /// + /// Validates a config file candidate against the shared trust rules: + /// must be within , must not be/traverse symlinks, + /// and must exist on the (scoped) filesystem. + /// + 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."); } /// diff --git a/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogConfigYaml.cs b/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogConfigYaml.cs new file mode 100644 index 0000000000..abdac29bdd --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogConfigYaml.cs @@ -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; + +/// +/// 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. +/// +[YamlSerializable] +internal sealed record ChangelogDirectiveConfigYaml +{ + public ChangelogDirectiveBundleConfigYaml? Bundle { get; set; } +} + +/// +/// Minimal bundle section from changelog.yml, containing only directive-relevant settings. +/// Reserved for future directive-relevant settings. +/// +[YamlSerializable] +internal sealed record ChangelogDirectiveBundleConfigYaml; diff --git a/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogInlineRenderer.cs b/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogInlineRenderer.cs index af282fef57..c0c0022717 100644 --- a/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogInlineRenderer.cs +++ b/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogInlineRenderer.cs @@ -178,7 +178,6 @@ private static string GenerateMarkdown( _ = sb.AppendLine(CultureInfo.InvariantCulture, $"## {title}"); - // Add release date if present if (releaseDate is { } date) { _ = sb.AppendLine(); diff --git a/src/Elastic.Markdown/Myst/YamlSerialization.cs b/src/Elastic.Markdown/Myst/YamlSerialization.cs index 6d84d67300..4f8502e75f 100644 --- a/src/Elastic.Markdown/Myst/YamlSerialization.cs +++ b/src/Elastic.Markdown/Myst/YamlSerialization.cs @@ -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; @@ -39,4 +40,6 @@ public static T Deserialize(string yaml, ProductsConfiguration products) [YamlSerializable(typeof(SettingMutability))] [YamlSerializable(typeof(ApplicableTo))] [YamlSerializable(typeof(ContributorEntry))] +[YamlSerializable(typeof(ChangelogDirectiveConfigYaml))] +[YamlSerializable(typeof(ChangelogDirectiveBundleConfigYaml))] public partial class DocsBuilderYamlStaticContext; diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs index 3b0d5baf6e..0b7a98ca87 100644 --- a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs +++ b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs @@ -91,6 +91,18 @@ public record BundleChangelogsArguments /// public string? Description { get; init; } + /// + /// Optional explicit release date for the bundle in YYYY-MM-DD format. + /// When provided, overrides auto-population behavior. + /// + public string? ReleaseDate { get; init; } + + /// + /// When true, skips auto-population of release date (respects --no-release-date). + /// Existing dates in bundle YAML files are still preserved. + /// + public bool SuppressReleaseDate { get; init; } + /// /// When non-null (including empty), PR/issue links are filtered to this owner/repo allowlist (from changelog.yml bundle.link_allow_repos). /// @@ -354,6 +366,29 @@ public async Task 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); @@ -395,6 +430,7 @@ public async Task 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)) { @@ -436,6 +472,7 @@ public async Task 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); // Handle profile-specific description with placeholder substitution var descriptionTemplate = profile.Description ?? config.Bundle.Description; @@ -480,7 +517,8 @@ public async Task BundleChangelogs(IDiagnosticsCollector collector, Bundle Repo = repo, Owner = owner, HideFeatures = mergedHideFeatures, - Description = profileDescription + Description = profileDescription, + SuppressReleaseDate = profileSuppressReleaseDate }; } @@ -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, @@ -515,6 +559,7 @@ private BundleChangelogsArguments ApplyConfigDefaults(BundleChangelogsArguments Repo = repo, Owner = owner, Description = description, + SuppressReleaseDate = suppressReleaseDate, LinkAllowRepos = config.Bundle.LinkAllowRepos }; } diff --git a/src/services/Elastic.Changelog/Configuration/ChangelogConfigurationLoader.cs b/src/services/Elastic.Changelog/Configuration/ChangelogConfigurationLoader.cs index 0f5b8a7edd..0b8927985b 100644 --- a/src/services/Elastic.Changelog/Configuration/ChangelogConfigurationLoader.cs +++ b/src/services/Elastic.Changelog/Configuration/ChangelogConfigurationLoader.cs @@ -521,6 +521,7 @@ private static PivotConfiguration ConvertPivot(PivotConfigurationYaml yamlPivot) Repo = kvp.Value.Repo, Owner = kvp.Value.Owner, HideFeatures = kvp.Value.HideFeatures?.Values, + ReleaseDates = kvp.Value.ReleaseDates, Source = kvp.Value.Source }); } @@ -533,6 +534,7 @@ private static PivotConfiguration ConvertPivot(PivotConfigurationYaml yamlPivot) Description = yaml.Description, Repo = yaml.Repo, Owner = yaml.Owner, + ReleaseDates = yaml.ReleaseDates, LinkAllowRepos = linkAllowRepos, Profiles = profiles }; diff --git a/src/services/Elastic.Changelog/GitHub/GitHubReleaseService.cs b/src/services/Elastic.Changelog/GitHub/GitHubReleaseService.cs index e9985e16df..c28a0b7437 100644 --- a/src/services/Elastic.Changelog/GitHub/GitHubReleaseService.cs +++ b/src/services/Elastic.Changelog/GitHub/GitHubReleaseService.cs @@ -103,7 +103,8 @@ static GitHubReleaseService() Body = releaseData.Body ?? string.Empty, Prerelease = releaseData.Prerelease, Draft = releaseData.Draft, - HtmlUrl = releaseData.HtmlUrl ?? string.Empty + HtmlUrl = releaseData.HtmlUrl ?? string.Empty, + PublishedAt = releaseData.PublishedAt }; } @@ -126,6 +127,9 @@ private sealed class GitHubReleaseResponse [JsonPropertyName("html_url")] public string? HtmlUrl { get; set; } + + [JsonPropertyName("published_at")] + public DateTimeOffset? PublishedAt { get; set; } } [JsonSerializable(typeof(GitHubReleaseResponse))] diff --git a/src/services/Elastic.Changelog/GitHub/IGitHubReleaseService.cs b/src/services/Elastic.Changelog/GitHub/IGitHubReleaseService.cs index 49281a04b8..20f984ff22 100644 --- a/src/services/Elastic.Changelog/GitHub/IGitHubReleaseService.cs +++ b/src/services/Elastic.Changelog/GitHub/IGitHubReleaseService.cs @@ -38,6 +38,11 @@ public record GitHubReleaseInfo /// The URL to the release page on GitHub /// public string HtmlUrl { get; init; } = ""; + + /// + /// The date and time when this release was published on GitHub + /// + public DateTimeOffset? PublishedAt { get; init; } } /// diff --git a/src/services/Elastic.Changelog/GithubRelease/GitHubReleaseChangelogService.cs b/src/services/Elastic.Changelog/GithubRelease/GitHubReleaseChangelogService.cs index 97344fc15d..958c74bcf8 100644 --- a/src/services/Elastic.Changelog/GithubRelease/GitHubReleaseChangelogService.cs +++ b/src/services/Elastic.Changelog/GithubRelease/GitHubReleaseChangelogService.cs @@ -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.Globalization; using System.IO.Abstractions; using System.Text; using Elastic.Changelog.Bundling; @@ -60,6 +61,12 @@ public record CreateChangelogsFromReleaseArguments /// public string? Description { get; init; } + /// + /// Optional explicit release date for the bundle in YYYY-MM-DD format. + /// When provided, overrides the GitHub release published_at date. + /// + public string? ReleaseDate { get; init; } + /// /// Whether to create a bundle file after creating individual changelog files. Defaults to true. /// Set to false when called from 'changelog add --release-version' to skip bundle creation. @@ -187,7 +194,7 @@ Cancel ctx // 8. Optionally create bundle file if changelogs were created if (input.CreateBundle && createdFiles.Count > 0) { - var bundlePath = await CreateBundleViaService(collector, outputDir, createdFiles, productInfo, owner, repo, input, ctx); + var bundlePath = await CreateBundleViaService(collector, outputDir, createdFiles, productInfo, owner, repo, input, release, ctx); if (bundlePath != null) _logger.LogInformation("Created bundle file: {BundlePath}", bundlePath); } @@ -313,6 +320,7 @@ private static string GenerateYaml(ChangelogEntry data) => string owner, string repo, CreateChangelogsFromReleaseArguments input, + GitHubReleaseInfo release, Cancel ctx) { // Build the bundles subfolder path (mirrors the previous CreateBundleFile convention) @@ -333,6 +341,13 @@ private static string GenerateYaml(ChangelogEntry data) => }) .ToArray(); + // Use explicit release date if provided, otherwise GitHub release published date, otherwise fall back to auto-population + var releaseDate = input.ReleaseDate; + if (string.IsNullOrEmpty(releaseDate) && release.PublishedAt.HasValue) + { + releaseDate = DateOnly.FromDateTime(release.PublishedAt.Value.UtcDateTime).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + } + var bundleArgs = new BundleChangelogsArguments { Directory = outputDir, @@ -342,7 +357,8 @@ private static string GenerateYaml(ChangelogEntry data) => Repo = repo, Config = input.Config, OutputProducts = [productInfo], - Description = input.Description + Description = input.Description, + ReleaseDate = releaseDate }; var success = await _bundlingService.BundleChangelogs(collector, bundleArgs, ctx); diff --git a/src/services/Elastic.Changelog/Rendering/Asciidoc/ChangelogAsciidocRenderer.cs b/src/services/Elastic.Changelog/Rendering/Asciidoc/ChangelogAsciidocRenderer.cs index e23554c59f..c5d400f046 100644 --- a/src/services/Elastic.Changelog/Rendering/Asciidoc/ChangelogAsciidocRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Asciidoc/ChangelogAsciidocRenderer.cs @@ -32,7 +32,6 @@ public async Task RenderAsciidoc(ChangelogRenderContext context, Cancel ctx) _ = sb.AppendLine(InvariantCulture, $"== {context.Title}"); _ = sb.AppendLine(); - // Add release date if present if (context.BundleReleaseDate is { } releaseDate) { _ = sb.AppendLine(InvariantCulture, $"_Released: {releaseDate.ToString("MMMM d, yyyy", InvariantCulture)}_"); diff --git a/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs b/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs index 800d64b93a..6d394a904a 100644 --- a/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs +++ b/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs @@ -31,6 +31,7 @@ public record RenderChangelogsArguments public string[]? HideFeatures { get; init; } public string? Config { get; init; } public ChangelogFileType FileType { get; init; } = ChangelogFileType.Markdown; + } /// diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs index 9933eacee1..c310834d2b 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs @@ -52,7 +52,6 @@ public override async Task RenderAsync(ChangelogRenderContext context, Cancel ct var sb = new StringBuilder(); _ = sb.AppendLine(InvariantCulture, $"## {context.Title} [{context.Repo}-release-notes-{context.TitleSlug}]"); - // Add release date if present if (context.BundleReleaseDate is { } releaseDate) { _ = sb.AppendLine(); diff --git a/src/services/Elastic.Changelog/Serialization/ChangelogConfigurationYaml.cs b/src/services/Elastic.Changelog/Serialization/ChangelogConfigurationYaml.cs index 372be436fc..03c56e9b8e 100644 --- a/src/services/Elastic.Changelog/Serialization/ChangelogConfigurationYaml.cs +++ b/src/services/Elastic.Changelog/Serialization/ChangelogConfigurationYaml.cs @@ -295,6 +295,11 @@ internal record BundleConfigurationYaml /// public string? Owner { get; set; } + /// + /// When true, auto-populate release date in bundle output. Defaults to true when omitted. + /// + public bool? ReleaseDates { get; set; } + /// /// When set, only PR/issue links targeting these owner/repo values are kept; others become # PRIVATE: sentinels (requires resolve). /// @@ -350,6 +355,11 @@ internal record BundleProfileYaml /// public YamlLenientList? HideFeatures { get; set; } + /// + /// When true, auto-populate release date in bundle output. Defaults to true when omitted. + /// + public bool? ReleaseDates { get; set; } + /// /// Profile source type. When set to "github_release", the profile fetches /// PR references directly from a GitHub release and uses them as the bundle filter. diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index f071af8c6a..a47cd20ec8 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -499,6 +499,8 @@ async static (s, collector, state, ctx) => await s.CreateChangelog(collector, st /// Optional: Directory containing changelog YAML files. Uses config bundle.directory or defaults to current directory /// Optional: Bundle description text with placeholder support. Supports {version}, {lifecycle}, {owner}, and {repo} placeholders. Overrides bundle.description from config. In option-based mode, placeholders require --output-products to be explicitly specified. /// Optional: Filter by feature IDs (comma-separated) or a path to a newline-delimited file containing feature IDs. Can be specified multiple times. Entries with matching feature-id values will be commented out when the bundle is rendered (by CLI render or {changelog} directive). + /// Optional: Skip auto-population of release date in the bundle. Mutually exclusive with --release-date. Not available in profile mode. + /// Optional: Explicit release date for the bundle in YYYY-MM-DD format. Overrides auto-population behavior. Mutually exclusive with --no-release-date. Not available in profile mode. /// Filter by products in format "product target lifecycle, ..." (for example, "cloud-serverless 2025-12-02 ga, cloud-serverless 2025-12-06 beta"). When specified, all three parts (product, target, lifecycle) are required but can be wildcards (*). Examples: "elasticsearch * *" matches all elasticsearch changelogs, "cloud-serverless 2025-12-02 *" matches cloud-serverless 2025-12-02 with any lifecycle, "* 9.3.* *" matches any product with target starting with "9.3.", "* * *" matches all changelogs (equivalent to --all). /// Filter by issue URLs (comma-separated), or a path to a newline-delimited file containing fully-qualified GitHub issue URLs. Can be specified multiple times. /// Optional: Output path for the bundled changelog. Can be either (1) a directory path, in which case 'changelog-bundle.yaml' is created in that directory, or (2) a file path ending in .yml or .yaml. Uses config bundle.output_directory or defaults to 'changelog-bundle.yaml' in the input directory @@ -522,6 +524,8 @@ public async Task Bundle( string? directory = null, string? description = null, string[]? hideFeatures = null, + bool noReleaseDate = false, + string? releaseDate = null, [ProductInfoParser] List? inputProducts = null, string? output = null, [ProductInfoParser] List? outputProducts = null, @@ -792,6 +796,35 @@ public async Task Bundle( return 0; } + // Validate release date flags + if (noReleaseDate && !string.IsNullOrWhiteSpace(releaseDate)) + { + collector.EmitError(string.Empty, "--no-release-date and --release-date are mutually exclusive."); + return 1; + } + + // Profile mode doesn't support release date CLI flags (use YAML configuration instead) + if (isProfileMode && (noReleaseDate || !string.IsNullOrWhiteSpace(releaseDate))) + { + var forbidden = new List(); + if (noReleaseDate) + forbidden.Add("--no-release-date"); + if (!string.IsNullOrWhiteSpace(releaseDate)) + forbidden.Add("--release-date"); + + collector.EmitError(string.Empty, + $"Profile mode does not support {string.Join(" and ", forbidden)}. " + + "Use bundle.release_dates or bundle.profiles..release_dates in changelog.yml instead."); + return 1; + } + + // Validate release date format if provided + if (!string.IsNullOrWhiteSpace(releaseDate) && !DateOnly.TryParseExact(releaseDate, "yyyy-MM-dd", out _)) + { + collector.EmitError(string.Empty, $"Invalid --release-date format '{releaseDate}'. Expected YYYY-MM-DD format."); + return 1; + } + // Determine resolve: CLI --no-resolve and --resolve override config. null = use config default. var shouldResolve = noResolve ? false : resolve; @@ -815,7 +848,9 @@ public async Task Bundle( Report = !isProfileMode ? report : null, Config = config, HideFeatures = allFeatureIdsForBundle.Count > 0 ? allFeatureIdsForBundle.ToArray() : null, - Description = description + Description = description, + ReleaseDate = releaseDate, + SuppressReleaseDate = noReleaseDate }; serviceInvoker.AddCommand(service, input, @@ -1128,6 +1163,7 @@ async static (s, collector, state, ctx) => await s.RenderChangelogs(collector, s /// Optional: Path to the changelog.yml configuration file. Defaults to 'docs/changelog.yml' /// Optional: Bundle description text with placeholder support. Supports {version}, {lifecycle}, {owner}, and {repo} placeholders. Overrides bundle.description from config. /// Optional: Output directory for changelog files. Falls back to bundle.directory in changelog.yml when not specified. Defaults to './changelogs' + /// Optional: Explicit release date for the bundle in YYYY-MM-DD format. Overrides GitHub release published date. /// Optional: Remove square brackets and text within them from the beginning of PR titles (e.g., "[Inference API] Title" becomes "Title") /// Optional: Warn when the type inferred from release notes section headers doesn't match the type derived from PR labels. Defaults to true /// @@ -1138,6 +1174,7 @@ public async Task GitHubRelease( string? config = null, string? description = null, string? output = null, + string? releaseDate = null, bool stripTitlePrefix = false, bool warnOnTypeMismatch = true, Cancel ctx = default @@ -1154,6 +1191,13 @@ public async Task GitHubRelease( IGitHubPrService prService = new GitHubPrService(logFactory); var service = new GitHubReleaseChangelogService(logFactory, configurationContext, releaseService, prService); + // Validate release date format if provided + if (!string.IsNullOrWhiteSpace(releaseDate) && !DateOnly.TryParseExact(releaseDate, "yyyy-MM-dd", out _)) + { + collector.EmitError(string.Empty, $"Invalid --release-date format '{releaseDate}'. Expected YYYY-MM-DD format."); + return 1; + } + // Resolve stripTitlePrefix: CLI flag true → explicit true; otherwise null (use config default) var stripTitlePrefixResolved = stripTitlePrefix ? true : (bool?)null; @@ -1165,7 +1209,8 @@ public async Task GitHubRelease( Output = resolvedOutput, StripTitlePrefix = stripTitlePrefixResolved, WarnOnTypeMismatch = warnOnTypeMismatch, - Description = description + Description = description, + ReleaseDate = releaseDate }; serviceInvoker.AddCommand(service, input, diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs index d7a671b0f8..74d4f89c41 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs @@ -6044,6 +6044,86 @@ public async Task BundleChangelogs_OptionModeWithConfigDescriptionAndPlaceholder } + [Fact] + public async Task BundleChangelogs_WithBundleReleaseDatesFalse_SuppressesReleaseDate() + { + // Arrange + CreateSampleChangelogs(); + + // Create a config with bundle.release_dates: false + var configDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + var docsDir = FileSystem.Path.Join(configDir, "docs"); + FileSystem.Directory.CreateDirectory(docsDir); + var configPath = FileSystem.Path.Join(docsDir, "changelog.yml"); + // language=yaml + await FileSystem.File.WriteAllTextAsync(configPath, + """ + bundle: + release_dates: false + """, + TestContext.Current.CancellationToken + ); + + var outputPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); + var input = new BundleChangelogsArguments + { + Directory = _changelogDir, + All = true, + Output = outputPath, + Config = configPath + }; + + // Act + var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue("bundling should succeed with release_dates config"); + Collector.Errors.Should().Be(0); + + var bundleContent = await FileSystem.File.ReadAllTextAsync(outputPath, TestContext.Current.CancellationToken); + bundleContent.Should().NotContain("release-date:", "release date should be suppressed when bundle.release_dates is false"); + } + + [Fact] + public async Task BundleChangelogs_WithBundleReleaseDatesTrue_AutoPopulatesReleaseDate() + { + // Arrange + CreateSampleChangelogs(); + + // Create a config with bundle.release_dates: true (explicit) + var configDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + var docsDir = FileSystem.Path.Join(configDir, "docs"); + FileSystem.Directory.CreateDirectory(docsDir); + var configPath = FileSystem.Path.Join(docsDir, "changelog.yml"); + // language=yaml + await FileSystem.File.WriteAllTextAsync(configPath, + """ + bundle: + release_dates: true + """, + TestContext.Current.CancellationToken + ); + + var outputPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); + var input = new BundleChangelogsArguments + { + Directory = _changelogDir, + All = true, + Output = outputPath, + Config = configPath + }; + + // Act + var result = await ServiceWithConfig.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue("bundling should succeed with release_dates config"); + Collector.Errors.Should().Be(0); + + var bundleContent = await FileSystem.File.ReadAllTextAsync(outputPath, TestContext.Current.CancellationToken); + bundleContent.Should().Contain("release-date:", "release date should be auto-populated when bundle.release_dates is true"); + } + private void CreateSampleChangelogs() { // language=yaml diff --git a/tests/Elastic.Changelog.Tests/Changelogs/ChangelogConfigurationTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/ChangelogConfigurationTests.cs index 78ea1ed0eb..712b379e35 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/ChangelogConfigurationTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/ChangelogConfigurationTests.cs @@ -1001,6 +1001,99 @@ public async Task LoadChangelogConfiguration_NoConfigFile_ReturnsDefaultWithNull } } + [Fact] + public async Task LoadChangelogConfiguration_BundleSection_ParsesReleaseDates() + { + // Arrange + var configLoader = new ChangelogConfigurationLoader(LoggerFactory, ConfigurationContext, FileSystem); + var configDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + var docsDir = FileSystem.Path.Join(configDir, "docs"); + FileSystem.Directory.CreateDirectory(docsDir); + var configPath = FileSystem.Path.Join(docsDir, "changelog.yml"); + // language=yaml + await FileSystem.File.WriteAllTextAsync(configPath, + """ + bundle: + release_dates: false + profiles: + test-profile: + output: "test-{version}.yaml" + release_dates: true + inherit-profile: + output: "inherit-{version}.yaml" + """, + TestContext.Current.CancellationToken + ); + + var originalDir = FileSystem.Directory.GetCurrentDirectory(); + try + { + FileSystem.Directory.SetCurrentDirectory(configDir); + // Act + var config = await configLoader.LoadChangelogConfiguration(Collector, null, TestContext.Current.CancellationToken); + + // Assert + config.Should().NotBeNull(); + Collector.Errors.Should().Be(0); + config.Bundle.Should().NotBeNull(); + config.Bundle.ReleaseDates.Should().BeFalse("bundle.release_dates was explicitly set to false"); + + // Test profile override + config.Bundle.Profiles.Should().ContainKey("test-profile"); + config.Bundle.Profiles["test-profile"].ReleaseDates.Should().BeTrue("profile overrides bundle setting"); + + // Test profile inheritance (profile omits release_dates, should be null for inheritance) + config.Bundle.Profiles.Should().ContainKey("inherit-profile"); + config.Bundle.Profiles["inherit-profile"].ReleaseDates.Should().BeNull("profile omits release_dates for inheritance"); + } + finally + { + FileSystem.Directory.SetCurrentDirectory(originalDir); + } + } + + [Fact] + public async Task LoadChangelogConfiguration_BundleSection_ReleaseDatesDefaultsToNull() + { + // Arrange + var configLoader = new ChangelogConfigurationLoader(LoggerFactory, ConfigurationContext, FileSystem); + var configDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + var docsDir = FileSystem.Path.Join(configDir, "docs"); + FileSystem.Directory.CreateDirectory(docsDir); + var configPath = FileSystem.Path.Join(docsDir, "changelog.yml"); + // language=yaml + await FileSystem.File.WriteAllTextAsync(configPath, + """ + bundle: + repo: test-repo + profiles: + test-profile: + output: "test-{version}.yaml" + """, + TestContext.Current.CancellationToken + ); + + var originalDir = FileSystem.Directory.GetCurrentDirectory(); + try + { + FileSystem.Directory.SetCurrentDirectory(configDir); + // Act + var config = await configLoader.LoadChangelogConfiguration(Collector, null, TestContext.Current.CancellationToken); + + // Assert + config.Should().NotBeNull(); + Collector.Errors.Should().Be(0); + config.Bundle.Should().NotBeNull(); + config.Bundle.ReleaseDates.Should().BeNull("bundle.release_dates was omitted, should default to null"); + config.Bundle.Profiles.Should().ContainKey("test-profile"); + config.Bundle.Profiles["test-profile"].ReleaseDates.Should().BeNull("profile.release_dates was omitted, should be null"); + } + finally + { + FileSystem.Directory.SetCurrentDirectory(originalDir); + } + } + [Fact] public async Task LoadChangelogConfiguration_WithPivotProducts_ComputesLabelToProductsMapping() { diff --git a/tests/Elastic.Markdown.Tests/Directives/ChangelogBasicTests.cs b/tests/Elastic.Markdown.Tests/Directives/ChangelogBasicTests.cs index 4f2b494660..b15f82abf9 100644 --- a/tests/Elastic.Markdown.Tests/Directives/ChangelogBasicTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/ChangelogBasicTests.cs @@ -622,21 +622,21 @@ public ChangelogReleaseDateTests(ITestOutputHelper output) : base(output, :::{changelog} ::: """) => FileSystem.AddFile("docs/changelog/bundles/1.34.0.yaml", new MockFileData( - // language=yaml - """ - products: - - product: apm-agent-dotnet - target: 1.34.0 - release-date: "2026-04-09" - entries: - - title: Add tracing improvements - type: feature - products: - - product: apm-agent-dotnet - target: 1.34.0 - prs: - - "500" - """)); + // language=yaml + """ + products: + - product: apm-agent-dotnet + target: 1.34.0 + release-date: "2026-04-09" + entries: + - title: Add tracing improvements + type: feature + products: + - product: apm-agent-dotnet + target: 1.34.0 + prs: + - "500" + """)); [Fact] public void RendersReleaseDate() => @@ -689,23 +689,23 @@ public ChangelogReleaseDateWithDescriptionTests(ITestOutputHelper output) : base :::{changelog} ::: """) => FileSystem.AddFile("docs/changelog/bundles/1.34.0.yaml", new MockFileData( - // language=yaml - """ - products: - - product: apm-agent-dotnet - target: 1.34.0 - release-date: "2026-04-09" - description: | - This release includes tracing improvements and bug fixes. - entries: - - title: Add tracing improvements - type: feature - products: - - product: apm-agent-dotnet - target: 1.34.0 - prs: - - "500" - """)); + // language=yaml + """ + products: + - product: apm-agent-dotnet + target: 1.34.0 + release-date: "2026-04-09" + description: | + This release includes tracing improvements and bug fixes. + entries: + - title: Add tracing improvements + type: feature + products: + - product: apm-agent-dotnet + target: 1.34.0 + prs: + - "500" + """)); [Fact] public void RendersReleaseDate() =>