diff --git a/config/changelog.example.yml b/config/changelog.example.yml index 10c82a139..3d4d5a754 100644 --- a/config/changelog.example.yml +++ b/config/changelog.example.yml @@ -219,6 +219,9 @@ bundle: # This release includes new features and bug fixes. # # For more information, see the [release notes](https://www.elastic.co/docs/release-notes/product#product-{version}). + # Whether to show release dates in rendered changelog output (default: false) + # When true, release-date fields are displayed as "Released: date" text + # show_release_dates: false # changelog-init-bundle-seed # PR/issue link allowlist: when set (including []), only links to these owner/repo pairs are kept # in bundle output; others are rewritten to '# PRIVATE:' sentinels (requires resolve: true). @@ -259,6 +262,8 @@ bundle: # # - Bug fixes and stability enhancements # # # # Download the release binaries: https://github.com/{owner}/{repo}/releases/tag/v{version} + # # Optional: profile-specific release date display setting (overrides bundle.show_release_dates) + # # show_release_dates: false # 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 7d4c06423..45934cc6f 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 b48020ce3..1ed370efd 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 559983b4a..f6bbc5a29 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,14 @@ 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. + +The directive currently reads the `bundle.show_release_dates` setting from this file to control whether release dates are displayed in the rendered output. ## Filtering entries with bundle rules @@ -251,6 +254,8 @@ 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. +The `bundle.show_release_dates` setting in `changelog.yml` (boolean, defaults to `false`) controls whether release dates appear in the rendered output. When set to `false`, the `release-date` field is preserved in the bundle YAML but ignored during rendering. The `{changelog}` directive auto-discovers this setting from `changelog.yml` (see [`:config:`](#config) for the discovery order). Per-profile overrides (`bundle.profiles..show_release_dates`) are supported by the CLI but not by the directive, which reads only the bundle-level default. + 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. ### Section types diff --git a/src/Elastic.Documentation.Configuration/Changelog/BundleConfiguration.cs b/src/Elastic.Documentation.Configuration/Changelog/BundleConfiguration.cs index 5282420b3..6296f4c9c 100644 --- a/src/Elastic.Documentation.Configuration/Changelog/BundleConfiguration.cs +++ b/src/Elastic.Documentation.Configuration/Changelog/BundleConfiguration.cs @@ -51,6 +51,13 @@ public record BundleConfiguration /// public IReadOnlyList? LinkAllowRepos { get; init; } + /// + /// Whether to show release dates in rendered changelog output. + /// When true, bundles with release-date fields will display "Released: date" text. + /// Defaults to false. + /// + public bool ShowReleaseDates { get; init; } + /// /// Named bundle profiles for different release scenarios. /// @@ -111,6 +118,13 @@ public record BundleProfile /// public IReadOnlyList? HideFeatures { get; init; } + /// + /// Whether to show release dates in rendered changelog output for this profile. + /// When provided, overrides the bundle.show_release_dates default. + /// When true, bundles with release-date fields will display "Released: date" text. + /// + public bool? ShowReleaseDates { 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 c3c1420fa..7956d5819 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 4744e8e79..0f54c3420 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; @@ -10,6 +11,8 @@ using Elastic.Documentation.ReleaseNotes; using Elastic.Markdown.Diagnostics; using Elastic.Markdown.Helpers; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; namespace Elastic.Markdown.Myst.Directives.Changelog; @@ -154,6 +157,11 @@ public class ChangelogBlock(DirectiveBlockParser parser, ParserContext context) /// public HashSet HideFeatures { get; private set; } = new(StringComparer.OrdinalIgnoreCase); + /// + /// Whether to show release dates in rendered output. Loaded from changelog.yml config. + /// + public bool ShowReleaseDates { get; private set; } + /// /// How to handle PR/issue links relative to private bundle repos (see :link-visibility: option). /// @@ -286,38 +294,112 @@ private void ExtractBundlesFolderPath() Found = true; } + private static readonly IDeserializer ConfigDeserializer = + new StaticDeserializerBuilder(new DocsBuilderYamlStaticContext()) + .WithNamingConvention(UnderscoredNamingConvention.Instance) + .IgnoreUnmatchedProperties() + .Build(); + /// - /// 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 (e.g. show_release_dates) from the config file. + /// Uses the explicit :config: path if specified, otherwise auto-discovers changelog.yml. /// private void LoadConfiguration() { - if (string.IsNullOrWhiteSpace(ConfigPath)) + var configFilePath = ResolveConfigPath(); + if (configFilePath == null) return; - var trimmedPath = ConfigPath.TrimStart('/'); - if (Path.IsPathRooted(trimmedPath)) + try { - this.EmitError("Changelog config path must not be an absolute path."); - return; + var yaml = Build.ReadFileSystem.File.ReadAllText(configFilePath); + var config = ConfigDeserializer.Deserialize(yaml); + if (config.Bundle?.ShowReleaseDates is true) + ShowReleaseDates = true; } - - var explicitPath = Path.GetFullPath(Build.DocumentationSourceDirectory.ResolvePathFrom(ConfigPath)); - var file = Build.ReadFileSystem.FileInfo.New(explicitPath); - if (!file.IsSubPathOf(Build.DocumentationSourceDirectory)) + catch { - this.EmitError("Changelog config path must resolve within the documentation source directory."); - return; + // Best-effort: if the config is malformed the CLI pipeline will report + // proper errors; the directive just falls back to ShowReleaseDates = false. } + } - if (SymlinkValidator.ValidateFileAccess(file, Build.DocumentationSourceDirectory) is { } accessError) + /// + /// 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(accessError); - 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); } - if (!Build.ReadFileSystem.File.Exists(explicitPath)) - this.EmitWarning($"Specified changelog config path '{ConfigPath}' not found."); + // 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 + { + 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 (!Build.ReadFileSystem.File.Exists(fullPath)) + { + if (emitDiagnostics) + this.EmitWarning($"Specified changelog config path '{ConfigPath}' not found."); + return null; + } + + return fullPath; + } + catch + { + return null; + } } /// 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 000000000..24ed60bb7 --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogConfigYaml.cs @@ -0,0 +1,26 @@ +// 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. +/// +[YamlSerializable] +internal sealed record ChangelogDirectiveBundleConfigYaml +{ + public bool? ShowReleaseDates { get; set; } +} diff --git a/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogInlineRenderer.cs b/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogInlineRenderer.cs index af282fef5..46c750ea8 100644 --- a/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogInlineRenderer.cs +++ b/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogInlineRenderer.cs @@ -37,7 +37,8 @@ public static class ChangelogInlineRenderer block.PrivateRepositories, block.HideFeatures, typeFilter, - block.LinkVisibility); + block.LinkVisibility, + block.ShowReleaseDates); _ = sb.Append(bundleMarkdown); isFirst = false; @@ -53,7 +54,8 @@ private static string RenderSingleBundle( HashSet privateRepositories, HashSet hideFeatures, ChangelogTypeFilter typeFilter, - ChangelogLinkVisibility linkVisibility) + ChangelogLinkVisibility linkVisibility, + bool showReleaseDates) { var titleSlug = ChangelogTextUtilities.TitleToSlug(bundle.Version); @@ -79,7 +81,7 @@ private static string RenderSingleBundle( }; var displayVersion = VersionOrDate.FormatDisplayVersion(bundle.Version); - return GenerateMarkdown(displayVersion, titleSlug, bundle.Repo, bundle.Owner, entriesByType, subsections, hideLinks, typeFilter, publishBlocker, bundle.Data?.Description, bundle.Data?.ReleaseDate); + return GenerateMarkdown(displayVersion, titleSlug, bundle.Repo, bundle.Owner, entriesByType, subsections, hideLinks, typeFilter, publishBlocker, bundle.Data?.Description, bundle.Data?.ReleaseDate, showReleaseDates); } /// @@ -154,7 +156,8 @@ private static string GenerateMarkdown( ChangelogTypeFilter typeFilter, PublishBlocker? publishBlocker, string? description = null, - DateOnly? releaseDate = null) + DateOnly? releaseDate = null, + bool showReleaseDates = false) { var sb = new StringBuilder(); @@ -178,8 +181,8 @@ private static string GenerateMarkdown( _ = sb.AppendLine(CultureInfo.InvariantCulture, $"## {title}"); - // Add release date if present - if (releaseDate is { } date) + // Add release date if present and ShowReleaseDates is enabled + if (showReleaseDates && releaseDate is { } date) { _ = sb.AppendLine(); _ = sb.AppendLine(CultureInfo.InvariantCulture, $"_Released: {date.ToString("MMMM d, yyyy", CultureInfo.InvariantCulture)}_"); diff --git a/src/Elastic.Markdown/Myst/YamlSerialization.cs b/src/Elastic.Markdown/Myst/YamlSerialization.cs index 6d84d6730..4f8502e75 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 3b0d5baf6..c47da4fd5 100644 --- a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs +++ b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs @@ -91,6 +91,25 @@ 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; } + + /// + /// Whether to show release dates in rendered output. + /// Resolved from profile (if applicable) > config > default (false). + /// Not written to bundle YAML; purely an in-memory rendering concern. + /// + public bool? ShowReleaseDates { 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 +373,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 +437,7 @@ public async Task BundleChangelogs(IDiagnosticsCollector collector, Bundle string? owner = null; string[]? mergedHideFeatures = null; string? profileDescription = null; + bool? profileShowReleaseDates = null; if (config?.Bundle?.Profiles != null && config.Bundle.Profiles.TryGetValue(input.Profile!, out var profile)) { @@ -437,6 +480,9 @@ public async Task BundleChangelogs(IDiagnosticsCollector collector, Bundle owner = profile.Owner ?? config.Bundle.Owner; mergedHideFeatures = profile.HideFeatures?.Count > 0 ? [.. profile.HideFeatures] : null; + // Profile-level ShowReleaseDates takes precedence over bundle-level default + profileShowReleaseDates = profile.ShowReleaseDates ?? config.Bundle.ShowReleaseDates; + // Handle profile-specific description with placeholder substitution var descriptionTemplate = profile.Description ?? config.Bundle.Description; if (!string.IsNullOrEmpty(descriptionTemplate)) @@ -480,7 +526,8 @@ public async Task BundleChangelogs(IDiagnosticsCollector collector, Bundle Repo = repo, Owner = owner, HideFeatures = mergedHideFeatures, - Description = profileDescription + Description = profileDescription, + ShowReleaseDates = profileShowReleaseDates ?? config?.Bundle?.ShowReleaseDates }; } @@ -507,6 +554,9 @@ private BundleChangelogsArguments ApplyConfigDefaults(BundleChangelogsArguments // Apply description: CLI takes precedence; fall back to bundle-level config default var description = input.Description ?? config.Bundle.Description; + // Apply ShowReleaseDates: CLI takes precedence; fall back to bundle-level config default + var showReleaseDates = input.ShowReleaseDates ?? config.Bundle.ShowReleaseDates; + return input with { Directory = directory, @@ -515,6 +565,7 @@ private BundleChangelogsArguments ApplyConfigDefaults(BundleChangelogsArguments Repo = repo, Owner = owner, Description = description, + ShowReleaseDates = showReleaseDates, LinkAllowRepos = config.Bundle.LinkAllowRepos }; } diff --git a/src/services/Elastic.Changelog/Configuration/ChangelogConfigurationLoader.cs b/src/services/Elastic.Changelog/Configuration/ChangelogConfigurationLoader.cs index 0f5b8a7ed..2e421f9bb 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, + ShowReleaseDates = kvp.Value.ShowReleaseDates, Source = kvp.Value.Source }); } @@ -534,6 +535,7 @@ private static PivotConfiguration ConvertPivot(PivotConfigurationYaml yamlPivot) Repo = yaml.Repo, Owner = yaml.Owner, LinkAllowRepos = linkAllowRepos, + ShowReleaseDates = yaml.ShowReleaseDates ?? false, Profiles = profiles }; } diff --git a/src/services/Elastic.Changelog/GitHub/GitHubReleaseService.cs b/src/services/Elastic.Changelog/GitHub/GitHubReleaseService.cs index e9985e16d..c28a0b743 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 49281a04b..20f984ff2 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 97344fc15..958c74bcf 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 e23554c59..ebd3b771c 100644 --- a/src/services/Elastic.Changelog/Rendering/Asciidoc/ChangelogAsciidocRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Asciidoc/ChangelogAsciidocRenderer.cs @@ -32,8 +32,8 @@ 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) + // Add release date if present and ShowReleaseDates is enabled + if (context.ShowReleaseDates && context.BundleReleaseDate is { } releaseDate) { _ = sb.AppendLine(InvariantCulture, $"_Released: {releaseDate.ToString("MMMM d, yyyy", InvariantCulture)}_"); _ = sb.AppendLine(); diff --git a/src/services/Elastic.Changelog/Rendering/ChangelogRenderContext.cs b/src/services/Elastic.Changelog/Rendering/ChangelogRenderContext.cs index 4fcdfbe20..33730ff05 100644 --- a/src/services/Elastic.Changelog/Rendering/ChangelogRenderContext.cs +++ b/src/services/Elastic.Changelog/Rendering/ChangelogRenderContext.cs @@ -35,4 +35,10 @@ public record ChangelogRenderContext /// Only set when there's a single unique release date across all bundles (MVP approach). /// public DateOnly? BundleReleaseDate { get; init; } + + /// + /// Whether to show release dates in the rendered output. + /// Controls whether the BundleReleaseDate (if present) is displayed. + /// + public bool ShowReleaseDates { get; init; } } diff --git a/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs b/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs index 800d64b93..daf722821 100644 --- a/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs +++ b/src/services/Elastic.Changelog/Rendering/ChangelogRenderingService.cs @@ -31,6 +31,11 @@ public record RenderChangelogsArguments public string[]? HideFeatures { get; init; } public string? Config { get; init; } public ChangelogFileType FileType { get; init; } = ChangelogFileType.Markdown; + + /// + /// Optional override for showing release dates. When null, falls back to config's bundle.show_release_dates. + /// + public bool? ShowReleaseDates { get; init; } } /// @@ -173,8 +178,10 @@ Cancel ctx renderReleaseDate = bundleReleaseDates[0]; } + var renderShowReleaseDates = input.ShowReleaseDates ?? config.Bundle?.ShowReleaseDates ?? false; + // Build render context - var context = BuildRenderContext(input, outputSetup, resolvedResult, combinedHideFeatures, config, renderDescription, renderReleaseDate); + var context = BuildRenderContext(input, outputSetup, resolvedResult, combinedHideFeatures, config, renderDescription, renderReleaseDate, renderShowReleaseDates); // Validate entry types if (!ValidateEntryTypes(collector, resolvedResult.Entries, config.Types)) @@ -288,7 +295,8 @@ private static ChangelogRenderContext BuildRenderContext( HashSet featureIdsToHide, ChangelogConfiguration? config, string? description = null, - DateOnly? releaseDate = null) + DateOnly? releaseDate = null, + bool showReleaseDates = false) { // Group entries by type var entriesByType = resolved.Entries @@ -331,7 +339,8 @@ private static ChangelogRenderContext BuildRenderContext( EntryToHideLinks = entryToHideLinks, Configuration = config, BundleDescription = description, - BundleReleaseDate = releaseDate + BundleReleaseDate = releaseDate, + ShowReleaseDates = showReleaseDates }; } diff --git a/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs b/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs index 9933eacee..02a8077d4 100644 --- a/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs +++ b/src/services/Elastic.Changelog/Rendering/Markdown/IndexMarkdownRenderer.cs @@ -52,8 +52,8 @@ 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) + // Add release date if present and ShowReleaseDates is enabled + if (context.ShowReleaseDates && context.BundleReleaseDate is { } releaseDate) { _ = sb.AppendLine(); _ = sb.AppendLine(InvariantCulture, $"_Released: {releaseDate.ToString("MMMM d, yyyy", InvariantCulture)}_"); diff --git a/src/services/Elastic.Changelog/Serialization/ChangelogConfigurationYaml.cs b/src/services/Elastic.Changelog/Serialization/ChangelogConfigurationYaml.cs index 372be436f..2c802fc0e 100644 --- a/src/services/Elastic.Changelog/Serialization/ChangelogConfigurationYaml.cs +++ b/src/services/Elastic.Changelog/Serialization/ChangelogConfigurationYaml.cs @@ -300,6 +300,11 @@ internal record BundleConfigurationYaml /// public YamlLenientList? LinkAllowRepos { get; set; } + /// + /// Whether to show release dates in rendered changelog output by default. + /// + public bool? ShowReleaseDates { get; set; } + /// /// Named bundle profiles. /// @@ -350,6 +355,12 @@ internal record BundleProfileYaml /// public YamlLenientList? HideFeatures { get; set; } + /// + /// Whether to show release dates in rendered changelog output for this profile. + /// Overrides bundle.show_release_dates when provided. + /// + public bool? ShowReleaseDates { 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 f071af8c6..7cbf0faa3 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.show_release_dates configuration 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.Markdown.Tests/Directives/ChangelogBasicTests.cs b/tests/Elastic.Markdown.Tests/Directives/ChangelogBasicTests.cs index 4f2b49466..478b3b91d 100644 --- a/tests/Elastic.Markdown.Tests/Directives/ChangelogBasicTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/ChangelogBasicTests.cs @@ -621,22 +621,31 @@ 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" - """)); + """) + { + FileSystem.AddFile("docs/changelog.yml", new MockFileData( + // language=yaml + """ + bundle: + show_release_dates: true + """)); + 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" + """)); + } [Fact] public void RendersReleaseDate() => @@ -688,24 +697,33 @@ 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" - """)); + """) + { + FileSystem.AddFile("docs/changelog.yml", new MockFileData( + // language=yaml + """ + bundle: + show_release_dates: true + """)); + 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" + """)); + } [Fact] public void RendersReleaseDate() =>