diff --git a/config/changelog.yml.example b/config/changelog.yml.example
new file mode 100644
index 000000000..47abf4266
--- /dev/null
+++ b/config/changelog.yml.example
@@ -0,0 +1,56 @@
+# Changelog Configuration
+# This file configures the valid values for changelog fields.
+# Place this file as `changelog.yml` in the `docs/` directory
+
+# Available types for changelog entries
+available_types:
+ - feature
+ - enhancement
+ - bug-fix
+ - known-issue
+ - breaking-change
+ - deprecation
+ - docs
+ - regression
+ - security
+ - other
+
+# Available subtypes for breaking changes
+available_subtypes:
+ - api
+ - behavioral
+ - configuration
+ - dependency
+ - subscription
+ - plugin
+ - security
+ - other
+
+# Available lifecycle values
+available_lifecycles:
+ - preview
+ - beta
+ - ga
+
+# Available areas (optional - if not specified, all areas are allowed)
+available_areas:
+ - search
+ - security
+ - machine-learning
+ - observability
+ - index-management
+ # Add more areas as needed
+
+# Available products (optional - if not specified, all products are allowed)
+available_products:
+ - elasticsearch
+ - kibana
+ - apm
+ - beats
+ - elastic-agent
+ - fleet
+ - cloud-hosted
+ - cloud-serverless
+ - cloud-enterprise
+ # Add more products as needed
+
diff --git a/src/services/Elastic.Documentation.Services/Changelog/ChangelogConfiguration.cs b/src/services/Elastic.Documentation.Services/Changelog/ChangelogConfiguration.cs
new file mode 100644
index 000000000..fd57b1615
--- /dev/null
+++ b/src/services/Elastic.Documentation.Services/Changelog/ChangelogConfiguration.cs
@@ -0,0 +1,51 @@
+// Licensed to Elasticsearch B.V under one or more agreements.
+// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
+// See the LICENSE file in the project root for more information
+
+namespace Elastic.Documentation.Services.Changelog;
+
+///
+/// Configuration for changelog generation
+///
+public class ChangelogConfiguration
+{
+ public List AvailableTypes { get; set; } =
+ [
+ "feature",
+ "enhancement",
+ "bug-fix",
+ "known-issue",
+ "breaking-change",
+ "deprecation",
+ "docs",
+ "regression",
+ "security",
+ "other"
+ ];
+
+ public List AvailableSubtypes { get; set; } =
+ [
+ "api",
+ "behavioral",
+ "configuration",
+ "dependency",
+ "subscription",
+ "plugin",
+ "security",
+ "other"
+ ];
+
+ public List AvailableLifecycles { get; set; } =
+ [
+ "preview",
+ "beta",
+ "ga"
+ ];
+
+ public List? AvailableAreas { get; set; }
+
+ public List? AvailableProducts { get; set; }
+
+ public static ChangelogConfiguration Default => new();
+}
+
diff --git a/src/services/Elastic.Documentation.Services/Changelog/ChangelogData.cs b/src/services/Elastic.Documentation.Services/Changelog/ChangelogData.cs
new file mode 100644
index 000000000..76b57504b
--- /dev/null
+++ b/src/services/Elastic.Documentation.Services/Changelog/ChangelogData.cs
@@ -0,0 +1,35 @@
+// Licensed to Elasticsearch B.V under one or more agreements.
+// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
+// See the LICENSE file in the project root for more information
+
+namespace Elastic.Documentation.Services.Changelog;
+
+///
+/// Data structure for changelog YAML file matching the exact schema
+///
+public class ChangelogData
+{
+ // Automated fields
+ public string? Pr { get; set; }
+ public List? Issues { get; set; }
+ public string Type { get; set; } = string.Empty;
+ public string? Subtype { get; set; }
+ public List Products { get; set; } = [];
+ public List? Areas { get; set; }
+
+ // Non-automated fields
+ public string Title { get; set; } = string.Empty;
+ public string? Description { get; set; }
+ public string? Impact { get; set; }
+ public string? Action { get; set; }
+ public string? FeatureId { get; set; }
+ public bool? Highlight { get; set; }
+}
+
+public class ProductInfo
+{
+ public string Product { get; set; } = string.Empty;
+ public string? Target { get; set; }
+ public string? Lifecycle { get; set; }
+}
+
diff --git a/src/services/Elastic.Documentation.Services/Changelog/ChangelogInput.cs b/src/services/Elastic.Documentation.Services/Changelog/ChangelogInput.cs
new file mode 100644
index 000000000..a3d943680
--- /dev/null
+++ b/src/services/Elastic.Documentation.Services/Changelog/ChangelogInput.cs
@@ -0,0 +1,27 @@
+// Licensed to Elasticsearch B.V under one or more agreements.
+// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
+// See the LICENSE file in the project root for more information
+
+namespace Elastic.Documentation.Services.Changelog;
+
+///
+/// Input data for creating a changelog fragment
+///
+public class ChangelogInput
+{
+ public required string Title { get; set; }
+ public required string Type { get; set; }
+ public required List Products { get; set; }
+ public string? Subtype { get; set; }
+ public string[] Areas { get; set; } = [];
+ public string? Pr { get; set; }
+ public string[] Issues { get; set; } = [];
+ public string? Description { get; set; }
+ public string? Impact { get; set; }
+ public string? Action { get; set; }
+ public string? FeatureId { get; set; }
+ public bool? Highlight { get; set; }
+ public string? Output { get; set; }
+ public string? Config { get; set; }
+}
+
diff --git a/src/services/Elastic.Documentation.Services/Changelog/ChangelogYamlStaticContext.cs b/src/services/Elastic.Documentation.Services/Changelog/ChangelogYamlStaticContext.cs
new file mode 100644
index 000000000..6aa2b85e8
--- /dev/null
+++ b/src/services/Elastic.Documentation.Services/Changelog/ChangelogYamlStaticContext.cs
@@ -0,0 +1,14 @@
+// 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.Documentation.Services.Changelog;
+
+[YamlStaticContext]
+[YamlSerializable(typeof(ChangelogData))]
+[YamlSerializable(typeof(ProductInfo))]
+[YamlSerializable(typeof(ChangelogConfiguration))]
+public partial class ChangelogYamlStaticContext;
+
diff --git a/src/services/Elastic.Documentation.Services/ChangelogService.cs b/src/services/Elastic.Documentation.Services/ChangelogService.cs
new file mode 100644
index 000000000..bf713c943
--- /dev/null
+++ b/src/services/Elastic.Documentation.Services/ChangelogService.cs
@@ -0,0 +1,294 @@
+// 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 System.Globalization;
+using System.IO.Abstractions;
+using System.Linq;
+using Elastic.Documentation.Configuration;
+using Elastic.Documentation.Diagnostics;
+using Elastic.Documentation.Services.Changelog;
+using Microsoft.Extensions.Logging;
+using YamlDotNet.Core;
+using YamlDotNet.Serialization;
+using YamlDotNet.Serialization.NamingConventions;
+namespace Elastic.Documentation.Services;
+
+public class ChangelogService(
+ ILoggerFactory logFactory,
+ IConfigurationContext configurationContext
+) : IService
+{
+ private readonly ILogger _logger = logFactory.CreateLogger();
+ private readonly IFileSystem _fileSystem = new FileSystem();
+
+ public async Task CreateChangelog(
+ IDiagnosticsCollector collector,
+ ChangelogInput input,
+ Cancel ctx
+ )
+ {
+ try
+ {
+ // Load changelog configuration
+ var config = await LoadChangelogConfiguration(collector, input.Config, ctx);
+ if (config == null)
+ {
+ collector.EmitError(string.Empty, "Failed to load changelog configuration");
+ return false;
+ }
+
+ // Validate required fields
+ if (string.IsNullOrWhiteSpace(input.Title))
+ {
+ collector.EmitError(string.Empty, "Title is required");
+ return false;
+ }
+
+ if (string.IsNullOrWhiteSpace(input.Type))
+ {
+ collector.EmitError(string.Empty, "Type is required");
+ return false;
+ }
+
+ if (input.Products.Count == 0)
+ {
+ collector.EmitError(string.Empty, "At least one product is required");
+ return false;
+ }
+
+ // Validate type is in allowed list
+ if (!config.AvailableTypes.Contains(input.Type))
+ {
+ collector.EmitError(string.Empty, $"Type '{input.Type}' is not in the list of available types. Available types: {string.Join(", ", config.AvailableTypes)}");
+ return false;
+ }
+
+ // Validate subtype if provided
+ if (!string.IsNullOrWhiteSpace(input.Subtype) && !config.AvailableSubtypes.Contains(input.Subtype))
+ {
+ collector.EmitError(string.Empty, $"Subtype '{input.Subtype}' is not in the list of available subtypes. Available subtypes: {string.Join(", ", config.AvailableSubtypes)}");
+ return false;
+ }
+
+ // Validate areas if configuration provides available areas
+ if (config.AvailableAreas != null && config.AvailableAreas.Count > 0)
+ {
+ foreach (var area in input.Areas.Where(area => !config.AvailableAreas.Contains(area)))
+ {
+ collector.EmitError(string.Empty, $"Area '{area}' is not in the list of available areas. Available areas: {string.Join(", ", config.AvailableAreas)}");
+ return false;
+ }
+ }
+
+ // Validate products if configuration provides available products
+ if (config.AvailableProducts != null && config.AvailableProducts.Count > 0)
+ {
+ foreach (var product in input.Products.Where(p => !config.AvailableProducts.Contains(p.Product)))
+ {
+ collector.EmitError(string.Empty, $"Product '{product.Product}' is not in the list of available products. Available products: {string.Join(", ", config.AvailableProducts)}");
+ return false;
+ }
+ }
+
+ // Validate lifecycle values in products
+ foreach (var product in input.Products.Where(product => !string.IsNullOrWhiteSpace(product.Lifecycle) && !config.AvailableLifecycles.Contains(product.Lifecycle)))
+ {
+ collector.EmitError(string.Empty, $"Lifecycle '{product.Lifecycle}' for product '{product.Product}' is not in the list of available lifecycles. Available lifecycles: {string.Join(", ", config.AvailableLifecycles)}");
+ return false;
+ }
+
+ // Build changelog data from input
+ var changelogData = BuildChangelogData(input);
+
+ // Generate YAML file
+ var yamlContent = GenerateYaml(changelogData, config);
+
+ // Determine output path
+ var outputDir = input.Output ?? Directory.GetCurrentDirectory();
+ if (!_fileSystem.Directory.Exists(outputDir))
+ {
+ _ = _fileSystem.Directory.CreateDirectory(outputDir);
+ }
+
+ // Generate filename (timestamp-slug.yaml)
+ var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
+ var slug = SanitizeFilename(input.Title);
+ var filename = $"{timestamp}-{slug}.yaml";
+ var filePath = _fileSystem.Path.Combine(outputDir, filename);
+
+ // Write file
+ await _fileSystem.File.WriteAllTextAsync(filePath, yamlContent, ctx);
+ _logger.LogInformation("Created changelog fragment: {FilePath}", filePath);
+
+ return true;
+ }
+ catch (OperationCanceledException)
+ {
+ // If cancelled, don't emit error; propagate cancellation signal.
+ throw;
+ }
+ catch (IOException ioEx)
+ {
+ collector.EmitError(string.Empty, $"IO error creating changelog: {ioEx.Message}", ioEx);
+ return false;
+ }
+ catch (UnauthorizedAccessException uaEx)
+ {
+ collector.EmitError(string.Empty, $"Access denied creating changelog: {uaEx.Message}", uaEx);
+ return false;
+ }
+ }
+
+ private async Task LoadChangelogConfiguration(
+ IDiagnosticsCollector collector,
+ string? configPath,
+ Cancel ctx
+ )
+ {
+ // Determine config file path
+ _ = configurationContext; // Suppress unused warning - kept for future extensibility
+ var finalConfigPath = configPath ?? _fileSystem.Path.Combine(Directory.GetCurrentDirectory(), "docs", "changelog.yml");
+
+ if (!_fileSystem.File.Exists(finalConfigPath))
+ {
+ // Use default configuration if file doesn't exist
+ _logger.LogWarning("Changelog configuration not found at {ConfigPath}, using defaults", finalConfigPath);
+ return ChangelogConfiguration.Default;
+ }
+
+ try
+ {
+ var yamlContent = await _fileSystem.File.ReadAllTextAsync(finalConfigPath, ctx);
+ var deserializer = new StaticDeserializerBuilder(new ChangelogYamlStaticContext())
+ .WithNamingConvention(UnderscoredNamingConvention.Instance)
+ .Build();
+
+ var config = deserializer.Deserialize(yamlContent);
+ return config;
+ }
+ catch (IOException ex)
+ {
+ collector.EmitError(finalConfigPath, $"I/O error loading changelog configuration: {ex.Message}", ex);
+ return null;
+ }
+ catch (UnauthorizedAccessException ex)
+ {
+ collector.EmitError(finalConfigPath, $"Access denied loading changelog configuration: {ex.Message}", ex);
+ return null;
+ }
+ catch (YamlException ex)
+ {
+ collector.EmitError(finalConfigPath, $"YAML parsing error in changelog configuration: {ex.Message}", ex);
+ return null;
+ }
+ }
+
+ private static ChangelogData BuildChangelogData(ChangelogInput input)
+ {
+ var data = new ChangelogData
+ {
+ Title = input.Title,
+ Type = input.Type,
+ Subtype = input.Subtype,
+ Description = input.Description,
+ Impact = input.Impact,
+ Action = input.Action,
+ FeatureId = input.FeatureId,
+ Highlight = input.Highlight,
+ Pr = input.Pr,
+ Products = input.Products
+ };
+
+ if (input.Areas.Length > 0)
+ {
+ data.Areas = input.Areas.ToList();
+ }
+
+ if (input.Issues.Length > 0)
+ {
+ data.Issues = input.Issues.ToList();
+ }
+
+ return data;
+ }
+
+ private string GenerateYaml(ChangelogData data, ChangelogConfiguration config)
+ {
+ // Ensure areas is null if empty to omit it from YAML
+ if (data.Areas != null && data.Areas.Count == 0)
+ data.Areas = null;
+
+ // Ensure issues is null if empty to omit it from YAML
+ if (data.Issues != null && data.Issues.Count == 0)
+ data.Issues = null;
+
+ var serializer = new StaticSerializerBuilder(new ChangelogYamlStaticContext())
+ .WithNamingConvention(UnderscoredNamingConvention.Instance)
+ .ConfigureDefaultValuesHandling(DefaultValuesHandling.OmitNull | DefaultValuesHandling.OmitEmptyCollections)
+ .Build();
+
+ var yaml = serializer.Serialize(data);
+
+ // Build types list
+ var typesList = string.Join("\n", config.AvailableTypes.Select(t => $"# - {t}"));
+
+ // Build subtypes list
+ var subtypesList = config.AvailableSubtypes.Count > 0
+ ? "\n# It can be one of:\n" + string.Join("\n", config.AvailableSubtypes.Select(s => $"# - {s}"))
+ : string.Empty;
+
+ // Add schema comments using raw string literal
+ var result = $"""
+ ##### Automated fields #####
+
+ # These fields are likely generated when the changelog is created and unlikely to require edits
+
+ # pr: An optional string that contains the pull request number
+ # issues: An optional array of strings that contain URLs for issues that are relevant to the PR
+ # type: A required string that contains the type of change
+ # It can be one of:
+ {typesList}
+ # subtype: An optional string that applies only to breaking changes{subtypesList}
+ # products: A required array of objects that denote the affected products
+ # Each product object contains:
+ # - product: A required string with a predefined product ID
+ # - target: An optional string with the target version or date
+ # - lifecycle: An optional string (preview, beta, ga)
+ # areas: An optional array of strings that denotes the parts/components/services affected
+
+ ##### Non-automated fields #####
+
+ # These fields might be generated when the changelog is created but are likely to require edits
+
+ # title: A required string that is a short, user-facing headline (Max 80 characters)
+ # description: An optional string that provides additional information (Max 600 characters)
+ # impact: An optional string that describes how the user's environment is affected
+ # action: An optional string that describes what users must do to mitigate
+ # feature-id: An optional string to associate with a unique feature flag
+ # highlight: An optional boolean for items that should be included in release highlights
+
+ {yaml}
+ """;
+
+ return result;
+ }
+
+ private static string SanitizeFilename(string input)
+ {
+ var sanitized = input.ToLowerInvariant()
+ .Replace(" ", "-")
+ .Replace("/", "-")
+ .Replace("\\", "-")
+ .Replace(":", "")
+ .Replace("'", "")
+ .Replace("\"", "");
+
+ // Limit length
+ if (sanitized.Length > 50)
+ sanitized = sanitized[..50];
+
+ return sanitized;
+ }
+}
+
diff --git a/src/services/Elastic.Documentation.Services/Elastic.Documentation.Services.csproj b/src/services/Elastic.Documentation.Services/Elastic.Documentation.Services.csproj
index bf808d354..0494a04d1 100644
--- a/src/services/Elastic.Documentation.Services/Elastic.Documentation.Services.csproj
+++ b/src/services/Elastic.Documentation.Services/Elastic.Documentation.Services.csproj
@@ -6,8 +6,15 @@
enable
+
+
+
+
+
+
+
diff --git a/src/tooling/docs-builder/Arguments/ProductInfoParser.cs b/src/tooling/docs-builder/Arguments/ProductInfoParser.cs
new file mode 100644
index 000000000..db10dc169
--- /dev/null
+++ b/src/tooling/docs-builder/Arguments/ProductInfoParser.cs
@@ -0,0 +1,51 @@
+// 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 ConsoleAppFramework;
+using Elastic.Documentation.Services.Changelog;
+
+namespace Documentation.Builder.Arguments;
+
+[AttributeUsage(AttributeTargets.Parameter)]
+public class ProductInfoParserAttribute : Attribute, IArgumentParser>
+{
+ public static bool TryParse(ReadOnlySpan s, out List result)
+ {
+ result = [];
+
+ // Split by comma to get individual product entries
+ var productEntries = s.ToString().Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+
+ foreach (var entry in productEntries)
+ {
+ // Split by whitespace to get product, target, lifecycle
+ var parts = entry.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+
+ if (parts.Length == 0)
+ continue;
+
+ var productInfo = new ProductInfo
+ {
+ Product = parts[0]
+ };
+
+ // Target is optional (second part)
+ if (parts.Length > 1)
+ {
+ productInfo.Target = parts[1];
+ }
+
+ // Lifecycle is optional (third part)
+ if (parts.Length > 2)
+ {
+ productInfo.Lifecycle = parts[2];
+ }
+
+ result.Add(productInfo);
+ }
+
+ return result.Count > 0;
+ }
+}
+
diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs
new file mode 100644
index 000000000..16f09f978
--- /dev/null
+++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs
@@ -0,0 +1,97 @@
+// 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 ConsoleAppFramework;
+using Documentation.Builder.Arguments;
+using Elastic.Documentation.Configuration;
+using Elastic.Documentation.Diagnostics;
+using Elastic.Documentation.Services;
+using Elastic.Documentation.Services.Changelog;
+using Microsoft.Extensions.Logging;
+
+namespace Documentation.Builder.Commands;
+
+internal sealed class ChangelogCommand(
+ ILoggerFactory logFactory,
+ IDiagnosticsCollector collector,
+ IConfigurationContext configurationContext
+)
+{
+ ///
+ /// Changelog commands. Use 'changelog add' to create a new changelog fragment.
+ ///
+ [Command("")]
+ public Task Default()
+ {
+ collector.EmitError(string.Empty, "Please specify a subcommand. Use 'changelog add' to create a new changelog fragment. Run 'changelog add --help' for usage information.");
+ return Task.FromResult(1);
+ }
+
+ ///
+ /// Add a new changelog fragment from command-line input
+ ///
+ /// Required: A short, user-facing title (max 80 characters)
+ /// Required: Type of change (feature, enhancement, bug-fix, breaking-change, etc.)
+ /// Required: Products affected in format "product target lifecycle, ..." (e.g., "elasticsearch 9.2.0 ga, cloud-serverless 2025-08-05")
+ /// Optional: Subtype for breaking changes (api, behavioral, configuration, etc.)
+ /// Optional: Area(s) affected (comma-separated or specify multiple times)
+ /// Optional: Pull request URL
+ /// Optional: Issue URL(s) (comma-separated or specify multiple times)
+ /// Optional: Additional information about the change (max 600 characters)
+ /// Optional: How the user's environment is affected
+ /// Optional: What users must do to mitigate
+ /// Optional: Feature flag ID
+ /// Optional: Include in release highlights
+ /// Optional: Output directory for the changelog fragment. Defaults to current directory
+ /// Optional: Path to the changelog.yml configuration file. Defaults to 'docs/changelog.yml'
+ ///
+ [Command("add")]
+ public async Task Create(
+ string title,
+ string type,
+ [ProductInfoParser] List products,
+ string? subtype = null,
+ string[]? areas = null,
+ string? pr = null,
+ string[]? issues = null,
+ string? description = null,
+ string? impact = null,
+ string? action = null,
+ string? featureId = null,
+ bool? highlight = null,
+ string? output = null,
+ string? config = null,
+ Cancel ctx = default
+ )
+ {
+ await using var serviceInvoker = new ServiceInvoker(collector);
+
+ var service = new ChangelogService(logFactory, configurationContext);
+
+ var input = new ChangelogInput
+ {
+ Title = title,
+ Type = type,
+ Products = products,
+ Subtype = subtype,
+ Areas = areas ?? [],
+ Pr = pr,
+ Issues = issues ?? [],
+ Description = description,
+ Impact = impact,
+ Action = action,
+ FeatureId = featureId,
+ Highlight = highlight,
+ Output = output,
+ Config = config
+ };
+
+ serviceInvoker.AddCommand(service, input,
+ async static (s, collector, state, ctx) => await s.CreateChangelog(collector, state, ctx)
+ );
+
+ return await serviceInvoker.InvokeAsync(ctx);
+ }
+}
+
diff --git a/src/tooling/docs-builder/Program.cs b/src/tooling/docs-builder/Program.cs
index a99f7538c..fe06e63f7 100644
--- a/src/tooling/docs-builder/Program.cs
+++ b/src/tooling/docs-builder/Program.cs
@@ -34,6 +34,7 @@
app.Add("serve");
app.Add("index");
app.Add("format");
+app.Add("changelog");
//assembler commands