From b38b5242a0c97bc87def02bae82035349bd748a8 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Wed, 22 Apr 2026 20:52:30 +0200 Subject: [PATCH 01/50] Add deprecated detection rules as sibling nav page - Auto-detects _deprecated/ subfolders in detection rule folders and generates a sibling "Deprecated prebuilt detection rules" page in the nav - Optional deprecated_file: field in docset.yml names a .md file whose content prefixes the auto-generated deprecated rules listing - Each deprecated rule page shows a deprecation warning with the deprecation date - Fixed stateful TomlParser causing serve-mode parse failures on repeated builds - Fixed serve --path scope: in-memory write FS now scoped to git root of --path so output at {externalRepo}/.artifacts is within allowed write scope - Fixed serve sourcePath: ReloadGeneratorService now passes DocumentationCheckoutDirectory (git root) rather than DocumentationSourceDirectory (docs/ subfolder) Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../FileSystemFactory.cs | 15 +++ .../DetectionRuleOverviewRef.cs | 46 +++++++- .../Toc/DocumentationSetFile.cs | 33 +++++- .../Toc/TableOfContentsYamlConverters.cs | 9 +- .../DetectionRules/DetectionRule.cs | 14 ++- .../DetectionRules/DetectionRuleFile.cs | 76 ++++++++++++- .../DetectionRulesDocsBuilderExtension.cs | 101 +++++++++++++++--- .../docs-builder/Http/InMemoryBuildState.cs | 23 +++- .../Http/ReloadGeneratorService.cs | 6 +- 9 files changed, 289 insertions(+), 34 deletions(-) diff --git a/src/Elastic.Documentation.Configuration/FileSystemFactory.cs b/src/Elastic.Documentation.Configuration/FileSystemFactory.cs index 50870d5d53..ea79a08f82 100644 --- a/src/Elastic.Documentation.Configuration/FileSystemFactory.cs +++ b/src/Elastic.Documentation.Configuration/FileSystemFactory.cs @@ -67,6 +67,21 @@ public static class FileSystemFactory /// public static ScopedFileSystem InMemory() => new(new MockFileSystem(), WorkingDirectoryReadOptions); + /// + /// Creates a new wrapping a fresh , + /// scoped to the git root of so that paths such as + /// {sourceRoot}/.artifacts/docs/html are within the allowed write scope. + /// Falls back to when is . + /// + public static ScopedFileSystem InMemoryForSourceRoot(string? sourcePath) + { + if (sourcePath is null) + return InMemory(); + var root = Paths.FindGitRoot(sourcePath); + var inner = new MockFileSystem(); + return new ScopedFileSystem(inner, BuildWriteOptions(inner, root, Paths.ApplicationData.FullName)); + } + /// /// Scopes to and /// for reading. Use when the inner FS contains files diff --git a/src/Elastic.Documentation.Configuration/Toc/DetectionRules/DetectionRuleOverviewRef.cs b/src/Elastic.Documentation.Configuration/Toc/DetectionRules/DetectionRuleOverviewRef.cs index 6751278a2d..fd92a37e83 100644 --- a/src/Elastic.Documentation.Configuration/Toc/DetectionRules/DetectionRuleOverviewRef.cs +++ b/src/Elastic.Documentation.Configuration/Toc/DetectionRules/DetectionRuleOverviewRef.cs @@ -10,12 +10,22 @@ public record DetectionRuleOverviewRef : FileRef { public IReadOnlyCollection DetectionRuleFolders { get; } + /// Optional path to a markdown file whose content prefixes the deprecated rules listing page. + public string? DeprecatedFile { get; init; } + + /// + /// The resolved deprecated-rules overview FileRef that should appear as a sibling to this ref in the nav. + /// Set by ResolveRuleOverviewReference when a _deprecated subfolder is detected. + /// + public FileRef? DeprecatedSiblingRef { get; init; } + public DetectionRuleOverviewRef( string pathRelativeToDocumentationSet, string pathRelativeToContainer, IReadOnlyCollection detectionRulesFolders, IReadOnlyCollection children, - string context + string context, + string? deprecatedFile = null ) : base(pathRelativeToDocumentationSet, pathRelativeToContainer, false, children, context) { PathRelativeToDocumentationSet = pathRelativeToDocumentationSet; @@ -23,6 +33,7 @@ string context DetectionRuleFolders = detectionRulesFolders; Children = children; Context = context; + DeprecatedFile = deprecatedFile; } public static IReadOnlyCollection CreateTableOfContentItems(IReadOnlyCollection sourceFolders, string context, IDirectoryInfo baseDirectory) @@ -38,6 +49,18 @@ public static IReadOnlyCollection CreateTableOfContentItem .ToArray(); } + public static IReadOnlyCollection CreateDeprecatedTableOfContentItems(IReadOnlyCollection sourceFolders, string context, IDirectoryInfo baseDirectory) + { + var tocItems = new List(); + foreach (var detectionRuleFolder in sourceFolders) + { + var children = ReadDeprecatedDetectionRuleFolder(detectionRuleFolder, context, baseDirectory); + tocItems.AddRange(children); + } + + return tocItems.ToArray(); + } + private static IReadOnlyCollection ReadDetectionRuleFolder(IDirectoryInfo directory, string context, IDirectoryInfo baseDirectory) { IReadOnlyCollection children = directory @@ -62,4 +85,25 @@ private static IReadOnlyCollection ReadDetectionRuleFolder return children; } + + private static IReadOnlyCollection ReadDeprecatedDetectionRuleFolder(IDirectoryInfo directory, string context, IDirectoryInfo baseDirectory) + { + IReadOnlyCollection children = directory + .EnumerateFiles("*.*", SearchOption.AllDirectories) + .Where(f => !f.Attributes.HasFlag(FileAttributes.Hidden) && !f.Attributes.HasFlag(FileAttributes.System)) + .Where(f => !f.Directory!.Attributes.HasFlag(FileAttributes.Hidden) && !f.Directory!.Attributes.HasFlag(FileAttributes.System)) + // skip symlinks + .Where(f => f.LinkTarget == null) + .Where(f => f.Extension == ".toml") + // only include files inside _deprecated subdirectories + .Where(f => f.FullName.Contains($"{Path.DirectorySeparatorChar}_deprecated{Path.DirectorySeparatorChar}")) + .Select(f => + { + var relativePath = Path.GetRelativePath(baseDirectory.Parent!.FullName, f.FullName); + return (ITableOfContentsItem)new DetectionRuleRef(f, relativePath, context); + }) + .ToArray(); + + return children; + } } diff --git a/src/Elastic.Documentation.Configuration/Toc/DocumentationSetFile.cs b/src/Elastic.Documentation.Configuration/Toc/DocumentationSetFile.cs index c21280f707..cd0c58b391 100644 --- a/src/Elastic.Documentation.Configuration/Toc/DocumentationSetFile.cs +++ b/src/Elastic.Documentation.Configuration/Toc/DocumentationSetFile.cs @@ -164,7 +164,12 @@ private static TableOfContents ResolveTableOfContents( }; if (resolvedItem != null) + { resolved.Add(resolvedItem); + // Emit the deprecated rules overview as a sibling immediately after the active rules ref + if (resolvedItem is DetectionRuleOverviewRef { DeprecatedSiblingRef: { } deprecatedSibling }) + resolved.Add(deprecatedSibling); + } } return resolved; @@ -443,9 +448,33 @@ private static ITableOfContentsItem ResolveRuleOverviewReference(IDiagnosticsCol .ToList(); var tomlChildren = DetectionRuleOverviewRef.CreateTableOfContentItems(tocSourceFolders, context, baseDirectory); - var children = resolvedChildren.Concat(tomlChildren).ToList(); + var children = resolvedChildren.ToList(); + children.AddRange(tomlChildren); - return new DetectionRuleOverviewRef(fullPath, pathRelativeToContainer, detectionRuleRef.DetectionRuleFolders, children, context); + // Auto-detect _deprecated subdirectories. When found, build the deprecated overview FileRef + // and attach it as DeprecatedSiblingRef so ResolveTableOfContents can emit it as a sibling, + // not as a child nested under the active rules. + FileRef? deprecatedSiblingRef = null; + var hasDeprecatedRules = tocSourceFolders.Any(d => + d.Exists && d.EnumerateDirectories("_deprecated", SearchOption.TopDirectoryOnly).Any()); + if (hasDeprecatedRules) + { + var deprecatedFileName = detectionRuleRef.DeprecatedFile ?? "deprecated-detection-rules.md"; + var overviewDir = fileSystem.Path.GetDirectoryName(fullPath); + var deprecatedFullPath = string.IsNullOrEmpty(overviewDir) + ? deprecatedFileName + : $"{overviewDir}/{deprecatedFileName}"; + var deprecatedPathRelativeToContainer = string.IsNullOrEmpty(containerPath) + ? deprecatedFullPath + : deprecatedFullPath.Substring(containerPath.Length + 1); + var deprecatedTomlChildren = DetectionRuleOverviewRef.CreateDeprecatedTableOfContentItems(tocSourceFolders, context, baseDirectory); + deprecatedSiblingRef = new FileRef(deprecatedFullPath, deprecatedPathRelativeToContainer, false, deprecatedTomlChildren, context); + } + + return new DetectionRuleOverviewRef(fullPath, pathRelativeToContainer, detectionRuleRef.DetectionRuleFolders, children, context, detectionRuleRef.DeprecatedFile) + { + DeprecatedSiblingRef = deprecatedSiblingRef + }; } diff --git a/src/Elastic.Documentation.Configuration/Toc/TableOfContentsYamlConverters.cs b/src/Elastic.Documentation.Configuration/Toc/TableOfContentsYamlConverters.cs index 428f6003a2..3cc11c7516 100644 --- a/src/Elastic.Documentation.Configuration/Toc/TableOfContentsYamlConverters.cs +++ b/src/Elastic.Documentation.Configuration/Toc/TableOfContentsYamlConverters.cs @@ -72,7 +72,7 @@ public class TocItemYamlConverter : IYamlTypeConverter } value = childrenList; } - else if (key.Value is "detection_rules" or "exclude") + else if (key.Value is "detection_rules" or "exclude" or "deprecated_detection_rules") { // Parse the children list manually var childrenList = new List(); @@ -135,11 +135,8 @@ public class TocItemYamlConverter : IYamlTypeConverter if (dictionary.TryGetValue("detection_rules", out var detectionRulesObj) && detectionRulesObj is string[] detectionRulesFolders && dictionary.TryGetValue("file", out var detectionRulesFilePath) && detectionRulesFilePath is string detectionRulesFile) { - // Create the index file reference (FolderIndexFileRef to mark it as the folder's index) - // Store ONLY the file name - the folder path will be prepended during resolution - // This allows validation to check if the file itself has deep paths - // PathRelativeToContainer will be set during resolution - return new DetectionRuleOverviewRef(detectionRulesFile, detectionRulesFile, detectionRulesFolders, children, placeholderContext); + var deprecatedFile = dictionary.TryGetValue("deprecated_file", out var deprecatedFileObj) && deprecatedFileObj is string df ? df : null; + return new DetectionRuleOverviewRef(detectionRulesFile, detectionRulesFile, detectionRulesFolders, children, placeholderContext, deprecatedFile); } // Check for file reference (file: or hidden:) diff --git a/src/Elastic.Markdown/Extensions/DetectionRules/DetectionRule.cs b/src/Elastic.Markdown/Extensions/DetectionRules/DetectionRule.cs index 7a66e4147a..e5feac3bf4 100644 --- a/src/Elastic.Markdown/Extensions/DetectionRules/DetectionRule.cs +++ b/src/Elastic.Markdown/Extensions/DetectionRules/DetectionRule.cs @@ -63,8 +63,7 @@ public record DetectionRuleTechnique : DetectionRuleSubTechnique public record DetectionRule { - // Reuse a single TomlParser instance for better performance - private static readonly TomlParser Parser = new(); + // TomlParser is stateful across calls; create a new instance per parse to avoid accumulation bugs in serve/reload mode // Cached version lock data, loaded once per build private static FrozenDictionary? VersionLock; @@ -103,6 +102,9 @@ public record DetectionRule public required DetectionRuleThreat[] Threats { get; init; } = []; + public required string? DeprecationDate { get; init; } + public required string? Maturity { get; init; } + /// /// Initializes the version lock cache from the version.lock.json file. /// This should be called once before processing detection rules. @@ -145,14 +147,14 @@ public static DetectionRule From(IFileInfo source) // Use optimized Utf8StreamReader for better I/O performance using var reader = new Utf8StreamReader(source.FullName, fileOpenMode: FileOpenMode.Throughput); var sourceText = Encoding.UTF8.GetString(reader.ReadToEndAsync().GetAwaiter().GetResult()); - model = Parser.Parse(sourceText); + model = new TomlParser().Parse(sourceText); } catch (Exception e) { throw new Exception($"Could not parse toml in: {source.FullName}", e); } - if (!model.TryGetValue("metadata", out var node) || node is not TomlTable) + if (!model.TryGetValue("metadata", out var node) || node is not TomlTable metadata) throw new Exception($"Could not find metadata section in {source.FullName}"); if (!model.TryGetValue("rule", out node) || node is not TomlTable rule) @@ -190,7 +192,9 @@ public static DetectionRule From(IFileInfo source) RunsEvery = TryGetString(rule, "interval"), MaximumAlertsPerExecution = maxSignals, Version = version, - Threats = threats + Threats = threats, + DeprecationDate = TryGetString(metadata, "deprecation_date"), + Maturity = TryGetString(metadata, "maturity") }; } diff --git a/src/Elastic.Markdown/Extensions/DetectionRules/DetectionRuleFile.cs b/src/Elastic.Markdown/Extensions/DetectionRules/DetectionRuleFile.cs index 059fe728e7..eae417fcea 100644 --- a/src/Elastic.Markdown/Extensions/DetectionRules/DetectionRuleFile.cs +++ b/src/Elastic.Markdown/Extensions/DetectionRules/DetectionRuleFile.cs @@ -11,6 +11,68 @@ namespace Elastic.Markdown.Extensions.DetectionRules; +public record DeprecatedDetectionRuleOverviewFile : MarkdownFile +{ + public DeprecatedDetectionRuleOverviewFile(IFileInfo sourceFile, IDirectoryInfo rootPath, MarkdownParser parser, BuildContext build) + : base(sourceFile, rootPath, parser, build) + { + } + + internal ILeafNavigationItem[] RuleNavigations { get; set; } = []; + + protected override Task GetMinimalParseDocumentAsync(Cancel ctx) + { + var markdown = GetMarkdown(); + var document = MarkdownParser.MinimalParseStringAsync(markdown, SourceFile, null); + return Task.FromResult(document); + } + + protected override Task GetParseDocumentAsync(Cancel ctx) + { + var markdown = GetMarkdown(); + var document = MarkdownParser.ParseStringAsync(markdown, SourceFile, null); + return Task.FromResult(document); + } + + private string GetMarkdown() + { + var rules = RuleNavigations.Select(nav => (Navigation: nav, Model: (DetectionRuleFile)nav.Model)).ToList(); + + string intro; + if (SourceFile.Exists) + { + var content = SourceFile.FileSystem.File.ReadAllText(SourceFile.FullName); + // Extract H1 title from the file if present, use remainder as intro body + var lines = content.Split('\n'); + var titleLine = lines.FirstOrDefault(l => l.TrimStart().StartsWith("# ", StringComparison.Ordinal)); + if (titleLine != null) + Title = titleLine.TrimStart().Substring(2).Trim(); + intro = content; + } + else + { + Title = "Deprecated prebuilt detection rules"; + intro = "# Deprecated prebuilt detection rules\n\n"; + } + + var markdown = intro + "\n\n"; + + var groupedRules = rules + .GroupBy(r => r.Model.Rule.Domain ?? "Unspecified") + .OrderBy(g => g.Key) + .ToArray(); + + foreach (var group in groupedRules) + { + markdown += $"\n## {group.Key}\n\n"; + foreach (var (navigation, model) in group.OrderBy(r => r.Model.Rule.Name)) + markdown += $"[{model.Rule.Name}](!{navigation.Url})
\n"; + } + + return markdown; + } +} + public record DetectionRuleOverviewFile : MarkdownFile { public DetectionRuleOverviewFile(IFileInfo sourceFile, IDirectoryInfo rootPath, MarkdownParser parser, BuildContext build) @@ -102,7 +164,8 @@ BuildContext build private static IFileInfo SourcePath(IFileInfo rulePath, BuildContext build) { - var relative = Path.GetRelativePath(build.DocumentationCheckoutDirectory!.FullName, rulePath.FullName); + var checkoutDir = build.DocumentationCheckoutDirectory ?? build.DocumentationSourceDirectory.Parent!; + var relative = Path.GetRelativePath(checkoutDir.FullName, rulePath.FullName); var newPath = Path.Join(build.DocumentationSourceDirectory.FullName, relative); var md = Path.ChangeExtension(newPath, ".md"); return rulePath.FileSystem.FileInfo.New(md); @@ -136,12 +199,21 @@ protected override Task GetParseDocumentAsync(Cancel ctx) private string GetMarkdown() { + var deprecationNotice = Rule.Maturity == "deprecated" + ? $""" + :::{"{warning}"} + This rule has been deprecated{(Rule.DeprecationDate != null ? $" as of {Rule.DeprecationDate}" : "")}. + ::: + + """ + : string.Empty; + // language=markdown var markdown = $""" # {Rule.Name} -{Rule.Description} +{deprecationNotice}{Rule.Description} **Rule type**: {Rule.Type}
**Rule indices**: {RenderArray(Rule.Indices)} diff --git a/src/Elastic.Markdown/Extensions/DetectionRules/DetectionRulesDocsBuilderExtension.cs b/src/Elastic.Markdown/Extensions/DetectionRules/DetectionRulesDocsBuilderExtension.cs index 82924f929b..0c7424540b 100644 --- a/src/Elastic.Markdown/Extensions/DetectionRules/DetectionRulesDocsBuilderExtension.cs +++ b/src/Elastic.Markdown/Extensions/DetectionRules/DetectionRulesDocsBuilderExtension.cs @@ -16,13 +16,16 @@ namespace Elastic.Markdown.Extensions.DetectionRules; public class DetectionRulesDocsBuilderExtension(BuildContext build) : IDocsBuilderExtension { - private BuildContext Build { get; } = build; + private BuildContext Build => build; private bool _versionLockInitialized; + private IReadOnlySet DeprecatedOverviewFileNames { get; } = + GetAllDetectionRuleOverviewRefs(build.ConfigurationYaml.TableOfContents) + .Select(r => r.DeprecatedFile ?? "deprecated-detection-rules.md") + .ToHashSet(); + public IEnumerable ExternalScopeRoots => - Build.ConfigurationYaml.TableOfContents - .OfType() - .SelectMany(f => f.Children.OfType()) + GetAllDetectionRuleOverviewRefs(Build.ConfigurationYaml.TableOfContents) .SelectMany(r => r.DetectionRuleFolders) .Select(f => Path.GetFullPath(f, Build.DocumentationSourceDirectory.FullName)) .Distinct(); @@ -31,6 +34,10 @@ public class DetectionRulesDocsBuilderExtension(BuildContext build) : IDocsBuild public DocumentationFile? CreateDocumentationFile(IFileInfo file, MarkdownParser markdownParser) { + // Handle the synthetic deprecated overview .md file when no physical file exists on disk + if (file.Extension == ".md" && DeprecatedOverviewFileNames.Contains(file.Name)) + return new DeprecatedDetectionRuleOverviewFile(file, Build.DocumentationSourceDirectory, markdownParser, Build); + if (file.Extension != ".toml") return null; @@ -44,22 +51,36 @@ public class DetectionRulesDocsBuilderExtension(BuildContext build) : IDocsBuild return new DetectionRuleFile(file, Build.DocumentationSourceDirectory, markdownParser, Build); } - public MarkdownFile? CreateMarkdownFile(IFileInfo file, IDirectoryInfo sourceDirectory, MarkdownParser markdownParser) => - file.Name != "index.md" ? null : new DetectionRuleOverviewFile(file, sourceDirectory, markdownParser, Build); + public MarkdownFile? CreateMarkdownFile(IFileInfo file, IDirectoryInfo sourceDirectory, MarkdownParser markdownParser) + { + if (file.Name == "index.md") + return new DetectionRuleOverviewFile(file, sourceDirectory, markdownParser, Build); + // Physical deprecated_file on disk takes priority over the synthetic path + if (DeprecatedOverviewFileNames.Contains(file.Name)) + return new DeprecatedDetectionRuleOverviewFile(file, sourceDirectory, markdownParser, Build); + return null; + } /// public void VisitNavigation(INavigationItem navigation, IDocumentationFile model) { - if (model is not DetectionRuleOverviewFile overview) - return; if (navigation is not VirtualFileNavigation node) return; - var detectionRuleNavigations = node.NavigationItems + + var ruleNavigations = node.NavigationItems .OfType>() .Where(n => n.Model is DetectionRuleFile) .ToArray(); - overview.RuleNavigations = detectionRuleNavigations; + switch (model) + { + case DetectionRuleOverviewFile overview: + overview.RuleNavigations = ruleNavigations; + break; + case DeprecatedDetectionRuleOverviewFile deprecatedOverview: + deprecatedOverview.RuleNavigations = ruleNavigations; + break; + } } public bool TryGetDocumentationFileBySlug(DocumentationSet documentationSet, string slug, out DocumentationFile? documentationFile) @@ -71,11 +92,65 @@ public bool TryGetDocumentationFileBySlug(DocumentationSet documentationSet, str public IReadOnlyCollection<(IFileInfo, DocumentationFile)> ScanDocumentationFiles(Func defaultFileHandling) { - var rules = Build.ConfigurationYaml.TableOfContents.OfType().First().Children.OfType().ToArray(); - if (rules.Length == 0) + var overviewRefs = GetAllDetectionRuleOverviewRefs(Build.ConfigurationYaml.TableOfContents).ToArray(); + + // Pass each overviewRef as a single-element sequence so the switch case that + // checks DeprecatedSiblingRef is triggered, picking up both active and deprecated rules. + var rules = overviewRefs + .SelectMany(r => GetAllDetectionRuleRefs([r])) + .ToArray(); + + var result = rules + .Select(r => (r.FileInfo, defaultFileHandling(r.FileInfo, r.FileInfo.Directory!))) + .ToList(); + + // Pre-register synthetic deprecated overview files for overviews without a physical file. + // When the physical file exists it is already picked up by the normal source directory scan. + foreach (var overviewRef in overviewRefs) + { + var deprecatedFileName = overviewRef.DeprecatedFile ?? "deprecated-detection-rules.md"; + var syntheticPath = Build.ReadFileSystem.Path.Join(Build.DocumentationSourceDirectory.FullName, deprecatedFileName); + var syntheticFileInfo = Build.ReadFileSystem.FileInfo.New(syntheticPath); + + if (syntheticFileInfo.Exists) + continue; // physical file handled by normal scan + + // Only register if this overview actually has deprecated rule children (now in DeprecatedSiblingRef) + var hasDeprecatedChildren = overviewRef.DeprecatedSiblingRef?.Children.OfType().Any() == true; + + if (!hasDeprecatedChildren) + continue; + + var deprecatedFile = defaultFileHandling(syntheticFileInfo, Build.DocumentationSourceDirectory); + if (deprecatedFile is not ExcludedFile) + result.Add((syntheticFileInfo, deprecatedFile)); + } + + if (result.Count == 0) return []; - return rules.Select(r => (r.FileInfo, defaultFileHandling(r.FileInfo, r.FileInfo.Directory!))).ToArray(); + return result.ToArray(); } + // Finds all DetectionRuleOverviewRef instances at any depth in the TOC tree + private static IEnumerable GetAllDetectionRuleOverviewRefs(IEnumerable items) => + items.SelectMany(item => item switch + { + DetectionRuleOverviewRef r => [r], + FileRef fr => GetAllDetectionRuleOverviewRefs(fr.Children), + _ => [] + }); + + // Finds all DetectionRuleRef instances at any depth within a set of TOC items. + // Also scans DeprecatedSiblingRef on DetectionRuleOverviewRef since deprecated rules + // are no longer nested in Children — they live in the sibling's Children instead. + private static IEnumerable GetAllDetectionRuleRefs(IEnumerable items) => + items.SelectMany(item => item switch + { + DetectionRuleRef dr => [dr], + DetectionRuleOverviewRef r when r.DeprecatedSiblingRef is { } dep => + GetAllDetectionRuleRefs(r.Children).Concat(GetAllDetectionRuleRefs(dep.Children)), + FileRef fr => GetAllDetectionRuleRefs(fr.Children), + _ => [] + }); } diff --git a/src/tooling/docs-builder/Http/InMemoryBuildState.cs b/src/tooling/docs-builder/Http/InMemoryBuildState.cs index 43154396c9..df67729f09 100644 --- a/src/tooling/docs-builder/Http/InMemoryBuildState.cs +++ b/src/tooling/docs-builder/Http/InMemoryBuildState.cs @@ -53,8 +53,11 @@ public class InMemoryBuildState(ILoggerFactory loggerFactory, IConfigurationCont private readonly Lock _diagnosticsLock = new(); private readonly List _diagnostics = []; - // Reuse MockFileSystem across builds to benefit from caching - private readonly ScopedFileSystem _writeFs = FileSystemFactory.InMemory(); + // Reuse MockFileSystem across builds to benefit from caching. + // Scoped lazily to the git root of the first sourcePath seen, so paths like + // {externalRepo}/.artifacts/docs/html are within the allowed write scope. + private ScopedFileSystem? _writeFs; + private readonly Lock _writeFsLock = new(); // Broadcast: maintain list of connected client channels private readonly Lock _clientsLock = new(); @@ -170,6 +173,7 @@ private async Task ExecuteBuildAsync(string sourcePath, Cancel ct) var streamingCollector = new StreamingDiagnosticsCollector(_loggerFactory, this); var readFs = FileSystemFactory.RealGitRootForPath(sourcePath); + var writeFs = GetOrCreateWriteFs(sourcePath); var service = new IsolatedBuildService(_loggerFactory, _configurationContext, new NullCoreService(), SystemEnvironmentVariables.Instance); _logger.LogInformation("Starting in-memory validation build for {Path}", sourcePath); @@ -186,7 +190,7 @@ private async Task ExecuteBuildAsync(string sourcePath, Cancel ct) false, // metadataOnly ExportOptions.Default, null, // canonicalBaseUrl - _writeFs, // reuse MockFileSystem across builds for caching + writeFs, // reuse MockFileSystem across builds for caching true, // skipOpenApi - skip for faster validation builds false, // skipCrossLinks - enable cross-links (cached in MockFileSystem) ct @@ -308,6 +312,19 @@ public DiagnosticDto[] GetStoredDiagnostics() Diagnostics: GetStoredDiagnostics() ); + private ScopedFileSystem GetOrCreateWriteFs(string sourcePath) + { + if (_writeFs is not null) + return _writeFs; + lock (_writeFsLock) + { + if (_writeFs is not null) + return _writeFs; + _writeFs = FileSystemFactory.InMemoryForSourceRoot(sourcePath); + return _writeFs; + } + } + public void Dispose() { _currentBuildCts?.Cancel(); diff --git a/src/tooling/docs-builder/Http/ReloadGeneratorService.cs b/src/tooling/docs-builder/Http/ReloadGeneratorService.cs index 1cf66d4d90..c20762a943 100644 --- a/src/tooling/docs-builder/Http/ReloadGeneratorService.cs +++ b/src/tooling/docs-builder/Http/ReloadGeneratorService.cs @@ -42,7 +42,8 @@ public async Task StartAsync(Cancel cancellationToken) _serviceCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); // Run live reload and in-memory validation build in parallel - var sourcePath = ReloadableGenerator.Generator.Context.DocumentationSourceDirectory.FullName; + var sourcePath = ReloadableGenerator.Generator.Context.DocumentationCheckoutDirectory?.FullName + ?? ReloadableGenerator.Generator.Context.DocumentationSourceDirectory.FullName; await Task.WhenAll( ReloadableGenerator.ReloadAsync(cancellationToken), InMemoryBuildState.StartBuildAsync(sourcePath, cancellationToken) @@ -88,7 +89,8 @@ private void Reload(bool reloadConfiguration = false) var token = _serviceCts?.Token ?? Cancel.None; _ = _debouncer.ExecuteAsync(async ctx => { - var sourcePath = ReloadableGenerator.Generator.Context.DocumentationSourceDirectory.FullName; + var sourcePath = ReloadableGenerator.Generator.Context.DocumentationCheckoutDirectory?.FullName + ?? ReloadableGenerator.Generator.Context.DocumentationSourceDirectory.FullName; // Start in-memory validation build (runs in parallel) var validationTask = InMemoryBuildState.StartBuildAsync(sourcePath, ctx); From 996386e8485d99bf057a8c158c70c73efbcf0de5 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Wed, 22 Apr 2026 21:14:01 +0200 Subject: [PATCH 02/50] Highlight parent nav item when current page has no nav link When a page is a hidden nav leaf (e.g. individual detection rule pages), the JavaScript active-state logic couldn't find a matching nav link and left the sidebar with nothing selected. Fix: emit in the page when current.Hidden is true, pointing to the nearest visible ancestor. pages-nav.ts reads this tag and uses that URL to mark the correct nav item as current instead of window.location.pathname. This works for both deprecated rules (/deprecated-detection-rules parent) and active prebuilt rules (/ parent = detection rules overview at index.md). Visible pages are unaffected (no meta tag emitted). Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/Elastic.Documentation.Site/Assets/pages-nav.ts | 8 +++++++- src/Elastic.Documentation.Site/Layout/_Head.cshtml | 4 ++++ src/Elastic.Documentation.Site/_ViewModels.cs | 7 +++++++ src/Elastic.Markdown/HtmlWriter.cs | 5 +++++ src/Elastic.Markdown/Page/Index.cshtml | 1 + src/Elastic.Markdown/Page/IndexViewModel.cs | 6 ++++++ 6 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/Elastic.Documentation.Site/Assets/pages-nav.ts b/src/Elastic.Documentation.Site/Assets/pages-nav.ts index 5f9419abbe..b30701714f 100644 --- a/src/Elastic.Documentation.Site/Assets/pages-nav.ts +++ b/src/Elastic.Documentation.Site/Assets/pages-nav.ts @@ -134,8 +134,14 @@ export function initNav() { // Normalize pathname by removing trailing slash to handle both URL variants const pathname = window.location.pathname.replace(/\/$/, '') + + // When the page is a hidden nav item (e.g. an individual detection rule), the server + // emits docs:nav-active pointing to the nearest visible ancestor so we can highlight it. + const navActiveMeta = document.querySelector('meta[name="docs:nav-active"]') + const activePathname = navActiveMeta?.content ?? pathname + const navItems = $$( - 'a[href="' + pathname + '"], a[href="' + pathname + '/"]', + 'a[href="' + activePathname + '"], a[href="' + activePathname + '/"]', pagesNav ) navItems.forEach((el) => { diff --git a/src/Elastic.Documentation.Site/Layout/_Head.cshtml b/src/Elastic.Documentation.Site/Layout/_Head.cshtml index bf65fa113c..a961e3b23a 100644 --- a/src/Elastic.Documentation.Site/Layout/_Head.cshtml +++ b/src/Elastic.Documentation.Site/Layout/_Head.cshtml @@ -44,6 +44,10 @@ @await RenderPartialAsync(_Favicon.Create()) + @if (!string.IsNullOrEmpty(Model.NavigationActiveUrl)) + { + + } diff --git a/src/Elastic.Documentation.Site/_ViewModels.cs b/src/Elastic.Documentation.Site/_ViewModels.cs index d171e961e8..eed50e4462 100644 --- a/src/Elastic.Documentation.Site/_ViewModels.cs +++ b/src/Elastic.Documentation.Site/_ViewModels.cs @@ -44,6 +44,13 @@ public record GlobalLayoutViewModel /// Breadcrumb trail for codex sub-header (Home / Group / Docset). public IReadOnlyList? CodexBreadcrumbs { get; init; } + /// + /// When the current page is a hidden nav item (e.g. an individual detection rule page), + /// the URL of its nearest visible ancestor. The client uses this to highlight the correct + /// nav entry when the page has no rendered nav link of its own. + /// + public string? NavigationActiveUrl { get; init; } + // Header properties for isolated mode public string? HeaderTitle { get; init; } diff --git a/src/Elastic.Markdown/HtmlWriter.cs b/src/Elastic.Markdown/HtmlWriter.cs index 4f309b47f2..25aa42c713 100644 --- a/src/Elastic.Markdown/HtmlWriter.cs +++ b/src/Elastic.Markdown/HtmlWriter.cs @@ -75,6 +75,10 @@ private async Task RenderLayout(MarkdownFile markdown, MarkdownDoc var next = NavigationTraversable.GetNext(markdown); var parents = NavigationTraversable.GetParentsOfMarkdownFile(markdown); + // For hidden nav items (e.g. individual detection rule pages) there is no rendered nav link, + // so JS can't mark anything as current. Point it at the nearest visible ancestor instead. + var navActiveUrl = current.Hidden ? parents.FirstOrDefault(p => !p.Hidden)?.Url : null; + var remote = DocumentationSet.Context.Git.RepositoryName; var branch = DocumentationSet.Context.Git.Branch; string? editUrl = null; @@ -150,6 +154,7 @@ private async Task RenderLayout(MarkdownFile markdown, MarkdownDoc PreviousDocument = previous, NextDocument = next, Breadcrumbs = breadcrumbs, + NavigationActiveUrl = navActiveUrl, NavigationHtml = navigationHtmlRenderResult.Html, UrlPathPrefix = markdown.UrlPathPrefix, SiteRootPath = DocumentationSet.Context.SiteRootPath, diff --git a/src/Elastic.Markdown/Page/Index.cshtml b/src/Elastic.Markdown/Page/Index.cshtml index c73cdb6f55..43054f6dd4 100644 --- a/src/Elastic.Markdown/Page/Index.cshtml +++ b/src/Elastic.Markdown/Page/Index.cshtml @@ -48,6 +48,7 @@ StaticFileContentHashProvider = Model.StaticFileContentHashProvider, ReportIssueUrl = Model.ReportIssueUrl, Breadcrumbs = Model.Breadcrumbs, + NavigationActiveUrl = Model.NavigationActiveUrl, Htmx = new DefaultHtmxAttributeProvider(GetRootPath(Model.SiteRootPath, Model.UrlPathPrefix)), CurrentVersion = Model.CurrentDocument.YamlFrontMatter?.Layout == MarkdownPageLayout.LandingPage ? Model.VersionsConfig.VersioningSystems[0].Current : Model.CurrentVersion, AllVersionsUrl = Model.AllVersionsUrl, diff --git a/src/Elastic.Markdown/Page/IndexViewModel.cs b/src/Elastic.Markdown/Page/IndexViewModel.cs index f64968f5e1..bf22d03a80 100644 --- a/src/Elastic.Markdown/Page/IndexViewModel.cs +++ b/src/Elastic.Markdown/Page/IndexViewModel.cs @@ -34,6 +34,12 @@ public class IndexViewModel public required INavigationItem? NextDocument { get; init; } public required INavigationItem[] Breadcrumbs { get; init; } + /// + /// When the current page is a hidden nav item, the URL of its nearest visible ancestor. + /// Emitted as a meta tag so JavaScript can highlight the correct nav entry. + /// + public string? NavigationActiveUrl { get; init; } + public required string NavigationHtml { get; init; } public required string? CurrentVersion { get; init; } From 9042a9afa2fda78d0bf8b359ad000810b42b839e Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Fri, 24 Apr 2026 20:10:37 +0200 Subject: [PATCH 03/50] style: run prettier on pages-nav.ts Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/Elastic.Documentation.Site/Assets/pages-nav.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Elastic.Documentation.Site/Assets/pages-nav.ts b/src/Elastic.Documentation.Site/Assets/pages-nav.ts index b30701714f..a47260b956 100644 --- a/src/Elastic.Documentation.Site/Assets/pages-nav.ts +++ b/src/Elastic.Documentation.Site/Assets/pages-nav.ts @@ -137,7 +137,9 @@ export function initNav() { // When the page is a hidden nav item (e.g. an individual detection rule), the server // emits docs:nav-active pointing to the nearest visible ancestor so we can highlight it. - const navActiveMeta = document.querySelector('meta[name="docs:nav-active"]') + const navActiveMeta = document.querySelector( + 'meta[name="docs:nav-active"]' + ) const activePathname = navActiveMeta?.content ?? pathname const navItems = $$( From f915644119c8ff72f532bb70318918b755115d1c Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Fri, 24 Apr 2026 20:21:40 +0200 Subject: [PATCH 04/50] Bump OpenTelemetry packages to 1.15.x to address moderate CVEs GHSA-g94r-2vxg-569j OpenTelemetry.Api 1.13.1 GHSA-mr8r-92fq-pj8p OpenTelemetry.Exporter.OpenTelemetryProtocol 1.13.1 GHSA-q834-8qmm-v933 OpenTelemetry.Exporter.OpenTelemetryProtocol 1.13.1 Co-Authored-By: Claude Sonnet 4.6 (1M context) --- Directory.Packages.props | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index a72cae5707..2a9fd3f388 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -43,7 +43,7 @@ - + @@ -102,11 +102,11 @@ - - - - - + + + + + @@ -120,7 +120,7 @@ - + From 256f30820957d6b53539844aa16c3ffb6531b821 Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Thu, 30 Apr 2026 06:43:54 -0300 Subject: [PATCH 05/50] Swap Tomlet with Tomlyn, add tests (#3214) --- Directory.Packages.props | 2 +- ...Elastic.Documentation.Configuration.csproj | 2 +- src/Elastic.Markdown/Elastic.Markdown.csproj | 4 + .../DetectionRules/DetectionRule.cs | 137 ++++----- .../DetectionRuleParsingTests.cs | 274 ++++++++++++++++++ 5 files changed, 352 insertions(+), 67 deletions(-) create mode 100644 tests/Elastic.Markdown.Tests/DetectionRules/DetectionRuleParsingTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 2a9fd3f388..633a64f397 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -55,6 +55,7 @@ + @@ -86,7 +87,6 @@ - diff --git a/src/Elastic.Documentation.Configuration/Elastic.Documentation.Configuration.csproj b/src/Elastic.Documentation.Configuration/Elastic.Documentation.Configuration.csproj index 0f96c00f34..501904008c 100644 --- a/src/Elastic.Documentation.Configuration/Elastic.Documentation.Configuration.csproj +++ b/src/Elastic.Documentation.Configuration/Elastic.Documentation.Configuration.csproj @@ -18,7 +18,7 @@ - + diff --git a/src/Elastic.Markdown/Elastic.Markdown.csproj b/src/Elastic.Markdown/Elastic.Markdown.csproj index ed5f7a3a37..70425216c2 100644 --- a/src/Elastic.Markdown/Elastic.Markdown.csproj +++ b/src/Elastic.Markdown/Elastic.Markdown.csproj @@ -46,6 +46,10 @@ + + + + diff --git a/src/Elastic.Markdown/Extensions/DetectionRules/DetectionRule.cs b/src/Elastic.Markdown/Extensions/DetectionRules/DetectionRule.cs index e5feac3bf4..fd78d70141 100644 --- a/src/Elastic.Markdown/Extensions/DetectionRules/DetectionRule.cs +++ b/src/Elastic.Markdown/Extensions/DetectionRules/DetectionRule.cs @@ -9,8 +9,9 @@ using System.Text.Json; using System.Text.Json.Serialization; using Cysharp.IO; -using Tomlet; -using Tomlet.Models; +using Tomlyn; +using Tomlyn.Model; +using Tomlyn.Serialization; namespace Elastic.Markdown.Extensions.DetectionRules; @@ -35,6 +36,9 @@ public record VersionLockEntry [JsonSerializable(typeof(Dictionary))] internal sealed partial class VersionLockJsonContext : JsonSerializerContext; +[TomlSerializable(typeof(TomlTable))] +internal sealed partial class DetectionRuleTomlContext : TomlSerializerContext; + public record DetectionRuleThreat { public required string Framework { get; init; } @@ -63,8 +67,6 @@ public record DetectionRuleTechnique : DetectionRuleSubTechnique public record DetectionRule { - // TomlParser is stateful across calls; create a new instance per parse to avoid accumulation bugs in serve/reload mode - // Cached version lock data, loaded once per build private static FrozenDictionary? VersionLock; @@ -141,27 +143,46 @@ public static void InitializeVersionLock(IFileSystem fileSystem, IDirectoryInfo? [SuppressMessage("Reliability", "CA2012:Use ValueTasks correctly")] public static DetectionRule From(IFileInfo source) { - TomlDocument model; + TomlTable model; try { - // Use optimized Utf8StreamReader for better I/O performance using var reader = new Utf8StreamReader(source.FullName, fileOpenMode: FileOpenMode.Throughput); var sourceText = Encoding.UTF8.GetString(reader.ReadToEndAsync().GetAwaiter().GetResult()); - model = new TomlParser().Parse(sourceText); + model = TomlSerializer.Deserialize(sourceText, DetectionRuleTomlContext.Default.TomlTable)!; } catch (Exception e) { throw new Exception($"Could not parse toml in: {source.FullName}", e); } - if (!model.TryGetValue("metadata", out var node) || node is not TomlTable metadata) + if (!model.TryGetValue("metadata", out var metadataObj) || metadataObj is not TomlTable metadata) throw new Exception($"Could not find metadata section in {source.FullName}"); - if (!model.TryGetValue("rule", out node) || node is not TomlTable rule) + if (!model.TryGetValue("rule", out var ruleObj) || ruleObj is not TomlTable rule) throw new Exception($"Could not find rule section in {source.FullName}"); + try + { + return BuildRule(metadata, rule); + } + catch (Exception e) + { + throw new Exception($"Could not read fields from: {source.FullName}", e); + } + } + + internal static DetectionRule FromToml(string toml) + { + var model = TomlSerializer.Deserialize(toml, DetectionRuleTomlContext.Default.TomlTable)!; + var metadata = (TomlTable)model["metadata"]!; + var rule = (TomlTable)model["rule"]!; + return BuildRule(metadata, rule); + } + + private static DetectionRule BuildRule(TomlTable metadata, TomlTable rule) + { var threats = GetThreats(rule); - var ruleId = rule.GetString("rule_id"); + var ruleId = GetString(rule, "rule_id"); // Get max_signals from TOML, default to 100 if not specified var maxSignals = TryGetInt(rule, "max_signals") ?? 100; @@ -174,13 +195,13 @@ public static DetectionRule From(IFileInfo source) return new DetectionRule { Authors = TryGetStringArray(rule, "author"), - Description = rule.GetString("description"), - Type = rule.GetString("type"), + Description = GetString(rule, "description"), + Type = GetString(rule, "type"), Language = TryGetString(rule, "language"), - License = rule.GetString("license"), + License = GetString(rule, "license"), RiskScore = TryGetInt(rule, "risk_score") ?? 0, RuleId = ruleId, - Severity = rule.GetString("severity"), + Severity = GetString(rule, "severity"), Tags = TryGetStringArray(rule, "tags"), Indices = TryGetStringArray(rule, "index"), References = TryGetStringArray(rule, "references"), @@ -188,7 +209,7 @@ public static DetectionRule From(IFileInfo source) Setup = TryGetString(rule, "setup"), Query = TryGetString(rule, "query"), Note = TryGetString(rule, "note"), - Name = rule.GetString("name"), + Name = GetString(rule, "name"), RunsEvery = TryGetString(rule, "interval"), MaximumAlertsPerExecution = maxSignals, Version = version, @@ -200,97 +221,83 @@ public static DetectionRule From(IFileInfo source) private static DetectionRuleThreat[] GetThreats(TomlTable model) { - if (!model.TryGetValue("threat", out var node) || node is not TomlArray threats) + if (!model.TryGetValue("threat", out var node) || node is not TomlTableArray threats) return []; - var threatsList = new List(threats.ArrayValues.Count); - foreach (var value in threats) + var threatsList = new List(threats.Count); + foreach (var threatTable in threats.OfType()) { - if (value is not TomlTable threatTable) - continue; - - var framework = threatTable.GetString("framework"); + var framework = GetString(threatTable, "framework"); var techniques = ReadTechniques(threatTable); - var tactic = ReadTactic(threatTable); - var threat = new DetectionRuleThreat + threatsList.Add(new DetectionRuleThreat { Framework = framework, - Techniques = techniques.ToArray(), + Techniques = techniques, Tactic = tactic - }; - threatsList.Add(threat); + }); } - return threatsList.ToArray(); + return [.. threatsList]; } - private static IReadOnlyCollection ReadTechniques(TomlTable threatTable) + private static DetectionRuleTechnique[] ReadTechniques(TomlTable threatTable) { - var techniquesArray = threatTable.TryGetValue("technique", out var node) && node is TomlArray ta ? ta : null; - if (techniquesArray is null) + if (!threatTable.TryGetValue("technique", out var node) || node is not TomlTableArray techniquesArray) return []; + var techniques = new List(techniquesArray.Count); - foreach (var t in techniquesArray) + foreach (var techniqueTable in techniquesArray.OfType()) { - if (t is not TomlTable techniqueTable) - continue; - var id = techniqueTable.GetString("id"); - var name = techniqueTable.GetString("name"); - var reference = techniqueTable.GetString("reference"); techniques.Add(new DetectionRuleTechnique { - Id = id, - Name = name, - Reference = reference, - SubTechniques = ReadSubTechniques(techniqueTable).ToArray() + Id = GetString(techniqueTable, "id"), + Name = GetString(techniqueTable, "name"), + Reference = GetString(techniqueTable, "reference"), + SubTechniques = ReadSubTechniques(techniqueTable) }); } - return techniques; + return [.. techniques]; } - private static IReadOnlyCollection ReadSubTechniques(TomlTable techniqueTable) + + private static DetectionRuleSubTechnique[] ReadSubTechniques(TomlTable techniqueTable) { - var subArray = techniqueTable.TryGetValue("subtechnique", out var node) && node is TomlArray ta ? ta : null; - if (subArray is null) + if (!techniqueTable.TryGetValue("subtechnique", out var node) || node is not TomlTableArray subArray) return []; + var subTechniques = new List(subArray.Count); - foreach (var t in subArray) + foreach (var subTable in subArray.OfType()) { - if (t is not TomlTable subTechniqueTable) - continue; - var id = subTechniqueTable.GetString("id"); - var name = subTechniqueTable.GetString("name"); - var reference = subTechniqueTable.GetString("reference"); subTechniques.Add(new DetectionRuleSubTechnique { - Id = id, - Name = name, - Reference = reference + Id = GetString(subTable, "id"), + Name = GetString(subTable, "name"), + Reference = GetString(subTable, "reference") }); } - return subTechniques; + return [.. subTechniques]; } private static DetectionRuleTactic ReadTactic(TomlTable threatTable) { - var tacticTable = threatTable.GetSubTable("tactic"); - var id = tacticTable.GetString("id"); - var name = tacticTable.GetString("name"); - var reference = tacticTable.GetString("reference"); + var tacticTable = (TomlTable)threatTable["tactic"]; return new DetectionRuleTactic { - Id = id, - Name = name, - Reference = reference + Id = GetString(tacticTable, "id"), + Name = GetString(tacticTable, "name"), + Reference = GetString(tacticTable, "reference") }; } + private static string GetString(TomlTable table, string key) => + (string)table[key]; + private static string[]? TryGetStringArray(TomlTable table, string key) => - table.TryGetValue(key, out var node) && node is TomlArray t ? t.ArrayValues.Select(value => value.StringValue).ToArray() : null; + table.TryGetValue(key, out var node) && node is TomlArray t ? t.OfType().ToArray() : null; private static string? TryGetString(TomlTable table, string key) => - table.TryGetValue(key, out var node) && node is TomlString t ? t.Value : null; + table.TryGetValue(key, out var node) && node is string s ? s : null; private static int? TryGetInt(TomlTable table, string key) => - table.TryGetValue(key, out var node) && node is TomlLong t ? (int)t.Value : null; + table.TryGetValue(key, out var node) && node is long l ? (int)l : null; } diff --git a/tests/Elastic.Markdown.Tests/DetectionRules/DetectionRuleParsingTests.cs b/tests/Elastic.Markdown.Tests/DetectionRules/DetectionRuleParsingTests.cs new file mode 100644 index 0000000000..e9fc329e03 --- /dev/null +++ b/tests/Elastic.Markdown.Tests/DetectionRules/DetectionRuleParsingTests.cs @@ -0,0 +1,274 @@ +// 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 AwesomeAssertions; +using Elastic.Markdown.Extensions.DetectionRules; + +namespace Elastic.Markdown.Tests.DetectionRules; + +public class DetectionRuleParsingTests +{ + private const string MinimalRule = """ + [metadata] + creation_date = "2024/08/01" + maturity = "production" + + [rule] + author = ["Elastic"] + description = "Test rule" + name = "Test Rule" + rule_id = "abc-123" + risk_score = 47 + severity = "medium" + type = "query" + license = "Elastic License v2" + language = "kuery" + query = "process.name : evil.exe" + """; + + [Fact] + public void FromToml_MinimalRule_ParsesCorrectly() + { + var rule = DetectionRule.FromToml(MinimalRule); + + rule.Name.Should().Be("Test Rule"); + rule.RuleId.Should().Be("abc-123"); + rule.Type.Should().Be("query"); + rule.RiskScore.Should().Be(47); + rule.Severity.Should().Be("medium"); + rule.Authors.Should().ContainSingle().Which.Should().Be("Elastic"); + } + + [Fact] + public void FromToml_ImplicitIntermediateTable_ParsesTransformInvestigate() + { + var toml = MinimalRule + """ + + [[transform.investigate]] + label = "Alerts associated with the user" + description = "" + providers = [] + + [[transform.investigate]] + label = "Alerts associated with the host" + description = "" + providers = [] + + [[rule.threat]] + framework = "MITRE ATT&CK" + [rule.threat.tactic] + id = "TA0011" + name = "Command and Control" + reference = "https://attack.mitre.org/tactics/TA0011/" + [[rule.threat.technique]] + id = "T1071" + name = "Application Layer Protocol" + reference = "https://attack.mitre.org/techniques/T1071/" + """; + + var rule = DetectionRule.FromToml(toml); + + rule.Threats.Should().HaveCount(1); + rule.Threats[0].Tactic.Id.Should().Be("TA0011"); + rule.Threats[0].Techniques.Should().HaveCount(1); + rule.Threats[0].Techniques[0].Id.Should().Be("T1071"); + } + + [Fact] + public void FromToml_MultiLineStringWithMarkdownLinks_ParsesCorrectly() + { + // TOML uses """ for multi-line strings; use 4-quote C# raw literals to embed them + var toml = """" + [metadata] + creation_date = "2024/08/01" + maturity = "production" + + [rule] + author = ["Elastic"] + description = "Test rule" + name = "Test Rule With Setup" + rule_id = "abc-456" + risk_score = 73 + severity = "high" + type = "esql" + license = "Elastic License v2" + setup = """ + ## Setup + + Follow the [helper guide](https://www.elastic.co/docs/some/path#anchor) to configure. + + Also see [another link](https://example.com). + """ + """"; + + var rule = DetectionRule.FromToml(toml); + + rule.Name.Should().Be("Test Rule With Setup"); + rule.Setup.Should().Contain("[helper guide]"); + rule.Setup.Should().Contain("elastic.co/docs"); + } + + [Fact] + public void FromToml_MixedMultiLineDelimiters_ParsesCorrectly() + { + // Triple-quoted """ appears inside a '''-delimited multi-line string + var toml = """" + [metadata] + creation_date = "2024/08/01" + maturity = "production" + + [rule] + author = ["Elastic"] + description = "Test rule" + name = "Mixed Delimiters" + rule_id = "abc-789" + risk_score = 50 + severity = "medium" + type = "esql" + license = "Elastic License v2" + query = ''' + from logs-endpoint.events.process-* + | grok process.command_line """e=Access&y=Guest&h=(?[^&]+)&p""" + | where Esql.server is not null + ''' + note = """## Triage + Check the process tree.""" + """"; + + var rule = DetectionRule.FromToml(toml); + + rule.Query.Should().Contain("grok process.command_line"); + rule.Query.Should().Contain("e=Access"); + rule.Note.Should().Contain("Triage"); + } + + [Fact] + public void FromToml_DeprecatedRule_ParsesDeprecationDate() + { + var toml = """ + [metadata] + creation_date = "2024/08/01" + deprecation_date = "2025/03/15" + maturity = "deprecated" + + [rule] + author = ["Elastic"] + description = "Deprecated rule" + name = "Old Rule" + rule_id = "dep-001" + risk_score = 21 + severity = "low" + type = "query" + license = "Elastic License v2" + """; + + var rule = DetectionRule.FromToml(toml); + + rule.DeprecationDate.Should().Be("2025/03/15"); + rule.Maturity.Should().Be("deprecated"); + } + + [Fact] + public void FromToml_ThreatWithSubTechniques_ParsesFullHierarchy() + { + var toml = MinimalRule + """ + + [[rule.threat]] + framework = "MITRE ATT&CK" + [rule.threat.tactic] + id = "TA0001" + name = "Initial Access" + reference = "https://attack.mitre.org/tactics/TA0001/" + [[rule.threat.technique]] + id = "T1566" + name = "Phishing" + reference = "https://attack.mitre.org/techniques/T1566/" + [[rule.threat.technique.subtechnique]] + id = "T1566.001" + name = "Spearphishing Attachment" + reference = "https://attack.mitre.org/techniques/T1566/001/" + [[rule.threat.technique.subtechnique]] + id = "T1566.002" + name = "Spearphishing Link" + reference = "https://attack.mitre.org/techniques/T1566/002/" + """; + + var rule = DetectionRule.FromToml(toml); + + rule.Threats.Should().HaveCount(1); + var technique = rule.Threats[0].Techniques[0]; + technique.Id.Should().Be("T1566"); + technique.SubTechniques.Should().HaveCount(2); + technique.SubTechniques[0].Id.Should().Be("T1566.001"); + technique.SubTechniques[1].Id.Should().Be("T1566.002"); + } + + [Fact] + public void FromToml_MultipleThreats_ParsesAll() + { + var toml = MinimalRule + """ + + [[rule.threat]] + framework = "MITRE ATT&CK" + [rule.threat.tactic] + id = "TA0011" + name = "Command and Control" + reference = "https://attack.mitre.org/tactics/TA0011/" + + [[rule.threat]] + framework = "MITRE ATT&CK" + [rule.threat.tactic] + id = "TA0005" + name = "Defense Evasion" + reference = "https://attack.mitre.org/tactics/TA0005/" + """; + + var rule = DetectionRule.FromToml(toml); + + rule.Threats.Should().HaveCount(2); + rule.Threats[0].Tactic.Id.Should().Be("TA0011"); + rule.Threats[1].Tactic.Id.Should().Be("TA0005"); + } + + [Fact] + public void FromToml_OptionalFieldsMissing_DefaultsCorrectly() + { + var rule = DetectionRule.FromToml(MinimalRule); + + rule.Setup.Should().BeNull(); + rule.Note.Should().BeNull(); + rule.References.Should().BeNull(); + rule.Indices.Should().BeNull(); + rule.RunsEvery.Should().BeNull(); + rule.IndicesFromDateMath.Should().BeNull(); + rule.DeprecationDate.Should().BeNull(); + rule.MaximumAlertsPerExecution.Should().Be(100); + rule.Threats.Should().BeEmpty(); + } + + [Fact] + public void FromToml_DomainTag_ExtractedCorrectly() + { + var toml = """ + [metadata] + creation_date = "2024/08/01" + maturity = "production" + + [rule] + author = ["Elastic"] + description = "Test" + name = "Domain Test" + rule_id = "dom-001" + risk_score = 50 + severity = "medium" + type = "query" + license = "Elastic License v2" + tags = ["Domain: Endpoint", "Use Case: Threat Detection"] + """; + + var rule = DetectionRule.FromToml(toml); + + rule.Domain.Should().Be("Endpoint"); + } +} From 91414709f9cae8debb00299f8b5afe615cbade7c Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Thu, 30 Apr 2026 10:16:44 -0300 Subject: [PATCH 06/50] Use defensive TryGetValue in ReadTactic to match surrounding patterns --- .../Extensions/DetectionRules/DetectionRule.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Elastic.Markdown/Extensions/DetectionRules/DetectionRule.cs b/src/Elastic.Markdown/Extensions/DetectionRules/DetectionRule.cs index fd78d70141..b3af5994b0 100644 --- a/src/Elastic.Markdown/Extensions/DetectionRules/DetectionRule.cs +++ b/src/Elastic.Markdown/Extensions/DetectionRules/DetectionRule.cs @@ -280,7 +280,9 @@ private static DetectionRuleSubTechnique[] ReadSubTechniques(TomlTable technique private static DetectionRuleTactic ReadTactic(TomlTable threatTable) { - var tacticTable = (TomlTable)threatTable["tactic"]; + if (!threatTable.TryGetValue("tactic", out var tacticObj) || tacticObj is not TomlTable tacticTable) + throw new InvalidOperationException("Threat entry is missing required 'tactic' section"); + return new DetectionRuleTactic { Id = GetString(tacticTable, "id"), From 6af5b68a4eb06dfafc8f9979f7e1975fb0b153cd Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri-Benedetti Date: Thu, 30 Apr 2026 16:54:10 +0200 Subject: [PATCH 07/50] Cache cross-link index across serve hot reloads (#3219) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Cache cross-link index across serve hot reloads (#2845) Every .md save in serve mode was re-fetching link-index.json over S3 for each cross-link entry, twice per repo, blocking the browser refresh. Cache FetchedCrossLinks across reloads (re-fetched only on configuration changes), and fold the duplicate per-repo GetRegistry calls into one fetch per registry up front. Same wins flow through to docs-builder build and codex builds via DocSetConfigurationCrossLinkFetcher. Co-Authored-By: Claude Opus 4.7 (1M context) * Skip full rebuild and validation on content-only changes For plain .md edits during serve, skip the DocumentationSet rebuild, ResolveDirectoryTree, and in-memory validation build entirely — the serve path already reads fresh content from disk via ParseFullAsync. Full rebuilds still run for structural changes (config/toc edits, file add/delete). Also watch common asset files (images, yml, toml) and trigger a browser-only refresh when they change. Co-Authored-By: Claude Opus 4.6 (1M context) * Propagate cancellation in TryGetRegistry, recreate fetcher on config reload Don't swallow OperationCanceledException in TryGetRegistry so cancellation propagates promptly instead of degrading to null. Recreate _crossLinkFetcher and _codexReader when configuration reloads so registry switches in docset.yml take effect immediately. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: Felipe Cotti --- .../CrossLinks/CrossLinkFetcher.cs | 6 +++ .../DocSetConfigurationCrossLinkFetcher.cs | 40 +++++++++++++++---- .../Http/ReloadGeneratorService.cs | 37 ++++++++++++----- .../Http/ReloadableGeneratorState.cs | 25 ++++++++++-- 4 files changed, 87 insertions(+), 21 deletions(-) diff --git a/src/Elastic.Documentation.Links/CrossLinks/CrossLinkFetcher.cs b/src/Elastic.Documentation.Links/CrossLinks/CrossLinkFetcher.cs index c0c7d21e76..893754c2d0 100644 --- a/src/Elastic.Documentation.Links/CrossLinks/CrossLinkFetcher.cs +++ b/src/Elastic.Documentation.Links/CrossLinks/CrossLinkFetcher.cs @@ -34,6 +34,12 @@ public record FetchedCrossLinks ///
public FrozenSet? CodexRepositories { get; init; } + /// + /// True when all declared repositories resolved without falling back to placeholder data. + /// When false, callers should avoid caching so a subsequent reload retries the fetch. + /// + public bool IsComplete { get; init; } = true; + public static FetchedCrossLinks Empty { get; } = new() { DeclaredRepositories = [], diff --git a/src/Elastic.Documentation.Links/CrossLinks/DocSetConfigurationCrossLinkFetcher.cs b/src/Elastic.Documentation.Links/CrossLinks/DocSetConfigurationCrossLinkFetcher.cs index 0af5a856b4..a72f789ac6 100644 --- a/src/Elastic.Documentation.Links/CrossLinks/DocSetConfigurationCrossLinkFetcher.cs +++ b/src/Elastic.Documentation.Links/CrossLinks/DocSetConfigurationCrossLinkFetcher.cs @@ -33,30 +33,36 @@ public override async Task FetchCrossLinks(Cancel ctx) var publicReader = linkIndexProvider ?? Aws3LinkIndexReader.CreateAnonymous(); var useDualRegistry = configuration.Registry != DocSetRegistry.Public && _codexReader is not null; + // Fetch each registry once up front so per-repository lookups don't trigger N S3 round-trips. + var publicRegistry = await TryGetRegistry(publicReader, ctx); + var codexRegistry = useDualRegistry ? await TryGetRegistry(_codexReader!, ctx) : null; + var hadFetchFailures = false; + foreach (var entry in configuration.CrossLinkEntries) { _ = declaredRepositories.Add(entry.Repository); var isCodexEntry = useDualRegistry && entry.Registry != DocSetRegistry.Public; var reader = isCodexEntry ? _codexReader! : publicReader; + var registry = isCodexEntry ? codexRegistry : publicRegistry; if (isCodexEntry) _ = codexRepositories.Add(entry.Repository); try { - var linkReference = await FetchCrossLinksFromReader(reader, entry.Repository, this, ctx); + if (registry is null || !registry.Repositories.TryGetValue(entry.Repository, out var repoBranches)) + throw new Exception($"Repository {entry.Repository} not found in link index"); + + var linkIndexEntry = GetNextContentSourceLinkIndexEntry(repoBranches, entry.Repository); + var linkReference = await FetchLinkIndexEntryFromReader(reader, entry.Repository, linkIndexEntry, ctx); + linkReferences.Add(entry.Repository, linkReference); + linkIndexEntries.Add(entry.Repository, linkIndexEntry); registryUrlsByRepository[entry.Repository] = reader.RegistryUrl; - - var registry = await reader.GetRegistry(ctx); - if (registry.Repositories.TryGetValue(entry.Repository, out var repoBranches)) - { - var linkIndexEntry = GetNextContentSourceLinkIndexEntry(repoBranches, entry.Repository); - linkIndexEntries.Add(entry.Repository, linkIndexEntry); - } } catch (Exception ex) { + hadFetchFailures = true; _logger.LogWarning(ex, "Error fetching link data for repository '{Repository}'. Cross-links to this repository may not resolve correctly.", entry.Repository); _ = registryUrlsByRepository.TryAdd(entry.Repository, reader.RegistryUrl); @@ -86,6 +92,24 @@ public override async Task FetchCrossLinks(Cancel ctx) LinkIndexEntries = linkIndexEntries.ToFrozenDictionary(), RegistryUrlsByRepository = registryUrlsByRepository.ToFrozenDictionary(), CodexRepositories = codexRepositories.Count > 0 ? codexRepositories.ToFrozenSet() : null, + IsComplete = !hadFetchFailures, }; } + + private async Task TryGetRegistry(ILinkIndexReader reader, Cancel ctx) + { + try + { + return await reader.GetRegistry(ctx); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to fetch link index registry from {RegistryUrl}", reader.RegistryUrl); + return null; + } + } } diff --git a/src/tooling/docs-builder/Http/ReloadGeneratorService.cs b/src/tooling/docs-builder/Http/ReloadGeneratorService.cs index c20762a943..8adbd39e17 100644 --- a/src/tooling/docs-builder/Http/ReloadGeneratorService.cs +++ b/src/tooling/docs-builder/Http/ReloadGeneratorService.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.Collections.Frozen; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Westwind.AspNetCore.LiveReload; @@ -28,13 +29,18 @@ public sealed class ReloadGeneratorService( ILogger logger ) : IHostedService, IDisposable { + private static readonly FrozenSet AssetExtensions = new[] + { + ".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", + ".yml", ".yaml", ".toml" + }.ToFrozenSet(StringComparer.OrdinalIgnoreCase); + private FileSystemWatcher? _watcher; private CancellationTokenSource? _serviceCts; private ReloadableGeneratorState ReloadableGenerator { get; } = reloadableGenerator; private InMemoryBuildState InMemoryBuildState { get; } = inMemoryBuildState; private ILogger Logger { get; } = logger; - //debounce reload requests due to many file changes private readonly Debouncer _debouncer = new(TimeSpan.FromMilliseconds(200)); public async Task StartAsync(Cancel cancellationToken) @@ -79,6 +85,8 @@ await Task.WhenAll( watcher.Filters.Add("docset.yml"); watcher.Filters.Add("_docset.yml"); watcher.Filters.Add("toc.yml"); + foreach (var ext in AssetExtensions) + watcher.Filters.Add($"*{ext}"); watcher.IncludeSubdirectories = true; watcher.EnableRaisingEvents = true; _watcher = watcher; @@ -89,19 +97,17 @@ private void Reload(bool reloadConfiguration = false) var token = _serviceCts?.Token ?? Cancel.None; _ = _debouncer.ExecuteAsync(async ctx => { - var sourcePath = ReloadableGenerator.Generator.Context.DocumentationCheckoutDirectory?.FullName - ?? ReloadableGenerator.Generator.Context.DocumentationSourceDirectory.FullName; - - // Start in-memory validation build (runs in parallel) - var validationTask = InMemoryBuildState.StartBuildAsync(sourcePath, ctx); - - // Wait for live reload to complete, then refresh the browser immediately await ReloadableGenerator.ReloadAsync(ctx, reloadConfiguration); Logger.LogInformation("Reload complete!"); _ = LiveReloadMiddleware.RefreshWebSocketRequest(); - // Wait for validation build to complete - await validationTask; + // Only run the full validation build for structural changes (config/toc edits, file add/delete). + // Content-only .md edits are picked up on the next request via ParseFullAsync. + if (reloadConfiguration) + { + var sourcePath = ReloadableGenerator.Generator.Context.DocumentationSourceDirectory.FullName; + await InMemoryBuildState.StartBuildAsync(sourcePath, ctx); + } }, token); } @@ -126,6 +132,9 @@ private static bool ShouldIgnorePath(string path) => private static bool IsConfigFile(string path) => path.EndsWith("docset.yml") || path.EndsWith("toc.yml"); + private static bool IsAssetFile(string path) => + AssetExtensions.Contains(Path.GetExtension(path)); + private void OnChanged(object sender, FileSystemEventArgs e) { if (e.ChangeType != WatcherChangeTypes.Changed) @@ -140,6 +149,8 @@ private void OnChanged(object sender, FileSystemEventArgs e) Reload(reloadConfiguration: true); else if (e.FullPath.EndsWith(".md")) Reload(); + else if (IsAssetFile(e.FullPath)) + _ = LiveReloadMiddleware.RefreshWebSocketRequest(); #if DEBUG if (e.FullPath.EndsWith(".cshtml")) _ = LiveReloadMiddleware.RefreshWebSocketRequest(); @@ -154,6 +165,8 @@ private void OnCreated(object sender, FileSystemEventArgs e) Logger.LogInformation("Created: {FullPath}", e.FullPath); if (e.FullPath.EndsWith(".md") || IsConfigFile(e.FullPath)) Reload(reloadConfiguration: true); + else if (IsAssetFile(e.FullPath)) + _ = LiveReloadMiddleware.RefreshWebSocketRequest(); } private void OnDeleted(object sender, FileSystemEventArgs e) @@ -164,6 +177,8 @@ private void OnDeleted(object sender, FileSystemEventArgs e) Logger.LogInformation("Deleted: {FullPath}", e.FullPath); if (e.FullPath.EndsWith(".md") || IsConfigFile(e.FullPath)) Reload(reloadConfiguration: true); + else if (IsAssetFile(e.FullPath)) + _ = LiveReloadMiddleware.RefreshWebSocketRequest(); } private void OnRenamed(object sender, RenamedEventArgs e) @@ -176,6 +191,8 @@ private void OnRenamed(object sender, RenamedEventArgs e) Logger.LogInformation(" New: {NewFullPath}", e.FullPath); if (e.FullPath.EndsWith(".md") || e.OldFullPath.EndsWith(".md") || IsConfigFile(e.FullPath) || IsConfigFile(e.OldFullPath)) Reload(reloadConfiguration: true); + else if (IsAssetFile(e.FullPath) || IsAssetFile(e.OldFullPath)) + _ = LiveReloadMiddleware.RefreshWebSocketRequest(); #if DEBUG if (e.FullPath.EndsWith(".cshtml")) _ = LiveReloadMiddleware.RefreshWebSocketRequest(); diff --git a/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs b/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs index 70acd4d757..11314ed6e6 100644 --- a/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs +++ b/src/tooling/docs-builder/Http/ReloadableGeneratorState.cs @@ -27,8 +27,9 @@ public class ReloadableGeneratorState : IDisposable private readonly ILoggerFactory _logFactory; private readonly BuildContext _context; private readonly bool _isWatchBuild; - private readonly DocSetConfigurationCrossLinkFetcher _crossLinkFetcher; - private readonly ILinkIndexReader? _codexReader; + private DocSetConfigurationCrossLinkFetcher _crossLinkFetcher; + private ILinkIndexReader? _codexReader; + private FetchedCrossLinks? _cachedCrossLinks; public ReloadableGeneratorState(ILoggerFactory logFactory, IDirectoryInfo sourcePath, @@ -66,11 +67,29 @@ bool isWatchBuild public async Task ReloadAsync(Cancel ctx, bool reloadConfiguration = true) { + // Content-only changes (e.g. .md edits) don't need a full rebuild: + // RenderLayout -> ParseFullAsync reads fresh content from disk on each request. + if (!reloadConfiguration && _cachedCrossLinks is not null) + return; + SourcePath.Refresh(); OutputPath.Refresh(); if (reloadConfiguration) + { _context.ReloadConfiguration(); - var crossLinks = await _crossLinkFetcher.FetchCrossLinks(ctx); + (_codexReader as IDisposable)?.Dispose(); + _codexReader = _context.Configuration.Registry != DocSetRegistry.Public + ? new GitLinkIndexReader(_context.Configuration.Registry.ToStringFast(true), FileSystemFactory.AppData) + : null; + _crossLinkFetcher = new DocSetConfigurationCrossLinkFetcher(_logFactory, _context.Configuration, codexLinkIndexReader: _codexReader); + } + var crossLinks = _cachedCrossLinks; + if (crossLinks is null || reloadConfiguration) + { + crossLinks = await _crossLinkFetcher.FetchCrossLinks(ctx); + // Only cache successful fetches so transient failures get retried on the next reload. + _cachedCrossLinks = crossLinks.IsComplete ? crossLinks : null; + } IUriEnvironmentResolver? uriResolver = crossLinks.CodexRepositories is not null ? new CodexAwareUriResolver(crossLinks.CodexRepositories) : null; From 74e4f45bbbeada028330e11682b93084c19f37cb Mon Sep 17 00:00:00 2001 From: "elastic-observability-automation[bot]" <180520183+elastic-observability-automation[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 15:19:34 +0000 Subject: [PATCH 08/50] [Automation] Bump product version numbers (#3215) --- config/versions.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/versions.yml b/config/versions.yml index 7ae9a9a151..b1bd847673 100644 --- a/config/versions.yml +++ b/config/versions.yml @@ -82,7 +82,7 @@ versioning_systems: current: 1.5.0 edot-browser: base: 0.1 - current: 0.1.0 + current: 0.2.0 edot-dotnet: base: 1.0 current: 1.4.0 From d9adb0d61e9631f09becb9626f80222bf72b140b Mon Sep 17 00:00:00 2001 From: "elastic-observability-automation[bot]" <180520183+elastic-observability-automation[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 22:27:34 +0200 Subject: [PATCH 09/50] chore: Update config/versions.yml edot-collector 9.3.4 (#3222) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Made with ❤️️ by updatecli Co-authored-by: elastic-observability-automation[bot] <180520183+elastic-observability-automation[bot]@users.noreply.github.com> --- config/versions.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/versions.yml b/config/versions.yml index b1bd847673..c6ac9978ae 100644 --- a/config/versions.yml +++ b/config/versions.yml @@ -73,7 +73,7 @@ versioning_systems: # EDOTs edot-collector: base: 9.0 - current: 9.3.3 + current: 9.3.4 edot-ios: base: 1.0 current: 2.0.0 From 2bad5086696533522655729490f1c4ad97525027 Mon Sep 17 00:00:00 2001 From: Lisa Cawley Date: Mon, 4 May 2026 07:44:00 -0700 Subject: [PATCH 10/50] [Changelog] Strip dash from title prefix (#3226) * [Changelog] Strip dash from title prefix * Update docs/contribute/configure-changelogs-ref.md Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Address code feedback --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- docs/cli/changelog/add.md | 10 +-- docs/cli/changelog/evaluate-pr.md | 3 +- docs/cli/changelog/gh-release.md | 15 ++-- docs/contribute/configure-changelogs-ref.md | 14 ++-- .../Changelog/ExtractConfiguration.cs | 5 +- .../ReleaseNotes/ReleaseNotesSerialization.cs | 57 ++++++++++++++- .../ReleaseNotes/ChangelogTextUtilities.cs | 33 ++++++++- .../Changelogs/Create/TitleProcessingTests.cs | 57 +++++++++++++++ .../ChangelogPrEvaluationServiceTests.cs | 16 ++++ .../ChangelogTextUtilitiesTests.cs | 20 +++++ .../ReleaseNotesSerializationTests.cs | 73 +++++++++++++++++++ 11 files changed, 276 insertions(+), 27 deletions(-) create mode 100644 tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/ReleaseNotesSerializationTests.cs diff --git a/docs/cli/changelog/add.md b/docs/cli/changelog/add.md index d90e8ed8e5..d700cd8016 100644 --- a/docs/cli/changelog/add.md +++ b/docs/cli/changelog/add.md @@ -92,10 +92,11 @@ docs-builder changelog add [options...] [-h|--help] : Optional: GitHub repository name (used when `--prs`, `--issues`, or `--release-version` is specified). Falls back to `bundle.repo` in `changelog.yml` when not specified. `--strip-title-prefix` -: Optional: When used with `--prs`, remove square brackets and text within them from the beginning of PR titles, and also remove a colon if it follows the closing bracket. -: For example, if a PR title is `"[Attack discovery]: Improves Attack discovery hallucination detection"`, the changelog title will be `"Improves Attack discovery hallucination detection"`. -: Multiple square bracket prefixes are also supported (for example `"[Discover][ESQL] Fix filtering by multiline string fields"` becomes `"Fix filtering by multiline string fields"`). -: This option applies only when the title is derived from the PR (when `--title` is not explicitly provided). +: Optional: When used with `--prs` or `--issues`, remove square brackets and text within them from the beginning of PR or issue titles, remove a colon if it follows the closing bracket, and remove a single ASCII hyphen when it's immediately after that prefix and followed by whitespace. +: For example, if a PR title is `"[Discover][ESQL]: Fix filtering by multiline string fields"` it becomes `"Fix filtering by multiline string fields"`. +: Likewise `"[Cases] - Enable numerical id service"` becomes `"Enable numerical id service"`. +: When a derived title still begins with `-`, `*`, `+`, an en dash, or an em dash, the emitted YAML uses a quoted `title` value so it is valid and unambiguous. +: This option applies only when the title is derived from GitHub (when `--title` is not explicitly provided). : By default, the behavior is determined by the `extract.strip_title_prefix` changelog configuration setting (which defaults to `false`). `--subtype ` @@ -198,7 +199,6 @@ In each of these cases where validation fails, a changelog file is not created. If the configuration file contains `rules.create` definitions and a PR or issue has a blocking label, that PR is skipped and no changelog file is created for it. For more information, refer to [Rules for creation and publishing](/contribute/configure-changelogs.md#rules). - ## CI auto-detection [ci-auto-detection] When running inside GitHub Actions, `changelog add` automatically reads the following environment variables to fill in arguments that were not provided on the command line: diff --git a/docs/cli/changelog/evaluate-pr.md b/docs/cli/changelog/evaluate-pr.md index 2c1695d535..87cc8067fb 100644 --- a/docs/cli/changelog/evaluate-pr.md +++ b/docs/cli/changelog/evaluate-pr.md @@ -51,7 +51,8 @@ docs-builder changelog evaluate-pr [options...] [-h|--help] : Default: `false` `--strip-title-prefix` -: Remove square-bracket prefixes from the PR title (e.g., `[Inference API] Title` becomes `Title`). +: Remove square-bracket prefixes from the PR title (for example, `[Inference API] Title` becomes `Title`), strip an optional colon after the prefix, and strip an ASCII ` - `-style separator after the prefix when the hyphen is followed by whitespace. +: Titles that still start with `-`, `*`, `+`, an en dash, or an em dash are surrounded by quotes to avoid rendering problems. : Default: `false` `--bot-name ` diff --git a/docs/cli/changelog/gh-release.md b/docs/cli/changelog/gh-release.md index fb4eab3715..2a8ef7ba82 100644 --- a/docs/cli/changelog/gh-release.md +++ b/docs/cli/changelog/gh-release.md @@ -44,10 +44,11 @@ docs-builder changelog gh-release [version] [options...] [-h|--help] : 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"`. -: Multiple bracket prefixes are also supported (for example, `"[Discover][ESQL] Fix filtering"` becomes `"Fix filtering"`). +: Optional: Remove square brackets and the text within them from the beginning of pull request titles, remove a colon or a single ASCII hyphen if it follows the closing bracket and is followed by whitespace. +: Multiple bracket prefixes are also supported (for example, `"[Discover][ESQL] - Fix filtering"` becomes `"Fix filtering"`). +: When the title still begins with `-`, `*`, `+`, an en dash, or an em dash, it's surrounded by quotes. : By default, the behavior is determined by the `extract.strip_title_prefix` changelog configuration setting (which defaults to `false`). + `--warn-on-type-mismatch` : Optional: Warn when the type inferred from Release Drafter section headers (for example, "Bug Fixes") doesn't match the type derived from the pull request's labels. Defaults to `true`. @@ -98,10 +99,4 @@ docs-builder changelog gh-release elasticsearch v9.2.0 \ ```sh docs-builder changelog gh-release elasticsearch v9.2.0 \ --description "Elasticsearch {version} includes new features and fixes. Download: https://github.com/{owner}/{repo}/releases/tag/v{version}" -``` - -### Strip component prefixes from titles - -```sh -docs-builder changelog gh-release elasticsearch v9.2.0 --strip-title-prefix -``` +``` \ No newline at end of file diff --git a/docs/contribute/configure-changelogs-ref.md b/docs/contribute/configure-changelogs-ref.md index b1d768d469..a0f6d4127e 100644 --- a/docs/contribute/configure-changelogs-ref.md +++ b/docs/contribute/configure-changelogs-ref.md @@ -104,8 +104,8 @@ Controls how the `changelog add` command extracts information from PR descriptio | Setting | Description | | ---------------------------- | ------------------------------------------------------------------- | | `extract.issues` | Auto-extract linked issues/PRs from descriptions (default: `true`). | -| `extract.release_notes` | Auto-extract descriptions from GitHub (default: `true`). | -| `extract.strip_title_prefix` | Remove square-bracket prefixes from PR titles (default: `false`). | +| `extract.release_notes` | Auto-extract descriptions from GitHub (default: `true`). | +| `extract.strip_title_prefix` | Remove square-bracket prefixes from PR titles; strip a single hyphen separator or colon after the prefix when it is followed by whitespace (default: `false`). | When `extract.issues` is `true`, the system looks for patterns like "Fixes #123" in PR bodies (when you're creating changelogs from PRs) or "Fixed by #123" in issue bodies (when you're creating changelogs from issues). @@ -120,13 +120,11 @@ When `extract.release_notes` is `true`, the system looks for content like this i The extracted release note text is used in the changelog `description`. -When `extract.strip_title_prefix` is `true` and PR or issue titles have a prefix in square brackets (such as `[ES|QL]` or `[Security]`), they are automatically removed from the changelog title. -Multiple square bracket prefixes are also supported (for example `[Discover][ESQL] Title` becomes `Title`). -If a colon follows the closing bracket, it is also removed. +When `extract.strip_title_prefix` is `true`: -:::{note} -The title cleanup only occurs when the title is derived from GitHub. If you specify `--title` explicitly, that title is used as-is without any prefix stripping. -::: +- The separator hyphen is removed only when at least one bracket prefix was stripped; PR titles that intentionally start with `-` followed by whitespace and have no bracket prefix are left unchanged. +- Titles that still begin with `-`, `*`, `+`, an en dash (U+2013), or an em dash (U+2014) are surrounded in quotes so they're not parsed as list markers. +- The title cleanup only occurs when the title is derived from GitHub. If you specify `--title` explicitly, that title is used as-is without any prefix stripping. ## Filename [filename] diff --git a/src/Elastic.Documentation.Configuration/Changelog/ExtractConfiguration.cs b/src/Elastic.Documentation.Configuration/Changelog/ExtractConfiguration.cs index fac3659203..2a1c0bedad 100644 --- a/src/Elastic.Documentation.Configuration/Changelog/ExtractConfiguration.cs +++ b/src/Elastic.Documentation.Configuration/Changelog/ExtractConfiguration.cs @@ -23,7 +23,10 @@ public record ExtractConfiguration /// /// Whether to strip square-bracket prefixes from PR titles by default. - /// Defaults to false. When enabled, titles like "[ES|QL] Fix bug" become "Fix bug". + /// Defaults to false. When enabled, titles like "[ES|QL] Fix bug" become "Fix bug", + /// and a single ASCII hyphen used as a team separator after the prefix is removed when it is followed by whitespace + /// (for example "[Team] - Do something" becomes "Do something"). + /// Serialized changelog YAML uses quoted scalars when a title still starts with -, *, +, en dash, or em dash so the value is not parsed as a list marker. /// Can be overridden by CLI --strip-title-prefix flag. /// public bool StripTitlePrefix { get; init; } diff --git a/src/Elastic.Documentation.Configuration/ReleaseNotes/ReleaseNotesSerialization.cs b/src/Elastic.Documentation.Configuration/ReleaseNotes/ReleaseNotesSerialization.cs index 6f0690ec9e..640aff2d34 100644 --- a/src/Elastic.Documentation.Configuration/ReleaseNotes/ReleaseNotesSerialization.cs +++ b/src/Elastic.Documentation.Configuration/ReleaseNotes/ReleaseNotesSerialization.cs @@ -4,6 +4,7 @@ using System.Globalization; using System.IO.Abstractions; +using System.Text; using System.Text.RegularExpressions; using Elastic.Documentation.Configuration.Serialization; using Elastic.Documentation.ReleaseNotes; @@ -80,7 +81,8 @@ public static Bundle DeserializeBundle(string yaml) public static string SerializeEntry(ChangelogEntry entry) { var dto = ToDto(entry); - return YamlSerializer.Serialize(dto); + var yaml = YamlSerializer.Serialize(dto); + return ApplyDefensiveTitleQuotingIfNeeded(yaml, entry.Title); } /// @@ -92,6 +94,59 @@ public static string SerializeBundle(Bundle bundle) return YamlSerializer.Serialize(dto); } + private static string ApplyDefensiveTitleQuotingIfNeeded(string yaml, string? title) + { + if (!ChangelogTextUtilities.TitleNeedsDefensiveYamlQuoting(title)) + return yaml; + + var lines = yaml.Split('\n'); + for (var i = 0; i < lines.Length; i++) + { + var line = lines[i]; + var trimmedStart = line.TrimStart(); + if (!trimmedStart.StartsWith("title:", StringComparison.Ordinal)) + continue; + + var colonIdx = line.IndexOf(':'); + if (colonIdx < 0) + continue; + + var valuePart = line[(colonIdx + 1)..].TrimStart(); + if (valuePart.Length > 0 && (valuePart[0] == '"' || valuePart[0] == '\'')) + return yaml; + + // Block literals (| / >) span following lines; rewriting only the header orphans continuations. + if (valuePart.Length > 0 && (valuePart[0] == '|' || valuePart[0] == '>')) + return yaml; + + lines[i] = string.Concat(line.AsSpan(0, colonIdx + 1), " ", ToYamlDoubleQuotedString(title!)); + break; + } + + return string.Join('\n', lines); + } + + private static string ToYamlDoubleQuotedString(string s) + { + var sb = new StringBuilder(s.Length + 2); + _ = sb.Append('"'); + foreach (var c in s) + { + _ = c switch + { + '\\' => sb.Append("\\\\"), + '"' => sb.Append("\\\""), + '\n' => sb.Append("\\n"), + '\r' => sb.Append("\\r"), + '\t' => sb.Append("\\t"), + _ => c < 0x20 ? sb.AppendFormat(CultureInfo.InvariantCulture, "\\u{0:X4}", (int)c) : sb.Append(c), + }; + } + + _ = sb.Append('"'); + return sb.ToString(); + } + #region Manual Mapping Methods private static ChangelogEntry ToEntry(ChangelogEntryDto dto) => new() diff --git a/src/Elastic.Documentation/ReleaseNotes/ChangelogTextUtilities.cs b/src/Elastic.Documentation/ReleaseNotes/ChangelogTextUtilities.cs index c7146dddb5..7774ff8fc3 100644 --- a/src/Elastic.Documentation/ReleaseNotes/ChangelogTextUtilities.cs +++ b/src/Elastic.Documentation/ReleaseNotes/ChangelogTextUtilities.cs @@ -100,7 +100,9 @@ public static string SanitizeFilename(string input) } /// - /// Strips square bracket prefix(es) and optional colon from title (e.g., "[Inference API] Title" -> "Title", "[Discover][ESQL] Title" -> "Title") + /// Strips square bracket prefix(es), optional colon, and a single ASCII hyphen used as a team/title separator + /// when it is followed by whitespace (e.g. [Cases] - Enable …Enable …). + /// The hyphen is removed only when the prefix strip removed at least one bracket segment. /// public static string StripSquareBracketPrefix(string title) { @@ -108,10 +110,12 @@ public static string StripSquareBracketPrefix(string title) return title; var span = title.AsSpan(); + var removedBracketPrefix = false; // Keep stripping square bracket prefixes until there are no more at the start while (span.Length > 0 && span[0] == '[') { + removedBracketPrefix = true; // Find the matching ']' var closingBracketIndex = span.IndexOf(']'); if (closingBracketIndex < 0) @@ -128,9 +132,36 @@ public static string StripSquareBracketPrefix(string title) if (span.Length > 0 && span[0] == ':') span = span[1..].TrimStart(); + if (removedBracketPrefix && + span.Length >= 2 && + span[0] == '-' && + char.IsWhiteSpace(span[1])) + span = span[2..].TrimStart(); + return span.ToString(); } + /// + /// Whether a changelog title should be emitted as an explicitly quoted YAML scalar so unambiguous + /// plain scalars are not parsed as list markers. Does not change the semantic title string. + /// + public static bool TitleNeedsDefensiveYamlQuoting(string? title) + { + if (string.IsNullOrEmpty(title)) + return false; + + var s = title.AsSpan().TrimStart(); + if (s.IsEmpty) + return false; + + return s[0] switch + { + '-' or '*' or '+' => true, + '\u2013' or '\u2014' => true, + _ => false + }; + } + /// /// Extracts PR number from PR URL or reference. /// diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Create/TitleProcessingTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/Create/TitleProcessingTests.cs index c1d0361fb4..aa4c36077a 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/Create/TitleProcessingTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/Create/TitleProcessingTests.cs @@ -196,6 +196,63 @@ public async Task CreateChangelog_WithStripTitlePrefix_RemovesMultipleSquareBrac yamlContent.Should().NotContain("[ESQL]"); } + [Fact] + public async Task CreateChangelog_WithStripTitlePrefix_StripsKibanaStyleTeamHyphenSeparator() + { + var prInfo = new GitHubPrInfo + { + Title = "[Cases] - Enable cases numerical id service", + Labels = ["type:feature"] + }; + + A.CallTo(() => MockGitHubService.FetchPrInfoAsync( + "https://github.com/elastic/kibana/pull/238555", + null, + null, + A._)) + .Returns(prInfo); + + var configContent = + """ + pivot: + types: + feature: "type:feature" + bug-fix: + breaking-change: + lifecycles: + - preview + - beta + - ga + """; + var configPath = await CreateConfigDirectory(configContent); + + var service = CreateService(); + + var input = new CreateChangelogArguments + { + Prs = ["https://github.com/elastic/kibana/pull/238555"], + Products = [new ProductArgument { Product = "kibana", Target = "9.2.0", Lifecycle = "ga" }], + Config = configPath, + Output = CreateOutputDirectory(), + StripTitlePrefix = true + }; + + var result = await service.CreateChangelog(Collector, input, TestContext.Current.CancellationToken); + + result.Should().BeTrue(); + Collector.Errors.Should().Be(0); + + var outputDir = input.Output ?? FileSystem.Directory.GetCurrentDirectory(); + if (!FileSystem.Directory.Exists(outputDir)) + FileSystem.Directory.CreateDirectory(outputDir); + var files = FileSystem.Directory.GetFiles(outputDir, "*.yaml"); + files.Should().HaveCount(1); + + var yamlContent = await FileSystem.File.ReadAllTextAsync(files[0], TestContext.Current.CancellationToken); + yamlContent.Should().Contain("title: Enable cases numerical id service"); + yamlContent.Should().NotContain("title: '- Enable"); + } + [Fact] public async Task CreateChangelog_WithExplicitTitle_OverridesPrTitle() { diff --git a/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrEvaluationServiceTests.cs b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrEvaluationServiceTests.cs index 2134311cb8..fd07e48830 100644 --- a/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrEvaluationServiceTests.cs +++ b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrEvaluationServiceTests.cs @@ -307,6 +307,22 @@ public async Task EvaluatePr_StripTitlePrefix_RemovesBrackets() VerifyOutputSet("title", "Fix timeout handling"); } + [Fact] + public async Task EvaluatePr_StripTitlePrefix_RemovesKibanaStyleTeamHyphenSeparator() + { + await WriteMinimalConfig(); + var service = CreateService(); + var args = DefaultArgs(prTitle: "[Cases] - Enable cases numerical id service") with + { + StripTitlePrefix = true + }; + + var result = await service.EvaluatePr(Collector, args, CancellationToken.None); + + result.Should().BeTrue(); + VerifyOutputSet("title", "Enable cases numerical id service"); + } + [Fact] public async Task EvaluatePr_NoConfig_UsesDefaults() { diff --git a/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/ChangelogTextUtilitiesTests.cs b/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/ChangelogTextUtilitiesTests.cs index 0559e89b01..0dc0eb6630 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/ChangelogTextUtilitiesTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/ChangelogTextUtilitiesTests.cs @@ -52,12 +52,32 @@ public void FormatAreaHeader_CapitalizesAndReplacesHyphens(string input, string [InlineData("[Test] Title", "Title")] [InlineData("No bracket prefix", "No bracket prefix")] [InlineData("[Unclosed bracket", "[Unclosed bracket")] + [InlineData("[Cases] - Enable cases numerical id service", "Enable cases numerical id service")] + [InlineData("[Team] - Leading", "Leading")] + [InlineData("- Leading dash without brackets", "- Leading dash without brackets")] + [InlineData("[Team]-NoSpace", "-NoSpace")] public void StripSquareBracketPrefix_RemovesPrefix(string input, string expected) { var result = ChangelogTextUtilities.StripSquareBracketPrefix(input); result.Should().Be(expected); } + [Theory] + [InlineData(null, false)] + [InlineData("", false)] + [InlineData(" ", false)] + [InlineData("Plain title", false)] + [InlineData("- Leading dash", true)] + [InlineData(" - Leading dash", true)] + [InlineData("* Star", true)] + [InlineData("+ Plus", true)] + [InlineData("\u2013 En dash", true)] + [InlineData("\u2014 Em dash", true)] + public void TitleNeedsDefensiveYamlQuoting_DetectsBulletLikeScalars(string? input, bool expected) + { + ChangelogTextUtilities.TitleNeedsDefensiveYamlQuoting(input).Should().Be(expected); + } + [Theory] [InlineData("https://github.com/elastic/elasticsearch/pull/123", 123)] [InlineData("elastic/elasticsearch#456", 456)] diff --git a/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/ReleaseNotesSerializationTests.cs b/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/ReleaseNotesSerializationTests.cs new file mode 100644 index 0000000000..d5a1072a64 --- /dev/null +++ b/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/ReleaseNotesSerializationTests.cs @@ -0,0 +1,73 @@ +// 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 AwesomeAssertions; +using Elastic.Documentation.Configuration.ReleaseNotes; +using Elastic.Documentation.ReleaseNotes; + +namespace Elastic.Documentation.Configuration.Tests.ReleaseNotes; + +public class ReleaseNotesSerializationTests +{ + [Fact] + public void SerializeEntry_TitleStartingWithDash_EmitsDoubleQuotedTitleAndRoundTrips() + { + var entry = new ChangelogEntry + { + Title = "- Manual leading dash", + Type = ChangelogEntryType.Feature, + Products = + [ + new ProductReference { ProductId = "kibana", Lifecycle = Lifecycle.Ga } + ] + }; + + var yaml = ReleaseNotesSerialization.SerializeEntry(entry); + + (yaml.Contains("title: \"- Manual leading dash\"", StringComparison.Ordinal) || + yaml.Contains("title: '- Manual leading dash'", StringComparison.Ordinal)) + .Should().BeTrue("title must be a quoted YAML scalar so '-' is not parsed as a list marker"); + + var roundTrip = ReleaseNotesSerialization.DeserializeEntry(yaml); + roundTrip.Title.Should().Be("- Manual leading dash"); + } + + [Fact] + public void SerializeEntry_PlainTitle_DoesNotForceDoubleQuotes() + { + var entry = new ChangelogEntry + { + Title = "Enable numerical id service", + Type = ChangelogEntryType.Feature, + Products = + [ + new ProductReference { ProductId = "kibana", Lifecycle = Lifecycle.Ga } + ] + }; + + var yaml = ReleaseNotesSerialization.SerializeEntry(entry); + + yaml.Should().Contain("title: Enable numerical id service"); + yaml.Should().NotContain("title: \"Enable numerical id service\""); + } + + [Fact] + public void SerializeEntry_MultilineTitleStartingWithDash_RoundTrips() + { + var entry = new ChangelogEntry + { + Title = "- line1\nline2", + Type = ChangelogEntryType.Feature, + Products = + [ + new ProductReference { ProductId = "kibana", Lifecycle = Lifecycle.Ga } + ] + }; + + var yaml = ReleaseNotesSerialization.SerializeEntry(entry); + + var roundTrip = ReleaseNotesSerialization.DeserializeEntry(yaml); + roundTrip.Title.Should().Be("- line1\nline2"); + } +} From 39ad15f55b50947838331e294c31195c96c09bfc Mon Sep 17 00:00:00 2001 From: Lisa Cawley Date: Mon, 4 May 2026 09:01:23 -0700 Subject: [PATCH 11/50] Add description-visibility option to changelog directive (#3224) Co-authored-by: Felipe Cotti --- docs/syntax/changelog.md | 16 + .../Directives/Changelog/ChangelogBlock.cs | 28 ++ .../ChangelogDescriptionVisibility.cs | 28 ++ .../Changelog/ChangelogInlineRenderer.cs | 107 +++++-- .../Directives/ChangelogBasicTests.cs | 1 + .../ChangelogDescriptionVisibilityTests.cs | 277 ++++++++++++++++++ 6 files changed, 428 insertions(+), 29 deletions(-) create mode 100644 src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogDescriptionVisibility.cs create mode 100644 tests/Elastic.Markdown.Tests/Directives/ChangelogDescriptionVisibilityTests.cs diff --git a/docs/syntax/changelog.md b/docs/syntax/changelog.md index 3d1ef59976..0fded8deb4 100644 --- a/docs/syntax/changelog.md +++ b/docs/syntax/changelog.md @@ -25,6 +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` | +| `:description-visibility: value` | Visibility of changelog **record** descriptions (YAML `description` on each entry) | `auto` | | `:config: path` | Path to `changelog.yml` configuration | auto-discover | ### Example with options @@ -34,6 +35,7 @@ The directive supports the following options: :type: all :subsections: :link-visibility: keep-links +:description-visibility: keep-descriptions ::: ``` @@ -114,6 +116,18 @@ Bundles whose repo is listed as private in `assembler.yml` hide links by default This aligns with the `changelog render` command's link visibility controls. +#### `:description-visibility:` + +Controls whether the **`description`** text on each **changelog record** appears in output (bullet body text under each item, and the first paragraph inside breaking-change, deprecation, known-issue, and highlight dropdowns). This is **different** from the optional **bundle** `description` field (release intro prose after `_Released:_`), which is always shown when present. See [Rendered output](#rendered-output). + +| Value | Behavior | +|-------|----------| +| `auto` | When **every** constituent repository in the bundle’s resolved repo identity is **public** (same private-repo detection as `:link-visibility:` from `assembler.yml`, including `repo1+repo2` merged bundles), **omit** record `description` bodies. When **any** constituent is marked **private**, **show** those bodies. In standalone builds without `assembler.yml`, every repo is treated as public ⇒ record descriptions are omitted under `auto`. | +| `keep-descriptions` | Always render record descriptions when present in the bundle source. Use this on pages such as deprecations or breaking changes when you still want full release-note prose alongside public repos. | +| `hide-descriptions` | Always omit record `description` bodies (titles, PR/issue links, Impact and Action sections, and bundle-level intros are unaffected). | + +**Contrast with `:link-visibility:`:** `:link-visibility: auto` hides **links** when a repo is **private**. `:description-visibility: auto` **shows** richer record **description** prose when **any** source repo is **private**, and hides that prose for bundles that resolve to **only public** repositories. + #### `:subsections:` When enabled, entries are grouped by "area" within each section. @@ -254,6 +268,8 @@ When present, the `release-date` field is rendered immediately after the version 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. +**Record descriptions:** Each changelog entry may have its own `description` field in YAML (shown as body text under list items or as the introductory paragraph inside dropdowns). Visibility of **these** descriptions is controlled with `:description-visibility:` (defaults to `auto`; see Option details section). Do not confuse bundle `description` (intro prose) with per-record `description` (entry bodies). + ### Section types | Section | Entry type | Rendering | diff --git a/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogBlock.cs b/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogBlock.cs index 004bd904fb..d4218547bd 100644 --- a/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogBlock.cs @@ -160,6 +160,11 @@ public class ChangelogBlock(DirectiveBlockParser parser, ParserContext context) /// public ChangelogLinkVisibility LinkVisibility { get; private set; } + /// + /// Visibility of changelog record description body text (see :description-visibility: option). + /// + public ChangelogDescriptionVisibility DescriptionVisibility { get; private set; } + /// /// Returns all anchors that will be generated by this directive during rendering. /// @@ -183,6 +188,7 @@ public override void FinalizeAndValidate(ParserContext context) LoadConfiguration(); LoadPrivateRepositories(); LinkVisibility = ParseLinkVisibility(); + DescriptionVisibility = ParseDescriptionVisibility(); if (Found) LoadAndCacheBundles(); } @@ -209,6 +215,28 @@ private ChangelogLinkVisibility EmitInvalidLinkVisibilityWarning(string value) return ChangelogLinkVisibility.Auto; } + private ChangelogDescriptionVisibility ParseDescriptionVisibility() + { + var value = Prop("description-visibility"); + if (string.IsNullOrWhiteSpace(value)) + return ChangelogDescriptionVisibility.Auto; + + return value.ToLowerInvariant() switch + { + "auto" => ChangelogDescriptionVisibility.Auto, + "keep-descriptions" => ChangelogDescriptionVisibility.KeepDescriptions, + "hide-descriptions" => ChangelogDescriptionVisibility.HideDescriptions, + _ => EmitInvalidDescriptionVisibilityWarning(value) + }; + } + + private ChangelogDescriptionVisibility EmitInvalidDescriptionVisibilityWarning(string value) + { + this.EmitWarning( + $"Invalid :description-visibility: value '{value}'. Valid values are: auto, keep-descriptions, hide-descriptions. Using auto."); + return ChangelogDescriptionVisibility.Auto; + } + /// /// Parses and validates the :type: option. /// Valid values: all, breaking-change, deprecation, known-issue, highlight. diff --git a/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogDescriptionVisibility.cs b/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogDescriptionVisibility.cs new file mode 100644 index 0000000000..3159d4152b --- /dev/null +++ b/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogDescriptionVisibility.cs @@ -0,0 +1,28 @@ +// 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.Markdown.Myst.Directives.Changelog; + +/// +/// Controls changelog entry description (body text) rendering for the {changelog} directive. +/// Mirrors the structure of while using opposite privacy defaults for . +/// +public enum ChangelogDescriptionVisibility +{ + /// + /// Hide record descriptions when the bundle has only public constituent repos (per assembler.yml); + /// show when any constituent is private. With no private repos configured, hides descriptions everywhere. + /// + Auto, + + /// + /// Always render record descriptions when present in source YAML. + /// + KeepDescriptions, + + /// + /// Never render record descriptions (including dropdown authoring placeholders). + /// + HideDescriptions +} diff --git a/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogInlineRenderer.cs b/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogInlineRenderer.cs index c0c0022717..dd975b89c7 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.DescriptionVisibility); _ = sb.Append(bundleMarkdown); isFirst = false; @@ -53,7 +54,8 @@ private static string RenderSingleBundle( HashSet privateRepositories, HashSet hideFeatures, ChangelogTypeFilter typeFilter, - ChangelogLinkVisibility linkVisibility) + ChangelogLinkVisibility linkVisibility, + ChangelogDescriptionVisibility descriptionVisibility) { var titleSlug = ChangelogTextUtilities.TitleToSlug(bundle.Version); @@ -78,8 +80,22 @@ private static string RenderSingleBundle( _ => ShouldHideLinksForRepo(bundle.Repo, privateRepositories) }; + var hideEntryDescriptions = ShouldHideEntryDescriptionsForRepo(bundle.Repo, privateRepositories, descriptionVisibility); + 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, + hideEntryDescriptions, + typeFilter, + publishBlocker, + bundle.Data?.Description, + bundle.Data?.ReleaseDate); } /// @@ -123,10 +139,35 @@ public static bool ShouldHideLinksForRepo(string bundleRepo, HashSet pri if (privateRepositories.Count == 0) return false; - // Split on '+' to handle merged bundles (e.g., "elasticsearch+kibana+private-repo") + return HasAnyPrivateRepoConstituent(bundleRepo, privateRepositories); + } + + /// + /// When true, changelog entry YAML description bodies (bullet text and dropdown intro) must not be rendered. + /// + public static bool ShouldHideEntryDescriptionsForRepo( + string bundleRepo, + HashSet privateRepositories, + ChangelogDescriptionVisibility visibility) => + visibility switch + { + ChangelogDescriptionVisibility.HideDescriptions => true, + ChangelogDescriptionVisibility.KeepDescriptions => false, + ChangelogDescriptionVisibility.Auto => !HasAnyPrivateRepoConstituent(bundleRepo, privateRepositories), + _ => !HasAnyPrivateRepoConstituent(bundleRepo, privateRepositories) + }; + + /// + /// True when merged (elasticsearch+kibana-style) has at least one + /// component listed as private for the build. + /// + public static bool HasAnyPrivateRepoConstituent(string bundleRepo, HashSet privateRepositories) + { + if (privateRepositories.Count == 0) + return false; + var repos = bundleRepo.Split('+', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - // Hide links if ANY component repo is private return repos.Any(privateRepositories.Contains); } @@ -151,6 +192,7 @@ private static string GenerateMarkdown( Dictionary> entriesByType, bool subsections, bool hideLinks, + bool hideEntryDescriptions, ChangelogTypeFilter typeFilter, PublishBlocker? publishBlocker, string? description = null, @@ -207,7 +249,7 @@ private static string GenerateMarkdown( if (typeFilter == ChangelogTypeFilter.Highlight) { if (highlights.Count > 0) - RenderDetailedEntries(sb, highlights, repo, owner, groupBySubtype: false, hideLinks, publishBlocker); + RenderDetailedEntries(sb, highlights, repo, owner, groupBySubtype: false, hideLinks, hideEntryDescriptions, publishBlocker); return sb.ToString(); } @@ -215,35 +257,35 @@ private static string GenerateMarkdown( { _ = sb.AppendLine(); _ = sb.AppendLine(CultureInfo.InvariantCulture, $"### Breaking changes [{repo}-{titleSlug}-breaking-changes]"); - RenderDetailedEntries(sb, breakingChanges, repo, owner, groupBySubtype: true, hideLinks, publishBlocker); + RenderDetailedEntries(sb, breakingChanges, repo, owner, groupBySubtype: true, hideLinks, hideEntryDescriptions, publishBlocker); } if (highlights.Count > 0 && typeFilter == ChangelogTypeFilter.All) { _ = sb.AppendLine(); _ = sb.AppendLine(CultureInfo.InvariantCulture, $"### Highlights [{repo}-{titleSlug}-highlights]"); - RenderDetailedEntries(sb, highlights, repo, owner, groupBySubtype: false, hideLinks, publishBlocker); + RenderDetailedEntries(sb, highlights, repo, owner, groupBySubtype: false, hideLinks, hideEntryDescriptions, publishBlocker); } if (security.Count > 0) { _ = sb.AppendLine(); _ = sb.AppendLine(CultureInfo.InvariantCulture, $"### Security [{repo}-{titleSlug}-security]"); - RenderEntriesByArea(sb, security, repo, owner, subsections, hideLinks, publishBlocker); + RenderEntriesByArea(sb, security, repo, owner, subsections, hideLinks, hideEntryDescriptions, publishBlocker); } if (knownIssues.Count > 0) { _ = sb.AppendLine(); _ = sb.AppendLine(CultureInfo.InvariantCulture, $"### Known issues [{repo}-{titleSlug}-known-issues]"); - RenderDetailedEntries(sb, knownIssues, repo, owner, groupBySubtype: false, hideLinks, publishBlocker); + RenderDetailedEntries(sb, knownIssues, repo, owner, groupBySubtype: false, hideLinks, hideEntryDescriptions, publishBlocker); } if (deprecations.Count > 0) { _ = sb.AppendLine(); _ = sb.AppendLine(CultureInfo.InvariantCulture, $"### Deprecations [{repo}-{titleSlug}-deprecations]"); - RenderDetailedEntries(sb, deprecations, repo, owner, groupBySubtype: false, hideLinks, publishBlocker); + RenderDetailedEntries(sb, deprecations, repo, owner, groupBySubtype: false, hideLinks, hideEntryDescriptions, publishBlocker); } if (features.Count > 0 || enhancements.Count > 0) @@ -251,35 +293,35 @@ private static string GenerateMarkdown( _ = sb.AppendLine(); _ = sb.AppendLine(CultureInfo.InvariantCulture, $"### Features and enhancements [{repo}-{titleSlug}-features-enhancements]"); var combined = features.Concat(enhancements).ToList(); - RenderEntriesByArea(sb, combined, repo, owner, subsections, hideLinks, publishBlocker); + RenderEntriesByArea(sb, combined, repo, owner, subsections, hideLinks, hideEntryDescriptions, publishBlocker); } if (bugFixes.Count > 0) { _ = sb.AppendLine(); _ = sb.AppendLine(CultureInfo.InvariantCulture, $"### Fixes [{repo}-{titleSlug}-fixes]"); - RenderEntriesByArea(sb, bugFixes, repo, owner, subsections, hideLinks, publishBlocker); + RenderEntriesByArea(sb, bugFixes, repo, owner, subsections, hideLinks, hideEntryDescriptions, publishBlocker); } if (docs.Count > 0) { _ = sb.AppendLine(); _ = sb.AppendLine(CultureInfo.InvariantCulture, $"### Documentation [{repo}-{titleSlug}-docs]"); - RenderEntriesByArea(sb, docs, repo, owner, subsections, hideLinks, publishBlocker); + RenderEntriesByArea(sb, docs, repo, owner, subsections, hideLinks, hideEntryDescriptions, publishBlocker); } if (regressions.Count > 0) { _ = sb.AppendLine(); _ = sb.AppendLine(CultureInfo.InvariantCulture, $"### Regressions [{repo}-{titleSlug}-regressions]"); - RenderEntriesByArea(sb, regressions, repo, owner, subsections, hideLinks, publishBlocker); + RenderEntriesByArea(sb, regressions, repo, owner, subsections, hideLinks, hideEntryDescriptions, publishBlocker); } if (other.Count > 0) { _ = sb.AppendLine(); _ = sb.AppendLine(CultureInfo.InvariantCulture, $"### Other changes [{repo}-{titleSlug}-other]"); - RenderEntriesByArea(sb, other, repo, owner, subsections, hideLinks, publishBlocker); + RenderEntriesByArea(sb, other, repo, owner, subsections, hideLinks, hideEntryDescriptions, publishBlocker); } return sb.ToString(); @@ -292,6 +334,7 @@ private static void RenderEntriesByArea( string owner, bool subsections, bool hideLinks, + bool hideEntryDescriptions, PublishBlocker? publishBlocker) { if (subsections) @@ -309,29 +352,29 @@ private static void RenderEntriesByArea( } foreach (var entry in areaGroup) - RenderSingleEntry(sb, entry, repo, owner, hideLinks); + RenderSingleEntry(sb, entry, repo, owner, hideLinks, hideEntryDescriptions); } } else { foreach (var entry in entries) - RenderSingleEntry(sb, entry, repo, owner, hideLinks); + RenderSingleEntry(sb, entry, repo, owner, hideLinks, hideEntryDescriptions); } } - private static void RenderSingleEntry(StringBuilder sb, ChangelogEntry entry, string repo, string owner, bool hideLinks) + private static void RenderSingleEntry(StringBuilder sb, ChangelogEntry entry, string repo, string owner, bool hideLinks, bool hideEntryDescriptions) { _ = sb.Append("* "); _ = sb.Append(ChangelogTextUtilities.Beautify(entry.Title)); RenderEntryLinks(sb, entry, repo, owner, hideLinks); - if (!string.IsNullOrWhiteSpace(entry.Description)) - { - _ = sb.AppendLine(); - var indented = ChangelogTextUtilities.Indent(entry.Description); - _ = sb.AppendLine(indented); - } + if (hideEntryDescriptions || string.IsNullOrWhiteSpace(entry.Description)) + return; + + _ = sb.AppendLine(); + var indented = ChangelogTextUtilities.Indent(entry.Description); + _ = sb.AppendLine(indented); } private static void RenderEntryLinks(StringBuilder sb, ChangelogEntry entry, string repo, string owner, bool hideLinks) @@ -373,6 +416,7 @@ private static void RenderDetailedEntries( string owner, bool groupBySubtype, bool hideLinks, + bool hideEntryDescriptions, PublishBlocker? publishBlocker) { var grouped = groupBySubtype @@ -391,16 +435,21 @@ private static void RenderDetailedEntries( } foreach (var entry in group) - RenderDetailedEntry(sb, entry, repo, owner, hideLinks); + RenderDetailedEntry(sb, entry, repo, owner, hideLinks, hideEntryDescriptions); } } - private static void RenderDetailedEntry(StringBuilder sb, ChangelogEntry entry, string repo, string owner, bool hideLinks) + private static void RenderDetailedEntry(StringBuilder sb, ChangelogEntry entry, string repo, string owner, bool hideLinks, bool hideEntryDescriptions) { _ = sb.AppendLine(); _ = sb.AppendLine(CultureInfo.InvariantCulture, $"::::{{dropdown}} {ChangelogTextUtilities.Beautify(entry.Title)}"); - _ = sb.AppendLine(entry.Description ?? "% Describe the change"); - _ = sb.AppendLine(); + if (!hideEntryDescriptions) + { + _ = sb.AppendLine(entry.Description ?? "% Describe the change"); + _ = sb.AppendLine(); + } + else + _ = sb.AppendLine(); RenderDetailedEntryLinks(sb, entry, repo, owner, hideLinks); diff --git a/tests/Elastic.Markdown.Tests/Directives/ChangelogBasicTests.cs b/tests/Elastic.Markdown.Tests/Directives/ChangelogBasicTests.cs index b15f82abf9..428597ee3e 100644 --- a/tests/Elastic.Markdown.Tests/Directives/ChangelogBasicTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/ChangelogBasicTests.cs @@ -582,6 +582,7 @@ public ChangelogTitleDescriptionSpacingTests(ITestOutputHelper output) : base(ou // language=markdown """ :::{changelog} + :description-visibility: keep-descriptions ::: """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( // language=yaml diff --git a/tests/Elastic.Markdown.Tests/Directives/ChangelogDescriptionVisibilityTests.cs b/tests/Elastic.Markdown.Tests/Directives/ChangelogDescriptionVisibilityTests.cs new file mode 100644 index 0000000000..956e8b4203 --- /dev/null +++ b/tests/Elastic.Markdown.Tests/Directives/ChangelogDescriptionVisibilityTests.cs @@ -0,0 +1,277 @@ +// 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.IO.Abstractions.TestingHelpers; +using AwesomeAssertions; +using Elastic.Markdown.Myst.Directives.Changelog; + +namespace Elastic.Markdown.Tests.Directives; + +/// Unit tests for . +public class ChangelogShouldHideEntryDescriptionsTests +{ + [Fact] + public void HideDescriptions_AlwaysReturnsTrue() + { + var privateRepos = new HashSet(StringComparer.OrdinalIgnoreCase) { "x" }; + + var result = ChangelogInlineRenderer.ShouldHideEntryDescriptionsForRepo( + "kibana", + privateRepos, + ChangelogDescriptionVisibility.HideDescriptions); + + result.Should().BeTrue(); + } + + [Fact] + public void KeepDescriptions_AlwaysReturnsFalse() + { + var result = ChangelogInlineRenderer.ShouldHideEntryDescriptionsForRepo( + "kibana", + [], + ChangelogDescriptionVisibility.KeepDescriptions); + + result.Should().BeFalse(); + } + + [Fact] + public void Auto_WithEmptyPrivateRepos_HidesBodies() + { + var result = ChangelogInlineRenderer.ShouldHideEntryDescriptionsForRepo( + "kibana", + [], + ChangelogDescriptionVisibility.Auto); + + result.Should().BeTrue(); + } + + [Fact] + public void Auto_WithPublicRepoOnly_HidesBodies() + { + var privateRepos = new HashSet(StringComparer.OrdinalIgnoreCase) { "secret-repo" }; + + var result = ChangelogInlineRenderer.ShouldHideEntryDescriptionsForRepo( + "kibana", + privateRepos, + ChangelogDescriptionVisibility.Auto); + + result.Should().BeTrue(); + } + + [Fact] + public void Auto_WithPrivateRepo_ShowsBodies() + { + var privateRepos = new HashSet(StringComparer.OrdinalIgnoreCase) { "kibana" }; + + var result = ChangelogInlineRenderer.ShouldHideEntryDescriptionsForRepo( + "kibana", + privateRepos, + ChangelogDescriptionVisibility.Auto); + + result.Should().BeFalse(); + } + + [Fact] + public void Auto_WithMergedBundle_OnePrivateConstituent_ShowsBodies() + { + var privateRepos = new HashSet(StringComparer.OrdinalIgnoreCase) { "kibana" }; + + var result = ChangelogInlineRenderer.ShouldHideEntryDescriptionsForRepo( + "elasticsearch+kibana", + privateRepos, + ChangelogDescriptionVisibility.Auto); + + result.Should().BeFalse(); + } + + [Fact] + public void Auto_WithMergedBundle_AllPublicConstituents_HidesBodies() + { + var privateRepos = new HashSet(StringComparer.OrdinalIgnoreCase) { "other-private" }; + + var result = ChangelogInlineRenderer.ShouldHideEntryDescriptionsForRepo( + "elasticsearch+kibana", + privateRepos, + ChangelogDescriptionVisibility.Auto); + + result.Should().BeTrue(); + } +} + +/// +/// Omitting :description-visibility: defaults to . +/// +public class ChangelogDescriptionVisibilityDefaultTests(ITestOutputHelper output) : DirectiveTest(output, + """ + :::{changelog} + ::: + """) +{ + protected override void AddToFileSystem(MockFileSystem fileSystem) => + fileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: Feature delta + type: feature + products: + - product: elasticsearch + target: 9.3.0 + description: BODY_DEFAULT_AUTO_VISIBILITY + """)); + + [Fact] + public void PropertyDefaultsToAuto() => + Block!.DescriptionVisibility.Should().Be(ChangelogDescriptionVisibility.Auto); + + /// Public bundle with no assembler private repos ⇒ auto hides record bodies. + [Fact] + public void HtmlOmitsBodyTextForPublicBundle() => + Html.Should().NotContain("BODY_DEFAULT_AUTO_VISIBILITY"); + + [Fact] + public void HtmlStillRendersTitles() => + Html.Should().Contain("Feature delta"); +} + +public class ChangelogDescriptionVisibilityAutoShowsForPrivateRepoTests(ITestOutputHelper output) : DirectiveTest(output, + """ + :::{changelog} + ::: + """) +{ + protected override void AddToFileSystem(MockFileSystem fileSystem) => + fileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: Feature epsilon + type: feature + products: + - product: elasticsearch + target: 9.3.0 + description: BODY_PRIVATE_VISIBILITY_TEST + """)); + + public override async ValueTask InitializeAsync() + { + await base.InitializeAsync(); + _ = Block!.PrivateRepositories.Add("elasticsearch"); + } + + [Fact] + public void MarkdownIncludesBodyTextWhenRepoIsPrivateForAutoMode() + { + var markdown = ChangelogInlineRenderer.RenderChangelogMarkdown(Block!); + markdown.Should().Contain("BODY_PRIVATE_VISIBILITY_TEST"); + } + + [Fact] + public void MarkdownRendersTitle() + { + var markdown = ChangelogInlineRenderer.RenderChangelogMarkdown(Block!); + markdown.Should().Contain("Feature epsilon"); + } +} + +public class ChangelogDescriptionVisibilityKeepExplicitTests(ITestOutputHelper output) : DirectiveTest(output, + """ + :::{changelog} + :description-visibility: keep-descriptions + ::: + """) +{ + protected override void AddToFileSystem(MockFileSystem fileSystem) => + fileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: Feature keep + type: feature + products: + - product: elasticsearch + target: 9.3.0 + description: BODY_KEEP_VISIBILITY + """)); + + [Fact] + public void KeepsBodyOnFullyPublicRepos() => + Html.Should().Contain("BODY_KEEP_VISIBILITY"); +} + +public class ChangelogDescriptionVisibilityHideExplicitTests(ITestOutputHelper output) : DirectiveTest(output, + """ + :::{changelog} + :description-visibility: hide-descriptions + ::: + """) +{ + protected override void AddToFileSystem(MockFileSystem fileSystem) => + fileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: Feature hide + type: feature + products: + - product: elasticsearch + target: 9.3.0 + description: BODY_HIDE_VISIBILITY + """)); + + [Fact] + public void OmitBody() => + Html.Should().NotContain("BODY_HIDE_VISIBILITY"); + + [Fact] + public void MarkdownRendersTitlesWithoutBodies() + { + var markdown = ChangelogInlineRenderer.RenderChangelogMarkdown(Block!); + markdown.Should().Contain("Feature hide"); + markdown.Should().NotContain("BODY_HIDE_VISIBILITY"); + } +} + +public class ChangelogDescriptionVisibilityInvalidTests(ITestOutputHelper output) : DirectiveTest(output, + """ + :::{changelog} + :description-visibility: nonsense-value + ::: + """) +{ + protected override void AddToFileSystem(MockFileSystem fileSystem) => + fileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: Feature warn + type: feature + products: + - product: elasticsearch + target: 9.3.0 + description: BODY_INVALID_VISIBILITY + """)); + + [Fact] + public void FallsBackToAuto() => + Block!.DescriptionVisibility.Should().Be(ChangelogDescriptionVisibility.Auto); + + [Fact] + public void EmitsWarning() => + Collector.Warnings.Should().BeGreaterThan(0); + + [Fact] + public void AutoTreatsFullyPublic_AsHideBody() => + Html.Should().NotContain("BODY_INVALID_VISIBILITY"); +} From 5f0a9f23f2af230881c626d323f554c8216898d4 Mon Sep 17 00:00:00 2001 From: Lisa Cawley Date: Mon, 4 May 2026 09:24:19 -0700 Subject: [PATCH 12/50] Add --report option for changelog add command (#3227) * Add --report option for changelog add command * Update tests/Elastic.Changelog.Tests/Changelogs/Create/AddReportOptionTests.cs Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: Felipe Cotti --- docs/cli/changelog/add.md | 28 +++-- .../docs-builder/Commands/ChangelogCommand.cs | 76 ++++++++++-- .../Changelogs/Create/AddReportOptionTests.cs | 111 ++++++++++++++++++ 3 files changed, 193 insertions(+), 22 deletions(-) create mode 100644 tests/Elastic.Changelog.Tests/Changelogs/Create/AddReportOptionTests.cs diff --git a/docs/cli/changelog/add.md b/docs/cli/changelog/add.md index d700cd8016..710fcb9f2f 100644 --- a/docs/cli/changelog/add.md +++ b/docs/cli/changelog/add.md @@ -30,7 +30,7 @@ docs-builder changelog add [options...] [-h|--help] `--no-extract-release-notes` : Optional: Turn off extraction of release notes from PR or issue descriptions. -: By default, the behavior is determined by the [extract.release_notes](/contribute/configure-changelogs-ref.md#extract) changelog configuration setting. +: By default, the behavior is determined by the [extract.release_notes](/contribute/configure-changelogs-ref.md#extract) changelog configuration setting. Release notes are extracted when using `--prs` or `--report` (and from issues when using `--issues`). `--feature-id ` : Optional: Feature flag ID @@ -50,10 +50,11 @@ docs-builder changelog add [options...] [-h|--help] : If `--owner` and `--repo` are provided, issue numbers can be used instead of URLs. : If specified, `--title` can be derived from the issue. : Creates one changelog file per issue. +: Mutually exclusive with `--report`. `--no-extract-issues` : Optional: Turn off extraction of linked references. -: When using `--prs`: turns off extraction of linked issues from the PR body (for example, "Fixes #123"). +: When using `--prs` or `--report`: turns off extraction of linked issues from the PR body (for example, "Fixes #123"). : When using `--issues`: turns off extraction of linked PRs from the issue body (for example, "Fixed by #123"). : By default, the behavior is determined by the `extract.issues` changelog configuration setting. @@ -61,7 +62,7 @@ docs-builder changelog add [options...] [-h|--help] : Optional: Output directory for the changelog fragment. Falls back to `bundle.directory` in `changelog.yml` when not specified. If that value is also absent, defaults to current directory. `--owner ` -: Optional: GitHub repository owner (used when `--prs` or `--issues` contains just numbers, or when using `--release-version`). +: Optional: GitHub repository owner (used when `--prs` or `--issues` contains just numbers, or when using `--release-version`). Not required when `--prs` or `--report` supplies only fully-qualified pull request URLs. : Falls back to `bundle.owner` in `changelog.yml` when not specified. If that value is also absent, defaults to `elastic`. `--products >` @@ -80,19 +81,26 @@ docs-builder changelog add [options...] [-h|--help] : If mappings are configured, `--areas`, `--type`, and `--products` can also be derived from the PR labels. : Creates one changelog file per PR. : If there are `rules.create` definitions in the changelog configuration file and a PR has a blocking label for the resolved products, that PR is skipped and no changelog file is created for it. +: Mutually exclusive with `--report`. +`--report ` +: Optional: URL or path to a promotion report HTML document (for example a Buildkite promotion report). The command extracts GitHub pull request URLs from the HTML and creates one changelog file per PR, using the same parsing rules as [`changelog bundle --report`](/cli/changelog/bundle.md). +: Mutually exclusive with `--prs`, `--issues`, and `--release-version`. +: For a plain newline-delimited list of fully-qualified PR URLs, use `--prs` with a file path instead of `--report`. +: When the value is an `https://` URL, only hosts allowed by the parser (such as `github.com` and `buildkite.com`) are supported, and the CLI needs network access to fetch the report. `--release-version ` : Optional: GitHub release tag to use as a source of pull requests (for example, `"v9.2.0"` or `"latest"`). : When specified, the command fetches the release from GitHub, parses PR references from the release notes, and creates one changelog file per PR — without creating a bundle. Only automated GitHub release notes (the default format or [Release Drafter](https://github.com/release-drafter/release-drafter) format) are supported at this time. : Use `docs-builder changelog gh-release` instead if you also want a bundle. : Requires `--repo` (or `bundle.repo` in `changelog.yml`). : Set to `latest` to use the most recent release. +: Mutually exclusive with `--report`, `--prs`, and `--issues`. `--repo ` -: Optional: GitHub repository name (used when `--prs`, `--issues`, or `--release-version` is specified). Falls back to `bundle.repo` in `changelog.yml` when not specified. +: Optional: GitHub repository name (used when `--prs`, `--issues`, `--report`, or `--release-version` is specified). Falls back to `bundle.repo` in `changelog.yml` when not specified. `--strip-title-prefix` -: Optional: When used with `--prs` or `--issues`, remove square brackets and text within them from the beginning of PR or issue titles, remove a colon if it follows the closing bracket, and remove a single ASCII hyphen when it's immediately after that prefix and followed by whitespace. +: Optional: When used with `--prs`, `--issues`, or `--report`, remove square brackets and text within them from the beginning of PR or issue titles, remove a colon if it follows the closing bracket, and remove a single ASCII hyphen when it's immediately after that prefix and followed by whitespace. : For example, if a PR title is `"[Discover][ESQL]: Fix filtering by multiline string fields"` it becomes `"Fix filtering by multiline string fields"`. : Likewise `"[Cases] - Enable numerical id service"` becomes `"Enable numerical id service"`. : When a derived title still begins with `-`, `*`, `+`, an en dash, or an em dash, the emitted YAML uses a quoted `title` value so it is valid and unambiguous. @@ -105,18 +113,18 @@ docs-builder changelog add [options...] [-h|--help] `--title ` : A short, user-facing title (max 80 characters) -: Required if neither `--prs` nor `--issues` is specified. +: Required if none of `--prs`, `--issues`, or `--report` is specified. : If both `--prs` and `--title` are specified, the latter value is used instead of what exists in the PR. : If the content contains any special characters such as backquotes, you must precede it with a backslash escape character (`\`). `--type ` -: Required if neither `--prs` nor `--issues` is specified. Type of change (for example, `feature`, `enhancement`, `bug-fix`, or `breaking-change`). +: Required if none of `--prs`, `--issues`, or `--report` is specified. Type of change (for example, `feature`, `enhancement`, `bug-fix`, or `breaking-change`). : If mappings are configured, type can be derived from the PR or issue. : The valid types are listed in [ChangelogConfiguration.cs](https://github.com/elastic/docs-builder/blob/main/src/services/Elastic.Documentation.Services/Changelog/ChangelogConfiguration.cs). `--use-pr-number` : Optional: Use PR numbers for filenames instead of the configured `filename` strategy. -: Requires `--prs` or `--issues`. +: Requires `--prs`, `--issues`, or `--report`. : Mutually exclusive with `--use-issue-number`. : Refer to [](#filenames). @@ -153,7 +161,7 @@ docs-builder changelog add \ ``` :::{important} -`--use-pr-number` and `--use-issue-number` are mutually exclusive; specify only one. Each requires `--prs` or `--issues`. The numbers are extracted from the URLs or identifiers you provide or from linked references in the issue or PR body when extraction is enabled. +`--use-pr-number` and `--use-issue-number` are mutually exclusive; specify only one. `--use-pr-number` requires `--prs`, `--issues`, or `--report`. `--use-issue-number` requires `--prs` or `--issues`. The numbers are extracted from the URLs or identifiers you provide or from linked references in the issue or PR body when extraction is enabled. **Precedence**: CLI flags (`--use-pr-number` / `--use-issue-number`) > `filename` in `changelog.yml` > default (`timestamp`). ::: @@ -181,6 +189,8 @@ The `changelog add` command resolves product values in the following order: 1. If `products.default` is defined in the changelog configuration file, those default products are used. 1. If `--repo` is specified (or `bundle.repo` is set in the changelog configuration file), the repository name is matched against known product IDs in `products.yml` and the derived value is used. +The same order applies when using `--report` (after PR URLs are resolved from the promotion report), and when using batch `--prs` with multiple pull requests. + If none of these steps yield at least one product, the command returns an error. ## Configuration checks diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index 66e200c0f3..e81b88a2f3 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -227,19 +227,20 @@ public Task Init( /// Optional: Feature flag ID /// Optional: Include in release highlights /// Optional: How the user's environment is affected - /// Optional: Issue URL(s) or number(s) (comma-separated), or a path to a newline-delimited file containing issue URLs or numbers. Can be specified multiple times. Each occurrence can be either comma-separated issues (e.g., `--issues "https://github.com/owner/repo/issues/123,456"`) or a file path (e.g., `--issues /path/to/file.txt`). If --owner and --repo are provided, issue numbers can be used instead of URLs. If specified, --title can be derived from the issue. Creates one changelog file per issue. + /// Optional: Issue URL(s) or number(s) (comma-separated), or a path to a newline-delimited file containing issue URLs or numbers. Can be specified multiple times. Each occurrence can be either comma-separated issues (e.g., `--issues "https://github.com/owner/repo/issues/123,456"`) or a file path (e.g., `--issues /path/to/file.txt`). If --owner and --repo are provided, issue numbers can be used instead of URLs. If specified, --title can be derived from the issue. Creates one changelog file per issue. Mutually exclusive with --release-version and --report. /// Optional: GitHub repository owner (used when --prs or --issues contains just numbers, or when using --release-version). Falls back to bundle.owner in changelog.yml when not specified. If that value is also absent, "elastic" is used. /// Optional: Output directory for the changelog. Falls back to bundle.directory in changelog.yml when not specified. Defaults to current directory. - /// Optional: Pull request URL(s) or PR number(s) (comma-separated), or a path to a newline-delimited file containing PR URLs or numbers. Can be specified multiple times. Each occurrence can be either comma-separated PRs (e.g., `--prs "https://github.com/owner/repo/pull/123,6789"`) or a file path (e.g., `--prs /path/to/file.txt`). When specifying PRs directly, provide comma-separated values. When specifying a file path, provide a single value that points to a newline-delimited file. If --owner and --repo are provided, PR numbers can be used instead of URLs. If specified, --title can be derived from the PR. If mappings are configured, --areas and --type can also be derived from the PR. Creates one changelog file per PR. + /// Optional: Pull request URL(s) or PR number(s) (comma-separated), or a path to a newline-delimited file containing PR URLs or numbers. Can be specified multiple times. Each occurrence can be either comma-separated PRs (e.g., `--prs "https://github.com/owner/repo/pull/123,6789"`) or a file path (e.g., `--prs /path/to/file.txt`). When specifying PRs directly, provide comma-separated values. When specifying a file path, provide a single value that points to a newline-delimited file. If --owner and --repo are provided, PR numbers can be used instead of URLs. If specified, --title can be derived from the PR. If mappings are configured, --areas and --type can also be derived from the PR. Creates one changelog file per PR. Mutually exclusive with --release-version and --report. + /// Optional: URL or file path to a promotion report HTML document. Extracts GitHub pull request URLs and creates one changelog per PR (same parsing as `changelog bundle --report`). Mutually exclusive with --prs, --issues, and --release-version. /// Optional: GitHub repository name (used when --prs or --issues contains just numbers, or when using --release-version). Falls back to bundle.repo in changelog.yml when not specified. - /// Optional: When used with --prs, remove square brackets and text within them from the beginning of PR titles, and also remove a colon if it follows the closing bracket (e.g., "[Inference API] Title" becomes "Title", "[ES|QL]: Title" becomes "Title", "[Discover][ESQL] Title" becomes "Title") + /// Optional: When used with --prs or --report, remove square brackets and text within them from the beginning of PR titles, and also remove a colon if it follows the closing bracket (e.g., "[Inference API] Title" becomes "Title", "[ES|QL]: Title" becomes "Title", "[Discover][ESQL] Title" becomes "Title") /// Optional: Subtype for breaking changes (api, behavioral, configuration, etc.) - /// Optional: A short, user-facing title (max 80 characters). Required if neither --prs nor --issues is specified. If --prs and --title are specified, the latter value is used instead of what exists in the PR. - /// Optional: Type of change (feature, enhancement, bug-fix, breaking-change, etc.). Required if neither --prs nor --issues is specified. If mappings are configured, type can be derived from the PR or issue. + /// Optional: A short, user-facing title (max 80 characters). Required if neither --prs, --issues, nor --report is specified. If --prs and --title are specified, the latter value is used instead of what exists in the PR. + /// Optional: Type of change (feature, enhancement, bug-fix, breaking-change, etc.). Required if neither --prs, --issues, nor --report is specified. If mappings are configured, type can be derived from the PR or issue. /// Optional: Omit schema reference comments from generated YAML files. Useful in CI to produce compact output. - /// Optional: Use PR numbers for filenames instead of timestamp-slug. With both --prs (which creates one changelog per specified PR) and --issues (which creates one changelog per specified issue), each changelog filename will be derived from its PR numbers. Requires --prs or --issues. Mutually exclusive with --use-issue-number. + /// Optional: Use PR numbers for filenames instead of timestamp-slug. With --prs, --report, or --issues (where PRs are resolved), each changelog filename will be derived from its PR numbers. Requires --prs, --report, or --issues. Mutually exclusive with --use-issue-number. /// Optional: Use issue numbers for filenames instead of timestamp-slug. With both --prs (which creates one changelog per specified PR) and --issues (which creates one changelog per specified issue), each changelog filename will be derived from its issues. Requires --prs or --issues. Mutually exclusive with --use-pr-number. - /// Optional: GitHub release tag to fetch PRs from (e.g., "v9.2.0" or "latest"). When specified, creates one changelog per PR in the release notes. Requires --repo (or bundle.repo in changelog.yml). Mutually exclusive with --prs and --issues. Does not create a bundle; use 'changelog gh-release' for that. + /// Optional: GitHub release tag to fetch PRs from (e.g., "v9.2.0" or "latest"). When specified, creates one changelog per PR in the release notes. Requires --repo (or bundle.repo in changelog.yml). Mutually exclusive with --prs, --issues, and --report. Does not create a bundle; use 'changelog gh-release' for that. /// Cancellation token [Command("add")] public async Task Create( @@ -258,6 +259,7 @@ public async Task Create( string? owner = null, string? output = null, string[]? prs = null, + string? report = null, string? releaseVersion = null, string? repo = null, bool stripTitlePrefix = false, @@ -271,9 +273,40 @@ public async Task Create( { await using var serviceInvoker = new ServiceInvoker(collector); - // Mutual exclusivity: --release-version cannot be combined with --prs or --issues + var hasReport = !string.IsNullOrWhiteSpace(report); + if (hasReport) + { + if (prs is { Length: > 0 }) + { + collector.EmitError(string.Empty, "--report and --prs cannot be specified together."); + _ = collector.StartAsync(ctx); + await collector.WaitForDrain(); + await collector.StopAsync(ctx); + return 1; + } + + if (issues is { Length: > 0 }) + { + collector.EmitError(string.Empty, "--report and --issues cannot be specified together."); + _ = collector.StartAsync(ctx); + await collector.WaitForDrain(); + await collector.StopAsync(ctx); + return 1; + } + } + + // Mutual exclusivity: --release-version cannot be combined with --prs, --issues, or --report if (releaseVersion != null) { + if (hasReport) + { + collector.EmitError(string.Empty, "--release-version and --report are mutually exclusive."); + _ = collector.StartAsync(ctx); + await collector.WaitForDrain(); + await collector.StopAsync(ctx); + return 1; + } + if (prs is { Length: > 0 }) { collector.EmitError(string.Empty, "--release-version and --prs are mutually exclusive."); @@ -295,7 +328,7 @@ public async Task Create( // Load changelog config and apply fallbacks for all modes. // Precedence: CLI option > bundle section in changelog.yml > built-in default. - // This applies to --prs, --issues, and --release-version alike. + // This applies to --prs, --issues, --release-version, and --report alike. var bundleConfig = await new ChangelogConfigurationLoader(logFactory, configurationContext, _fileSystem) .LoadChangelogConfiguration(collector, config, ctx); var resolvedRepo = !string.IsNullOrWhiteSpace(repo) ? repo : bundleConfig?.Bundle?.Repo; @@ -342,9 +375,26 @@ async static (s, collector, state, ctx) => await s.CreateChangelogsFromRelease(c IGitHubPrService githubPrService = new GitHubPrService(logFactory); var service = new ChangelogCreationService(logFactory, configurationContext, githubPrService, env: SystemEnvironmentVariables.Instance); - // Parse PRs: handle both comma-separated values and file paths + // Parse PRs: promotion report (--report), or comma-separated values and file paths (--prs) string[]? parsedPrs = null; - if (prs is { Length: > 0 }) + if (hasReport) + { + var reportSource = report!.Trim(); + if (!reportSource.StartsWith("http://", StringComparison.OrdinalIgnoreCase) && + !reportSource.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + reportSource = NormalizePath(reportSource); + + var reportParser = new PromotionReportParser(logFactory, null); + parsedPrs = await reportParser.ParseReportToPrUrlsAsync(collector, reportSource, ctx); + if (parsedPrs == null) + { + _ = collector.StartAsync(ctx); + await collector.WaitForDrain(); + await collector.StopAsync(ctx); + return 1; + } + } + else if (prs is { Length: > 0 }) { var allPrs = new List(); var validPrs = prs.Where(prValue => !string.IsNullOrWhiteSpace(prValue)); @@ -438,7 +488,7 @@ async static (s, collector, state, ctx) => await s.CreateChangelogsFromRelease(c // --use-pr-number with --issues is allowed: PRs can be extracted from the issue body (Fixed by #123, etc.) if (usePrNumber && (parsedPrs == null || parsedPrs.Length == 0) && (parsedIssues == null || parsedIssues.Length == 0)) { - collector.EmitError(string.Empty, "--use-pr-number requires --prs or --issues to be specified."); + collector.EmitError(string.Empty, "--use-pr-number requires --prs, --issues, or --report to be specified."); _ = collector.StartAsync(ctx); await collector.WaitForDrain(); await collector.StopAsync(ctx); @@ -880,7 +930,7 @@ async static (s, collector, state, ctx) => await s.BundleChangelogs(collector, s /// Filter by pull request URLs (comma-separated) or a path to a newline-delimited file containing fully-qualified GitHub PR URLs. Can be specified multiple times. /// GitHub release tag to use as a filter source (for example, "v9.2.0" or "latest"). Fetches the release, parses PR references from the release notes, and removes changelogs whose PR URLs match — equivalent to passing the PR list using --prs. /// GitHub repository name, which is used when PRs or issues are specified as numbers or when --release-version is used. Falls back to bundle.repo in changelog.yml when not specified. If that value is also absent, the product ID is used. - /// Optional (option-based mode only): URL or file path to a promotion report. Extracts PR URLs and uses them as the filter. Mutually exclusive with --all, --products, --prs, and --issues. + /// Optional (option-based mode only): URL or file path to a promotion report. Extracts PR URLs and uses them as the filter. Mutually exclusive with --all, --products, --prs, --release-version, and --issues. /// [Command("remove")] public async Task Remove( diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Create/AddReportOptionTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/Create/AddReportOptionTests.cs new file mode 100644 index 0000000000..f5f74ca3ea --- /dev/null +++ b/tests/Elastic.Changelog.Tests/Changelogs/Create/AddReportOptionTests.cs @@ -0,0 +1,111 @@ +// 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 AwesomeAssertions; +using Elastic.Changelog.Bundling; +using Elastic.Changelog.Creation; +using Elastic.Changelog.GitHub; +using Elastic.Documentation.Configuration; +using FakeItEasy; + +namespace Elastic.Changelog.Tests.Changelogs.Create; + +/// +/// Tests promotion-report parsing feeding (same expansion +/// changelog add --report performs before creation). +/// +public class AddReportOptionTests(ITestOutputHelper output) : CreateChangelogTestBase(output) +{ + [Fact] + public async Task CreateChangelog_FromPromotionReportHtmlFile_CreatesOneYamlPerPr() + { + var html = + """ + + PR #7001 + PR #7002 + + """; + var reportFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "promotion.html"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(reportFile)!); + await FileSystem.File.WriteAllTextAsync(reportFile, html, TestContext.Current.CancellationToken); + + var pr1 = new GitHubPrInfo { Title = "First from report", Labels = ["type:feature"] }; + var pr2 = new GitHubPrInfo { Title = "Second from report", Labels = ["type:bug"] }; + A.CallTo(() => MockGitHubService.FetchPrInfoAsync( + A.That.Contains("7001"), + null, + null, + A._)) + .Returns(pr1); + A.CallTo(() => MockGitHubService.FetchPrInfoAsync( + A.That.Contains("7002"), + null, + null, + A._)) + .Returns(pr2); + + // language=yaml + var configContent = + """ + pivot: + types: + feature: "type:feature" + bug-fix: "type:bug" + breaking-change: + lifecycles: + - preview + - beta + - ga + """; + var configPath = await CreateConfigDirectory(configContent); + + var parser = new PromotionReportParser(LoggerFactory, FileSystem); + var prUrls = await parser.ParseReportToPrUrlsAsync(Collector, reportFile, TestContext.Current.CancellationToken); + prUrls.Should().NotBeNull(); + prUrls!.Should().HaveCount(2); + + var service = CreateService(); + var input = new CreateChangelogArguments + { + Prs = prUrls, + Products = [new ProductArgument { Product = "elasticsearch", Target = "9.2.0", Lifecycle = "ga" }], + Config = configPath, + Output = CreateOutputDirectory(), + UsePrNumber = true + }; + + var result = await service.CreateChangelog(Collector, input, TestContext.Current.CancellationToken); + + result.Should().BeTrue(); + Collector.Errors.Should().Be(0); + + var outputDir = input.Output!; + var files = FileSystem.Directory.GetFiles(outputDir, "*.yaml"); + files.Should().HaveCount(2); + Array.Sort(files, StringComparer.Ordinal); + Path.GetFileName(files[0]).Should().Be("7001.yaml"); + Path.GetFileName(files[1]).Should().Be("7002.yaml"); + + var yaml1 = await FileSystem.File.ReadAllTextAsync(files[0], TestContext.Current.CancellationToken); + yaml1.Should().Contain("title: First from report"); + yaml1.Should().Contain("https://github.com/elastic/elasticsearch/pull/7001"); + + var yaml2 = await FileSystem.File.ReadAllTextAsync(files[1], TestContext.Current.CancellationToken); + yaml2.Should().Contain("title: Second from report"); + yaml2.Should().Contain("https://github.com/elastic/elasticsearch/pull/7002"); + } + + [Fact] + public async Task PromotionReportParser_ReportFileMissing_EmitsError() + { + var missing = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "nope.html"); + var parser = new PromotionReportParser(LoggerFactory, FileSystem); + + var prUrls = await parser.ParseReportToPrUrlsAsync(Collector, missing, TestContext.Current.CancellationToken); + + prUrls.Should().BeNull(); + Collector.Errors.Should().BeGreaterThan(0); + } +} From 0a05ecce96bd28f277cbcb067513071f1c8e4e30 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 13:36:52 -0300 Subject: [PATCH 13/50] Bump release-drafter/release-drafter from 7.2.0 to 7.2.1 (#3230) Bumps [release-drafter/release-drafter](https://github.com/release-drafter/release-drafter) from 7.2.0 to 7.2.1. - [Release notes](https://github.com/release-drafter/release-drafter/releases) - [Commits](https://github.com/release-drafter/release-drafter/compare/5de93583980a40bd78603b6dfdcda5b4df377b32...563bf132657a13ded0b01fcb723c5a58cdd824e2) --- updated-dependencies: - dependency-name: release-drafter/release-drafter dependency-version: 7.2.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/release-drafter.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index c5388d61ad..0b719eb3ac 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -18,6 +18,6 @@ jobs: pull-requests: read runs-on: ubuntu-latest steps: - - uses: release-drafter/release-drafter@5de93583980a40bd78603b6dfdcda5b4df377b32 # v7.2.0 + - uses: release-drafter/release-drafter@563bf132657a13ded0b01fcb723c5a58cdd824e2 # v7.2.1 env: GITHUB_TOKEN: ${{ github.token }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4982e8b987..3edc162c33 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,7 +26,7 @@ jobs: echo "This workflow is only allowed to run on the main branch." exit 1 fi - - uses: release-drafter/release-drafter@5de93583980a40bd78603b6dfdcda5b4df377b32 # v7.2.0 + - uses: release-drafter/release-drafter@563bf132657a13ded0b01fcb723c5a58cdd824e2 # v7.2.1 id: release-drafter env: GITHUB_TOKEN: ${{ github.token }} From 950ebe9fa9380b58a75d7d05074f334d6786cf26 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Mon, 4 May 2026 20:42:47 +0200 Subject: [PATCH 14/50] Retry S3 link registry fetch on transient errors in match command (#3229) Co-authored-by: Claude Sonnet 4.6 (1M context) --- .../RepositoryBuildMatchingService.cs | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/services/Elastic.Documentation.Assembler/ContentSources/RepositoryBuildMatchingService.cs b/src/services/Elastic.Documentation.Assembler/ContentSources/RepositoryBuildMatchingService.cs index 7e83293103..ea4a8ee7af 100644 --- a/src/services/Elastic.Documentation.Assembler/ContentSources/RepositoryBuildMatchingService.cs +++ b/src/services/Elastic.Documentation.Assembler/ContentSources/RepositoryBuildMatchingService.cs @@ -8,6 +8,7 @@ using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.LinkIndex; +using Elastic.Documentation.Links; using Elastic.Documentation.Services; using Microsoft.Extensions.Logging; using Nullean.ScopedFileSystem; @@ -24,6 +25,26 @@ ScopedFileSystem fileSystem { private readonly ILogger _logger = logFactory.CreateLogger(); + private async Task GetRegistryWithRetry(Aws3LinkIndexReader provider, Cancel ctx) + { + const int maxAttempts = 3; + for (var attempt = 1; attempt <= maxAttempts; attempt++) + { + try + { + return await provider.GetRegistry(ctx); + } + catch (Exception ex) when (attempt < maxAttempts) + { + var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt)); + _logger.LogWarning("S3 link registry fetch failed (attempt {Attempt}/{Max}), retrying in {Delay}s: {Message}", + attempt, maxAttempts, delay.TotalSeconds, ex.Message); + await Task.Delay(delay, ctx); + } + } + return await provider.GetRegistry(ctx); + } + //TODO return contentsourcematch /// /// Validates whether the on should be build and therefor published. @@ -43,7 +64,7 @@ public async Task ShouldBuild(IDiagnosticsCollector collector, string? rep // environment does not matter to check the configuration, defaulting to dev var linkIndexProvider = Aws3LinkIndexReader.CreateAnonymous(); - var linkRegistry = await linkIndexProvider.GetRegistry(ctx); + var linkRegistry = await GetRegistryWithRetry(linkIndexProvider, ctx); var alreadyPublishing = linkRegistry.Repositories.ContainsKey(repo); _logger.LogInformation("'{Repository}' publishing to link registry: {PublishState} ", repo, alreadyPublishing); var assembleContext = new AssembleContext(configuration, configurationContext, "dev", collector, fileSystem, fileSystem, null, null); From 7abe8e186eb8a18bae3a51b84e8e660b993620b0 Mon Sep 17 00:00:00 2001 From: Lisa Cawley Date: Mon, 4 May 2026 14:40:09 -0700 Subject: [PATCH 15/50] [Changelog] Fix missing links in changelog directive and render command (#3228) Co-authored-by: Felipe Cotti Co-authored-by: Cursor --- docs/syntax/changelog.md | 23 +- .../ReleaseNotes/ChangelogTextUtilities.cs | 17 + .../Directives/Changelog/ChangelogBlock.cs | 7 + .../Changelog/ChangelogInlineRenderer.cs | 192 ++++++++- .../Changelogs/Render/PrivateLinkBugTests.cs | 177 ++++++++ .../ChangelogTextUtilitiesTests.cs | 84 ++++ .../Directives/ChangelogDropdownsTests.cs | 390 ++++++++++++++++++ .../Directives/ChangelogHideLinksTests.cs | 2 + .../ChangelogPrivateLinkBugTests.cs | 163 ++++++++ 9 files changed, 1028 insertions(+), 27 deletions(-) create mode 100644 tests/Elastic.Changelog.Tests/Changelogs/Render/PrivateLinkBugTests.cs create mode 100644 tests/Elastic.Markdown.Tests/Directives/ChangelogDropdownsTests.cs create mode 100644 tests/Elastic.Markdown.Tests/Directives/ChangelogPrivateLinkBugTests.cs diff --git a/docs/syntax/changelog.md b/docs/syntax/changelog.md index 0fded8deb4..16c752d840 100644 --- a/docs/syntax/changelog.md +++ b/docs/syntax/changelog.md @@ -26,6 +26,7 @@ The directive supports the following options: | `:subsections:` | Group entries by area/component | false | | `:link-visibility: value` | Visibility of pull request (PR) and issue links | `auto` | | `:description-visibility: value` | Visibility of changelog **record** descriptions (YAML `description` on each entry) | `auto` | +| `:dropdowns:` | Render breaking changes, deprecations, known issues, and highlights as expandable dropdowns instead of flattened bulleted lists | false | | `:config: path` | Path to `changelog.yml` configuration | auto-discover | ### Example with options @@ -36,6 +37,7 @@ The directive supports the following options: :subsections: :link-visibility: keep-links :description-visibility: keep-descriptions +:dropdowns: ::: ``` @@ -118,7 +120,7 @@ This aligns with the `changelog render` command's link visibility controls. #### `:description-visibility:` -Controls whether the **`description`** text on each **changelog record** appears in output (bullet body text under each item, and the first paragraph inside breaking-change, deprecation, known-issue, and highlight dropdowns). This is **different** from the optional **bundle** `description` field (release intro prose after `_Released:_`), which is always shown when present. See [Rendered output](#rendered-output). +Controls whether the **`description`** text on each **changelog record** appears in output (bullet body text under each item, or the first paragraph inside a breaking-change, deprecation, known-issue, or highlight entry when [`:dropdowns:`](#dropdowns) is enabled). This is **different** from the optional **bundle** `description` field (release intro prose after `_Released:_`), which is always shown when present. See [Rendered output](#rendered-output). | Value | Behavior | |-------|----------| @@ -128,6 +130,17 @@ Controls whether the **`description`** text on each **changelog record** appears **Contrast with `:link-visibility:`:** `:link-visibility: auto` hides **links** when a repo is **private**. `:description-visibility: auto` **shows** richer record **description** prose when **any** source repo is **private**, and hides that prose for bundles that resolve to **only public** repositories. +#### `:dropdowns:` [dropdowns] + +Controls how the "separated" entry types (`breaking-change`, `deprecation`, `known-issue`, and entries flagged `highlight: true`) are rendered. This option only affects these types; features, enhancements, security, bug fixes, documentation, regressions, and other changes are always rendered as flat bulleted lists. + +| Mode | Behavior | +|------|----------| +| (omitted, default) | Flattened: each entry renders as a bullet with its title, links, and (when present) `Impact:` / `Action:` lines as indented continuation. | +| `:dropdowns:` | Dropdowns: each entry renders as an expandable `{dropdown}` with the title as the summary and description, links, `**Impact**`, and `**Action**` inside. | + +Use dropdowns when breaking-change and deprecation entries have long `description`, `impact`, or `action` prose that benefits from being collapsed by default. Use the flattened default for compact release-notes pages where the list itself is the primary content. + #### `:subsections:` When enabled, entries are grouped by "area" within each section. @@ -279,10 +292,10 @@ Bundle descriptions are rendered when present in the bundle YAML file. The descr | Documentation | `docs` | Grouped by area | | Regressions | `regression` | Grouped by area | | Other changes | `other` | Grouped by area | -| Breaking changes | `breaking-change` | Expandable dropdowns | -| Highlights | Entries with `highlight: true` | Expandable dropdowns | -| Deprecations | `deprecation` | Expandable dropdowns | -| Known issues | `known-issue` | Expandable dropdowns | +| Breaking changes | `breaking-change` | Flattened bullets by default; expandable dropdowns with [`:dropdowns:`](#dropdowns) | +| Highlights | Entries with `highlight: true` | Flattened bullets by default; expandable dropdowns with [`:dropdowns:`](#dropdowns) | +| Deprecations | `deprecation` | Flattened bullets by default; expandable dropdowns with [`:dropdowns:`](#dropdowns) | +| Known issues | `known-issue` | Flattened bullets by default; expandable dropdowns with [`:dropdowns:`](#dropdowns) | **Note about highlights:** - Highlights only appear when using `:type: all` (they are excluded from the default view) diff --git a/src/Elastic.Documentation/ReleaseNotes/ChangelogTextUtilities.cs b/src/Elastic.Documentation/ReleaseNotes/ChangelogTextUtilities.cs index 7774ff8fc3..0991e13171 100644 --- a/src/Elastic.Documentation/ReleaseNotes/ChangelogTextUtilities.cs +++ b/src/Elastic.Documentation/ReleaseNotes/ChangelogTextUtilities.cs @@ -374,6 +374,23 @@ public static string FormatIssueLink(string issue, string repo, bool hidePrivate return link; } + /// + /// Determines if an entry has any visible (non-empty) formatted PR or issue links. + /// This checks the actual formatted output to handle cases where links are sanitized + /// with the PRIVATE sentinel prefix. + /// + /// The changelog entry to check + /// The repository name for link formatting + /// Whether private links should be hidden (commented out) + /// The repository owner (default: "elastic") + /// True if the entry has at least one visible formatted link + public static bool HasVisibleLinks(ChangelogEntry entry, string repo, bool hidePrivateLinks, string owner = "elastic") + { + var hasPrs = entry.Prs?.Any(pr => !string.IsNullOrEmpty(FormatPrLink(pr, repo, hidePrivateLinks, owner))) == true; + var hasIssues = entry.Issues?.Any(issue => !string.IsNullOrEmpty(FormatIssueLink(issue, repo, hidePrivateLinks, owner))) == true; + return hasPrs || hasIssues; + } + /// /// Formats PR link as asciidoc. /// diff --git a/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogBlock.cs b/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogBlock.cs index d4218547bd..c89052377d 100644 --- a/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogBlock.cs +++ b/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogBlock.cs @@ -165,6 +165,12 @@ public class ChangelogBlock(DirectiveBlockParser parser, ParserContext context) /// public ChangelogDescriptionVisibility DescriptionVisibility { get; private set; } + /// + /// Whether to render separated types (breaking changes, deprecations, known issues, highlights) as + /// Myst dropdown sections. When false (default), these types render as flattened bulleted lists. + /// + public bool DropdownsEnabled { get; private set; } + /// /// Returns all anchors that will be generated by this directive during rendering. /// @@ -189,6 +195,7 @@ public override void FinalizeAndValidate(ParserContext context) LoadPrivateRepositories(); LinkVisibility = ParseLinkVisibility(); DescriptionVisibility = ParseDescriptionVisibility(); + DropdownsEnabled = PropBool("dropdowns"); if (Found) LoadAndCacheBundles(); } diff --git a/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogInlineRenderer.cs b/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogInlineRenderer.cs index dd975b89c7..f58e3f9f30 100644 --- a/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogInlineRenderer.cs +++ b/src/Elastic.Markdown/Myst/Directives/Changelog/ChangelogInlineRenderer.cs @@ -38,7 +38,8 @@ public static class ChangelogInlineRenderer block.HideFeatures, typeFilter, block.LinkVisibility, - block.DescriptionVisibility); + block.DescriptionVisibility, + block.DropdownsEnabled); _ = sb.Append(bundleMarkdown); isFirst = false; @@ -55,7 +56,8 @@ private static string RenderSingleBundle( HashSet hideFeatures, ChangelogTypeFilter typeFilter, ChangelogLinkVisibility linkVisibility, - ChangelogDescriptionVisibility descriptionVisibility) + ChangelogDescriptionVisibility descriptionVisibility, + bool dropdownsEnabled) { var titleSlug = ChangelogTextUtilities.TitleToSlug(bundle.Version); @@ -92,6 +94,7 @@ private static string RenderSingleBundle( subsections, hideLinks, hideEntryDescriptions, + dropdownsEnabled, typeFilter, publishBlocker, bundle.Data?.Description, @@ -193,6 +196,7 @@ private static string GenerateMarkdown( bool subsections, bool hideLinks, bool hideEntryDescriptions, + bool dropdownsEnabled, ChangelogTypeFilter typeFilter, PublishBlocker? publishBlocker, string? description = null, @@ -249,7 +253,12 @@ private static string GenerateMarkdown( if (typeFilter == ChangelogTypeFilter.Highlight) { if (highlights.Count > 0) - RenderDetailedEntries(sb, highlights, repo, owner, groupBySubtype: false, hideLinks, hideEntryDescriptions, publishBlocker); + { + if (dropdownsEnabled) + RenderDetailedEntries(sb, highlights, repo, owner, groupBySubtype: false, hideLinks, hideEntryDescriptions, publishBlocker); + else + RenderDetailedEntriesFlattened(sb, highlights, repo, owner, groupBySubtype: false, hideLinks, hideEntryDescriptions); + } return sb.ToString(); } @@ -257,14 +266,20 @@ private static string GenerateMarkdown( { _ = sb.AppendLine(); _ = sb.AppendLine(CultureInfo.InvariantCulture, $"### Breaking changes [{repo}-{titleSlug}-breaking-changes]"); - RenderDetailedEntries(sb, breakingChanges, repo, owner, groupBySubtype: true, hideLinks, hideEntryDescriptions, publishBlocker); + if (dropdownsEnabled) + RenderDetailedEntries(sb, breakingChanges, repo, owner, groupBySubtype: true, hideLinks, hideEntryDescriptions, publishBlocker); + else + RenderDetailedEntriesFlattened(sb, breakingChanges, repo, owner, groupBySubtype: true, hideLinks, hideEntryDescriptions); } if (highlights.Count > 0 && typeFilter == ChangelogTypeFilter.All) { _ = sb.AppendLine(); _ = sb.AppendLine(CultureInfo.InvariantCulture, $"### Highlights [{repo}-{titleSlug}-highlights]"); - RenderDetailedEntries(sb, highlights, repo, owner, groupBySubtype: false, hideLinks, hideEntryDescriptions, publishBlocker); + if (dropdownsEnabled) + RenderDetailedEntries(sb, highlights, repo, owner, groupBySubtype: false, hideLinks, hideEntryDescriptions, publishBlocker); + else + RenderDetailedEntriesFlattened(sb, highlights, repo, owner, groupBySubtype: false, hideLinks, hideEntryDescriptions); } if (security.Count > 0) @@ -278,14 +293,20 @@ private static string GenerateMarkdown( { _ = sb.AppendLine(); _ = sb.AppendLine(CultureInfo.InvariantCulture, $"### Known issues [{repo}-{titleSlug}-known-issues]"); - RenderDetailedEntries(sb, knownIssues, repo, owner, groupBySubtype: false, hideLinks, hideEntryDescriptions, publishBlocker); + if (dropdownsEnabled) + RenderDetailedEntries(sb, knownIssues, repo, owner, groupBySubtype: false, hideLinks, hideEntryDescriptions, publishBlocker); + else + RenderDetailedEntriesFlattened(sb, knownIssues, repo, owner, groupBySubtype: false, hideLinks, hideEntryDescriptions); } if (deprecations.Count > 0) { _ = sb.AppendLine(); _ = sb.AppendLine(CultureInfo.InvariantCulture, $"### Deprecations [{repo}-{titleSlug}-deprecations]"); - RenderDetailedEntries(sb, deprecations, repo, owner, groupBySubtype: false, hideLinks, hideEntryDescriptions, publishBlocker); + if (dropdownsEnabled) + RenderDetailedEntries(sb, deprecations, repo, owner, groupBySubtype: false, hideLinks, hideEntryDescriptions, publishBlocker); + else + RenderDetailedEntriesFlattened(sb, deprecations, repo, owner, groupBySubtype: false, hideLinks, hideEntryDescriptions); } if (features.Count > 0 || enhancements.Count > 0) @@ -439,6 +460,106 @@ private static void RenderDetailedEntries( } } + private static void RenderDetailedEntriesFlattened( + StringBuilder sb, + List entries, + string repo, + string owner, + bool groupBySubtype, + bool hideLinks, + bool hideEntryDescriptions) + { + if (groupBySubtype) + { + // Group by subtype and sort - same logic as RenderDetailedEntries + var groupedBySubtype = entries.GroupBy(e => e.Subtype?.ToStringFast(true) ?? string.Empty).OrderBy(g => g.Key).ToList(); + + foreach (var group in groupedBySubtype) + { + // Add subtype header if group has a non-empty key (same logic as RenderDetailedEntries) + if (!string.IsNullOrWhiteSpace(group.Key)) + { + var header = ChangelogTextUtilities.FormatSubtypeHeader(group.Key); + _ = sb.AppendLine(); + _ = sb.AppendLine(header); + _ = sb.AppendLine(); + } + + foreach (var entry in group) + RenderDetailedEntryFlattened(sb, entry, repo, owner, hideLinks, hideEntryDescriptions); + } + } + else + { + foreach (var entry in entries) + RenderDetailedEntryFlattened(sb, entry, repo, owner, hideLinks, hideEntryDescriptions); + } + } + + private static void RenderDetailedEntryFlattened(StringBuilder sb, ChangelogEntry entry, string repo, string owner, bool hideLinks, bool hideEntryDescriptions) + { + // Start with bullet point and title (no bold, matching regular entries) + _ = sb.Append("* "); + _ = sb.Append(ChangelogTextUtilities.Beautify(entry.Title)); + + // Add PR/Issue links on the same line if available + var linksText = GetLinksText(entry, repo, owner, hideLinks); + if (!string.IsNullOrEmpty(linksText)) + _ = sb.Append($" {linksText}"); + + _ = sb.AppendLine(); + + // Add description if not hidden + if (!hideEntryDescriptions && !string.IsNullOrWhiteSpace(entry.Description)) + { + _ = sb.AppendLine(ChangelogTextUtilities.Indent(entry.Description)); + } + + // Add Impact section + if (!string.IsNullOrWhiteSpace(entry.Impact)) + { + _ = sb.AppendLine(); + _ = sb.AppendLine(ChangelogTextUtilities.Indent($"**Impact:** {entry.Impact}")); + } + + // Add Action section + if (!string.IsNullOrWhiteSpace(entry.Action)) + { + _ = sb.AppendLine(); + _ = sb.AppendLine(ChangelogTextUtilities.Indent($"**Action:** {entry.Action}")); + } + } + + private static string GetLinksText(ChangelogEntry entry, string repo, string owner, bool hideLinks) + { + if (!ChangelogTextUtilities.HasVisibleLinks(entry, repo, hideLinks, owner)) + return string.Empty; + + var linksParts = new List(); + + if (entry.Prs != null) + { + foreach (var pr in entry.Prs) + { + var formatted = ChangelogTextUtilities.FormatPrLink(pr, repo, hideLinks, owner); + if (!string.IsNullOrEmpty(formatted)) + linksParts.Add(formatted); + } + } + + if (entry.Issues != null) + { + foreach (var issue in entry.Issues) + { + var formatted = ChangelogTextUtilities.FormatIssueLink(issue, repo, hideLinks, owner); + if (!string.IsNullOrEmpty(formatted)) + linksParts.Add(formatted); + } + } + + return linksParts.Count > 0 ? $"[{string.Join(", ", linksParts)}]" : string.Empty; + } + private static void RenderDetailedEntry(StringBuilder sb, ChangelogEntry entry, string repo, string owner, bool hideLinks, bool hideEntryDescriptions) { _ = sb.AppendLine(); @@ -469,19 +590,38 @@ private static void RenderDetailedEntry(StringBuilder sb, ChangelogEntry entry, private static void RenderDetailedEntryLinks(StringBuilder sb, ChangelogEntry entry, string repo, string owner, bool hideLinks) { - var hasPrs = entry.Prs is { Count: > 0 }; - var hasIssues = entry.Issues is { Count: > 0 }; - - if (!hasPrs && !hasIssues) + // Check if the entry has any visible links after formatting + // This handles cases where all links are sanitized with PRIVATE prefix + if (!ChangelogTextUtilities.HasVisibleLinks(entry, repo, hideLinks, owner)) return; if (hideLinks) { + var hasVisibleContent = false; foreach (var pr in entry.Prs ?? []) - _ = sb.AppendLine(ChangelogTextUtilities.FormatPrLink(pr, repo, hidePrivateLinks: true, owner)); + { + var formatted = ChangelogTextUtilities.FormatPrLink(pr, repo, hidePrivateLinks: true, owner); + if (!string.IsNullOrEmpty(formatted)) + { + _ = sb.AppendLine(formatted); + hasVisibleContent = true; + } + } foreach (var issue in entry.Issues ?? []) - _ = sb.AppendLine(ChangelogTextUtilities.FormatIssueLink(issue, repo, hidePrivateLinks: true, owner)); - _ = sb.AppendLine("For more information, check the pull request or issue above."); + { + var formatted = ChangelogTextUtilities.FormatIssueLink(issue, repo, hidePrivateLinks: true, owner); + if (!string.IsNullOrEmpty(formatted)) + { + _ = sb.AppendLine(formatted); + hasVisibleContent = true; + } + } + + // Only show the reference text if we actually rendered some links + if (hasVisibleContent) + { + _ = sb.AppendLine("For more information, check the pull request or issue above."); + } _ = sb.AppendLine(); return; } @@ -490,17 +630,25 @@ private static void RenderDetailedEntryLinks(StringBuilder sb, ChangelogEntry en var first = true; foreach (var pr in entry.Prs ?? []) { - if (!first) - _ = sb.Append(' '); - _ = sb.Append(ChangelogTextUtilities.FormatPrLink(pr, repo, hidePrivateLinks: false, owner)); - first = false; + var formatted = ChangelogTextUtilities.FormatPrLink(pr, repo, hidePrivateLinks: false, owner); + if (!string.IsNullOrEmpty(formatted)) + { + if (!first) + _ = sb.Append(' '); + _ = sb.Append(formatted); + first = false; + } } foreach (var issue in entry.Issues ?? []) { - if (!first) - _ = sb.Append(' '); - _ = sb.Append(ChangelogTextUtilities.FormatIssueLink(issue, repo, hidePrivateLinks: false, owner)); - first = false; + var formatted = ChangelogTextUtilities.FormatIssueLink(issue, repo, hidePrivateLinks: false, owner); + if (!string.IsNullOrEmpty(formatted)) + { + if (!first) + _ = sb.Append(' '); + _ = sb.Append(formatted); + first = false; + } } _ = sb.AppendLine("."); _ = sb.AppendLine(); diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Render/PrivateLinkBugTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/Render/PrivateLinkBugTests.cs new file mode 100644 index 0000000000..cb2e6b721d --- /dev/null +++ b/tests/Elastic.Changelog.Tests/Changelogs/Render/PrivateLinkBugTests.cs @@ -0,0 +1,177 @@ +// 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 AwesomeAssertions; +using Elastic.Changelog.Bundling; +using Elastic.Changelog.Rendering; +using Elastic.Documentation.Configuration; + +namespace Elastic.Changelog.Tests.Changelogs.Render; + +/// +/// Tests that CLI rendering does not produce incomplete "For more information, check." sentences +/// when entries have only PRIVATE PR/issue references. +/// +public class PrivateLinkBugTests(ITestOutputHelper output) : RenderChangelogTestBase(output) +{ + [Fact] + public async Task RenderChangelogs_WithOnlyPrivateLinks_DoesNotRenderIncompleteForMoreInformationSentence() + { + // Arrange + var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(changelogDir); + + // Create deprecation entry with only private links (matching Cloud scenario) + // language=yaml + var changelog = + """ + title: The v1 Costs API has been deprecated. Customers should migrate to the v2 Costs API. + type: deprecation + products: + - product: elasticsearch + target: 9.3.0 + prs: + - '# PRIVATE: https://github.com/elastic/cloud/pull/153728' + description: This API will be removed in a future version. + impact: Users must update their integration. + action: Follow the migration guide. + """; + + var changelogFile = FileSystem.Path.Join(changelogDir, "153728-deprecate-costs-api-v1.yaml"); + await FileSystem.File.WriteAllTextAsync(changelogFile, changelog, TestContext.Current.CancellationToken); + + // Create bundle file + var bundleFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(bundleFile)!); + + // language=yaml + var bundleContent = + $""" + products: + - product: elasticsearch + target: 9.3.0 + repo: elasticsearch + owner: elastic + entries: + - file: + name: 153728-deprecate-costs-api-v1.yaml + checksum: {ComputeSha1(changelog)} + """; + await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); + + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + + var input = new RenderChangelogsArguments + { + Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir, Repo = "elasticsearch" }], + Output = outputDir, + Title = "9.3.0" + }; + + // Act + var result = await Service.RenderChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + Collector.Errors.Should().Be(0); + + var deprecationsFile = FileSystem.Path.Join(outputDir, "9.3.0", "deprecations.md"); + FileSystem.File.Exists(deprecationsFile).Should().BeTrue(); + + var content = await FileSystem.File.ReadAllTextAsync(deprecationsFile, TestContext.Current.CancellationToken); + + // Should not contain the incomplete sentence + content.Should().NotContain("For more information, check."); + content.Should().NotContain("check ."); + content.Should().NotContain("check."); + + // Should still contain the entry content + content.Should().Contain("The v1 Costs API has been deprecated"); + content.Should().Contain("This API will be removed"); + content.Should().Contain("Users must update"); + content.Should().Contain("Follow the migration guide"); + } + + [Fact] + public async Task RenderChangelogs_WithMixedPrivateAndPublicLinks_RendersOnlyPublicLinks() + { + // Arrange + var changelogDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(changelogDir); + + // Create entry with mix of private and public links + // language=yaml + var changelog = + """ + title: Mixed links breaking change + type: breaking-change + products: + - product: elasticsearch + target: 9.3.0 + prs: + - '# PRIVATE: https://github.com/elastic/cloud/pull/153728' + - "123456" + issues: + - '# PRIVATE: https://github.com/elastic/cloud/issues/789' + - "654321" + description: This change breaks compatibility. + impact: Users must update. + action: Follow upgrade guide. + """; + + var changelogFile = FileSystem.Path.Join(changelogDir, "123456-mixed-links.yaml"); + await FileSystem.File.WriteAllTextAsync(changelogFile, changelog, TestContext.Current.CancellationToken); + + // Create bundle file + var bundleFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(bundleFile)!); + + // language=yaml + var bundleContent = + $""" + products: + - product: elasticsearch + target: 9.3.0 + repo: elasticsearch + owner: elastic + entries: + - file: + name: 123456-mixed-links.yaml + checksum: {ComputeSha1(changelog)} + """; + await FileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); + + var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + + var input = new RenderChangelogsArguments + { + Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir, Repo = "elasticsearch" }], + Output = outputDir, + Title = "9.3.0" + }; + + // Act + var result = await Service.RenderChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + Collector.Errors.Should().Be(0); + + var breakingChangesFile = FileSystem.Path.Join(outputDir, "9.3.0", "breaking-changes.md"); + FileSystem.File.Exists(breakingChangesFile).Should().BeTrue(); + + var content = await FileSystem.File.ReadAllTextAsync(breakingChangesFile, TestContext.Current.CancellationToken); + + // Should contain proper "For more information" with only public links + content.Should().Contain("For more information, check"); + content.Should().Contain("#123456"); + content.Should().Contain("#654321"); + + // Should not contain incomplete sentence + content.Should().NotContain("For more information, check."); + + // Should end properly after the links + content.Should().Contain("654321](https://github.com/elastic/elasticsearch/issues/654321)."); + } +} diff --git a/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/ChangelogTextUtilitiesTests.cs b/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/ChangelogTextUtilitiesTests.cs index 0dc0eb6630..3222df485d 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/ChangelogTextUtilitiesTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/ChangelogTextUtilitiesTests.cs @@ -156,4 +156,88 @@ public void GenerateSlug_GeneratesSlug(string input, string expected) var result = ChangelogTextUtilities.GenerateSlug(input); result.Should().Be(expected); } + + [Fact] + public void HasVisibleLinks_WithOnlyPrivateLinks_ReturnsFalse() + { + var entry = new ChangelogEntry + { + Prs = ["# PRIVATE: https://github.com/elastic/cloud/pull/123"], + Issues = ["# PRIVATE: https://github.com/elastic/cloud/issues/456"] + }; + + var result = ChangelogTextUtilities.HasVisibleLinks(entry, "elasticsearch", false); + + result.Should().BeFalse(); + } + + [Fact] + public void HasVisibleLinks_WithMixedLinks_ReturnsTrue() + { + var entry = new ChangelogEntry + { + Prs = ["# PRIVATE: https://github.com/elastic/cloud/pull/123", "456"], + Issues = ["# PRIVATE: https://github.com/elastic/cloud/issues/789"] + }; + + var result = ChangelogTextUtilities.HasVisibleLinks(entry, "elasticsearch", false); + + result.Should().BeTrue(); + } + + [Fact] + public void HasVisibleLinks_WithPublicLinks_ReturnsTrue() + { + var entry = new ChangelogEntry + { + Prs = ["123"], + Issues = ["456"] + }; + + var result = ChangelogTextUtilities.HasVisibleLinks(entry, "elasticsearch", false); + + result.Should().BeTrue(); + } + + [Fact] + public void HasVisibleLinks_WithNoLinks_ReturnsFalse() + { + var entry = new ChangelogEntry + { + Prs = null, + Issues = null + }; + + var result = ChangelogTextUtilities.HasVisibleLinks(entry, "elasticsearch", false); + + result.Should().BeFalse(); + } + + [Fact] + public void HasVisibleLinks_WithEmptyArrays_ReturnsFalse() + { + var entry = new ChangelogEntry + { + Prs = [], + Issues = [] + }; + + var result = ChangelogTextUtilities.HasVisibleLinks(entry, "elasticsearch", false); + + result.Should().BeFalse(); + } + + [Fact] + public void HasVisibleLinks_WithHidePrivateLinks_ChecksCommentedFormat() + { + var entry = new ChangelogEntry + { + Prs = ["123"] // This should format as "% [#123](url)" when hidePrivateLinks=true + }; + + // When hidePrivateLinks is true, links are commented out but still "visible" (non-empty) + var result = ChangelogTextUtilities.HasVisibleLinks(entry, "elasticsearch", true); + + result.Should().BeTrue(); + } } diff --git a/tests/Elastic.Markdown.Tests/Directives/ChangelogDropdownsTests.cs b/tests/Elastic.Markdown.Tests/Directives/ChangelogDropdownsTests.cs new file mode 100644 index 0000000000..81524b7e84 --- /dev/null +++ b/tests/Elastic.Markdown.Tests/Directives/ChangelogDropdownsTests.cs @@ -0,0 +1,390 @@ +// 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.IO.Abstractions.TestingHelpers; +using AwesomeAssertions; +using Elastic.Markdown.Myst.Directives.Changelog; + +namespace Elastic.Markdown.Tests.Directives; + +/// +/// Tests for the :dropdowns: parameter on the changelog directive. +/// By default (omitted), separated types (breaking changes, deprecations, known issues, highlights) +/// are rendered as flattened bulleted lists. +/// With :dropdowns:, they render as Myst dropdown sections. +/// +public class ChangelogDropdownsDefaultTests : DirectiveTest +{ + public ChangelogDropdownsDefaultTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + :type: breaking-change + :description-visibility: keep-descriptions + ::: + """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: Breaking API change + type: breaking-change + products: + - product: elasticsearch + target: 9.3.0 + description: API has been changed to improve performance. + impact: Existing API calls will fail. + action: Update your code to use the new API endpoints. + prs: + - "333333" + - title: Another breaking change + type: breaking-change + products: + - product: elasticsearch + target: 9.3.0 + description: Removed deprecated parameter. + impact: Scripts using the old parameter will fail. + action: Remove references to the deprecated parameter. + prs: + - "444444" + """)); + + [Fact] + public void DefaultBehaviorDoesNotParseDropdownsOption() + { + Block!.DropdownsEnabled.Should().BeFalse(); + } + + [Fact] + public void DefaultBehaviorRendersFlattened() + { + // Should NOT contain dropdown HTML structure + Html.Should().NotContain("
"); + Html.Should().NotContain("dropdown-title__summary-text"); + + // Should contain bulleted list format without bold titles (matching regular entries) + Html.Should().Contain("Breaking API change."); + Html.Should().Contain("Another breaking change."); + } + + [Fact] + public void DefaultBehaviorIncludesImpactAndActionSections() + { + Html.Should().Contain("Impact: Existing API calls will fail."); + Html.Should().Contain("Action: Update your code to use the new API endpoints."); + Html.Should().Contain("Impact: Scripts using the old parameter will fail."); + Html.Should().Contain("Action: Remove references to the deprecated parameter."); + } + + [Fact] + public void DefaultBehaviorIncludesDescriptions() + { + // Note: Descriptions may be hidden by default due to :description-visibility: auto behavior + // This test validates that when descriptions are shown, they appear in the correct format + Html.Should().Contain("API has been changed to improve performance."); + Html.Should().Contain("Removed deprecated parameter."); + } +} + +/// +/// Tests for explicit :dropdowns: - should render separated types as Myst dropdown sections. +/// +public class ChangelogDropdownsEnabledTests : DirectiveTest +{ + public ChangelogDropdownsEnabledTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + :type: breaking-change + :dropdowns: + :description-visibility: keep-descriptions + ::: + """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: Breaking API change + type: breaking-change + products: + - product: elasticsearch + target: 9.3.0 + description: API has been changed to improve performance. + impact: Existing API calls will fail. + action: Update your code to use the new API endpoints. + prs: + - "333333" + """)); + + [Fact] + public void ExplicitDropdownsParsesCorrectly() + { + Block!.DropdownsEnabled.Should().BeTrue(); + } + + [Fact] + public void ExplicitDropdownsRendersDropdownFormat() + { + // Should contain dropdown HTML structure + Html.Should().Contain("
"); + Html.Should().Contain("dropdown-title__summary-text"); + Html.Should().Contain("Breaking API change."); + + // Should NOT contain bulleted list format + Html.Should().NotContain("
  • Breaking API change."); + } + + [Fact] + public void ExplicitDropdownsIncludesDescriptionInDropdown() + { + Html.Should().Contain("API has been changed to improve performance."); + } + + [Fact] + public void ExplicitDropdownsIncludesImpactAndActionInDropdown() + { + Html.Should().Contain("Impact
    Existing API calls will fail."); + Html.Should().Contain("Action
    Update your code to use the new API endpoints."); + } +} + +///

    +/// Tests interaction between :dropdowns: and :description-visibility: for flattened rendering. +/// +public class ChangelogDropdownsWithHiddenDescriptionsTests : DirectiveTest +{ + public ChangelogDropdownsWithHiddenDescriptionsTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + :type: breaking-change + :description-visibility: hide-descriptions + ::: + """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: Breaking API change + type: breaking-change + products: + - product: elasticsearch + target: 9.3.0 + description: This description should be hidden. + impact: Existing API calls will fail. + action: Update your code to use the new API endpoints. + prs: + - "333333" + """)); + + [Fact] + public void FlattendRenderingHidesDescriptionsButKeepsImpactAction() + { + // Should render as flattened (no dropdowns by default) + Html.Should().Contain("Breaking API change."); + Html.Should().NotContain("
    "); + + // Description should be hidden due to :description-visibility: hide-descriptions + Html.Should().NotContain("This description should be hidden."); + + // Impact and Action should still be shown in flattened format + Html.Should().Contain("Impact: Existing API calls will fail."); + Html.Should().Contain("Action: Update your code to use the new API endpoints."); + } +} + +/// +/// Tests interaction between :dropdowns: and :description-visibility: for dropdown rendering. +/// +public class ChangelogDropdownsEnabledWithHiddenDescriptionsTests : DirectiveTest +{ + public ChangelogDropdownsEnabledWithHiddenDescriptionsTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + :type: breaking-change + :dropdowns: + :description-visibility: hide-descriptions + ::: + """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: Breaking API change + type: breaking-change + products: + - product: elasticsearch + target: 9.3.0 + description: This description should be hidden. + impact: Existing API calls will fail. + action: Update your code to use the new API endpoints. + prs: + - "333333" + """)); + + [Fact] + public void DropdownRenderingHidesDescriptionsButKeepsImpactAction() + { + // Should render as dropdown due to explicit :dropdowns: + Html.Should().Contain("
    "); + Html.Should().Contain("Breaking API change."); + Html.Should().NotContain("
  • Breaking API change."); + + // Description should be hidden due to :description-visibility: hide-descriptions + Html.Should().NotContain("This description should be hidden."); + + // Impact and Action should still be shown in dropdown format + Html.Should().Contain("Impact
    Existing API calls will fail."); + Html.Should().Contain("Action
    Update your code to use the new API endpoints."); + } +} + +///

    +/// Tests that :dropdowns: works with different separated types (deprecations, known issues). +/// +public class ChangelogDropdownsWithDifferentTypesTests : DirectiveTest +{ + public ChangelogDropdownsWithDifferentTypesTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + :type: all + ::: + """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: Feature addition + type: feature + products: + - product: elasticsearch + target: 9.3.0 + description: Added a new feature. + prs: + - "111111" + - title: Breaking API change + type: breaking-change + products: + - product: elasticsearch + target: 9.3.0 + description: API changed. + impact: Users must update. + action: Follow guide. + prs: + - "222222" + - title: Known issue with search + type: known-issue + products: + - product: elasticsearch + target: 9.3.0 + description: Search may fail in some cases. + impact: Search results incomplete. + action: Use workaround. + prs: + - "333333" + - title: Deprecated API + type: deprecation + products: + - product: elasticsearch + target: 9.3.0 + description: Old API is deprecated. + impact: Will be removed in future. + action: Use new API. + prs: + - "444444" + """)); + + [Fact] + public void DefaultRendersMixedTypesCorrectly() + { + // Regular types should render as bulleted lists (unchanged behavior) + Html.Should().Contain("Feature addition."); // Regular feature type (in
  • tags) + + // Separated types should render as flattened lists (new behavior) - no bold titles + Html.Should().Contain("Breaking API change."); + Html.Should().Contain("Known issue with search."); + Html.Should().Contain("Deprecated API."); + + // Should NOT contain dropdown HTML structure for separated types + Html.Should().NotContain("
    "); + } +} + +/// +/// Tests that :dropdowns: works with different separated types using explicit dropdown rendering. +/// +public class ChangelogDropdownsExplicitWithDifferentTypesTests : DirectiveTest +{ + public ChangelogDropdownsExplicitWithDifferentTypesTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + :type: all + :dropdowns: + ::: + """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: Feature addition + type: feature + products: + - product: elasticsearch + target: 9.3.0 + description: Added a new feature. + prs: + - "111111" + - title: Breaking API change + type: breaking-change + products: + - product: elasticsearch + target: 9.3.0 + description: API changed. + impact: Users must update. + action: Follow guide. + prs: + - "222222" + - title: Known issue with search + type: known-issue + products: + - product: elasticsearch + target: 9.3.0 + description: Search may fail in some cases. + impact: Search results incomplete. + action: Use workaround. + prs: + - "333333" + """)); + + [Fact] + public void ExplicitDropdownsRendersMixedTypesCorrectly() + { + // Regular types should still render as bulleted lists (unchanged behavior) + Html.Should().Contain("Feature addition."); // Regular feature type (in
  • tags) + + // Separated types should render as dropdowns (explicit :dropdowns:) + Html.Should().Contain("
    "); + Html.Should().Contain("Breaking API change."); + Html.Should().Contain("Known issue with search."); + + // Should NOT contain flattened format for separated types (check they're in dropdown, not flat list) + Html.Should().NotContain("
  • Breaking API change."); + Html.Should().NotContain("

  • Known issue with search."); + } +} diff --git a/tests/Elastic.Markdown.Tests/Directives/ChangelogHideLinksTests.cs b/tests/Elastic.Markdown.Tests/Directives/ChangelogHideLinksTests.cs index bf036aee85..34fe687d70 100644 --- a/tests/Elastic.Markdown.Tests/Directives/ChangelogHideLinksTests.cs +++ b/tests/Elastic.Markdown.Tests/Directives/ChangelogHideLinksTests.cs @@ -216,6 +216,8 @@ public ChangelogLinksHiddenInDetailedEntriesTests(ITestOutputHelper output) : ba """ :::{changelog} :type: all + :dropdowns: + :description-visibility: keep-descriptions ::: """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( // language=yaml diff --git a/tests/Elastic.Markdown.Tests/Directives/ChangelogPrivateLinkBugTests.cs b/tests/Elastic.Markdown.Tests/Directives/ChangelogPrivateLinkBugTests.cs new file mode 100644 index 0000000000..fc12e504d7 --- /dev/null +++ b/tests/Elastic.Markdown.Tests/Directives/ChangelogPrivateLinkBugTests.cs @@ -0,0 +1,163 @@ +// 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.IO.Abstractions.TestingHelpers; +using AwesomeAssertions; +using Elastic.Markdown.Myst.Directives.Changelog; + +namespace Elastic.Markdown.Tests.Directives; + +///

    +/// Tests for the bug where entries with only PRIVATE PR/issue references +/// produce incomplete "For more information, check." sentences. +/// +public class ChangelogPrivateLinkBugTests : DirectiveTest +{ + public ChangelogPrivateLinkBugTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + :type: deprecation + :dropdowns: + :description-visibility: keep-descriptions + ::: + """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: The v1 Costs API has been deprecated. Customers should migrate to the v2 Costs API. + type: deprecation + products: + - product: elasticsearch + target: 9.3.0 + prs: + - "# PRIVATE: https://github.com/elastic/cloud/pull/153728" + description: This API will be removed in a future version. + impact: Users must update their integration. + action: Follow the migration guide. + """)); + + [Fact] + public void DoesNotRenderIncompleteForMoreInformationSentence() + { + var markdown = ChangelogInlineRenderer.RenderChangelogMarkdown(Block!); + + // Should not contain the incomplete sentence + markdown.Should().NotContain("For more information, check."); + + // Should not contain any dangling "check" references + markdown.Should().NotContain("check ."); + markdown.Should().NotContain("check."); + } + + [Fact] + public void StillRendersEntryWithoutLinkSection() + { + var markdown = ChangelogInlineRenderer.RenderChangelogMarkdown(Block!); + + // Entry content should still be present + markdown.Should().Contain("The v1 Costs API has been deprecated"); + markdown.Should().Contain("This API will be removed"); + markdown.Should().Contain("**Impact**"); + markdown.Should().Contain("Users must update"); + markdown.Should().Contain("**Action**"); + markdown.Should().Contain("Follow the migration guide"); + } +} + +/// +/// Test mixed scenarios with both private and public links +/// +public class ChangelogMixedLinkBugTests : DirectiveTest +{ + public ChangelogMixedLinkBugTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + :type: deprecation + :dropdowns: + :description-visibility: keep-descriptions + ::: + """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: Mixed links deprecation + type: deprecation + products: + - product: elasticsearch + target: 9.3.0 + prs: + - "# PRIVATE: https://github.com/elastic/cloud/pull/153728" + - "123456" + issues: + - "# PRIVATE: https://github.com/elastic/cloud/issues/789" + - "654321" + """)); + + [Fact] + public void RendersForMoreInformationWithOnlyVisibleLinks() + { + var markdown = ChangelogInlineRenderer.RenderChangelogMarkdown(Block!); + + // Should contain proper "For more information" with visible links only + markdown.Should().Contain("For more information, check"); + markdown.Should().Contain("#123456"); + markdown.Should().Contain("#654321"); + + // Should not contain incomplete sentence + markdown.Should().NotContain("For more information, check."); + + // Should end with period after the links + markdown.Should().Contain("654321](https://github.com/elastic/elasticsearch/issues/654321)."); + } +} + +/// +/// Test entries with no PR/issue references at all +/// +public class ChangelogNoLinksTests : DirectiveTest +{ + public ChangelogNoLinksTests(ITestOutputHelper output) : base(output, + // language=markdown + """ + :::{changelog} + :type: deprecation + :dropdowns: + :description-visibility: keep-descriptions + ::: + """) => FileSystem.AddFile("docs/changelog/bundles/9.3.0.yaml", new MockFileData( + // language=yaml + """ + products: + - product: elasticsearch + target: 9.3.0 + entries: + - title: No links deprecation + type: deprecation + products: + - product: elasticsearch + target: 9.3.0 + description: This has no PR or issue references. + """)); + + [Fact] + public void DoesNotRenderForMoreInformationSection() + { + var markdown = ChangelogInlineRenderer.RenderChangelogMarkdown(Block!); + + // Should not contain any "For more information" section + markdown.Should().NotContain("For more information"); + + // Should still render entry content + markdown.Should().Contain("No links deprecation"); + markdown.Should().Contain("This has no PR or issue references"); + } +} From 406712875a66c12eea665ba81a78eeab31604860 Mon Sep 17 00:00:00 2001 From: shainaraskas <58563081+shainaraskas@users.noreply.github.com> Date: Tue, 5 May 2026 07:22:47 -0400 Subject: [PATCH 16/50] [Stack 9.4.0] assembler + version bump (#3070) --- config/assembler.yml | 4 ++-- config/versions.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/config/assembler.yml b/config/assembler.yml index e65cfcdf2f..47247a71e9 100644 --- a/config/assembler.yml +++ b/config/assembler.yml @@ -56,8 +56,8 @@ environments: shared_configuration: stack: &stack - current: 9.3 - next: 9.4 + current: 9.4 + next: main edge: main master: &master diff --git a/config/versions.yml b/config/versions.yml index c6ac9978ae..1129c1d96e 100644 --- a/config/versions.yml +++ b/config/versions.yml @@ -3,7 +3,7 @@ versioning_systems: # Updates for Stack versions are manual stack: &stack base: 9.0 - current: 9.3.4 + current: 9.4.0 # Using an unlikely high version # So that our logic that would display "planned" doesn't trigger From eaa2bc838d47486deb6a886ff0d19b3db23fdf79 Mon Sep 17 00:00:00 2001 From: Fabrizio Ferri-Benedetti Date: Tue, 5 May 2026 16:01:09 +0200 Subject: [PATCH 17/50] fix(cross-links): use codex path shape in fallback error URL (#3174) When the codex link index can't be fetched (e.g. CI without credentials to reach the private codex-link-index repo), the cross-link resolver falls through to an error message containing a best-effort URL to the repo's links.json. That fallback was hardcoded to the public-S3 layout `elastic/{scheme}/main/links.json`, so errors against a codex/internal registry pointed at a non-existent path under codex-link-index. Thread the per-repo DocSetRegistry through FetchedCrossLinks and pick the codex layout (`{env}/elastic/{scheme}/links.json`) when the entry is non-public, keeping the existing S3 layout for public entries. Co-authored-by: Claude Opus 4.7 (1M context) --- .../CrossLinks/CrossLinkFetcher.cs | 7 ++ .../CrossLinks/CrossLinkResolver.cs | 20 ++++- .../DocSetConfigurationCrossLinkFetcher.cs | 3 + .../CrossLinks/UriEnvironmentResolverTests.cs | 80 +++++++++++++++++++ 4 files changed, 109 insertions(+), 1 deletion(-) diff --git a/src/Elastic.Documentation.Links/CrossLinks/CrossLinkFetcher.cs b/src/Elastic.Documentation.Links/CrossLinks/CrossLinkFetcher.cs index 893754c2d0..2c54f1d374 100644 --- a/src/Elastic.Documentation.Links/CrossLinks/CrossLinkFetcher.cs +++ b/src/Elastic.Documentation.Links/CrossLinks/CrossLinkFetcher.cs @@ -28,6 +28,12 @@ public record FetchedCrossLinks ///
  • public FrozenDictionary? RegistryUrlsByRepository { get; init; } + /// + /// Optional map of repository name to the declared for that cross-link entry. + /// Used to pick the correct links.json path shape in error messages when the index could not be fetched. + /// + public FrozenDictionary? RegistryByRepository { get; init; } + /// /// Set of repository names that belong to a codex (non-public) registry. /// Used by the URI resolver to generate codex URLs instead of public preview URLs. @@ -46,6 +52,7 @@ public record FetchedCrossLinks LinkReferences = new Dictionary().ToFrozenDictionary(), LinkIndexEntries = new Dictionary().ToFrozenDictionary(), RegistryUrlsByRepository = null, + RegistryByRepository = null, CodexRepositories = null }; } diff --git a/src/Elastic.Documentation.Links/CrossLinks/CrossLinkResolver.cs b/src/Elastic.Documentation.Links/CrossLinks/CrossLinkResolver.cs index 4fd7eb6206..abaf584c2b 100644 --- a/src/Elastic.Documentation.Links/CrossLinks/CrossLinkResolver.cs +++ b/src/Elastic.Documentation.Links/CrossLinks/CrossLinkResolver.cs @@ -4,6 +4,7 @@ using System.Collections.Frozen; using System.Diagnostics.CodeAnalysis; +using Elastic.Documentation.Configuration; namespace Elastic.Documentation.Links.CrossLinks; @@ -107,7 +108,7 @@ public static bool TryResolve( var baseUrl = GetLinksJsonBaseUrl(registryUrl); var linksJson = fetchedCrossLinks.LinkIndexEntries.TryGetValue(crossLinkUri.Scheme, out var indexEntry) ? $"{baseUrl}/{indexEntry.Path}" - : $"{baseUrl}/elastic/{crossLinkUri.Scheme}/main/links.json"; + : BuildFallbackLinksJsonUrl(baseUrl, crossLinkUri.Scheme, fetchedCrossLinks); errorEmitter($"'{originalLookupPath}' is not a valid link in the '{crossLinkUri.Scheme}' cross link index: {linksJson}"); resolvedUri = null; @@ -257,4 +258,21 @@ private static string GetLinksJsonBaseUrl(string registryUrl) return registryUrl.Replace("/link-index.json", "", StringComparison.OrdinalIgnoreCase).TrimEnd('/'); return registryUrl.TrimEnd('/'); } + + /// + /// Builds a best-effort links.json URL to show in error messages when the index could not be fetched + /// and no is available. Codex/internal indexes use + /// {env}/elastic/{scheme}/links.json; the public S3 index uses elastic/{scheme}/main/links.json. + /// + private static string BuildFallbackLinksJsonUrl(string baseUrl, string scheme, FetchedCrossLinks fetchedCrossLinks) + { + if (fetchedCrossLinks.RegistryByRepository is not null + && fetchedCrossLinks.RegistryByRepository.TryGetValue(scheme, out var registry) + && registry != DocSetRegistry.Public) + { + return $"{baseUrl}/{registry.ToStringFast(true)}/elastic/{scheme}/links.json"; + } + + return $"{baseUrl}/elastic/{scheme}/main/links.json"; + } } diff --git a/src/Elastic.Documentation.Links/CrossLinks/DocSetConfigurationCrossLinkFetcher.cs b/src/Elastic.Documentation.Links/CrossLinks/DocSetConfigurationCrossLinkFetcher.cs index a72f789ac6..ec8ab8aa46 100644 --- a/src/Elastic.Documentation.Links/CrossLinks/DocSetConfigurationCrossLinkFetcher.cs +++ b/src/Elastic.Documentation.Links/CrossLinks/DocSetConfigurationCrossLinkFetcher.cs @@ -27,6 +27,7 @@ public override async Task FetchCrossLinks(Cancel ctx) var linkReferences = new Dictionary(); var linkIndexEntries = new Dictionary(); var registryUrlsByRepository = new Dictionary(); + var registryByRepository = new Dictionary(); var codexRepositories = new HashSet(); var declaredRepositories = new HashSet(); @@ -41,6 +42,7 @@ public override async Task FetchCrossLinks(Cancel ctx) foreach (var entry in configuration.CrossLinkEntries) { _ = declaredRepositories.Add(entry.Repository); + registryByRepository[entry.Repository] = entry.Registry; var isCodexEntry = useDualRegistry && entry.Registry != DocSetRegistry.Public; var reader = isCodexEntry ? _codexReader! : publicReader; var registry = isCodexEntry ? codexRegistry : publicRegistry; @@ -91,6 +93,7 @@ public override async Task FetchCrossLinks(Cancel ctx) LinkReferences = linkReferences.ToFrozenDictionary(), LinkIndexEntries = linkIndexEntries.ToFrozenDictionary(), RegistryUrlsByRepository = registryUrlsByRepository.ToFrozenDictionary(), + RegistryByRepository = registryByRepository.ToFrozenDictionary(), CodexRepositories = codexRepositories.Count > 0 ? codexRepositories.ToFrozenSet() : null, IsComplete = !hadFetchFailures, }; diff --git a/tests/Elastic.Markdown.Tests/CrossLinks/UriEnvironmentResolverTests.cs b/tests/Elastic.Markdown.Tests/CrossLinks/UriEnvironmentResolverTests.cs index bfc189f6fa..8072ebba37 100644 --- a/tests/Elastic.Markdown.Tests/CrossLinks/UriEnvironmentResolverTests.cs +++ b/tests/Elastic.Markdown.Tests/CrossLinks/UriEnvironmentResolverTests.cs @@ -4,6 +4,9 @@ using System.Collections.Frozen; using AwesomeAssertions; +using Elastic.Documentation; +using Elastic.Documentation.Configuration; +using Elastic.Documentation.Links; using Elastic.Documentation.Links.CrossLinks; namespace Elastic.Markdown.Tests.CrossLinks; @@ -223,3 +226,80 @@ public void CrossRepoRedirect_TargetInCodexRepo_ResolvesToCodexPath() resolvedUri.ToString().Should().Be("/r/kibana/get-started"); } } + +public class CrossLinkResolverFallbackUrlTests +{ + private static FetchedCrossLinks BuildFallbackOnlyCrossLinks(string repository, string registryUrl, DocSetRegistry registry) + { + var emptyRepositoryLinks = new RepositoryLinks + { + Links = [], + Origin = new GitCheckoutInformation + { + Branch = "main", + RepositoryName = repository, + Remote = "origin", + Ref = "refs/heads/main" + }, + UrlPathPrefix = "", + CrossLinks = [] + }; + return new FetchedCrossLinks + { + DeclaredRepositories = [repository], + LinkReferences = new Dictionary { [repository] = emptyRepositoryLinks }.ToFrozenDictionary(), + LinkIndexEntries = new Dictionary().ToFrozenDictionary(), + RegistryUrlsByRepository = new Dictionary { [repository] = registryUrl }.ToFrozenDictionary(), + RegistryByRepository = new Dictionary { [repository] = registry }.ToFrozenDictionary() + }; + } + + [Fact] + public void InternalRegistry_NoIndexEntry_UsesCodexInternalPath() + { + var crossLinks = BuildFallbackOnlyCrossLinks( + "platform-observability-team", + "https://github.com/elastic/codex-link-index", + DocSetRegistry.Internal + ); + + string? emittedError = null; + var resolver = new IsolatedBuildEnvironmentUriResolver(); + var success = CrossLinkResolver.TryResolve( + s => emittedError = s, + crossLinks, + resolver, + new Uri("platform-observability-team://index.md", UriKind.Absolute), + out _ + ); + + success.Should().BeFalse(); + emittedError.Should().NotBeNull(); + emittedError.Should().Contain("https://github.com/elastic/codex-link-index/blob/main/internal/elastic/platform-observability-team/links.json"); + emittedError.Should().NotContain("/main/links.json"); + } + + [Fact] + public void PublicRegistry_NoIndexEntry_UsesPublicS3Path() + { + var crossLinks = BuildFallbackOnlyCrossLinks( + "docs-content", + "https://elastic-docs-link-index.s3.us-east-2.amazonaws.com", + DocSetRegistry.Public + ); + + string? emittedError = null; + var resolver = new IsolatedBuildEnvironmentUriResolver(); + var success = CrossLinkResolver.TryResolve( + s => emittedError = s, + crossLinks, + resolver, + new Uri("docs-content://index.md", UriKind.Absolute), + out _ + ); + + success.Should().BeFalse(); + emittedError.Should().NotBeNull(); + emittedError.Should().Contain("https://elastic-docs-link-index.s3.us-east-2.amazonaws.com/elastic/docs-content/main/links.json"); + } +} From 780c46846ac7523062e63f2af886b16854f86d5a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 11:01:23 -0300 Subject: [PATCH 18/50] Bump AWSSDK.S3 from 4.0.21.2 to 4.0.22.1 (#3240) --- updated-dependencies: - dependency-name: AWSSDK.S3 dependency-version: 4.0.22.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 6ab5c8f72a..eb7c11fce4 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -37,7 +37,7 @@ - + From 8b540c437b3e9cad0d34756ec996fb676ec00c4c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 11:02:37 -0300 Subject: [PATCH 19/50] Bump Elastic.Clients.Elasticsearch from 9.3.5 to 9.3.6 (#3241) --- updated-dependencies: - dependency-name: Elastic.Clients.Elasticsearch dependency-version: 9.3.6 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index eb7c11fce4..c538c9c8e6 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -47,7 +47,7 @@ - + From 2773e59a696c367b00ad086b63d49993f4b74ed3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 11:02:48 -0300 Subject: [PATCH 20/50] Bump AWSSDK.DynamoDBv2 from 4.0.17.9 to 4.0.18 (#3239) --- updated-dependencies: - dependency-name: AWSSDK.DynamoDBv2 dependency-version: 4.0.18 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index c538c9c8e6..a13ba8012a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -35,7 +35,7 @@ - + From 420030caff3ec0a5b628695e71378ac39ffed24c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 11:03:14 -0300 Subject: [PATCH 21/50] Bump wait-on from 9.0.3 to 9.0.5 in /src/Elastic.Documentation.Site (#3236) Bumps [wait-on](https://github.com/jeffbski/wait-on) from 9.0.3 to 9.0.5. - [Release notes](https://github.com/jeffbski/wait-on/releases) - [Commits](https://github.com/jeffbski/wait-on/compare/v9.0.3...v9.0.5) --- updated-dependencies: - dependency-name: wait-on dependency-version: 9.0.5 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .../package-lock.json | 34 +++++++++---------- src/Elastic.Documentation.Site/package.json | 2 +- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/Elastic.Documentation.Site/package-lock.json b/src/Elastic.Documentation.Site/package-lock.json index d6607430f2..ed224f2367 100644 --- a/src/Elastic.Documentation.Site/package-lock.json +++ b/src/Elastic.Documentation.Site/package-lock.json @@ -84,7 +84,7 @@ "text-diff": "1.0.1", "typescript": "^5.9.3", "typescript-eslint": "8.58.1", - "wait-on": "9.0.3" + "wait-on": "9.0.5" } }, "node_modules/@adobe/css-tools": { @@ -3091,9 +3091,9 @@ "license": "BSD-3-Clause" }, "node_modules/@hapi/tlds": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@hapi/tlds/-/tlds-1.1.3.tgz", - "integrity": "sha512-QIvUMB5VZ8HMLZF9A2oWr3AFM430QC8oGd0L35y2jHpuW6bIIca6x/xL7zUf4J7L9WJ3qjz+iJII8ncaeMbpSg==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@hapi/tlds/-/tlds-1.1.6.tgz", + "integrity": "sha512-xdi7A/4NZokvV0ewovme3aUO5kQhW9pQ2YD1hRqZGhhSi5rBv4usHYidVocXSi9eihYsznZxLtAiEYYUL6VBGw==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -22970,9 +22970,9 @@ } }, "node_modules/@standard-schema/spec": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", - "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "dev": true, "license": "MIT" }, @@ -29585,9 +29585,9 @@ } }, "node_modules/joi": { - "version": "18.0.1", - "resolved": "https://registry.npmjs.org/joi/-/joi-18.0.1.tgz", - "integrity": "sha512-IiQpRyypSnLisQf3PwuN2eIHAsAIGZIrLZkd4zdvIar2bDyhM91ubRjy8a3eYablXsh9BeI/c7dmPYHca5qtoA==", + "version": "18.1.2", + "resolved": "https://registry.npmjs.org/joi/-/joi-18.1.2.tgz", + "integrity": "sha512-rF5MAmps5esSlhCA+N1b6IYHDw9j/btzGaqfgie522jS02Ju/HXBxamlXVlKEHAxoMKQL77HWI8jlqWsFuekZA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -29597,7 +29597,7 @@ "@hapi/pinpoint": "^2.0.1", "@hapi/tlds": "^1.1.1", "@hapi/topo": "^6.0.2", - "@standard-schema/spec": "^1.0.0" + "@standard-schema/spec": "^1.1.0" }, "engines": { "node": ">= 20" @@ -33796,15 +33796,15 @@ } }, "node_modules/wait-on": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.0.3.tgz", - "integrity": "sha512-13zBnyYvFDW1rBvWiJ6Av3ymAaq8EDQuvxZnPIw3g04UqGi4TyoIJABmfJ6zrvKo9yeFQExNkOk7idQbDJcuKA==", + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.0.5.tgz", + "integrity": "sha512-qgnbHDfDTRIp73ANEJNRW/7kn8CrDUcvZz18xotJQku/P4saTGkbIzvnMZebPmVvVNUiRq1qWAPyqCH+W4H8KA==", "dev": true, "license": "MIT", "dependencies": { - "axios": "^1.13.2", - "joi": "^18.0.1", - "lodash": "^4.17.21", + "axios": "^1.15.0", + "joi": "^18.1.2", + "lodash": "^4.18.1", "minimist": "^1.2.8", "rxjs": "^7.8.2" }, diff --git a/src/Elastic.Documentation.Site/package.json b/src/Elastic.Documentation.Site/package.json index 4c0f2b5281..81ea26724a 100644 --- a/src/Elastic.Documentation.Site/package.json +++ b/src/Elastic.Documentation.Site/package.json @@ -92,7 +92,7 @@ "text-diff": "1.0.1", "typescript": "^5.9.3", "typescript-eslint": "8.58.1", - "wait-on": "9.0.3" + "wait-on": "9.0.5" }, "browserslist": [ "defaults" From 38b7b6a20d7bb85bbc1d17b66a086996227da965 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 11:03:33 -0300 Subject: [PATCH 22/50] Bump globals from 17.4.0 to 17.5.0 in /src/Elastic.Documentation.Site (#3237) Bumps [globals](https://github.com/sindresorhus/globals) from 17.4.0 to 17.5.0. - [Release notes](https://github.com/sindresorhus/globals/releases) - [Commits](https://github.com/sindresorhus/globals/compare/v17.4.0...v17.5.0) --- updated-dependencies: - dependency-name: globals dependency-version: 17.5.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- src/Elastic.Documentation.Site/package-lock.json | 8 ++++---- src/Elastic.Documentation.Site/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Elastic.Documentation.Site/package-lock.json b/src/Elastic.Documentation.Site/package-lock.json index ed224f2367..7a68465c94 100644 --- a/src/Elastic.Documentation.Site/package-lock.json +++ b/src/Elastic.Documentation.Site/package-lock.json @@ -67,7 +67,7 @@ "@types/testing-library__jest-dom": "6.0.0", "babel-jest": "30.3.0", "eslint": "10.2.0", - "globals": "17.4.0", + "globals": "17.5.0", "identity-obj-proxy": "3.0.0", "jest": "30.2.0", "jest-environment-jsdom": "30.3.0", @@ -27467,9 +27467,9 @@ } }, "node_modules/globals": { - "version": "17.4.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.4.0.tgz", - "integrity": "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==", + "version": "17.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.5.0.tgz", + "integrity": "sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g==", "dev": true, "license": "MIT", "engines": { diff --git a/src/Elastic.Documentation.Site/package.json b/src/Elastic.Documentation.Site/package.json index 81ea26724a..5ca9c95654 100644 --- a/src/Elastic.Documentation.Site/package.json +++ b/src/Elastic.Documentation.Site/package.json @@ -75,7 +75,7 @@ "@types/testing-library__jest-dom": "6.0.0", "babel-jest": "30.3.0", "eslint": "10.2.0", - "globals": "17.4.0", + "globals": "17.5.0", "identity-obj-proxy": "3.0.0", "jest": "30.2.0", "jest-environment-jsdom": "30.3.0", From da06237bf0621d038d47d0cc7f3c872045d85b69 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 11:03:47 -0300 Subject: [PATCH 23/50] Bump @types/katex in /src/Elastic.Documentation.Site (#3234) Bumps [@types/katex](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/katex) from 0.16.7 to 0.16.8. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/katex) --- updated-dependencies: - dependency-name: "@types/katex" dependency-version: 0.16.8 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- src/Elastic.Documentation.Site/package-lock.json | 8 ++++---- src/Elastic.Documentation.Site/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Elastic.Documentation.Site/package-lock.json b/src/Elastic.Documentation.Site/package-lock.json index 7a68465c94..41f97939be 100644 --- a/src/Elastic.Documentation.Site/package-lock.json +++ b/src/Elastic.Documentation.Site/package-lock.json @@ -62,7 +62,7 @@ "@testing-library/user-event": "14.6.1", "@trivago/prettier-plugin-sort-imports": "6.0.2", "@types/jest": "30.0.0", - "@types/katex": "^0.16.7", + "@types/katex": "^0.16.8", "@types/lodash": "^4.17.24", "@types/testing-library__jest-dom": "6.0.0", "babel-jest": "30.3.0", @@ -24283,9 +24283,9 @@ "license": "MIT" }, "node_modules/@types/katex": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.7.tgz", - "integrity": "sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==", + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz", + "integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==", "dev": true, "license": "MIT" }, diff --git a/src/Elastic.Documentation.Site/package.json b/src/Elastic.Documentation.Site/package.json index 5ca9c95654..763c07ce7a 100644 --- a/src/Elastic.Documentation.Site/package.json +++ b/src/Elastic.Documentation.Site/package.json @@ -70,7 +70,7 @@ "@testing-library/user-event": "14.6.1", "@trivago/prettier-plugin-sort-imports": "6.0.2", "@types/jest": "30.0.0", - "@types/katex": "^0.16.7", + "@types/katex": "^0.16.8", "@types/lodash": "^4.17.24", "@types/testing-library__jest-dom": "6.0.0", "babel-jest": "30.3.0", From 539324c870bf9b9ea494ff8e872e2f46932018d3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 11:04:04 -0300 Subject: [PATCH 24/50] Bump amondnet/vercel-action in /actions/publish-vercel (#3232) Bumps [amondnet/vercel-action](https://github.com/amondnet/vercel-action) from 42.2.0 to 42.3.0. - [Release notes](https://github.com/amondnet/vercel-action/releases) - [Changelog](https://github.com/amondnet/vercel-action/blob/master/CHANGELOG.md) - [Commits](https://github.com/amondnet/vercel-action/compare/v42.2.0...v42.3.0) --- updated-dependencies: - dependency-name: amondnet/vercel-action dependency-version: 42.3.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- actions/publish-vercel/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/actions/publish-vercel/action.yml b/actions/publish-vercel/action.yml index 9c2fdc0891..2841fcb9d6 100644 --- a/actions/publish-vercel/action.yml +++ b/actions/publish-vercel/action.yml @@ -36,7 +36,7 @@ runs: } EOF - - uses: amondnet/vercel-action@v42.2.0 #deploy + - uses: amondnet/vercel-action@v42.3.0 #deploy with: vercel-token: ${{ inputs.VERCEL_TOKEN }} # Required vercel-args: '--prod' #Optional From 919d11e457cbfccd21d8cfc413adbad175d2f10e Mon Sep 17 00:00:00 2001 From: Nassim Kammah Date: Tue, 5 May 2026 17:11:43 +0200 Subject: [PATCH 25/50] fix: upgrade @tanstack/react-query from 5.96.2 to 5.97.0 (#3217) Snyk has created this PR to upgrade @tanstack/react-query from 5.96.2 to 5.97.0. See this package in npm: @tanstack/react-query See this project in Snyk: https://app.snyk.io/org/docs-wmk/project/69782e43-c85b-4c27-afd1-ad863be7a38a?utm_source=github&utm_medium=referral&page=upgrade-pr Co-authored-by: snyk-bot --- src/Elastic.Documentation.Site/package-lock.json | 16 ++++++++-------- src/Elastic.Documentation.Site/package.json | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/Elastic.Documentation.Site/package-lock.json b/src/Elastic.Documentation.Site/package-lock.json index 41f97939be..86ba5b0127 100644 --- a/src/Elastic.Documentation.Site/package-lock.json +++ b/src/Elastic.Documentation.Site/package-lock.json @@ -27,7 +27,7 @@ "@opentelemetry/sdk-trace-web": "^2.7.0", "@opentelemetry/semantic-conventions": "^1.40.0", "@r2wc/react-to-web-component": "2.1.1", - "@tanstack/react-query": "^5.96.2", + "@tanstack/react-query": "^5.97.0", "@theletterf/beautiful-mermaid": "0.1.5", "@uidotdev/usehooks": "2.4.1", "dompurify": "3.4.0", @@ -23889,9 +23889,9 @@ } }, "node_modules/@tanstack/query-core": { - "version": "5.96.2", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.96.2.tgz", - "integrity": "sha512-hzI6cTVh4KNRk8UtoIBS7Lv9g6BnJPXvBKsvYH1aGWvv0347jT3BnSvztOE+kD76XGvZnRC/t6qdW1CaIfwCeA==", + "version": "5.97.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.97.0.tgz", + "integrity": "sha512-QdpLP5VzVMgo4VtaPppRA2W04UFjIqX+bxke/ZJhE5cfd5UPkRzqIAJQt9uXkQJjqE8LBOMbKv7f8HCsZltXlg==", "license": "MIT", "funding": { "type": "github", @@ -23899,12 +23899,12 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.96.2", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.96.2.tgz", - "integrity": "sha512-sYyzzJT4G0g02azzJ8o55VFFV31XvFpdUpG+unxS0vSaYsJnSPKGoI6WdPwUucJL1wpgGfwfmntNX/Ub1uOViA==", + "version": "5.97.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.97.0.tgz", + "integrity": "sha512-y4So4eGcQoK2WVMAcDNZE9ofB/p5v1OlKvtc1F3uqHwrtifobT7q+ZnXk2mRkc8E84HKYSlAE9z6HXl2V0+ySQ==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.96.2" + "@tanstack/query-core": "5.97.0" }, "funding": { "type": "github", diff --git a/src/Elastic.Documentation.Site/package.json b/src/Elastic.Documentation.Site/package.json index 763c07ce7a..ec868d0db6 100644 --- a/src/Elastic.Documentation.Site/package.json +++ b/src/Elastic.Documentation.Site/package.json @@ -117,7 +117,7 @@ "@opentelemetry/sdk-trace-web": "^2.7.0", "@opentelemetry/semantic-conventions": "^1.40.0", "@r2wc/react-to-web-component": "2.1.1", - "@tanstack/react-query": "^5.96.2", + "@tanstack/react-query": "^5.97.0", "@uidotdev/usehooks": "2.4.1", "@theletterf/beautiful-mermaid": "0.1.5", "dompurify": "3.4.0", From f553c148e9ac0f0eaa3eea39ae2a2e6c3f87dd0f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 12:12:01 -0300 Subject: [PATCH 26/50] Bump axios from 1.15.0 to 1.16.0 in /src/Elastic.Documentation.Site (#3248) Bumps [axios](https://github.com/axios/axios) from 1.15.0 to 1.16.0. - [Release notes](https://github.com/axios/axios/releases) - [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md) - [Commits](https://github.com/axios/axios/compare/v1.15.0...v1.16.0) --- updated-dependencies: - dependency-name: axios dependency-version: 1.16.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- src/Elastic.Documentation.Site/package-lock.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Elastic.Documentation.Site/package-lock.json b/src/Elastic.Documentation.Site/package-lock.json index 86ba5b0127..4a1f4a8714 100644 --- a/src/Elastic.Documentation.Site/package-lock.json +++ b/src/Elastic.Documentation.Site/package-lock.json @@ -25290,13 +25290,13 @@ } }, "node_modules/axios": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", - "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz", + "integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==", "dev": true, "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.11", + "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } From 1d888d2245e345284b6728304eb53d90f3fcbe5b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 12:12:28 -0300 Subject: [PATCH 27/50] Bump the eslint group across 1 directory with 2 updates (#3233) Bumps the eslint group with 2 updates in the /src/Elastic.Documentation.Site directory: [eslint](https://github.com/eslint/eslint) and [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint). Updates `eslint` from 10.2.0 to 10.2.1 - [Release notes](https://github.com/eslint/eslint/releases) - [Commits](https://github.com/eslint/eslint/compare/v10.2.0...v10.2.1) Updates `typescript-eslint` from 8.58.1 to 8.59.0 - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.59.0/packages/typescript-eslint) --- updated-dependencies: - dependency-name: eslint dependency-version: 10.2.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: eslint - dependency-name: typescript-eslint dependency-version: 8.58.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: eslint ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .../package-lock.json | 140 +++++++++--------- src/Elastic.Documentation.Site/package.json | 4 +- 2 files changed, 72 insertions(+), 72 deletions(-) diff --git a/src/Elastic.Documentation.Site/package-lock.json b/src/Elastic.Documentation.Site/package-lock.json index 4a1f4a8714..d04fa14795 100644 --- a/src/Elastic.Documentation.Site/package-lock.json +++ b/src/Elastic.Documentation.Site/package-lock.json @@ -66,7 +66,7 @@ "@types/lodash": "^4.17.24", "@types/testing-library__jest-dom": "6.0.0", "babel-jest": "30.3.0", - "eslint": "10.2.0", + "eslint": "10.2.1", "globals": "17.5.0", "identity-obj-proxy": "3.0.0", "jest": "30.2.0", @@ -83,7 +83,7 @@ "svgo": "^4.0.1", "text-diff": "1.0.1", "typescript": "^5.9.3", - "typescript-eslint": "8.58.1", + "typescript-eslint": "8.59.0", "wait-on": "9.0.5" } }, @@ -24426,17 +24426,17 @@ "dev": true }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.1.tgz", - "integrity": "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.0.tgz", + "integrity": "sha512-HyAZtpdkgZwpq8Sz3FSUvCR4c+ScbuWa9AksK2Jweub7w4M3yTz4O11AqVJzLYjy/B9ZWPyc81I+mOdJU/bDQw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.58.1", - "@typescript-eslint/type-utils": "8.58.1", - "@typescript-eslint/utils": "8.58.1", - "@typescript-eslint/visitor-keys": "8.58.1", + "@typescript-eslint/scope-manager": "8.59.0", + "@typescript-eslint/type-utils": "8.59.0", + "@typescript-eslint/utils": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -24449,7 +24449,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.58.1", + "@typescript-eslint/parser": "^8.59.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } @@ -24465,16 +24465,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.1.tgz", - "integrity": "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.0.tgz", + "integrity": "sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.58.1", - "@typescript-eslint/types": "8.58.1", - "@typescript-eslint/typescript-estree": "8.58.1", - "@typescript-eslint/visitor-keys": "8.58.1", + "@typescript-eslint/scope-manager": "8.59.0", + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/typescript-estree": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0", "debug": "^4.4.3" }, "engines": { @@ -24490,14 +24490,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.1.tgz", - "integrity": "sha512-gfQ8fk6cxhtptek+/8ZIqw8YrRW5048Gug8Ts5IYcMLCw18iUgrZAEY/D7s4hkI0FxEfGakKuPK/XUMPzPxi5g==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.0.tgz", + "integrity": "sha512-Lw5ITrR5s5TbC19YSvlr63ZfLaJoU6vtKTHyB0GQOpX0W7d5/Ir6vUahWi/8Sps/nOukZQ0IB3SmlxZnjaKVnw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.58.1", - "@typescript-eslint/types": "^8.58.1", + "@typescript-eslint/tsconfig-utils": "^8.59.0", + "@typescript-eslint/types": "^8.59.0", "debug": "^4.4.3" }, "engines": { @@ -24512,14 +24512,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.1.tgz", - "integrity": "sha512-TPYUEqJK6avLcEjumWsIuTpuYODTTDAtoMdt8ZZa93uWMTX13Nb8L5leSje1NluammvU+oI3QRr5lLXPgihX3w==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.0.tgz", + "integrity": "sha512-UzR16Ut8IpA3Mc4DbgAShlPPkVm8xXMWafXxB0BocaVRHs8ZGakAxGRskF7FId3sdk9lgGD73GSFaWmWFDE4dg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.58.1", - "@typescript-eslint/visitor-keys": "8.58.1" + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -24530,9 +24530,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.1.tgz", - "integrity": "sha512-JAr2hOIct2Q+qk3G+8YFfqkqi7sC86uNryT+2i5HzMa2MPjw4qNFvtjnw1IiA1rP7QhNKVe21mSSLaSjwA1Olw==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.0.tgz", + "integrity": "sha512-91Sbl3s4Kb3SybliIY6muFBmHVv+pYXfybC4Oolp3dvk8BvIE3wOPc+403CWIT7mJNkfQRGtdqghzs2+Z91Tqg==", "dev": true, "license": "MIT", "engines": { @@ -24547,15 +24547,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.1.tgz", - "integrity": "sha512-HUFxvTJVroT+0rXVJC7eD5zol6ID+Sn5npVPWoFuHGg9Ncq5Q4EYstqR+UOqaNRFXi5TYkpXXkLhoCHe3G0+7w==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.0.tgz", + "integrity": "sha512-3TRiZaQSltGqGeNrJzzr1+8YcEobKH9rHnqIp/1psfKFmhRQDNMGP5hBufanYTGznwShzVLs3Mz+gDN7HkWfXg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.58.1", - "@typescript-eslint/typescript-estree": "8.58.1", - "@typescript-eslint/utils": "8.58.1", + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/typescript-estree": "8.59.0", + "@typescript-eslint/utils": "8.59.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -24572,9 +24572,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.1.tgz", - "integrity": "sha512-io/dV5Aw5ezwzfPBBWLoT+5QfVtP8O7q4Kftjn5azJ88bYyp/ZMCsyW1lpKK46EXJcaYMZ1JtYj+s/7TdzmQMw==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.0.tgz", + "integrity": "sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A==", "dev": true, "license": "MIT", "engines": { @@ -24586,16 +24586,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.1.tgz", - "integrity": "sha512-w4w7WR7GHOjqqPnvAYbazq+Y5oS68b9CzasGtnd6jIeOIeKUzYzupGTB2T4LTPSv4d+WPeccbxuneTFHYgAAWg==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.0.tgz", + "integrity": "sha512-O9Re9P1BmBLFJyikRbQpLku/QA3/AueZNO9WePLBwQrvkixTmDe8u76B6CYUAITRl/rHawggEqUGn5QIkVRLMw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.58.1", - "@typescript-eslint/tsconfig-utils": "8.58.1", - "@typescript-eslint/types": "8.58.1", - "@typescript-eslint/visitor-keys": "8.58.1", + "@typescript-eslint/project-service": "8.59.0", + "@typescript-eslint/tsconfig-utils": "8.59.0", + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -24666,16 +24666,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.1.tgz", - "integrity": "sha512-Ln8R0tmWC7pTtLOzgJzYTXSCjJ9rDNHAqTaVONF4FEi2qwce8mD9iSOxOpLFFvWp/wBFlew0mjM1L1ihYWfBdQ==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.0.tgz", + "integrity": "sha512-I1R/K7V07XsMJ12Oaxg/O9GfrysGTmCRhvZJBv0RE0NcULMzjqVpR5kRRQjHsz3J/bElU7HwCO7zkqL+MSUz+g==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.58.1", - "@typescript-eslint/types": "8.58.1", - "@typescript-eslint/typescript-estree": "8.58.1" + "@typescript-eslint/scope-manager": "8.59.0", + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/typescript-estree": "8.59.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -24690,13 +24690,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.1.tgz", - "integrity": "sha512-y+vH7QE8ycjoa0bWciFg7OpFcipUuem1ujhrdLtq1gByKwfbC7bPeKsiny9e0urg93DqwGcHey+bGRKCnF1nZQ==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.0.tgz", + "integrity": "sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/types": "8.59.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -26770,18 +26770,18 @@ } }, "node_modules/eslint": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.2.0.tgz", - "integrity": "sha512-+L0vBFYGIpSNIt/KWTpFonPrqYvgKw1eUI5Vn7mEogrQcWtWYtNQ7dNqC+px/J0idT3BAkiWrhfS7k+Tum8TUA==", + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.2.1.tgz", + "integrity": "sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", - "@eslint/config-array": "^0.23.4", - "@eslint/config-helpers": "^0.5.4", - "@eslint/core": "^1.2.0", - "@eslint/plugin-kit": "^0.7.0", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.5.5", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", @@ -33277,16 +33277,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.1.tgz", - "integrity": "sha512-gf6/oHChByg9HJvhMO1iBexJh12AqqTfnuxscMDOVqfJW3htsdRJI/GfPpHTTcyeB8cSTUY2JcZmVgoyPqcrDg==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.0.tgz", + "integrity": "sha512-BU3ONW9X+v90EcCH9ZS6LMackcVtxRLlI3XrYyqZIwVSHIk7Qf7bFw1z0M9Q0IUxhTMZCf8piY9hTYaNEIASrw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.58.1", - "@typescript-eslint/parser": "8.58.1", - "@typescript-eslint/typescript-estree": "8.58.1", - "@typescript-eslint/utils": "8.58.1" + "@typescript-eslint/eslint-plugin": "8.59.0", + "@typescript-eslint/parser": "8.59.0", + "@typescript-eslint/typescript-estree": "8.59.0", + "@typescript-eslint/utils": "8.59.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" diff --git a/src/Elastic.Documentation.Site/package.json b/src/Elastic.Documentation.Site/package.json index ec868d0db6..7d338d0785 100644 --- a/src/Elastic.Documentation.Site/package.json +++ b/src/Elastic.Documentation.Site/package.json @@ -74,7 +74,7 @@ "@types/lodash": "^4.17.24", "@types/testing-library__jest-dom": "6.0.0", "babel-jest": "30.3.0", - "eslint": "10.2.0", + "eslint": "10.2.1", "globals": "17.5.0", "identity-obj-proxy": "3.0.0", "jest": "30.2.0", @@ -91,7 +91,7 @@ "svgo": "^4.0.1", "text-diff": "1.0.1", "typescript": "^5.9.3", - "typescript-eslint": "8.58.1", + "typescript-eslint": "8.59.0", "wait-on": "9.0.5" }, "browserslist": [ From 90bc137dc7adc8021bb9376a7b210ffdb1049591 Mon Sep 17 00:00:00 2001 From: "elastic-observability-automation[bot]" <180520183+elastic-observability-automation[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 11:41:35 -0400 Subject: [PATCH 28/50] [Automation] Bump product version numbers (#3247) --- config/versions.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/config/versions.yml b/config/versions.yml index 1129c1d96e..51a832543e 100644 --- a/config/versions.yml +++ b/config/versions.yml @@ -19,7 +19,7 @@ versioning_systems: ech: *all eck: base: 3.0 - current: 3.3.2 + current: 3.4.0 ess: *all ecs: base: 9.0 @@ -91,7 +91,7 @@ versioning_systems: current: 1.11.0 edot-node: base: 1.0 - current: 1.11.0 + current: 1.12.0 edot-php: base: 1.0 current: 1.4.0 @@ -100,7 +100,7 @@ versioning_systems: current: 1.12.0 edot-cf-aws: base: 1.0 - current: 1.5.0 + current: 1.5.1 edot-cf-azure: base: 0.7.1 current: 0.7.1 @@ -152,7 +152,7 @@ versioning_systems: current: 9.3.3 elasticsearch-client-java: base: 9.0 - current: 9.3.4 + current: 9.4.0 elasticsearch-client-javascript: base: 9.0 current: 9.3.4 From 82660402e61c8f83bc425df178ccf8e066f3cbe7 Mon Sep 17 00:00:00 2001 From: "elastic-observability-automation[bot]" <180520183+elastic-observability-automation[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 18:58:11 +0200 Subject: [PATCH 29/50] [Automation] Bump product version numbers (#3249) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: Update config/versions.yml elasticsearch-client-go 9.4.0 Made with ❤️️ by updatecli * chore: Update config/versions.yml edot-collector 9.4.0 Made with ❤️️ by updatecli --------- Co-authored-by: elastic-observability-automation[bot] <180520183+elastic-observability-automation[bot]@users.noreply.github.com> --- config/versions.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config/versions.yml b/config/versions.yml index 51a832543e..ca6e38f29f 100644 --- a/config/versions.yml +++ b/config/versions.yml @@ -73,7 +73,7 @@ versioning_systems: # EDOTs edot-collector: base: 9.0 - current: 9.3.4 + current: 9.4.0 edot-ios: base: 1.0 current: 2.0.0 @@ -149,7 +149,7 @@ versioning_systems: # Elasticsearch clients (separate from Elasticsearch) elasticsearch-client-go: base: 9.0 - current: 9.3.3 + current: 9.4.0 elasticsearch-client-java: base: 9.0 current: 9.4.0 From 4c36217b141e8d74d4774aa17bd9b8a419a44fc8 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Wed, 6 May 2026 11:32:21 +0200 Subject: [PATCH 30/50] Add white-label branding support for isolated builds (#3159) Co-authored-by: Claude Sonnet 4.6 (1M context) --- .../Builder/ConfigurationFile.cs | 63 ++++++++ .../Toc/DocumentationSetFile.cs | 26 ++++ .../web-components/Header/DeploymentInfo.tsx | 3 +- .../Assets/web-components/Header/Header.tsx | 141 +++++++++++++----- .../Layout/_Head.cshtml | 11 +- .../Layout/_IsolatedFooter.cshtml | 59 +++++--- .../Layout/_IsolatedHeader.cshtml | 5 +- src/Elastic.Documentation.Site/_ViewModels.cs | 13 ++ .../GitCheckoutInformation.cs | 66 ++++++++ .../DocumentationGenerator.cs | 40 +++++ src/Elastic.Markdown/HtmlWriter.cs | 25 +++- .../Layout/_TableOfContents.cshtml | 6 +- src/Elastic.Markdown/Page/Index.cshtml | 1 + src/Elastic.Markdown/Page/IndexViewModel.cs | 4 + 14 files changed, 386 insertions(+), 77 deletions(-) diff --git a/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs b/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs index 20c038bcc8..8f792853ee 100644 --- a/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs +++ b/src/Elastic.Documentation.Configuration/Builder/ConfigurationFile.cs @@ -68,6 +68,11 @@ public record ConfigurationFile /// public HashSet SuppressDiagnostics { get; } = []; + /// + /// White-label branding overrides. When non-null, all Elastic-specific chrome is suppressed. + /// + public BrandingConfiguration? Branding { get; private set; } + /// This is a documentation set not linked to by assembler. /// Setting this to true relaxes a few restrictions such as mixing toc references with file and folder reference public bool DevelopmentDocs { get; } @@ -248,6 +253,10 @@ public ConfigurationFile(DocumentationSetFile docSetFile, IDocumentationSetConte .ToHashSet()!; } + // Process branding with validation + if (docSetFile.Branding is not null) + Branding = ValidateBranding(docSetFile.Branding, context); + // Process features _features = new Dictionary(StringComparer.OrdinalIgnoreCase); if (docSetFile.Features.PrimaryNav.HasValue) @@ -255,6 +264,10 @@ public ConfigurationFile(DocumentationSetFile docSetFile, IDocumentationSetConte if (docSetFile.Features.DisableGithubEditLink.HasValue) _features["disable-github-edit-link"] = docSetFile.Features.DisableGithubEditLink.Value; + // primary-nav requires the Elastic global navigation which is not available for white-label builds + if (Branding is not null && docSetFile.Features.PrimaryNav is true) + context.EmitError(context.ConfigurationPath, "'features.primary-nav' cannot be used together with 'branding': the primary nav requires Elastic global navigation."); + // Add version substitutions foreach (var (id, system) in versionsConfig.VersioningSystems) { @@ -283,6 +296,56 @@ public ConfigurationFile(DocumentationSetFile docSetFile, IDocumentationSetConte } } + private static readonly HashSet AllowedImageExtensions = + [".svg", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico"]; + + private static BrandingConfiguration ValidateBranding(BrandingConfiguration branding, IDocumentationSetContext context) + { + branding.Icon = ValidateBrandingImage(branding.Icon, "branding.icon", context); + branding.OgImage = ValidateBrandingImage(branding.OgImage, "branding.og-image", context); + return branding; + } + + private static string? ValidateBrandingImage(string? imagePath, string fieldName, IDocumentationSetContext context) + { + if (string.IsNullOrEmpty(imagePath)) + return null; + + var ext = Path.GetExtension(imagePath).ToLowerInvariant(); + if (!AllowedImageExtensions.Contains(ext)) + { + context.EmitError(context.ConfigurationPath, + $"'{fieldName}' has unsupported extension '{ext}'. Allowed: {string.Join(", ", AllowedImageExtensions)}"); + return null; + } + + var resolved = context.ReadFileSystem.FileInfo.New( + Path.GetFullPath(Path.Join(context.DocumentationSourceDirectory.FullName, imagePath)) + ); + + if (!resolved.IsSubPathOf(context.DocumentationSourceDirectory)) + { + context.EmitError(context.ConfigurationPath, + $"'{fieldName}' path '{imagePath}' escapes the documentation source directory."); + return null; + } + + if (resolved.LinkTarget is not null) + { + context.EmitError(context.ConfigurationPath, + $"'{fieldName}' path '{imagePath}' is a symbolic link, which is not allowed for branding images."); + return null; + } + + if (!resolved.Exists) + { + context.EmitError(context.ConfigurationPath, $"'{fieldName}' file '{imagePath}' does not exist."); + return null; + } + + return imagePath; + } + private static CrossLinkEntry? ParseCrossLinkEntry(string raw, DocSetRegistry docsetRegistry, IFileInfo configPath, IDocumentationContext context) { DocSetRegistry entryRegistry; diff --git a/src/Elastic.Documentation.Configuration/Toc/DocumentationSetFile.cs b/src/Elastic.Documentation.Configuration/Toc/DocumentationSetFile.cs index 52320b9cd8..3aa71c2f53 100644 --- a/src/Elastic.Documentation.Configuration/Toc/DocumentationSetFile.cs +++ b/src/Elastic.Documentation.Configuration/Toc/DocumentationSetFile.cs @@ -66,6 +66,12 @@ public class DocumentationSetFile : TableOfContentsFile [YamlMember(Alias = "codex")] public CodexDocSetMetadata? Codex { get; set; } + /// + /// Optional white-label branding overrides. When present, all Elastic-specific chrome is suppressed. + /// + [YamlMember(Alias = "branding")] + public BrandingConfiguration? Branding { get; set; } + public static FileRef[] GetFileRefs(ITableOfContentsItem item) { if (item is FileRef fileRef) @@ -679,3 +685,23 @@ public class CodexDocSetMetadata [YamlMember(Alias = "group")] public string? Group { get; set; } } + +/// +/// White-label branding overrides for isolated builds. Presence of this section removes all Elastic-specific chrome. +/// All image paths are relative to the directory containing docset.yml. +/// +[YamlSerializable] +public class BrandingConfiguration +{ + /// Path to the site icon image, relative to the docs source directory. + [YamlMember(Alias = "icon")] + public string? Icon { get; set; } + + /// CSS colour value for the header background. Defaults to #000000 when not specified. + [YamlMember(Alias = "header-bg", ApplyNamingConventions = false)] + public string? HeaderBg { get; set; } + + /// Path to the Open Graph image, relative to the docs source directory. + [YamlMember(Alias = "og-image", ApplyNamingConventions = false)] + public string? OgImage { get; set; } +} diff --git a/src/Elastic.Documentation.Site/Assets/web-components/Header/DeploymentInfo.tsx b/src/Elastic.Documentation.Site/Assets/web-components/Header/DeploymentInfo.tsx index 20a646afcc..e2f83a464b 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/Header/DeploymentInfo.tsx +++ b/src/Elastic.Documentation.Site/Assets/web-components/Header/DeploymentInfo.tsx @@ -275,7 +275,8 @@ function getDeploymentLinks( gitCommit: string, githubRef?: string ): { ref?: string; branch: string; commit: string; repository: string } { - const repo = githubRepository.startsWith('elastic/') + // Backend passes full org/repo; fallback only fires for bare names (shouldn't occur) + const repo = githubRepository.includes('/') ? githubRepository : `elastic/${githubRepository}` const base = `${GITHUB_BASE}/${repo}` diff --git a/src/Elastic.Documentation.Site/Assets/web-components/Header/Header.tsx b/src/Elastic.Documentation.Site/Assets/web-components/Header/Header.tsx index 39e0c071b9..f6af29ce3f 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/Header/Header.tsx +++ b/src/Elastic.Documentation.Site/Assets/web-components/Header/Header.tsx @@ -23,6 +23,16 @@ interface Props { githubRef?: string /** When true, deployment info is hidden (not relevant in air-gapped environments). */ airGapped?: boolean + /** + * When true the docset has `branding` configured: suppresses the Elastic logo and + * uses a custom background. The Razor view always passes this explicitly so the + * component does not have to infer branding state from other optional props. + */ + branded?: boolean + /** Custom header background CSS colour. Only used when branded=true; defaults to #000000. */ + headerBg?: string + /** Custom icon image URL. When set (and branded=true), renders an instead of the title text. */ + iconSrc?: string } export const Header = ({ @@ -34,11 +44,82 @@ export const Header = ({ gitCommit, githubRef, airGapped = false, + branded = false, + headerBg, + iconSrc, }: Props) => { const { euiTheme } = useEuiTheme() const containerRef = useRef(null) useHtmxContainer(containerRef) + const bgColor = branded ? headerBg || '#000000' : euiTheme.colors.primary + + const logoSection = branded ? ( + iconSrc ? ( + + + {title} + {title} + + + ) : ( + // Branding configured but no icon — title text only, no Elastic logo + + + {title} + + + ) + ) : ( + // Default: Elastic-branded logo (light-mode styling) + + span { + color: ${euiTheme.colors.textInk}; + } + `} + > + {title} + + + ) + return ( - span { - color: ${euiTheme.colors.textInk}; - } - `} - > - {title} - - , - ], + items: [logoSection], }, ...(!airGapped ? [ @@ -114,9 +176,7 @@ export const Header = ({ , ], @@ -141,6 +201,9 @@ customElements.define( gitCommit: 'string', githubRef: 'string', airGapped: 'boolean', + branded: 'boolean', + headerBg: 'string', + iconSrc: 'string', }, }) ) diff --git a/src/Elastic.Documentation.Site/Layout/_Head.cshtml b/src/Elastic.Documentation.Site/Layout/_Head.cshtml index a961e3b23a..bb9991f266 100644 --- a/src/Elastic.Documentation.Site/Layout/_Head.cshtml +++ b/src/Elastic.Documentation.Site/Layout/_Head.cshtml @@ -51,8 +51,15 @@ - - + @if (Model.Branding is null) + { + + + } + else if (Model.BrandingOgImageStaticPath is { } ogPath) + { + + } @if (!string.IsNullOrEmpty(Model.CanonicalUrl)) { diff --git a/src/Elastic.Documentation.Site/Layout/_IsolatedFooter.cshtml b/src/Elastic.Documentation.Site/Layout/_IsolatedFooter.cshtml index 33e12eb868..f7af1fb8d9 100644 --- a/src/Elastic.Documentation.Site/Layout/_IsolatedFooter.cshtml +++ b/src/Elastic.Documentation.Site/Layout/_IsolatedFooter.cshtml @@ -1,25 +1,38 @@ @inherits RazorSlice -
    -
    - - Elastic logo - -

    - © @(DateTime.Today.Year) Elasticsearch B.V. All Rights Reserved. -

    - -
    - @if (Model.Features.DiagnosticsPanelEnabled) - { -
    @* height of the diagnostics panel as placeholder so we can see the full footer *@
    - - } -
    +@if (Model.Branding is null) +{ +
    +
    + + Elastic logo + +

    + © @(DateTime.Today.Year) Elasticsearch B.V. All Rights Reserved. +

    + +
    + @if (Model.Features.DiagnosticsPanelEnabled) + { +
    @* height of the diagnostics panel as placeholder so we can see the full footer *@
    + + } +
    +} +else +{ +
    + @if (Model.Features.DiagnosticsPanelEnabled) + { +
    + + } +
    +} diff --git a/src/Elastic.Documentation.Site/Layout/_IsolatedHeader.cshtml b/src/Elastic.Documentation.Site/Layout/_IsolatedHeader.cshtml index bbed1cafa5..e4b488c17a 100644 --- a/src/Elastic.Documentation.Site/Layout/_IsolatedHeader.cshtml +++ b/src/Elastic.Documentation.Site/Layout/_IsolatedHeader.cshtml @@ -6,6 +6,9 @@ github-link="@(Model.GitHubDocsUrl)" git-branch="@(Model.GitBranch)" git-commit="@(Model.GitCommitShort)" - github-ref="@(Model.GitHubRef)"> + github-ref="@(Model.GitHubRef)" + branded="@(Model.Branding != null ? "true" : "false")" + header-bg="@(Model.Branding?.HeaderBg)" + icon-src="@(Model.BrandingIconStaticPath)"> diff --git a/src/Elastic.Documentation.Site/_ViewModels.cs b/src/Elastic.Documentation.Site/_ViewModels.cs index eed50e4462..2ef179eb99 100644 --- a/src/Elastic.Documentation.Site/_ViewModels.cs +++ b/src/Elastic.Documentation.Site/_ViewModels.cs @@ -3,11 +3,13 @@ // See the LICENSE file in the project root for more information using System; +using System.IO; using System.Text.Json; using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Configuration.Builder; +using Elastic.Documentation.Configuration.Toc; using Elastic.Documentation.Navigation; using Elastic.Documentation.Site.FileProviders; @@ -75,6 +77,17 @@ public record GlobalLayoutViewModel public bool RenderHamburgerIcon { get; init; } = true; + /// White-label branding overrides. When non-null, all Elastic-specific chrome is suppressed. + public BrandingConfiguration? Branding { get; init; } + + /// Static URL of the branding icon, if configured. + public string? BrandingIconStaticPath => + Branding?.Icon is { } icon ? Static(Path.GetFileName(icon)) : null; + + /// Static URL of the OG image, if configured. + public string? BrandingOgImageStaticPath => + Branding?.OgImage is { } og ? Static(Path.GetFileName(og)) : null; + /// Root path for static assets. For codex builds, strips the /r/repoName segment from the URL path prefix. public string StaticPathPrefix => GetStaticPathPrefix(); diff --git a/src/Elastic.Documentation/GitCheckoutInformation.cs b/src/Elastic.Documentation/GitCheckoutInformation.cs index e08b965101..48a8ff7b05 100644 --- a/src/Elastic.Documentation/GitCheckoutInformation.cs +++ b/src/Elastic.Documentation/GitCheckoutInformation.cs @@ -40,6 +40,72 @@ public partial record GitCheckoutInformation [JsonPropertyName("github_ref")] public string? GitHubRef { get; init; } + /// + /// The GitHub repository in org/repo format, derived from the git remote URL. + /// Falls back to elastic/docs-builder when either or is unavailable, + /// or when the remote does not resolve to a valid GitHub org/repo path, + /// to avoid silently linking to an arbitrary repository. + /// + [JsonIgnore] + public string GitHubRepository => ExtractGitHubOrgRepo(Remote) ?? "elastic/docs-builder"; + + /// Extracts a validated org/repo path from a GitHub remote URL, or returns null. + /// + /// Handles the common remote formats: + /// + /// https://github.com/org/repo.git + /// git@github.com:org/repo.git + /// ssh://git@github.com/org/repo.git + /// org/repo (bare, e.g. from GITHUB_REPOSITORY) + /// + /// + private static string? ExtractGitHubOrgRepo(string? remote) + { + if (string.IsNullOrEmpty(remote) || remote == "unavailable") + return null; + + var path = NormalizeToGitHubPath(remote); + if (path is null) + return null; + + // Strip trailing .git + if (path.EndsWith(".git", StringComparison.OrdinalIgnoreCase)) + path = path[..^4]; + + // Validate: must be exactly org/repo — two non-empty segments, no extra slashes + var parts = path.Split('/'); + if (parts.Length != 2 || string.IsNullOrEmpty(parts[0]) || string.IsNullOrEmpty(parts[1])) + return null; + + return path; + } + + /// Normalises the remote to the org/repo[.git] path portion, or null if not a GitHub remote. + private static string? NormalizeToGitHubPath(string remote) + { + const string githubHost = "github.com"; + + // git@github.com:org/repo.git → org/repo.git + if (remote.StartsWith("git@github.com:", StringComparison.OrdinalIgnoreCase)) + return remote["git@github.com:".Length..].TrimStart('/'); + + // ssh://git@github.com/org/repo.git → org/repo.git + if (remote.StartsWith("ssh://git@github.com/", StringComparison.OrdinalIgnoreCase)) + return remote["ssh://git@github.com/".Length..].TrimStart('/'); + + // https://github.com/org/repo.git or http://github.com/org/repo.git → org/repo.git + if (remote.StartsWith("https://github.com/", StringComparison.OrdinalIgnoreCase)) + return remote["https://github.com/".Length..].TrimStart('/'); + if (remote.StartsWith("http://github.com/", StringComparison.OrdinalIgnoreCase)) + return remote["http://github.com/".Length..].TrimStart('/'); + + // Bare org/repo (e.g. GITHUB_REPOSITORY env var) — must not contain "://" or "@" (i.e. not a URL) + if (!remote.Contains("://") && !remote.Contains('@') && !remote.Contains(githubHost, StringComparison.OrdinalIgnoreCase)) + return remote.TrimStart('/'); + + return null; + } + // manual read because libgit2sharp is not yet AOT ready public static GitCheckoutInformation Create(IDirectoryInfo? source, IFileSystem fileSystem, ILogger? logger = null) { diff --git a/src/Elastic.Markdown/DocumentationGenerator.cs b/src/Elastic.Markdown/DocumentationGenerator.cs index 265b87d6b0..4a140e4a7c 100644 --- a/src/Elastic.Markdown/DocumentationGenerator.cs +++ b/src/Elastic.Markdown/DocumentationGenerator.cs @@ -145,7 +145,10 @@ public async Task GenerateAll(Cancel ctx) HintUnusedSubstitutionKeys(); if (Context.AvailableExporters.Contains(Exporter.Html)) + { await ExtractEmbeddedStaticResources(ctx); + CopyBrandingResources(); + } if (generateState) { @@ -197,6 +200,43 @@ await Parallel.ForEachAsync(DocumentationSet.Files, ctx, async (file, token) => } + private void CopyBrandingResources() + { + var branding = Context.Configuration.Branding; + if (branding is null) + return; + + var sourceDir = DocumentationSet.Context.DocumentationSourceDirectory.FullName; + var outputStaticDir = Path.Join(DocumentationSet.OutputDirectory.FullName, "_static"); + _ = _writeFileSystem.Directory.CreateDirectory(outputStaticDir); + + // Track destination basenames to catch collisions between icon and og-image + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var imagePath in new[] { branding.Icon, branding.OgImage }) + { + if (string.IsNullOrEmpty(imagePath)) + continue; + + var source = Context.ReadFileSystem.FileInfo.New(Path.Join(sourceDir, imagePath)); + if (!source.Exists) + { + Context.Collector.EmitError(Context.ConfigurationPath.FullName, $"Branding image '{imagePath}' does not exist."); + continue; + } + + if (!seen.Add(source.Name)) + { + Context.Collector.EmitError(Context.ConfigurationPath.FullName, + $"Branding image '{imagePath}' has the same filename as another branding image — use unique filenames to avoid overwriting."); + continue; + } + + var destination = _writeFileSystem.FileInfo.New(Path.Join(outputStaticDir, source.Name)); + _ = source.CopyTo(destination.FullName, overwrite: true); + _logger.LogInformation("Copied branding asset {Source} -> {Destination}", source.FullName, destination.FullName); + } + } + private void HintUnusedSubstitutionKeys() { var definedKeys = new HashSet(Context.Configuration.Substitutions.Keys.ToArray()); diff --git a/src/Elastic.Markdown/HtmlWriter.cs b/src/Elastic.Markdown/HtmlWriter.cs index 5c5da224ec..0283636180 100644 --- a/src/Elastic.Markdown/HtmlWriter.cs +++ b/src/Elastic.Markdown/HtmlWriter.cs @@ -86,14 +86,14 @@ private async Task RenderLayout(MarkdownFile markdown, MarkdownDoc // so JS can't mark anything as current. Point it at the nearest visible ancestor instead. var navActiveUrl = current.Hidden ? parents.FirstOrDefault(p => !p.Hidden)?.Url : null; - var remote = DocumentationSet.Context.Git.RepositoryName; + var gitHubRepo = DocumentationSet.Context.Git.GitHubRepository; var branch = DocumentationSet.Context.Git.Branch; string? editUrl = null; if (DocumentationSet.Context.Git != GitCheckoutInformation.Unavailable && DocumentationSet.Context.DocumentationCheckoutDirectory is { } checkoutDirectory) { var relativeSourcePath = Path.GetRelativePath(checkoutDirectory.FullName, DocumentationSet.Context.DocumentationSourceDirectory.FullName); var path = UrlPath.Join(relativeSourcePath, markdown.RelativePath); - editUrl = $"https://github.com/elastic/{remote}/edit/{branch}/{path}"; + editUrl = $"https://github.com/{gitHubRepo}/edit/{branch}/{path}"; } Uri? reportLinkParameter = null; @@ -101,7 +101,10 @@ private async Task RenderLayout(MarkdownFile markdown, MarkdownDoc { reportLinkParameter = new Uri(DocumentationSet.Context.CanonicalBaseUrl, current.Url); } - var reportUrl = $"https://github.com/elastic/docs-content/issues/new?template=issue-report.yaml&link={reportLinkParameter}&labels=source:web"; + // Suppress the report URL for white-label builds — elastic/docs-content is an Elastic-owned repo + var reportUrl = DocumentationSet.Configuration.Branding is null + ? $"https://github.com/elastic/docs-content/issues/new?template=issue-report.yaml&link={reportLinkParameter}&labels=source:web" + : null; var siteName = DocumentationSet.Navigation.NavigationTitle; var legacyPages = LegacyUrlMapper.MapLegacyUrl(markdown.YamlFrontMatter?.MappedPages); @@ -139,12 +142,17 @@ private async Task RenderLayout(MarkdownFile markdown, MarkdownDoc // Git info for isolated header - var gitRepo = DocumentationSet.Context.Git.RepositoryName; var gitBranch = DocumentationSet.Context.Git.Branch; var gitRef = DocumentationSet.Context.Git.Ref; string? gitHubDocsUrl = null; - if (!string.IsNullOrEmpty(gitRepo) && gitRepo != "unavailable" && !string.IsNullOrEmpty(gitBranch) && gitBranch != "unavailable") - gitHubDocsUrl = $"https://github.com/elastic/{gitRepo}/tree/{gitBranch}/docs"; + if (gitHubRepo != "elastic/docs-builder" + && !string.IsNullOrEmpty(gitBranch) && gitBranch != "unavailable" + && DocumentationSet.Context.DocumentationCheckoutDirectory is { } docsCheckoutDir) + { + var relativeDocsPath = Path.GetRelativePath(docsCheckoutDir.FullName, DocumentationSet.Context.DocumentationSourceDirectory.FullName) + .Replace(Path.DirectorySeparatorChar, '/'); + gitHubDocsUrl = $"https://github.com/{gitHubRepo}/tree/{gitBranch}/{relativeDocsPath}"; + } var slice = PageViewFactory.Create(new IndexViewModel { @@ -186,9 +194,10 @@ private async Task RenderLayout(MarkdownFile markdown, MarkdownDoc // Git info for isolated header GitBranch = gitBranch != "unavailable" ? gitBranch : null, GitCommitShort = gitRef is { Length: >= 7 } r && r != "unavailable" ? r[..7] : null, - GitRepository = gitRepo != "unavailable" ? gitRepo : null, + GitRepository = gitHubRepo, GitHubDocsUrl = gitHubDocsUrl, - GitHubRef = DocumentationSet.Context.Git.GitHubRef + GitHubRef = DocumentationSet.Context.Git.GitHubRef, + Branding = DocumentationSet.Configuration.Branding }); return new RenderResult diff --git a/src/Elastic.Markdown/Layout/_TableOfContents.cshtml b/src/Elastic.Markdown/Layout/_TableOfContents.cshtml index 542e8fc64e..48eaf6a875 100644 --- a/src/Elastic.Markdown/Layout/_TableOfContents.cshtml +++ b/src/Elastic.Markdown/Layout/_TableOfContents.cshtml @@ -3,7 +3,7 @@ @inherits RazorSlice
    public static Bundle DeserializeBundle(string yaml) { + yaml = Utf8TextNormalization.StripLeadingUtf8Bom(yaml)!; var yamlDto = YamlDeserializer.Deserialize(yaml); return ToBundle(yamlDto); } @@ -364,6 +367,7 @@ private static ChangelogEntryType ParseEntryType(string? value) /// The normalized YAML content. public static string NormalizeYaml(string yaml) { + yaml = Utf8TextNormalization.StripLeadingUtf8Bom(yaml)!; // Skip comment lines var yamlLines = yaml.Split('\n'); var yamlWithoutComments = string.Join('\n', yamlLines.Where(line => !line.TrimStart().StartsWith('#'))); diff --git a/src/Elastic.Documentation/Text/Utf8TextNormalization.cs b/src/Elastic.Documentation/Text/Utf8TextNormalization.cs new file mode 100644 index 0000000000..049a5af622 --- /dev/null +++ b/src/Elastic.Documentation/Text/Utf8TextNormalization.cs @@ -0,0 +1,57 @@ +// 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.Text; + +/// +/// UTF-8 text normalization utilities for handling Byte Order Marks (BOMs) and related text encoding concerns. +/// +public static class Utf8TextNormalization +{ + /// + /// UTF-8 Byte Order Mark character (U+FEFF Zero Width No-Break Space). + /// + public const char Utf8BomChar = '\uFEFF'; + + /// + /// UTF-8 Byte Order Mark byte sequence (EF BB BF). + /// + public static readonly byte[] Utf8BomBytes = [0xEF, 0xBB, 0xBF]; + + /// + /// Strips all consecutive leading UTF-8 BOM characters (U+FEFF) from the beginning of a string. + /// + /// This method removes the UTF-8 Byte Order Mark / Zero Width No-Break Space character only. + /// It does NOT strip other zero-width characters like U+200B (Zero Width Space) or U+2060 (Word Joiner) + /// as they can appear in legitimate content and are not part of the UTF-8 BOM sequence. + /// + /// + /// The input string, which may be null or empty. + /// The string with leading BOM characters removed, or the original string if null/empty or no BOM present. + public static string? StripLeadingUtf8Bom(string? text) + { + if (string.IsNullOrEmpty(text)) + return text; + + // Strip all consecutive leading U+FEFF characters + var span = text.AsSpan(); + while (span.Length > 0 && span[0] == Utf8BomChar) + { + span = span[1..]; + } + + return span.Length == text.Length ? text : span.ToString(); + } + + /// + /// Checks if the given byte span starts with the UTF-8 Byte Order Mark sequence (EF BB BF). + /// + /// The byte span to check. + /// True if the span starts with the UTF-8 BOM sequence, false otherwise. + public static bool HasUtf8Bom(ReadOnlySpan bytes) => + bytes.Length >= 3 && + bytes[0] == 0xEF && + bytes[1] == 0xBB && + bytes[2] == 0xBF; +} diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogBundleAmendService.cs b/src/services/Elastic.Changelog/Bundling/ChangelogBundleAmendService.cs index a744c8a224..4120b13a25 100644 --- a/src/services/Elastic.Changelog/Bundling/ChangelogBundleAmendService.cs +++ b/src/services/Elastic.Changelog/Bundling/ChangelogBundleAmendService.cs @@ -7,6 +7,7 @@ using System.Text; using System.Text.RegularExpressions; using Elastic.Changelog.Configuration; +using Elastic.Changelog.Utilities; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Assembler; using Elastic.Documentation.Configuration.Changelog; @@ -50,6 +51,11 @@ public partial class ChangelogBundleAmendService( ScopedFileSystem? fileSystem = null, IConfigurationContext? configurationContext = null) : IService { + /// + /// UTF-8 encoding without BOM for writing YAML files. + /// + private static readonly UTF8Encoding Utf8NoBom = new(encoderShouldEmitUTF8Identifier: false); + private readonly ILogger _logger = logFactory.CreateLogger(); private readonly IFileSystem _fileSystem = fileSystem ?? FileSystemFactory.RealRead; private readonly ChangelogConfigurationLoader? _configLoader = configurationContext != null @@ -256,7 +262,9 @@ public async Task AmendBundle(IDiagnosticsCollector collector, AmendBundle if (!string.IsNullOrWhiteSpace(outputDir) && !_fileSystem.Directory.Exists(outputDir)) _ = _fileSystem.Directory.CreateDirectory(outputDir); - await _fileSystem.File.WriteAllTextAsync(amendFilePath, yaml, Encoding.UTF8, ctx); + // Strip any leading BOM to ensure clean UTF-8 output for tooling compatibility + var normalizedYaml = ChangelogUtf8Normalization.StripLeadingUtf8BomChar(yaml); + await _fileSystem.File.WriteAllTextAsync(amendFilePath, normalizedYaml, Utf8NoBom, ctx); _logger.LogInformation("Created amend file: {AmendFilePath} with {Count} entries", amendFilePath, entries.Count); return true; diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs index 8747f2f39d..bd1adbe4e8 100644 --- a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs +++ b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs @@ -9,6 +9,7 @@ using Elastic.Changelog.Configuration; using Elastic.Changelog.GitHub; using Elastic.Changelog.Rendering; +using Elastic.Changelog.Utilities; using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Assembler; @@ -137,6 +138,11 @@ public partial class ChangelogBundlingService( ? new ChangelogConfigurationLoader(logFactory, configurationContext, fileSystem ?? FileSystemFactory.RealRead) : null; + /// + /// UTF-8 encoding without BOM for writing YAML files. + /// + private static readonly UTF8Encoding Utf8NoBom = new(encoderShouldEmitUTF8Identifier: false); + [GeneratedRegex(@"(\s+)version:", RegexOptions.Multiline)] internal static partial Regex VersionToTargetRegex(); @@ -766,7 +772,9 @@ private async Task WriteBundleFileAsync(Bundle bundledData, string outputPath, C } // Write bundled file with explicit UTF-8 encoding to ensure proper character handling - await _fileSystem.File.WriteAllTextAsync(outputPath, bundledYaml, Encoding.UTF8, ctx); + // Strip any leading BOM to ensure clean UTF-8 output for tooling compatibility + var normalizedYaml = ChangelogUtf8Normalization.StripLeadingUtf8BomChar(bundledYaml); + await _fileSystem.File.WriteAllTextAsync(outputPath, normalizedYaml, Utf8NoBom, ctx); _logger.LogInformation("Created bundled changelog: {OutputPath}", outputPath); } diff --git a/src/services/Elastic.Changelog/Creation/ChangelogFileWriter.cs b/src/services/Elastic.Changelog/Creation/ChangelogFileWriter.cs index ece1e60c72..8c40eac84a 100644 --- a/src/services/Elastic.Changelog/Creation/ChangelogFileWriter.cs +++ b/src/services/Elastic.Changelog/Creation/ChangelogFileWriter.cs @@ -4,6 +4,7 @@ using System.IO.Abstractions; using System.Text; +using Elastic.Changelog.Utilities; using Elastic.Documentation; using Elastic.Documentation.Configuration.Changelog; using Elastic.Documentation.Configuration.ReleaseNotes; @@ -18,6 +19,10 @@ namespace Elastic.Changelog.Creation; ///
    public class ChangelogFileWriter(IFileSystem fileSystem, ILogger logger) { + /// + /// UTF-8 encoding without BOM for writing YAML files. + /// + private static readonly UTF8Encoding Utf8NoBom = new(encoderShouldEmitUTF8Identifier: false); /// /// Writes a changelog file with the given data. /// @@ -46,8 +51,9 @@ public async Task WriteChangelogAsync( var filename = GenerateFilename(collector, input); var filePath = fileSystem.Path.Join(outputDir, filename); - // Write file with explicit UTF-8 encoding to ensure proper character handling - await fileSystem.File.WriteAllTextAsync(filePath, yamlContent, Encoding.UTF8, ctx); + // Write UTF-8 text without BOM using explicit encoding instance. + var normalizedContent = ChangelogUtf8Normalization.StripLeadingUtf8BomChar(yamlContent); + await fileSystem.File.WriteAllTextAsync(filePath, normalizedContent, Utf8NoBom, ctx); logger.LogInformation("Created changelog fragment: {FilePath}", filePath); return true; diff --git a/src/services/Elastic.Changelog/Evaluation/ChangelogPrepareArtifactService.cs b/src/services/Elastic.Changelog/Evaluation/ChangelogPrepareArtifactService.cs index cf0305b29f..a7b768073e 100644 --- a/src/services/Elastic.Changelog/Evaluation/ChangelogPrepareArtifactService.cs +++ b/src/services/Elastic.Changelog/Evaluation/ChangelogPrepareArtifactService.cs @@ -3,9 +3,11 @@ // See the LICENSE file in the project root for more information using System.IO.Abstractions; +using System.Text; using System.Text.Json; using Actions.Core.Services; using Elastic.Changelog.Configuration; +using Elastic.Changelog.Utilities; using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.Services; @@ -21,6 +23,11 @@ public class ChangelogPrepareArtifactService( IFileSystem? fileSystem = null ) : IService { + /// + /// UTF-8 encoding without BOM for writing YAML files. + /// + private static readonly UTF8Encoding Utf8NoBom = new(encoderShouldEmitUTF8Identifier: false); + private readonly ILogger _logger = logFactory.CreateLogger(); private readonly IFileSystem _fileSystem = fileSystem ?? new FileSystem(); private readonly ChangelogConfigurationLoader _configLoader = new(logFactory, configurationContext, fileSystem ?? new FileSystem()); @@ -48,8 +55,11 @@ public async Task PrepareArtifact(IDiagnosticsCollector collector, Prepare _logger.LogInformation("Reusing existing filename {Filename} for stable path on branch", changelogFilename); var destYaml = _fileSystem.Path.Combine(input.OutputDir, changelogFilename); - _fileSystem.File.Copy(sourceYaml, destYaml, overwrite: true); - _logger.LogInformation("Copied changelog YAML: {Source} → {Dest}", sourceYaml, destYaml); + // Read YAML, normalize to remove any BOM, then write UTF-8 bytes without BOM (avoids provider-specific WriteAllText preamble behavior). + var yamlContent = await _fileSystem.File.ReadAllTextAsync(sourceYaml, ctx); + var normalizedContent = ChangelogUtf8Normalization.StripLeadingUtf8BomChar(yamlContent); + await _fileSystem.File.WriteAllTextAsync(destYaml, normalizedContent, Utf8NoBom, ctx); + _logger.LogInformation("Normalized and copied changelog YAML: {Source} → {Dest}", sourceYaml, destYaml); } else { diff --git a/src/services/Elastic.Changelog/GithubRelease/GitHubReleaseChangelogService.cs b/src/services/Elastic.Changelog/GithubRelease/GitHubReleaseChangelogService.cs index 958c74bcf8..2351414d40 100644 --- a/src/services/Elastic.Changelog/GithubRelease/GitHubReleaseChangelogService.cs +++ b/src/services/Elastic.Changelog/GithubRelease/GitHubReleaseChangelogService.cs @@ -8,6 +8,7 @@ using Elastic.Changelog.Bundling; using Elastic.Changelog.Configuration; using Elastic.Changelog.GitHub; +using Elastic.Changelog.Utilities; using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Changelog; @@ -86,6 +87,11 @@ public class GitHubReleaseChangelogService( ChangelogBundlingService? bundlingService = null ) : IService { + /// + /// UTF-8 encoding without BOM for writing YAML files. + /// + private static readonly UTF8Encoding Utf8NoBom = new(encoderShouldEmitUTF8Identifier: false); + private readonly ILogger _logger = logFactory.CreateLogger(); private readonly IFileSystem _fileSystem = fileSystem ?? FileSystemFactory.RealRead; private readonly ChangelogConfigurationLoader _configLoader = new(logFactory, configurationContext, fileSystem ?? FileSystemFactory.RealRead); @@ -301,7 +307,9 @@ private async Task ProcessPrReference( var slug = ChangelogTextUtilities.GenerateSlug(title); var filename = $"{prRef.PrNumber}-{finalType.ToStringFast(true)}-{slug}.yaml"; var filePath = _fileSystem.Path.Join(outputDir, filename); - await _fileSystem.File.WriteAllTextAsync(filePath, yamlContent, Encoding.UTF8, ctx); + // Strip any leading BOM to ensure clean UTF-8 output for tooling compatibility + var normalizedContent = ChangelogUtf8Normalization.StripLeadingUtf8BomChar(yamlContent); + await _fileSystem.File.WriteAllTextAsync(filePath, normalizedContent, Utf8NoBom, ctx); createdFiles.Add(filename); _logger.LogDebug("Created changelog: {FilePath}", filePath); diff --git a/src/services/Elastic.Changelog/Utilities/ChangelogUtf8Normalization.cs b/src/services/Elastic.Changelog/Utilities/ChangelogUtf8Normalization.cs new file mode 100644 index 0000000000..d844cfe05c --- /dev/null +++ b/src/services/Elastic.Changelog/Utilities/ChangelogUtf8Normalization.cs @@ -0,0 +1,43 @@ +// 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; +using Elastic.Documentation.Text; + +namespace Elastic.Changelog.Utilities; + +/// +/// Utilities for normalizing UTF-8 encoding in changelog YAML files. +/// Ensures YAML output is UTF-8 without BOM for better tooling compatibility and review ergonomics. +/// This class now serves as a thin forwarder to the shared UTF-8 text normalization utilities. +/// +public static class ChangelogUtf8Normalization +{ + /// + /// UTF-8 Byte Order Mark character (U+FEFF). + /// + public const char Utf8BomChar = Utf8TextNormalization.Utf8BomChar; + + /// + /// UTF-8 Byte Order Mark as byte sequence (EF BB BF). + /// + public static readonly byte[] Utf8BomBytes = Utf8TextNormalization.Utf8BomBytes; + + /// + /// Strips the leading UTF-8 BOM character from a string if present. + /// YAML should be UTF-8 without BOM for tooling and review ergonomics. + /// + /// The text to normalize + /// Text with leading BOM character removed if it was present + public static string StripLeadingUtf8BomChar(string text) => + Utf8TextNormalization.StripLeadingUtf8Bom(text)!; + + /// + /// Checks if a byte span starts with the UTF-8 BOM sequence (EF BB BF). + /// + /// The byte span to check + /// True if the span starts with UTF-8 BOM bytes + public static bool HasUtf8Bom(ReadOnlySpan bytes) => + Utf8TextNormalization.HasUtf8Bom(bytes); +} diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index 74b367d006..9bdca50eb7 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -18,6 +18,7 @@ using Elastic.Changelog.GithubRelease; using Elastic.Changelog.Rendering; using Elastic.Changelog.Uploading; +using Elastic.Changelog.Utilities; using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.ReleaseNotes; @@ -155,6 +156,8 @@ public Task Init( try { var content = _fileSystem.File.ReadAllText(configPath); + // Strip any leading BOM that might be present after reading + content = ChangelogUtf8Normalization.StripLeadingUtf8BomChar(content); if (useNonDefaultChangelogDir) { @@ -168,7 +171,9 @@ public Task Init( content = BundleOutputDirectoryRegex().Replace(content, "$1" + outputValue); } - _fileSystem.File.WriteAllText(configPath, content); + // Ensure normalized content is written without BOM + var normalizedContent = ChangelogUtf8Normalization.StripLeadingUtf8BomChar(content); + _fileSystem.File.WriteAllText(configPath, normalizedContent); _logger.LogInformation("Updated bundle paths in changelog configuration: {ConfigPath}", configPath); } catch (IOException ex) diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs index 74d4f89c41..7e53004baa 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs @@ -5,6 +5,7 @@ using System.Text; using AwesomeAssertions; using Elastic.Changelog.Bundling; +using Elastic.Changelog.Utilities; using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; using Microsoft.Extensions.Logging.Abstractions; @@ -6124,6 +6125,59 @@ await FileSystem.File.WriteAllTextAsync(configPath, bundleContent.Should().Contain("release-date:", "release date should be auto-populated when bundle.release_dates is true"); } + [Fact] + public async Task BundleChangelogs_WithBomPrefixedInput_ProducesNormalizedOutput() + { + // Arrange - Create changelog with BOM prefix + // language=yaml + var changelogContent = + """ + title: Test changelog with BOM + type: feature + products: + - product: elasticsearch + target: 9.2.0 + lifecycle: ga + prs: + - https://github.com/elastic/elasticsearch/pull/123 + """; + + // Add UTF-8 BOM to the content + var contentWithBom = ChangelogUtf8Normalization.Utf8BomChar + changelogContent; + var changelogFile = FileSystem.Path.Join(_changelogDir, "changelog-with-bom.yaml"); + + // Write the file with BOM using explicit encoding + await FileSystem.File.WriteAllTextAsync(changelogFile, contentWithBom, Encoding.UTF8, TestContext.Current.CancellationToken); + + // Verify the source file has BOM by reading as bytes + var sourceBytes = await FileSystem.File.ReadAllBytesAsync(changelogFile, TestContext.Current.CancellationToken); + ChangelogUtf8Normalization.HasUtf8Bom(sourceBytes).Should().BeTrue("source file should contain BOM"); + + var outputPath = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml"); + var input = new BundleChangelogsArguments + { + Directory = _changelogDir, + All = true, + Output = outputPath + }; + + // Act + var result = await Service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue("bundling should succeed"); + Collector.Errors.Should().Be(0); + + // Verify output file does not contain BOM + var outputBytes = await FileSystem.File.ReadAllBytesAsync(outputPath, TestContext.Current.CancellationToken); + ChangelogUtf8Normalization.HasUtf8Bom(outputBytes).Should().BeFalse("bundled output should not contain UTF-8 BOM"); + + // Verify content refs (bundle uses file refs + checksum unless resolve inlines entries) + var bundleContent = await FileSystem.File.ReadAllTextAsync(outputPath, TestContext.Current.CancellationToken); + bundleContent.Should().Contain("changelog-with-bom.yaml"); + bundleContent.Should().Contain("entries:"); + } + private void CreateSampleChangelogs() { // language=yaml diff --git a/tests/Elastic.Changelog.Tests/Creation/ChangelogCreationServiceTests.cs b/tests/Elastic.Changelog.Tests/Creation/ChangelogCreationServiceTests.cs index 386cfd47ef..b4f7e4fc30 100644 --- a/tests/Elastic.Changelog.Tests/Creation/ChangelogCreationServiceTests.cs +++ b/tests/Elastic.Changelog.Tests/Creation/ChangelogCreationServiceTests.cs @@ -3,10 +3,12 @@ // See the LICENSE file in the project root for more information using System.IO.Abstractions.TestingHelpers; +using System.Text; using AwesomeAssertions; using Elastic.Changelog.Creation; using Elastic.Changelog.GitHub; using Elastic.Changelog.Tests.Changelogs; +using Elastic.Changelog.Utilities; using Elastic.Documentation.Configuration; using FakeItEasy; @@ -230,4 +232,43 @@ public async Task CreateChangelog_TempOutputDirectory_Succeeds() writeFs.Directory.Exists(tempOutput).Should().BeTrue(); writeFs.Directory.GetFiles(tempOutput, "*.yaml").Should().NotBeEmpty(); } + + [Fact] + public async Task CreateChangelog_OutputDoesNotContainBom() + { + await WriteConfig(ConfigWithProductLabels); + var tempOutput = Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString()); + FileSystem.Directory.CreateDirectory(tempOutput); + + var service = new ChangelogCreationService(LoggerFactory, ConfigurationContext, _mockGitHub, FileSystem, null); + var input = new CreateChangelogArguments + { + Title = "Test BOM handling", + Type = "feature", + Products = [new ProductArgument { Product = "elasticsearch", Target = "9.1.0", Lifecycle = "ga" }], + Config = Path.Join(Paths.WorkingDirectoryRoot.FullName, "config", "changelog.yml"), + Output = tempOutput, + Concise = true + }; + + // Act + var result = await service.CreateChangelog(Collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue("changelog creation should succeed"); + Collector.Errors.Should().Be(0); + + // Verify created file does not contain BOM + var yamlFiles = FileSystem.Directory.GetFiles(tempOutput, "*.yaml"); + yamlFiles.Should().NotBeEmpty("should create a YAML file"); + + var yamlFile = yamlFiles[0]; + var bytes = await FileSystem.File.ReadAllBytesAsync(yamlFile, TestContext.Current.CancellationToken); + ChangelogUtf8Normalization.HasUtf8Bom(bytes).Should().BeFalse("created changelog should not contain UTF-8 BOM"); + + // Verify content is correct + var content = await FileSystem.File.ReadAllTextAsync(yamlFile, TestContext.Current.CancellationToken); + content.Should().Contain("Test BOM handling"); + content.Should().Contain("type: feature"); + } } diff --git a/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrepareArtifactServiceTests.cs b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrepareArtifactServiceTests.cs index 23ec00ba79..6e8450bba0 100644 --- a/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrepareArtifactServiceTests.cs +++ b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrepareArtifactServiceTests.cs @@ -2,11 +2,13 @@ // 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.Text; using System.Text.Json; using Actions.Core.Services; using AwesomeAssertions; using Elastic.Changelog.Evaluation; using Elastic.Changelog.Tests.Changelogs; +using Elastic.Changelog.Utilities; using Elastic.Documentation.Configuration; using Elastic.Documentation.ReleaseNotes; using FakeItEasy; @@ -242,6 +244,59 @@ public async Task PrepareArtifact_MissingStagingYaml_StatusError() metadata.Status.Should().Be("error"); } + [Fact] + public async Task PrepareArtifact_WithBomPrefixedYaml_NormalizesOutput() + { + // Arrange + await SetupConfig(); + FileSystem.Directory.CreateDirectory(StagingDir); + + // Create YAML with BOM prefix + const string yamlContent = """ + title: Test changelog + type: feature + products: + - product: elasticsearch + target: 9.1.0 + lifecycle: ga + """; + + var contentWithBom = ChangelogUtf8Normalization.Utf8BomChar + yamlContent; + var stagingYaml = Path.Join(StagingDir, "changelog.yaml"); + await FileSystem.File.WriteAllTextAsync(stagingYaml, contentWithBom, Encoding.UTF8, CancellationToken.None); + + // Verify staging file has BOM + var stagingBytes = await FileSystem.File.ReadAllBytesAsync(stagingYaml, CancellationToken.None); + ChangelogUtf8Normalization.HasUtf8Bom(stagingBytes).Should().BeTrue("staging file should contain BOM"); + + var service = CreateService(); + var args = DefaultArgs() with + { + EvaluateStatus = "proceed", + GenerateOutcome = "success" + }; + + // Act + await service.PrepareArtifact(Collector, args, CancellationToken.None); + + // Assert + var outputYaml = Path.Join(OutputDir, "changelog.yaml"); + FileSystem.File.Exists(outputYaml).Should().BeTrue("output YAML file should exist"); + + // Verify output file does not contain BOM + var outputBytes = await FileSystem.File.ReadAllBytesAsync(outputYaml, CancellationToken.None); + ChangelogUtf8Normalization.HasUtf8Bom(outputBytes).Should().BeFalse("output file should not contain UTF-8 BOM"); + + // Verify content is preserved + var outputContent = await FileSystem.File.ReadAllTextAsync(outputYaml, CancellationToken.None); + outputContent.Should().Contain("Test changelog"); + outputContent.Should().Contain("type: feature"); + + var metadata = ReadMetadata(); + metadata.Status.Should().Be("success"); + metadata.ChangelogFilename.Should().Be("changelog.yaml"); + } + [Theory] [InlineData("proceed", "success", PrEvaluationResult.Success)] [InlineData("proceed", "failure", PrEvaluationResult.Error)] diff --git a/tests/Elastic.Changelog.Tests/Utilities/ChangelogUtf8NormalizationTests.cs b/tests/Elastic.Changelog.Tests/Utilities/ChangelogUtf8NormalizationTests.cs new file mode 100644 index 0000000000..ce94db676a --- /dev/null +++ b/tests/Elastic.Changelog.Tests/Utilities/ChangelogUtf8NormalizationTests.cs @@ -0,0 +1,155 @@ +// 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 AwesomeAssertions; +using Elastic.Changelog.Utilities; + +namespace Elastic.Changelog.Tests.Utilities; + +public class ChangelogUtf8NormalizationTests +{ + [Fact] + public void StripLeadingUtf8BomChar_EmptyString_ReturnsEmpty() + { + var result = ChangelogUtf8Normalization.StripLeadingUtf8BomChar(string.Empty); + + result.Should().Be(string.Empty); + } + + [Fact] + public void StripLeadingUtf8BomChar_NullString_ReturnsNull() + { + var result = ChangelogUtf8Normalization.StripLeadingUtf8BomChar(null!); + + result.Should().BeNull(); + } + + [Fact] + public void StripLeadingUtf8BomChar_StringWithoutBom_ReturnsUnchanged() + { + const string input = "type: feature\ntitle: Test"; + + var result = ChangelogUtf8Normalization.StripLeadingUtf8BomChar(input); + + result.Should().Be(input); + } + + [Fact] + public void StripLeadingUtf8BomChar_StringWithLeadingBom_RemovesBom() + { + const string content = "type: feature\ntitle: Test"; + var input = ChangelogUtf8Normalization.Utf8BomChar + content; + + var result = ChangelogUtf8Normalization.StripLeadingUtf8BomChar(input); + + result.Should().Be(content); + } + + [Fact] + public void StripLeadingUtf8BomChar_StringOnlyBom_ReturnsEmpty() + { + var input = ChangelogUtf8Normalization.Utf8BomChar.ToString(); + + var result = ChangelogUtf8Normalization.StripLeadingUtf8BomChar(input); + + result.Should().Be(string.Empty); + } + + [Fact] + public void StripLeadingUtf8BomChar_StringWithBomInMiddle_DoesNotChange() + { + var input = $"type: feature{ChangelogUtf8Normalization.Utf8BomChar}title: Test"; + + var result = ChangelogUtf8Normalization.StripLeadingUtf8BomChar(input); + + result.Should().Be(input); + } + + [Fact] + public void StripLeadingUtf8BomChar_StringWithConsecutiveLeadingBoms_RemovesAllLeadingBoms() + { + const string content = "type: feature\ntitle: Test"; + // Two consecutive BOM characters at the start + var input = ChangelogUtf8Normalization.Utf8BomChar.ToString() + + ChangelogUtf8Normalization.Utf8BomChar + content; + + var result = ChangelogUtf8Normalization.StripLeadingUtf8BomChar(input); + + result.Should().Be(content); + } + + [Fact] + public void StripLeadingUtf8BomChar_StringWithThreeConsecutiveLeadingBoms_RemovesAllLeadingBoms() + { + const string content = "type: feature\ntitle: Test"; + // Three consecutive BOM characters at the start (edge case test) + var input = ChangelogUtf8Normalization.Utf8BomChar.ToString() + + ChangelogUtf8Normalization.Utf8BomChar + + ChangelogUtf8Normalization.Utf8BomChar + content; + + var result = ChangelogUtf8Normalization.StripLeadingUtf8BomChar(input); + + result.Should().Be(content); + } + + [Fact] + public void HasUtf8Bom_EmptySpan_ReturnsFalse() + { + var bytes = ReadOnlySpan.Empty; + + var result = ChangelogUtf8Normalization.HasUtf8Bom(bytes); + + result.Should().BeFalse(); + } + + [Fact] + public void HasUtf8Bom_TooShortSpan_ReturnsFalse() + { + var bytes = new ReadOnlySpan([0xEF, 0xBB]); + + var result = ChangelogUtf8Normalization.HasUtf8Bom(bytes); + + result.Should().BeFalse(); + } + + [Fact] + public void HasUtf8Bom_ValidBomBytes_ReturnsTrue() + { + var bytes = new ReadOnlySpan([0xEF, 0xBB, 0xBF, 0x74, 0x79]); + + var result = ChangelogUtf8Normalization.HasUtf8Bom(bytes); + + result.Should().BeTrue(); + } + + [Fact] + public void HasUtf8Bom_ExactBomBytes_ReturnsTrue() + { + var bytes = new ReadOnlySpan([0xEF, 0xBB, 0xBF]); + + var result = ChangelogUtf8Normalization.HasUtf8Bom(bytes); + + result.Should().BeTrue(); + } + + [Fact] + public void HasUtf8Bom_InvalidBomBytes_ReturnsFalse() + { + var bytes = new ReadOnlySpan([0xEF, 0xBB, 0xBE, 0x74, 0x79]); + + var result = ChangelogUtf8Normalization.HasUtf8Bom(bytes); + + result.Should().BeFalse(); + } + + [Fact] + public void HasUtf8Bom_NormalYamlBytes_ReturnsFalse() + { + var bytes = new ReadOnlySpan([0x74, 0x79, 0x70, 0x65]); + + var result = ChangelogUtf8Normalization.HasUtf8Bom(bytes); + + result.Should().BeFalse(); + } +} diff --git a/tests/Elastic.Documentation.Configuration.Tests/Text/Utf8TextNormalizationTests.cs b/tests/Elastic.Documentation.Configuration.Tests/Text/Utf8TextNormalizationTests.cs new file mode 100644 index 0000000000..576f92ca7b --- /dev/null +++ b/tests/Elastic.Documentation.Configuration.Tests/Text/Utf8TextNormalizationTests.cs @@ -0,0 +1,176 @@ +// 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 AwesomeAssertions; +using Elastic.Documentation.Text; + +namespace Elastic.Documentation.Configuration.Tests.Text; + +public class Utf8TextNormalizationTests +{ + [Fact] + public void StripLeadingUtf8Bom_EmptyString_ReturnsEmpty() + { + var result = Utf8TextNormalization.StripLeadingUtf8Bom(string.Empty); + + result.Should().Be(string.Empty); + } + + [Fact] + public void StripLeadingUtf8Bom_NullString_ReturnsNull() + { + var result = Utf8TextNormalization.StripLeadingUtf8Bom(null); + + result.Should().BeNull(); + } + + [Fact] + public void StripLeadingUtf8Bom_StringWithoutBom_ReturnsUnchanged() + { + const string input = "type: feature\ntitle: Test changelog entry"; + + var result = Utf8TextNormalization.StripLeadingUtf8Bom(input); + + result.Should().BeSameAs(input); // Should return the same instance for efficiency + } + + [Fact] + public void StripLeadingUtf8Bom_StringWithSingleLeadingBom_RemovesBom() + { + const string content = "type: feature\ntitle: Test changelog entry"; + var input = Utf8TextNormalization.Utf8BomChar + content; + + var result = Utf8TextNormalization.StripLeadingUtf8Bom(input); + + result.Should().Be(content); + } + + [Fact] + public void StripLeadingUtf8Bom_StringWithTwoConsecutiveLeadingBoms_RemovesBothBoms() + { + const string content = "type: feature\ntitle: Test changelog entry"; + var input = Utf8TextNormalization.Utf8BomChar.ToString() + + Utf8TextNormalization.Utf8BomChar + content; + + var result = Utf8TextNormalization.StripLeadingUtf8Bom(input); + + result.Should().Be(content); + } + + [Fact] + public void StripLeadingUtf8Bom_StringWithThreeConsecutiveLeadingBoms_RemovesAllBoms() + { + const string content = "type: feature\ntitle: Test changelog entry"; + var input = Utf8TextNormalization.Utf8BomChar.ToString() + + Utf8TextNormalization.Utf8BomChar + + Utf8TextNormalization.Utf8BomChar + content; + + var result = Utf8TextNormalization.StripLeadingUtf8Bom(input); + + result.Should().Be(content); + } + + [Fact] + public void StripLeadingUtf8Bom_StringOnlyBoms_ReturnsEmpty() + { + var input = Utf8TextNormalization.Utf8BomChar.ToString() + + Utf8TextNormalization.Utf8BomChar; + + var result = Utf8TextNormalization.StripLeadingUtf8Bom(input); + + result.Should().Be(string.Empty); + } + + [Fact] + public void StripLeadingUtf8Bom_StringWithBomInMiddle_DoesNotStripMiddleBom() + { + var input = $"type: feature{Utf8TextNormalization.Utf8BomChar}title: Test"; + + var result = Utf8TextNormalization.StripLeadingUtf8Bom(input); + + result.Should().Be(input); + } + + [Fact] + public void StripLeadingUtf8Bom_StringWithBomAtEnd_DoesNotStripEndBom() + { + var input = $"type: feature\ntitle: Test{Utf8TextNormalization.Utf8BomChar}"; + + var result = Utf8TextNormalization.StripLeadingUtf8Bom(input); + + result.Should().Be(input); + } + + [Fact] + public void HasUtf8Bom_EmptySpan_ReturnsFalse() + { + var bytes = ReadOnlySpan.Empty; + + var result = Utf8TextNormalization.HasUtf8Bom(bytes); + + result.Should().BeFalse(); + } + + [Fact] + public void HasUtf8Bom_TooShortSpan_ReturnsFalse() + { + var bytes = new ReadOnlySpan([0xEF, 0xBB]); + + var result = Utf8TextNormalization.HasUtf8Bom(bytes); + + result.Should().BeFalse(); + } + + [Fact] + public void HasUtf8Bom_ValidBomBytes_ReturnsTrue() + { + var bytes = new ReadOnlySpan([0xEF, 0xBB, 0xBF, 0x74, 0x79]); + + var result = Utf8TextNormalization.HasUtf8Bom(bytes); + + result.Should().BeTrue(); + } + + [Fact] + public void HasUtf8Bom_ExactBomBytes_ReturnsTrue() + { + var bytes = new ReadOnlySpan([0xEF, 0xBB, 0xBF]); + + var result = Utf8TextNormalization.HasUtf8Bom(bytes); + + result.Should().BeTrue(); + } + + [Fact] + public void HasUtf8Bom_InvalidBomBytes_ReturnsFalse() + { + var bytes = new ReadOnlySpan([0xEF, 0xBB, 0xBE, 0x74, 0x79]); + + var result = Utf8TextNormalization.HasUtf8Bom(bytes); + + result.Should().BeFalse(); + } + + [Fact] + public void HasUtf8Bom_NormalTextBytes_ReturnsFalse() + { + var bytes = new ReadOnlySpan([0x74, 0x79, 0x70, 0x65]); + + var result = Utf8TextNormalization.HasUtf8Bom(bytes); + + result.Should().BeFalse(); + } + + [Fact] + public void Utf8BomChar_MatchesExpectedValue() + { + Utf8TextNormalization.Utf8BomChar.Should().Be('\uFEFF'); + } + + [Fact] + public void Utf8BomBytes_MatchesExpectedSequence() + { + Utf8TextNormalization.Utf8BomBytes.Should().Equal([0xEF, 0xBB, 0xBF]); + } +} From 5307c3114e5217e298130673e04d2a0f6782b9a1 Mon Sep 17 00:00:00 2001 From: "elastic-observability-automation[bot]" <180520183+elastic-observability-automation[bot]@users.noreply.github.com> Date: Thu, 7 May 2026 10:00:00 -0400 Subject: [PATCH 41/50] [Automation] Bump product version numbers (#3257) --- config/versions.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/config/versions.yml b/config/versions.yml index 89f4b3f18b..865f521458 100644 --- a/config/versions.yml +++ b/config/versions.yml @@ -91,7 +91,7 @@ versioning_systems: current: 1.11.0 edot-node: base: 1.0 - current: 1.12.0 + current: 1.13.0 edot-php: base: 1.0 current: 1.4.0 @@ -155,19 +155,19 @@ versioning_systems: current: 9.4.0 elasticsearch-client-javascript: base: 9.0 - current: 9.3.4 + current: 9.4.0 elasticsearch-client-dotnet: base: 9.0 - current: 9.3.6 + current: 9.4.0 elasticsearch-client-php: base: 9.0 - current: 9.3.0 + current: 9.4.0 elasticsearch-client-python: base: 9.0 - current: 9.3.0 + current: 9.4.0 elasticsearch-client-ruby: base: 9.0 - current: 9.3.1 + current: 9.4.0 elasticsearch-client-rust: base: 9.0 current: 9.1.0-alpha.1 From 8ca38c46edf7ffe6c7caf36a22afd1f8f7684907 Mon Sep 17 00:00:00 2001 From: Lisa Cawley Date: Thu, 7 May 2026 07:33:18 -0700 Subject: [PATCH 42/50] Edit changelog bundle docs (#3213) --- config/changelog.example.yml | 2 +- docs/cli/changelog/add.md | 3 +- docs/cli/changelog/bundle.md | 523 ++++++-------- docs/cli/changelog/gh-release.md | 2 +- docs/cli/changelog/remove.md | 105 +-- docs/contribute/bundle-changelogs.md | 741 ++++++++------------ docs/contribute/configure-changelogs-ref.md | 135 +++- docs/contribute/configure-changelogs.md | 44 +- docs/contribute/create-changelogs.md | 24 +- docs/syntax/changelog.md | 5 +- 10 files changed, 671 insertions(+), 913 deletions(-) diff --git a/config/changelog.example.yml b/config/changelog.example.yml index 859dbe2a4a..e02f65074a 100644 --- a/config/changelog.example.yml +++ b/config/changelog.example.yml @@ -151,7 +151,7 @@ pivot: # - Comma-separated string: "value1, value2, value3" # - YAML list: [value1, value2, value3] # -# For details and examples, refer to the [rules documentation](https://github.com/elastic/docs-builder/blob/main/docs/contribute/configure-changelogs.md#rules). +# For details and examples, refer to the [rules documentation](https://github.com/elastic/docs-builder/blob/main/docs/contribute/configure-changelogs-ref.md#rules). rules: # match: any diff --git a/docs/cli/changelog/add.md b/docs/cli/changelog/add.md index 710fcb9f2f..f38a9eaf38 100644 --- a/docs/cli/changelog/add.md +++ b/docs/cli/changelog/add.md @@ -88,6 +88,7 @@ docs-builder changelog add [options...] [-h|--help] : Mutually exclusive with `--prs`, `--issues`, and `--release-version`. : For a plain newline-delimited list of fully-qualified PR URLs, use `--prs` with a file path instead of `--report`. : When the value is an `https://` URL, only hosts allowed by the parser (such as `github.com` and `buildkite.com`) are supported, and the CLI needs network access to fetch the report. + `--release-version ` : Optional: GitHub release tag to use as a source of pull requests (for example, `"v9.2.0"` or `"latest"`). : When specified, the command fetches the release from GitHub, parses PR references from the release notes, and creates one changelog file per PR — without creating a bundle. Only automated GitHub release notes (the default format or [Release Drafter](https://github.com/release-drafter/release-drafter) format) are supported at this time. @@ -207,7 +208,7 @@ If a configuration file exists, the command validates its values before generati In each of these cases where validation fails, a changelog file is not created. If the configuration file contains `rules.create` definitions and a PR or issue has a blocking label, that PR is skipped and no changelog file is created for it. -For more information, refer to [Rules for creation and publishing](/contribute/configure-changelogs.md#rules). +For more information, refer to [](/contribute/create-changelogs.md#rules). ## CI auto-detection [ci-auto-detection] diff --git a/docs/cli/changelog/bundle.md b/docs/cli/changelog/bundle.md index ec73417875..3a1e0628fe 100644 --- a/docs/cli/changelog/bundle.md +++ b/docs/cli/changelog/bundle.md @@ -86,11 +86,15 @@ You must choose one method for determining what's in the bundle (`--all`, `--inp : - **Profile-based mode**: Requires either a version argument OR `output_products` in the profile configuration `--hide-features ` -: Optional: A list of feature IDs (comma-separated), or a path to a newline-delimited file containing feature IDs. +: Optional: A list of feature IDs (comma-separated) or a path to a newline-delimited file containing feature IDs. : Can be specified multiple times. : Adds a `hide-features` list to the bundle. : When the bundle is rendered (by the `changelog render` command or `{changelog}` directive), changelogs with matching `feature-id` values will be commented out of the documentation. +:::{note} +The `--hide-features` option on the `render` command and the `hide-features` field in bundles are **combined**. If you specify `--hide-features` on both the `bundle` and `render` commands, all specified features are hidden. The `{changelog}` directive automatically reads `hide-features` from all loaded bundles and applies them. +::: + `--input-products ?>` : Filter by products in the format "product target lifecycle, ...". : For more information about the valid product and lifecycle values, go to [Product format](#product-format). @@ -103,11 +107,12 @@ You must choose one method for determining what's in the bundle (`--all`, `--inp - `"* * *"` - match all changelogs (equivalent to `--all`) :::{note} -The `--input-products` option determines which changelog files are gathered for consideration. **`rules.bundle` is not disabled** when you use `--input-products` — global `include_products` / `exclude_products`, type/area rules, and (when configured) per-product rules still run **after** matching, unless your configuration is in no-filtering mode per [Bundle rules](/contribute/configure-changelogs-ref.md#rules-bundle). The only “mutually exclusive” pairing on this command is **profile-based** bundling versus **option-based** flags (see [Usage](#usage)), not `--input-products` versus `rules.bundle`. +The `--input-products` option determines which changelog files are gathered for consideration. +Bundle rules are not turned off when you use `--input-products`-- they run **after** matching, unless your configuration is in no-filtering mode per [Bundle rules](/contribute/configure-changelogs-ref.md#rules-bundle). ::: `--issues ` -: Filter by issue URLs (comma-separated), or a path to a newline-delimited file. Can be specified multiple times. +: Include changelogs for the specified issue URLs (comma-separated), or a path to a newline-delimited file. Can be specified multiple times. : Each occurrence can be either comma-separated issues ( `--issues "https://github.com/owner/repo/issues/123,456"`) or a file path (for example `--issues /path/to/file.txt`). : When using a file, every line must be a fully-qualified GitHub issue URL such as `https://github.com/owner/repo/issues/123`. Bare numbers and short forms are not allowed in files. @@ -123,31 +128,33 @@ The `--input-products` option determines which changelog files are gathered for : Optional: Explicitly set the products array in the output file in format "product target lifecycle, ...". : This value replaces information that would otherwise be derived from changelogs. : For more information about the valid product and lifecycle values, go to [Product format](#product-format). -: 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-ref.md#rules-bundle-products). +: When `rules.bundle.products` per-product overrides are configured, `--output-products` also supplies the product IDs used to determine the **rule context product** (if there are multiple, the first ID alphabetically is used). Refer to [Product-specific bundle rules](/contribute/configure-changelogs-ref.md#rules-bundle-products). + +:::{tip} +Though technically optional, it is strongly recommended to set `--output-products` ( or `output_products` for profiles) so that you have a single clean product entry that reflects the context of the release. +::: `--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`. `--prs ` -: Filter by pull request URLs (comma-separated) or a path to a newline-delimited file. Can be specified multiple times. +: Include changelogs for the specified pull request URLs (comma-separated) or a path to a newline-delimited file. Can be specified multiple times. : Each occurrence can be either comma-separated PRs (for example `--prs "https://github.com/owner/repo/pull/123,6789"`) or a file path (for example `--prs /path/to/file.txt`). : When using a file, every line must be a fully-qualified GitHub PR URL such as `https://github.com/owner/repo/pull/123`. Bare numbers and short forms are not allowed in files. -`--release-version ` -: GitHub release tag to use as a source of pull requests (for example, `"v9.2.0"` or `"latest"`). +`--release-version ` +: Bundle changelogs for the pull requests in GitHub release notes. For example, the tag can be `"v9.2.0"` or `"latest"`. : When specified, the command fetches the release from GitHub, parses PR references from the release notes, and uses them as the bundle filter. Only automated GitHub release notes (the default format or [Release Drafter](https://github.com/release-drafter/release-drafter) format) are supported at this time. : Requires repo (`--repo` or `bundle.repo` in `changelog.yml`) and owner (`--owner` flag > `bundle.owner` in `changelog.yml` > `elastic`) details. : When `--output-products` is not specified, the products array in the bundle is derived from the matched changelog files' own `products` fields, consistent with all other filter options. @@ -157,14 +164,22 @@ The `--input-products` option determines which changelog files are gathered for : Falls back to `bundle.repo` in `changelog.yml` when not specified; if that is also absent, the product ID is used. `--report ` -: Filter by pull requests extracted from a promotion report. Accepts a URL or a local file path. +: Include changelogs based on the pull requests in a promotion report. Accepts a URL or a local file path. : The report can be an HTML page from Buildkite or any file containing GitHub PR URLs. `--resolve` : Optional: Copy the contents of each changelog file into the entries array. : By default, the bundle contains only the file names and checksums. -## Output files +## File paths and filenames [output-files] + +**Input directory** (where changelog YAML files are read from) follows the same fallback for both modes, minus the explicit CLI override that is forbidden in profile mode: + +| Priority | Profile-based | Option-based | +|----------|---------------|--------------| +| 1 | `bundle.directory` in `changelog.yml` | `--directory` CLI option | +| 2 | Current working directory | `bundle.directory` in `changelog.yml` | +| 3 | — | Current working directory | Both modes use the same ordered fallback to determine where to write the bundle. The first value that is set wins: @@ -178,14 +193,6 @@ Both modes use the same ordered fallback to determine where to write the bundle. | 4 | Current working directory | `bundle.directory` in `changelog.yml` | | 5 | — | Current working directory | -**Input directory** (where changelog YAML files are read from) follows the same fallback for both modes, minus the explicit CLI override that is forbidden in profile mode: - -| Priority | Profile-based | Option-based | -|----------|---------------|--------------| -| 1 | `bundle.directory` in `changelog.yml` | `--directory` CLI option | -| 2 | Current working directory | `bundle.directory` in `changelog.yml` | -| 3 | — | Current working directory | - **Bundle filename** is determined by the `bundle.profiles..output` setting (profile-based) or defaults to `changelog-bundle.yaml` (both modes). The profile `output` setting can include additional path segments. For example: `"stack/kibana-{version}.yaml"`. @@ -228,6 +235,8 @@ For example: - `"cloud-serverless 2025-08-05"` - `"cloud-enterprise 4.0.3, cloud-hosted 2025-10-31"` +If you use `"* * *"` in the `--input-products` command option or `bundle.profiles..products` configuration setting, it's equivalent to the `--all` command option. + ## Repository name in bundles [changelog-bundle-repo] The repository name is stored in each bundle product entry to ensure that PR and issue links are generated correctly when the bundle is rendered. @@ -268,57 +277,6 @@ If no `repo` is set at any level, the product ID is used as a fallback for link This may result in broken links if the product ID doesn't match the GitHub repository name (for example, `cloud-serverless` product ID in the `elasticsearch` repo). ::: -## Rules for filtered bundles [changelog-bundle-rules] - -The `rules.bundle` section in the changelog configuration file lets you filter entries during bundling. It applies to both `changelog bundle` and `changelog gh-release`, after entries are matched by the primary filter (`--prs`, `--issues`, `--all`, **`--input-products`**, and so on) and before the bundle is written. - -Which `rules.bundle` fields take effect depends on the bundle rule modes (no filtering, global rules against each changelog’s content, or per-product rule context). Input stage (gathering entries) and bundle filtering stage (filtering for output) are conceptually separate. For more information, refer to [bundle rules](/contribute/configure-changelogs-ref.md#rules-bundle). - -The following fields are supported: - -`exclude_products` -: A product ID or list of product IDs to exclude from the bundle. Cannot be combined with `include_products`. - -`include_products` -: A product ID or list of product IDs to include in the bundle (all others are excluded). Cannot be combined with `exclude_products`. - -`match_products` -: Match mode for the product filter (`any`, `all`, or `conjunction`). Inherits from `rules.match` when not specified. - -`exclude_types` -: A changelog type or list of types to exclude from the bundle. - -`include_types` -: Only changelogs with these types are kept; all others are excluded. - -`exclude_areas` -: A changelog area or list of areas to exclude from the bundle. - -`include_areas` -: Only changelogs with these areas are kept; all others are excluded. - -`match_areas` -: Match mode for the area filter (`any`, `all`, or `conjunction`). Inherits from `rules.match` when not specified. - -`products` -: Per-product filter overrides for **all filter types** (product, type, area). Keys are product IDs (or comma-separated lists). -: When this map is **non-empty**, the bundler uses **per-product rule context** mode: global `rules.bundle` product/type/area fields are **not** used for filtering (repeat constraints under each product key if you still need them). -: For details, refer to [Bundle rules](/contribute/configure-changelogs-ref.md#rules-bundle). - -```yaml -rules: - bundle: - exclude_products: cloud-enterprise - exclude_types: deprecation - exclude_areas: - - Internal - products: - cloud-serverless: - include_areas: - - "Search" - - "Monitoring" -``` - ## PR and issue link allowlist [link-allowlist] A changelog in a public repository might contain links to pull requests or issues in repositories that should not appear in published documentation. @@ -341,58 +299,186 @@ Sentinel values are omitted from rendered documentation but remain in bundle fil ## Option-based examples -### Bundle by report or URL list +### Bundle by GitHub release [changelog-bundle-release-version] -You can use `--report` to filter by a promotion report: +You can use `--release-version` to fetch pull request references directly from GitHub release notes and use them as the bundle filter. +This is equivalent to building a PR list file manually and passing it with `--prs`, but without any file management. + +:::{important} +Only automated GitHub release notes (the default format or [Release Drafter](https://github.com/release-drafter/release-drafter) format) are supported at this time. +::: ```sh -# Extract PRs from a downloaded report and use them as the filter docs-builder changelog bundle \ - --report ./promotion-report.html \ - --directory ./docs/changelog \ - --output ./docs/releases/bundle.yaml + --release-version v1.34.0 \ <1> + --repo apm-agent-dotnet \ <2> + --owner elastic <3> + --output-products "apm-agent-dotnet 1.34.0 ga" <4> ``` -By default all changelogs that match PRs in the promotion report are included in the bundle. -To apply additional filtering by the changelog type, areas, or products, add `rules.bundle` [filters](#changelog-bundle-rules). +1. The tag value that is used in the `GET /repos/{owner}/{repo}/releases/tags/{tag}` releases API. +2. You must specify `--repo` or set `bundle.repo` in the changelog configuration file. +3. If you don't specify `--owner`, it uses `bundle.owner` in the changelog configuration or else defaults to `elastic`. +4. The bundle's product metadata is inferred automatically from the release tag and repository name; you can override that behavior with the `--output-products` option. -### Bundle by GitHub release [changelog-bundle-release-version] +:::{note} +`--release-version` requires a `GITHUB_TOKEN` or `GH_TOKEN` environment variable (or an active `gh` login) to fetch release details from the GitHub API. +::: -You can use `--release-version` to fetch pull request references directly from GitHub release notes and use them as the bundle filter. -This is equivalent to building a PR list file manually and passing it with `--prs`, but without any file management. +By default all changelogs that match PRs in the GitHub release notes are included in the bundle. +To apply additional filtering by the changelog type, areas, or products, add [rules.bundle](/contribute/configure-changelogs-ref.md#rules-bundle) configuration settings. -:::{important} -Only automated GitHub release notes (the default format or [Release Drafter](https://github.com/release-drafter/release-drafter) format) are supported at this time. +:::{tip} +If you are not creating changelogs when you create your pull requests, consider the `docs-builder changelog gh-release` command as a one-shot alternative to the `changelog add` and `changelog bundle` commands. +It parses the release notes, creates one changelog file per pull request found, and creates a `changelog-bundle.yaml` file — all in a single step. Refer to [](/cli/changelog/gh-release.md) ::: +### Bundle by issues [changelog-bundle-issues] + +You can use the `--issues` option to create a bundle of changelogs that relate to those GitHub issues. +Issues can be identified by a full URL (such as `https://github.com/owner/repo/issues/123`), a short format (such as `owner/repo#123`), or just a number (in which case `--owner` and `--repo` are required — or set via `bundle.owner` and `bundle.repo` in the configuration). + ```sh -docs-builder changelog bundle \ - --release-version v1.34.0 \ - --repo apm-agent-dotnet \ <1> - --owner elastic <2> +docs-builder changelog bundle --issues "12345,12346" \ + --repo elasticsearch \ + --owner elastic \ + --output-products "elasticsearch 9.2.2 ga" +``` + +Alternatively, you can specify a path to a newline-delimited file that contains the issue URLS (for example, `--issues /path/to/file.txt`). +In this case, you cannot use short URLs or numbers, each line must have a full URL. + +By default all changelogs that match issues in the list are included in the bundle. +To apply additional filtering by the changelog type, areas, or products, add [rules.bundle](/contribute/configure-changelogs-ref.md#rules-bundle) configuration settings. + + +### Bundle by pull requests [changelog-bundle-pr] + +You can use the `--prs` option to create a bundle of the changelogs that relate to those pull requests. + +Pull requests can be identified by a full URL (such as `https://github.com/owner/repo/pull/123`), a short format (such as `owner/repo#123`), or just a number. + +```sh +docs-builder changelog bundle --prs "108875,135873,136886" \ <1> + --repo elasticsearch \ <2> + --owner elastic \ <3> + --output-products "elasticsearch 9.2.2 ga" <4> ``` -1. You must specify `--repo` or set `bundle.repo` in the changelog configuration file. -2. If you don't specify `--owner`, it uses `bundle.owner` in the changelog configuration or else defaults to `elastic`. +1. The comma-separated list of pull request numbers to seek. +2. The repository in the pull request URLs. Not required when using full PR URLs, or when `bundle.repo` is set in the changelog configuration. +3. The owner in the pull request URLs. Not required when using full PR URLs, or when `bundle.owner` is set in the changelog configuration. +4. The product metadata for the bundle. If it is not provided, it will be derived from all the changelog product values. -Without `--output-products`, the products array in the bundle is derived from the matched changelog files' own `products` fields — the same behavior as `--prs`, `--issues`, `--report`, and `--all`. -Use `--output-products` when you need a single, authoritative product entry that reflects the release identity rather than the diverse metadata across individual changelog files. +Alternatively, you can specify a path to a newline-delimited file that contains the PR URLS (for example, `--prs /path/to/file.txt`). +In this case, you cannot use short URLs or numbers, each line must have a full URL. For example: +```txt +https://github.com/elastic/elasticsearch/pull/108875 +https://github.com/elastic/elasticsearch/pull/135873 +https://github.com/elastic/elasticsearch/pull/136886 +https://github.com/elastic/elasticsearch/pull/137126 +``` + +By default all changelogs that match PRs in the list are included in the bundle. +To apply additional filtering by the changelog type, areas, or products, add [rules.bundle](/contribute/configure-changelogs-ref.md#rules-bundle) configuration settings. + +If you have changelog files that reference those pull requests, the command creates a file like this: + +```yaml +products: +- product: elasticsearch + target: 9.2.2 + lifecycle: ga +entries: +- file: + name: 1765507819-fix-ml-calendar-event-update-scalability-issues.yaml + checksum: 069b59edb14594e0bc3b70365e81626bde730ab7 +- file: + name: 1765507798-convert-bytestransportresponse-when-proxying-respo.yaml + checksum: c6dbd4730bf34dbbc877c16c042e6578dd108b62 +- file: + name: 1765507839-use-ivf_pq-for-gpu-index-build-for-large-datasets.yaml + checksum: 451d60283fe5df426f023e824339f82c2900311e +``` + +### Bundle by product [changelog-bundle-product] + +You can use the `--input-products` option to create a bundle of changelogs that match the product details. +When using `--input-products`, you must provide all three parts: product, target, and lifecycle. +Each part can be a wildcard (`*`) to match any value. + +:::{tip} +If you use profile-based bundling, provide this information in the `bundle.profiles..products` field. +::: + ```sh docs-builder changelog bundle \ - --release-version v1.34.0 \ - --output-products "apm-agent-dotnet 1.34.0 ga" + --input-products "cloud-serverless 2025-12-02 ga, cloud-serverless 2025-12-06 beta" <1> +``` + +1. Include all changelogs that have the `cloud-serverless` product identifier with target dates of either December 2 2025 (lifecycle `ga`) or December 6 2025 (lifecycle `beta`). For more information about product values, refer to [Product format](/cli/changelog/bundle.md#product-format). + +You can use wildcards in any of the three parts: + +```sh +# Bundle any changelogs that have exact matches for either of these clauses +docs-builder changelog bundle --input-products "cloud-serverless 2025-12-02 ga, elasticsearch 9.3.0 beta" + +# Bundle all elasticsearch changelogs regardless of target or lifecycle +docs-builder changelog bundle --input-products "elasticsearch * *" + +# Bundle all cloud-serverless 2025-12-02 changelogs with any lifecycle +docs-builder changelog bundle --input-products "cloud-serverless 2025-12-02 *" + +# Bundle any cloud-serverless changelogs with target starting with "2025-11-" and "ga" lifecycle +docs-builder changelog bundle --input-products "cloud-serverless 2025-11-* ga" + +# Bundle all changelogs (equivalent to --all) +docs-builder changelog bundle --input-products "* * *" ``` +If you have changelog files that reference those product details, the command creates a file like this: + +```yaml +products: <1> +- product: cloud-serverless + target: 2025-12-02 +- product: cloud-serverless + target: 2025-12-06 +entries: +- file: + name: 1765495972-fixes-enrich-and-lookup-join-resolution-based-on-m.yaml + checksum: 6c3243f56279b1797b5dfff6c02ebf90b9658464 +- file: + name: 1765507778-break-on-fielddata-when-building-global-ordinals.yaml + checksum: 70d197d96752c05b6595edffe6fe3ba3d055c845 +``` + +1. By default these values match your `--input-products` (even if the changelogs have more products). +To specify different product metadata, use the `--output-products` option. + :::{note} -`--release-version` requires a `GITHUB_TOKEN` or `GH_TOKEN` environment variable (or an active `gh` login) to fetch release details from the GitHub API. +When a changelog matches multiple `--input-products` filters, it appears only once in the bundle. This deduplication applies even when using `--all` or `--prs`. ::: -By default all changelogs that match PRs in the GitHub release notes are included in the bundle. -To apply additional filtering by the changelog type, areas, or products, add `rules.bundle` [filters](#changelog-bundle-rules). +### Bundle by report + +You can use `--report` to filter by a promotion report: + +```sh +# Extract PRs from a downloaded report and use them as the filter +docs-builder changelog bundle \ + --report ./promotion-report.html \ + --directory ./docs/changelog \ + --output ./docs/releases/bundle.yaml +``` -### Bundle with description +By default all changelogs that match PRs in the promotion report are included in the bundle. +To apply additional filtering by the changelog type, areas, or products, add [rules.bundle](/contribute/configure-changelogs-ref.md#rules-bundle) configuration settings. + +### Bundle descriptions You can add a description to bundles using the `--description` option. For simple descriptions, use regular quotes: @@ -419,7 +505,7 @@ docs-builder changelog bundle \ --description "Elasticsearch {version} includes performance improvements. Download: https://github.com/{owner}/{repo}/releases/tag/v{version}" ``` -### Bundle with release date +### Bundle release dates You can add a `release-date` field directly to a bundle YAML file. This field is optional and purely informative for end-users. It is especially useful for components released outside the usual stack lifecycle, such as APM agents and EDOT agents. @@ -440,65 +526,58 @@ When the bundle is rendered (by the `changelog render` command or `{changelog}` ## Profile-based examples -When the changelog configuration file defines `bundle.profiles`, you can use those profiles with the `changelog bundle` command. +When the changelog configuration file defines [bundle.profiles](/contribute/configure-changelogs-ref.md#bundle-profiles), you can use those profiles with the `changelog bundle` command. -### Profile configuration fields [changelog-bundle-profile-config] +Refer to [](/contribute/bundle-changelogs.md#create-profiles) for examples. -If you're using profile-based commands, they're affected by the following fields in the `bundle.profiles` section of the changelog configuration file: +### Lifecycle inference [lifecycle-inference] -`source` -: Optional. When set to `github_release`, the PR list is fetched automatically from the GitHub release identified by the version argument. Requires `repo` to be set at the profile or `bundle` level. Mutually exclusive with `products`. -: Example: `source: github_release` +The way that lifecycle values are inferred varies between [GitHub release profiles](#lifecycles-github) and [standard profiles](#lifecycles-standard). -`products` -: Required when filtering by product metadata (equivalent to the `--input-products` command option). -: The value `"* * *"` is equivalent to the `--all` command option. -: Not used when the filter comes from a promotion report, URL list file, or `source: github_release` — in those cases the PR or issue list determines what's included and `products` is ignored. -: Supports `{version}` and `{lifecycle}` placeholders that are substituted at runtime. -: Example: `"elasticsearch {version} {lifecycle}"` -: Refer to [](#product-format). +#### GitHub release profiles [lifecycles-github] -:::{note} -The `products` field determines which changelog files are gathered for consideration. **`rules.bundle` still applies** afterward (see the note under [`--input-products`](#options)). Input stage and bundle filtering stage are conceptually separate. -::: +For `source: github_release` profiles, the `{lifecycle}` placeholder in `output` and `output_products` is derived from the full release tag name and `{version}` is the base version extracted from that same tag. +For example: + +| Release tag | `{version}` | `{lifecycle}` | +|-------------|-------------|---------------| +| `v1.2.3` | `1.2.3` | `ga` | +| `v1.2.3-beta.1` | `1.2.3` | `beta` | +| `v1.2.3-preview.1` | `1.2.3` | `preview` | -`output` -: Optional. The output filename pattern for the bundle file. Supports `{version}` and `{lifecycle}` placeholders. -: When not set, the output path falls back in order to: `bundle.output_directory/changelog-bundle.yaml` (if `bundle.output_directory` is configured), then `changelog-bundle.yaml` in the input directory. -: Setting this is recommended so each profile produces a distinctly named file rather than overwriting the default. -: Example: `"elasticsearch-{version}.yaml"` +If the lifecycle you want to advertise cannot be inferred from the tag format — for example, because your team uses clean tags like `v1.34.1` even for pre-releases — hardcode the lifecycle directly in `output_products` instead of using the `{lifecycle}` placeholder: -`output_products` -: Optional. Overrides the products array written to the bundle output. Supports `{version}` and `{lifecycle}` placeholders. -: When **not set**, the products array is derived from the individual changelog files matched by the filter. This often produces multiple product entries (one per unique product/target/lifecycle combination across all matched files), which may not reflect a single clean release identity. -: When **set**, the products array in the bundle is exactly the value you specify, replacing anything that would be derived from the matched changelogs. Use this to publish a single, authoritative product entry with a specific version and lifecycle. -: The `{lifecycle}` placeholder is substituted at runtime with the inferred lifecycle. For `source: github_release` profiles this comes from the release tag suffix. For standard profiles it comes from the version argument. Refer to [](#changelog-bundle-standard-profile-lifecycle) and [](#changelog-bundle-github-release-profile) for details. -: If you omit lifecycle from the pattern (for example, `"elasticsearch {version}"`), the lifecycle field is omitted from the products array entirely. -: Example: `"elasticsearch {version} {lifecycle}"` or `"elasticsearch {version} ga"` to hardcode GA regardless of tag. -: Refer to [](#product-format). +```yaml +# Instead of relying on {lifecycle} inference, hardcode the lifecycle +gh-release: + source: github_release + repo: apm-agent-dotnet + output: "apm-agent-dotnet-{version}.yaml" + output_products: "apm-agent-dotnet {version} preview" +``` -`repo` -: Optional. The GitHub repository name written to each product entry in the bundle. Used by the `{changelog}` directive to generate correct PR/issue links. Only needed when the product ID doesn't match the GitHub repository name. Overrides `bundle.repo` when set. Required when `source: github_release` is used and no `bundle.repo` default is set. -: Example: `repo: elasticsearch`. +You can invoke the profile with commands like this: -`owner` -: Optional. The GitHub owner written to each product entry in the bundle. Overrides `bundle.owner` when set. -: Example: `owner: elastic` +```sh +# Bundle changelogs using the PR list from a GitHub release (source: github_release) +docs-builder changelog bundle gh-release v1.2.3 -`hide_features` -: Optional. Feature IDs to mark as hidden in the bundle output (string or list). When the bundle is rendered, entries with matching `feature-id` values are commented out. +# Use "latest" to fetch the most recent release +docs-builder changelog bundle gh-release latest +``` -### Lifecycle inference for standard profiles [changelog-bundle-standard-profile-lifecycle] +#### Standard profiles [lifecycles-standard] -If your configuration file defines a standard profile (that is to say, not a GitHub release profile), the lifecycle is inferred from the version string you pass as the second argument: +If your configuration file defines a standard profile (that is to say, not a GitHub release profile), the `{version}` is copied verbatim from your command argument and the `{lifecycle}` is derived from that value. +For example: -| Version argument | Inferred lifecycle | -|------------------|--------------------| -| `9.2.0` | `ga` | -| `9.2.0-rc.1` | `ga` | -| `9.2.0-beta.1` | `beta` | -| `9.2.0-alpha.1` | `preview` | -| `9.2.0-preview.1` | `preview` | +| Version argument | `{version}` | `{lifecycle}` | +|------------------|-------------|---------------| +| `9.2.0` | `9.2.0` | `ga` | +| `9.2.0-rc.1` | `9.2.0-rc.1` | `ga` | +| `9.2.0-beta.1` | `9.2.0-beta.1` | `beta` | +| `9.2.0-alpha.1` | `9.2.0-alpha.1` | `preview` | +| `9.2.0-preview.1` | `9.2.0-preview.1` | `preview` | For more information about acceptable product and lifecycle values, go to [Product format](#product-format). @@ -520,92 +599,9 @@ docs-builder changelog bundle serverless-report 2026-02-13 ./promotion-report.ht # Same using a URL list file instead of an HTML promotion report docs-builder changelog bundle serverless-report 2026-02-13 ./prs.txt - -# Bundle changelogs using the PR list from a GitHub release (source: github_release) -docs-builder changelog bundle elasticsearch-gh-release 9.2.0 - -# Use "latest" to fetch the most recent release -docs-builder changelog bundle elasticsearch-gh-release latest -``` - -:::{warning} -**Placeholder validation**: If your profile uses `{version}` or `{lifecycle}` placeholders in the description, you must ensure predictable substitution values: - -```sh -# ✅ Good: Version provided for placeholder substitution -docs-builder changelog bundle elasticsearch-release 9.2.0 ./report.html - -# ❌ Bad: No version, placeholders will fail unless profile has output_products -docs-builder changelog bundle elasticsearch-release ./report.html -``` - -To fix the second case, either provide a version argument or add an `output_products` pattern to your profile: - -```yaml -bundle: - profiles: - elasticsearch-release: - products: "elasticsearch * *" - output_products: "elasticsearch {version}" # Enables placeholder substitution - description: "Download: https://github.com/{owner}/{repo}/releases/tag/v{version}" ``` -::: - -### Bundle by product - -You can create profiles that are equivalent to the `--input-products` filter option, that is to say the bundle will contain only changelogs with matching `products`. -For example: - -```yaml -bundle: - # Input directory containing changelog YAML files - directory: docs/changelog - # Output directory for bundles - output_directory: docs/releases - # Whether to resolve (copy contents) by default - resolve: true - repo: elasticsearch <1> - owner: elastic - profiles: - # Collect all changelogs - release-all: - products: "* * *" <2> - output: "all.yaml" - # Find changelogs with any lifecycle and a partial date - serverless-monthly: - products: "cloud-serverless {version}-* *" <3> - output: "serverless-{version}.yaml" - output_products: "cloud-serverless {version}" - - # Find changelogs with a specific lifecycle - elasticsearch-ga-only: - products: "elasticsearch {version} ga" <4> - output: "elasticsearch-{version}.yaml" - - # Infer the lifecycle from the version - elasticsearch-release: - hide_features: <5> - - feature-flag-1 - - feature-flag-2 - products: "elasticsearch {version} {lifecycle}" <6> - output: "elasticsearch-{version}.yaml" - output_products: "elasticsearch {version}" -``` - -1. Bundle-level defaults that apply to all profiles. Individual profiles can override these. -2. Collects all changelogs from the `directory`. This is equivalent to the `--all` command. -3. Collects any changelogs that have `product: cloud-serverless`, any lifecycle, and the date partially specified in the command. This is equivalent to the `--input-products` command option's support for wildcards. -4. Collects any changelogs that have `product: elasticsearch`, `lifecycle: ga`, and the version specified in the command. -5. Adds a `hide-features` array in the bundle. This is equivalent to the `--hide-features` command option. -6. In this case, the lifecycle is inferred from the version string passed as the second command argument (for example, `9.2.0-beta.1` → `beta`). - -`output_products: "elasticsearch {version} {lifecycle}"` produces a single, authoritative product entry in the bundle derived from the release tag — for example, tag `v9.2.0` gives `elasticsearch 9.2.0 ga` and tag `v9.2.0-beta.1` gives `elasticsearch 9.2.0 beta`. Without `output_products`, the bundle products array is instead derived from the matched changelog files' own `products` fields, which is the consistent fallback for all profile types. Set `output_products` when you need a single clean product entry that reflects the release identity rather than the diverse metadata across individual changelog files. - -:::{note} -The `products` field determines which changelog files are gathered for consideration. **`rules.bundle` still applies** afterward (see the note under [`--input-products`](#options)). Input stage and bundle filtering stage are conceptually separate. -::: -For profiles that use static patterns (without `{version}` or `{lifecycle}` placeholders), the second argument is still required but serves no functional purpose. You can pass any placeholder value. For example: +For profiles that use static patterns (without `{version}` or `{lifecycle}` placeholders), the second argument is still required but serves no functional purpose. You can pass any placeholder value: ```sh # Profile with static patterns - second argument unused but required @@ -613,74 +609,3 @@ docs-builder changelog bundle release-all '*' docs-builder changelog bundle release-all 'unused' docs-builder changelog bundle release-all 'none' ``` - -If you are using the `{version}` placeholder in the `output_products` or `output` fields, you must provide an appropriate value even though it's not used by the `products` filter. - -### Bundle by report or URL list [profile-bundle-report-examples] - -You can also create profiles that are equivalent to the `--prs`, `--issues`, and `--report` filter options. -That is to say you can create bundles that contain only changelogs with matching `prs` or `issues`. -For example: - -```yaml -bundle: - repo: elasticsearch <1> - owner: elastic - profiles: - # Find changelogs that match a list of PRs - serverless-report: <2> - output: "serverless-{version}.yaml" - output_products: "cloud-serverless {version}" -``` - -1. Bundle-level defaults that apply to all profiles. Individual profiles can override these. -2. If a profile is intended for use with a promotion report or a newline delimited file that lists the issues or pull requests, it does not need a `products` filter. If the `output` and `output_products` are omitted, the default path and file names are used. This example shows how you can use a `{version}` variable to customize the bundle's filename and product metadata. - -By default all changelogs that match PRs or issues in the list or report are included in the bundle. -To apply additional filtering by the changelog type, areas, or products, add `rules.bundle` [filters](#changelog-bundle-rules). - -### Bundle by GitHub release profiles [changelog-bundle-github-release-profile] - -To make bundling by GitHub release more easily repeatable, create a profile with `source: github_release` in your changelog configuration file. -For example: - -```yaml -bundle: - profiles: - # Fetch the PR list directly from a GitHub release - agent-gh-release: - source: github_release <1> - repo: apm-agent-dotnet <2> - output: "agent-{version}.yaml" - output_products: "apm-agent-dotnet {version} {lifecycle}" -``` - -1. Instead of filtering pre-existing changelog files by product, this profile fetches the PR list from the GitHub release notes for the given version. Mutually exclusive with `products`. -2. The repository to fetch the release from. Overrides `bundle.repo` for this profile. - -For `source: github_release` profiles, the `{lifecycle}` placeholder in `output` and `output_products` is inferred from the **release tag** returned by GitHub (not the argument you pass to the command). -This means the pre-release suffix on the tag drives the lifecycle value: - -| Release tag | `{version}` | `{lifecycle}` | -|-------------|-------------|---------------| -| `v9.2.0` | `9.2.0` | `ga` | -| `v9.2.0-beta.1` | `9.2.0` | `beta` | -| `v9.2.0-preview.1` | `9.2.0` | `preview` | -| `v1.34.1` | `1.34.1` | `ga` | -| `v1.34.1-preview.1` | `1.34.1` | `preview` | - -This differs from standard profiles, where lifecycle is inferred from the version argument you type. For `source: github_release`, the `{version}` placeholder always uses the clean base version (stripped of any pre-release suffix), while `{lifecycle}` reflects the actual tag format. - -If the lifecycle you want to advertise cannot be inferred from the tag format — for example, because your team uses clean tags like `v1.34.1` even for pre-releases — hardcode the lifecycle directly in `output_products` instead of using the `{lifecycle}` placeholder: - -```yaml -# Instead of relying on {lifecycle} inference, hardcode the lifecycle -gh-release: - source: github_release - repo: apm-agent-dotnet - output: "apm-agent-dotnet-{version}.yaml" - output_products: "apm-agent-dotnet {version} preview" -``` - -By default all changelogs that match PRs in the GitHub release notes are included in the bundle. -To apply additional filtering by the changelog type, areas, or products, add `rules.bundle` [filters](#changelog-bundle-rules). diff --git a/docs/cli/changelog/gh-release.md b/docs/cli/changelog/gh-release.md index 2a8ef7ba82..dc74d61a08 100644 --- a/docs/cli/changelog/gh-release.md +++ b/docs/cli/changelog/gh-release.md @@ -64,7 +64,7 @@ The product, target version, and lifecycle are inferred automatically from the r ## Configuration The `rules.bundle` section of your `changelog.yml` applies to bundles created by this command (after changelog files are gathered from the release). -For details, refer to [Rules for filtered bundles](/cli/changelog/bundle.md#changelog-bundle-rules). +For details, refer to [](/contribute/configure-changelogs-ref.md#rules-bundle). ## Examples diff --git a/docs/cli/changelog/remove.md b/docs/cli/changelog/remove.md index ddaa310ac5..cc931dc827 100644 --- a/docs/cli/changelog/remove.md +++ b/docs/cli/changelog/remove.md @@ -142,6 +142,16 @@ Setting `bundle.directory` and `bundle.output_directory` in `changelog.yml` is r ## Option-based examples +You can remove changelogs based on their issues, pull requests, product metadata, or remove all changelogs from a folder. +Exactly one filter option must be specified: `--all`, `--products`, `--prs`, `--issues`, `--release-version` or `--report`. +When using a file for `--prs` or `--issues`, every line must be a fully-qualified GitHub URL. + +For example: + +```sh +docs-builder changelog remove --products "elasticsearch 9.3.0 *" --dry-run +``` + ### Remove by GitHub release [changelog-remove-release-version] You can use `--release-version` to fetch pull request references directly from GitHub release notes and use them as the removal filter. @@ -190,97 +200,4 @@ When a `changelog.yml` configuration file defines `bundle.profiles`, you can use Profile-based commands discover the changelog configuration automatically (no `--config` flag): they look for `changelog.yml` in the current directory, then `docs/changelog.yml`. If neither file is found, the command returns an error with instructions to run `docs-builder changelog init` or to re-run from the folder where the file exists. -### Profile fields - -The `changelog remove` command reads the same `bundle.profiles` configuration as `changelog bundle`, but only a subset of fields are relevant to removal: - -| Field | Used by `changelog remove`? | Notes | -|---|---|---| -| `products` | Yes, when filtering by product | Required when the profile argument is a version string and no `source: github_release` is set. Not needed when the filter comes from a promotion report, URL list file, or `source: github_release`. | -| `source` | Yes | `source: github_release` fetches the PR list from the GitHub release to use as the removal filter. | -| `repo` | Yes, with `source: github_release` | Identifies the GitHub repository to fetch the release from. | -| `owner` | Yes, with `source: github_release` | Identifies the GitHub repository owner. | -| `output` | No | Ignored — removal does not write any output files. | -| `output_products` | No | Ignored. | -| `hide_features` | No | Ignored. | -| `rules.bundle` | No | Ignored — bundle-time product filtering is not applied during removal. | - -### Remove by product - -You can create profiles that are equivalent to the `--products` filter option, that is to say the removal will affect only changelogs with matching `products`. - -```yaml -bundle: - profiles: - elasticsearch-release: - products: "elasticsearch {version} {lifecycle}" - output: "elasticsearch-{version}.yaml" -``` - -You can remove the matching changelogs with: - -```sh -docs-builder changelog remove elasticsearch-release 9.2.0 --dry-run -``` - -This removes changelogs for `elasticsearch 9.2.0 ga` — the same set that `docs-builder changelog bundle elasticsearch-release 9.2.0` would include. The lifecycle is inferred from the version string: `9.2.0` → `ga`, `9.2.0-beta.1` → `beta`. Refer to [Lifecycle inference for standard profiles](/cli/changelog/bundle.md#changelog-bundle-standard-profile-lifecycle) for details. - -### Remove by report or URL list - -You can also create profiles that are equivalent to the `--prs`, `--issues`, or `--report` filter options. -That is to say the removal will affect only changelogs with matching `prs` or `issues`. - -For these profile-based commands, you can pass a promotion report URL, a local `.html` file, or a URL list file as the second argument. The command removes changelogs whose `prs` field matches the PR URLs extracted from the report or file. The following commands perform the same task with and without a profile: - -```sh -docs-builder changelog remove serverless-report ./promotion-report.html - -docs-builder changelog remove \ - --report ./promotion-report.html -``` - -Alternatively, use a newline-delimited text file that lists pull request or issue URLs: - -```sh -docs-builder changelog remove serverless-report ./prs.txt -``` - -When you want to use both a version (for `{version}` substitution in the output filename) and a report as the filter, pass both as separate arguments: - -```sh -docs-builder changelog remove serverless-report 2026-02-13 ./promotion-report.html -``` - -### Remove by GitHub release profiles [changelog-remove-github-release-profile] - -To make removal by GitHub release more easily repeatable, create a profile with `source: github_release` in your changelog configuration file. -For example: - -```yaml -bundle: - profiles: - agent-gh-release: - source: github_release - repo: apm-agent-dotnet - owner: elastic - output: "agent-{version}.yaml" -``` - -You can remove the matching changelogs with: - -```sh -docs-builder changelog remove agent-gh-release 1.34.0 -``` - -Use `--dry-run` to preview the files that would be deleted before committing: - -```sh -docs-builder changelog remove agent-gh-release 1.34.0 --dry-run -``` - -:::{note} -`source: github_release` profiles require a `GITHUB_TOKEN` or `GH_TOKEN` environment variable (or an active `gh` login) to fetch release details from the GitHub API. -The `repo` and `owner` used to identify the release follow the same precedence as bundling: profile-level `repo`/`owner` override `bundle.repo`/`bundle.owner`, which in turn override the default owner `elastic`. -::: - -For the full list of profile configuration fields, go to [Profile configuration fields](/cli/changelog/bundle.md#changelog-bundle-profile-config). +Refer to [](/contribute/bundle-changelogs.md#changelog-remove) for examples. diff --git a/docs/contribute/bundle-changelogs.md b/docs/contribute/bundle-changelogs.md index facc3071ce..b3c175c51b 100644 --- a/docs/contribute/bundle-changelogs.md +++ b/docs/contribute/bundle-changelogs.md @@ -1,506 +1,265 @@ # Bundle changelogs -You can use the `docs-builder changelog bundle` command to create a YAML file that lists multiple changelogs. -The command has two modes of operation: you can specify all the command options or you can define "profiles" in the changelog configuration file. -The latter is more convenient and consistent for repetitive workflows. -For up-to-date details, use the `-h` option or refer to [](/cli/changelog/bundle.md). +You can use `docs-builder changelog` commands to created consolidated data files ("release bundles") that list all the notable changes associated with a particular release. +These files are ultimately used to generate release documentation. -The command supports two mutually exclusive usage modes: +This page describes how to create these files from the command line. +For details about the equivalent GitHub action, refer to the [docs-actions README](https://github.com/elastic/docs-actions/blob/main/changelog/README.md#bundling-changelogs). -- **Option-based** — you provide filter and output options directly on the command line. -- **Profile-based** — you specify a named profile from your `changelog.yml` configuration file. +## Before you begin -You cannot mix these two modes: when you use a profile name, no filter or output options are accepted on the command line. +1. Create a changelog configuration file to define all the default behavior and optional profiles and rules. Refer to [](/contribute/configure-changelogs.md). +1. Create changelogs that describe all the notable changes. Refer to [](/contribute/create-changelogs.md). -:::{tip} -Alternatively, if you already have automated release notes for GitHub releases, you can use the `docs-builder changelog gh-release` command to create changelog files and a bundle from your GitHub release notes. Refer to [](/cli/changelog/gh-release.md). -::: +## Identify your source of truth -## Option-based bundling [changelog-bundle-options] +To have accurate release notes, there must be a definitive source of truth for what was shipped in each release. +This is a superset of what will appear in the documentation. -You can specify only one of the following filter options: +The source of truth can be: -- `--all`: Include all changelogs from the directory. -- `--input-products`: Include changelogs for the specified products. Refer to [Filter by product](#changelog-bundle-product). -- `--prs`: Include changelogs for the specified pull request URLs, or a path to a newline-delimited file. When using a file, every line must be a fully-qualified GitHub URL such as `https://github.com/owner/repo/pull/123`. Go to [Filter by pull requests](#changelog-bundle-pr). -- `--issues`: Include changelogs for the specified issue URLs, or a path to a newline-delimited file. When using a file, every line must be a fully-qualified GitHub URL such as `https://github.com/owner/repo/issues/123`. Go to [Filter by issues](#changelog-bundle-issues). -- `--release-version`: Bundle changelogs for the pull requests in GitHub release notes. Refer to [Bundle by GitHub release](#changelog-bundle-release-version). -- `--report`: Include changelogs whose pull requests appear in a promotion report. Accepts a URL or a local file path to an HTML report. +- a list of GitHub pull requests +- a list of GitHub issues +- a buildkite promotion report (which contains a list of PRs) +- automated release notes for GitHub releases +- all changelog files that exist in a specific folder +- all changelog files that match specific products, versions, and lifecycles -`rules.bundle` in `changelog.yml` is **not** mutually exclusive with these options: it runs as a **second stage** after the primary filter matches entries (for example, `--input-products` gathers changelogs, then global or per-product bundle rules may exclude some). The only mutually exclusive pairing is **profile-based** versus **option-based** invocation. See [bundle rules](/contribute/configure-changelogs-ref.md#rules-bundle). +Deriving the source of truth from the contents of a folder or from the metadata in changelogs are the least accurate options (unless you have additional processes to confirm the validity of that information). +It is recommended to use lists that are generated as part of your release coordination activities. +Consider your options carefully and discuss with your docs team if necessary. -By default, the output file contains only the changelog file names and checksums. -To change this behavior, set `bundle.resolve` to `true` in the changelog configuration file or use the `--resolve` command option. +:::{important} +Not everything that was shipped will have a changelog. +For example, you can configure [rules](/contribute/create-changelogs.md#rules) that control changelog creation for work that's not publicly notable or spans multiple PRs. -:::{tip} -If you plan to use [changelog directives](/contribute/publish-changelogs.md#changelog-directive), it is recommended to pull all of the content from each changelog into the bundle; otherwise you can't delete your changelogs. -If you likewise want to regenerate your [Asciidoc or Markdown files](/contribute/publish-changelogs.md#render-changelogs) after deleting your changelogs, it's only possible if you have "resolved" bundles. +Your release workflow should not assume there will be a one-to-one mapping between what was shipped and what will be documented. ::: - +## Create profiles -## Profile-based bundling [changelog-bundle-profile] +The [changelog bundle](/cli/changelog/bundle.md) command has two modes of operation. +You can: -If your `changelog.yml` configuration file defines `bundle.profiles`, you can run a bundle by profile name instead of supplying individual options: +- specify all the command options every time you run the command, or +- define "profiles" in the changelog configuration file -```sh -docs-builder changelog bundle -``` +The latter method is more convenient and consistent for repetitive workflows, therefore it's the recommended method described here. -The second argument accepts a version string, a promotion report URL/path, or a URL list file (a plain-text file with one fully-qualified GitHub URL per line). When your profile uses `{version}` in its `output` or `output_products` pattern and you also want to filter by a report, pass both. -For example: +You must create profiles that match your chosen source of truth. -```sh -# Standard profile: lifecycle is inferred from the version string -docs-builder changelog bundle elasticsearch-release 9.2.0 # {lifecycle} → "ga" -docs-builder changelog bundle elasticsearch-release 9.2.0-beta.1 # {lifecycle} → "beta" - -# Standard profile: filter by a promotion report (version used for {version}) -docs-builder changelog bundle elasticsearch-release ./promotion-report.html -docs-builder changelog bundle elasticsearch-release 9.2.0 ./promotion-report.html -``` - - +:::{tip} +It is strongly recommended to set `output_products` in your profile so your bundles have a single top-level product entry that provides the context of the release. This context is particularly important if you'll be [applying bundle rules](#rules). +::: -Top-level `bundle` fields: +For the most up-to-date changelog configuration options, refer to [changelog.example.yml](https://github.com/elastic/docs-builder/blob/main/config/changelog.example.yml) and [](/contribute/configure-changelogs-ref.md). -| Field | Description | -|---|---| -| `repo` | Default GitHub repository name applied to all profiles. Falls back to product ID if not set at any level. | -| `owner` | Default GitHub repository owner applied to all profiles. | -| `resolve` | When `true`, embeds full changelog entry content in the bundle (same as `--resolve`). Required when `link_allow_repos` is set. | -| `link_allow_repos` | When set (including an empty list), only PR/issue links whose resolved repository is in this `owner/repo` list are kept; others are rewritten to `# PRIVATE:` sentinels in bundle YAML. When absent, no link filtering is applied. Requires `resolve: true`. Refer to [PR and issue link allowlist](/cli/changelog/bundle.md#link-allowlist). | +### Bundle by report or URL list [profile-url] -Profile configuration fields in `bundle.profiles`: +If the source of truth for what was shipped in each release is: -| Field | Description | -|---|---| -| `source` | Optional. Set to `github_release` to fetch the PR list from a GitHub release. Mutually exclusive with `products`. Requires `repo` at the profile or `bundle` level. | -| `products` | Product filter pattern with `{version}` and `{lifecycle}` placeholders. Used to match changelog files. Required when filtering by product metadata. Not used when the filter comes from a promotion report, URL list file, or `source: github_release`. | -| `output` | Output file path pattern with `{version}` and `{lifecycle}` placeholders. | -| `output_products` | Optional override for the products array written to the bundle. Useful when the bundle should have a single product ID though it's filtered from many or have a different lifecycle or version than the filter. With multiple product IDs, Mode 3 rule resolution uses the first alphabetically; use separate profiles or bundle runs with a single product in `output_products` when you need a different rule context. | -| `repo` | Optional. Overrides `bundle.repo` for this profile only. Required when `source: github_release` is used and no `bundle.repo` is set. | -| `owner` | Optional. Overrides `bundle.owner` for this profile only. | -| `hide_features` | List of feature IDs to embed in the bundle as hidden. | +- a list of GitHub pull requests +- a list of GitHub issues +- a buildkite promotion report (which contains a list of PRs) -Example profile configuration: +... your profile does not have any mandatory settings. +However it's a good idea to define the [basic bundle settings](/contribute/configure-changelogs-ref.md#bundle-basic) and the [profile settings](/contribute/configure-changelogs-ref.md#bundle-profiles) for the output filename and output products. +For example: ```yaml bundle: - repo: elasticsearch # The default repository for PR and issue links. - owner: elastic # The default repository owner for PR and issue links. - directory: docs/changelog # The directory that contains changelog files. - output_directory: docs/releases # The directory that contains changelog bundles. - # Optional: Default bundle description with placeholder support - description: | - This release includes new features and bug fixes. - - For more information, see the [release notes](https://www.elastic.co/docs/release-notes/elasticsearch#elasticsearch-{version}). + directory: docs/changelog <1> + output_directory: docs/releases <2> + repo: elasticsearch + owner: elastic + resolve: true <3> profiles: + serverless-report: + output: "serverless/{version}.yaml" <4> + output_products: "cloud-serverless {version}" <5> elasticsearch-release: - products: "elasticsearch {version} {lifecycle}" output: "elasticsearch/{version}.yaml" - output_products: "elasticsearch {version}" - # Profile-specific description overrides bundle.description - description: | - Elasticsearch {version} includes: - - Performance improvements - - Bug fixes and stability enhancements - - Download the release binaries: https://github.com/{owner}/{repo}/releases/tag/v{version} - hide_features: - - feature:experimental-api - serverless-release: - products: "cloud-serverless {version} *" - output: "serverless/{version}.yaml" - output_products: "cloud-serverless {version}" - # inherits repo: elasticsearch and owner: elastic from bundle level - # Multi-product profile: rule context for Mode 3 is the first product alphabetically (here: kibana). - # For security-specific rules only, use a separate profile with output_products listing only security. - kibana-security-release: - products: "kibana {version} {lifecycle}, security {version} {lifecycle}" - output: "kibana-security/{version}.yaml" - output_products: "kibana {version}, security {version}" + output_products: "elasticsearch {version} {lifecycle}" ``` -### Bundle changelogs from a GitHub release [changelog-bundle-profile-github-release] +1. The directory that contains changelog files. +2. The directory that contains changelog bundles. +3. Resolve the changelog files in the bundle rather than just referencing them. Otherwise, when you move or remove changelog files the bundle cannot be rendered. +4. If `output` is omitted, the default path and file names are used. This example shows how you can use a `{version}` variable to customize the bundle's filename. +5. The bundle's product metadata, which affects the rules that are applied and the product and version titles that ultimately appear in the documentation. If omitted, it's derived from all the changelogs in the bundle. + +### Bundle by GitHub releases [profile-gh-release] -Set `source: github_release` on a profile to make `changelog bundle` fetch the PR list directly from a published GitHub release. +If you have automated GitHub release notes, the `changelog bundle` command can fetch the release from GitHub, parse PR references from the release notes, and uses them as the bundle filter. +Only automated GitHub release notes (the default format or [Release Drafter](https://github.com/release-drafter/release-drafter) format) are supported at this time. -This is equivalent to running `changelog bundle --release-version `, but fully configured in `changelog.yml` so you don't have to remember command-line flags. +Your profile must contain `source: github_release`. +It's also a good idea to include the [basic bundle settings](/contribute/configure-changelogs-ref.md#bundle-basic) and the [profile settings](/contribute/configure-changelogs-ref.md#bundle-profiles) for the output filename and output products. +For example: ```yaml bundle: - owner: elastic + resolve: true profiles: agent-gh-release: - source: github_release + source: github_release <1> repo: apm-agent-dotnet - output: "my-agents-{version}.yaml" - output_products: "apm-agent-dotnet {version} {lifecycle}" -``` - -Invoke the profile with a version tag or `latest`: - -```sh -docs-builder changelog bundle agent-gh-release 1.34.0 -docs-builder changelog bundle agent-gh-release latest + owner: elastic + output: "agent-{version}.yaml" + output_products: "apm-agent-dotnet {version} {lifecycle}" <2> ``` -The `{version}` placeholder is substituted with the clean base version extracted from the release tag (for example, `v1.34.0` → `1.34.0`, `v1.34.0-beta.1` → `1.34.0`). The `{lifecycle}` placeholder is inferred from the **release tag** returned by GitHub, not from the argument you pass to the command: - -| Release tag | `{version}` | `{lifecycle}` | -|-------------|-------------|---------------| -| `v1.2.3` | `1.2.3` | `ga` | -| `v1.2.3-beta.1` | `1.2.3` | `beta` | -| `v1.2.3-preview.1` | `1.2.3` | `preview` | - -This differs from standard profiles, where `{lifecycle}` is inferred from the version string you type at the command line. - -### Bundle descriptions - -You can add introductory text to bundles using the `description` field. This text appears at the top of rendered changelogs, after the release heading but before the entry sections. - -**Configuration locations:** +1. This profile fetches the PR list from the GitHub release notes for the version tag specified in the command. +2. For `source: github_release` profiles, the `{lifecycle}` placeholder in `output` and `output_products` is inferred from full release tag name. For example, if the release tag is `v1.34.1-preview.1` the lifecycle is `preview`. Refer to [](/cli/changelog/bundle.md#lifecycle-inference) for more details. -- `bundle.description`: Default description for all profiles -- `bundle.profiles..description`: Profile-specific description (overrides the default) +### Bundle by folder or changelog product -**Placeholder support:** +If the source of truth for what was shipped in each release is: -Bundle descriptions support these placeholders: +- the `products` information that exists in each changelog +- all changelog files that exist in a specific folder -- `{version}`: The resolved version string -- `{lifecycle}`: The resolved lifecycle (ga, beta, preview, etc.) -- `{owner}`: The GitHub repository owner -- `{repo}`: The GitHub repository name - -**Important**: When using `{version}` or `{lifecycle}` placeholders, you must ensure predictable substitution values: - -- **Option-based mode**: Requires `--output-products` when using placeholders -- **Profile-based mode**: Requires either a version argument (e.g., `bundle profile 9.2.0`) OR an `output_products` pattern in the profile configuration when using placeholders. If you invoke a profile with only a promotion report (e.g., `bundle profile ./report.html`), placeholders will fail unless `output_products` is configured. - -**Multiline descriptions in YAML:** - -For complex descriptions with multiple paragraphs, lists, and links, use YAML literal block scalars with the `|` (pipe) syntax: +... you must include `products` in your profile. +For example: ```yaml bundle: - description: | - This release includes significant improvements: - - - Enhanced performance - - Bug fixes and stability improvements - - New features for better user experience - - For security updates, go to [security announcements](https://example.com/docs). - - Download the release binaries: https://github.com/{owner}/{repo}/releases/tag/v{version} -``` - -The `|` (pipe) preserves line breaks and is ideal for Markdown-formatted text. Avoid using `>` (greater than) for descriptions as it folds line breaks into spaces, making lists and paragraphs difficult to format correctly. - -**Command line usage:** - -For simple descriptions, use the `--description` option with regular quotes: - -```sh -docs-builder changelog bundle --all --description "This release includes new features." -``` - -For multiline descriptions on the command line, use ANSI-C quoting (`$'...'`) with `\n` for line breaks: - -```sh -docs-builder changelog bundle --all --description $'Enhanced release:\n\n- Performance improvements\n- Bug fixes' -``` - -`output_products` is optional. When omitted, the bundle products array is derived from the matched changelog files' own `products` fields — the same fallback used by all other profile types. Set `output_products` when you want a single clean product entry that reflects the release identity rather than the diverse metadata across individual changelog files, or to hardcode a lifecycle that cannot be inferred from the tag format: - -```yaml -# Produce one authoritative product entry instead of inheriting from changelog files -agent-gh-release: - source: github_release - repo: apm-agent-dotnet - output: "apm-agent-dotnet-{version}.yaml" - output_products: "apm-agent-dotnet {version} {lifecycle}" - -# Or hardcode the lifecycle when the tag format doesn't encode it -agent-gh-release-preview: - source: github_release - repo: apm-agent-dotnet - output: "apm-agent-dotnet-{version}-preview.yaml" - output_products: "apm-agent-dotnet {version} preview" + directory: docs/changelog + output_directory: docs/releases + resolve: true + repo: elasticsearch + owner: elastic + profiles: + # Collect all changelogs + release-all: + products: "* * *" <1> + output: "all.yaml" + # Find changelogs with any lifecycle and a partial date + serverless-monthly: + products: "cloud-serverless {version}-* *" <2> + output: "serverless-{version}.yaml" + output_products: "cloud-serverless {version}" + # Find changelogs with a specific lifecycle + elasticsearch-ga-only: + products: "elasticsearch {version} ga" <3> + output: "elasticsearch-{version}.yaml" + # Infer the lifecycle from the version + elasticsearch-with-lifecycle: + products: "elasticsearch {version} {lifecycle}" <4> + output: "elasticsearch-{version}.yaml" + output_products: "elasticsearch {version}" ``` -`source: github_release` is mutually exclusive with `products`, and a third positional argument (promotion report or URL list) is not accepted by this profile type. - -## Filter by product [changelog-bundle-product] +1. This profile collects all changelogs from the `directory`. +2. This profile collects any changelogs that have `product: cloud-serverless`, any lifecycle, and the date partially specified in the command. +3. This profile collects any changelogs that have `product: elasticsearch`, `lifecycle: ga`, and the version specified in the command. +4. In this case, the lifecycle is inferred from the version specified in the command. For example, if the version is `9.2.0-beta.1` the lifecycle is `beta`. Refer to [](/cli/changelog/bundle.md#lifecycle-inference). -You can use the `--input-products` option to create a bundle of changelogs that match the product details. -When using `--input-products`, you must provide all three parts: product, target, and lifecycle. -Each part can be a wildcard (`*`) to match any value. - -:::{tip} -If you use profile-based bundling, provide this information in the `bundle.profiles..products` field. +:::{note} +The `products` field determines which changelog files are gathered for consideration. You can still apply [rules](#rules) afterward to further filter changelogs from the bundle. The input stage and bundle filtering stage are conceptually separate. ::: -```sh -docs-builder changelog bundle \ - --input-products "cloud-serverless 2025-12-02 ga, cloud-serverless 2025-12-06 beta" <1> -``` +## Create bundles -1. Include all changelogs that have the `cloud-serverless` product identifier with target dates of either December 2 2025 (lifecycle `ga`) or December 6 2025 (lifecycle `beta`). For more information about product values, refer to [Product format](/cli/changelog/bundle.md#product-format). - -You can use wildcards in any of the three parts: +If you created profiles, you can use them with the `changelog bundle` command like this: ```sh -# Bundle any changelogs that have exact matches for either of these clauses -docs-builder changelog bundle --input-products "cloud-serverless 2025-12-02 ga, elasticsearch 9.3.0 beta" - -# Bundle all elasticsearch changelogs regardless of target or lifecycle -docs-builder changelog bundle --input-products "elasticsearch * *" - -# Bundle all cloud-serverless 2025-12-02 changelogs with any lifecycle -docs-builder changelog bundle --input-products "cloud-serverless 2025-12-02 *" - -# Bundle any cloud-serverless changelogs with target starting with "2025-11-" and "ga" lifecycle -docs-builder changelog bundle --input-products "cloud-serverless 2025-11-* ga" - -# Bundle all changelogs (equivalent to --all) -docs-builder changelog bundle --input-products "* * *" +docs-builder changelog bundle ``` -If you have changelog files that reference those product details, the command creates a file like this: - -```yaml -products: <1> -- product: cloud-serverless - target: 2025-12-02 -- product: cloud-serverless - target: 2025-12-06 -entries: -- file: - name: 1765495972-fixes-enrich-and-lookup-join-resolution-based-on-m.yaml - checksum: 6c3243f56279b1797b5dfff6c02ebf90b9658464 -- file: - name: 1765507778-break-on-fielddata-when-building-global-ordinals.yaml - checksum: 70d197d96752c05b6595edffe6fe3ba3d055c845 -``` +The second argument accepts a version string, a promotion report URL or path, or a URL list file (a plain-text file with one fully-qualified GitHub URL per line). +If you are using a `{version}` placeholder in the `output_products` or `output` fields, you must provide that value as well as your report or URL argument. -1. By default these values match your `--input-products` (even if the changelogs have more products). -To specify different product metadata, use the `--output-products` option. +For example, if the source of truth for what was shipped in each release is: -## Filter by pull requests [changelog-bundle-pr] +- a list of GitHub pull requests or issues: -You can use the `--prs` option to create a bundle of the changelogs that relate to those pull requests. -You can provide either a comma-separated list of PRs (`--prs "https://github.com/owner/repo/pull/123,12345"`) or a path to a newline-delimited file (`--prs /path/to/file.txt`). -In the latter case, the file should contain one PR URL or number per line. + ```sh + # Bundle changelogs from a PR list ({lifecycle} → "ga" inferred from "9.2.0") + docs-builder changelog bundle elasticsearch-release 9.2.0 ./prs.txt + ``` -Pull requests can be identified by a full URL (such as `https://github.com/owner/repo/pull/123`), a short format (such as `owner/repo#123`), or just a number (in which case you must also provide `--owner` and `--repo` options). + ... where `prs.txt` is a newline delimited file with PR or issue URLs like this this: -```sh -docs-builder changelog bundle --prs "108875,135873,136886" \ <1> - --repo elasticsearch \ <2> - --owner elastic \ <3> - --output-products "elasticsearch 9.2.2 ga" <4> -``` + ```txt + https://github.com/elastic/kibana/pull/123 + https://github.com/elastic/kibana/pull/456 + ``` -1. The comma-separated list of pull request numbers to seek. -2. The repository in the pull request URLs. Not required when using full PR URLs, or when `bundle.repo` is set in the changelog configuration. -3. The owner in the pull request URLs. Not required when using full PR URLs, or when `bundle.owner` is set in the changelog configuration. -4. The product metadata for the bundle. If it is not provided, it will be derived from all the changelog product values. +- a buildkite promotion report: -In Mode 3, the **rule context product** is the first alphabetically from `--output-products` (or from aggregated changelog products if omitted). To apply a different product's per-product rules, use a bundle whose `output_products` contains only that product (separate command or profile). + ```sh + # Bundle changelogs from a buildkite report ({version} → "2026-02-13") + docs-builder changelog bundle serverless-report 2026-02-13 ./promotion-report.html + ``` -If you have changelog files that reference those pull requests, the command creates a file like this: +- automated release notes for GitHub releases: -```yaml -products: -- product: elasticsearch - target: 9.2.2 - lifecycle: ga -entries: -- file: - name: 1765507819-fix-ml-calendar-event-update-scalability-issues.yaml - checksum: 069b59edb14594e0bc3b70365e81626bde730ab7 -- file: - name: 1765507798-convert-bytestransportresponse-when-proxying-respo.yaml - checksum: c6dbd4730bf34dbbc877c16c042e6578dd108b62 -- file: - name: 1765507839-use-ivf_pq-for-gpu-index-build-for-large-datasets.yaml - checksum: 451d60283fe5df426f023e824339f82c2900311e -``` + ```sh + # Bundle changelogs from the release notes of a specific GitHub tag + docs-builder changelog bundle agent-gh-release v1.34.1 -## Filter by issues [changelog-bundle-issues] + # Use "latest" to fetch the most recent release + docs-builder changelog bundle agent-gh-release latest + ``` -You can use the `--issues` option to create a bundle of changelogs that relate to those GitHub issues. -Provide either a comma-separated list of issues (`--issues "https://github.com/owner/repo/issues/123,456"`) or a path to a newline-delimited file (`--issues /path/to/file.txt`). -Issues can be identified by a full URL (such as `https://github.com/owner/repo/issues/123`), a short format (such as `owner/repo#123`), or just a number (in which case `--owner` and `--repo` are required — or set via `bundle.owner` and `bundle.repo` in the configuration). + Alternatively, use the [changelog gh-release](/cli/changelog/gh-release.md) command, which creates the changelogs and bundles at the same time. -```sh -docs-builder changelog bundle --issues "12345,12346" \ - --repo elasticsearch \ - --owner elastic \ - --output-products "elasticsearch 9.2.2 ga" -``` + :::{note} + This method requires a `GITHUB_TOKEN` or `GH_TOKEN` environment variable (or an active `gh` login) to fetch release details from the GitHub API. + ::: -## Filter by pull request or issue file [changelog-bundle-file] +- all changelog files that exist in a specific folder: -If you have a file that lists pull requests (such as PRs associated with a GitHub release), you can pass it to `--prs`. -For example, if you have a file that contains full pull request URLs like this: + ```sh + docs-builder changelog bundle release-all '*' + ``` -```txt -https://github.com/elastic/elasticsearch/pull/108875 -https://github.com/elastic/elasticsearch/pull/135873 -https://github.com/elastic/elasticsearch/pull/136886 -https://github.com/elastic/elasticsearch/pull/137126 -``` +- all changelog files that match specific products, versions, and lifecycles: -You can use the `--prs` option with the file path to create a bundle of the changelogs that relate to those pull requests. -You can also combine multiple `--prs` options: + ```sh + # Bundle changelogs with partial dates + docs-builder changelog bundle serverless-monthly 2026-02 + + # Bundle changelogs for a GA release ({lifecycle} → "ga" inferred from "9.2.0") + docs-builder changelog bundle elasticsearch-with-lifecycle 9.2.0 -```sh -./docs-builder changelog bundle \ - --prs "https://github.com/elastic/elasticsearch/pull/108875,135873" \ <1> - --prs test/9.2.2.txt \ <2> - --output-products "elasticsearch 9.2.2 ga" <3> - --resolve <4> -``` + # Bundle changelogs for a beta release ({lifecycle} → "beta" inferred from "9.2.0-beta.1") + docs-builder changelog bundle elasticsearch-with-lifecycle 9.2.0-beta.1 + ``` -1. Comma-separated list of pull request URLs or numbers. -2. The path for the file that lists the pull requests. If the file contains only PR numbers, you must add `--repo` and `--owner` command options. -3. The product metadata for the bundle. If it is not provided, it will be derived from all the changelog product values. -4. Optionally include the contents of each changelog in the output file. +By default all changelogs that match the chosen source of truth are included in the bundle. :::{tip} -You can use these files with profile-based bundling too. Refer to [](/cli/changelog/bundle.md). +It is strongly recommended to pull all of the content from each changelog into the bundle; otherwise you can't move or remove your changelogs. If your bundle contains only references to the files, add set [bundle.resolve](/contribute/configure-changelogs-ref.md#bundle-basic) to true and re-generate your bundle. ::: -If you have changelog files that reference those pull requests, the command creates a file like this: +To apply additional filtering by the changelog type, areas, or products, add [bundle rules](#rules). -```yaml -products: -- product: elasticsearch - target: 9.2.2 - lifecycle: ga -entries: -- file: - name: 1765507778-break-on-fielddata-when-building-global-ordinals.yaml - checksum: 70d197d96752c05b6595edffe6fe3ba3d055c845 - type: bug-fix - title: Break on FieldData when building global ordinals - products: - - product: elasticsearch - areas: - - Aggregations - prs: - - https://github.com/elastic/elasticsearch/pull/108875 -... -``` - -:::{note} -When a changelog matches multiple `--input-products` filters, it appears only once in the bundle. This deduplication applies even when using `--all` or `--prs`. -::: +If you don't want to use profiles and prefer to specify all the command options every time you run the command, refer to [Option-based examples](/cli/changelog/bundle.md#option-based-examples). -## Filter by GitHub release notes [changelog-bundle-release-version] +## Amend bundles [changelog-bundle-amend] -If you have GitHub releases with automated release notes (the default format or [Release Drafter](https://github.com/release-drafter/release-drafter) format), you can use the `--release-version` option to derive the PR list from those release notes. +When you need to add changelogs to an existing bundle, you can use the `docs-builder changelog bundle-amend` command, which creates _amend bundles_. For example: ```sh -docs-builder changelog bundle \ - --release-version v1.34.0 \ - --repo apm-agent-dotnet --owner elastic <1> -``` - -1. The repo and repo owner are used to fetch the release and follow these rules of precedence: - -- Repo: `--repo` flag > `bundle.repo` in `changelog.yml` (one source is required) -- Owner: `--owner` flag > `bundle.owner` in `changelog.yml` > `elastic` - -This command creates a bundle of changelogs that match the list of PRs found in the `v1.34.0` GitHub release notes. - -The bundle's product metadata is inferred automatically from the release tag and repository name; you can override that behavior with the `--output-products` option. - -:::{tip} -If you are not creating changelogs when you create your pull requests, consider the `docs-builder changelog gh-release` command as a one-shot alternative to the `changelog add` and `changelog bundle` commands. -It parses the release notes, creates one changelog file per pull request found, and creates a `changelog-bundle.yaml` file — all in a single step. Refer to [](/cli/changelog/gh-release.md) -::: - -## Hide features [changelog-bundle-hide-features] - -You can use the `--hide-features` option to embed feature IDs that should be hidden when the bundle is rendered. This is useful for features that are not yet ready for public documentation. - -```sh -docs-builder changelog bundle \ - --input-products "elasticsearch 9.3.0 *" \ - --hide-features "feature:hidden-api,feature:experimental" \ <1> - --output /path/to/bundles/9.3.0.yaml -``` - -1. Feature IDs to hide. Changelogs with matching `feature-id` values will be commented out when rendered. - - - -The bundle output will include a `hide-features` field: - -```yaml -products: -- product: elasticsearch - target: 9.3.0 -hide-features: - - feature:hidden-api - - feature:experimental -entries: -- file: - name: 1765495972-new-feature.yaml - checksum: 6c3243f56279b1797b5dfff6c02ebf90b9658464 +docs-builder changelog bundle-amend \ + ./docs/releases/9.3.0.yaml \ + --add "./docs/changelog/138723.yaml,./docs/changelog/1770424335.yaml" ``` -When this bundle is rendered (either via the `changelog render` command or the `{changelog}` directive), changelogs with `feature-id` values matching any of the listed features will be commented out in the output. +Amend bundles follow a specific naming convention: `{parent-bundle-name}.amend-{N}.yaml` where `{N}` is a sequence number. :::{note} -The `--hide-features` option on the `render` command and the `hide-features` field in bundles are **combined**. If you specify `--hide-features` on both the `bundle` and `render` commands, all specified features are hidden. The `{changelog}` directive automatically reads `hide-features` from all loaded bundles and applies them. +There is currently no command to **remove** changelogs from a bundle. You must edit the bundle file manually or else re-generate the bundle with an updated source of truth or a new rule that excludes the changelog. ::: -## Hide private links - -A changelog can reference multiple pull requests and issues in the `prs` and `issues` array fields. - -To comment out links that are not in your allowlist in all changelogs in your bundles, refer to [changelog bundle](/cli/changelog/bundle.md#link-allowlist). - -If you are working in a private repo and do not want any pull request or issue links to appear (even if they target a public repo), you also have the option to configure link visibiblity in the [changelog directive](/syntax/changelog.md) and [changelog render](/cli/changelog/render.md) command. - -:::{tip} -You must run the `docs-builder changelog bundle` command with the `--resolve` option or set `bundle.resolve` to `true` in the changelog configuration file (so that bundle files are self-contained) in order to hide the private links. -::: - -## Amend bundles [changelog-bundle-amend] - -When you need to add changelogs to an existing bundle without modifying the original file, you can use the `docs-builder changelog bundle-amend` command to create amend bundles. -Amend bundles follow a specific naming convention: `{parent-bundle-name}.amend-{N}.yaml` where `{N}` is a sequence number. - -When bundles are loaded (either via the `changelog render` command or the `{changelog}` directive), amend files are **automatically merged** with their parent bundles. +When bundles are turned into docs (either via the `changelog render` command or the `{changelog}` directive), amend files are **automatically merged** with their parent bundles. The changelogs from all matching amend files are combined with the parent bundle's changelogs and the result is rendered as a single release. :::{warning} -If you explicitly list the amend bundles in the `--input` option of the `docs-builder changelog render` command, you'll get duplicate entries in the output files. List only the original bundles. +Don't explicitly list the amend bundles in the `--input` option of the `docs-builder changelog render` command--you'll get duplicate entries in the output files. List only the original/parent bundles. ::: For more details and examples, go to [](/cli/changelog/bundle-amend.md). @@ -518,8 +277,47 @@ Likewise, the `docs-builder changelog render` command fails for "unresolved" bun ::: You can use the `docs-builder changelog remove` command to remove changelogs. -It supports the same two modes as `changelog bundle`: you can specify all the command options or you can define "profiles" in the changelog configuration file. -In the command option mode, exactly one filter option must be specified: `--all`, `--products`, `--prs`, `--issues`, `--release-version`, or `--report`. +If you created profiles, you can use them like this: + +```sh +docs-builder changelog remove +``` + +For example, if the source of truth for what was shipped in each release is: + +- a list of GitHub pull requests or issues: + + ```sh + docs-builder changelog remove elasticsearch-release ./prs.txt + ``` + +- a buildkite promotion report: + + ```sh + docs-builder changelog remove serverless-report ./promotion-report.html + ``` + +- automated release notes for GitHub releases: + + ```sh + docs-builder changelog remove agent-gh-release 1.34.1 + ``` + + :::{note} + This method requires a `GITHUB_TOKEN` or `GH_TOKEN` environment variable (or an active `gh` login) to fetch release details from the GitHub API. + ::: + +- all changelog files that exist in a specific folder: + + ```sh + docs-builder changelog remove release-all '*' + ``` + +- all changelog files that match specific products, versions, and lifecycles: + + ```sh + docs-builder changelog remove serverless-monthly 2026-02 + ``` Before deleting, the command automatically scans for bundles that still hold unresolved (`file:`) references to the matching changelog files. If any are found, the command reports an error for each dependency. @@ -527,71 +325,110 @@ This check prevents the `{changelog}` directive from failing at build time with To proceed with removal even when unresolved bundle dependencies exist, use `--force`. To preview what would be removed without deleting anything, use `--dry-run`. -Bundle dependency conflicts are also reported in dry-run mode. -### Removal with profiles [changelog-remove-profile] +For full option details, go to [](/cli/changelog/remove.md). -If your `changelog.yml` configuration file defines `bundle.profiles`, you can use those profiles with `changelog remove`. -This is the easiest way to remove exactly the changelogs that were included in a profile-based bundle. -The command syntax is: +## Examples -```sh -docs-builder changelog remove -``` +The following sections provide more details about optional and advanced steps. -For example, if you bundled with: +### Apply bundle rules [rules] -```sh -docs-builder changelog bundle elasticsearch-release 9.2.0 -``` +:::{important} +Not everything that was shipped in a release and has a changelog necessarily belongs in the release bundle. +::: -You can remove the same changelogs with: +If you want to automatically include or exclude changelogs from bundles based on their areas, types, or products, you can accomplish this with rules in your changelog configuration file. +Bundle rules run as a secondary stage after the candidate changelogs are collected (for example, based on a PR list, promotion report, or other valid source of truth). -```sh -docs-builder changelog remove elasticsearch-release 9.2.0 --dry-run +For example, you might choose to omit `other` or `docs` types of changelogs. +Or you might choose to omit all changelogs related to specific features (`areas`) from a product's release bundles. + +You can define rules at the global level (applies to all products) like this: + +```yaml +rules: + bundle: + exclude_products: elasticsearch + exclude_types: deprecation + exclude_areas: + - Internal ``` -The command automatically discovers `changelog.yml` by checking `./changelog.yml` then `./docs/changelog.yml` relative to your current directory. -If no configuration file is found, the command returns an error with advice to create one or to run from the directory where the file exists. +Alternatively, you can define product-specific rules: -The `output`, `output_products`, `hide_features`, `link_allow_repos`, and `resolve` fields are bundle-specific and are always ignored for removal (along with other bundle-only settings that do not affect which changelog files match the filter). -Which other fields are used depends on the profile type: +```yaml +rules: + bundle: + products: + cloud-serverless: + include_areas: + - "Search" + - "Monitoring" + elasticsearch: + exclude_areas: + - Autoscaling +``` -- Standard profiles: only the `products` field is used. The `repo` and `owner` fields are ignored (they only affect bundle output metadata). -- GitHub release profiles (`source: github_release`): `source`, `repo`, and `owner` are all used. The command fetches the PR list from the GitHub release identified by the version argument and removes changelogs whose `prs` field matches. +Product-specific rules override the global rules entirely—they do not merge. +For details, refer to [](/contribute/configure-changelogs-ref.md#rules-bundle) and [](/contribute/configure-changelogs-ref.md#advanced-rule-examples). -For example, given a GitHub release profile: +### Hide features [changelog-bundle-hide-features] -```sh -docs-builder changelog remove agent-gh-release v1.34.0 --dry-run +Changelogs have an optional `feature-id` field that you can use to associate the change with a specific feature or project. +If there are features or projects that are not yet ready for public documentation, you can list those IDs in the [`hide_features`](/contribute/configure-changelogs-ref.md#bundle-profiles) setting: + +```yaml +bundle: + directory: docs/changelog + output_directory: docs/releases + repo: elasticsearch + owner: elastic + resolve: true + profiles: + serverless-report: + output: "serverless/{version}.yaml" + output_products: "cloud-serverless {version}" + hide_features: <1> + - feature-flag-1 + - feature-flag-2 ``` -This fetches the PR list from the `v1.34.0` release (using the profile's `repo`/`owner` settings) and removes matching changelogs. +1. The feature identifiers to hide. -:::{note} -`source: github_release` profiles require a `GITHUB_TOKEN` or `GH_TOKEN` environment variable (or an active `gh` login) to fetch release details from the GitHub API. -::: +When you use this profile to create a bundle, the list is carried forward into its metadata. +Any changelogs with matching `feature-id` values are commented out when you publish the bundle. -Profile-based removal is mutually exclusive with command options. -The only options allowed alongside a profile name are `--dry-run` and `--force`. +### Hide private links -You can also pass a promotion report URL, file path, or URL list file as the second argument, and the command removes changelogs whose pull request or issue URLs appear in the report: +A changelog can reference multiple pull requests and issues in its `prs` and `issues` fields. +You can allowlist links to certain repos with the `link_allow_repos` setting: -```sh -docs-builder changelog remove elasticsearch-release https://buildkite.../promotion-report.html -docs-builder changelog remove serverless-release 2026-02 ./promotion-report.html -docs-builder changelog remove serverless-release 2026-02 ./prs.txt +```yaml +bundle: + directory: docs/changelog + output_directory: docs/releases + repo: elasticsearch + owner: elastic + resolve: true + link_allow_repos: <1> + - elastic/elasticsearch + - elastic/kibana + - elastic/roadmap ``` -### Removal with command options [changelog-remove-raw] +1. Only links to these owner/repo pairs are shown in the release docs. Others are rewritten to `# PRIVATE:` sentinels. -You can alternatively remove changelogs based on their issues, pull requests, product metadata, or remove all changelogs from a folder. -Exactly one filter option must be specified: `--all`, `--products`, `--prs`, `--issues`, `--release-version` or `--report`. -When using a file for `--prs` or `--issues`, every line must be a fully-qualified GitHub URL. +There are no implicit values for this setting. You must list every repo whose links should appear, including the current repo. +When this setting is omitted entirely, no link filtering is applied. +For more details, refer to [PR and issue link allowlist](/cli/changelog/bundle.md#link-allowlist). -```sh -docs-builder changelog remove --products "elasticsearch 9.3.0 *" --dry-run -``` +:::{tip} +You must set `bundle.resolve` to `true` in the changelog configuration file (so that bundle files are self-contained) in order to hide the private links. The bundle's changelog entries are sanitized but the individual changelog files are unchanged. +::: -For full option details, go to [](/cli/changelog/remove.md). +If you are working in a private repo and do not want any pull request or issue links to appear (even if they target a public repo), you can also configure link visibility in the [changelog directive](/syntax/changelog.md#hide-links) and [changelog render](/cli/changelog/render.md) command. + +## Next steps +After you've created release bundles, you can use them to generate [release docs](/contribute/publish-changelogs.md). diff --git a/docs/contribute/configure-changelogs-ref.md b/docs/contribute/configure-changelogs-ref.md index a0f6d4127e..0ee785f408 100644 --- a/docs/contribute/configure-changelogs-ref.md +++ b/docs/contribute/configure-changelogs-ref.md @@ -30,6 +30,12 @@ These are the main configuration sections: Configures directory paths, GitHub repository defaults, and named profiles for bundle operations. These settings are separate from `rules.bundle` filtering. +Refer to: + +- [Basic bundle settings](#bundle-basic) +- [Bundle descriptions](#bundle-descriptions) +- [Bundle profiles](#bundle-profiles) + ### Basic settings [bundle-basic] Controls bundle-level behavior. @@ -38,18 +44,15 @@ These settings are relevant to one or all of the `changelog bundle`, `changelog :::{table} :widths: description - -| Setting | Description | -| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `bundle.description` | Default template for bundle descriptions. Supports `{version}`, `{lifecycle}`, `{owner}`, and `{repo}` placeholders. | -| `bundle.directory` | Input directory containing changelog YAML files (default: `docs/changelog`). | +| Setting | Description | +| ------------------------- | ----------- | +| `bundle.directory` | Input directory containing changelog YAML files (default: `docs/changelog`). | | `bundle.link_allow_repos` | List of `owner/repo` pairs whose PR/issue links are preserved. When set (including empty `[]`), links to unlisted repos become `# PRIVATE:` sentinels. Requires `bundle.resolve: true` | -| `bundle.output_directory` | Output directory for bundled files (default: `docs/releases`). | -| `bundle.owner` | Default GitHub repository owner (for example, `elastic`). | -| `bundle.release_dates` | When `true`, bundles include a `release-date` field (default: true). | -| `bundle.repo` | Default GitHub repository name (for example, `elasticsearch`). | -| `bundle.resolve` | When `true`, changelog contents are copied into bundle (default: `true`). | - +| `bundle.output_directory` | Output directory for bundled files (default: `docs/releases`). | +| `bundle.owner` | Default GitHub repository owner (for example, `elastic`). | +| `bundle.release_dates` | When `true`, bundles include a `release-date` field (default: true). | +| `bundle.repo` | Default GitHub repository name (for example, `elasticsearch`). Used by the `{changelog}` directive to generate correct PR and issue links. Only needed when the product ID doesn't match the GitHub repository name. | +| `bundle.resolve` | When `true`, changelog contents are copied into bundle (default: `true`). | ::: @@ -60,31 +63,91 @@ When `bundle.link_allow_repos` is omitted, no link filtering occurs. - For public repos, add your `owner/repo` to the list at a minimum. ::: -### Bundle profiles [bundle-profiles] +### Bundle descriptions [bundle-descriptions] -Named profiles simplify bundle creation for different release scenarios. -Profiles work with both `changelog bundle` and `changelog remove` commands. +You can add introductory text to bundles using the `description` field. This text appears at the top of rendered changelogs, after the release heading but before the entry sections. -These settings are located in the `bundle.profiles.` section of the configuration file. +When using profiles, you can provide this information in: -:::{table} -:widths: description +- `bundle.description`: Default description for all profiles +- `bundle.profiles..description`: Profile-specific description (overrides the default) +Bundle descriptions support these `{version}`, `{lifecycle}`, `{owner}`, and `{repo}` substitution variables. +When using `{version}` or `{lifecycle}`, you must provide the necessary version argument in the command (for example, `bundle profile 9.2.0`) or define `output_products` in your configuration file. -| Profile setting | Description | -| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | -| `description` | Profile-specific description template. Overrides `bundle.description`. | -| `hide_features` | List of feature IDs to mark as hidden (commented out) in bundle output. | -| `output` | Output filename pattern (for example, `"elasticsearch/{version}.yaml"`). | -| `output_products` | Products list in bundle metadata; supports placeholders. | -| `owner` | Profile-specific GitHub owner. Overrides `bundle.owner`. | -| `products` | Product filter pattern (for example, `"elasticsearch {version} {lifecycle}"`) where placeholders are substituted at runtime. | -| `release_dates` | When `true`, bundles include a `release-date` field. Overrides `bundle.release_dates`. | -| `repo` | Profile-specific GitHub repository name. Overrides `bundle.repo`. | -| `source` | When set to `"github_release"`, fetches PR list from GitHub release instead of filtering changelogs. Mutually exclusive with `products`. | +For complex descriptions with multiple paragraphs, lists, and links, use YAML literal block scalars with the `|` (pipe) syntax: +```yaml +bundle: + description: | + This release includes significant improvements: + + - Enhanced performance + - Bug fixes and stability improvements + - New features for better user experience + + For security updates, go to [security announcements](https://example.com/docs). + + Download the release binaries: https://github.com/{owner}/{repo}/releases/tag/v{version} +``` -::: +The `|` (pipe) preserves line breaks and is ideal for Markdown-formatted text. Avoid using `>` (greater than) for descriptions as it folds line breaks into spaces, making lists and paragraphs difficult to format correctly. + +### Bundle profiles [bundle-profiles] + +Named profiles enable you to run commands repeatedly with consistent options. +They work with both `changelog bundle` and `changelog remove` commands. + +These settings are located in the `bundle.profiles.` section of the configuration file: + +`description` +: Overrides the global [bundle.description](#bundle-descriptions). + +`hide_features` +: Feature IDs to mark as hidden in the bundle. +: When the bundle is rendered, entries with matching `feature-id` values are commented out. + +`output` +: The output filename pattern for the bundle file. +: Supports `{version}` and `{lifecycle}` placeholders. +: When not set, the output path falls back in order to: `bundle.output_directory/changelog-bundle.yaml` (if `bundle.output_directory` is configured), then `changelog-bundle.yaml` in the input directory. +: Setting this is recommended so each profile produces a distinctly named file rather than overwriting the default. +: Example: `"elasticsearch/{version}.yaml"` + +`output_products` +: The bundle's `products` metadata, which affects the bundle rules that are applied and the product and version titles that ultimately appear in documentation. +: Supports `{version}` and `{lifecycle}` placeholders. +: When not set, the products array is derived from the individual changelog files matched by the filter. This often produces multiple product entries (one per unique product/target/lifecycle combination across all matched files), which may not reflect a single clean release identity. +: When set, the products array in the bundle is exactly the value you specify, replacing anything that would be derived from the matched changelogs. Use this to publish a single, authoritative product entry with a specific version and lifecycle. +: The `{lifecycle}` placeholder is substituted at runtime with the inferred lifecycle. For `source: github_release` profiles this comes from the release tag suffix. For standard profiles it comes from the version argument. Refer to [](/cli/changelog/bundle.md#lifecycle-inference). +: If you omit lifecycle from the pattern (for example, `"elasticsearch {version}"`), the lifecycle field is omitted from the products array entirely. +: Example: `"elasticsearch {version} {lifecycle}"` or `"elasticsearch {version} ga"` to hardcode GA regardless of tag. +: Refer to [](/cli/changelog/bundle.md#product-format). + +`owner` +: Overrides [bundle.owner](#bundle-basic). + +`products` +: Derive the list of changelogs by matching their `products` values (equivalent to the `--input-products` command option). +: Not used when the source of truth for the release is a promotion report, URL list file, or `source: github_release`; in those cases this setting is ignored. +: Supports `{version}` and `{lifecycle}` placeholders that are substituted at runtime. +: The value `"* * *"` is equivalent to the `--all` command option. +: Example: `"elasticsearch {version} {lifecycle}"` +: Refer to [](/cli/changelog/bundle.md#product-format). + +`release_dates` +: Overrides [bundle.release_dates](#bundle-basic). + +`repo` +: Overrides [bundle.repo](#bundle-basic). +: Required when `source: github_release` is used and `bundle.repo` is not set. + +`source` +: Derive the list of changelogs from the specified source. +: Only `github_release` is currently supported (equivalent to the `--release-version` command option), which means a PR list is fetched from the GitHub release identified by the version argument. +: Requires `repo` to be set at the profile or `bundle` level. +: Mutually exclusive with `products`. +: Example: `source: github_release` Example profile usage: @@ -284,10 +347,16 @@ rules: exclude: ">non-issue, ILM" ``` +For more context, go to [](/contribute/create-changelogs.md#rules). + ### `rules.bundle` [rules-bundle] Controls which changelogs are included in bundles. -These rules are applied by the `docs-builder changelog bundle` and `docs-builder changelog gh-release` commands **after** the primary filter (`--prs`, `--issues`, `--all`, or `--input-products`) has identified the relevant changelogs. +These rules are applied by the `docs-builder changelog bundle` and `docs-builder changelog gh-release` commands **after** the primary filter (`--prs`, `--issues`, `--all`, or `--input-products`) has identified the relevant changelogs and **before** the bundle is written. + +:::{tip} +The input stage (gathering entries) and bundle filtering stage (filtering for output) are conceptually separate. +::: These settings are located in the `rules.bundle` section of the configuration file: @@ -296,9 +365,9 @@ These settings are located in the `rules.bundle` section of the configuration fi | `exclude_areas` | string or list | Changelog areas to exclude from the bundle. | | `exclude_products` | string or list | Changelog products to exclude from the bundle. | | `exclude_types` | string or list | Changelog types to exclude from the bundle. | -| `include_areas` | string or list | Only changelogs with these areas are included. | -| `include_products` | string or list | Only changelogs with these product IDs are included. | -| `include_types` | string or list | Only changelogs with these types are included. | +| `include_areas` | string or list | Changelog areas to include in the bundle. | +| `include_products` | string or list | Changelog products to include in the bundle. | +| `include_types` | string or list | Changelog types to include in the bundle. | | `match_areas` | string | Override `rules.match` for area matching. Values: `any`, `all`, `conjunction`. | | `match_products` | string | Override `rules.match` for product matching. Values: `any`, `all`, `conjunction`. | | `products` | map | Per-product type/area filter overrides. Refer to [](#rules-bundle-products).| diff --git a/docs/contribute/configure-changelogs.md b/docs/contribute/configure-changelogs.md index 7dfc230b27..35ef796b14 100644 --- a/docs/contribute/configure-changelogs.md +++ b/docs/contribute/configure-changelogs.md @@ -64,7 +64,23 @@ For example, run the following command in your GitHub repo's root directory: docs-builder changelog init ``` -By default, it creates `docs/changelog.yml` file that contains settings like this: +## Review the settings + +1. Find the configuration file. + By default, the `changelog init` command creates `docs/changelog.yml`. + If you move or rename this file, there will be extra steps when you configure its [usage](#usage). +1. Review the settings and update them based on your preferences. + For descriptions of all the settings, refer to [](/contribute/configure-changelogs-ref.md). + For example: + - To limit the acceptable values, update [lifecycles](/contribute/configure-changelogs-ref.md#lifecycles) and [products](/contribute/configure-changelogs-ref.md#products). + - To set the changelog file name pattern, update [filename](/contribute/configure-changelogs-ref.md#filename). + - To control what is extracted from GitHub, update [extract](/contribute/configure-changelogs-ref.md#extract). + - To change bundle default behaviour or create reusable profiles, update [bundle](/contribute/configure-changelogs-ref.md#bundle). + - If you have GitHub labels to set changelog areas, types, or products, update [pivot](/contribute/configure-changelogs-ref.md#pivot). + - If you have GitHub labels to opt in or out of changelogs, update [rules.create](/contribute/configure-changelogs-ref.md#rules-create). + - If you want to filter changelogs in or out of release bundles based on their area, type, or products, update [rules.bundle](/contribute/configure-changelogs-ref.md#rules-bundle). + +For example, a simple `docs/changelog.yml` looks like this: ```yml filename: timestamp @@ -88,27 +104,11 @@ pivot: For the most up-to-date changelog configuration options, refer to [changelog.example.yml](https://github.com/elastic/docs-builder/blob/main/config/changelog.example.yml). -For descriptions of all the settings, refer to [Changelog configuration reference](/contribute/configure-changelogs-ref.md) - -## Rules for creation and bundling [rules] - -If you have pull request labels that indicate a changelog is not required (such as `>non-issue` or `release_note:skip`), you can declare these in the `rules.create` section of the changelog configuration. - -When you run the `docs-builder changelog add` command with the `--prs` or `--issues` options and the pull request or issue has one of the identified labels, the command does not create a changelog. - -Likewise, if you want to exclude changelogs with certain products, areas, or types from the release bundles, you can declare these in the `rules.bundle` section of the changelog configuration. -For example, you might choose to omit `other` or `docs` changelogs. -Or you might want to omit all autoscaling-related changelogs from the Cloud Serverless release bundles. - -You can define rules at the global level (applies to all products) or for specific products. -Product-specific rules override the global rules entirely—they do not merge. -For details, refer to [Rules](/contribute/configure-changelogs-ref.md#rules). - -## Use a changelog configuration file +## Next steps [usage] After you've created a config file, all subsequent changelog commands can use it. -By default, they look for `docs/changelog.yml` but you can specify a different path with the `--config` command option. - -For specific details about the usage and impact of the configuration file, refer to the [changelog commands](/cli/changelog/index.md). +By default, they look for `changelog.yml` by checking `./changelog.yml` then `./docs/changelog.yml` relative to your current directory. +Most changelog commands allow you to specify a different path with the `--config` option. -The [changelog directive](/syntax/changelog.md) also uses the changelog configuration file and you can specify a non-default path if necessary. +1. Start [creating changelog files](/contribute/create-changelogs.md). +1. You might need to make further changes to your configuration file after you've performed a few tests. Likewise you might need to add bundle settings or profiles when you're ready to [create release bundles](/contribute/bundle-changelogs.md). diff --git a/docs/contribute/create-changelogs.md b/docs/contribute/create-changelogs.md index 34251447b3..6ce147d8fe 100644 --- a/docs/contribute/create-changelogs.md +++ b/docs/contribute/create-changelogs.md @@ -52,6 +52,8 @@ If you already have automated release notes for GitHub releases, you can use the ## Create changelogs from GitHub actions [github-actions] +For details about this method, refer to the [README](https://github.com/elastic/docs-actions/blob/main/changelog/README.md). + When automated via the [changelog GitHub Actions](https://github.com/elastic/docs-actions/tree/main/changelog), changelog creation is a two-step process: 1. `changelog evaluate-pr` inspects the PR (title, labels, body) and produces outputs such as `title`, `type`, `description`, and `products`. @@ -93,10 +95,10 @@ You can further limit the possible values with the [products](/contribute/config ## Examples -### Control changelog creation [example-block-label] +### Control changelog creation [rules] -You can prevent changelog creation for PRs based on their labels. -For example, your configuration file can contain a `rules.create` section like this: +If you want to automatically block the creation of changelogs for pull requests or issues based on their labels, you can accomplish this with rules in your changelog configuration file. +For example, your `changelog.yml` file can contain a `rules.create` section like this: ```yaml rules: @@ -110,7 +112,11 @@ rules: exclude: ">non-issue, >test" ``` -Those settings affect commands with the `--prs` or `--issues` options, for example: +You can define rules at the global level (applies to all products) or for specific products (`cloud-serverless` in this example). +Product-specific rules override the global rules entirely—they do not merge. + +When you run the `docs-builder changelog add` command with the `--prs` or `--issues` options and the pull request or issue has one of the identified labels, the command does not create a changelog. +For example: ```sh docs-builder changelog add --prs "1234, 5678" \ @@ -120,8 +126,7 @@ docs-builder changelog add --prs "1234, 5678" \ If PR 1234 has the `>non-issue` or `>test` labels, it will be skipped and no changelog will be created. If PR 5678 does not have any blocking labels, a changelog is created. -Alternatively, you can define `rules.create.include` labels. -For example, to only create changelogs for PRs with specific labels: +Alternatively, you can define `rules.create.include` to only create changelogs for PRs with specific labels: ```yaml rules: @@ -169,4 +174,9 @@ The option precedence is: CLI option > `changelog.yml` bundle section > built-in ::: You can use the `docs-builder changelog gh-release` command as a one-shot alternative to `changelog add` and `changelog bundle` commands. -The command parses the release notes, creates one changelog file per pull request found, and creates a `changelog-bundle.yaml` file — all in a single step. Refer to [](/cli/changelog/gh-release.md) \ No newline at end of file +The command parses the release notes, creates one changelog file per pull request found, and creates a `changelog-bundle.yaml` file — all in a single step. Refer to [](/cli/changelog/gh-release.md). + +## Next steps + +After you've created a changelog files, you can gather them into [release bundles](/contribute/bundle-changelogs.md). +The release bundles are ultimately used to generate [release docs](/contribute/publish-changelogs.md). \ No newline at end of file diff --git a/docs/syntax/changelog.md b/docs/syntax/changelog.md index 16c752d840..e102681b23 100644 --- a/docs/syntax/changelog.md +++ b/docs/syntax/changelog.md @@ -160,10 +160,9 @@ Both explicit and auto-discovered paths must resolve within the repository check You can filter changelog entries at bundle time using the `rules.bundle` configuration in your `changelog.yml` file. This is evaluated during `changelog bundle` and `changelog gh-release`, before the bundle is written. Entries that don't match are excluded from the bundle entirely. -The `{changelog}` directive and the `changelog render` command both do not apply `rules.publish`. To filter entries, use `rules.bundle` at bundle time so entries are excluded before bundling. Both receive only the bundled entries. See the [changelog bundle documentation](/cli/changelog/bundle.md#changelog-bundle-rules) for full syntax. - +The `{changelog}` directive and the `changelog render` command both do not apply `rules.publish`. To filter entries, use `rules.bundle` at bundle time so entries are excluded before bundling. Both receive only the bundled entries. `rules.bundle` supports product, type, and area filtering, and per-product overrides. -For full syntax, refer to the [rules for filtered bundles](/cli/changelog/bundle.md#changelog-bundle-rules). +For full syntax, refer to the [](/contribute/configure-changelogs-ref.md#rules-bundle). ## Hiding features From 38fc55e262379ec4bf0e5eb24b006d8a17a76a89 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Thu, 7 May 2026 22:26:44 +0700 Subject: [PATCH 43/50] fix: upgrade @tanstack/react-query from 5.97.0 to 5.99.0 (#3258) Snyk has created this PR to upgrade @tanstack/react-query from 5.97.0 to 5.99.0. See this package in npm: @tanstack/react-query See this project in Snyk: https://app.snyk.io/org/docs-wmk/project/69782e43-c85b-4c27-afd1-ad863be7a38a?utm_source=github&utm_medium=referral&page=upgrade-pr Co-authored-by: snyk-bot --- src/Elastic.Documentation.Site/package-lock.json | 16 ++++++++-------- src/Elastic.Documentation.Site/package.json | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/Elastic.Documentation.Site/package-lock.json b/src/Elastic.Documentation.Site/package-lock.json index d04fa14795..8352ce873e 100644 --- a/src/Elastic.Documentation.Site/package-lock.json +++ b/src/Elastic.Documentation.Site/package-lock.json @@ -27,7 +27,7 @@ "@opentelemetry/sdk-trace-web": "^2.7.0", "@opentelemetry/semantic-conventions": "^1.40.0", "@r2wc/react-to-web-component": "2.1.1", - "@tanstack/react-query": "^5.97.0", + "@tanstack/react-query": "^5.99.0", "@theletterf/beautiful-mermaid": "0.1.5", "@uidotdev/usehooks": "2.4.1", "dompurify": "3.4.0", @@ -23889,9 +23889,9 @@ } }, "node_modules/@tanstack/query-core": { - "version": "5.97.0", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.97.0.tgz", - "integrity": "sha512-QdpLP5VzVMgo4VtaPppRA2W04UFjIqX+bxke/ZJhE5cfd5UPkRzqIAJQt9uXkQJjqE8LBOMbKv7f8HCsZltXlg==", + "version": "5.99.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.99.0.tgz", + "integrity": "sha512-3Jv3WQG0BCcH7G+7lf/bP8QyBfJOXeY+T08Rin3GZ1bshvwlbPt7NrDHMEzGdKIOmOzvIQmxjk28YEQX60k7pQ==", "license": "MIT", "funding": { "type": "github", @@ -23899,12 +23899,12 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.97.0", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.97.0.tgz", - "integrity": "sha512-y4So4eGcQoK2WVMAcDNZE9ofB/p5v1OlKvtc1F3uqHwrtifobT7q+ZnXk2mRkc8E84HKYSlAE9z6HXl2V0+ySQ==", + "version": "5.99.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.99.0.tgz", + "integrity": "sha512-OY2bCqPemT1LlqJ8Y2CUau4KELnIhhG9Ol3ZndPbdnB095pRbPo1cHuXTndg8iIwtoHTgwZjyaDnQ0xD0mYwAw==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.97.0" + "@tanstack/query-core": "5.99.0" }, "funding": { "type": "github", diff --git a/src/Elastic.Documentation.Site/package.json b/src/Elastic.Documentation.Site/package.json index 7d338d0785..038e1e2ac9 100644 --- a/src/Elastic.Documentation.Site/package.json +++ b/src/Elastic.Documentation.Site/package.json @@ -117,7 +117,7 @@ "@opentelemetry/sdk-trace-web": "^2.7.0", "@opentelemetry/semantic-conventions": "^1.40.0", "@r2wc/react-to-web-component": "2.1.1", - "@tanstack/react-query": "^5.97.0", + "@tanstack/react-query": "^5.99.0", "@uidotdev/usehooks": "2.4.1", "@theletterf/beautiful-mermaid": "0.1.5", "dompurify": "3.4.0", From fcad874b1f4a040fce18740f37e5a77ed252b24c Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Thu, 7 May 2026 17:28:49 +0200 Subject: [PATCH 44/50] Skip AWS auth in docs preview when docs build fails (#3269) Co-authored-by: Claude Sonnet 4.6 (1M context) Co-authored-by: Cursor --- .github/workflows/docs-preview-local.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/docs-preview-local.yml b/.github/workflows/docs-preview-local.yml index 51c9096e74..764adb6b51 100644 --- a/.github/workflows/docs-preview-local.yml +++ b/.github/workflows/docs-preview-local.yml @@ -328,8 +328,11 @@ jobs: - uses: elastic/docs-builder/.github/actions/aws-auth@main if: > - !cancelled() + env.MATCH == 'true' + && !cancelled() && needs.check.outputs.any_modified != 'false' + && steps.internal-docs-build.outputs.skip != 'true' + && steps.internal-docs-build.outcome == 'success' && steps.internal-validate-path-prefixes.outcome == 'success' - name: Upload to S3 From 701e6d14de005ac82efcb94b49f7767a03a2405c Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Thu, 7 May 2026 20:53:16 +0200 Subject: [PATCH 45/50] feat(cli-docs): auto-generate CLI reference from schema JSON (#3221) Co-authored-by: Claude Sonnet 4.6 (1M context) --- .github/workflows/ci.yml | 7 + Directory.Packages.props | 8 +- docs/_docset.yml | 52 +- docs/_redirects.yml | 53 +- docs/building-blocks/inbound-cross-links.md | 4 +- docs/cli-schema.json | 5940 +++++++++++++++++ docs/cli/assembler/assemble.md | 91 - .../assembler-bloom-filter-create.md | 24 - .../assembler-bloom-filter-lookup.md | 17 - docs/cli/assembler/assembler-build.md | 51 - docs/cli/assembler/assembler-clone.md | 29 - docs/cli/assembler/assembler-config-init.md | 31 - .../assembler-content-source-match.md | 33 - .../assembler-content-source-validate.md | 13 - docs/cli/assembler/assembler-deploy-apply.md | 24 - docs/cli/assembler/assembler-deploy-plan.md | 27 - .../assembler-deploy-update-redirects.md | 21 - docs/cli/assembler/assembler-index.md | 75 - ...bler-navigation-validate-link-reference.md | 20 - .../assembler-navigation-validate.md | 13 - docs/cli/assembler/assembler-serve.md | 21 - docs/cli/assembler/index.md | 64 - docs/cli/changelog/add.md | 241 - docs/cli/changelog/bundle-amend.md | 125 - docs/cli/changelog/bundle.md | 611 -- docs/cli/changelog/cmd-bundle.md | 138 + docs/cli/changelog/evaluate-pr.md | 99 - docs/cli/changelog/gh-release.md | 102 - docs/cli/changelog/index.md | 21 +- docs/cli/changelog/init.md | 71 - docs/cli/changelog/remove.md | 203 - docs/cli/changelog/render.md | 143 - docs/cli/cli-reference-how-to.md | 141 + docs/cli/docset/build.md | 65 - docs/cli/docset/diff-validate.md | 18 - docs/cli/docset/format.md | 145 - docs/cli/docset/index-command.md | 71 - docs/cli/docset/index.md | 26 - docs/cli/docset/mv.md | 25 - docs/cli/docset/serve.md | 33 - docs/cli/index.md | 54 - docs/cli/installation.md | 39 + docs/cli/links/inbound-links-validate-all.md | 9 - .../inbound-links-validate-link-reference.md | 17 - docs/cli/links/inbound-links-validate.md | 17 - docs/cli/links/index.md | 15 - docs/cli/shell-autocompletion.md | 40 + docs/contribute/bundle-changelogs.md | 237 +- docs/contribute/create-changelogs.md | 4 +- docs/syntax/changelog.md | 5 +- docs/syntax/directives.md | 1 + docs/syntax/page-card.md | 119 + .../FileSystemFactory.cs | 23 +- .../Toc/CliReference/CliReferenceRef.cs | 21 + .../Toc/CliReference/CliSchema.cs | 137 + .../Toc/CliReference/CliSchemaJsonContext.cs | 11 + .../Toc/DocumentationSetFile.cs | 64 + .../Toc/TableOfContentsYamlConverters.cs | 9 + .../Node/DocumentationSetNavigation.cs | 167 + .../Assets/styles.css | 94 + .../Navigation/_TocTreeNav.cshtml | 35 +- .../Extensions/CliReference/CliCommandFile.cs | 61 + .../CliReference/CliMarkdownGenerator.cs | 661 ++ .../CliReference/CliNamespaceFile.cs | 61 + .../CliReferenceDocsBuilderExtension.cs | 389 ++ .../Extensions/CliReference/CliRootFile.cs | 54 + src/Elastic.Markdown/IO/DocumentationSet.cs | 27 + src/Elastic.Markdown/IO/MarkdownFile.cs | 2 +- .../Layout/_Breadcrumbs.cshtml | 7 +- .../Layout/_PrevNextNav.cshtml | 9 +- .../Myst/Directives/DirectiveBlockParser.cs | 4 + .../Myst/Directives/DirectiveHtmlRenderer.cs | 15 + .../Myst/Directives/PageCard/PageCardBlock.cs | 62 + .../Directives/PageCard/PageCardView.cshtml | 23 + .../Directives/PageCard/PageCardViewModel.cs | 11 + .../DiagnosticLinkInlineParser.cs | 5 +- src/Elastic.Markdown/Myst/MarkdownParser.cs | 1 + .../Renderers/DefinitionTermAnchorRenderer.cs | 182 + .../Elastic.Documentation.Refactor/Move.cs | 4 + .../Commands/Assembler/AssemblerCommands.cs | 4 + .../Commands/Assembler/DeployCommands.cs | 6 + .../docs-builder/Commands/ChangelogCommand.cs | 7 +- .../Commands/Codex/CodexCommands.cs | 4 + .../Commands/Codex/CodexIndexCommand.cs | 3 + .../docs-builder/Commands/IndexCommand.cs | 3 + .../Commands/IsolatedBuildCommand.cs | 3 + .../docs-builder/Commands/MoveCommand.cs | 7 +- .../docs-builder/Commands/ServeCommand.cs | 4 +- .../docs-builder/Http/InMemoryBuildState.cs | 13 +- .../Http/ReloadGeneratorService.cs | 4 +- .../PhysicalDocsetTests.cs | 15 +- .../authoring/Generator/LinkReferenceFile.fs | 81 +- 92 files changed, 8938 insertions(+), 2808 deletions(-) create mode 100644 docs/cli-schema.json delete mode 100644 docs/cli/assembler/assemble.md delete mode 100644 docs/cli/assembler/assembler-bloom-filter-create.md delete mode 100644 docs/cli/assembler/assembler-bloom-filter-lookup.md delete mode 100644 docs/cli/assembler/assembler-build.md delete mode 100644 docs/cli/assembler/assembler-clone.md delete mode 100644 docs/cli/assembler/assembler-config-init.md delete mode 100644 docs/cli/assembler/assembler-content-source-match.md delete mode 100644 docs/cli/assembler/assembler-content-source-validate.md delete mode 100644 docs/cli/assembler/assembler-deploy-apply.md delete mode 100644 docs/cli/assembler/assembler-deploy-plan.md delete mode 100644 docs/cli/assembler/assembler-deploy-update-redirects.md delete mode 100644 docs/cli/assembler/assembler-index.md delete mode 100644 docs/cli/assembler/assembler-navigation-validate-link-reference.md delete mode 100644 docs/cli/assembler/assembler-navigation-validate.md delete mode 100644 docs/cli/assembler/assembler-serve.md delete mode 100644 docs/cli/assembler/index.md delete mode 100644 docs/cli/changelog/add.md delete mode 100644 docs/cli/changelog/bundle-amend.md delete mode 100644 docs/cli/changelog/bundle.md create mode 100644 docs/cli/changelog/cmd-bundle.md delete mode 100644 docs/cli/changelog/evaluate-pr.md delete mode 100644 docs/cli/changelog/gh-release.md delete mode 100644 docs/cli/changelog/init.md delete mode 100644 docs/cli/changelog/remove.md delete mode 100644 docs/cli/changelog/render.md create mode 100644 docs/cli/cli-reference-how-to.md delete mode 100644 docs/cli/docset/build.md delete mode 100644 docs/cli/docset/diff-validate.md delete mode 100644 docs/cli/docset/format.md delete mode 100644 docs/cli/docset/index-command.md delete mode 100644 docs/cli/docset/index.md delete mode 100644 docs/cli/docset/mv.md delete mode 100644 docs/cli/docset/serve.md delete mode 100644 docs/cli/index.md create mode 100644 docs/cli/installation.md delete mode 100644 docs/cli/links/inbound-links-validate-all.md delete mode 100644 docs/cli/links/inbound-links-validate-link-reference.md delete mode 100644 docs/cli/links/inbound-links-validate.md delete mode 100644 docs/cli/links/index.md create mode 100644 docs/cli/shell-autocompletion.md create mode 100644 docs/syntax/page-card.md create mode 100644 src/Elastic.Documentation.Configuration/Toc/CliReference/CliReferenceRef.cs create mode 100644 src/Elastic.Documentation.Configuration/Toc/CliReference/CliSchema.cs create mode 100644 src/Elastic.Documentation.Configuration/Toc/CliReference/CliSchemaJsonContext.cs create mode 100644 src/Elastic.Markdown/Extensions/CliReference/CliCommandFile.cs create mode 100644 src/Elastic.Markdown/Extensions/CliReference/CliMarkdownGenerator.cs create mode 100644 src/Elastic.Markdown/Extensions/CliReference/CliNamespaceFile.cs create mode 100644 src/Elastic.Markdown/Extensions/CliReference/CliReferenceDocsBuilderExtension.cs create mode 100644 src/Elastic.Markdown/Extensions/CliReference/CliRootFile.cs create mode 100644 src/Elastic.Markdown/Myst/Directives/PageCard/PageCardBlock.cs create mode 100644 src/Elastic.Markdown/Myst/Directives/PageCard/PageCardView.cshtml create mode 100644 src/Elastic.Markdown/Myst/Directives/PageCard/PageCardViewModel.cs create mode 100644 src/Elastic.Markdown/Myst/Renderers/DefinitionTermAnchorRenderer.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 302edbc4f0..db20400825 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -158,6 +158,13 @@ jobs: - name: Compile run: dotnet run --project build -c release -- compile + - name: Check CLI schema is up to date + if: ${{ matrix.os == 'ubuntu-latest' }} + run: | + dotnet run --project src/tooling/docs-builder -c release --no-build -- __schema > docs/cli-schema.json.tmp + diff docs/cli-schema.json docs/cli-schema.json.tmp || (echo "docs/cli-schema.json is out of date — run: dotnet run --project src/tooling/docs-builder -- __schema > docs/cli-schema.json" && exit 1) + rm docs/cli-schema.json.tmp + - name: Test run: dotnet run --project build -c release -- unit-test diff --git a/Directory.Packages.props b/Directory.Packages.props index bd18e9e65f..70842ebe1c 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -70,9 +70,9 @@ - - - + + + @@ -89,7 +89,7 @@ - + diff --git a/docs/_docset.yml b/docs/_docset.yml index 8c56fc0d0a..d2bc1c11b5 100644 --- a/docs/_docset.yml +++ b/docs/_docset.yml @@ -150,6 +150,7 @@ toc: - file: line_breaks.md - file: links.md - file: list-sub-pages.md + - file: page-card.md - file: passthrough.md - file: sidebars.md - file: stepper.md @@ -160,53 +161,12 @@ toc: - file: tabs.md - file: tagged_regions.md - file: titles.md - - folder: cli + - cli: cli-schema.json + folder: cli children: - - file: index.md - - folder: docset - children: - - file: index.md - - file: build.md - - file: diff-validate.md - - file: format.md - - file: index-command.md - - file: mv.md - - file: serve.md - - folder: assembler - children: - - file: index.md - - file: assemble.md - - file: assembler-bloom-filter-create.md - - file: assembler-bloom-filter-lookup.md - - file: assembler-build.md - - file: assembler-clone.md - - file: assembler-config-init.md - - file: assembler-content-source-match.md - - file: assembler-content-source-validate.md - - file: assembler-deploy-apply.md - - file: assembler-deploy-plan.md - - file: assembler-deploy-update-redirects.md - - file: assembler-index.md - - file: assembler-navigation-validate.md - - file: assembler-navigation-validate-link-reference.md - - file: assembler-serve.md - - folder: links - children: - - file: index.md - - file: inbound-links-validate.md - - file: inbound-links-validate-all.md - - file: inbound-links-validate-link-reference.md - - folder: changelog - children: - - file: index.md - - file: add.md - - file: bundle.md - - file: bundle-amend.md - - file: evaluate-pr.md - - file: gh-release.md - - file: init.md - - file: remove.md - - file: render.md + - file: installation.md + - file: shell-autocompletion.md + - file: cli-reference-how-to.md - folder: mcp children: - file: index.md diff --git a/docs/_redirects.yml b/docs/_redirects.yml index 350e9d57b1..efb262699e 100644 --- a/docs/_redirects.yml +++ b/docs/_redirects.yml @@ -1,18 +1,45 @@ redirects: 'migration/freeze/gh-action.md' : 'index.md' 'migration/freeze/index.md' : 'index.md' - 'cli/mcp.md': 'index.md' - 'cli/release/changelog-add.md': 'cli/changelog/add.md' - 'cli/release/changelog-evaluate-artifact.md': 'index.md' - 'cli/release/changelog-evaluate-pr.md': 'cli/changelog/evaluate-pr.md' - 'cli/release/changelog-bundle.md': 'cli/changelog/bundle.md' - 'cli/release/changelog-bundle-amend.md': 'cli/changelog/bundle-amend.md' - 'cli/release/changelog-gh-release.md': 'cli/changelog/gh-release.md' - 'cli/release/changelog-init.md': 'cli/changelog/init.md' - 'cli/release/changelog-prepare-artifact.md': 'index.md' - 'cli/release/changelog-remove.md': 'cli/changelog/remove.md' - 'cli/release/changelog-render.md': 'cli/changelog/render.md' - 'cli/release/index.md': 'cli/changelog/index.md' + # CLI docs: old static pages replaced by schema-generated equivalents. + # Targets use physical pages so redirect validation works in authoring tests. + # The CLI schema-generated pages (synthetic) serve the real content in production. + 'cli/index.md': 'cli/installation.md' + 'cli/assembler/index.md': 'cli/installation.md' + 'cli/changelog/add.md': 'cli/changelog/index.md' + 'cli/changelog/bundle-amend.md': 'cli/changelog/index.md' + 'cli/changelog/bundle.md': 'cli/changelog/index.md' + 'cli/changelog/evaluate-pr.md': 'cli/changelog/index.md' + 'cli/changelog/gh-release.md': 'cli/changelog/index.md' + 'cli/changelog/init.md': 'cli/changelog/index.md' + 'cli/changelog/remove.md': 'cli/changelog/index.md' + 'cli/changelog/render.md': 'cli/changelog/index.md' + 'cli/assembler/assemble.md': 'cli/installation.md' + 'cli/assembler/assembler-bloom-filter-create.md': 'cli/installation.md' + 'cli/assembler/assembler-bloom-filter-lookup.md': 'cli/installation.md' + 'cli/assembler/assembler-build.md': 'cli/installation.md' + 'cli/assembler/assembler-clone.md': 'cli/installation.md' + 'cli/assembler/assembler-config-init.md': 'cli/installation.md' + 'cli/assembler/assembler-content-source-match.md': 'cli/installation.md' + 'cli/assembler/assembler-content-source-validate.md': 'cli/installation.md' + 'cli/assembler/assembler-deploy-apply.md': 'cli/installation.md' + 'cli/assembler/assembler-deploy-plan.md': 'cli/installation.md' + 'cli/assembler/assembler-deploy-update-redirects.md': 'cli/installation.md' + 'cli/assembler/assembler-index.md': 'cli/installation.md' + 'cli/assembler/assembler-navigation-validate-link-reference.md': 'cli/installation.md' + 'cli/assembler/assembler-navigation-validate.md': 'cli/installation.md' + 'cli/assembler/assembler-serve.md': 'cli/installation.md' + 'cli/docset/index.md': 'cli/installation.md' + 'cli/docset/build.md': 'cli/installation.md' + 'cli/docset/diff-validate.md': 'cli/installation.md' + 'cli/docset/format.md': 'cli/installation.md' + 'cli/docset/index-command.md': 'cli/installation.md' + 'cli/docset/mv.md': 'cli/installation.md' + 'cli/docset/serve.md': 'cli/installation.md' + 'cli/links/index.md': 'cli/installation.md' + 'cli/links/inbound-links-validate.md': 'cli/installation.md' + 'cli/links/inbound-links-validate-all.md': 'cli/installation.md' + 'cli/links/inbound-links-validate-link-reference.md': 'cli/installation.md' 'testing/redirects/4th-page.md': 'testing/redirects/5th-page.md' 'testing/redirects/9th-page.md': '!testing/redirects/5th-page.md' 'testing/redirects/6th-page.md': @@ -35,4 +62,4 @@ redirects: "yy": "bb" 'testing/redirects/third-page.md': anchors: - 'removed-anchor': \ No newline at end of file + 'removed-anchor': diff --git a/docs/building-blocks/inbound-cross-links.md b/docs/building-blocks/inbound-cross-links.md index 207aca023a..cc72e84891 100644 --- a/docs/building-blocks/inbound-cross-links.md +++ b/docs/building-blocks/inbound-cross-links.md @@ -16,9 +16,9 @@ Inbound cross-link validation allows you to: ## How it works -A regular [build](../cli/docset/build.md) of a documentation set won't validate inbound links automatically. +A regular [build](../cli/build.md) of a documentation set won't validate inbound links automatically. -You have to use the [inbound-links validate-link-reference](../cli/links/inbound-links-validate-link-reference.md) after a build to validate all inbound links. +You have to use the [inbound-links validate-link-reference](../cli/inbound-links/validate-link-reference.md) after a build to validate all inbound links. The reason for this is that validating all inbound links has to download all published [Link Index](link-index.md) files for the current [Content Source](../configure/content-sources.md). diff --git a/docs/cli-schema.json b/docs/cli-schema.json new file mode 100644 index 0000000000..99fae06718 --- /dev/null +++ b/docs/cli-schema.json @@ -0,0 +1,5940 @@ +{ + "schemaVersion": 1, + "name": "docs-builder", + "version": "1.0.0.0", + "description": null, + "reservedMetaCommands": [ + "__complete", + "__completion", + "__schema" + ], + "globalOptions": [ + { + "role": "flag", + "name": "log-level", + "shortName": "l", + "type": "enum", + "required": false, + "summary": "Minimum log level. Default: information", + "enumValues": [ + "trace", + "debug", + "information", + "warning", + "error", + "critical", + "none" + ] + }, + { + "role": "flag", + "name": "config-source", + "shortName": "c", + "type": "enum", + "required": false, + "summary": "Override the configuration source: local, remote", + "enumValues": [ + "local", + "remote", + "embedded" + ] + }, + { + "role": "flag", + "name": "skip-private-repositories", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Skip cloning private repositories" + } + ], + "rootDefault": null, + "commands": [ + { + "path": [], + "name": "assemble", + "summary": "Clone all repositories and build the unified documentation site in one step.", + "notes": "The assembler clones multiple documentation repositories and builds them into a single unified site\ncomposed by a shared navigation.yml. This command combines assembler config init,\nassembler clone, and assembler build into a single invocation.", + "usage": "docs-builder assemble [options]", + "examples": [], + "parameters": [ + { + "role": "flag", + "name": "strict", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Treat warnings as errors." + }, + { + "role": "flag", + "name": "environment", + "shortName": null, + "type": "string", + "required": false, + "summary": "Named deployment target, e.g. dev, staging, production. Determines which configuration branch and index names are used." + }, + { + "role": "flag", + "name": "metadata-only", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Write only metadata files; skip HTML generation. Ignored when --exporters is also set." + }, + { + "role": "flag", + "name": "show-hints", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Print documentation hints emitted during the build." + }, + { + "role": "flag", + "name": "exporters", + "shortName": null, + "type": "string", + "required": false, + "summary": "Comma-separated list of exporters to run." + }, + { + "role": "flag", + "name": "assume-build", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Skip the build step when .artifacts/docs/index.html already exists. Intended for test scenarios only." + }, + { + "role": "flag", + "name": "fetch-latest", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Fetch the HEAD of each branch instead of the pinned link-registry ref.", + "defaultValue": "default" + }, + { + "role": "flag", + "name": "assume-cloned", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Skip cloning; assume repositories are already on disk. Useful for iterating on the build.", + "defaultValue": "default" + }, + { + "role": "flag", + "name": "serve", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Serve the site on port 4000 after a successful build.", + "defaultValue": "false" + }, + { + "role": "flag", + "name": "log-level", + "shortName": "l", + "type": "enum", + "required": false, + "summary": "Minimum log level. Default: information", + "enumValues": [ + "trace", + "debug", + "information", + "warning", + "error", + "critical", + "none" + ] + }, + { + "role": "flag", + "name": "config-source", + "shortName": "c", + "type": "enum", + "required": false, + "summary": "Override the configuration source: local, remote", + "enumValues": [ + "local", + "remote", + "embedded" + ] + }, + { + "role": "flag", + "name": "skip-private-repositories", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Skip cloning private repositories" + } + ] + }, + { + "path": [], + "name": "build", + "summary": "Build a single documentation set from source.", + "notes": "Locates the documentation root by searching for a docset.yml file starting at options .Path.\nThe output directory is wiped and rebuilt on each run unless incremental build detects no changes.", + "usage": "docs-builder build [options]", + "examples": [], + "parameters": [ + { + "role": "flag", + "name": "path", + "shortName": "p", + "type": "string", + "required": false, + "summary": "Root directory of the documentation source. Defaults to cwd/docs.", + "validations": [ + { + "kind": "rejectSymbolicLinks", + "min": null, + "max": null, + "pattern": null, + "values": null + }, + { + "kind": "existing", + "min": null, + "max": null, + "pattern": null, + "values": null + }, + { + "kind": "expandUserProfile", + "min": null, + "max": null, + "pattern": null, + "values": null + } + ] + }, + { + "role": "flag", + "name": "output", + "shortName": "o", + "type": "string", + "required": false, + "summary": "Destination for generated HTML. Defaults to .artifacts/html.", + "validations": [ + { + "kind": "rejectSymbolicLinks", + "min": null, + "max": null, + "pattern": null, + "values": null + }, + { + "kind": "expandUserProfile", + "min": null, + "max": null, + "pattern": null, + "values": null + } + ] + }, + { + "role": "flag", + "name": "path-prefix", + "shortName": null, + "type": "string", + "required": false, + "summary": "URL path prefix prepended to every generated link." + }, + { + "role": "flag", + "name": "force", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Delete and rebuild the output folder even if nothing changed." + }, + { + "role": "flag", + "name": "strict", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Treat warnings as errors." + }, + { + "role": "flag", + "name": "allow-indexing", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Emit meta robots tags that allow search engine indexing." + }, + { + "role": "flag", + "name": "metadata-only", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Write only metadata files; skip HTML generation. Ignored when --exporters is also set." + }, + { + "role": "flag", + "name": "exporters", + "shortName": null, + "type": "string", + "required": false, + "summary": "Comma-separated list of exporters to run." + }, + { + "role": "flag", + "name": "canonical-base-url", + "shortName": null, + "type": "string", + "required": false, + "summary": "Base URL written into \u003Clink rel=canonical\u003E tags.", + "validations": [ + { + "kind": "uriScheme", + "min": null, + "max": null, + "pattern": null, + "values": [ + "http", + "https" + ] + } + ] + }, + { + "role": "flag", + "name": "skip-api", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Skip OpenAPI spec generation for faster builds." + }, + { + "role": "flag", + "name": "skip-cross-links", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Skip fetching cross-doc-set link indexes." + }, + { + "role": "flag", + "name": "in-memory", + "shortName": null, + "type": "boolean", + "required": false, + "summary": null, + "defaultValue": "false" + }, + { + "role": "flag", + "name": "log-level", + "shortName": "l", + "type": "enum", + "required": false, + "summary": "Minimum log level. Default: information", + "enumValues": [ + "trace", + "debug", + "information", + "warning", + "error", + "critical", + "none" + ] + }, + { + "role": "flag", + "name": "config-source", + "shortName": "c", + "type": "enum", + "required": false, + "summary": "Override the configuration source: local, remote", + "enumValues": [ + "local", + "remote", + "embedded" + ] + }, + { + "role": "flag", + "name": "skip-private-repositories", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Skip cloning private repositories" + } + ], + "intent": { + "idempotent": true, + "scope": "directory" + } + }, + { + "path": [], + "name": "diff", + "summary": "Verify every renamed or removed page in the current branch has a redirect entry.", + "notes": "Compares the git diff of the working branch against the redirect file. Exits 1 if any moved\nor deleted page is missing a redirect entry. Run before merging to catch broken links early.", + "usage": "docs-builder diff [options]", + "examples": [], + "parameters": [ + { + "role": "flag", + "name": "path", + "shortName": "p", + "type": "string", + "required": false, + "summary": "Root of the documentation source. Defaults to cwd/docs." + }, + { + "role": "flag", + "name": "log-level", + "shortName": "l", + "type": "enum", + "required": false, + "summary": "Minimum log level. Default: information", + "enumValues": [ + "trace", + "debug", + "information", + "warning", + "error", + "critical", + "none" + ] + }, + { + "role": "flag", + "name": "config-source", + "shortName": "c", + "type": "enum", + "required": false, + "summary": "Override the configuration source: local, remote", + "enumValues": [ + "local", + "remote", + "embedded" + ] + }, + { + "role": "flag", + "name": "skip-private-repositories", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Skip cloning private repositories" + } + ] + }, + { + "path": [], + "name": "format", + "summary": "Fix common formatting issues (irregular spacing, trailing whitespace) across documentation files.", + "notes": "Exactly one of --check or --write must be specified.", + "usage": "docs-builder format [options]", + "examples": [], + "parameters": [ + { + "role": "flag", + "name": "path", + "shortName": "p", + "type": "string", + "required": false, + "summary": "Documentation root. Defaults to cwd." + }, + { + "role": "flag", + "name": "check", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Report files that need formatting without modifying them. Exits 1 when any file is out of format.", + "defaultValue": "false" + }, + { + "role": "flag", + "name": "write", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Apply formatting changes in place.", + "defaultValue": "false" + }, + { + "role": "flag", + "name": "log-level", + "shortName": "l", + "type": "enum", + "required": false, + "summary": "Minimum log level. Default: information", + "enumValues": [ + "trace", + "debug", + "information", + "warning", + "error", + "critical", + "none" + ] + }, + { + "role": "flag", + "name": "config-source", + "shortName": "c", + "type": "enum", + "required": false, + "summary": "Override the configuration source: local, remote", + "enumValues": [ + "local", + "remote", + "embedded" + ] + }, + { + "role": "flag", + "name": "skip-private-repositories", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Skip cloning private repositories" + } + ], + "intent": { + "idempotent": true, + "scope": "directory" + } + }, + { + "path": [], + "name": "index", + "summary": "Index a single documentation set into Elasticsearch.", + "notes": "Builds the documentation set in metadata-only mode and streams the output to Elasticsearch.\nDoes not write HTML to disk. Requires a running cluster and valid credentials.", + "usage": "docs-builder index [options]", + "examples": [], + "parameters": [ + { + "role": "flag", + "name": "endpoint", + "shortName": null, + "type": "string", + "required": false, + "summary": "-es,--endpoint, Elasticsearch endpoint URL. Falls back to env DOCUMENTATION_ELASTIC_URL.", + "validations": [ + { + "kind": "uriScheme", + "min": null, + "max": null, + "pattern": null, + "values": [ + "http", + "https" + ] + } + ] + }, + { + "role": "flag", + "name": "api-key", + "shortName": null, + "type": "string", + "required": false, + "summary": "API key for authentication. Falls back to env DOCUMENTATION_ELASTIC_APIKEY." + }, + { + "role": "flag", + "name": "username", + "shortName": null, + "type": "string", + "required": false, + "summary": "Username for basic authentication. Falls back to env DOCUMENTATION_ELASTIC_USERNAME." + }, + { + "role": "flag", + "name": "password", + "shortName": null, + "type": "string", + "required": false, + "summary": "Password for basic authentication. Falls back to env DOCUMENTATION_ELASTIC_PASSWORD." + }, + { + "role": "flag", + "name": "ai-enrichment", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Enable AI enrichment of documents using LLM-generated metadata (enabled by default)." + }, + { + "role": "flag", + "name": "search-num-threads", + "shortName": null, + "type": "integer", + "required": false, + "summary": "Number of search threads for the inference endpoint.", + "validations": [ + { + "kind": "range", + "min": "1", + "max": "128", + "pattern": null, + "values": null + } + ] + }, + { + "role": "flag", + "name": "index-num-threads", + "shortName": null, + "type": "integer", + "required": false, + "summary": "Number of index threads for the inference endpoint.", + "validations": [ + { + "kind": "range", + "min": "1", + "max": "128", + "pattern": null, + "values": null + } + ] + }, + { + "role": "flag", + "name": "eis", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Use the Elastic Inference Service to bootstrap the inference endpoint (enabled by default)." + }, + { + "role": "flag", + "name": "bootstrap-timeout", + "shortName": null, + "type": "string", + "required": false, + "summary": "How long to wait for the inference endpoint to become ready (e.g. 4m, 90s).", + "validations": [ + { + "kind": "timeSpanRange", + "min": "\u00221s\u0022", + "max": "\u002260m\u0022", + "pattern": null, + "values": null + } + ] + }, + { + "role": "flag", + "name": "force-reindex", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Force a full reindex, discarding any incremental state." + }, + { + "role": "flag", + "name": "buffer-size", + "shortName": null, + "type": "integer", + "required": false, + "summary": "Number of documents per bulk request.", + "validations": [ + { + "kind": "range", + "min": "1", + "max": "10000", + "pattern": null, + "values": null + } + ] + }, + { + "role": "flag", + "name": "max-retries", + "shortName": null, + "type": "integer", + "required": false, + "summary": "Number of retry attempts for failed bulk items.", + "validations": [ + { + "kind": "range", + "min": "0", + "max": "20", + "pattern": null, + "values": null + } + ] + }, + { + "role": "flag", + "name": "debug-mode", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Log every Elasticsearch request and response body; append ?pretty to all requests." + }, + { + "role": "flag", + "name": "proxy-address", + "shortName": null, + "type": "string", + "required": false, + "summary": "Route requests through this proxy URL.", + "validations": [ + { + "kind": "uriScheme", + "min": null, + "max": null, + "pattern": null, + "values": [ + "http", + "https" + ] + } + ] + }, + { + "role": "flag", + "name": "proxy-username", + "shortName": null, + "type": "string", + "required": false, + "summary": "Proxy server username." + }, + { + "role": "flag", + "name": "proxy-password", + "shortName": null, + "type": "string", + "required": false, + "summary": "Proxy server password." + }, + { + "role": "flag", + "name": "disable-ssl-verification", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Disable SSL certificate validation. Use only in controlled environments." + }, + { + "role": "flag", + "name": "certificate-fingerprint", + "shortName": null, + "type": "string", + "required": false, + "summary": "SHA-256 fingerprint of a self-signed server certificate." + }, + { + "role": "flag", + "name": "certificate-path", + "shortName": null, + "type": "string", + "required": false, + "summary": "Path to a PEM or DER certificate file for SSL validation.", + "validations": [ + { + "kind": "rejectSymbolicLinks", + "min": null, + "max": null, + "pattern": null, + "values": null + }, + { + "kind": "existing", + "min": null, + "max": null, + "pattern": null, + "values": null + }, + { + "kind": "fileExtensions", + "min": null, + "max": null, + "pattern": null, + "values": [ + "pem", + "der", + "crt", + "cer" + ] + }, + { + "kind": "expandUserProfile", + "min": null, + "max": null, + "pattern": null, + "values": null + } + ] + }, + { + "role": "flag", + "name": "certificate-not-root", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Set when the certificate is an intermediate CA rather than the root." + }, + { + "role": "flag", + "name": "path", + "shortName": null, + "type": "string", + "required": false, + "summary": null + }, + { + "role": "flag", + "name": "log-level", + "shortName": "l", + "type": "enum", + "required": false, + "summary": "Minimum log level. Default: information", + "enumValues": [ + "trace", + "debug", + "information", + "warning", + "error", + "critical", + "none" + ] + }, + { + "role": "flag", + "name": "config-source", + "shortName": "c", + "type": "enum", + "required": false, + "summary": "Override the configuration source: local, remote", + "enumValues": [ + "local", + "remote", + "embedded" + ] + }, + { + "role": "flag", + "name": "skip-private-repositories", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Skip cloning private repositories" + } + ], + "intent": { + "requiresAuth": true + } + }, + { + "path": [], + "name": "mv", + "summary": "Move a file or folder and rewrite all inbound links across the documentation set.", + "notes": null, + "usage": "docs-builder mv \u003Csource\u003E \u003Ctarget\u003E [options]", + "examples": [], + "parameters": [ + { + "role": "positional", + "name": "source", + "shortName": null, + "type": "string", + "required": true, + "summary": "Source file or folder path." + }, + { + "role": "positional", + "name": "target", + "shortName": null, + "type": "string", + "required": true, + "summary": "Destination file or folder path." + }, + { + "role": "dryRun", + "name": "dry-run", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Print the changes that would be made without applying them.", + "defaultValue": "default" + }, + { + "role": "flag", + "name": "path", + "shortName": "p", + "type": "string", + "required": false, + "summary": "Documentation root. Defaults to cwd." + }, + { + "role": "flag", + "name": "log-level", + "shortName": "l", + "type": "enum", + "required": false, + "summary": "Minimum log level. Default: information", + "enumValues": [ + "trace", + "debug", + "information", + "warning", + "error", + "critical", + "none" + ] + }, + { + "role": "flag", + "name": "config-source", + "shortName": "c", + "type": "enum", + "required": false, + "summary": "Override the configuration source: local, remote", + "enumValues": [ + "local", + "remote", + "embedded" + ] + }, + { + "role": "flag", + "name": "skip-private-repositories", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Skip cloning private repositories" + } + ], + "intent": { + "destructive": true, + "scope": "directory" + } + }, + { + "path": [], + "name": "serve", + "summary": "Serve a documentation folder at http://localhost:3000 with live reload.", + "notes": "File-system changes are reflected without restarting the server.", + "usage": "docs-builder serve [options]", + "examples": [], + "parameters": [ + { + "role": "flag", + "name": "path", + "shortName": "p", + "type": "string", + "required": false, + "summary": "Documentation source directory. Defaults to the cwd/docs folder.", + "validations": [ + { + "kind": "rejectSymbolicLinks", + "min": null, + "max": null, + "pattern": null, + "values": null + }, + { + "kind": "existing", + "min": null, + "max": null, + "pattern": null, + "values": null + }, + { + "kind": "expandUserProfile", + "min": null, + "max": null, + "pattern": null, + "values": null + } + ] + }, + { + "role": "flag", + "name": "port", + "shortName": null, + "type": "integer", + "required": false, + "summary": "Port to serve the documentation. Default: 3000", + "defaultValue": "3000" + }, + { + "role": "flag", + "name": "watch", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Special flag for dotnet watch optimizations during development", + "defaultValue": "false" + }, + { + "role": "flag", + "name": "log-level", + "shortName": "l", + "type": "enum", + "required": false, + "summary": "Minimum log level. Default: information", + "enumValues": [ + "trace", + "debug", + "information", + "warning", + "error", + "critical", + "none" + ] + }, + { + "role": "flag", + "name": "config-source", + "shortName": "c", + "type": "enum", + "required": false, + "summary": "Override the configuration source: local, remote", + "enumValues": [ + "local", + "remote", + "embedded" + ] + }, + { + "role": "flag", + "name": "skip-private-repositories", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Skip cloning private repositories" + } + ] + } + ], + "namespaces": [ + { + "segment": "assembler", + "summary": "Build a unified documentation site by composing multiple documentation sets under a shared navigation.", + "notes": null, + "options": [], + "defaultCommand": null, + "commands": [ + { + "path": [ + "assembler" + ], + "name": "build", + "summary": "Build the unified site from all previously cloned repositories.", + "notes": "Run after assembler clone. Reads every cloned repository, applies the shared navigation.yml,\nand writes the unified site to .artifacts/docs/.", + "usage": "docs-builder assembler build [options]", + "examples": [], + "parameters": [ + { + "role": "flag", + "name": "strict", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Treat warnings as errors." + }, + { + "role": "flag", + "name": "environment", + "shortName": null, + "type": "string", + "required": false, + "summary": "Named deployment target, e.g. dev, staging, production. Determines which configuration branch and index names are used." + }, + { + "role": "flag", + "name": "metadata-only", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Write only metadata files; skip HTML generation. Ignored when --exporters is also set." + }, + { + "role": "flag", + "name": "show-hints", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Print documentation hints emitted during the build." + }, + { + "role": "flag", + "name": "exporters", + "shortName": null, + "type": "string", + "required": false, + "summary": "Comma-separated list of exporters to run." + }, + { + "role": "flag", + "name": "assume-build", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Skip the build step when .artifacts/docs/index.html already exists. Intended for test scenarios only." + }, + { + "role": "flag", + "name": "log-level", + "shortName": "l", + "type": "enum", + "required": false, + "summary": "Minimum log level. Default: information", + "enumValues": [ + "trace", + "debug", + "information", + "warning", + "error", + "critical", + "none" + ] + }, + { + "role": "flag", + "name": "config-source", + "shortName": "c", + "type": "enum", + "required": false, + "summary": "Override the configuration source: local, remote", + "enumValues": [ + "local", + "remote", + "embedded" + ] + }, + { + "role": "flag", + "name": "skip-private-repositories", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Skip cloning private repositories" + } + ], + "intent": { + "idempotent": true + } + }, + { + "path": [ + "assembler" + ], + "name": "clone", + "summary": "Clone all repositories listed in the assembler configuration.", + "notes": "Run assembler config init first to fetch the repository list. Clones into a local\nworking directory; subsequent assembler build reads from there.", + "usage": "docs-builder assembler clone [options]", + "examples": [], + "parameters": [ + { + "role": "flag", + "name": "strict", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Treat warnings as errors.", + "defaultValue": "default" + }, + { + "role": "flag", + "name": "environment", + "shortName": null, + "type": "string", + "required": false, + "summary": "Named deployment target. Determines which repositories and branches are cloned." + }, + { + "role": "flag", + "name": "fetch-latest", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Fetch the HEAD of each branch instead of the pinned link-registry ref.", + "defaultValue": "default" + }, + { + "role": "flag", + "name": "assume-cloned", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Skip cloning; assume repositories are already on disk.", + "defaultValue": "default" + }, + { + "role": "flag", + "name": "log-level", + "shortName": "l", + "type": "enum", + "required": false, + "summary": "Minimum log level. Default: information", + "enumValues": [ + "trace", + "debug", + "information", + "warning", + "error", + "critical", + "none" + ] + }, + { + "role": "flag", + "name": "config-source", + "shortName": "c", + "type": "enum", + "required": false, + "summary": "Override the configuration source: local, remote", + "enumValues": [ + "local", + "remote", + "embedded" + ] + }, + { + "role": "flag", + "name": "skip-private-repositories", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Skip cloning private repositories" + } + ], + "intent": { + "idempotent": true + } + }, + { + "path": [ + "assembler" + ], + "name": "index", + "summary": "Index the assembled documentation into Elasticsearch.", + "notes": "Runs an assembler build with only the Elasticsearch exporter enabled, then streams documents\nto the cluster. The index name is derived from the environment name.\n\n\nRun after assembler build or use instead of it when indexing is the only goal.", + "usage": "docs-builder assembler index [options]", + "examples": [], + "parameters": [ + { + "role": "flag", + "name": "endpoint", + "shortName": null, + "type": "string", + "required": false, + "summary": "-es,--endpoint, Elasticsearch endpoint URL. Falls back to env DOCUMENTATION_ELASTIC_URL.", + "validations": [ + { + "kind": "uriScheme", + "min": null, + "max": null, + "pattern": null, + "values": [ + "http", + "https" + ] + } + ] + }, + { + "role": "flag", + "name": "api-key", + "shortName": null, + "type": "string", + "required": false, + "summary": "API key for authentication. Falls back to env DOCUMENTATION_ELASTIC_APIKEY." + }, + { + "role": "flag", + "name": "username", + "shortName": null, + "type": "string", + "required": false, + "summary": "Username for basic authentication. Falls back to env DOCUMENTATION_ELASTIC_USERNAME." + }, + { + "role": "flag", + "name": "password", + "shortName": null, + "type": "string", + "required": false, + "summary": "Password for basic authentication. Falls back to env DOCUMENTATION_ELASTIC_PASSWORD." + }, + { + "role": "flag", + "name": "ai-enrichment", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Enable AI enrichment of documents using LLM-generated metadata (enabled by default)." + }, + { + "role": "flag", + "name": "search-num-threads", + "shortName": null, + "type": "integer", + "required": false, + "summary": "Number of search threads for the inference endpoint.", + "validations": [ + { + "kind": "range", + "min": "1", + "max": "128", + "pattern": null, + "values": null + } + ] + }, + { + "role": "flag", + "name": "index-num-threads", + "shortName": null, + "type": "integer", + "required": false, + "summary": "Number of index threads for the inference endpoint.", + "validations": [ + { + "kind": "range", + "min": "1", + "max": "128", + "pattern": null, + "values": null + } + ] + }, + { + "role": "flag", + "name": "eis", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Use the Elastic Inference Service to bootstrap the inference endpoint (enabled by default)." + }, + { + "role": "flag", + "name": "bootstrap-timeout", + "shortName": null, + "type": "string", + "required": false, + "summary": "How long to wait for the inference endpoint to become ready (e.g. 4m, 90s).", + "validations": [ + { + "kind": "timeSpanRange", + "min": "\u00221s\u0022", + "max": "\u002260m\u0022", + "pattern": null, + "values": null + } + ] + }, + { + "role": "flag", + "name": "force-reindex", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Force a full reindex, discarding any incremental state." + }, + { + "role": "flag", + "name": "buffer-size", + "shortName": null, + "type": "integer", + "required": false, + "summary": "Number of documents per bulk request.", + "validations": [ + { + "kind": "range", + "min": "1", + "max": "10000", + "pattern": null, + "values": null + } + ] + }, + { + "role": "flag", + "name": "max-retries", + "shortName": null, + "type": "integer", + "required": false, + "summary": "Number of retry attempts for failed bulk items.", + "validations": [ + { + "kind": "range", + "min": "0", + "max": "20", + "pattern": null, + "values": null + } + ] + }, + { + "role": "flag", + "name": "debug-mode", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Log every Elasticsearch request and response body; append ?pretty to all requests." + }, + { + "role": "flag", + "name": "proxy-address", + "shortName": null, + "type": "string", + "required": false, + "summary": "Route requests through this proxy URL.", + "validations": [ + { + "kind": "uriScheme", + "min": null, + "max": null, + "pattern": null, + "values": [ + "http", + "https" + ] + } + ] + }, + { + "role": "flag", + "name": "proxy-username", + "shortName": null, + "type": "string", + "required": false, + "summary": "Proxy server username." + }, + { + "role": "flag", + "name": "proxy-password", + "shortName": null, + "type": "string", + "required": false, + "summary": "Proxy server password." + }, + { + "role": "flag", + "name": "disable-ssl-verification", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Disable SSL certificate validation. Use only in controlled environments." + }, + { + "role": "flag", + "name": "certificate-fingerprint", + "shortName": null, + "type": "string", + "required": false, + "summary": "SHA-256 fingerprint of a self-signed server certificate." + }, + { + "role": "flag", + "name": "certificate-path", + "shortName": null, + "type": "string", + "required": false, + "summary": "Path to a PEM or DER certificate file for SSL validation.", + "validations": [ + { + "kind": "rejectSymbolicLinks", + "min": null, + "max": null, + "pattern": null, + "values": null + }, + { + "kind": "existing", + "min": null, + "max": null, + "pattern": null, + "values": null + }, + { + "kind": "fileExtensions", + "min": null, + "max": null, + "pattern": null, + "values": [ + "pem", + "der", + "crt", + "cer" + ] + }, + { + "kind": "expandUserProfile", + "min": null, + "max": null, + "pattern": null, + "values": null + } + ] + }, + { + "role": "flag", + "name": "certificate-not-root", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Set when the certificate is an intermediate CA rather than the root." + }, + { + "role": "flag", + "name": "environment", + "shortName": null, + "type": "string", + "required": false, + "summary": "Named deployment target; becomes part of the Elasticsearch index name." + }, + { + "role": "flag", + "name": "log-level", + "shortName": "l", + "type": "enum", + "required": false, + "summary": "Minimum log level. Default: information", + "enumValues": [ + "trace", + "debug", + "information", + "warning", + "error", + "critical", + "none" + ] + }, + { + "role": "flag", + "name": "config-source", + "shortName": "c", + "type": "enum", + "required": false, + "summary": "Override the configuration source: local, remote", + "enumValues": [ + "local", + "remote", + "embedded" + ] + }, + { + "role": "flag", + "name": "skip-private-repositories", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Skip cloning private repositories" + } + ] + }, + { + "path": [ + "assembler" + ], + "name": "serve", + "summary": "Serve the output of a completed assembler build at http://localhost:4000.", + "notes": "Run after assembler build. Does not watch for file changes.", + "usage": "docs-builder assembler serve [options]", + "examples": [], + "parameters": [ + { + "role": "flag", + "name": "port", + "shortName": null, + "type": "integer", + "required": false, + "summary": "Port to listen on. Default: 4000.", + "defaultValue": "4000" + }, + { + "role": "flag", + "name": "path", + "shortName": null, + "type": "string", + "required": false, + "summary": "Path to the built site. Defaults to .artifacts/docs/.", + "validations": [ + { + "kind": "rejectSymbolicLinks", + "min": null, + "max": null, + "pattern": null, + "values": null + }, + { + "kind": "existing", + "min": null, + "max": null, + "pattern": null, + "values": null + }, + { + "kind": "expandUserProfile", + "min": null, + "max": null, + "pattern": null, + "values": null + } + ] + }, + { + "role": "flag", + "name": "log-level", + "shortName": "l", + "type": "enum", + "required": false, + "summary": "Minimum log level. Default: information", + "enumValues": [ + "trace", + "debug", + "information", + "warning", + "error", + "critical", + "none" + ] + }, + { + "role": "flag", + "name": "config-source", + "shortName": "c", + "type": "enum", + "required": false, + "summary": "Override the configuration source: local, remote", + "enumValues": [ + "local", + "remote", + "embedded" + ] + }, + { + "role": "flag", + "name": "skip-private-repositories", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Skip cloning private repositories" + } + ] + }, + { + "path": [ + "assembler" + ], + "name": "sitemap", + "summary": "Generate sitemap.xml using accurate content_last_updated dates from Elasticsearch.", + "notes": "The sitemap generated by assembler build uses the current date as a placeholder.\nRun this command after assembler index to overwrite it with precise last-modified dates\nsourced from the search index.", + "usage": "docs-builder assembler sitemap [options]", + "examples": [], + "parameters": [ + { + "role": "flag", + "name": "endpoint", + "shortName": null, + "type": "string", + "required": false, + "summary": "-es,--endpoint, Elasticsearch endpoint URL. Falls back to env DOCUMENTATION_ELASTIC_URL.", + "validations": [ + { + "kind": "uriScheme", + "min": null, + "max": null, + "pattern": null, + "values": [ + "http", + "https" + ] + } + ] + }, + { + "role": "flag", + "name": "api-key", + "shortName": null, + "type": "string", + "required": false, + "summary": "API key for authentication. Falls back to env DOCUMENTATION_ELASTIC_APIKEY." + }, + { + "role": "flag", + "name": "username", + "shortName": null, + "type": "string", + "required": false, + "summary": "Username for basic authentication. Falls back to env DOCUMENTATION_ELASTIC_USERNAME." + }, + { + "role": "flag", + "name": "password", + "shortName": null, + "type": "string", + "required": false, + "summary": "Password for basic authentication. Falls back to env DOCUMENTATION_ELASTIC_PASSWORD." + }, + { + "role": "flag", + "name": "ai-enrichment", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Enable AI enrichment of documents using LLM-generated metadata (enabled by default)." + }, + { + "role": "flag", + "name": "search-num-threads", + "shortName": null, + "type": "integer", + "required": false, + "summary": "Number of search threads for the inference endpoint.", + "validations": [ + { + "kind": "range", + "min": "1", + "max": "128", + "pattern": null, + "values": null + } + ] + }, + { + "role": "flag", + "name": "index-num-threads", + "shortName": null, + "type": "integer", + "required": false, + "summary": "Number of index threads for the inference endpoint.", + "validations": [ + { + "kind": "range", + "min": "1", + "max": "128", + "pattern": null, + "values": null + } + ] + }, + { + "role": "flag", + "name": "eis", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Use the Elastic Inference Service to bootstrap the inference endpoint (enabled by default)." + }, + { + "role": "flag", + "name": "bootstrap-timeout", + "shortName": null, + "type": "string", + "required": false, + "summary": "How long to wait for the inference endpoint to become ready (e.g. 4m, 90s).", + "validations": [ + { + "kind": "timeSpanRange", + "min": "\u00221s\u0022", + "max": "\u002260m\u0022", + "pattern": null, + "values": null + } + ] + }, + { + "role": "flag", + "name": "force-reindex", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Force a full reindex, discarding any incremental state." + }, + { + "role": "flag", + "name": "buffer-size", + "shortName": null, + "type": "integer", + "required": false, + "summary": "Number of documents per bulk request.", + "validations": [ + { + "kind": "range", + "min": "1", + "max": "10000", + "pattern": null, + "values": null + } + ] + }, + { + "role": "flag", + "name": "max-retries", + "shortName": null, + "type": "integer", + "required": false, + "summary": "Number of retry attempts for failed bulk items.", + "validations": [ + { + "kind": "range", + "min": "0", + "max": "20", + "pattern": null, + "values": null + } + ] + }, + { + "role": "flag", + "name": "debug-mode", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Log every Elasticsearch request and response body; append ?pretty to all requests." + }, + { + "role": "flag", + "name": "proxy-address", + "shortName": null, + "type": "string", + "required": false, + "summary": "Route requests through this proxy URL.", + "validations": [ + { + "kind": "uriScheme", + "min": null, + "max": null, + "pattern": null, + "values": [ + "http", + "https" + ] + } + ] + }, + { + "role": "flag", + "name": "proxy-username", + "shortName": null, + "type": "string", + "required": false, + "summary": "Proxy server username." + }, + { + "role": "flag", + "name": "proxy-password", + "shortName": null, + "type": "string", + "required": false, + "summary": "Proxy server password." + }, + { + "role": "flag", + "name": "disable-ssl-verification", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Disable SSL certificate validation. Use only in controlled environments." + }, + { + "role": "flag", + "name": "certificate-fingerprint", + "shortName": null, + "type": "string", + "required": false, + "summary": "SHA-256 fingerprint of a self-signed server certificate." + }, + { + "role": "flag", + "name": "certificate-path", + "shortName": null, + "type": "string", + "required": false, + "summary": "Path to a PEM or DER certificate file for SSL validation.", + "validations": [ + { + "kind": "rejectSymbolicLinks", + "min": null, + "max": null, + "pattern": null, + "values": null + }, + { + "kind": "existing", + "min": null, + "max": null, + "pattern": null, + "values": null + }, + { + "kind": "fileExtensions", + "min": null, + "max": null, + "pattern": null, + "values": [ + "pem", + "der", + "crt", + "cer" + ] + }, + { + "kind": "expandUserProfile", + "min": null, + "max": null, + "pattern": null, + "values": null + } + ] + }, + { + "role": "flag", + "name": "certificate-not-root", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Set when the certificate is an intermediate CA rather than the root." + }, + { + "role": "flag", + "name": "environment", + "shortName": null, + "type": "string", + "required": false, + "summary": "Named deployment target; used to resolve the correct Elasticsearch index." + }, + { + "role": "flag", + "name": "log-level", + "shortName": "l", + "type": "enum", + "required": false, + "summary": "Minimum log level. Default: information", + "enumValues": [ + "trace", + "debug", + "information", + "warning", + "error", + "critical", + "none" + ] + }, + { + "role": "flag", + "name": "config-source", + "shortName": "c", + "type": "enum", + "required": false, + "summary": "Override the configuration source: local, remote", + "enumValues": [ + "local", + "remote", + "embedded" + ] + }, + { + "role": "flag", + "name": "skip-private-repositories", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Skip cloning private repositories" + } + ] + } + ], + "namespaces": [ + { + "segment": "bloom-filter", + "summary": "Build and query the bloom filter used for legacy-URL redirect coverage.", + "notes": null, + "options": [], + "defaultCommand": null, + "commands": [ + { + "path": [ + "assembler", + "bloom-filter" + ], + "name": "create", + "summary": "Build a bloom filter binary from a local legacy-docs repository.", + "notes": "The bloom filter is a compact data structure that records which legacy URLs existed before migration.\nIt is used to verify redirect coverage: if a legacy URL is absent from the filter, any redirect\npointing to it cannot be validated. Run once after cloning the legacy-docs repository.", + "usage": "docs-builder assembler bloom-filter create --built-docs-dir \u003Cdir\u003E", + "examples": [], + "parameters": [ + { + "role": "flag", + "name": "built-docs-dir", + "shortName": null, + "type": "string", + "required": true, + "summary": "Path to the local legacy-docs repository checkout.", + "validations": [ + { + "kind": "rejectSymbolicLinks", + "min": null, + "max": null, + "pattern": null, + "values": null + }, + { + "kind": "existing", + "min": null, + "max": null, + "pattern": null, + "values": null + }, + { + "kind": "expandUserProfile", + "min": null, + "max": null, + "pattern": null, + "values": null + } + ] + }, + { + "role": "flag", + "name": "log-level", + "shortName": "l", + "type": "enum", + "required": false, + "summary": "Minimum log level. Default: information", + "enumValues": [ + "trace", + "debug", + "information", + "warning", + "error", + "critical", + "none" + ] + }, + { + "role": "flag", + "name": "config-source", + "shortName": "c", + "type": "enum", + "required": false, + "summary": "Override the configuration source: local, remote", + "enumValues": [ + "local", + "remote", + "embedded" + ] + }, + { + "role": "flag", + "name": "skip-private-repositories", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Skip cloning private repositories" + } + ] + }, + { + "path": [ + "assembler", + "bloom-filter" + ], + "name": "lookup", + "summary": "Test whether a URL path is recorded in the bloom filter.", + "notes": null, + "usage": "docs-builder assembler bloom-filter lookup --path \u003Cstring\u003E", + "examples": [], + "parameters": [ + { + "role": "flag", + "name": "path", + "shortName": null, + "type": "string", + "required": true, + "summary": "URL path to look up (e.g. /guide/en/elasticsearch/reference/current/index.html)." + }, + { + "role": "flag", + "name": "log-level", + "shortName": "l", + "type": "enum", + "required": false, + "summary": "Minimum log level. Default: information", + "enumValues": [ + "trace", + "debug", + "information", + "warning", + "error", + "critical", + "none" + ] + }, + { + "role": "flag", + "name": "config-source", + "shortName": "c", + "type": "enum", + "required": false, + "summary": "Override the configuration source: local, remote", + "enumValues": [ + "local", + "remote", + "embedded" + ] + }, + { + "role": "flag", + "name": "skip-private-repositories", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Skip cloning private repositories" + } + ] + } + ], + "namespaces": [] + }, + { + "segment": "config", + "summary": "Fetch and manage the central assembler configuration repository.", + "notes": null, + "options": [], + "defaultCommand": null, + "commands": [ + { + "path": [ + "assembler", + "config" + ], + "name": "init", + "summary": "Fetch the assembler configuration into local application data.", + "notes": "All assembler and codex commands read their repository list from a central configuration repository.\nRun this once before the first assembler clone or assemble invocation, and whenever\nthe configuration has changed upstream.", + "usage": "docs-builder assembler config init [options]", + "examples": [], + "parameters": [ + { + "role": "flag", + "name": "git-ref", + "shortName": null, + "type": "string", + "required": false, + "summary": "Git ref to fetch. Defaults to main." + }, + { + "role": "flag", + "name": "local", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Write the configuration into cwd so subsequent commands treat it as a local override.", + "defaultValue": "false" + }, + { + "role": "flag", + "name": "log-level", + "shortName": "l", + "type": "enum", + "required": false, + "summary": "Minimum log level. Default: information", + "enumValues": [ + "trace", + "debug", + "information", + "warning", + "error", + "critical", + "none" + ] + }, + { + "role": "flag", + "name": "config-source", + "shortName": "c", + "type": "enum", + "required": false, + "summary": "Override the configuration source: local, remote", + "enumValues": [ + "local", + "remote", + "embedded" + ] + }, + { + "role": "flag", + "name": "skip-private-repositories", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Skip cloning private repositories" + } + ] + } + ], + "namespaces": [] + }, + { + "segment": "content-source", + "summary": "Inspect and validate repository entries in the link registry.", + "notes": null, + "options": [], + "defaultCommand": null, + "commands": [ + { + "path": [ + "assembler", + "content-source" + ], + "name": "match", + "summary": "Check whether a repository at a specific branch or tag should be included in the next build.", + "notes": "Exits 0 if the repository matches; 1 otherwise. Useful for conditional CI steps.", + "usage": "docs-builder assembler content-source match [\u003Crepository\u003E] [\u003Cbranch-or-tag\u003E]", + "examples": [], + "parameters": [ + { + "role": "positional", + "name": "repository", + "shortName": null, + "type": "string", + "required": false, + "summary": "Repository slug to match (e.g. elastic/elasticsearch)." + }, + { + "role": "positional", + "name": "branch-or-tag", + "shortName": null, + "type": "string", + "required": false, + "summary": "Branch name or version tag to test against." + }, + { + "role": "flag", + "name": "log-level", + "shortName": "l", + "type": "enum", + "required": false, + "summary": "Minimum log level. Default: information", + "enumValues": [ + "trace", + "debug", + "information", + "warning", + "error", + "critical", + "none" + ] + }, + { + "role": "flag", + "name": "config-source", + "shortName": "c", + "type": "enum", + "required": false, + "summary": "Override the configuration source: local, remote", + "enumValues": [ + "local", + "remote", + "embedded" + ] + }, + { + "role": "flag", + "name": "skip-private-repositories", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Skip cloning private repositories" + } + ] + }, + { + "path": [ + "assembler", + "content-source" + ], + "name": "validate", + "summary": "Verify that every repository in the assembler configuration has an active published entry in the link registry.", + "notes": null, + "usage": "docs-builder assembler content-source validate", + "examples": [], + "parameters": [ + { + "role": "flag", + "name": "log-level", + "shortName": "l", + "type": "enum", + "required": false, + "summary": "Minimum log level. Default: information", + "enumValues": [ + "trace", + "debug", + "information", + "warning", + "error", + "critical", + "none" + ] + }, + { + "role": "flag", + "name": "config-source", + "shortName": "c", + "type": "enum", + "required": false, + "summary": "Override the configuration source: local, remote", + "enumValues": [ + "local", + "remote", + "embedded" + ] + }, + { + "role": "flag", + "name": "skip-private-repositories", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Skip cloning private repositories" + } + ] + } + ], + "namespaces": [] + }, + { + "segment": "deploy", + "summary": "Deploy built documentation to S3 and update CloudFront redirect rules.", + "notes": null, + "options": [], + "defaultCommand": null, + "commands": [ + { + "path": [ + "assembler", + "deploy" + ], + "name": "apply", + "summary": "Upload the changes described in a plan file to S3.", + "notes": "Run after assembler deploy plan. Applies the pre-computed diff to the S3 bucket.", + "usage": "docs-builder assembler deploy apply --environment \u003Cstring\u003E --s3-bucket-name \u003Cstring\u003E --plan-file \u003Cfile\u003E", + "examples": [], + "parameters": [ + { + "role": "flag", + "name": "environment", + "shortName": null, + "type": "string", + "required": true, + "summary": "Named deployment target." + }, + { + "role": "flag", + "name": "s3-bucket-name", + "shortName": null, + "type": "string", + "required": true, + "summary": "S3 bucket to deploy to." + }, + { + "role": "flag", + "name": "plan-file", + "shortName": null, + "type": "string", + "required": true, + "summary": "Path to the plan file produced by assembler deploy plan.", + "validations": [ + { + "kind": "rejectSymbolicLinks", + "min": null, + "max": null, + "pattern": null, + "values": null + }, + { + "kind": "existing", + "min": null, + "max": null, + "pattern": null, + "values": null + }, + { + "kind": "fileExtensions", + "min": null, + "max": null, + "pattern": null, + "values": [ + "json", + "plan" + ] + }, + { + "kind": "expandUserProfile", + "min": null, + "max": null, + "pattern": null, + "values": null + } + ] + }, + { + "role": "flag", + "name": "log-level", + "shortName": "l", + "type": "enum", + "required": false, + "summary": "Minimum log level. Default: information", + "enumValues": [ + "trace", + "debug", + "information", + "warning", + "error", + "critical", + "none" + ] + }, + { + "role": "flag", + "name": "config-source", + "shortName": "c", + "type": "enum", + "required": false, + "summary": "Override the configuration source: local, remote", + "enumValues": [ + "local", + "remote", + "embedded" + ] + }, + { + "role": "flag", + "name": "skip-private-repositories", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Skip cloning private repositories" + } + ], + "intent": { + "destructive": true, + "scope": "global", + "requiresAuth": true + } + }, + { + "path": [ + "assembler", + "deploy" + ], + "name": "plan", + "summary": "Compute a diff of what would change when deploying to S3 and write it to a plan file.", + "notes": "Two-step deployment: plan computes the diff and writes a plan file; apply executes it.\nReview the plan before applying to avoid accidental mass deletions.", + "usage": "docs-builder assembler deploy plan --environment \u003Cstring\u003E --s3-bucket-name \u003Cstring\u003E [options]", + "examples": [], + "parameters": [ + { + "role": "flag", + "name": "environment", + "shortName": null, + "type": "string", + "required": true, + "summary": "Named deployment target." + }, + { + "role": "flag", + "name": "s3-bucket-name", + "shortName": null, + "type": "string", + "required": true, + "summary": "S3 bucket to deploy to." + }, + { + "role": "flag", + "name": "out", + "shortName": null, + "type": "string", + "required": false, + "summary": "Path to write the plan file. Defaults to stdout.", + "validations": [ + { + "kind": "rejectSymbolicLinks", + "min": null, + "max": null, + "pattern": null, + "values": null + }, + { + "kind": "expandUserProfile", + "min": null, + "max": null, + "pattern": null, + "values": null + } + ] + }, + { + "role": "flag", + "name": "delete-threshold", + "shortName": null, + "type": "number", + "required": false, + "summary": "Abort if the plan would delete more than this percentage of objects (0\u2013100).", + "defaultValue": "default" + }, + { + "role": "flag", + "name": "log-level", + "shortName": "l", + "type": "enum", + "required": false, + "summary": "Minimum log level. Default: information", + "enumValues": [ + "trace", + "debug", + "information", + "warning", + "error", + "critical", + "none" + ] + }, + { + "role": "flag", + "name": "config-source", + "shortName": "c", + "type": "enum", + "required": false, + "summary": "Override the configuration source: local, remote", + "enumValues": [ + "local", + "remote", + "embedded" + ] + }, + { + "role": "flag", + "name": "skip-private-repositories", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Skip cloning private repositories" + } + ], + "intent": { + "scope": "global", + "requiresAuth": true + } + }, + { + "path": [ + "assembler", + "deploy" + ], + "name": "update-redirects", + "summary": "Push the redirects mapping to CloudFront\u0027s KeyValueStore.", + "notes": "Run after assembler build produces a redirects.json.", + "usage": "docs-builder assembler deploy update-redirects --environment \u003Cstring\u003E [options]", + "examples": [], + "parameters": [ + { + "role": "flag", + "name": "environment", + "shortName": null, + "type": "string", + "required": true, + "summary": "Named deployment target." + }, + { + "role": "flag", + "name": "redirects-file", + "shortName": null, + "type": "string", + "required": false, + "summary": "Path to redirects.json. Defaults to .artifacts/docs/redirects.json.", + "validations": [ + { + "kind": "rejectSymbolicLinks", + "min": null, + "max": null, + "pattern": null, + "values": null + }, + { + "kind": "existing", + "min": null, + "max": null, + "pattern": null, + "values": null + }, + { + "kind": "fileExtensions", + "min": null, + "max": null, + "pattern": null, + "values": [ + "json" + ] + }, + { + "kind": "expandUserProfile", + "min": null, + "max": null, + "pattern": null, + "values": null + } + ] + }, + { + "role": "flag", + "name": "log-level", + "shortName": "l", + "type": "enum", + "required": false, + "summary": "Minimum log level. Default: information", + "enumValues": [ + "trace", + "debug", + "information", + "warning", + "error", + "critical", + "none" + ] + }, + { + "role": "flag", + "name": "config-source", + "shortName": "c", + "type": "enum", + "required": false, + "summary": "Override the configuration source: local, remote", + "enumValues": [ + "local", + "remote", + "embedded" + ] + }, + { + "role": "flag", + "name": "skip-private-repositories", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Skip cloning private repositories" + } + ] + } + ], + "namespaces": [] + }, + { + "segment": "navigation", + "summary": "Validate the global navigation structure and cross-doc-set link references.", + "notes": null, + "options": [], + "defaultCommand": null, + "commands": [ + { + "path": [ + "assembler", + "navigation" + ], + "name": "validate", + "summary": "Check navigation.yml for duplicate path prefixes and non-unique URLs.", + "notes": null, + "usage": "docs-builder assembler navigation validate", + "examples": [], + "parameters": [ + { + "role": "flag", + "name": "log-level", + "shortName": "l", + "type": "enum", + "required": false, + "summary": "Minimum log level. Default: information", + "enumValues": [ + "trace", + "debug", + "information", + "warning", + "error", + "critical", + "none" + ] + }, + { + "role": "flag", + "name": "config-source", + "shortName": "c", + "type": "enum", + "required": false, + "summary": "Override the configuration source: local, remote", + "enumValues": [ + "local", + "remote", + "embedded" + ] + }, + { + "role": "flag", + "name": "skip-private-repositories", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Skip cloning private repositories" + } + ] + }, + { + "path": [ + "assembler", + "navigation" + ], + "name": "validate-link-reference", + "summary": "Check that no link in a local links.json conflicts with a path prefix defined in navigation.yml.", + "notes": null, + "usage": "docs-builder assembler navigation validate-link-reference [\u003Cfile\u003E]", + "examples": [], + "parameters": [ + { + "role": "positional", + "name": "file", + "shortName": null, + "type": "string", + "required": false, + "summary": "Path to links.json. Defaults to .artifacts/docs/html/links.json.", + "validations": [ + { + "kind": "rejectSymbolicLinks", + "min": null, + "max": null, + "pattern": null, + "values": null + }, + { + "kind": "existing", + "min": null, + "max": null, + "pattern": null, + "values": null + }, + { + "kind": "fileExtensions", + "min": null, + "max": null, + "pattern": null, + "values": [ + "json" + ] + }, + { + "kind": "expandUserProfile", + "min": null, + "max": null, + "pattern": null, + "values": null + } + ] + }, + { + "role": "flag", + "name": "log-level", + "shortName": "l", + "type": "enum", + "required": false, + "summary": "Minimum log level. Default: information", + "enumValues": [ + "trace", + "debug", + "information", + "warning", + "error", + "critical", + "none" + ] + }, + { + "role": "flag", + "name": "config-source", + "shortName": "c", + "type": "enum", + "required": false, + "summary": "Override the configuration source: local, remote", + "enumValues": [ + "local", + "remote", + "embedded" + ] + }, + { + "role": "flag", + "name": "skip-private-repositories", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Skip cloning private repositories" + } + ] + } + ], + "namespaces": [] + } + ] + }, + { + "segment": "changelog", + "summary": "Create, bundle, and publish changelog entries.", + "notes": null, + "options": [], + "defaultCommand": null, + "commands": [ + { + "path": [ + "changelog" + ], + "name": "add", + "summary": "Create a new changelog entry YAML file.", + "notes": null, + "usage": "docs-builder changelog add [options]", + "examples": [], + "parameters": [ + { + "role": "flag", + "name": "products", + "shortName": null, + "type": "string", + "required": false, + "summary": "Optional: Products affected in format \u0022product target lifecycle, ...\u0022 (e.g., \u0022elasticsearch 9.2.0 ga, cloud-serverless 2025-08-05\u0022). If not specified, will be inferred from repository or config defaults." + }, + { + "role": "flag", + "name": "action", + "shortName": null, + "type": "string", + "required": false, + "summary": "Optional: What users must do to mitigate" + }, + { + "role": "flag", + "name": "areas", + "shortName": null, + "type": "array", + "required": false, + "summary": "Optional: Area(s) affected (comma-separated or specify multiple times)", + "repeatable": true, + "elementType": "string" + }, + { + "role": "flag", + "name": "concise", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Optional: Omit schema reference comments from generated YAML files. Useful in CI to produce compact output.", + "defaultValue": "false" + }, + { + "role": "flag", + "name": "config", + "shortName": null, + "type": "string", + "required": false, + "summary": "Optional: Path to the changelog.yml configuration file. Defaults to \u0027docs/changelog.yml\u0027", + "validations": [ + { + "kind": "rejectSymbolicLinks", + "min": null, + "max": null, + "pattern": null, + "values": null + }, + { + "kind": "existing", + "min": null, + "max": null, + "pattern": null, + "values": null + }, + { + "kind": "fileExtensions", + "min": null, + "max": null, + "pattern": null, + "values": [ + "yml", + "yaml" + ] + }, + { + "kind": "expandUserProfile", + "min": null, + "max": null, + "pattern": null, + "values": null + } + ] + }, + { + "role": "flag", + "name": "description", + "shortName": null, + "type": "string", + "required": false, + "summary": "Optional: Additional information about the change (max 600 characters)" + }, + { + "role": "flag", + "name": "no-extract-release-notes", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Optional: Turn off extraction of release notes from PR descriptions. By default, release notes are extracted when using --prs. Matched release note text is used as the changelog description (only if --description is not explicitly provided). The changelog title comes from --title or the PR title, not from the release note section.", + "defaultValue": "false" + }, + { + "role": "flag", + "name": "no-extract-issues", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Optional: Turn off extraction of linked references. When using --prs: turns off extraction of linked issues from the PR body (e.g., \u0022Fixes #123\u0022). When using --issues: turns off extraction of linked PRs from the issue body (e.g., \u0022Fixed by #123\u0022). By default, linked references are extracted in both cases.", + "defaultValue": "false" + }, + { + "role": "flag", + "name": "feature-id", + "shortName": null, + "type": "string", + "required": false, + "summary": "Optional: Feature flag ID" + }, + { + "role": "flag", + "name": "highlight", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Optional: Include in release highlights", + "defaultValue": "default" + }, + { + "role": "flag", + "name": "impact", + "shortName": null, + "type": "string", + "required": false, + "summary": "Optional: How the user\u0027s environment is affected" + }, + { + "role": "flag", + "name": "issues", + "shortName": null, + "type": "array", + "required": false, + "summary": "Optional: Issue URL(s) or number(s) (comma-separated), or a path to a newline-delimited file containing issue URLs or numbers. Can be specified multiple times. Each occurrence can be either comma-separated issues (e.g., \u0060--issues \u0022https://github.com/owner/repo/issues/123,456\u0022\u0060) or a file path (e.g., \u0060--issues /path/to/file.txt\u0060). If --owner and --repo are provided, issue numbers can be used instead of URLs. If specified, --title can be derived from the issue. Creates one changelog file per issue. Mutually exclusive with --release-version and --report.", + "repeatable": true, + "elementType": "string" + }, + { + "role": "flag", + "name": "owner", + "shortName": null, + "type": "string", + "required": false, + "summary": "Optional: GitHub repository owner (used when --prs or --issues contains just numbers, or when using --release-version). Falls back to bundle.owner in changelog.yml when not specified. If that value is also absent, \u0022elastic\u0022 is used." + }, + { + "role": "flag", + "name": "output", + "shortName": null, + "type": "string", + "required": false, + "summary": "Optional: Output directory for the changelog. Falls back to bundle.directory in changelog.yml when not specified. Defaults to current directory." + }, + { + "role": "flag", + "name": "prs", + "shortName": null, + "type": "array", + "required": false, + "summary": "Optional: Pull request URL(s) or PR number(s) (comma-separated), or a path to a newline-delimited file containing PR URLs or numbers. Can be specified multiple times. Each occurrence can be either comma-separated PRs (e.g., \u0060--prs \u0022https://github.com/owner/repo/pull/123,6789\u0022\u0060) or a file path (e.g., \u0060--prs /path/to/file.txt\u0060). When specifying PRs directly, provide comma-separated values. When specifying a file path, provide a single value that points to a newline-delimited file. If --owner and --repo are provided, PR numbers can be used instead of URLs. If specified, --title can be derived from the PR. If mappings are configured, --areas and --type can also be derived from the PR. Creates one changelog file per PR. Mutually exclusive with --release-version and --report.", + "repeatable": true, + "elementType": "string" + }, + { + "role": "flag", + "name": "report", + "shortName": null, + "type": "string", + "required": false, + "summary": "Optional: URL or file path to a promotion report HTML document. Extracts GitHub pull request URLs and creates one changelog per PR (same parsing as \u0060changelog bundle --report\u0060). Mutually exclusive with --prs, --issues, and --release-version." + }, + { + "role": "flag", + "name": "release-version", + "shortName": null, + "type": "string", + "required": false, + "summary": "Optional: GitHub release tag to fetch PRs from (e.g., \u0022v9.2.0\u0022 or \u0022latest\u0022). When specified, creates one changelog per PR in the release notes. Requires --repo (or bundle.repo in changelog.yml). Mutually exclusive with --prs, --issues, and --report. Does not create a bundle; use \u0027changelog gh-release\u0027 for that." + }, + { + "role": "flag", + "name": "repo", + "shortName": null, + "type": "string", + "required": false, + "summary": "Optional: GitHub repository name (used when --prs or --issues contains just numbers, or when using --release-version). Falls back to bundle.repo in changelog.yml when not specified." + }, + { + "role": "flag", + "name": "strip-title-prefix", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Optional: When used with --prs or --report, remove square brackets and text within them from the beginning of PR titles, and also remove a colon if it follows the closing bracket (e.g., \u0022[Inference API] Title\u0022 becomes \u0022Title\u0022, \u0022[ES|QL]: Title\u0022 becomes \u0022Title\u0022, \u0022[Discover][ESQL] Title\u0022 becomes \u0022Title\u0022)", + "defaultValue": "false" + }, + { + "role": "flag", + "name": "subtype", + "shortName": null, + "type": "string", + "required": false, + "summary": "Optional: Subtype for breaking changes (api, behavioral, configuration, etc.)" + }, + { + "role": "flag", + "name": "title", + "shortName": null, + "type": "string", + "required": false, + "summary": "Optional: A short, user-facing title (max 80 characters). Required if neither --prs, --issues, nor --report is specified. If --prs and --title are specified, the latter value is used instead of what exists in the PR." + }, + { + "role": "flag", + "name": "type", + "shortName": null, + "type": "string", + "required": false, + "summary": "Optional: Type of change (feature, enhancement, bug-fix, breaking-change, etc.). Required if neither --prs, --issues, nor --report is specified. If mappings are configured, type can be derived from the PR or issue." + }, + { + "role": "flag", + "name": "use-pr-number", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Optional: Use PR numbers for filenames instead of timestamp-slug. With --prs, --report, or --issues (where PRs are resolved), each changelog filename will be derived from its PR numbers. Requires --prs, --report, or --issues. Mutually exclusive with --use-issue-number.", + "defaultValue": "false" + }, + { + "role": "flag", + "name": "use-issue-number", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Optional: Use issue numbers for filenames instead of timestamp-slug. With both --prs (which creates one changelog per specified PR) and --issues (which creates one changelog per specified issue), each changelog filename will be derived from its issues. Requires --prs or --issues. Mutually exclusive with --use-pr-number.", + "defaultValue": "false" + }, + { + "role": "flag", + "name": "log-level", + "shortName": "l", + "type": "enum", + "required": false, + "summary": "Minimum log level. Default: information", + "enumValues": [ + "trace", + "debug", + "information", + "warning", + "error", + "critical", + "none" + ] + }, + { + "role": "flag", + "name": "config-source", + "shortName": "c", + "type": "enum", + "required": false, + "summary": "Override the configuration source: local, remote", + "enumValues": [ + "local", + "remote", + "embedded" + ] + }, + { + "role": "flag", + "name": "skip-private-repositories", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Skip cloning private repositories" + } + ] + }, + { + "path": [ + "changelog" + ], + "name": "bundle", + "summary": "Aggregate changelog entries matching a filter into a single bundle YAML.", + "notes": "Accepts either a named profile from changelog.yml (e.g. bundle my-release 9.2.0) or\nan explicit filter flag. Exactly one filter must be specified: --all, --input-products,\n--prs, --issues, --release-version, or --report.", + "usage": "docs-builder changelog bundle [\u003Cprofile\u003E] [\u003Cprofile-arg\u003E] [\u003Cprofile-report\u003E] [options]", + "examples": [], + "parameters": [ + { + "role": "positional", + "name": "profile", + "shortName": null, + "type": "string", + "required": false, + "summary": "Optional: Profile name from bundle.profiles in config (for example, \u0022elasticsearch-release\u0022). When specified, the second argument is the version or promotion report URL." + }, + { + "role": "positional", + "name": "profile-arg", + "shortName": null, + "type": "string", + "required": false, + "summary": "Optional: Version number or promotion report URL/path when using a profile (for example, \u00229.2.0\u0022 or \u0022https://buildkite.../promotion-report.html\u0022)" + }, + { + "role": "positional", + "name": "profile-report", + "shortName": null, + "type": "string", + "required": false, + "summary": "Optional: Promotion report or URL list file when also providing a version. When provided, the second argument must be a version string and this is the PR/issue filter source (for example, \u0022bundle serverless-release 2026-02 ./report.html\u0022)." + }, + { + "role": "flag", + "name": "all", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Include all changelogs in the directory.", + "defaultValue": "false" + }, + { + "role": "flag", + "name": "config", + "shortName": null, + "type": "string", + "required": false, + "summary": "Optional: Path to the changelog.yml configuration file. Defaults to \u0027docs/changelog.yml\u0027", + "validations": [ + { + "kind": "rejectSymbolicLinks", + "min": null, + "max": null, + "pattern": null, + "values": null + }, + { + "kind": "existing", + "min": null, + "max": null, + "pattern": null, + "values": null + }, + { + "kind": "fileExtensions", + "min": null, + "max": null, + "pattern": null, + "values": [ + "yml", + "yaml" + ] + }, + { + "kind": "expandUserProfile", + "min": null, + "max": null, + "pattern": null, + "values": null + } + ] + }, + { + "role": "flag", + "name": "directory", + "shortName": null, + "type": "string", + "required": false, + "summary": "Optional: Directory containing changelog YAML files. Uses config bundle.directory or defaults to current directory", + "validations": [ + { + "kind": "rejectSymbolicLinks", + "min": null, + "max": null, + "pattern": null, + "values": null + }, + { + "kind": "expandUserProfile", + "min": null, + "max": null, + "pattern": null, + "values": null + } + ] + }, + { + "role": "flag", + "name": "description", + "shortName": null, + "type": "string", + "required": false, + "summary": "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." + }, + { + "role": "flag", + "name": "hide-features", + "shortName": null, + "type": "array", + "required": false, + "summary": "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).", + "repeatable": true, + "elementType": "string" + }, + { + "role": "flag", + "name": "no-release-date", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Optional: Skip auto-population of release date in the bundle. Mutually exclusive with --release-date. Not available in profile mode.", + "defaultValue": "false" + }, + { + "role": "flag", + "name": "release-date", + "shortName": null, + "type": "string", + "required": false, + "summary": "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." + }, + { + "role": "flag", + "name": "input-products", + "shortName": null, + "type": "string", + "required": false, + "summary": "Filter by products in format \u0022product target lifecycle, ...\u0022 (for example, \u0022cloud-serverless 2025-12-02 ga, cloud-serverless 2025-12-06 beta\u0022). When specified, all three parts (product, target, lifecycle) are required but can be wildcards (*). Examples: \u0022elasticsearch * *\u0022 matches all elasticsearch changelogs, \u0022cloud-serverless 2025-12-02 *\u0022 matches cloud-serverless 2025-12-02 with any lifecycle, \u0022* 9.3.* *\u0022 matches any product with target starting with \u00229.3.\u0022, \u0022* * *\u0022 matches all changelogs (equivalent to --all)." + }, + { + "role": "flag", + "name": "output", + "shortName": null, + "type": "string", + "required": false, + "summary": "Optional: Output path for the bundled changelog. Can be either (1) a directory path, in which case \u0027changelog-bundle.yaml\u0027 is created in that directory, or (2) a file path ending in .yml or .yaml. Uses config bundle.output_directory or defaults to \u0027changelog-bundle.yaml\u0027 in the input directory" + }, + { + "role": "flag", + "name": "output-products", + "shortName": null, + "type": "string", + "required": false, + "summary": "Optional: Explicitly set the products array in the output file in format \u0022product target lifecycle, ...\u0022. Overrides any values from changelogs." + }, + { + "role": "flag", + "name": "issues", + "shortName": null, + "type": "array", + "required": false, + "summary": "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.", + "repeatable": true, + "elementType": "string" + }, + { + "role": "flag", + "name": "owner", + "shortName": null, + "type": "string", + "required": false, + "summary": "GitHub repository owner, which is used when PRs or issues are specified as numbers or when using --release-version. Falls back to bundle.owner in changelog.yml when not specified. If that value is also absent, \u0022elastic\u0022 is used." + }, + { + "role": "flag", + "name": "plan", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Emit GitHub Actions step outputs (needs_network, needs_github_token, output_path) describing network requirements and the resolved output path, then exit without generating the bundle. Intended for CI actions.", + "defaultValue": "false" + }, + { + "role": "flag", + "name": "prs", + "shortName": null, + "type": "array", + "required": false, + "summary": "Filter by pull request URLs (comma-separated), or a path to a newline-delimited file containing fully-qualified GitHub PR URLs. Can be specified multiple times.", + "repeatable": true, + "elementType": "string" + }, + { + "role": "flag", + "name": "release-version", + "shortName": null, + "type": "string", + "required": false, + "summary": "GitHub release tag to use as a filter source (for example, \u0022v9.2.0\u0022 or \u0022latest\u0022). When specified, fetches the release, parses PR references from the release notes, and uses those PRs as the filter \u2014 equivalent to passing the PR list via --prs. When --output-products is not specified, it is inferred from the release tag and repository name." + }, + { + "role": "flag", + "name": "repo", + "shortName": null, + "type": "string", + "required": false, + "summary": "GitHub repository name, which is used when PRs or issues are specified as numbers or when using --release-version. Falls back to bundle.repo in changelog.yml when not specified. If that value is also absent, the product ID is used." + }, + { + "role": "flag", + "name": "report", + "shortName": null, + "type": "string", + "required": false, + "summary": "A URL or file path to a promotion report. Extracts PR URLs and uses them as the filter." + }, + { + "role": "flag", + "name": "resolve", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Optional: Copy the contents of each changelog file into the entries array. Uses config bundle.resolve or defaults to false.", + "defaultValue": "default" + }, + { + "role": "flag", + "name": "log-level", + "shortName": "l", + "type": "enum", + "required": false, + "summary": "Minimum log level. Default: information", + "enumValues": [ + "trace", + "debug", + "information", + "warning", + "error", + "critical", + "none" + ] + }, + { + "role": "flag", + "name": "config-source", + "shortName": "c", + "type": "enum", + "required": false, + "summary": "Override the configuration source: local, remote", + "enumValues": [ + "local", + "remote", + "embedded" + ] + }, + { + "role": "flag", + "name": "skip-private-repositories", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Skip cloning private repositories" + } + ] + }, + { + "path": [ + "changelog" + ], + "name": "bundle-amend", + "summary": "Append additional changelog entries to a published bundle without modifying it.", + "notes": "Creates an immutable .amend-N.yaml sidecar file alongside the original bundle.", + "usage": "docs-builder changelog bundle-amend \u003Cbundle-path\u003E [options]", + "examples": [], + "parameters": [ + { + "role": "positional", + "name": "bundle-path", + "shortName": null, + "type": "string", + "required": true, + "summary": "Required: Path to the original bundle file to amend", + "validations": [ + { + "kind": "rejectSymbolicLinks", + "min": null, + "max": null, + "pattern": null, + "values": null + }, + { + "kind": "existing", + "min": null, + "max": null, + "pattern": null, + "values": null + }, + { + "kind": "fileExtensions", + "min": null, + "max": null, + "pattern": null, + "values": [ + "yml", + "yaml" + ] + }, + { + "kind": "expandUserProfile", + "min": null, + "max": null, + "pattern": null, + "values": null + } + ] + }, + { + "role": "flag", + "name": "add", + "shortName": null, + "type": "array", + "required": false, + "summary": "Required: Path(s) to changelog YAML file(s) to add as comma-separated values (e.g., --add \u0022file1.yaml,file2.yaml\u0022). Supports tilde (~) expansion and relative paths.", + "repeatable": true, + "elementType": "string" + }, + { + "role": "flag", + "name": "resolve", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Optional: Copy the contents of each changelog file into the entries array. Use --no-resolve to explicitly turn off resolve (overrides inference from original bundle).", + "defaultValue": "default" + }, + { + "role": "flag", + "name": "log-level", + "shortName": "l", + "type": "enum", + "required": false, + "summary": "Minimum log level. Default: information", + "enumValues": [ + "trace", + "debug", + "information", + "warning", + "error", + "critical", + "none" + ] + }, + { + "role": "flag", + "name": "config-source", + "shortName": "c", + "type": "enum", + "required": false, + "summary": "Override the configuration source: local, remote", + "enumValues": [ + "local", + "remote", + "embedded" + ] + }, + { + "role": "flag", + "name": "skip-private-repositories", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Skip cloning private repositories" + } + ] + }, + { + "path": [ + "changelog" + ], + "name": "evaluate-artifact", + "summary": "(CI) Evaluate downloaded artifact in the resolving workflow.", + "notes": "Reads metadata, validates PR state (SHA, labels), and sets GitHub Actions outputs\nfor downstream steps (commit, comment).", + "usage": "docs-builder changelog evaluate-artifact --metadata \u003Cstring\u003E --owner \u003Cstring\u003E --repo \u003Cstring\u003E", + "examples": [], + "parameters": [ + { + "role": "flag", + "name": "metadata", + "shortName": null, + "type": "string", + "required": true, + "summary": "Path to the downloaded metadata.json file" + }, + { + "role": "flag", + "name": "owner", + "shortName": null, + "type": "string", + "required": true, + "summary": "GitHub repository owner" + }, + { + "role": "flag", + "name": "repo", + "shortName": null, + "type": "string", + "required": true, + "summary": "GitHub repository name" + }, + { + "role": "flag", + "name": "log-level", + "shortName": "l", + "type": "enum", + "required": false, + "summary": "Minimum log level. Default: information", + "enumValues": [ + "trace", + "debug", + "information", + "warning", + "error", + "critical", + "none" + ] + }, + { + "role": "flag", + "name": "config-source", + "shortName": "c", + "type": "enum", + "required": false, + "summary": "Override the configuration source: local, remote", + "enumValues": [ + "local", + "remote", + "embedded" + ] + }, + { + "role": "flag", + "name": "skip-private-repositories", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Skip cloning private repositories" + } + ] + }, + { + "path": [ + "changelog" + ], + "name": "evaluate-pr", + "summary": "(CI) Evaluate a pull request for changelog generation eligibility and set GitHub Actions outputs.", + "notes": "Runs pre-flight checks (body-only edit, bot loop, manual edit), applies label rules from\nchangelog.yml, and resolves the entry type and title. Designed to be called from a\nGitHub Actions workflow step.", + "usage": "docs-builder changelog evaluate-pr --config \u003Cfile\u003E --owner \u003Cstring\u003E --repo \u003Cstring\u003E --pr-number \u003Cint\u003E --pr-title \u003Cstring\u003E --pr-labels \u003Cstring\u003E --head-ref \u003Cstring\u003E --head-sha \u003Cstring\u003E [options]", + "examples": [], + "parameters": [ + { + "role": "flag", + "name": "config", + "shortName": null, + "type": "string", + "required": true, + "summary": "Path to the changelog.yml configuration file", + "validations": [ + { + "kind": "fileExtensions", + "min": null, + "max": null, + "pattern": null, + "values": [ + "yml", + "yaml" + ] + } + ] + }, + { + "role": "flag", + "name": "owner", + "shortName": null, + "type": "string", + "required": true, + "summary": "GitHub repository owner" + }, + { + "role": "flag", + "name": "repo", + "shortName": null, + "type": "string", + "required": true, + "summary": "GitHub repository name" + }, + { + "role": "flag", + "name": "pr-number", + "shortName": null, + "type": "integer", + "required": true, + "summary": "Pull request number" + }, + { + "role": "flag", + "name": "pr-title", + "shortName": null, + "type": "string", + "required": true, + "summary": "Pull request title" + }, + { + "role": "flag", + "name": "pr-labels", + "shortName": null, + "type": "string", + "required": true, + "summary": "Comma-separated PR labels" + }, + { + "role": "flag", + "name": "head-ref", + "shortName": null, + "type": "string", + "required": true, + "summary": "PR head branch ref" + }, + { + "role": "flag", + "name": "head-sha", + "shortName": null, + "type": "string", + "required": true, + "summary": "PR head commit SHA" + }, + { + "role": "flag", + "name": "event-action", + "shortName": null, + "type": "string", + "required": false, + "summary": "Optional: GitHub event action (e.g., opened, synchronize, edited). When omitted, body-only-edit and bot-loop checks are skipped." + }, + { + "role": "flag", + "name": "title-changed", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Whether the PR title changed (for edited events)", + "defaultValue": "false" + }, + { + "role": "flag", + "name": "body-changed", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Whether the PR body changed (for edited events)", + "defaultValue": "false" + }, + { + "role": "flag", + "name": "strip-title-prefix", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Remove square-bracket prefixes from the PR title", + "defaultValue": "false" + }, + { + "role": "flag", + "name": "bot-name", + "shortName": null, + "type": "string", + "required": false, + "summary": "Bot login name for loop detection", + "defaultValue": "github-actions[bot]" + }, + { + "role": "flag", + "name": "log-level", + "shortName": "l", + "type": "enum", + "required": false, + "summary": "Minimum log level. Default: information", + "enumValues": [ + "trace", + "debug", + "information", + "warning", + "error", + "critical", + "none" + ] + }, + { + "role": "flag", + "name": "config-source", + "shortName": "c", + "type": "enum", + "required": false, + "summary": "Override the configuration source: local, remote", + "enumValues": [ + "local", + "remote", + "embedded" + ] + }, + { + "role": "flag", + "name": "skip-private-repositories", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Skip cloning private repositories" + } + ] + }, + { + "path": [ + "changelog" + ], + "name": "gh-release", + "summary": "Create changelog entries from the PRs referenced in a GitHub release.", + "notes": null, + "usage": "docs-builder changelog gh-release \u003Crepo\u003E [\u003Cversion\u003E] [options]", + "examples": [], + "parameters": [ + { + "role": "positional", + "name": "repo", + "shortName": null, + "type": "string", + "required": true, + "summary": "Required: GitHub repository in owner/repo format (e.g., \u0022elastic/elasticsearch\u0022 or just \u0022elasticsearch\u0022 which defaults to elastic/elasticsearch)" + }, + { + "role": "positional", + "name": "version", + "shortName": null, + "type": "string", + "required": false, + "summary": "Optional: Version tag to fetch (e.g., \u0022v9.0.0\u0022, \u00229.0.0\u0022). Defaults to \u0022latest\u0022", + "defaultValue": "latest" + }, + { + "role": "flag", + "name": "config", + "shortName": null, + "type": "string", + "required": false, + "summary": "Optional: Path to the changelog.yml configuration file. Defaults to \u0027docs/changelog.yml\u0027", + "validations": [ + { + "kind": "rejectSymbolicLinks", + "min": null, + "max": null, + "pattern": null, + "values": null + }, + { + "kind": "existing", + "min": null, + "max": null, + "pattern": null, + "values": null + }, + { + "kind": "fileExtensions", + "min": null, + "max": null, + "pattern": null, + "values": [ + "yml", + "yaml" + ] + }, + { + "kind": "expandUserProfile", + "min": null, + "max": null, + "pattern": null, + "values": null + } + ] + }, + { + "role": "flag", + "name": "description", + "shortName": null, + "type": "string", + "required": false, + "summary": "Optional: Bundle description text with placeholder support. Supports VERSION, LIFECYCLE, OWNER, and REPO placeholders. Overrides bundle.description from config." + }, + { + "role": "flag", + "name": "output", + "shortName": null, + "type": "string", + "required": false, + "summary": "Optional: Output directory for changelog files. Falls back to bundle.directory in changelog.yml when not specified. Defaults to \u0027./changelogs\u0027" + }, + { + "role": "flag", + "name": "release-date", + "shortName": null, + "type": "string", + "required": false, + "summary": "Optional: Explicit release date for the bundle in YYYY-MM-DD format. Overrides GitHub release published date." + }, + { + "role": "flag", + "name": "strip-title-prefix", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Optional: Remove square brackets and text within them from the beginning of PR titles (e.g., \u0022[Inference API] Title\u0022 becomes \u0022Title\u0022)", + "defaultValue": "false" + }, + { + "role": "flag", + "name": "warn-on-type-mismatch", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Optional: Warn when the type inferred from release notes section headers doesn\u0027t match the type derived from PR labels. Defaults to true", + "defaultValue": "false" + }, + { + "role": "flag", + "name": "log-level", + "shortName": "l", + "type": "enum", + "required": false, + "summary": "Minimum log level. Default: information", + "enumValues": [ + "trace", + "debug", + "information", + "warning", + "error", + "critical", + "none" + ] + }, + { + "role": "flag", + "name": "config-source", + "shortName": "c", + "type": "enum", + "required": false, + "summary": "Override the configuration source: local, remote", + "enumValues": [ + "local", + "remote", + "embedded" + ] + }, + { + "role": "flag", + "name": "skip-private-repositories", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Skip cloning private repositories" + } + ] + }, + { + "path": [ + "changelog" + ], + "name": "init", + "summary": "Create changelog.yml and the changelog/releases directory structure.", + "notes": "Discovers the docs folder via docset.yml; falls back to creating PATH/docs.\nWhen changelog.yml already exists, updates only the paths specified via or .\nSeeds bundle.owner, bundle.repo, and bundle.link_allow_repos from the git remote origin when available.", + "usage": "docs-builder changelog init [options]", + "examples": [], + "parameters": [ + { + "role": "flag", + "name": "path", + "shortName": null, + "type": "string", + "required": false, + "summary": "Repository root. Defaults to cwd.", + "validations": [ + { + "kind": "rejectSymbolicLinks", + "min": null, + "max": null, + "pattern": null, + "values": null + }, + { + "kind": "expandUserProfile", + "min": null, + "max": null, + "pattern": null, + "values": null + } + ] + }, + { + "role": "flag", + "name": "changelog-dir", + "shortName": null, + "type": "string", + "required": false, + "summary": "Changelog entry directory. Defaults to docs/changelog.", + "validations": [ + { + "kind": "rejectSymbolicLinks", + "min": null, + "max": null, + "pattern": null, + "values": null + }, + { + "kind": "expandUserProfile", + "min": null, + "max": null, + "pattern": null, + "values": null + } + ] + }, + { + "role": "flag", + "name": "bundles-dir", + "shortName": null, + "type": "string", + "required": false, + "summary": "Bundle output directory. Defaults to docs/releases.", + "validations": [ + { + "kind": "rejectSymbolicLinks", + "min": null, + "max": null, + "pattern": null, + "values": null + }, + { + "kind": "expandUserProfile", + "min": null, + "max": null, + "pattern": null, + "values": null + } + ] + }, + { + "role": "flag", + "name": "owner", + "shortName": null, + "type": "string", + "required": false, + "summary": "GitHub owner for seeding bundle defaults. Overrides the value inferred from git remote origin." + }, + { + "role": "flag", + "name": "repo", + "shortName": null, + "type": "string", + "required": false, + "summary": "GitHub repository name for seeding bundle defaults. Overrides the value inferred from git remote origin." + }, + { + "role": "flag", + "name": "log-level", + "shortName": "l", + "type": "enum", + "required": false, + "summary": "Minimum log level. Default: information", + "enumValues": [ + "trace", + "debug", + "information", + "warning", + "error", + "critical", + "none" + ] + }, + { + "role": "flag", + "name": "config-source", + "shortName": "c", + "type": "enum", + "required": false, + "summary": "Override the configuration source: local, remote", + "enumValues": [ + "local", + "remote", + "embedded" + ] + }, + { + "role": "flag", + "name": "skip-private-repositories", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Skip cloning private repositories" + } + ] + }, + { + "path": [ + "changelog" + ], + "name": "prepare-artifact", + "summary": "(CI) Package changelog artifact for cross-workflow transfer.", + "notes": "Resolves final status from evaluate-pr \u002B changelog add outcomes, copies generated YAML,\nwrites metadata.json, and sets GitHub Actions outputs. Always succeeds (exit 0) so the upload step runs.", + "usage": "docs-builder changelog prepare-artifact --staging-dir \u003Cstring\u003E --output-dir \u003Cstring\u003E --evaluate-status \u003Cstring\u003E --generate-outcome \u003Cstring\u003E --pr-number \u003Cint\u003E --head-ref \u003Cstring\u003E --head-sha \u003Cstring\u003E [options]", + "examples": [], + "parameters": [ + { + "role": "flag", + "name": "staging-dir", + "shortName": null, + "type": "string", + "required": true, + "summary": "Directory where changelog add wrote the generated YAML" + }, + { + "role": "flag", + "name": "output-dir", + "shortName": null, + "type": "string", + "required": true, + "summary": "Directory to write the artifact (metadata.json \u002B YAML)" + }, + { + "role": "flag", + "name": "evaluate-status", + "shortName": null, + "type": "string", + "required": true, + "summary": "Status output from the evaluate-pr step" + }, + { + "role": "flag", + "name": "generate-outcome", + "shortName": null, + "type": "string", + "required": true, + "summary": "Outcome of the changelog add step (success/failure)" + }, + { + "role": "flag", + "name": "pr-number", + "shortName": null, + "type": "integer", + "required": true, + "summary": "Pull request number" + }, + { + "role": "flag", + "name": "head-ref", + "shortName": null, + "type": "string", + "required": true, + "summary": "PR head branch ref" + }, + { + "role": "flag", + "name": "head-sha", + "shortName": null, + "type": "string", + "required": true, + "summary": "PR head commit SHA" + }, + { + "role": "flag", + "name": "is-fork", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Whether the PR is from a fork", + "defaultValue": "false" + }, + { + "role": "flag", + "name": "can-commit", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Whether the commit strategy allows committing", + "defaultValue": "false" + }, + { + "role": "flag", + "name": "maintainer-can-modify", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Whether the fork PR allows maintainer edits", + "defaultValue": "false" + }, + { + "role": "flag", + "name": "head-repo", + "shortName": null, + "type": "string", + "required": false, + "summary": "Fork repository full name (owner/repo)" + }, + { + "role": "flag", + "name": "label-table", + "shortName": null, + "type": "string", + "required": false, + "summary": "Optional: markdown label table from evaluate-pr" + }, + { + "role": "flag", + "name": "product-label-table", + "shortName": null, + "type": "string", + "required": false, + "summary": "Optional: markdown product label table from evaluate-pr" + }, + { + "role": "flag", + "name": "skip-labels", + "shortName": null, + "type": "string", + "required": false, + "summary": "Optional: comma-separated skip labels from evaluate-pr" + }, + { + "role": "flag", + "name": "config", + "shortName": null, + "type": "string", + "required": false, + "summary": "Optional: path to changelog.yml" + }, + { + "role": "flag", + "name": "existing-changelog-filename", + "shortName": null, + "type": "string", + "required": false, + "summary": "Optional: filename of a previously committed changelog for this PR" + }, + { + "role": "flag", + "name": "log-level", + "shortName": "l", + "type": "enum", + "required": false, + "summary": "Minimum log level. Default: information", + "enumValues": [ + "trace", + "debug", + "information", + "warning", + "error", + "critical", + "none" + ] + }, + { + "role": "flag", + "name": "config-source", + "shortName": "c", + "type": "enum", + "required": false, + "summary": "Override the configuration source: local, remote", + "enumValues": [ + "local", + "remote", + "embedded" + ] + }, + { + "role": "flag", + "name": "skip-private-repositories", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Skip cloning private repositories" + } + ] + }, + { + "path": [ + "changelog" + ], + "name": "remove", + "summary": "Delete changelog entry files matching a filter.", + "notes": "Blocks when a file is referenced by an unresolved bundle to avoid breaking the {changelog}\ndirective in published documentation. Pass --force to override.", + "usage": "docs-builder changelog remove [\u003Cprofile\u003E] [\u003Cprofile-arg\u003E] [\u003Cprofile-report\u003E] [options]", + "examples": [], + "parameters": [ + { + "role": "positional", + "name": "profile", + "shortName": null, + "type": "string", + "required": false, + "summary": "Optional: Profile name from bundle.profiles in config (for example, \u0022elasticsearch-release\u0022). When specified, the second argument is the version or promotion report URL." + }, + { + "role": "positional", + "name": "profile-arg", + "shortName": null, + "type": "string", + "required": false, + "summary": "Optional: Version number or promotion report URL/path when using a profile (for example, \u00229.2.0\u0022 or \u0022https://buildkite.../promotion-report.html\u0022)" + }, + { + "role": "positional", + "name": "profile-report", + "shortName": null, + "type": "string", + "required": false, + "summary": "Optional: Promotion report or URL list file when also providing a version. When provided, the second argument must be a version string and this is the PR/issue filter source." + }, + { + "role": "flag", + "name": "all", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Remove all changelogs in the directory. Exactly one filter option must be specified: --all, --products, --prs, --issues, or --report.", + "defaultValue": "false" + }, + { + "role": "flag", + "name": "bundles-dir", + "shortName": null, + "type": "string", + "required": false, + "summary": "Optional: Override the directory that is scanned for bundles during the dependency check. Auto-discovered from config or fallback paths when not specified.", + "validations": [ + { + "kind": "rejectSymbolicLinks", + "min": null, + "max": null, + "pattern": null, + "values": null + }, + { + "kind": "expandUserProfile", + "min": null, + "max": null, + "pattern": null, + "values": null + } + ] + }, + { + "role": "flag", + "name": "config", + "shortName": null, + "type": "string", + "required": false, + "summary": "Optional: Path to the changelog.yml configuration file. Defaults to \u0027docs/changelog.yml\u0027", + "validations": [ + { + "kind": "rejectSymbolicLinks", + "min": null, + "max": null, + "pattern": null, + "values": null + }, + { + "kind": "existing", + "min": null, + "max": null, + "pattern": null, + "values": null + }, + { + "kind": "fileExtensions", + "min": null, + "max": null, + "pattern": null, + "values": [ + "yml", + "yaml" + ] + }, + { + "kind": "expandUserProfile", + "min": null, + "max": null, + "pattern": null, + "values": null + } + ] + }, + { + "role": "flag", + "name": "directory", + "shortName": null, + "type": "string", + "required": false, + "summary": "Optional: Directory containing changelog YAML files. Uses config bundle.directory or defaults to current directory", + "validations": [ + { + "kind": "rejectSymbolicLinks", + "min": null, + "max": null, + "pattern": null, + "values": null + }, + { + "kind": "expandUserProfile", + "min": null, + "max": null, + "pattern": null, + "values": null + } + ] + }, + { + "role": "dryRun", + "name": "dry-run", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Print the files that would be removed without deleting them. Valid in both profile and raw mode.", + "defaultValue": "false" + }, + { + "role": "confirmationSkip", + "name": "force", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Proceed with removal even when files are referenced by unresolved bundles. Emits warnings instead of errors for each dependency. Valid in both profile and raw mode.", + "defaultValue": "false" + }, + { + "role": "flag", + "name": "issues", + "shortName": null, + "type": "array", + "required": false, + "summary": "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.", + "repeatable": true, + "elementType": "string" + }, + { + "role": "flag", + "name": "owner", + "shortName": null, + "type": "string", + "required": false, + "summary": "Optional: GitHub repository owner, which is used when PRs or issues are specified as numbers or when using --release-version. Falls back to bundle.owner in changelog.yml when not specified. If that value is also absent, \u0022elastic\u0022 is used." + }, + { + "role": "flag", + "name": "products", + "shortName": null, + "type": "string", + "required": false, + "summary": "Filter by products in format \u0022product target lifecycle, ...\u0022 (for example, \u0022elasticsearch 9.3.0 ga\u0022). All three parts are required but can be wildcards (*)." + }, + { + "role": "flag", + "name": "prs", + "shortName": null, + "type": "array", + "required": false, + "summary": "Filter by pull request URLs (comma-separated) or a path to a newline-delimited file containing fully-qualified GitHub PR URLs. Can be specified multiple times.", + "repeatable": true, + "elementType": "string" + }, + { + "role": "flag", + "name": "release-version", + "shortName": null, + "type": "string", + "required": false, + "summary": "GitHub release tag to use as a filter source (for example, \u0022v9.2.0\u0022 or \u0022latest\u0022). Fetches the release, parses PR references from the release notes, and removes changelogs whose PR URLs match \u2014 equivalent to passing the PR list using --prs." + }, + { + "role": "flag", + "name": "repo", + "shortName": null, + "type": "string", + "required": false, + "summary": "GitHub repository name, which is used when PRs or issues are specified as numbers or when --release-version is used. Falls back to bundle.repo in changelog.yml when not specified. If that value is also absent, the product ID is used." + }, + { + "role": "flag", + "name": "report", + "shortName": null, + "type": "string", + "required": false, + "summary": "Optional (option-based mode only): URL or file path to a promotion report. Extracts PR URLs and uses them as the filter. Mutually exclusive with --all, --products, --prs, --release-version, and --issues." + }, + { + "role": "flag", + "name": "log-level", + "shortName": "l", + "type": "enum", + "required": false, + "summary": "Minimum log level. Default: information", + "enumValues": [ + "trace", + "debug", + "information", + "warning", + "error", + "critical", + "none" + ] + }, + { + "role": "flag", + "name": "config-source", + "shortName": "c", + "type": "enum", + "required": false, + "summary": "Override the configuration source: local, remote", + "enumValues": [ + "local", + "remote", + "embedded" + ] + }, + { + "role": "flag", + "name": "skip-private-repositories", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Skip cloning private repositories" + } + ], + "intent": { + "destructive": true, + "scope": "directory", + "requiresConfirmation": true + } + }, + { + "path": [ + "changelog" + ], + "name": "render", + "summary": "Render one or more changelog bundles to Markdown or AsciiDoc.", + "notes": null, + "usage": "docs-builder changelog render [options]", + "examples": [], + "parameters": [ + { + "role": "flag", + "name": "input", + "shortName": null, + "type": "array", + "required": false, + "summary": "Required: Bundle input(s) in format \u0022bundle-file-path|changelog-file-path|repo|link-visibility\u0022 (use pipe as delimiter). To merge multiple bundles, separate them with commas. Only bundle-file-path is required. link-visibility can be \u0022hide-links\u0022 or \u0022keep-links\u0022 (default). Use \u0022hide-links\u0022 for private repositories; when set, all PR and issue links for each affected entry are hidden (entries may have multiple links via the prs and issues arrays). Paths support tilde (~) expansion and relative paths.", + "repeatable": true, + "elementType": "string" + }, + { + "role": "flag", + "name": "config", + "shortName": null, + "type": "string", + "required": false, + "summary": "Optional: Path to the changelog.yml configuration file. Defaults to \u0027docs/changelog.yml\u0027", + "validations": [ + { + "kind": "rejectSymbolicLinks", + "min": null, + "max": null, + "pattern": null, + "values": null + }, + { + "kind": "existing", + "min": null, + "max": null, + "pattern": null, + "values": null + }, + { + "kind": "fileExtensions", + "min": null, + "max": null, + "pattern": null, + "values": [ + "yml", + "yaml" + ] + }, + { + "kind": "expandUserProfile", + "min": null, + "max": null, + "pattern": null, + "values": null + } + ] + }, + { + "role": "flag", + "name": "file-type", + "shortName": null, + "type": "string", + "required": false, + "summary": "Optional: Output file type. Valid values: \u0022markdown\u0022 or \u0022asciidoc\u0022. Defaults to \u0022markdown\u0022", + "defaultValue": "markdown" + }, + { + "role": "flag", + "name": "hide-features", + "shortName": null, + "type": "array", + "required": false, + "summary": "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 in the output.", + "repeatable": true, + "elementType": "string" + }, + { + "role": "flag", + "name": "output", + "shortName": null, + "type": "string", + "required": false, + "summary": "Optional: Output directory for rendered files. Defaults to current directory" + }, + { + "role": "flag", + "name": "subsections", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Optional: Group entries by area/component in subsections. For breaking changes with a subtype, groups by subtype instead of area. Defaults to false", + "defaultValue": "false" + }, + { + "role": "flag", + "name": "title", + "shortName": null, + "type": "string", + "required": false, + "summary": "Optional: Title to use for section headers in output files. Defaults to version from first bundle" + }, + { + "role": "flag", + "name": "log-level", + "shortName": "l", + "type": "enum", + "required": false, + "summary": "Minimum log level. Default: information", + "enumValues": [ + "trace", + "debug", + "information", + "warning", + "error", + "critical", + "none" + ] + }, + { + "role": "flag", + "name": "config-source", + "shortName": "c", + "type": "enum", + "required": false, + "summary": "Override the configuration source: local, remote", + "enumValues": [ + "local", + "remote", + "embedded" + ] + }, + { + "role": "flag", + "name": "skip-private-repositories", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Skip cloning private repositories" + } + ] + }, + { + "path": [ + "changelog" + ], + "name": "upload", + "summary": "Upload changelog entries or bundle artifacts to S3 or Elasticsearch.", + "notes": "Uses content-hash\u2013based incremental transfer \u2014 only changed files are uploaded.", + "usage": "docs-builder changelog upload --artifact-type \u003Cstring\u003E --target \u003Cstring\u003E [options]", + "examples": [], + "parameters": [ + { + "role": "flag", + "name": "artifact-type", + "shortName": null, + "type": "string", + "required": true, + "summary": "Artifact type to upload: \u0027changelog\u0027 (individual entries) or \u0027bundle\u0027 (consolidated bundles)." + }, + { + "role": "flag", + "name": "target", + "shortName": null, + "type": "string", + "required": true, + "summary": "Upload destination: \u0027s3\u0027 or \u0027elasticsearch\u0027." + }, + { + "role": "flag", + "name": "s3-bucket-name", + "shortName": null, + "type": "string", + "required": false, + "summary": "S3 bucket name (required when target is \u0027s3\u0027)." + }, + { + "role": "flag", + "name": "config", + "shortName": null, + "type": "string", + "required": false, + "summary": "Path to changelog.yml configuration file. Defaults to docs/changelog.yml.", + "validations": [ + { + "kind": "rejectSymbolicLinks", + "min": null, + "max": null, + "pattern": null, + "values": null + }, + { + "kind": "existing", + "min": null, + "max": null, + "pattern": null, + "values": null + }, + { + "kind": "fileExtensions", + "min": null, + "max": null, + "pattern": null, + "values": [ + "yml", + "yaml" + ] + }, + { + "kind": "expandUserProfile", + "min": null, + "max": null, + "pattern": null, + "values": null + } + ] + }, + { + "role": "flag", + "name": "directory", + "shortName": null, + "type": "string", + "required": false, + "summary": "Override changelog directory instead of reading it from config.", + "validations": [ + { + "kind": "rejectSymbolicLinks", + "min": null, + "max": null, + "pattern": null, + "values": null + }, + { + "kind": "expandUserProfile", + "min": null, + "max": null, + "pattern": null, + "values": null + } + ] + }, + { + "role": "flag", + "name": "log-level", + "shortName": "l", + "type": "enum", + "required": false, + "summary": "Minimum log level. Default: information", + "enumValues": [ + "trace", + "debug", + "information", + "warning", + "error", + "critical", + "none" + ] + }, + { + "role": "flag", + "name": "config-source", + "shortName": "c", + "type": "enum", + "required": false, + "summary": "Override the configuration source: local, remote", + "enumValues": [ + "local", + "remote", + "embedded" + ] + }, + { + "role": "flag", + "name": "skip-private-repositories", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Skip cloning private repositories" + } + ] + } + ], + "namespaces": [] + }, + { + "segment": "codex", + "summary": "Build a documentation portal over multiple independent documentation sets, each with its own navigation.", + "notes": null, + "options": [], + "defaultCommand": { + "kind": "namespace", + "summary": "Clone all repositories and build the portal in one step.", + "notes": null, + "usage": "docs-builder codex __argh_root \u003Cconfig\u003E [options]", + "examples": [], + "parameters": [ + { + "role": "positional", + "name": "config", + "shortName": null, + "type": "string", + "required": true, + "summary": "Path to the codex.yml configuration file.", + "validations": [ + { + "kind": "rejectSymbolicLinks", + "min": null, + "max": null, + "pattern": null, + "values": null + }, + { + "kind": "existing", + "min": null, + "max": null, + "pattern": null, + "values": null + }, + { + "kind": "fileExtensions", + "min": null, + "max": null, + "pattern": null, + "values": [ + "yml", + "yaml" + ] + }, + { + "kind": "expandUserProfile", + "min": null, + "max": null, + "pattern": null, + "values": null + } + ] + }, + { + "role": "flag", + "name": "strict", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Treat warnings as errors.", + "defaultValue": "false" + }, + { + "role": "flag", + "name": "fetch-latest", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Fetch the HEAD of each branch instead of the pinned ref.", + "defaultValue": "false" + }, + { + "role": "flag", + "name": "assume-cloned", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Skip cloning; assume repositories are already on disk.", + "defaultValue": "false" + }, + { + "role": "flag", + "name": "output", + "shortName": null, + "type": "string", + "required": false, + "summary": "Output directory for the built portal. Defaults to .artifacts/codex/.", + "validations": [ + { + "kind": "rejectSymbolicLinks", + "min": null, + "max": null, + "pattern": null, + "values": null + }, + { + "kind": "expandUserProfile", + "min": null, + "max": null, + "pattern": null, + "values": null + } + ] + }, + { + "role": "flag", + "name": "serve", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Serve the portal on port 4000 after a successful build.", + "defaultValue": "false" + }, + { + "role": "flag", + "name": "log-level", + "shortName": "l", + "type": "enum", + "required": false, + "summary": "Minimum log level. Default: information", + "enumValues": [ + "trace", + "debug", + "information", + "warning", + "error", + "critical", + "none" + ] + }, + { + "role": "flag", + "name": "config-source", + "shortName": "c", + "type": "enum", + "required": false, + "summary": "Override the configuration source: local, remote", + "enumValues": [ + "local", + "remote", + "embedded" + ] + }, + { + "role": "flag", + "name": "skip-private-repositories", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Skip cloning private repositories" + } + ] + }, + "commands": [ + { + "path": [ + "codex" + ], + "name": "build", + "summary": "Build the portal from previously cloned repositories.", + "notes": "Run after codex clone.", + "usage": "docs-builder codex build \u003Cconfig\u003E [options]", + "examples": [], + "parameters": [ + { + "role": "positional", + "name": "config", + "shortName": null, + "type": "string", + "required": true, + "summary": "Path to the codex.yml configuration file.", + "validations": [ + { + "kind": "rejectSymbolicLinks", + "min": null, + "max": null, + "pattern": null, + "values": null + }, + { + "kind": "existing", + "min": null, + "max": null, + "pattern": null, + "values": null + }, + { + "kind": "fileExtensions", + "min": null, + "max": null, + "pattern": null, + "values": [ + "yml", + "yaml" + ] + }, + { + "kind": "expandUserProfile", + "min": null, + "max": null, + "pattern": null, + "values": null + } + ] + }, + { + "role": "flag", + "name": "strict", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Treat warnings as errors.", + "defaultValue": "false" + }, + { + "role": "flag", + "name": "output", + "shortName": null, + "type": "string", + "required": false, + "summary": "Output directory. Defaults to .artifacts/codex/.", + "validations": [ + { + "kind": "rejectSymbolicLinks", + "min": null, + "max": null, + "pattern": null, + "values": null + }, + { + "kind": "expandUserProfile", + "min": null, + "max": null, + "pattern": null, + "values": null + } + ] + }, + { + "role": "flag", + "name": "log-level", + "shortName": "l", + "type": "enum", + "required": false, + "summary": "Minimum log level. Default: information", + "enumValues": [ + "trace", + "debug", + "information", + "warning", + "error", + "critical", + "none" + ] + }, + { + "role": "flag", + "name": "config-source", + "shortName": "c", + "type": "enum", + "required": false, + "summary": "Override the configuration source: local, remote", + "enumValues": [ + "local", + "remote", + "embedded" + ] + }, + { + "role": "flag", + "name": "skip-private-repositories", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Skip cloning private repositories" + } + ], + "intent": { + "idempotent": true + } + }, + { + "path": [ + "codex" + ], + "name": "clone", + "summary": "Clone all repositories listed in the codex configuration.", + "notes": null, + "usage": "docs-builder codex clone \u003Cconfig\u003E [options]", + "examples": [], + "parameters": [ + { + "role": "positional", + "name": "config", + "shortName": null, + "type": "string", + "required": true, + "summary": "Path to the codex.yml configuration file.", + "validations": [ + { + "kind": "rejectSymbolicLinks", + "min": null, + "max": null, + "pattern": null, + "values": null + }, + { + "kind": "existing", + "min": null, + "max": null, + "pattern": null, + "values": null + }, + { + "kind": "fileExtensions", + "min": null, + "max": null, + "pattern": null, + "values": [ + "yml", + "yaml" + ] + }, + { + "kind": "expandUserProfile", + "min": null, + "max": null, + "pattern": null, + "values": null + } + ] + }, + { + "role": "flag", + "name": "strict", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Treat warnings as errors.", + "defaultValue": "false" + }, + { + "role": "flag", + "name": "fetch-latest", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Fetch the HEAD of each branch instead of the pinned ref.", + "defaultValue": "false" + }, + { + "role": "flag", + "name": "assume-cloned", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Skip cloning; assume repositories are already on disk.", + "defaultValue": "false" + }, + { + "role": "flag", + "name": "log-level", + "shortName": "l", + "type": "enum", + "required": false, + "summary": "Minimum log level. Default: information", + "enumValues": [ + "trace", + "debug", + "information", + "warning", + "error", + "critical", + "none" + ] + }, + { + "role": "flag", + "name": "config-source", + "shortName": "c", + "type": "enum", + "required": false, + "summary": "Override the configuration source: local, remote", + "enumValues": [ + "local", + "remote", + "embedded" + ] + }, + { + "role": "flag", + "name": "skip-private-repositories", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Skip cloning private repositories" + } + ], + "intent": { + "idempotent": true + } + }, + { + "path": [ + "codex" + ], + "name": "index", + "summary": "Index the built portal documentation into Elasticsearch.", + "notes": "Run after codex build. Streams documents from all included documentation sets to the cluster.", + "usage": "docs-builder codex index \u003Cconfig\u003E [options]", + "examples": [], + "parameters": [ + { + "role": "positional", + "name": "config", + "shortName": null, + "type": "string", + "required": true, + "summary": "Path to the codex.yml configuration file.", + "validations": [ + { + "kind": "rejectSymbolicLinks", + "min": null, + "max": null, + "pattern": null, + "values": null + }, + { + "kind": "existing", + "min": null, + "max": null, + "pattern": null, + "values": null + }, + { + "kind": "fileExtensions", + "min": null, + "max": null, + "pattern": null, + "values": [ + "yml", + "yaml" + ] + }, + { + "kind": "expandUserProfile", + "min": null, + "max": null, + "pattern": null, + "values": null + } + ] + }, + { + "role": "flag", + "name": "endpoint", + "shortName": null, + "type": "string", + "required": false, + "summary": "-es,--endpoint, Elasticsearch endpoint URL. Falls back to env DOCUMENTATION_ELASTIC_URL.", + "validations": [ + { + "kind": "uriScheme", + "min": null, + "max": null, + "pattern": null, + "values": [ + "http", + "https" + ] + } + ] + }, + { + "role": "flag", + "name": "api-key", + "shortName": null, + "type": "string", + "required": false, + "summary": "API key for authentication. Falls back to env DOCUMENTATION_ELASTIC_APIKEY." + }, + { + "role": "flag", + "name": "username", + "shortName": null, + "type": "string", + "required": false, + "summary": "Username for basic authentication. Falls back to env DOCUMENTATION_ELASTIC_USERNAME." + }, + { + "role": "flag", + "name": "password", + "shortName": null, + "type": "string", + "required": false, + "summary": "Password for basic authentication. Falls back to env DOCUMENTATION_ELASTIC_PASSWORD." + }, + { + "role": "flag", + "name": "ai-enrichment", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Enable AI enrichment of documents using LLM-generated metadata (enabled by default)." + }, + { + "role": "flag", + "name": "search-num-threads", + "shortName": null, + "type": "integer", + "required": false, + "summary": "Number of search threads for the inference endpoint.", + "validations": [ + { + "kind": "range", + "min": "1", + "max": "128", + "pattern": null, + "values": null + } + ] + }, + { + "role": "flag", + "name": "index-num-threads", + "shortName": null, + "type": "integer", + "required": false, + "summary": "Number of index threads for the inference endpoint.", + "validations": [ + { + "kind": "range", + "min": "1", + "max": "128", + "pattern": null, + "values": null + } + ] + }, + { + "role": "flag", + "name": "eis", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Use the Elastic Inference Service to bootstrap the inference endpoint (enabled by default)." + }, + { + "role": "flag", + "name": "bootstrap-timeout", + "shortName": null, + "type": "string", + "required": false, + "summary": "How long to wait for the inference endpoint to become ready (e.g. 4m, 90s).", + "validations": [ + { + "kind": "timeSpanRange", + "min": "\u00221s\u0022", + "max": "\u002260m\u0022", + "pattern": null, + "values": null + } + ] + }, + { + "role": "flag", + "name": "force-reindex", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Force a full reindex, discarding any incremental state." + }, + { + "role": "flag", + "name": "buffer-size", + "shortName": null, + "type": "integer", + "required": false, + "summary": "Number of documents per bulk request.", + "validations": [ + { + "kind": "range", + "min": "1", + "max": "10000", + "pattern": null, + "values": null + } + ] + }, + { + "role": "flag", + "name": "max-retries", + "shortName": null, + "type": "integer", + "required": false, + "summary": "Number of retry attempts for failed bulk items.", + "validations": [ + { + "kind": "range", + "min": "0", + "max": "20", + "pattern": null, + "values": null + } + ] + }, + { + "role": "flag", + "name": "debug-mode", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Log every Elasticsearch request and response body; append ?pretty to all requests." + }, + { + "role": "flag", + "name": "proxy-address", + "shortName": null, + "type": "string", + "required": false, + "summary": "Route requests through this proxy URL.", + "validations": [ + { + "kind": "uriScheme", + "min": null, + "max": null, + "pattern": null, + "values": [ + "http", + "https" + ] + } + ] + }, + { + "role": "flag", + "name": "proxy-username", + "shortName": null, + "type": "string", + "required": false, + "summary": "Proxy server username." + }, + { + "role": "flag", + "name": "proxy-password", + "shortName": null, + "type": "string", + "required": false, + "summary": "Proxy server password." + }, + { + "role": "flag", + "name": "disable-ssl-verification", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Disable SSL certificate validation. Use only in controlled environments." + }, + { + "role": "flag", + "name": "certificate-fingerprint", + "shortName": null, + "type": "string", + "required": false, + "summary": "SHA-256 fingerprint of a self-signed server certificate." + }, + { + "role": "flag", + "name": "certificate-path", + "shortName": null, + "type": "string", + "required": false, + "summary": "Path to a PEM or DER certificate file for SSL validation.", + "validations": [ + { + "kind": "rejectSymbolicLinks", + "min": null, + "max": null, + "pattern": null, + "values": null + }, + { + "kind": "existing", + "min": null, + "max": null, + "pattern": null, + "values": null + }, + { + "kind": "fileExtensions", + "min": null, + "max": null, + "pattern": null, + "values": [ + "pem", + "der", + "crt", + "cer" + ] + }, + { + "kind": "expandUserProfile", + "min": null, + "max": null, + "pattern": null, + "values": null + } + ] + }, + { + "role": "flag", + "name": "certificate-not-root", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Set when the certificate is an intermediate CA rather than the root." + }, + { + "role": "flag", + "name": "log-level", + "shortName": "l", + "type": "enum", + "required": false, + "summary": "Minimum log level. Default: information", + "enumValues": [ + "trace", + "debug", + "information", + "warning", + "error", + "critical", + "none" + ] + }, + { + "role": "flag", + "name": "config-source", + "shortName": "c", + "type": "enum", + "required": false, + "summary": "Override the configuration source: local, remote", + "enumValues": [ + "local", + "remote", + "embedded" + ] + }, + { + "role": "flag", + "name": "skip-private-repositories", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Skip cloning private repositories" + } + ], + "intent": { + "requiresAuth": true + } + }, + { + "path": [ + "codex" + ], + "name": "serve", + "summary": "Serve the built portal at http://localhost:4000.", + "notes": "Run after codex build. Does not rebuild on file changes.", + "usage": "docs-builder codex serve [options]", + "examples": [], + "parameters": [ + { + "role": "flag", + "name": "port", + "shortName": null, + "type": "integer", + "required": false, + "summary": "Port to listen on. Default: 4000.", + "defaultValue": "4000" + }, + { + "role": "flag", + "name": "path", + "shortName": null, + "type": "string", + "required": false, + "summary": "Path to the portal output. Defaults to .artifacts/codex/docs/.", + "validations": [ + { + "kind": "rejectSymbolicLinks", + "min": null, + "max": null, + "pattern": null, + "values": null + }, + { + "kind": "existing", + "min": null, + "max": null, + "pattern": null, + "values": null + }, + { + "kind": "expandUserProfile", + "min": null, + "max": null, + "pattern": null, + "values": null + } + ] + }, + { + "role": "flag", + "name": "log-level", + "shortName": "l", + "type": "enum", + "required": false, + "summary": "Minimum log level. Default: information", + "enumValues": [ + "trace", + "debug", + "information", + "warning", + "error", + "critical", + "none" + ] + }, + { + "role": "flag", + "name": "config-source", + "shortName": "c", + "type": "enum", + "required": false, + "summary": "Override the configuration source: local, remote", + "enumValues": [ + "local", + "remote", + "embedded" + ] + }, + { + "role": "flag", + "name": "skip-private-repositories", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Skip cloning private repositories" + } + ] + }, + { + "path": [ + "codex" + ], + "name": "update-redirects", + "summary": "Push the codex redirects mapping to CloudFront\u0027s KeyValueStore.", + "notes": "Run after codex build produces a redirects.json.", + "usage": "docs-builder codex update-redirects \u003Cconfig\u003E [options]", + "examples": [], + "parameters": [ + { + "role": "positional", + "name": "config", + "shortName": null, + "type": "string", + "required": true, + "summary": "Path to the codex.yml configuration file (used to resolve the environment).", + "validations": [ + { + "kind": "rejectSymbolicLinks", + "min": null, + "max": null, + "pattern": null, + "values": null + }, + { + "kind": "existing", + "min": null, + "max": null, + "pattern": null, + "values": null + }, + { + "kind": "fileExtensions", + "min": null, + "max": null, + "pattern": null, + "values": [ + "yml", + "yaml" + ] + }, + { + "kind": "expandUserProfile", + "min": null, + "max": null, + "pattern": null, + "values": null + } + ] + }, + { + "role": "flag", + "name": "environment", + "shortName": null, + "type": "string", + "required": false, + "summary": "Named deployment target. Defaults to the value in codex.yml or the ENVIRONMENT env var." + }, + { + "role": "flag", + "name": "redirects-file", + "shortName": null, + "type": "string", + "required": false, + "summary": "Path to redirects.json. Defaults to .artifacts/codex/docs/redirects.json.", + "validations": [ + { + "kind": "rejectSymbolicLinks", + "min": null, + "max": null, + "pattern": null, + "values": null + }, + { + "kind": "existing", + "min": null, + "max": null, + "pattern": null, + "values": null + }, + { + "kind": "fileExtensions", + "min": null, + "max": null, + "pattern": null, + "values": [ + "json" + ] + }, + { + "kind": "expandUserProfile", + "min": null, + "max": null, + "pattern": null, + "values": null + } + ] + }, + { + "role": "flag", + "name": "log-level", + "shortName": "l", + "type": "enum", + "required": false, + "summary": "Minimum log level. Default: information", + "enumValues": [ + "trace", + "debug", + "information", + "warning", + "error", + "critical", + "none" + ] + }, + { + "role": "flag", + "name": "config-source", + "shortName": "c", + "type": "enum", + "required": false, + "summary": "Override the configuration source: local, remote", + "enumValues": [ + "local", + "remote", + "embedded" + ] + }, + { + "role": "flag", + "name": "skip-private-repositories", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Skip cloning private repositories" + } + ] + } + ], + "namespaces": [] + }, + { + "segment": "inbound-links", + "summary": "Validate cross-doc-set links against the published link registry.", + "notes": "Every documentation set publishes a links.json file containing the URLs of all its pages.\nThese files are aggregated into a shared link registry. Inbound-links commands validate that\ncross-links between documentation sets resolve to real pages in the registry.", + "options": [], + "defaultCommand": null, + "commands": [ + { + "path": [ + "inbound-links" + ], + "name": "validate", + "summary": "Validate all cross-links originating from or targeting a specific repository.", + "notes": null, + "usage": "docs-builder inbound-links validate [options]", + "examples": [], + "parameters": [ + { + "role": "flag", + "name": "from", + "shortName": null, + "type": "string", + "required": false, + "summary": "Only check links published by this repository slug." + }, + { + "role": "flag", + "name": "to", + "shortName": null, + "type": "string", + "required": false, + "summary": "Only check links that point to this repository slug." + }, + { + "role": "flag", + "name": "log-level", + "shortName": "l", + "type": "enum", + "required": false, + "summary": "Minimum log level. Default: information", + "enumValues": [ + "trace", + "debug", + "information", + "warning", + "error", + "critical", + "none" + ] + }, + { + "role": "flag", + "name": "config-source", + "shortName": "c", + "type": "enum", + "required": false, + "summary": "Override the configuration source: local, remote", + "enumValues": [ + "local", + "remote", + "embedded" + ] + }, + { + "role": "flag", + "name": "skip-private-repositories", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Skip cloning private repositories" + } + ] + }, + { + "path": [ + "inbound-links" + ], + "name": "validate-all", + "summary": "Validate all cross-links across every published links.json in the registry.", + "notes": null, + "usage": "docs-builder inbound-links validate-all", + "examples": [], + "parameters": [ + { + "role": "flag", + "name": "log-level", + "shortName": "l", + "type": "enum", + "required": false, + "summary": "Minimum log level. Default: information", + "enumValues": [ + "trace", + "debug", + "information", + "warning", + "error", + "critical", + "none" + ] + }, + { + "role": "flag", + "name": "config-source", + "shortName": "c", + "type": "enum", + "required": false, + "summary": "Override the configuration source: local, remote", + "enumValues": [ + "local", + "remote", + "embedded" + ] + }, + { + "role": "flag", + "name": "skip-private-repositories", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Skip cloning private repositories" + } + ] + }, + { + "path": [ + "inbound-links" + ], + "name": "validate-link-reference", + "summary": "Validate a locally built links.json against the published link registry.", + "notes": "Use this to verify cross-links before publishing. The local links.json is checked against\nall currently published registries to ensure every outbound cross-link resolves.", + "usage": "docs-builder inbound-links validate-link-reference [options]", + "examples": [], + "parameters": [ + { + "role": "flag", + "name": "file", + "shortName": null, + "type": "string", + "required": false, + "summary": "Path to links.json. Defaults to .artifacts/docs/html/links.json.", + "validations": [ + { + "kind": "rejectSymbolicLinks", + "min": null, + "max": null, + "pattern": null, + "values": null + }, + { + "kind": "existing", + "min": null, + "max": null, + "pattern": null, + "values": null + }, + { + "kind": "fileExtensions", + "min": null, + "max": null, + "pattern": null, + "values": [ + "json" + ] + }, + { + "kind": "expandUserProfile", + "min": null, + "max": null, + "pattern": null, + "values": null + } + ] + }, + { + "role": "flag", + "name": "path", + "shortName": "p", + "type": "string", + "required": false, + "summary": "Root of the documentation source. Defaults to cwd." + }, + { + "role": "flag", + "name": "log-level", + "shortName": "l", + "type": "enum", + "required": false, + "summary": "Minimum log level. Default: information", + "enumValues": [ + "trace", + "debug", + "information", + "warning", + "error", + "critical", + "none" + ] + }, + { + "role": "flag", + "name": "config-source", + "shortName": "c", + "type": "enum", + "required": false, + "summary": "Override the configuration source: local, remote", + "enumValues": [ + "local", + "remote", + "embedded" + ] + }, + { + "role": "flag", + "name": "skip-private-repositories", + "shortName": null, + "type": "boolean", + "required": false, + "summary": "Skip cloning private repositories" + } + ] + } + ], + "namespaces": [] + } + ] +} \ No newline at end of file diff --git a/docs/cli/assembler/assemble.md b/docs/cli/assembler/assemble.md deleted file mode 100644 index 13058aa0cb..0000000000 --- a/docs/cli/assembler/assemble.md +++ /dev/null @@ -1,91 +0,0 @@ -# assemble - -Do a full assembler clone, build and optional serving of the full documentation in one swoop - -## Usage - -``` -docs-builder assemble [options...] [-h|--help] [--version] -``` - - - -## Usage examples - -The following will clone the repository, build the documentation and serve it on port 4000 using the embedded configuration inside the `docs-builder` binary. - -```bash -docs-builder assemble --serve -``` - -This single command is equivalent to the following commands: - -```bash -docs-builder assembler clone -docs-builder assembler build -docs-builder assembler serve -``` - -### Using a local workspace for assembler builds - -Where this command really shines is when you want to create a temporary workspace folder to validate: - -* changes to [site wide configuration](../../configure/site/index.md). -* changes to one or more repositories and their effect on the assembler build. - -To do that inside an empty folder, call: - -```bash -docs-builder assembler config init --local -docs-builder assemble --serve -``` - -This will source the latest configuration from [The `config` folder on the `main` branch of `docs-builder`](https://github.com/elastic/docs-builder/tree/main/config) -and place them inside the `$(pwd)/config` folder. - -Now when you call `docs-builder assemble` rather than using the embedded configuration, it will use local one that one you just created. -You can be explicit about the configuration source to use: - -```bash -docs-builder assembler config init --local -docs-builder assemble --serve -c local -``` - -## Options - -`--strict` `` -: Treat warnings as errors and fail the build on warnings (optional) - -`--environment` `` -: The environment to build (optional) defaults to 'dev' - -`--fetch-latest` `` -: If true, fetch the latest commit of the branch instead of the link registry entry ref (optional) - -`--assume-cloned` `` -: If true, assume the repository folder already exists on disk assume it's cloned already, primarily used for testing (optional) - -`--metadata-only` `` -: Only emit documentation metadata to output, ignored if 'exporters' is also set (optional) - -`--show-hints` `` -: Show hints from all documentation sets during assembler build (optional) - -`--exporters` `` -: Set available exporters: - - * html - * es, - * config, - * links, - * state, - * llm, - * redirect, - * metadata, - * default - * none. - - Defaults to (html, llm, config, links, state, redirect) or 'default'. (optional) - -`--serve` -: Serve the documentation on port 4000 after successful build (Optional) \ No newline at end of file diff --git a/docs/cli/assembler/assembler-bloom-filter-create.md b/docs/cli/assembler/assembler-bloom-filter-create.md deleted file mode 100644 index cda2ec4a21..0000000000 --- a/docs/cli/assembler/assembler-bloom-filter-create.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -navigation_title: "bloom-filter create" ---- - -# assembler bloom-filter create - -Generates a bloom filter that gets embedded into the `docs-builder` binary. - -This bloom filter is used to determine whether a document's `mapped_page` in the frontmatter exists in - -the project of [legacy-url-mappings](../../configure/site/legacy-url-mappings.md) - -The existence determines how the document history selector should be populated. - -## Usage - -``` -docs-builder assembler bloom-filter create [options...] [-h|--help] [--version] -``` - -## Options - -`--built-docs-dir` `` -: The local dir of local elastic/built-docs repository (Required) \ No newline at end of file diff --git a/docs/cli/assembler/assembler-bloom-filter-lookup.md b/docs/cli/assembler/assembler-bloom-filter-lookup.md deleted file mode 100644 index bdb3027121..0000000000 --- a/docs/cli/assembler/assembler-bloom-filter-lookup.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -navigation_title: "bloom-filter lookup" ---- -# assembler bloom-filter lookup - -Test command to assert if an old V2 url matches with our bloom filter - -## Usage - -``` -docs-builder assembler bloom-filter lookup [options...] [-h|--help] [--version] -``` - -## Options - -`--path` `` -: The local dir of local elastic/built-docs repository (Required) \ No newline at end of file diff --git a/docs/cli/assembler/assembler-build.md b/docs/cli/assembler/assembler-build.md deleted file mode 100644 index 6134277a1a..0000000000 --- a/docs/cli/assembler/assembler-build.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -navigation_title: "build" ---- - -# assembler build - -:::note -This command requires that you've previously ran `docs-builder assembler clone` to clone the documentation sets. -If you clone using a certain `--environment` you must also use that same `--environment` when building. -::: - -Builds all the documentation sets and assembles them into an assembled complete documentation site that's ready to be deployed. - -It uses [the site configuration files](../../configure/site/index.md) to direct how the documentation sets should be assembled. - -## Usage - -``` -docs-builder assembler build [options...] [-h|--help] [--version] -``` - -## Options - -`--strict` `` -: Treat warnings as errors and fail the build on warnings (optional) - -`--environment` `` -: The environment to build (optional) - -`--metadata-only` `` -: Only emit documentation metadata to output, ignored if 'exporters' is also set (optional) - -`--show-hints` `` -: Show hints from all documentation sets during assembler build (optional) - -`--exporters` `` -: Set available exporters: - - * html - * es, - * config, - * links, - * state, - * llm, - * redirect, - * metadata, - * default - * none. - - Defaults to (html, llm, config, links, state, redirect) or 'default'. (optional) - diff --git a/docs/cli/assembler/assembler-clone.md b/docs/cli/assembler/assembler-clone.md deleted file mode 100644 index 0a555990f8..0000000000 --- a/docs/cli/assembler/assembler-clone.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -navigation_title: "clone" ---- - -# assembler clone - -Clones all repositories. Defaults to `$(pwd)/.artifacts/checkouts/{content_source}`. - -The `content_source` is the `content_source` of the `--environment` option as configured in `assembly.yaml` - -## Usage - -``` -docs-builder assembler clone [options...] [-h|--help] [--version] -``` - -## Options - -`--strict` `` -: Treat warnings as errors and fail the build on warnings (optional) - -`--environment` `` -: The environment to build (optional) - -`--fetch-latest` `` -: If true, fetch the latest commit of the branch instead of the link registry entry ref (optional) - -`--assume-cloned` `` -: If true, assume the repository folder already exists on disk assume it's cloned already, primarily used for testing (optional) \ No newline at end of file diff --git a/docs/cli/assembler/assembler-config-init.md b/docs/cli/assembler/assembler-config-init.md deleted file mode 100644 index d1c5e4eb49..0000000000 --- a/docs/cli/assembler/assembler-config-init.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -navigation_title: "config init" ---- - -# assembler config init - -Sources the configuration from [The `config` folder on the `main` branch of `docs-builder`](https://github.com/elastic/docs-builder/tree/main/config) - -By default, the configuration is placed in a special application folder as its main usages is to be used by CI environments. - -* OSX: `~/Library/Application Support/docs-builder` [NSApplicationSupportDirectory](https://developer.apple.com/documentation/foundation/filemanager/searchpathdirectory/applicationsupportdirectory). -* Linux: `~/.config/docs-builder`. -* {icon}`logo_windows` Windows: `%APPDATA%\docs-builder`. - -You can also use the `--local` option to save the configuration locally in the current working directory. This exposes a great way to assemble the full documentation locally in an empty directory. - -See [using assemble to create local workspaces](assemble.md#using-a-local-workspace-for-assembler-builds) for more information. - -## Usage - -``` -docs-builder assembler config init [options...] [-h|--help] [--version] -``` - -## Options - -`--git-ref` `` -: The git reference of the config, defaults to 'main' (optional) - -`--local` -: Save the remote configuration locally in the pwd so later commands can pick it up as local (Optional) \ No newline at end of file diff --git a/docs/cli/assembler/assembler-content-source-match.md b/docs/cli/assembler/assembler-content-source-match.md deleted file mode 100644 index ab955ff325..0000000000 --- a/docs/cli/assembler/assembler-content-source-match.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -navigation_title: "content-source match" ---- - -# assembler content-source match - -This command is used to match a repository and branch to a content source it will emit the following `$GITHUB_OUTPUT`: - -* `content-source-match` - whether the branch is a configured content source. -* `content-source-next` - whether the branch is the next content source. -* `content-source-current` - whether the branch is the current content source. -* `content-source-speculative` - whether the branch is a speculative content source. - -#### Speculative builds - -If branches follow semantic versioning, if a branch is cut that is greater than the current version, it will be considered a speculative build. -`docs-builer`'s shared workflow will trigger even if it's not specified as a content source in `assembler.yml`. - -This allows a branch `links.json` to be published to the `Link Service` a head of time before it's configured as a content source. - -## Usage - -``` -docs-builder assembler content-source match [-h|--help] [--version] -``` - -## Arguments - -` -: The name of the `elastic/` repository you want to match if it should be build on CI - -` -: The branch you want to match if it should be build on CI` \ No newline at end of file diff --git a/docs/cli/assembler/assembler-content-source-validate.md b/docs/cli/assembler/assembler-content-source-validate.md deleted file mode 100644 index 661269a812..0000000000 --- a/docs/cli/assembler/assembler-content-source-validate.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -navigation_title: "content-source validate" ---- - -# assembler content-source validate - -Validates that the configured content source branches are publishing succesfully to the `Links Service`. - -## Usage - -``` -docs-builder assembler content-source validate [-h|--help] [--version] -``` \ No newline at end of file diff --git a/docs/cli/assembler/assembler-deploy-apply.md b/docs/cli/assembler/assembler-deploy-apply.md deleted file mode 100644 index 4e745387ad..0000000000 --- a/docs/cli/assembler/assembler-deploy-apply.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -navigation_title: "deploy apply" ---- - -# assembler deploy apply - -Applies an incremental synchronization plan created by [`docs-builder assembler deploy plan`](./assembler-deploy-plan.md). - -## Usage - -``` -docs-builder assembler deploy apply [options...] [-h|--help] [--version] -``` - -## Options - -`--environment` `` -: The environment to build (Required) - -`--s3-bucket-name` `` -: The S3 bucket name to deploy to (Required) - -`--plan-file` `` -: The file path to the plan file to apply (Required) \ No newline at end of file diff --git a/docs/cli/assembler/assembler-deploy-plan.md b/docs/cli/assembler/assembler-deploy-plan.md deleted file mode 100644 index cf4e5c5e7b..0000000000 --- a/docs/cli/assembler/assembler-deploy-plan.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -navigation_title: "deploy plan" ---- - -# assembler deploy plan - -Creates an incremental synchronization plan by comparing the reote `--s3-bucket-name` with the local output of the build. - -## Usage - -``` -docs-builder assembler deploy plan [options...] [-h|--help] [--version] -``` - -## Options - -`--environment` `` -: The environment to build (Required) - -`--s3-bucket-name` `` -: The S3 bucket name to deploy to (Required) - -`--out` `` -: The file to write the plan to (Default: "") - -`--delete-threshold` `` -: The percentage of deletions allowed in the plan as float (optional) \ No newline at end of file diff --git a/docs/cli/assembler/assembler-deploy-update-redirects.md b/docs/cli/assembler/assembler-deploy-update-redirects.md deleted file mode 100644 index 5a811df776..0000000000 --- a/docs/cli/assembler/assembler-deploy-update-redirects.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -navigation_title: "deploy update-redirects" ---- - -# assembler deploy update-redirects - -Refreshes the redirects mapping in Cloudfront's KeyValueStore - -## Usage - -``` -docs-builder assembler deploy update-redirects [options...] [-h|--help] [--version] -``` - -## Options - -`--environment` `` -: The environment to build (Required) - -`--redirects-file` `` -: Path to the redirects mapping pre-generated by docs-builder assemble (optional) \ No newline at end of file diff --git a/docs/cli/assembler/assembler-index.md b/docs/cli/assembler/assembler-index.md deleted file mode 100644 index 8ae72ddcda..0000000000 --- a/docs/cli/assembler/assembler-index.md +++ /dev/null @@ -1,75 +0,0 @@ ---- -navigation_title: "index" ---- - -# assembler index - -Index documentation to Elasticsearch, calls `docs-builder assembler build --exporters elasticsearch`. Exposes more options - -## Usage - -``` -docs-builder assembler index [options...] [-h|--help] [--version] -``` - -## Options - -`-es|--endpoint ` -: Elasticsearch endpoint, alternatively set env DOCUMENTATION_ELASTIC_URL (optional) - -`--environment` `` -: The --environment used to clone ends up being part of the index name (optional) - -`--api-key` `` -: Elasticsearch API key, alternatively set env DOCUMENTATION_ELASTIC_APIKEY (optional) - -`--username` `` -: Elasticsearch username (basic auth), alternatively set env DOCUMENTATION_ELASTIC_USERNAME (optional) - -`--password` `` -: Elasticsearch password (basic auth), alternatively set env DOCUMENTATION_ELASTIC_PASSWORD (optional) - -`--search-num-threads` `` -: The number of search threads the inference endpoint should use. Defaults: 8 (optional) - -`--index-num-threads` `` -: The number of index threads the inference endpoint should use. Defaults: 8 (optional) - -`--bootstrap-timeout` `` -: Timeout in minutes for the inference endpoint creation. Defaults: 4 (optional) - -`--index-name-prefix` `` -: The prefix for the computed index/alias names. Defaults: semantic-docs (optional) - -`--force-reindex` `` -: Force reindex strategy to semantic index, by default, we multiplex writes if the semantic index does not exist yet (optional) - -`--buffer-size` `` -: The number of documents to send to ES as part of the bulk. Defaults: 100 (optional) - -`--max-retries` `` -: The number of times failed bulk items should be retried. Defaults: 3 (optional) - -`--debug-mode` `` -: Buffer ES request/responses for better error messages and pass ?pretty to all requests (optional) - -`--proxy-address` `` -: Route requests through a proxy server (optional) - -`--proxy-password` `` -: Proxy server password (optional) - -`--proxy-username` `` -: Proxy server username (optional) - -`--disable-ssl-verification` `` -: Disable SSL certificate validation (EXPERT OPTION) (optional) - -`--certificate-fingerprint` `` -: Pass a self-signed certificate fingerprint to validate the SSL connection (optional) - -`--certificate-path` `` -: Pass a self-signed certificate to validate the SSL connection (optional) - -`--certificate-not-root` `` -: If the certificate is not root but only part of the validation chain pass this (optional) \ No newline at end of file diff --git a/docs/cli/assembler/assembler-navigation-validate-link-reference.md b/docs/cli/assembler/assembler-navigation-validate-link-reference.md deleted file mode 100644 index 678724217e..0000000000 --- a/docs/cli/assembler/assembler-navigation-validate-link-reference.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -navigation_title: "navigation validate-link-reference" ---- - -# assembler navigation validate-link-reference - -Validate all published links in links.json do not collide with navigation path_prefixes and all urls are unique. - -Read more about [navigation](../../configure/site/navigation.md). - -## Usage - -``` -docs-builder assembler navigation validate-link-reference [arguments...] [-h|--help] [--version] -``` - -## Arguments - -`[0] ` -: Path to `links.json` defaults to '.artifacts/docs/html/links.json' \ No newline at end of file diff --git a/docs/cli/assembler/assembler-navigation-validate.md b/docs/cli/assembler/assembler-navigation-validate.md deleted file mode 100644 index 4e897d41e2..0000000000 --- a/docs/cli/assembler/assembler-navigation-validate.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -navigation_title: "navigation validate" ---- - -# assembler navigation validate - -Validates [navigation.yml](../../configure/site/navigation.md) does not contain colliding path prefixes and all urls are unique - -## Usage - -``` -docs-builder assembler navigation validate [-h|--help] [--version] -``` \ No newline at end of file diff --git a/docs/cli/assembler/assembler-serve.md b/docs/cli/assembler/assembler-serve.md deleted file mode 100644 index 805d1b438b..0000000000 --- a/docs/cli/assembler/assembler-serve.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -navigation_title: "serve" ---- - -# assembler serve - -Serve the output of an assembler build on `http://localhost:4000/` - -## Usage - -``` -docs-builder assembler serve [options...] [-h|--help] [--version] -``` - -## Options - -`--port` `` -: Port to serve the documentation. (Default: 4000) - -`--path` `` -: (optional) \ No newline at end of file diff --git a/docs/cli/assembler/index.md b/docs/cli/assembler/index.md deleted file mode 100644 index 6c92bac9e1..0000000000 --- a/docs/cli/assembler/index.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -navigation_title: "assembler" ---- - -# Assembler commands - -Assembler builds bring together all isolated builds and turn them into the overall documentation that gets published. - -If you want to build the latest documentation, you can do so using the following commands - -:::{note} -When assembling using the `config init --local` option, it's advised to create an empty directory to run these commands in. -This creates a dedicated workspace for the assembler build and any local changes that you might want to test. -::: - -```bash -docs-builder assembler config init --local -docs-builder assemble --serve -``` - -The full assembled documentation should now be running at http://localhost:4000. - -The [assemble](assemble.md) command is syntactic sugar over the following commands: - -```bash -docs-builder assembler config init --local -docs-builder assembler clone -docs-builder assembler build -docs-builder assembler serve -``` - -Which may be more appropriate to call in isolation depending on the workflow you are going for. - -All `assembler` commans take an `--environment ` argument that defaults to 'dev' but can be set e.g to 'prod' to -build the production documentation. See [assembler.yml](../../configure/site/index.md) configuration for which environments are -available - -## Build commands - -- [assemble](assemble.md) -- [assembler build](assembler-build.md) -- [assembler clone](assembler-clone.md) -- [assembler config init](assembler-config-init.md) -- [assembler index](assembler-index.md) -- [assembler serve](assembler-serve.md) - -## Specialized build commands - -- [assembler bloom-filter create](assembler-bloom-filter-create.md) -- [assembler bloom-filter lookup](assembler-bloom-filter-lookup.md) - -## Validation commands - -- [assembler content-source match](assembler-content-source-match.md) -- [assembler content-source validate](assembler-content-source-validate.md) -- [assembler navigation validate](assembler-navigation-validate.md) -- [assembler navigation validate-link-reference](assembler-navigation-validate-link-reference.md) - -## Deploy commands - -- [assembler deploy apply](assembler-deploy-apply.md) -- [assembler deploy plan](assembler-deploy-plan.md) -- [assembler deploy update-redirects](assembler-deploy-update-redirects.md) - diff --git a/docs/cli/changelog/add.md b/docs/cli/changelog/add.md deleted file mode 100644 index f38a9eaf38..0000000000 --- a/docs/cli/changelog/add.md +++ /dev/null @@ -1,241 +0,0 @@ -# changelog add - -Create a changelog file that describes a single item in the release documentation. -For details and examples, go to [](/contribute/create-changelogs.md). - -## Usage - -```sh -docs-builder changelog add [options...] [-h|--help] -``` - -## Options - -`--action ` -: Optional: What users must do to mitigate. -: If the content contains any special characters such as backquotes(`), you must precede it with a backslash escape character (`\`). - -`--areas ` -: Optional: Areas affected (comma-separated or specify multiple times). - -`--concise` -: Optional: Omit schema reference comments from the generated YAML files. Useful in CI workflows to produce compact output. - -`--config ` -: Optional: Path to the changelog.yml configuration file. Defaults to `docs/changelog.yml`. - -`--description ` -: Optional: Additional information about the change (max 600 characters). -: If the content contains any special characters such as backquotes, you must precede it with a backslash escape character (`\`). - -`--no-extract-release-notes` -: Optional: Turn off extraction of release notes from PR or issue descriptions. -: By default, the behavior is determined by the [extract.release_notes](/contribute/configure-changelogs-ref.md#extract) changelog configuration setting. Release notes are extracted when using `--prs` or `--report` (and from issues when using `--issues`). - -`--feature-id ` -: Optional: Feature flag ID - -`--highlight ` -: Optional: Include in release highlights. - -`--impact ` -: Optional: How the user's environment is affected. -: If the content contains any special characters such as backquotes, you must precede it with a backslash escape character (`\`). - -`--issues ` -: Optional: Issue URL(s) or number(s) (comma-separated), or a path to a newline-delimited file containing issue URLs or numbers. Can be specified multiple times. -: Each occurrence can be either comma-separated issues (for example `--issues "https://github.com/owner/repo/issues/123,456"`) or a file path (for example `--issues /path/to/file.txt`). -: When specifying issues directly, provide comma-separated values. -: When specifying a file path, provide a single value that points to a newline-delimited file. -: If `--owner` and `--repo` are provided, issue numbers can be used instead of URLs. -: If specified, `--title` can be derived from the issue. -: Creates one changelog file per issue. -: Mutually exclusive with `--report`. - -`--no-extract-issues` -: Optional: Turn off extraction of linked references. -: When using `--prs` or `--report`: turns off extraction of linked issues from the PR body (for example, "Fixes #123"). -: When using `--issues`: turns off extraction of linked PRs from the issue body (for example, "Fixed by #123"). -: By default, the behavior is determined by the `extract.issues` changelog configuration setting. - -`--output ` -: Optional: Output directory for the changelog fragment. Falls back to `bundle.directory` in `changelog.yml` when not specified. If that value is also absent, defaults to current directory. - -`--owner ` -: Optional: GitHub repository owner (used when `--prs` or `--issues` contains just numbers, or when using `--release-version`). Not required when `--prs` or `--report` supplies only fully-qualified pull request URLs. -: Falls back to `bundle.owner` in `changelog.yml` when not specified. If that value is also absent, defaults to `elastic`. - -`--products >` -: Products affected in format "product target lifecycle, ..." (for example, `"elasticsearch 9.2.0 ga, cloud-serverless 2025-08-05"`). -: The valid product identifiers are listed in [products.yml](https://github.com/elastic/docs-builder/blob/main/config/products.yml). -: The valid lifecycles are listed in [ChangelogConfiguration.cs](https://github.com/elastic/docs-builder/blob/main/src/services/Elastic.Documentation.Services/Changelog/ChangelogConfiguration.cs). -: For more information about the valid product and lifecycle values, go to [Product format](#product-format). - -`--prs ` -: Optional: Pull request URLs or numbers (comma-separated), or a path to a newline-delimited file containing PR URLs or numbers. Can be specified multiple times. -: Each occurrence can be either comma-separated PRs (for example `--prs "https://github.com/owner/repo/pull/123,6789"`) or a file path (for example `--prs /path/to/file.txt`). -: When specifying PRs directly, provide comma-separated values. -: When specifying a file path, provide a single value that points to a newline-delimited file. -: If `--owner` and `--repo` are provided, PR numbers can be used instead of URLs. -: If specified, `--title` can be derived from the PR. -: If mappings are configured, `--areas`, `--type`, and `--products` can also be derived from the PR labels. -: Creates one changelog file per PR. -: If there are `rules.create` definitions in the changelog configuration file and a PR has a blocking label for the resolved products, that PR is skipped and no changelog file is created for it. -: Mutually exclusive with `--report`. - -`--report ` -: Optional: URL or path to a promotion report HTML document (for example a Buildkite promotion report). The command extracts GitHub pull request URLs from the HTML and creates one changelog file per PR, using the same parsing rules as [`changelog bundle --report`](/cli/changelog/bundle.md). -: Mutually exclusive with `--prs`, `--issues`, and `--release-version`. -: For a plain newline-delimited list of fully-qualified PR URLs, use `--prs` with a file path instead of `--report`. -: When the value is an `https://` URL, only hosts allowed by the parser (such as `github.com` and `buildkite.com`) are supported, and the CLI needs network access to fetch the report. - -`--release-version ` -: Optional: GitHub release tag to use as a source of pull requests (for example, `"v9.2.0"` or `"latest"`). -: When specified, the command fetches the release from GitHub, parses PR references from the release notes, and creates one changelog file per PR — without creating a bundle. Only automated GitHub release notes (the default format or [Release Drafter](https://github.com/release-drafter/release-drafter) format) are supported at this time. -: Use `docs-builder changelog gh-release` instead if you also want a bundle. -: Requires `--repo` (or `bundle.repo` in `changelog.yml`). -: Set to `latest` to use the most recent release. -: Mutually exclusive with `--report`, `--prs`, and `--issues`. - -`--repo ` -: Optional: GitHub repository name (used when `--prs`, `--issues`, `--report`, or `--release-version` is specified). Falls back to `bundle.repo` in `changelog.yml` when not specified. - -`--strip-title-prefix` -: Optional: When used with `--prs`, `--issues`, or `--report`, remove square brackets and text within them from the beginning of PR or issue titles, remove a colon if it follows the closing bracket, and remove a single ASCII hyphen when it's immediately after that prefix and followed by whitespace. -: For example, if a PR title is `"[Discover][ESQL]: Fix filtering by multiline string fields"` it becomes `"Fix filtering by multiline string fields"`. -: Likewise `"[Cases] - Enable numerical id service"` becomes `"Enable numerical id service"`. -: When a derived title still begins with `-`, `*`, `+`, an en dash, or an em dash, the emitted YAML uses a quoted `title` value so it is valid and unambiguous. -: This option applies only when the title is derived from GitHub (when `--title` is not explicitly provided). -: By default, the behavior is determined by the `extract.strip_title_prefix` changelog configuration setting (which defaults to `false`). - -`--subtype ` -: Optional: Subtype for breaking changes (for example, `api`, `behavioral`, or `configuration`). -: The valid subtypes are listed in [ChangelogConfiguration.cs](https://github.com/elastic/docs-builder/blob/main/src/services/Elastic.Documentation.Services/Changelog/ChangelogConfiguration.cs). - -`--title ` -: A short, user-facing title (max 80 characters) -: Required if none of `--prs`, `--issues`, or `--report` is specified. -: If both `--prs` and `--title` are specified, the latter value is used instead of what exists in the PR. -: If the content contains any special characters such as backquotes, you must precede it with a backslash escape character (`\`). - -`--type ` -: Required if none of `--prs`, `--issues`, or `--report` is specified. Type of change (for example, `feature`, `enhancement`, `bug-fix`, or `breaking-change`). -: If mappings are configured, type can be derived from the PR or issue. -: The valid types are listed in [ChangelogConfiguration.cs](https://github.com/elastic/docs-builder/blob/main/src/services/Elastic.Documentation.Services/Changelog/ChangelogConfiguration.cs). - -`--use-pr-number` -: Optional: Use PR numbers for filenames instead of the configured `filename` strategy. -: Requires `--prs`, `--issues`, or `--report`. -: Mutually exclusive with `--use-issue-number`. -: Refer to [](#filenames). - -`--use-issue-number` -: Optional: Use issue numbers for filenames instead of the configured `filename` strategy. -: Requires `--prs` or `--issues`. -: Mutually exclusive with `--use-pr-number`. -: Refer to [](#filenames). - -## Filenames - -By default, output files are named according to the `filename` strategy in `changelog.yml`: - -| Strategy | Example filename | Description | -|---|---|---| -| `timestamp` (default) | `1735689600-fixes-enrich-and-lookup-join-resolution.yaml` | Uses a Unix timestamp with a sanitized title slug. | -| `pr` | `137431.yaml` | Uses the PR number. | -| `issue` | `2571.yaml` | Uses the issue number. | - -Refer to [changelog.example.yml](https://github.com/elastic/docs-builder/blob/main/config/changelog.example.yml) or [](/contribute/configure-changelogs-ref.md). - -You can override those settings with the `--use-pr-number` or `--use-issue-number` CLI flags: - -```sh -docs-builder changelog add \ - --prs 1234 \ - --products "elasticsearch 9.2.3" \ - --use-pr-number - -docs-builder changelog add \ - --issues 4567 \ - --products "elasticsearch 9.3.0" \ - --use-issue-number -``` - -:::{important} -`--use-pr-number` and `--use-issue-number` are mutually exclusive; specify only one. `--use-pr-number` requires `--prs`, `--issues`, or `--report`. `--use-issue-number` requires `--prs` or `--issues`. The numbers are extracted from the URLs or identifiers you provide or from linked references in the issue or PR body when extraction is enabled. - -**Precedence**: CLI flags (`--use-pr-number` / `--use-issue-number`) > `filename` in `changelog.yml` > default (`timestamp`). -::: - -## Product format and resolution [product-format] - -The `--products` command option accepts values with the format `"product target lifecycle, ..."` where: - -- `product` is a product ID that exists in [products.yml](https://github.com/elastic/docs-builder/blob/main/config/products.yml) (required) -- `target` is the target version or date (optional) -- `lifecycle` exists in [Lifecycle.cs](https://github.com/elastic/docs-builder/blob/main/src/Elastic.Documentation/Lifecycle.cs) (optional) - -You can further limit the possible values with the [products](/contribute/configure-changelogs-ref.md#products) and [lifecycles](/contribute/configure-changelogs-ref.md#lifecycles) options in the changelog configuration file. - -For example: - -- `"kibana 9.2.0 ga"` -- `"cloud-serverless 2025-08-05"` -- `"cloud-enterprise 4.0.3, cloud-hosted 2025-10-31"` - -The `changelog add` command resolves product values in the following order: - -1. The `--products` CLI option always takes priority. -1. If `pivot.products` is defined in the changelog configuration file and the PR or issue has labels that match, those products are used. Multiple matching entries are all applied. -1. If `products.default` is defined in the changelog configuration file, those default products are used. -1. If `--repo` is specified (or `bundle.repo` is set in the changelog configuration file), the repository name is matched against known product IDs in `products.yml` and the derived value is used. - -The same order applies when using `--report` (after PR URLs are resolved from the promotion report), and when using batch `--prs` with multiple pull requests. - -If none of these steps yield at least one product, the command returns an error. - -## Configuration checks - -By default, the command checks the following path for a configuration file: `docs/changelog.yml`. -You can specify a different path with the `--config` command option. - -If a configuration file exists, the command validates its values before generating changelog files: - -- If the configuration file contains `lifecycles`, `products`, `subtype`, or `type` values that don't match the values in `ChangelogEntryType.cs`, `ChangelogEntrySubtype.cs`, or `Lifecycle.cs`, validation fails. -- If the configuration file contains `areas` values and they don't match what you specify in the `--areas` command option, validation fails. -- If the configuration file contains `lifecycles` or `products` values that are a subset of the available values and you try to create a changelog with values outside that subset, validation fails. - -In each of these cases where validation fails, a changelog file is not created. - -If the configuration file contains `rules.create` definitions and a PR or issue has a blocking label, that PR is skipped and no changelog file is created for it. -For more information, refer to [](/contribute/create-changelogs.md#rules). - -## CI auto-detection [ci-auto-detection] - -When running inside GitHub Actions, `changelog add` automatically reads the following environment variables to fill in arguments that were not provided on the command line: - -| Environment variable | Fills | Set from | -| --- | --- | --- | -| `CHANGELOG_PR_NUMBER` | `--prs` | `github.event.pull_request.number` | -| `CHANGELOG_TITLE` | `--title` | `steps.evaluate.outputs.title` | -| `CHANGELOG_DESCRIPTION` | `--description` | `steps.evaluate.outputs.description` | -| `CHANGELOG_TYPE` | `--type` | `steps.evaluate.outputs.type` | -| `CHANGELOG_PRODUCTS` | `--products` | `steps.evaluate.outputs.products` | -| `CHANGELOG_OWNER` | `--owner` | `github.repository_owner` | -| `CHANGELOG_REPO` | `--repo` | `github.event.repository.name` | - -**Precedence**: explicit CLI arguments always take priority over environment variables. Environment variables are only used when the corresponding CLI argument is not provided. - -`CHANGELOG_DESCRIPTION` has additional precedence rules related to release note extraction: - -- If `--description` is provided on the command line, it always wins. -- If `--no-extract-release-notes` is passed (or `extract.release_notes: false` is set in the changelog configuration), `CHANGELOG_DESCRIPTION` is ignored. This prevents a description that was extracted by `evaluate-pr` from being applied when extraction has been disabled. -- Otherwise, `CHANGELOG_DESCRIPTION` fills `--description` when it is not set on the command line. - -The filename strategy is controlled by the `filename` option in `changelog.yml` (defaulting to `timestamp`). Refer to [changelog.example.yml](https://github.com/elastic/docs-builder/blob/main/config/changelog.example.yml) for details. - -This allows the CI action to invoke `changelog add` with a minimal command line: - -```sh -docs-builder changelog add --config docs/changelog.yml --output /tmp/staging --concise --strip-title-prefix -``` diff --git a/docs/cli/changelog/bundle-amend.md b/docs/cli/changelog/bundle-amend.md deleted file mode 100644 index 03ccb615b7..0000000000 --- a/docs/cli/changelog/bundle-amend.md +++ /dev/null @@ -1,125 +0,0 @@ -# changelog bundle-amend - -Amend a bundle with additional changelog entries. -Amend bundles follow a specific naming convention: `{parent-bundle-name}.amend-{N}.yaml` where `{N}` is a sequence number. - -To create a bundle, use [](/cli/changelog/bundle.md). -For details and examples, go to [](/contribute/bundle-changelogs.md). - -## Usage - -```sh -docs-builder changelog bundle-amend [arguments...] [options...] [-h|--help] -``` - -## Arguments - -`` -: Required: Path to the original bundle file to amend. - -## Options - -`--add ` -: Required: Path(s) to changelog YAML file(s) to add as comma-separated values. Supports tilde (~) expansion and relative paths. - -`--no-resolve`: -: Optional: Explicitly turn off resolve (overrides inference from original bundle). - -`--resolve` -: Optional: Copy the contents of each changelog file into the entries array. Defaults to false. - -## Resolve behaviour - -By default, the `bundle-amend` command **infers** whether to resolve entries from the original bundle. -If the original bundle contains resolved entries (with inline `title`, `type`, and so on), the amend file will also be resolved. -If the original bundle contains only file references, the amend file will also contain only file references. - -This inference ensures that amend files are portable—they contain everything needed to be understood alongside the original bundle, even when copied to another repository. - -You can override this behaviour: - -- `--resolve`: Force entries to be resolved (inline content), regardless of the original bundle. -- `--no-resolve`: Force entries to contain only file references, regardless of the original bundle. - -## Output - -Amend bundles contain only the additional entries, they are not a full repetition of the original bundle. -For example: - -```yaml -# 9.3.0.amend-1.yaml -entries: -- file: - name: late-addition.yaml - checksum: abc123def456 -``` - -When bundles are loaded (either via the `changelog render` command or the `{changelog}` directive), amend files are **automatically merged** with their parent bundles. -The entries from all matching amend files are combined with the parent bundle's entries, and the result is rendered as a single release. - -:::{note} -Amend bundles do not need to include `products` or `hide-features` fields—they inherit these from their parent bundle. If an amend bundle is found without a matching parent bundle, it remains standalone. - -`rules.bundle` filtering does not apply to `changelog bundle-amend`. The command is designed as a direct-injection escape hatch: the files you specify with `--add` are always included regardless of any product, type, or area filter configuration. Filtering only applies during the initial `changelog bundle` or `changelog gh-release` run. -::: -## Examples - -### Add a single changelog to a bundle - -```sh -docs-builder changelog bundle-amend \ - ./docs/changelog/bundles/9.3.0.yaml \ - --add ./docs/changelog/138723.yaml -``` - -The new bundle automatically matches the resolve style of the original bundle. - -### Add multiple changelogs to a bundle - -Specify multiple files as comma-separated values: - -```sh -docs-builder changelog bundle-amend \ - ./docs/changelog/bundles/9.3.0.yaml \ - --add "./docs/changelog/138723.yaml,./docs/changelog/1770424335.yaml" -``` - -### Using different path styles - -The command supports tilde expansion, relative paths, and absolute paths: - -```sh -# With tilde expansion -docs-builder changelog bundle-amend \ - ~/docs/changelog/bundles/9.3.0.yaml \ - --add "~/docs/changelog/138723.yaml,~/docs/changelog/1770424335.yaml" - -# With relative paths -docs-builder changelog bundle-amend \ - ./bundles/9.3.0.yaml \ - --add "./138723.yaml,./1770424335.yaml" - -# With absolute paths -docs-builder changelog bundle-amend \ - /path/to/bundles/9.3.0.yaml \ - --add "/path/to/138723.yaml,/path/to/1770424335.yaml" -``` - -### Resolving changelog contents - -Use `--resolve` to copy the full contents of each changelog file into the new bundle even if the original bundle is unresolved: - -```sh -docs-builder changelog bundle-amend \ - ./docs/changelog/bundles/9.3.0.yaml \ - --add "./docs/changelog/138723.yaml,./docs/changelog/1770424335.yaml" \ - --resolve -``` - -Likewise, you can force file-only references even if the original bundle is resolved: - -```sh -docs-builder changelog bundle-amend 9.3.0.yaml \ - --add ./docs/changelog/late-addition.yaml \ - --no-resolve -``` diff --git a/docs/cli/changelog/bundle.md b/docs/cli/changelog/bundle.md deleted file mode 100644 index 3a1e0628fe..0000000000 --- a/docs/cli/changelog/bundle.md +++ /dev/null @@ -1,611 +0,0 @@ -# changelog bundle - -Bundle changelog files. - -To create the changelogs, use [](/cli/changelog/add.md). -For details and examples, go to [](/contribute/changelog.md). - -## Usage - -```sh -docs-builder changelog bundle [arguments...] [options...] [-h|--help] -``` - -`changelog bundle` supports two mutually exclusive invocation modes: - -- **Profile-based**: All paths and filters come from the changelog configuration file. No other options are allowed. For example, `bundle `. -- **Option-based**: You supply all filter and output options directly. For example, `bundle --all` (or `--input-products`, `--prs`, `--issues`). - -You cannot mix the two modes. Passing any option-based flag together with a profile returns an error. - -Profile-based commands discover the changelog configuration automatically (no `--config` flag): they look for `changelog.yml` in the current directory, then `docs/changelog.yml`. -If neither file is found, the command returns an error with instructions to run `docs-builder changelog init` or to re-run from the folder where the file exists. - -Option-based commands ignore the `bundle.profiles` section of the changelog configuration file. - -## Arguments - -These arguments apply to profile-based bundling: - -`[0] ` -: Profile name from `bundle.profiles` in the changelog configuration file. -: For example, "elasticsearch-release". -: When specified, the second argument is the version, promotion report URL, or URL list file. - -`[1] ` -: Version number, promotion report URL/path, or URL list file. -: For example, `9.2.0`, `https://buildkite.../promotion-report.html`, or `/path/to/prs.txt`. - -`[2] ` -: Optional: Promotion report URL/path or URL list file when the second argument is a version string. -: When provided, `[1]` must be a version string and `[2]` is the PR/issue filter source. -: For example, `docs-builder changelog bundle serverless-release 2026-02 ./promotion-report.html`. - -:::{note} -The third argument (`[2]`) is required when your profile uses `{version}` placeholders in `output` or `output_products` patterns and you also want to filter by a promotion report or URL list. Without it, the version defaults to `"unknown"`. -::: - -### Profile argument types - -The second argument (`[1]`) and optional third argument (`[2]`) accept the following: - -- **Version string** — Used for `{version}` substitution in profile patterns. For example, `9.2.0` or `2026-02`. -- **Promotion report URL** — A URL to an HTML promotion report. PR URLs are extracted from it. -- **Promotion report file** — A path to a downloaded `.html` file containing a promotion report. -- **URL list file** — A path to a plain-text file containing one fully-qualified GitHub PR or issue URL per line. For example, `https://github.com/elastic/elasticsearch/pull/123`. The file must contain only PR URLs or only issue URLs, not a mix. Bare numbers and short forms such as `owner/repo#123` are not allowed. - -## General options - -These options work with both profile-based and option-based modes. - -`--plan` -: Output a structured set of CI step outputs (`needs_network`, `needs_github_token`, `output_path`) describing Docker flags, network requirements, and the resolved output path, then exit without generating the bundle. Intended for CI actions that need to determine container configuration before running the actual bundle step. When running outside GitHub Actions, the output is written to stdout. - -## Options - -The following options are only valid in option-based mode (no profile argument). -Using any of them with a profile returns an error. -You must choose one method for determining what's in the bundle (`--all`, `--input-products`, `--prs`, `--issues`, `--release-version`, or `--report`). - -`--all` -: Include all changelogs from the directory. - -`--config ` -: Optional: Path to the changelog.yml configuration file. -: Defaults to `docs/changelog.yml`. - -`--directory ` -: Optional: The directory that contains the changelog YAML files. -: When not specified, falls back to `bundle.directory` from the changelog configuration, then the current working directory. See [Output files](#output-files) for the full resolution order. - -`--description ` -: Optional: Bundle description text with placeholder support. -: Supports `{version}`, `{lifecycle}`, `{owner}`, and `{repo}` placeholders. Overrides `bundle.description` from config. -: When using `{version}` or `{lifecycle}` placeholders, predictable substitution values are required: -: - **Option-based mode**: Requires `--output-products` to be explicitly specified -: - **Profile-based mode**: Requires either a version argument OR `output_products` in the profile configuration - -`--hide-features ` -: Optional: A list of feature IDs (comma-separated) or a path to a newline-delimited file containing feature IDs. -: Can be specified multiple times. -: Adds a `hide-features` list to the bundle. -: When the bundle is rendered (by the `changelog render` command or `{changelog}` directive), changelogs with matching `feature-id` values will be commented out of the documentation. - -:::{note} -The `--hide-features` option on the `render` command and the `hide-features` field in bundles are **combined**. If you specify `--hide-features` on both the `bundle` and `render` commands, all specified features are hidden. The `{changelog}` directive automatically reads `hide-features` from all loaded bundles and applies them. -::: - -`--input-products ?>` -: Filter by products in the format "product target lifecycle, ...". -: For more information about the valid product and lifecycle values, go to [Product format](#product-format). -: When specified, all three parts (product, target, lifecycle) are required but can be wildcards (`*`). Multiple comma-separated values are combined with OR: a changelog is included if it matches any of the specified product/target/lifecycle combinations. For example: - -- `"cloud-serverless 2025-12-02 ga, cloud-serverless 2025-12-06 beta"` — include changelogs for either cloud-serverless 2025-12-02 ga or cloud-serverless 2025-12-06 beta -- `"cloud-serverless 2025-12-02 *"` - match cloud-serverless 2025-12-02 with any lifecycle -- `"elasticsearch * *"` - match all elasticsearch changelogs -- `"* 9.3.* *"` - match any product with target starting with "9.3." -- `"* * *"` - match all changelogs (equivalent to `--all`) - -:::{note} -The `--input-products` option determines which changelog files are gathered for consideration. -Bundle rules are not turned off when you use `--input-products`-- they run **after** matching, unless your configuration is in no-filtering mode per [Bundle rules](/contribute/configure-changelogs-ref.md#rules-bundle). -::: - -`--issues ` -: Include changelogs for the specified issue URLs (comma-separated), or a path to a newline-delimited file. Can be specified multiple times. -: Each occurrence can be either comma-separated issues ( `--issues "https://github.com/owner/repo/issues/123,456"`) or a file path (for example `--issues /path/to/file.txt`). -: When using a file, every line must be a fully-qualified GitHub issue URL such as `https://github.com/owner/repo/issues/123`. Bare numbers and short forms are not allowed in files. - -`--no-resolve` -: Optional: Explicitly turn off the `resolve` option if it's specified in the changelog configuration file. - -`--output ` -: Optional: The output path for the bundle. -: 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`. -: When not specified, falls back to `bundle.output_directory` from the changelog configuration, then the input directory (which is itself resolved from `--directory`, `bundle.directory`, or the current working directory). See [Output files](#output-files) for the full resolution order. - -`--output-products ?>` -: Optional: Explicitly set the products array in the output file in format "product target lifecycle, ...". -: This value replaces information that would otherwise be derived from changelogs. -: For more information about the valid product and lifecycle values, go to [Product format](#product-format). -: When `rules.bundle.products` per-product overrides are configured, `--output-products` also supplies the product IDs used to determine the **rule context product** (if there are multiple, the first ID alphabetically is used). Refer to [Product-specific bundle rules](/contribute/configure-changelogs-ref.md#rules-bundle-products). - -:::{tip} -Though technically optional, it is strongly recommended to set `--output-products` ( or `output_products` for profiles) so that you have a single clean product entry that reflects the context of the release. -::: - -`--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`. - -`--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`. - -`--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`. - -`--prs ` -: Include changelogs for the specified pull request URLs (comma-separated) or a path to a newline-delimited file. Can be specified multiple times. -: Each occurrence can be either comma-separated PRs (for example `--prs "https://github.com/owner/repo/pull/123,6789"`) or a file path (for example `--prs /path/to/file.txt`). -: When using a file, every line must be a fully-qualified GitHub PR URL such as `https://github.com/owner/repo/pull/123`. Bare numbers and short forms are not allowed in files. - -`--release-version ` -: Bundle changelogs for the pull requests in GitHub release notes. For example, the tag can be `"v9.2.0"` or `"latest"`. -: When specified, the command fetches the release from GitHub, parses PR references from the release notes, and uses them as the bundle filter. Only automated GitHub release notes (the default format or [Release Drafter](https://github.com/release-drafter/release-drafter) format) are supported at this time. -: Requires repo (`--repo` or `bundle.repo` in `changelog.yml`) and owner (`--owner` flag > `bundle.owner` in `changelog.yml` > `elastic`) details. -: When `--output-products` is not specified, the products array in the bundle is derived from the matched changelog files' own `products` fields, consistent with all other filter options. - -`--repo ` -: Optional: The GitHub repository name. -: Falls back to `bundle.repo` in `changelog.yml` when not specified; if that is also absent, the product ID is used. - -`--report ` -: Include changelogs based on the pull requests in a promotion report. Accepts a URL or a local file path. -: The report can be an HTML page from Buildkite or any file containing GitHub PR URLs. - -`--resolve` -: Optional: Copy the contents of each changelog file into the entries array. -: By default, the bundle contains only the file names and checksums. - -## File paths and filenames [output-files] - -**Input directory** (where changelog YAML files are read from) follows the same fallback for both modes, minus the explicit CLI override that is forbidden in profile mode: - -| Priority | Profile-based | Option-based | -|----------|---------------|--------------| -| 1 | `bundle.directory` in `changelog.yml` | `--directory` CLI option | -| 2 | Current working directory | `bundle.directory` in `changelog.yml` | -| 3 | — | Current working directory | - -Both modes use the same ordered fallback to determine where to write the bundle. The first value that is set wins: - -**Output directory** (where the bundle file is placed): - -| Priority | Profile-based | Option-based | -|----------|---------------|--------------| -| 1 | — | `--output` (explicit file or directory path) | -| 2 | `bundle.output_directory` in `changelog.yml` | `bundle.output_directory` in `changelog.yml` | -| 3 | `bundle.directory` in `changelog.yml` | `--directory` CLI option | -| 4 | Current working directory | `bundle.directory` in `changelog.yml` | -| 5 | — | Current working directory | - -**Bundle filename** is determined by the `bundle.profiles..output` setting (profile-based) or defaults to `changelog-bundle.yaml` (both modes). -The profile `output` setting can include additional path segments. For example: `"stack/kibana-{version}.yaml"`. - -In option-based mode, when you specify `--output`, it supports two formats: - -1. **Directory path**: If you specify a directory path (without a filename), the command creates `changelog-bundle.yaml` in that directory: - - ```sh - docs-builder changelog bundle --all --output /path/to/output/dir - # Creates /path/to/output/dir/changelog-bundle.yaml - ``` - -2. **File path**: If you specify a file path ending in `.yml` or `.yaml`, the command uses that exact path: - - ```sh - docs-builder changelog bundle --all --output /path/to/custom-bundle.yaml - # Creates /path/to/custom-bundle.yaml - ``` - -If you specify a file path with a different extension (not `.yml` or `.yaml`), the command returns an error. - -:::{note} -"Current working directory" means the directory you are in when you run the command (`pwd`). -Setting `bundle.directory` and `bundle.output_directory` in `changelog.yml` is recommended so you don't need to rely on running the command from a specific directory. -::: - -## Product format - -The `changelog bundle` command has `--input-products` and `--output-products` options that accept values with the format `"product target lifecycle, ..."` where: - -- `product` is the product ID from [products.yml](https://github.com/elastic/docs-builder/blob/main/config/products.yml) (required) -- `target` is the target version or date (optional) -- `lifecycle` exists in [Lifecycle.cs](https://github.com/elastic/docs-builder/blob/main/src/Elastic.Documentation/Lifecycle.cs) (optional) - -You can further limit the possible values with the [products](/contribute/configure-changelogs-ref.md#products) and [lifecycles](/contribute/configure-changelogs-ref.md#lifecycles) options in the changelog configuration file. - -For example: - -- `"kibana 9.2.0 ga"` -- `"cloud-serverless 2025-08-05"` -- `"cloud-enterprise 4.0.3, cloud-hosted 2025-10-31"` - -If you use `"* * *"` in the `--input-products` command option or `bundle.profiles..products` configuration setting, it's equivalent to the `--all` command option. - -## Repository name in bundles [changelog-bundle-repo] - -The repository name is stored in each bundle product entry to ensure that PR and issue links are generated correctly when the bundle is rendered. -It can be set in three ways, in order of precedence: - -1. **`--repo` option** (option-based mode only) -2. **`repo` field in the profile** (profile-based mode only; overrides the bundle-level default) -3. **`bundle.repo` in `changelog.yml`** (applies to both modes as a default when neither of the above is set) - -Setting `bundle.repo` and `bundle.owner` in your configuration means you rarely need to pass `--repo` and `--owner` on the command line: - -```yaml -bundle: - repo: elasticsearch - owner: elastic -``` - -You can still override them per profile if a project has multiple products with different repos. - -The bundle output includes a `repo` field in each product: - -```yaml -products: -- product: cloud-serverless - target: 2025-12-02 - repo: elasticsearch - owner: elastic -entries: -- file: - name: 1765495972-new-feature.yaml - checksum: 6c3243f56279b1797b5dfff6c02ebf90b9658464 -``` - -When rendering, pull request and issue links use `https://github.com/elastic/elasticsearch/...` instead of the product ID. - -:::{note} -If no `repo` is set at any level, the product ID is used as a fallback for link generation. -This may result in broken links if the product ID doesn't match the GitHub repository name (for example, `cloud-serverless` product ID in the `elasticsearch` repo). -::: - -## PR and issue link allowlist [link-allowlist] - -A changelog in a public repository might contain links to pull requests or issues in repositories that should not appear in published documentation. - -Set `bundle.link_allow_repos` in `changelog.yml` to an explicit list of `owner/repo` strings (for example, `elastic/elasticsearch`). When this key is present (including as an empty list), PR and issue references are filtered at bundle time: only links whose resolved repository is in the list are kept; others are rewritten to quoted `# PRIVATE:` sentinel strings in the bundle YAML. - -:::{important} -`bundle.link_allow_repos` requires a **resolved** bundle. Set `bundle.resolve: true` or pass `--resolve`. Unresolved bundles that only store `file:` pointers are not rewritten. -::: - -When [`assembler.yml`](/configure/site/content.md) is available, docs-builder emits **warnings** (non-fatal) if an allowlisted repo is missing from `references` or is marked `private: true`, so you can verify the registry before publishing. - -The `changelog bundle`, `changelog gh-release`, and `changelog bundle-amend` commands apply the same rules. The changelog directive and `changelog render` command omit `# PRIVATE:` sentinels from rendered documentation. - -:::{warning} -Sentinel values are omitted from rendered documentation but remain in bundle files; they are not cryptographic redaction. -::: - -`bundle.repo` must name a **single** GitHub repository (do not use `repo1+repo2` merged-repo syntax). - -## Option-based examples - -### Bundle by GitHub release [changelog-bundle-release-version] - -You can use `--release-version` to fetch pull request references directly from GitHub release notes and use them as the bundle filter. -This is equivalent to building a PR list file manually and passing it with `--prs`, but without any file management. - -:::{important} -Only automated GitHub release notes (the default format or [Release Drafter](https://github.com/release-drafter/release-drafter) format) are supported at this time. -::: - -```sh -docs-builder changelog bundle \ - --release-version v1.34.0 \ <1> - --repo apm-agent-dotnet \ <2> - --owner elastic <3> - --output-products "apm-agent-dotnet 1.34.0 ga" <4> -``` - -1. The tag value that is used in the `GET /repos/{owner}/{repo}/releases/tags/{tag}` releases API. -2. You must specify `--repo` or set `bundle.repo` in the changelog configuration file. -3. If you don't specify `--owner`, it uses `bundle.owner` in the changelog configuration or else defaults to `elastic`. -4. The bundle's product metadata is inferred automatically from the release tag and repository name; you can override that behavior with the `--output-products` option. - -:::{note} -`--release-version` requires a `GITHUB_TOKEN` or `GH_TOKEN` environment variable (or an active `gh` login) to fetch release details from the GitHub API. -::: - -By default all changelogs that match PRs in the GitHub release notes are included in the bundle. -To apply additional filtering by the changelog type, areas, or products, add [rules.bundle](/contribute/configure-changelogs-ref.md#rules-bundle) configuration settings. - -:::{tip} -If you are not creating changelogs when you create your pull requests, consider the `docs-builder changelog gh-release` command as a one-shot alternative to the `changelog add` and `changelog bundle` commands. -It parses the release notes, creates one changelog file per pull request found, and creates a `changelog-bundle.yaml` file — all in a single step. Refer to [](/cli/changelog/gh-release.md) -::: - -### Bundle by issues [changelog-bundle-issues] - -You can use the `--issues` option to create a bundle of changelogs that relate to those GitHub issues. -Issues can be identified by a full URL (such as `https://github.com/owner/repo/issues/123`), a short format (such as `owner/repo#123`), or just a number (in which case `--owner` and `--repo` are required — or set via `bundle.owner` and `bundle.repo` in the configuration). - -```sh -docs-builder changelog bundle --issues "12345,12346" \ - --repo elasticsearch \ - --owner elastic \ - --output-products "elasticsearch 9.2.2 ga" -``` - -Alternatively, you can specify a path to a newline-delimited file that contains the issue URLS (for example, `--issues /path/to/file.txt`). -In this case, you cannot use short URLs or numbers, each line must have a full URL. - -By default all changelogs that match issues in the list are included in the bundle. -To apply additional filtering by the changelog type, areas, or products, add [rules.bundle](/contribute/configure-changelogs-ref.md#rules-bundle) configuration settings. - - -### Bundle by pull requests [changelog-bundle-pr] - -You can use the `--prs` option to create a bundle of the changelogs that relate to those pull requests. - -Pull requests can be identified by a full URL (such as `https://github.com/owner/repo/pull/123`), a short format (such as `owner/repo#123`), or just a number. - -```sh -docs-builder changelog bundle --prs "108875,135873,136886" \ <1> - --repo elasticsearch \ <2> - --owner elastic \ <3> - --output-products "elasticsearch 9.2.2 ga" <4> -``` - -1. The comma-separated list of pull request numbers to seek. -2. The repository in the pull request URLs. Not required when using full PR URLs, or when `bundle.repo` is set in the changelog configuration. -3. The owner in the pull request URLs. Not required when using full PR URLs, or when `bundle.owner` is set in the changelog configuration. -4. The product metadata for the bundle. If it is not provided, it will be derived from all the changelog product values. - -Alternatively, you can specify a path to a newline-delimited file that contains the PR URLS (for example, `--prs /path/to/file.txt`). -In this case, you cannot use short URLs or numbers, each line must have a full URL. -For example: - -```txt -https://github.com/elastic/elasticsearch/pull/108875 -https://github.com/elastic/elasticsearch/pull/135873 -https://github.com/elastic/elasticsearch/pull/136886 -https://github.com/elastic/elasticsearch/pull/137126 -``` - -By default all changelogs that match PRs in the list are included in the bundle. -To apply additional filtering by the changelog type, areas, or products, add [rules.bundle](/contribute/configure-changelogs-ref.md#rules-bundle) configuration settings. - -If you have changelog files that reference those pull requests, the command creates a file like this: - -```yaml -products: -- product: elasticsearch - target: 9.2.2 - lifecycle: ga -entries: -- file: - name: 1765507819-fix-ml-calendar-event-update-scalability-issues.yaml - checksum: 069b59edb14594e0bc3b70365e81626bde730ab7 -- file: - name: 1765507798-convert-bytestransportresponse-when-proxying-respo.yaml - checksum: c6dbd4730bf34dbbc877c16c042e6578dd108b62 -- file: - name: 1765507839-use-ivf_pq-for-gpu-index-build-for-large-datasets.yaml - checksum: 451d60283fe5df426f023e824339f82c2900311e -``` - -### Bundle by product [changelog-bundle-product] - -You can use the `--input-products` option to create a bundle of changelogs that match the product details. -When using `--input-products`, you must provide all three parts: product, target, and lifecycle. -Each part can be a wildcard (`*`) to match any value. - -:::{tip} -If you use profile-based bundling, provide this information in the `bundle.profiles..products` field. -::: - -```sh -docs-builder changelog bundle \ - --input-products "cloud-serverless 2025-12-02 ga, cloud-serverless 2025-12-06 beta" <1> -``` - -1. Include all changelogs that have the `cloud-serverless` product identifier with target dates of either December 2 2025 (lifecycle `ga`) or December 6 2025 (lifecycle `beta`). For more information about product values, refer to [Product format](/cli/changelog/bundle.md#product-format). - -You can use wildcards in any of the three parts: - -```sh -# Bundle any changelogs that have exact matches for either of these clauses -docs-builder changelog bundle --input-products "cloud-serverless 2025-12-02 ga, elasticsearch 9.3.0 beta" - -# Bundle all elasticsearch changelogs regardless of target or lifecycle -docs-builder changelog bundle --input-products "elasticsearch * *" - -# Bundle all cloud-serverless 2025-12-02 changelogs with any lifecycle -docs-builder changelog bundle --input-products "cloud-serverless 2025-12-02 *" - -# Bundle any cloud-serverless changelogs with target starting with "2025-11-" and "ga" lifecycle -docs-builder changelog bundle --input-products "cloud-serverless 2025-11-* ga" - -# Bundle all changelogs (equivalent to --all) -docs-builder changelog bundle --input-products "* * *" -``` - -If you have changelog files that reference those product details, the command creates a file like this: - -```yaml -products: <1> -- product: cloud-serverless - target: 2025-12-02 -- product: cloud-serverless - target: 2025-12-06 -entries: -- file: - name: 1765495972-fixes-enrich-and-lookup-join-resolution-based-on-m.yaml - checksum: 6c3243f56279b1797b5dfff6c02ebf90b9658464 -- file: - name: 1765507778-break-on-fielddata-when-building-global-ordinals.yaml - checksum: 70d197d96752c05b6595edffe6fe3ba3d055c845 -``` - -1. By default these values match your `--input-products` (even if the changelogs have more products). -To specify different product metadata, use the `--output-products` option. - -:::{note} -When a changelog matches multiple `--input-products` filters, it appears only once in the bundle. This deduplication applies even when using `--all` or `--prs`. -::: - -### Bundle by report - -You can use `--report` to filter by a promotion report: - -```sh -# Extract PRs from a downloaded report and use them as the filter -docs-builder changelog bundle \ - --report ./promotion-report.html \ - --directory ./docs/changelog \ - --output ./docs/releases/bundle.yaml -``` - -By default all changelogs that match PRs in the promotion report are included in the bundle. -To apply additional filtering by the changelog type, areas, or products, add [rules.bundle](/contribute/configure-changelogs-ref.md#rules-bundle) configuration settings. - -### Bundle descriptions - -You can add a description to bundles using the `--description` option. For simple descriptions, use regular quotes: - -```sh -docs-builder changelog bundle \ - --all \ - --description "This release includes new features and bug fixes." -``` - -For multiline descriptions with multiple paragraphs, lists, and links, use ANSI-C quoting (`$'...'`) with `\n` for line breaks: - -```sh -docs-builder changelog bundle \ - --all \ - --description $'This release includes significant improvements:\n\n- Enhanced performance\n- Bug fixes and stability improvements\n\nFor security updates, go to [security announcements](https://example.com/docs).' -``` - -When using placeholders in option-based mode, you must explicitly specify `--output-products` for predictable substitution: - -```sh -docs-builder changelog bundle \ - --all \ - --output-products "elasticsearch 9.1.0 ga" \ - --description "Elasticsearch {version} includes performance improvements. Download: https://github.com/{owner}/{repo}/releases/tag/v{version}" -``` - -### Bundle release dates - -You can add a `release-date` field directly to a bundle YAML file. This field is optional and purely informative for end-users. It is especially useful for components released outside the usual stack lifecycle, such as APM agents and EDOT agents. - -```yaml -products: - - product: apm-agent-dotnet - target: 1.34.0 -release-date: "April 9, 2026" -description: | - This release includes tracing improvements and bug fixes. -entries: - - file: - name: tracing-improvement.yaml - checksum: abc123 -``` - -When the bundle is rendered (by the `changelog render` command or `{changelog}` directive), the release date appears immediately after the version heading as italicized text: `_Released: April 9, 2026_`. - -## Profile-based examples - -When the changelog configuration file defines [bundle.profiles](/contribute/configure-changelogs-ref.md#bundle-profiles), you can use those profiles with the `changelog bundle` command. - -Refer to [](/contribute/bundle-changelogs.md#create-profiles) for examples. - -### Lifecycle inference [lifecycle-inference] - -The way that lifecycle values are inferred varies between [GitHub release profiles](#lifecycles-github) and [standard profiles](#lifecycles-standard). - -#### GitHub release profiles [lifecycles-github] - -For `source: github_release` profiles, the `{lifecycle}` placeholder in `output` and `output_products` is derived from the full release tag name and `{version}` is the base version extracted from that same tag. -For example: - -| Release tag | `{version}` | `{lifecycle}` | -|-------------|-------------|---------------| -| `v1.2.3` | `1.2.3` | `ga` | -| `v1.2.3-beta.1` | `1.2.3` | `beta` | -| `v1.2.3-preview.1` | `1.2.3` | `preview` | - -If the lifecycle you want to advertise cannot be inferred from the tag format — for example, because your team uses clean tags like `v1.34.1` even for pre-releases — hardcode the lifecycle directly in `output_products` instead of using the `{lifecycle}` placeholder: - -```yaml -# Instead of relying on {lifecycle} inference, hardcode the lifecycle -gh-release: - source: github_release - repo: apm-agent-dotnet - output: "apm-agent-dotnet-{version}.yaml" - output_products: "apm-agent-dotnet {version} preview" -``` - -You can invoke the profile with commands like this: - -```sh -# Bundle changelogs using the PR list from a GitHub release (source: github_release) -docs-builder changelog bundle gh-release v1.2.3 - -# Use "latest" to fetch the most recent release -docs-builder changelog bundle gh-release latest -``` - -#### Standard profiles [lifecycles-standard] - -If your configuration file defines a standard profile (that is to say, not a GitHub release profile), the `{version}` is copied verbatim from your command argument and the `{lifecycle}` is derived from that value. -For example: - -| Version argument | `{version}` | `{lifecycle}` | -|------------------|-------------|---------------| -| `9.2.0` | `9.2.0` | `ga` | -| `9.2.0-rc.1` | `9.2.0-rc.1` | `ga` | -| `9.2.0-beta.1` | `9.2.0-beta.1` | `beta` | -| `9.2.0-alpha.1` | `9.2.0-alpha.1` | `preview` | -| `9.2.0-preview.1` | `9.2.0-preview.1` | `preview` | - -For more information about acceptable product and lifecycle values, go to [Product format](#product-format). - -You can invoke those profiles with commands like this: - -```sh -# Bundle changelogs for a GA release ({lifecycle} → "ga" inferred from "9.2.0") -docs-builder changelog bundle elasticsearch-release 9.2.0 - -# Bundle changelogs for a beta release ({lifecycle} → "beta" inferred from "9.2.0-beta.1") -docs-builder changelog bundle elasticsearch-release 9.2.0-beta.1 - -# Bundle changelogs with partial dates -docs-builder changelog bundle serverless-monthly 2026-02 - -# Bundle changelogs that match a list of PRs in a downloaded promotion report -# (version used for {version} substitution; report used as PR filter) -docs-builder changelog bundle serverless-report 2026-02-13 ./promotion-report.html - -# Same using a URL list file instead of an HTML promotion report -docs-builder changelog bundle serverless-report 2026-02-13 ./prs.txt -``` - -For profiles that use static patterns (without `{version}` or `{lifecycle}` placeholders), the second argument is still required but serves no functional purpose. You can pass any placeholder value: - -```sh -# Profile with static patterns - second argument unused but required -docs-builder changelog bundle release-all '*' -docs-builder changelog bundle release-all 'unused' -docs-builder changelog bundle release-all 'none' -``` diff --git a/docs/cli/changelog/cmd-bundle.md b/docs/cli/changelog/cmd-bundle.md new file mode 100644 index 0000000000..6a767a008c --- /dev/null +++ b/docs/cli/changelog/cmd-bundle.md @@ -0,0 +1,138 @@ +Aggregates changelog YAML files matching a filter into a single bundle file. The bundle is the artifact used by the `{changelog}` directive and `docs-builder changelog render` to produce release notes. + +The command has **two mutually exclusive modes**. You cannot mix them: supplying a profile name on the command line disables all filter and output flags. + +## Profile-based mode + +Define reusable profiles in `changelog.yml` and invoke by name. This is the recommended approach for release workflows because the filter, output path, and product metadata are all captured in configuration and don't need to be specified on the command line. + +```sh +# Bundle using a named profile (version inferred for {lifecycle} placeholder) +docs-builder changelog bundle elasticsearch-release 9.2.0 + +# Bundle using a profile with a promotion report as the filter source +docs-builder changelog bundle elasticsearch-release 9.2.0 ./promotion-report.html +``` + +The second positional argument accepts: +- A version string (e.g. `9.2.0`, `9.2.0-beta.1`) — lifecycle is inferred automatically (`ga`, `beta`, `rc`) +- A promotion report URL or file path +- A plain-text URL list file (one fully-qualified GitHub URL per line) + +When your profile uses `{version}` in its output pattern and you also want to filter by a report, pass both arguments. + +Example profile in `changelog.yml`: + +```yaml +bundle: + repo: elasticsearch + owner: elastic + directory: docs/changelog + output_directory: docs/releases + profiles: + elasticsearch-release: + products: "elasticsearch {version} {lifecycle}" + output: "elasticsearch/{version}.yaml" + output_products: "elasticsearch {version}" +``` + +## Option-based mode + +Supply filter flags directly when you don't have a profile configured or need a one-off bundle. + +Exactly one of the following filter flags is required: + +- `--all` — include every changelog in the directory +- `--input-products` — match by product, target date, and lifecycle (e.g. `"elasticsearch * *"`) +- `--prs` — filter by PR URLs or a newline-delimited file of PR URLs +- `--issues` — filter by issue URLs or a newline-delimited file of issue URLs +- `--release-version` — fetch PR references from a GitHub release tag (e.g. `v9.2.0` or `latest`) +- `--report` — filter by PRs referenced in a promotion report (URL or local file) + +```sh +# Bundle all changelogs in docs/changelog/ +docs-builder changelog bundle --all --directory docs/changelog + +# Bundle changelogs for a specific product release +docs-builder changelog bundle \ + --input-products "elasticsearch 9.2.0 ga" \ + --output docs/releases/9.2.0.yaml + +# Bundle from a GitHub release +docs-builder changelog bundle \ + --release-version v9.2.0 \ + --repo elasticsearch \ + --owner elastic +``` + +## Resolved vs. reference bundles + +By default the bundle contains only file names and checksums — the original changelog files must remain on disk for rendering. Add `--resolve` (or set `bundle.resolve: true` in `changelog.yml`) to embed the full entry content inside the bundle. A resolved bundle is: + +- Required when using the `{changelog}` directive after deleting the source changelog files +- Required when `link_allow_repos` is configured (private-link scrubbing only runs during resolve) +- Necessary to regenerate rendered Markdown or AsciiDoc after the source files are removed + +:::{tip} +For most release workflows, use `--resolve`. It makes the bundle self-contained and allows you to clean up the changelog files with `docs-builder changelog remove` immediately after bundling. +::: + +## CI usage + +Pass `--plan` to emit GitHub Actions step outputs (`needs_network`, `needs_github_token`, `output_path`) without generating the bundle. Use this in a planning step to decide whether subsequent steps require a GitHub token or network access. + +For full configuration reference, see [Bundle changelogs](/contribute/bundle-changelogs.md). + +## Product format [product-format] + +The `changelog bundle` command has `--input-products` and `--output-products` options that accept values with the format `"product target lifecycle, ..."` where: + +- `product` is the product ID from [products.yml](https://github.com/elastic/docs-builder/blob/main/config/products.yml) (required) +- `target` is the target version or date (optional) +- `lifecycle` exists in [Lifecycle.cs](https://github.com/elastic/docs-builder/blob/main/src/Elastic.Documentation/Lifecycle.cs) (optional) + +You can further limit the possible values with the [products](/contribute/configure-changelogs-ref.md#products) and [lifecycles](/contribute/configure-changelogs-ref.md#lifecycles) options in the changelog configuration file. + +For example: + +- `"kibana 9.2.0 ga"` +- `"cloud-serverless 2025-08-05"` +- `"cloud-enterprise 4.0.3, cloud-hosted 2025-10-31"` + +If you use `"* * *"` in the `--input-products` command option or `bundle.profiles..products` configuration setting, it's equivalent to the `--all` command option. + +## Lifecycle inference [lifecycle-inference] + +The way that lifecycle values are inferred varies between GitHub release profiles and standard profiles. + +### GitHub release profiles + +For `source: github_release` profiles, the `{lifecycle}` placeholder in `output` and `output_products` is derived from the full release tag name. For example: + +| Release tag | `{version}` | `{lifecycle}` | +|-------------|-------------|---------------| +| `v1.2.3` | `1.2.3` | `ga` | +| `v1.2.3-beta.1` | `1.2.3` | `beta` | +| `v1.2.3-preview.1` | `1.2.3` | `preview` | + +### Standard profiles + +For standard profiles, `{version}` is copied verbatim from your command argument and `{lifecycle}` is derived from that value. For example: + +| Version argument | `{version}` | `{lifecycle}` | +|------------------|-------------|---------------| +| `9.2.0` | `9.2.0` | `ga` | +| `9.2.0-beta.1` | `9.2.0-beta.1` | `beta` | +| `9.2.0-preview.1` | `9.2.0-preview.1` | `preview` | + +For more information about acceptable product and lifecycle values, go to [Product format](#product-format). + +## PR and issue link allowlist [link-allowlist] + +A changelog in a public repository might contain links to pull requests or issues in repositories that should not appear in published documentation. + +Set `bundle.link_allow_repos` in `changelog.yml` to an explicit list of `owner/repo` strings. When this key is present (including as an empty list), PR and issue references are filtered at bundle time: only links whose resolved repository is in the list are kept; others are rewritten to `# PRIVATE:` sentinel strings in the bundle YAML. + +:::{important} +`bundle.link_allow_repos` requires a **resolved** bundle. Set `bundle.resolve: true` or pass `--resolve`. +::: diff --git a/docs/cli/changelog/evaluate-pr.md b/docs/cli/changelog/evaluate-pr.md deleted file mode 100644 index 87cc8067fb..0000000000 --- a/docs/cli/changelog/evaluate-pr.md +++ /dev/null @@ -1,99 +0,0 @@ ---- -navigation_title: "changelog evaluate-pr" ---- - -# changelog evaluate-pr - -:::{note} -This command is intended for CI automation. It is used internally by the changelog GitHub Actions and is not typically invoked directly by users. -::: - -Evaluate a pull request for changelog generation eligibility. -Performs pre-flight checks (body-only edit, bot loop detection, manual edit detection), loads the changelog configuration, checks label-based creation rules, resolves the PR title and type, and sets GitHub Actions outputs for downstream steps. - -## Usage - -```sh -docs-builder changelog evaluate-pr [options...] [-h|--help] -``` - -## Options - -`--config ` -: Path to the `changelog.yml` configuration file. - -`--owner ` -: GitHub repository owner. - -`--repo ` -: GitHub repository name. - -`--pr-number ` -: Pull request number. - -`--pr-title ` -: Pull request title. - -`--pr-labels ` -: Comma-separated PR labels. - -`--head-ref ` -: PR head branch ref. - -`--head-sha ` -: PR head commit SHA. - -`--event-action ` -: GitHub event action (e.g., `opened`, `synchronize`, `edited`). - -`--title-changed` -: Whether the PR title changed (for `edited` events). -: Default: `false` - -`--strip-title-prefix` -: Remove square-bracket prefixes from the PR title (for example, `[Inference API] Title` becomes `Title`), strip an optional colon after the prefix, and strip an ASCII ` - `-style separator after the prefix when the hyphen is followed by whitespace. -: Titles that still start with `-`, `*`, `+`, an en dash, or an em dash are surrounded by quotes to avoid rendering problems. -: Default: `false` - -`--bot-name ` -: Bot login name for loop detection. -: Default: `github-actions[bot]` - -## GitHub Actions outputs - -| Output | Description | -|--------|-------------| -| `status` | Evaluation result: `skipped`, `manually-edited`, `no-title`, `no-label`, or `proceed` | -| `should-generate` | `true` if `changelog add` should run | -| `should-upload` | `true` if the artifact should be uploaded | -| `title` | Resolved PR title | -| `description` | Release note extracted from the PR body (when `extract.release_notes` is enabled and a release note is found). Long or multi-line release notes (>120 characters) are placed here. Passed downstream as `CHANGELOG_DESCRIPTION` for `changelog add`. | -| `type` | Resolved changelog type | -| `products` | Comma-separated product specs resolved from PR labels via `pivot.products` mappings (e.g., `cloud-hosted, cloud-serverless`) | -| `label-table` | Markdown table of configured label-to-type mappings | -| `product-label-table` | Markdown table of configured label-to-product mappings | -| `existing-changelog-filename` | Filename of a previously committed changelog for this PR (if any) | - -## Environment variables - -| Variable | Purpose | -|----------|---------| -| `GITHUB_TOKEN` | GitHub API authentication for bot-commit and manual-edit detection | - -## Examples - -Evaluate PR #42 in the `elastic/elasticsearch` repository: - -```sh - docs-builder changelog evaluate-pr \ - --config docs/changelog.yml \ - --owner elastic \ - --repo elasticsearch \ - --pr-number 42 \ - --pr-title "Add new feature" \ - --pr-labels "enhancement,Team:Core" \ - --head-ref feature-branch \ - --head-sha abc123 \ - --event-action opened \ - --strip-title-prefix -``` diff --git a/docs/cli/changelog/gh-release.md b/docs/cli/changelog/gh-release.md deleted file mode 100644 index dc74d61a08..0000000000 --- a/docs/cli/changelog/gh-release.md +++ /dev/null @@ -1,102 +0,0 @@ ---- -navigation_title: "changelog gh-release" ---- - -# changelog gh-release - -Create changelog files and a bundle from a GitHub release by parsing pull request references from the release notes. - -:::{important} -Only automated GitHub release notes (the default format or [Release Drafter](https://github.com/release-drafter/release-drafter) format) are supported at this time. -::: - -For general information about changelogs, go to [](/contribute/changelog.md). - -## Usage - -```sh -docs-builder changelog gh-release [version] [options...] [-h|--help] -``` - -## Arguments - -`repo` -: Required: GitHub repository in `owner/repo` format (for example, `elastic/elasticsearch`) or just the repository name (for example, `elasticsearch`), which defaults to `elastic` as the owner. - -`version` -: Optional: The release tag to fetch (for example, `v9.2.0` or `9.2.0`). Defaults to `latest`. - -## Options - -`--config ` -: Optional: Path to the changelog.yml configuration file. Defaults to `docs/changelog.yml`. - -`--description ` -: Optional: Bundle description text with placeholder support. -: Supports `{version}`, `{lifecycle}`, `{owner}`, and `{repo}` placeholders. Overrides `bundle.description` from config. - -`--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, remove a colon or a single ASCII hyphen if it follows the closing bracket and is followed by whitespace. -: Multiple bracket prefixes are also supported (for example, `"[Discover][ESQL] - Fix filtering"` becomes `"Fix filtering"`). -: When the title still begins with `-`, `*`, `+`, an en dash, or an em dash, it's surrounded by quotes. -: By default, the behavior is determined by the `extract.strip_title_prefix` changelog configuration setting (which defaults to `false`). - -`--warn-on-type-mismatch` -: Optional: Warn when the type inferred from Release Drafter section headers (for example, "Bug Fixes") doesn't match the type derived from the pull request's labels. Defaults to `true`. - -## Output - -The command creates two types of output in the directory specified by `--output`: - -- One YAML changelog file per pull request found in the release notes. -- A bundle file at `{output}/bundles/{version}-{product}-bundle.yml` that references all created changelog files. - -The product, target version, and lifecycle are inferred automatically from the release tag and the repository name (via [products.yml](https://github.com/elastic/docs-builder/blob/main/config/products.yml)). For example, a tag of `v9.2.0` on `elastic/elasticsearch` creates changelogs with `product: elasticsearch`, `target: 9.2.0`, and `lifecycle: ga`. - -## Configuration - -The `rules.bundle` section of your `changelog.yml` applies to bundles created by this command (after changelog files are gathered from the release). -For details, refer to [](/contribute/configure-changelogs-ref.md#rules-bundle). - -## Examples - -### Create changelogs from the latest release - -```sh -docs-builder changelog gh-release elastic/elasticsearch -``` - -### Create changelogs from a specific version tag - -```sh -docs-builder changelog gh-release elastic/elasticsearch v9.2.0 -``` - -### Use a short repository name - -```sh -docs-builder changelog gh-release elasticsearch v9.2.0 -``` - -### Specify a custom output directory - -```sh -docs-builder changelog gh-release elasticsearch v9.2.0 \ - --output ./docs/changelog \ - --config ./docs/changelog.yml -``` - -### Add description with placeholders - -```sh -docs-builder changelog gh-release elasticsearch v9.2.0 \ - --description "Elasticsearch {version} includes new features and fixes. Download: https://github.com/{owner}/{repo}/releases/tag/v{version}" -``` \ No newline at end of file diff --git a/docs/cli/changelog/index.md b/docs/cli/changelog/index.md index 7efbfc40fe..0460cd28c9 100644 --- a/docs/cli/changelog/index.md +++ b/docs/cli/changelog/index.md @@ -1,15 +1,12 @@ ---- -navigation_title: "changelog" ---- +The `changelog` commands manage a file-per-change workflow that produces release notes with a consistent layout across all your products. Each developer creates a small YAML file per pull request; you later bundle those files into a release artifact and render it into Markdown or AsciiDoc. -# Changelog commands +## Typical workflow -These commands are associated with product release documentation. +1. **Configure** — create `docs/changelog.yml` with label mappings and bundle profiles: `docs-builder changelog init` +2. **Create** — add a changelog YAML for each notable PR: `docs-builder changelog add` +3. **Bundle** — aggregate entries for a release: `docs-builder changelog bundle` +4. **Publish** — render the bundle to a release notes page: `docs-builder changelog render` -- [changelog add](add.md) - Create a changelog file -- [changelog bundle](bundle.md) - Create a changelog bundle file -- [changelog gh-release](gh-release.md) - Create changelogs and a bundle from a GitHub release -- [changelog init](init.md) - Initialize changelog configuration and folder structure -- [changelog bundle-amend](bundle-amend.md) - Add entries to an existing bundle -- [changelog render](render.md) - Generate markdown output from changelog bundle files -- [changelog evaluate-pr](evaluate-pr.md) - (CI) Evaluate a PR for changelog generation eligibility +When working in CI, `docs-builder changelog evaluate-pr` inspects an open pull request and decides whether it needs a changelog file, then sets GitHub Actions outputs so your workflow can gate on the result. + +See [Create release notes from changelogs](/contribute/changelog.md) for the end-to-end guide. diff --git a/docs/cli/changelog/init.md b/docs/cli/changelog/init.md deleted file mode 100644 index f4db0efd18..0000000000 --- a/docs/cli/changelog/init.md +++ /dev/null @@ -1,71 +0,0 @@ ---- -navigation_title: "changelog init" ---- - -# changelog init - -Initialize changelog configuration and folder structure for a repository. - -If a docs folder that contains `docset.yml` exists (in the repository root or `docs/` directory), the command uses that folder. -If a `docs` folder exists without `docset.yml`, the command uses it. -If no docs folder exists, the command creates `{path}/docs` and places `changelog.yml` there. - -The command creates a `changelog.yml` configuration file (from the built-in template) and `changelog` and `releases` subdirectories in the `docs` folder. -When `--changelog-dir` or `--bundles-dir` is specified, the corresponding `bundle.directory` and `bundle.output_directory` values in `changelog.yml` are set or updated (whether creating a new file or the file already exists). - -When the template is written for the first time, the command can **seed** `bundle.owner`, `bundle.repo`, and `bundle.link_allow_repos` so PR and issue links resolve under the explicit link allowlist in [changelog.example.yml](https://github.com/elastic/docs-builder/blob/main/config/changelog.example.yml) (there is no implicit allow for your own repository). Seeding runs when `git` remote `origin` points at **github.com** and/or when you pass `--owner` and/or `--repo`. CLI values override values inferred from `git`. If you pass `--repo` without `--owner` and `git` does not supply an owner, the owner defaults to `elastic`. If neither `git` nor CLI provides enough information, the placeholder line is removed from the template and you can set bundle fields manually. - -## Usage - -```sh -docs-builder changelog init [options...] [-h|--help] -``` - -## Options - -`--path ` -: Optional: Repository root path. -: Defaults to the output of `pwd` (current directory). The docs folder is `{path}/docs`, created if it does not exist. - -`--changelog-dir ` -: Optional: Path to the changelog directory. -: Defaults to `{docsFolder}/changelog`. - -`--bundles-dir ` -: Optional: Path to the bundles output directory. -: Defaults to `{docsFolder}/releases`. - -`--owner ` -: Optional: GitHub organization or user for `bundle.owner` and for seeding `bundle.link_allow_repos` when creating `changelog.yml`. Overrides the owner parsed from `git` remote `origin`. - -`--repo ` -: Optional: GitHub repository name for `bundle.repo` and for seeding `bundle.link_allow_repos` when creating `changelog.yml`. Overrides the repository name parsed from `git` remote `origin`. - -## Examples - -Initialize changelog (creates or uses docs folder, places `changelog.yml` there, plus `changelog` and `releases` subdirectories): - -```sh -docs-builder changelog init -``` - -Initialize when run from a subdirectory, specifying the root path: - -```sh -docs-builder changelog init --path /path/to/my-repo -``` - -Use custom changelog and bundles directories. -Sets or updates `bundle.directory` and `bundle.output_directory` in `changelog.yml` (creating the file if it does not exist): - -```sh -docs-builder changelog init \ - --changelog-dir ./my-changelogs \ - --bundles-dir ./my-releases -``` - -Initialize without relying on `git` (for example in a clean checkout or CI), setting the GitHub owner and repository used to seed bundle defaults and `link_allow_repos`: - -```sh -docs-builder changelog init --owner elastic --repo kibana -``` diff --git a/docs/cli/changelog/remove.md b/docs/cli/changelog/remove.md deleted file mode 100644 index cc931dc827..0000000000 --- a/docs/cli/changelog/remove.md +++ /dev/null @@ -1,203 +0,0 @@ -# changelog remove - -Remove changelog YAML files from a directory. - -You can use either profile-based or command-option-based removal: - -- **Profile-based**: `docs-builder changelog remove ` — uses the same `bundle.profiles` configuration as [`changelog bundle`](/cli/changelog/bundle.md) to determine which changelogs to remove. -- **Option-based**: `docs-builder changelog remove --products "..." ` (or `--prs`, `--issues`, `--all`, `--release-version`, `--report`) — specify the filter directly. - -These modes are mutually exclusive. You can't combine a profile argument with the command filter options. - -Before deleting anything, the command checks whether any of the matching files are referenced by unresolved bundles, to prevent silently breaking the `{changelog}` directive. - -For more context, go to [](/contribute/bundle-changelogs.md#changelog-remove). - -## Usage - -```sh -docs-builder changelog remove [arguments...] [options...] [-h|--help] -``` - -## Arguments - -These arguments apply to profile-based removal: - -`[0] ` -: Profile name from `bundle.profiles` in the changelog configuration file. -: For example, "elasticsearch-release". -: When specified, the second argument is the version, promotion report URL, or URL list file. - -`[1] ` -: Version number, promotion report URL/path, or URL list file. -: For example, `9.2.0`, `https://buildkite.../promotion-report.html`, or `/path/to/prs.txt`. -: See [Profile argument types](/cli/changelog/bundle.md#profile-argument-types) for details on accepted formats. - -`[2] ` -: Optional: Promotion report URL/path or URL list file when the second argument is a version string. -: When provided, `[1]` must be a version string and `[2]` is the PR/issue filter source. -: For example, `docs-builder changelog remove serverless-release 2026-02 ./promotion-report.html`. - -## Options - -For command-option-based removal, only one filter option can be specified: `--all`, `--products`, `--prs`, `--issues`, `--release-version`, or `--report`. - -`--all` -: Remove all changelog files in the directory. -: Cannot be combined with a profile argument. - -`--bundles-dir ` -: Optional: Override the directory scanned for bundles during the dependency check. -: When not specified, the directory is resolved in order: `bundle.output_directory` from the changelog configuration, then `{changelog-dir}/bundles`, then `{changelog-dir}/../bundles`. -: Not allowed with a profile argument. In profile mode, the same automatic discovery applies. - -`--config ` -: Optional: Path to the changelog configuration file. -: Defaults to `docs/changelog.yml`. -: Not allowed with a profile argument. In profile mode, the configuration is discovered automatically. - -`--directory ` -: Optional: The directory that contains the changelog YAML files. -: When not specified, falls back to `bundle.directory` from the changelog configuration, then the current working directory. -: Not allowed with a profile argument. In profile mode, the same fallback applies (starting from `bundle.directory`). - -`--dry-run` -: Print the files that would be removed and any bundle dependency conflicts, without deleting anything. -: Valid in both profile and command-option-based mode. - -`--force` -: Proceed with removal even when files are referenced by unresolved bundles. -: Emits a warning per dependency instead of blocking. -: Valid in both profile and command-option-based mode. - -`--issues ` -: Filter by issue URLs (comma-separated), or a path to a newline-delimited file. -: Can be specified multiple times. -: When using a file, every line must be a fully-qualified GitHub issue URL. Bare numbers and short forms are not allowed in files. -: Cannot be combined with a profile argument. - -`--owner ` -: Optional: The GitHub repository owner, which is used when pull requests or issues are specified as numbers. -: Precedence: `--owner` flag > `bundle.owner` in `changelog.yml` > `elastic`. -: Cannot be combined with a profile argument. - -`--products ?>` -: Filter by products in format `"product target lifecycle, ..."` -: Cannot be combined with a profile argument. -: All three parts (product, target, lifecycle) are required but can be wildcards (`*`). Multiple comma-separated values are combined with OR: a changelog is removed if it matches any of the specified product/target/lifecycle combinations. For example: - -- `"elasticsearch 9.3.0 ga"` — exact match -- `"cloud-serverless 2025-12-02 ga, cloud-serverless 2025-12-06 beta"` — remove changelogs for either cloud-serverless 2025-12-02 ga or cloud-serverless 2025-12-06 beta -- `"elasticsearch * *"` — all elasticsearch changelogs -- `"* 9.3.* *"` — any product with a target starting with `9.3.` -- `"* * *"` — all changelogs (equivalent to `--all`) - -`--prs ` -: Filter by pull request URLs (comma-separated), or a path to a newline-delimited file. -: Can be specified multiple times. -: When using a file, every line must be a fully-qualified GitHub PR URL. Bare numbers and short forms are not allowed in files. -: Cannot be combined with a profile argument. - -`--release-version ` -: GitHub release tag to use as a source of pull requests (for example, `"v9.2.0"` or `"latest"`). -: When specified, the command fetches the release from GitHub, parses PR references from the release notes, and use it as the removal filter. Only automated GitHub release notes (the default format or [Release Drafter](https://github.com/release-drafter/release-drafter) format) are supported at this time. -: Requires repo (`--repo` or `bundle.repo` in `changelog.yml`) and owner (`--owner` flag > `bundle.owner` in `changelog.yml` > `elastic`) details. -: Requires a `GITHUB_TOKEN` or `GH_TOKEN` environment variable (or an active `gh` login). - -`--repo ` -: The GitHub repository name, which is required when pull requests or issues are specified as numbers or when using `--release-version`. -: Precedence: `--repo` flag > `bundle.repo` in `changelog.yml`. -: Cannot be combined with a profile argument. - -`--report ` -: Filter by pull requests extracted from a promotion report. Accepts a URL or a local file path. -: Exactly one filter option must be specified: `--all`, `--products`, `--prs`, `--issues`, or `--report`. -: Not allowed with a profile argument. - -## Directory resolution [changelog-remove-dirs] - -Both modes use the same ordered fallback to locate changelog YAML files and existing bundles. - -**Changelog files directory** (where changelog YAML files are read from): - -| Priority | Profile-based | Option-based | -|----------|---------------|--------------| -| 1 | `bundle.directory` in `changelog.yml` | `--directory` CLI option | -| 2 | Current working directory | `bundle.directory` in `changelog.yml` | -| 3 | — | Current working directory | - -**Bundles directory** (scanned for existing bundles during the dependency check): - -| Priority | Both modes | -|----------|------------| -| 1 | `--bundles-dir` CLI option (command-option-based only) | -| 2 | `bundle.output_directory` in `changelog.yml` | -| 3 | `{changelog-dir}/bundles` | -| 4 | `{changelog-dir}/../bundles` | - -:::{note} -"Current working directory" means the directory you are in when you run the command (`pwd`). -Setting `bundle.directory` and `bundle.output_directory` in `changelog.yml` is recommended so you don't need to rely on running the command from a specific directory. -::: - -## Option-based examples - -You can remove changelogs based on their issues, pull requests, product metadata, or remove all changelogs from a folder. -Exactly one filter option must be specified: `--all`, `--products`, `--prs`, `--issues`, `--release-version` or `--report`. -When using a file for `--prs` or `--issues`, every line must be a fully-qualified GitHub URL. - -For example: - -```sh -docs-builder changelog remove --products "elasticsearch 9.3.0 *" --dry-run -``` - -### Remove by GitHub release [changelog-remove-release-version] - -You can use `--release-version` to fetch pull request references directly from GitHub release notes and use them as the removal filter. - -:::{important} -Only automated GitHub release notes (the default format or [Release Drafter](https://github.com/release-drafter/release-drafter) format) are supported at this time. -::: - -This mirrors the equivalent [`--release-version` option on `changelog bundle`](/cli/changelog/bundle.md#changelog-bundle-release-version) and is useful when cleaning up after a release-based bundle. -For example: - -```sh -docs-builder changelog remove \ - --release-version v1.34.0 \ - --repo apm-agent-dotnet --owner elastic -``` - -The repo and owner used to fetch the release follow the same precedence as `changelog bundle`: - -- Repo: `--repo` flag > `bundle.repo` in `changelog.yml` (one source is required) -- Owner: `--owner` flag > `bundle.owner` in `changelog.yml` > `elastic` - -Use `--dry-run` to preview which files would be deleted before committing: - -```sh -docs-builder changelog remove \ - --release-version v1.34.0 \ - --dry-run -``` - -Pass `latest` to target the most recent release: - -```sh -docs-builder changelog remove \ - --release-version latest \ - --dry-run -``` - -:::{note} -`--release-version` requires a `GITHUB_TOKEN` or `GH_TOKEN` environment variable (or an active `gh` login) to fetch release details from the GitHub API. -::: - -## Profile-based examples [changelog-remove-profile] - -When a `changelog.yml` configuration file defines `bundle.profiles`, you can use those same profiles with `changelog remove` to remove exactly the changelogs that would be included in a matching bundle. - -Profile-based commands discover the changelog configuration automatically (no `--config` flag): they look for `changelog.yml` in the current directory, then `docs/changelog.yml`. If neither file is found, the command returns an error with instructions to run `docs-builder changelog init` or to re-run from the folder where the file exists. - -Refer to [](/contribute/bundle-changelogs.md#changelog-remove) for examples. diff --git a/docs/cli/changelog/render.md b/docs/cli/changelog/render.md deleted file mode 100644 index 9ad7ac3434..0000000000 --- a/docs/cli/changelog/render.md +++ /dev/null @@ -1,143 +0,0 @@ -# changelog render - -Generate markdown or asciidoc files from changelog bundle files. - -To create the bundle files, use [](/cli/changelog/bundle.md). -For details and examples, go to [](/contribute/publish-changelogs.md). - -## Usage - -```sh -docs-builder changelog render [options...] [-h|--help] -``` - -## Options - -`--config ` -: Optional: Path to the changelog.yml configuration file. -: Defaults to `docs/changelog.yml`. -: Note: The `changelog render` command does not use `rules.publish` for filtering. Filtering must be done at bundle time using `rules.bundle`. - -`--hide-features ` -: Optional: Filter by feature IDs (comma-separated), or a path to a newline-delimited file containing feature IDs. Can be specified multiple times. -: Each occurrence can be either comma-separated feature IDs (e.g., `--hide-features "feature:new-search-api,feature:enhanced-analytics"`) or a file path (e.g., `--hide-features /path/to/file.txt`). -: When specifying feature IDs directly, provide comma-separated values. -: When specifying a file path, provide a single value that points to a newline-delimited file. The file should contain one feature ID per line. -: Entries with matching `feature-id` values will be commented out in the output and a warning will be emitted. -: If the bundle contains `hide-features` values (that is to say, it was created with the `--hide-features` option), those values are merged with this list and are also hidden. - -`--input ` -: One or more bundle input files. -: Each bundle is specified as "bundle-file-path|changelog-file-path|repo|link-visibility" using pipe (`|`) as delimiter. -: To merge multiple bundles, separate them with commas: `--input "bundle1|dir1|repo1|keep-links,bundle2|dir2|repo2|hide-links"`. -: For example, `--input "/path/to/changelog-bundle.yaml|/path/to/changelogs|elasticsearch|keep-links"`. -: Only `bundle-file-path` is required for each bundle. -: Use `repo` if your changelogs do not contain full URLs for the pull requests or issues; otherwise they will be incorrectly derived with "elastic/elastic" in the URL by default. -: Use `link-visibility` to control whether PR/issue links are shown or hidden for entries from this bundle. Valid values are `keep-links` (default) or `hide-links`. Use `hide-links` for bundles from private repositories. When `hide-links` is set, all links are hidden for each affected entry — changelog entries can contain multiple PR links (`prs`) and issue links (`issues`), and all of them are hidden or shown together. -: Paths support tilde (`~`) expansion and relative paths. - -:::{note} -The `render` command automatically discovers and merges `.amend-*.yaml` files with their parent bundle. For more information about amended bundles, go to [](bundle-amend.md). -::: - -`--file-type ` -: Optional: Output file type. Valid values: `"markdown"` or `"asciidoc"`. -: Defaults to `"markdown"`. -: When `"markdown"` is specified, the command generates multiple markdown files (index.md, breaking-changes.md, deprecations.md, known-issues.md). -: When `"asciidoc"` is specified, the command generates a single asciidoc file with all sections. - -`--output ` -: Optional: The output directory for rendered files. -: Defaults to current directory. - -`--subsections` -: Optional: Group entries by area in subsections. -: Defaults to false. -: When enabled, entries are grouped by their area within each section. The first area from each entry's areas list is used for grouping. - -`--title ` -: Optional: The title to use for section headers, directories, and anchors in output files. -: Defaults to the version in the first bundle. -: If the string contains spaces, they are replaced with dashes when used in directory names and anchors. - -The `changelog render` command does **not** use `rules.publish` for filtering. Filtering must be done at bundle time using `rules.bundle`. For more information, refer to [](/contribute/publish-changelogs.md). For how the directive differs, see the [{changelog} directive syntax reference](/syntax/changelog.md). - -## Output formats - -### Markdown format - -When `--file-type markdown` is specified (the default), the command generates multiple markdown files: - -- `index.md` - Contains features, enhancements, bug fixes, security updates, documentation changes, regressions, and other changes -- `breaking-changes.md` - Contains breaking changes -- `deprecations.md` - Contains deprecations -- `known-issues.md` - Contains known issues -- `highlights.md` - Contains highlighted entries (only created when at least one entry has `highlight: true`) - -### Asciidoc format - -When `--file-type asciidoc` is specified, the command generates a single asciidoc file with all sections: - -- Security updates -- Bug fixes -- Highlights (only included when at least one entry has `highlight: true`) -- New features and enhancements -- Breaking changes -- Deprecations -- Known issues -- Documentation -- Regressions -- Other changes - -The asciidoc output uses attribute references for links (for example, `{repo-pull}NUMBER[#NUMBER]`). - -### Multiple PR and issue links - -Changelog entries can reference multiple pull requests and issues using the `prs` and `issues` array fields. When an entry has multiple links, all of them are rendered inline for that entry: - -```md -* Fix ML calendar event update scalability issues. [#136886](https://github.com/elastic/elastic/pull/136886) [#136900](https://github.com/elastic/elastic/pull/136900) -``` - -## Examples - -### Render a single bundle - -```sh -docs-builder changelog render \ - --input "./docs/changelog/bundles/9.3.0.yaml" \ - --output ./release-notes -``` - -### Render with tilde expansion - -```sh -docs-builder changelog render \ - --input "~/docs/changelog/bundles/9.3.0.yaml|~/docs/changelog|elasticsearch" \ - --output ~/release-notes -``` - -### Render with relative paths - -```sh -docs-builder changelog render \ - --input "./bundles/9.3.0.yaml|./changelog|elasticsearch|keep-links" \ - --file-type markdown \ - --output ./output -``` - -### Merge multiple bundles - -```sh -docs-builder changelog render \ - --input "./bundles/elasticsearch-9.3.0.yaml|./changelog|elasticsearch,./bundles/kibana-9.3.0.yaml|./changelog|kibana" \ - --output ./merged-release-notes -``` - -### Hide links from private repository bundles - -```sh -docs-builder changelog render \ - --input "./public-bundle.yaml|./changelog|elasticsearch|keep-links,./private-bundle.yaml|./private-changelog|internal-repo|hide-links" \ - --output ./release-notes -``` diff --git a/docs/cli/cli-reference-how-to.md b/docs/cli/cli-reference-how-to.md new file mode 100644 index 0000000000..5a70552ce4 --- /dev/null +++ b/docs/cli/cli-reference-how-to.md @@ -0,0 +1,141 @@ +--- +navigation_title: Automated Reference +--- + +# Automated CLI reference docs + +`docs-builder` can generate a complete CLI reference section from a JSON schema file that describes your tool's commands, namespaces, flags, and arguments. The generated pages render usage synopses, parameter definitions, and examples directly from that schema — no hand-maintained markdown required. + +The schema format is documented at [argh-cli-schema.json](https://github.com/nullean/argh/blob/main/schema/argh-cli-schema.json). + +:::{note} +`docs-builder` supports automatic schema generation through the `__schema` meta-command, a built-in feature of the [Nullean.Argh](https://github.com/nullean/argh) CLI framework. +For other frameworks, add a command or script to your build tooling that writes an equivalent JSON file. +::: + +:::::{stepper} + +::::{step} Create a schema file + +Add a mechanism to your CLI that outputs a schema JSON matching the [argh-cli-schema.json](https://github.com/nullean/argh/blob/main/schema/argh-cli-schema.json) format. The schema describes your CLI's structure: top-level commands, namespaces, nested sub-namespaces, per-parameter types and descriptions, usage synopses, and examples. + +Once you have a way to generate the schema, write it to a file in your docs repository and commit it: + +```bash +# Example — replace with whatever generates your schema +my-tool export-schema > docs/cli-schema.json +``` + +Commit that file. It is the source of truth for the generated reference section. + +:::{tip} +Add a CI step that regenerates the schema and fails if the checked-in copy has drifted: + +```yaml +- name: Check CLI schema is up to date + run: | + my-tool export-schema > docs/cli-schema.json.tmp + diff docs/cli-schema.json docs/cli-schema.json.tmp || \ + (echo "cli-schema.json is out of date — regenerate and commit it" && exit 1) + rm docs/cli-schema.json.tmp +``` +::: + +:::: + +::::{step} Add a cli: entry to docset.yml + +In your `docset.yml`, add a `cli:` entry to the `toc:` section pointing at the schema file: + +```yaml +toc: + - cli: cli-schema.json +``` + +That's the minimal setup. `docs-builder` generates a navigation subtree and a page for every namespace and command. + +To give the section a stable URL prefix and a home for supplemental docs, also set `folder:` to the directory name you want to use: + +```yaml +toc: + - cli: cli-schema.json + folder: cli-reference +``` + +Use `children:` to prepend hand-written pages — installation guides, conceptual overviews, or quick-start tutorials — before the auto-generated reference. All schema-generated pages follow the listed children: + +```yaml +toc: + - cli: cli-schema.json + folder: cli-reference + children: + - file: installation.md + - file: getting-started.md +``` + +:::: + +::::{step} Write supplemental content for namespaces and commands + +The supplemental folder lets you replace the auto-generated summary on any namespace or command page with your own prose. The parameter table, usage synopsis, and examples are always appended from the schema — you're only replacing the introductory description. + +**Files are discovered automatically** — drop a file into the folder following the naming convention and it is picked up on the next build with no configuration needed. + +**Validation is strict** — any supplemental file whose name doesn't match a known namespace or command in the schema produces a build error. This means renamed or removed commands can't leave orphaned docs behind silently; your CI catches it immediately. + +Two naming conventions are supported. Both can coexist in the same folder. + +**Hierarchy style** — mirrors the CLI namespace structure, works well alongside the `index.md` convention already common in documentation repos: + +``` +cli-reference/ + database/ + index.md ← intro for the database namespace + migrate.md ← intro for database migrate command + deploy.md ← intro for the root deploy command +``` + +**Flat prefix style** — all files at the folder root, full path encoded in the name: + +``` +cli-reference/ + ns-database.md ← intro for the database namespace + database-migrate.md ← intro for database migrate command + deploy.md ← intro for the root deploy command +``` + +For a live example, see how `docs-builder` uses both files in its own `cli/changelog/` folder: + +- [changelog namespace intro](/cli/changelog/index.md) — `docs/cli/changelog/index.md` introduces the changelog workflow before the auto-generated commands list +- [changelog bundle command](/cli/changelog/bundle.md) — `docs/cli/changelog/bundle.md` explains the two bundling modes; the generated parameter table follows underneath + +:::: + +::::{step} Done + +Your CLI reference section is live. As your CLI evolves, regenerate the schema and commit — the docs update automatically on the next build. + +**Navigation indicators** — generated pages show a `ns` (purple) or `cmd` (amber) badge in the sidebar, making it easy to see at a glance which pages come from the schema and which are hand-written. + +**Schema version** — the JSON includes a `schemaVersion` field. Check the [schema spec](https://github.com/nullean/argh/blob/main/schema/argh-cli-schema.json) for the current version when updating your schema generator. + +:::: + +::::: + +## Reference + +| docset.yml key | Description | +|---|---| +| `cli: ` | Path to the schema JSON, relative to `docset.yml` | +| `folder: ` | Supplemental docs folder; also sets the URL prefix | +| `children:` | Regular toc items prepended before generated pages | + +| Supplemental file | Matches | +|---|---| +| `ns-root.md` | Root CLI overview page | +| `/index.md` | Namespace `` (hierarchy style) | +| `ns-.md` | Namespace `` (flat style) | +| `/.md` | Command `` inside namespace `` | +| `-.md` | Same, flat style | +| `.md` | Root-level command `` | diff --git a/docs/cli/docset/build.md b/docs/cli/docset/build.md deleted file mode 100644 index 3de45a1f65..0000000000 --- a/docs/cli/docset/build.md +++ /dev/null @@ -1,65 +0,0 @@ -# build - -Builds a local documentation set folder. - -Repeated invocations will do incremental builds of only the changed files unless: - -* The base branch has changed. -* The state file in the output folder has been removed. -* The `--force` option is specified. - -## Usage - -``` -docs-builder [command] [options...] [-h|--help] [--version] -``` - -## Global Options - -- `--log-level` `level` - -## Options - -`-p|--path ` -: Defaults to the`{pwd}/docs` folder (optional) - -`-o|--output ` -: Defaults to `.artifacts/html` (optional) - -`--path-prefix` `` -: Specifies the path prefix for urls (optional) - -`--force` `` -: Force a full rebuild of the destination folder (optional) - -`--strict` `` -: Treat warnings as errors and fail the build on warnings (optional) - -`--allow-indexing` `` -: Allow indexing and following of HTML files (optional) - -`--metadata-only` `` -: Only emit documentation metadata to output, ignored if 'exporters' is also set (optional) - -`--exporters` `` -: Set available exporters: - - * html - * es, - * config, - * links, - * state, - * llm, - * redirect, - * metadata, - * default - * none. - - Defaults to (html, llm, config, links, state, redirect) or 'default'. (optional) - - -`--canonical-base-url` `` -: The base URL for the canonical url tag (optional) - -`--skip-api` -: Skip [API Explorer](../../configure/content-set/api-explorer.md) generation for faster builds. (optional) \ No newline at end of file diff --git a/docs/cli/docset/diff-validate.md b/docs/cli/docset/diff-validate.md deleted file mode 100644 index 59513c5f90..0000000000 --- a/docs/cli/docset/diff-validate.md +++ /dev/null @@ -1,18 +0,0 @@ -# diff validate - -Gathers the local changes by inspecting the git log, stashed and unstashed changes. - -It currently validates the following: - -* Ensures that renames and deletions are reflected in [redirects.yml](../../contribute/redirects.md). - -## Usage - -``` -docs-builder diff validate [options...] [-h|--help] [--version] -``` - -## Options - -`-p|--path ` -: Defaults to the`{pwd}/docs` folder (optional) \ No newline at end of file diff --git a/docs/cli/docset/format.md b/docs/cli/docset/format.md deleted file mode 100644 index ba20a1fe9c..0000000000 --- a/docs/cli/docset/format.md +++ /dev/null @@ -1,145 +0,0 @@ -# format - -Format documentation files by fixing common issues like irregular space - -## Usage - -``` -docs-builder format --check [options...] -docs-builder format --write [options...] -``` - -## Options - -`--check` -: Check if files need formatting without modifying them. Exits with code 1 if formatting is needed, 0 if all files are properly formatted. (required, mutually exclusive with --write) - -`--write` -: Write formatting changes to files. (required, mutually exclusive with --check) - -`-p|--path` `` -: Path to the documentation folder, defaults to pwd. (optional) - -## Description - -The `format` command automatically detects and fixes formatting issues in your documentation files. The command only processes Markdown files (`.md`) that are included in your `_docset.yml` table of contents, ensuring that only intentional documentation files are modified. - -You must specify exactly one of `--check` or `--write`: -- `--check` validates formatting without modifying files, useful for CI/CD pipelines -- `--write` applies formatting changes to files - -Currently, it handles irregular space characters that may impair Markdown rendering. - -### Irregular Space Detection - -The format command intelligently handles irregular space characters by categorizing them into three groups: - -#### Characters removed entirely - -These characters are removed completely as they serve no visual purpose and can cause rendering issues: - -- Line Tabulation (U+000B) -- Form Feed (U+000C) -- Next Line (U+0085) -- Ogham Space Mark (U+1680) -- Mongolian Vowel Separator (U+180E) -- Zero Width No-Break Space/BOM (U+FEFF) -- Zero Width Space (U+200B) -- Line Separator (U+2028) -- Paragraph Separator (U+2029) - -#### Characters preserved - -These characters are preserved as they serve important typographic or functional purposes: - -- No-Break Space (U+00A0) - Prevents line breaks -- Figure Space (U+2007) - Aligns numbers in tables -- Narrow No-Break Space (U+202F) - French typography -- Medium Mathematical Space (U+205F) - Mathematical expressions - -#### Characters replaced with regular spaces - -These characters are replaced with standard spaces (U+0020) as they can cause inconsistent rendering: - -- En Quad (U+2000) -- Em Quad (U+2001) -- En Space (U+2002) -- Em Space (U+2003) -- Tree-Per-Em (U+2004) -- Four-Per-Em (U+2005) -- Six-Per-Em (U+2006) -- Punctuation Space (U+2008) -- Thin Space (U+2009) -- Hair Space (U+200A) -- Ideographic Space (U+3000) - -These characters can cause unexpected rendering issues in Markdown and are often introduced accidentally through copy-paste operations from other applications. - -## Examples - -### Check if formatting is needed (CI/CD) - -```bash -docs-builder format --check -``` - -Exit codes: -- `0`: All files are properly formatted -- `1`: Some files need formatting - -### Apply formatting changes - -```bash -docs-builder format --write -``` - -### Check specific documentation folder - -```bash -docs-builder format --check --path /path/to/docs -``` - -### Format specific documentation folder - -```bash -docs-builder format --write --path /path/to/docs -``` - -## Output - -### Check mode output - -When using `--check`, the command reports which files need formatting: - -``` -Checking documentation in: /path/to/docs - -Formatting needed: - Files needing formatting: 2 - irregular space fixes needed: 3 - -Run 'docs-builder format --write' to apply changes -``` - -### Write mode output - -When using `--write`, the command reports the changes made: - -``` -Formatting documentation in: /path/to/docs -Formatted index.md (2 change(s)) - -Formatting complete: - Files processed: 155 - Files modified: 1 - irregular space fixes: 2 -``` - -## Future Enhancements - -The format command is designed to be extended with additional formatting capabilities in the future, such as: - -- Line ending normalization -- Trailing whitespace removal -- Consistent heading spacing -- And other formatting fixes diff --git a/docs/cli/docset/index-command.md b/docs/cli/docset/index-command.md deleted file mode 100644 index 00e28cf1c8..0000000000 --- a/docs/cli/docset/index-command.md +++ /dev/null @@ -1,71 +0,0 @@ -# index - -Index a single documentation set to Elasticsearch, calls `docs-builder --exporters elasticsearch`. Exposes more options - -## Usage - -``` -docs-builder index [options...] [-h|--help] [--version] -``` - -## Options - -`-es|--endpoint ` -: Elasticsearch endpoint, alternatively set env DOCUMENTATION_ELASTIC_URL (optional) - -`--path` `` -: path to the documentation folder, defaults to pwd. (optional) - -`--api-key` `` -: Elasticsearch API key, alternatively set env DOCUMENTATION_ELASTIC_APIKEY (optional) - -`--username` `` -: Elasticsearch username (basic auth), alternatively set env DOCUMENTATION_ELASTIC_USERNAME (optional) - -`--password` `` -: Elasticsearch password (basic auth), alternatively set env DOCUMENTATION_ELASTIC_PASSWORD (optional) - -`--search-num-threads` `` -: The number of search threads the inference endpoint should use. Defaults: 8 (optional) - -`--index-num-threads` `` -: The number of index threads the inference endpoint should use. Defaults: 8 (optional) - -`--bootstrap-timeout` `` -: Timeout in minutes for the inference endpoint creation. Defaults: 4 (optional) - -`--index-name-prefix` `` -: The prefix for the computed index/alias names. Defaults: semantic-docs (optional) - -`--force-reindex` `` -: Force reindex strategy to semantic index, by default, we multiplex writes if the semantic index does not exist yet (optional) - -`--buffer-size` `` -: The number of documents to send to ES as part of the bulk. Defaults: 100 (optional) - -`--max-retries` `` -: The number of times failed bulk items should be retried. Defaults: 3 (optional) - -`--debug-mode` `` -: Buffer ES request/responses for better error messages and pass ?pretty to all requests (optional) - -`--proxy-address` `` -: Route requests through a proxy server (optional) - -`--proxy-password` `` -: Proxy server password (optional) - -`--proxy-username` `` -: Proxy server username (optional) - -`--disable-ssl-verification` `` -: Disable SSL certificate validation (EXPERT OPTION) (optional) - -`--certificate-fingerprint` `` -: Pass a self-signed certificate fingerprint to validate the SSL connection (optional) - -`--certificate-path` `` -: Pass a self-signed certificate to validate the SSL connection (optional) - -`--certificate-not-root` `` -: If the certificate is not root but only part of the validation chain pass this (optional) \ No newline at end of file diff --git a/docs/cli/docset/index.md b/docs/cli/docset/index.md deleted file mode 100644 index d3e794c053..0000000000 --- a/docs/cli/docset/index.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -navigation_title: "documentation set" ---- - -# Documentation Set Commands - -An isolated build means building a single documentation set. - -A `Documentation Set` is defined as a folder containing a [docset.yml](../../configure/content-set/index.md) file. - -These commands are typically what you interface with when you are working on the documentation of a single repository locally. - -## Isolated build commands - -`build` is the default command so you can just run `docs-builder` to build a single documentation set. `docs-builder` will -locate the `docset.yml` anywhere in the directory tree automatically and build the documentation. - -- [build](build.md) - build a single documentation set (incrementally) -- [serve](serve.md) - partial build and serve documentation as needed at http://localhost:3000 -- [index](index-command.md) - ingest a single documentation set to an Elasticsearch index. - -## Refactor commands - -- [mv](mv.md) - move a file or folder to a new location. This will rewrite all links in all files too. -- [diff validate](diff-validate.md) - validate that local changes are reflected in [redirects.yml](../../contribute/redirects.md) - diff --git a/docs/cli/docset/mv.md b/docs/cli/docset/mv.md deleted file mode 100644 index 173563b1ad..0000000000 --- a/docs/cli/docset/mv.md +++ /dev/null @@ -1,25 +0,0 @@ -# mv - -Move a file or folder from one location to another and update all links in the documentation - -## Usage - -``` -docs-builder mv [arguments...] [options...] [-h|--help] [--version] -``` - -## Arguments - -`[0] ` -: The source file or folder path to move from (required) - -`[1] ` -: The target file or folder path to move to (required) - -## Options - -`--dry-run` `` -: Dry run the move operation (optional) - -`-p|--path ` -: Defaults to the`{pwd}` folder (optional) \ No newline at end of file diff --git a/docs/cli/docset/serve.md b/docs/cli/docset/serve.md deleted file mode 100644 index cab49ffeae..0000000000 --- a/docs/cli/docset/serve.md +++ /dev/null @@ -1,33 +0,0 @@ -# serve - -Continuously serve a documentation folder at http://localhost:3000. - -When running `docs-builder serve`, the documentation is not built in full. -Each page will be build on the fly continuously when requested in the browser. - -The `serve` command is also `live reload` enabled so that file systems changes will be reflected without having to restart the server. -This includes changes to the documentation files, the navigation, or the configuration files. - -## Usage - -``` -docs-builder serve [options...] [-h|--help] [--version] -``` - -## Options - -`-p|--path ` -: Path to serve the documentation. Defaults to the`{pwd}/docs` folder (optional) - -`--port` `` -: Port to serve the documentation. (Default: 3000) - -## API documentation - -When your content set includes an [API Explorer configuration](../../configure/content-set/api-explorer.md) in `docset.yml`, `docs-builder serve` generates API documentation on startup and serves it under `/api/{product-key}/`. For example, an `elasticsearch` key produces pages at `http://localhost:3000/api/elasticsearch/`. - -API spec files are watched for changes and regenerated automatically when they're updated. - -:::{note} -API generation is skipped when running `docs-builder serve --watch`. This is a performance optimization for `dotnet watch` workflows. Run `serve` without `--watch` to include API docs in your local preview. -::: \ No newline at end of file diff --git a/docs/cli/index.md b/docs/cli/index.md deleted file mode 100644 index 55f5242de0..0000000000 --- a/docs/cli/index.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -navigation_title: CLI (docs-builder) ---- - -# Command line interface - -`docs-builder` is the binary used to invoke various commands. -These commands can be roughly grouped into four main categories - -- [Documentation Set commands](#documentation-set-commands) -- [Link commands](#link-commands) -- [Assembler commands](#assembler-commands) -- [Changelog commands](#changelog-commands) - -### Global options - -The following options are available for all commands: - -`--log-level ` -: Change the log level one of ( `trace`, `debug`, `info`, `warn`, `error`, `critical`). Defaults to `info` - -`--config-source` or `-c` -: Explicitly set the configuration source one of `local`, `remote` or `embedded`. Defaults to `local` if available - other wise `embedded` - -## Documentation set commands - -Commands that operate over a single documentation set. - -A `Documentation Set` is defined as a folder containing a [docset.yml](../configure/content-set/index.md) file. - -These commands are typically what you interface with when you are working on the documentation of a single repository locally. - -[See available CLI commands for documentation sets](docset/index.md) - -## Link commands - -Outbound links, those going from the documentation set to other sources, are validated as part of the build process. - -Inbound links, those going from other sources to the documentation set, are validated using specialized commands. - -[See available CLI commands for inbound links](links/index.md) - -## Assembler commands - -Assembler builds bring together all isolated documentation set builds and turn them into the overall documentation that gets published. - -[See available CLI commands for assembler](assembler/index.md) - -## Changelog commands - -Commands that pertain to creating and publishing product release documentation. - -[See available CLI commands for release docs](changelog/index.md) diff --git a/docs/cli/installation.md b/docs/cli/installation.md new file mode 100644 index 0000000000..e402e516ab --- /dev/null +++ b/docs/cli/installation.md @@ -0,0 +1,39 @@ +# Installation + +## Automated install (recommended) + +The quickest way to get started on Linux and macOS is the one-line installer: + +```bash +curl -sL https://ela.st/docs-builder-install | sh +``` + +On Windows, run this in PowerShell: + +```ps1 +iex (New-Object System.Net.WebClient).DownloadString('https://ela.st/docs-builder-install-win') +``` + +Both scripts download the latest release binary and add it to your system `PATH`. + +## Manual install + +Download the binary directly from the [Releases page](https://github.com/elastic/docs-builder/releases), extract it, and place it somewhere on your `PATH`. + +## Build from source + +You need [.NET 10](https://dotnet.microsoft.com/download) installed. + +```bash +git clone https://github.com/elastic/docs-builder +cd docs-builder +./build.sh publishbinaries +``` + +The compiled binary is written to `.artifacts/publish/docs-builder/release/docs-builder`. + +## Verify the installation + +```bash +docs-builder --version +``` diff --git a/docs/cli/links/inbound-links-validate-all.md b/docs/cli/links/inbound-links-validate-all.md deleted file mode 100644 index 3563db79d0..0000000000 --- a/docs/cli/links/inbound-links-validate-all.md +++ /dev/null @@ -1,9 +0,0 @@ -# inbound-links validate-all - -Validate all published cross_links in all published links.json files. - -## Usage - -``` -docs-builder inbound-links validate-all [-h|--help] [--version] -``` \ No newline at end of file diff --git a/docs/cli/links/inbound-links-validate-link-reference.md b/docs/cli/links/inbound-links-validate-link-reference.md deleted file mode 100644 index b9eaca3c26..0000000000 --- a/docs/cli/links/inbound-links-validate-link-reference.md +++ /dev/null @@ -1,17 +0,0 @@ -# inbound-links validate-link-reference - -Validate a locally published links.json file against all published links.json files in the registry - -## Usage - -``` -docs-builder inbound-links validate-link-reference [options...] [-h|--help] [--version] -``` - -## Options - -`--file` `` -: Path to `links.json` defaults to '.artifacts/docs/html/links.json' (optional) - -`-p|--path ` -: Defaults to the `{pwd}` folder (optional) \ No newline at end of file diff --git a/docs/cli/links/inbound-links-validate.md b/docs/cli/links/inbound-links-validate.md deleted file mode 100644 index 80036eec2a..0000000000 --- a/docs/cli/links/inbound-links-validate.md +++ /dev/null @@ -1,17 +0,0 @@ -# inbound-links validate - -Validate all published cross_links in all published links.json files. - -## Usage - -``` -docs-builder inbound-links validate [options...] [-h|--help] [--version] -``` - -## Options - -`--from` `` -: (optional) - -`--to` `` -: (optional) \ No newline at end of file diff --git a/docs/cli/links/index.md b/docs/cli/links/index.md deleted file mode 100644 index 002dbfbe79..0000000000 --- a/docs/cli/links/index.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -navigation_title: links ---- - -# Inbound Links - -Outbound links, those going from the documentation set to other sources, are validated as part of the build process. - -Inbound links, those going from other sources to the documentation set, are validated using specialized commands. - -### Inbound link validation commands - -- [inbound-links validate-all](inbound-links-validate-all.md) - validate all inbounds links as published to the links registry. -- [inbound-links validate](inbound-links-validate.md) - validate inbound links from and to specific repositories -- [inbound-links validate-link-reference](inbound-links-validate-link-reference.md) - validate a local link reference artifact from [build](../docset/build.md) with the published registry diff --git a/docs/cli/shell-autocompletion.md b/docs/cli/shell-autocompletion.md new file mode 100644 index 0000000000..19709f5370 --- /dev/null +++ b/docs/cli/shell-autocompletion.md @@ -0,0 +1,40 @@ +# Shell autocompletion + +`docs-builder` ships with built-in tab completion for subcommands, namespaces, and flags. No extra packages are needed — the completions are generated at build time and are trimming- and AOT-safe. + +Run the following once to install completions for your shell, then open a new terminal session. + +## Bash + +Add to `~/.bashrc`: + +```bash +eval "$(docs-builder __completion bash)" +``` + +## Zsh + +Add to `~/.zshrc`: + +```zsh +source <(docs-builder __completion zsh) +``` + +## Fish + +```shell +mkdir -p ~/.config/fish/completions +docs-builder __completion fish > ~/.config/fish/completions/docs-builder.fish +``` + +Requires Fish 3.4 or later. + +## Inspect the generated script + +You can print the raw completion script for any shell without installing it: + +```bash +docs-builder __completion bash +docs-builder __completion zsh +docs-builder __completion fish +``` diff --git a/docs/contribute/bundle-changelogs.md b/docs/contribute/bundle-changelogs.md index b3c175c51b..5ebafa9bd8 100644 --- a/docs/contribute/bundle-changelogs.md +++ b/docs/contribute/bundle-changelogs.md @@ -186,7 +186,12 @@ For example, if the source of truth for what was shipped in each release is: https://github.com/elastic/kibana/pull/456 ``` -- a buildkite promotion report: +| Field | Description | +|---|---| +| `repo` | Default GitHub repository name applied to all profiles. Falls back to product ID if not set at any level. | +| `owner` | Default GitHub repository owner applied to all profiles. | +| `resolve` | When `true`, embeds full changelog entry content in the bundle (same as `--resolve`). Required when `link_allow_repos` is set. | +| `link_allow_repos` | When set (including an empty list), only PR/issue links whose resolved repository is in this `owner/repo` list are kept; others are rewritten to `# PRIVATE:` sentinels in bundle YAML. When absent, no link filtering is applied. Requires `resolve: true`. Refer to [PR and issue link allowlist](/cli/changelog/bundle.md). | ```sh # Bundle changelogs from a buildkite report ({version} → "2026-02-13") @@ -236,7 +241,235 @@ It is strongly recommended to pull all of the content from each changelog into t To apply additional filtering by the changelog type, areas, or products, add [bundle rules](#rules). -If you don't want to use profiles and prefer to specify all the command options every time you run the command, refer to [Option-based examples](/cli/changelog/bundle.md#option-based-examples). +1. Include all changelogs that have the `cloud-serverless` product identifier with target dates of either December 2 2025 (lifecycle `ga`) or December 6 2025 (lifecycle `beta`). For more information about product values, refer to [Product format](/cli/changelog/bundle.md). + +You can use wildcards in any of the three parts: + +```sh +# Bundle any changelogs that have exact matches for either of these clauses +docs-builder changelog bundle --input-products "cloud-serverless 2025-12-02 ga, elasticsearch 9.3.0 beta" + +# Bundle all elasticsearch changelogs regardless of target or lifecycle +docs-builder changelog bundle --input-products "elasticsearch * *" + +# Bundle all cloud-serverless 2025-12-02 changelogs with any lifecycle +docs-builder changelog bundle --input-products "cloud-serverless 2025-12-02 *" + +# Bundle any cloud-serverless changelogs with target starting with "2025-11-" and "ga" lifecycle +docs-builder changelog bundle --input-products "cloud-serverless 2025-11-* ga" + +# Bundle all changelogs (equivalent to --all) +docs-builder changelog bundle --input-products "* * *" +``` + +If you have changelog files that reference those product details, the command creates a file like this: + +```yaml +products: <1> +- product: cloud-serverless + target: 2025-12-02 +- product: cloud-serverless + target: 2025-12-06 +entries: +- file: + name: 1765495972-fixes-enrich-and-lookup-join-resolution-based-on-m.yaml + checksum: 6c3243f56279b1797b5dfff6c02ebf90b9658464 +- file: + name: 1765507778-break-on-fielddata-when-building-global-ordinals.yaml + checksum: 70d197d96752c05b6595edffe6fe3ba3d055c845 +``` + +1. By default these values match your `--input-products` (even if the changelogs have more products). +To specify different product metadata, use the `--output-products` option. + +## Filter by pull requests [changelog-bundle-pr] + +You can use the `--prs` option to create a bundle of the changelogs that relate to those pull requests. +You can provide either a comma-separated list of PRs (`--prs "https://github.com/owner/repo/pull/123,12345"`) or a path to a newline-delimited file (`--prs /path/to/file.txt`). +In the latter case, the file should contain one PR URL or number per line. + +Pull requests can be identified by a full URL (such as `https://github.com/owner/repo/pull/123`), a short format (such as `owner/repo#123`), or just a number (in which case you must also provide `--owner` and `--repo` options). + +```sh +docs-builder changelog bundle --prs "108875,135873,136886" \ <1> + --repo elasticsearch \ <2> + --owner elastic \ <3> + --output-products "elasticsearch 9.2.2 ga" <4> +``` + +1. The comma-separated list of pull request numbers to seek. +2. The repository in the pull request URLs. Not required when using full PR URLs, or when `bundle.repo` is set in the changelog configuration. +3. The owner in the pull request URLs. Not required when using full PR URLs, or when `bundle.owner` is set in the changelog configuration. +4. The product metadata for the bundle. If it is not provided, it will be derived from all the changelog product values. + +In Mode 3, the **rule context product** is the first alphabetically from `--output-products` (or from aggregated changelog products if omitted). To apply a different product's per-product rules, use a bundle whose `output_products` contains only that product (separate command or profile). + +If you have changelog files that reference those pull requests, the command creates a file like this: + +```yaml +products: +- product: elasticsearch + target: 9.2.2 + lifecycle: ga +entries: +- file: + name: 1765507819-fix-ml-calendar-event-update-scalability-issues.yaml + checksum: 069b59edb14594e0bc3b70365e81626bde730ab7 +- file: + name: 1765507798-convert-bytestransportresponse-when-proxying-respo.yaml + checksum: c6dbd4730bf34dbbc877c16c042e6578dd108b62 +- file: + name: 1765507839-use-ivf_pq-for-gpu-index-build-for-large-datasets.yaml + checksum: 451d60283fe5df426f023e824339f82c2900311e +``` + +## Filter by issues [changelog-bundle-issues] + +You can use the `--issues` option to create a bundle of changelogs that relate to those GitHub issues. +Provide either a comma-separated list of issues (`--issues "https://github.com/owner/repo/issues/123,456"`) or a path to a newline-delimited file (`--issues /path/to/file.txt`). +Issues can be identified by a full URL (such as `https://github.com/owner/repo/issues/123`), a short format (such as `owner/repo#123`), or just a number (in which case `--owner` and `--repo` are required — or set via `bundle.owner` and `bundle.repo` in the configuration). + +```sh +docs-builder changelog bundle --issues "12345,12346" \ + --repo elasticsearch \ + --owner elastic \ + --output-products "elasticsearch 9.2.2 ga" +``` + +## Filter by pull request or issue file [changelog-bundle-file] + +If you have a file that lists pull requests (such as PRs associated with a GitHub release), you can pass it to `--prs`. +For example, if you have a file that contains full pull request URLs like this: + +```txt +https://github.com/elastic/elasticsearch/pull/108875 +https://github.com/elastic/elasticsearch/pull/135873 +https://github.com/elastic/elasticsearch/pull/136886 +https://github.com/elastic/elasticsearch/pull/137126 +``` + +You can use the `--prs` option with the file path to create a bundle of the changelogs that relate to those pull requests. +You can also combine multiple `--prs` options: + +```sh +./docs-builder changelog bundle \ + --prs "https://github.com/elastic/elasticsearch/pull/108875,135873" \ <1> + --prs test/9.2.2.txt \ <2> + --output-products "elasticsearch 9.2.2 ga" <3> + --resolve <4> +``` + +1. Comma-separated list of pull request URLs or numbers. +2. The path for the file that lists the pull requests. If the file contains only PR numbers, you must add `--repo` and `--owner` command options. +3. The product metadata for the bundle. If it is not provided, it will be derived from all the changelog product values. +4. Optionally include the contents of each changelog in the output file. + +:::{tip} +You can use these files with profile-based bundling too. Refer to [](/cli/changelog/bundle.md). +::: + +If you have changelog files that reference those pull requests, the command creates a file like this: + +```yaml +products: +- product: elasticsearch + target: 9.2.2 + lifecycle: ga +entries: +- file: + name: 1765507778-break-on-fielddata-when-building-global-ordinals.yaml + checksum: 70d197d96752c05b6595edffe6fe3ba3d055c845 + type: bug-fix + title: Break on FieldData when building global ordinals + products: + - product: elasticsearch + areas: + - Aggregations + prs: + - https://github.com/elastic/elasticsearch/pull/108875 +... +``` + +:::{note} +When a changelog matches multiple `--input-products` filters, it appears only once in the bundle. This deduplication applies even when using `--all` or `--prs`. +::: + +## Filter by GitHub release notes [changelog-bundle-release-version] + +If you have GitHub releases with automated release notes (the default format or [Release Drafter](https://github.com/release-drafter/release-drafter) format), you can use the `--release-version` option to derive the PR list from those release notes. +For example: + +```sh +docs-builder changelog bundle \ + --release-version v1.34.0 \ + --repo apm-agent-dotnet --owner elastic <1> +``` + +1. The repo and repo owner are used to fetch the release and follow these rules of precedence: + +- Repo: `--repo` flag > `bundle.repo` in `changelog.yml` (one source is required) +- Owner: `--owner` flag > `bundle.owner` in `changelog.yml` > `elastic` + +This command creates a bundle of changelogs that match the list of PRs found in the `v1.34.0` GitHub release notes. + +The bundle's product metadata is inferred automatically from the release tag and repository name; you can override that behavior with the `--output-products` option. + +:::{tip} +If you are not creating changelogs when you create your pull requests, consider the `docs-builder changelog gh-release` command as a one-shot alternative to the `changelog add` and `changelog bundle` commands. +It parses the release notes, creates one changelog file per pull request found, and creates a `changelog-bundle.yaml` file — all in a single step. Refer to [](/cli/changelog/gh-release.md) +::: + +## Hide features [changelog-bundle-hide-features] + +You can use the `--hide-features` option to embed feature IDs that should be hidden when the bundle is rendered. This is useful for features that are not yet ready for public documentation. + +```sh +docs-builder changelog bundle \ + --input-products "elasticsearch 9.3.0 *" \ + --hide-features "feature:hidden-api,feature:experimental" \ <1> + --output /path/to/bundles/9.3.0.yaml +``` + +1. Feature IDs to hide. Changelogs with matching `feature-id` values will be commented out when rendered. + + + +The bundle output will include a `hide-features` field: + +```yaml +products: +- product: elasticsearch + target: 9.3.0 +hide-features: + - feature:hidden-api + - feature:experimental +entries: +- file: + name: 1765495972-new-feature.yaml + checksum: 6c3243f56279b1797b5dfff6c02ebf90b9658464 +``` + +When this bundle is rendered (either via the `changelog render` command or the `{changelog}` directive), changelogs with `feature-id` values matching any of the listed features will be commented out in the output. + +:::{note} +The `--hide-features` option on the `render` command and the `hide-features` field in bundles are **combined**. If you specify `--hide-features` on both the `bundle` and `render` commands, all specified features are hidden. The `{changelog}` directive automatically reads `hide-features` from all loaded bundles and applies them. +::: + +## Hide private links + +A changelog can reference multiple pull requests and issues in the `prs` and `issues` array fields. + +To comment out links that are not in your allowlist in all changelogs in your bundles, refer to [changelog bundle](/cli/changelog/bundle.md). + +If you are working in a private repo and do not want any pull request or issue links to appear (even if they target a public repo), you also have the option to configure link visibiblity in the [changelog directive](/syntax/changelog.md) and [changelog render](/cli/changelog/render.md) command. + +:::{tip} +You must run the `docs-builder changelog bundle` command with the `--resolve` option or set `bundle.resolve` to `true` in the changelog configuration file (so that bundle files are self-contained) in order to hide the private links. +::: ## Amend bundles [changelog-bundle-amend] diff --git a/docs/contribute/create-changelogs.md b/docs/contribute/create-changelogs.md index 6ce147d8fe..3d08b10790 100644 --- a/docs/contribute/create-changelogs.md +++ b/docs/contribute/create-changelogs.md @@ -62,7 +62,7 @@ When automated via the [changelog GitHub Actions](https://github.com/elastic/doc The `description` output from step 1 contains the release note extracted from the PR body (when `extract.release_notes` is enabled). If extraction is disabled (either by setting `extract.release_notes: false` in `changelog.yml` or by passing `--no-extract-release-notes` to `changelog add`), the `CHANGELOG_DESCRIPTION` environment variable is ignored and the extracted description is not written to the changelog. -Refer to [CI auto-detection](/cli/changelog/add.md#ci-auto-detection) for the full list of environment variables and precedence rules. +Refer to [CI auto-detection](/cli/changelog/add.md) for the full list of environment variables and precedence rules. ## Review the content [review] @@ -70,7 +70,7 @@ Refer to [CI auto-detection](/cli/changelog/add.md#ci-auto-detection) for the fu You can specify the file location with command options (`--output`) or configuration options (`bundle.directory`). Likewise you can control the file names with command options (`--use-issue-number` or `--use-pr-number`) or the `filename` configuration option. - Refer to the [Filenames](/cli/changelog/add.md#filenames). + Refer to the [Filenames](/cli/changelog/add.md). 1. Verify that the files contain content that is accurate and user-friendly. This review is especially important when you're pulling content from GitHub, since there might be some missing or extraneous information. diff --git a/docs/syntax/changelog.md b/docs/syntax/changelog.md index e102681b23..60739c657b 100644 --- a/docs/syntax/changelog.md +++ b/docs/syntax/changelog.md @@ -160,9 +160,10 @@ Both explicit and auto-discovered paths must resolve within the repository check You can filter changelog entries at bundle time using the `rules.bundle` configuration in your `changelog.yml` file. This is evaluated during `changelog bundle` and `changelog gh-release`, before the bundle is written. Entries that don't match are excluded from the bundle entirely. -The `{changelog}` directive and the `changelog render` command both do not apply `rules.publish`. To filter entries, use `rules.bundle` at bundle time so entries are excluded before bundling. Both receive only the bundled entries. +The `{changelog}` directive and the `changelog render` command both do not apply `rules.publish`. To filter entries, use `rules.bundle` at bundle time so entries are excluded before bundling. Both receive only the bundled entries. See the [changelog bundle documentation](/cli/changelog/bundle.md) for full syntax. + `rules.bundle` supports product, type, and area filtering, and per-product overrides. -For full syntax, refer to the [](/contribute/configure-changelogs-ref.md#rules-bundle). +For full syntax, refer to the [rules for filtered bundles](/cli/changelog/bundle.md). ## Hiding features diff --git a/docs/syntax/directives.md b/docs/syntax/directives.md index 556953a045..b3754b6c6e 100644 --- a/docs/syntax/directives.md +++ b/docs/syntax/directives.md @@ -75,6 +75,7 @@ The following directives are available: - [Include](file_inclusion.md) - Include content from other files - [List sub-pages](list-sub-pages.md) - List sibling pages in the current section - [Math](math.md) - Mathematical expressions and equations +- [Page cards](page-card.md) - Full-width clickable navigation rows - [Settings](automated_settings.md) - Configuration blocks - [Stepper](stepper.md) - Step-by-step content - [Tabs](tabs.md) - Tabbed content organization diff --git a/docs/syntax/page-card.md b/docs/syntax/page-card.md new file mode 100644 index 0000000000..133da106e2 --- /dev/null +++ b/docs/syntax/page-card.md @@ -0,0 +1,119 @@ +# Page cards + +Page cards are full-width, clickable navigation rows that link to another page in the docs. Use them to build index-style landing pages where you want each destination to be visually prominent and immediately navigable. + +:::{page-card} [Admonitions](admonitions.md) +Callout boxes for notes, warnings, tips, and other asides. +::: + +## Basic usage + +A page card takes a single Markdown link as its argument. The description body is optional. + +:::::::{tab-set} +::::::{tab-item} Output +:::{page-card} [Code blocks](code.md) +Syntax-highlighted code with copy button, callouts, and console output support. +::: +:::::: + +::::::{tab-item} Markdown +```markdown +:::{page-card} [Code blocks](code.md) +Syntax-highlighted code with copy button, callouts, and console output support. +::: +``` +:::::: +::::::: + +## Without a description + +:::::::{tab-set} +::::::{tab-item} Output +:::{page-card} [Tables](tables.md) +::: +:::::: + +::::::{tab-item} Markdown +```markdown +:::{page-card} [Tables](tables.md) +::: +``` +:::::: +::::::: + +## Stacking cards + +Consecutive page cards stack into a list automatically — no container directive required. + +:::::::{tab-set} +::::::{tab-item} Output +:::{page-card} [Admonitions](admonitions.md) +Callout boxes for notes, warnings, tips, and important asides. +::: + +:::{page-card} [Tabs](tabs.md) +Organise related content into selectable tab panels. +::: + +:::{page-card} [Stepper](stepper.md) +Step-by-step instructions with numbered visual progression. +::: +:::::: + +::::::{tab-item} Markdown +```markdown +:::{page-card} [Admonitions](admonitions.md) +Callout boxes for notes, warnings, tips, and important asides. +::: + +:::{page-card} [Tabs](tabs.md) +Organise related content into selectable tab panels. +::: + +:::{page-card} [Stepper](stepper.md) +Step-by-step instructions with numbered visual progression. +::: +``` +:::::: +::::::: + +## Link types + +### Local links + +The most common use — a relative path to another `.md` file in the same documentation set: + +```markdown +:::{page-card} [Configuration](./configuration.md) +How to configure contexts and credentials. +::: +``` + +### Cross-repository links + +Page cards support [cross-repository links](links.md#cross-repository-links) using the `scheme://path` syntax: + +```markdown +:::{page-card} [Getting Started](docs-content://get-started/introduction.md) +Learn the basics of the Elastic Stack. +::: +``` + +### Absolute URLs are not allowed + +Page cards are for in-docs navigation only. Absolute `http://` or `https://` URLs are rejected at build time: + +```markdown +:::{page-card} [Elastic website](https://elastic.co) ← build error +::: +``` + +Use a standard Markdown link or a [button](buttons.md) for external destinations. + +## Reference + +| Part | Required | Description | +|------|----------|-------------| +| Argument | Yes | A Markdown link `[Title](url)` — title becomes the card heading, url must be a local `.md` path or a crosslink. | +| Body | No | One or more lines of description text rendered below the title. | diff --git a/src/Elastic.Documentation.Configuration/FileSystemFactory.cs b/src/Elastic.Documentation.Configuration/FileSystemFactory.cs index 4af7c9814b..9f8b55b4cc 100644 --- a/src/Elastic.Documentation.Configuration/FileSystemFactory.cs +++ b/src/Elastic.Documentation.Configuration/FileSystemFactory.cs @@ -68,18 +68,23 @@ public static class FileSystemFactory public static ScopedFileSystem InMemory() => new(new MockFileSystem(), WorkingDirectoryReadOptions); /// - /// Creates a new wrapping a fresh , - /// scoped to the git root of so that paths such as - /// {sourceRoot}/.artifacts/docs/html are within the allowed write scope. - /// Falls back to when is . + /// Like but additionally scopes the mock filesystem to 's + /// git root. Use when serving docs from a directory outside the current working tree so that the + /// in-memory output path (<source>/.artifacts/docs/html) passes scope validation. /// - public static ScopedFileSystem InMemoryForSourceRoot(string? sourcePath) + public static ScopedFileSystem InMemoryForPath(string? path) { - if (sourcePath is null) + if (path is null) + return InMemory(); + var root = Paths.FindGitRoot(path); + if (root == Paths.WorkingDirectoryRoot.FullName) return InMemory(); - var root = Paths.FindGitRoot(sourcePath); - var inner = new MockFileSystem(); - return new ScopedFileSystem(inner, BuildWriteOptions(inner, root, Paths.ApplicationData.FullName)); + return new(new MockFileSystem(), new ScopedFileSystemOptions( + [Paths.WorkingDirectoryRoot.FullName, Paths.ApplicationData.FullName, root]) + { + AllowedHiddenFolderNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git", ".artifacts" }, + AllowedHiddenFileNames = new HashSet(StringComparer.OrdinalIgnoreCase) { ".git", ".doc.state" } + }); } /// diff --git a/src/Elastic.Documentation.Configuration/Toc/CliReference/CliReferenceRef.cs b/src/Elastic.Documentation.Configuration/Toc/CliReference/CliReferenceRef.cs new file mode 100644 index 0000000000..f67f023a2c --- /dev/null +++ b/src/Elastic.Documentation.Configuration/Toc/CliReference/CliReferenceRef.cs @@ -0,0 +1,21 @@ +// 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.Configuration.Toc.CliReference; + +/// +/// Represents a CLI reference entry in the table of contents, parsed from: +/// +/// - cli: schema/cli.json +/// folder: cli-reference/ +/// +/// +public record CliReferenceRef( + string SchemaPath, + string? SupplementalFolder, + string PathRelativeToDocumentationSet, + string PathRelativeToContainer, + string Context, + IReadOnlyCollection Children +) : ITableOfContentsItem; diff --git a/src/Elastic.Documentation.Configuration/Toc/CliReference/CliSchema.cs b/src/Elastic.Documentation.Configuration/Toc/CliReference/CliSchema.cs new file mode 100644 index 0000000000..d4324974ce --- /dev/null +++ b/src/Elastic.Documentation.Configuration/Toc/CliReference/CliSchema.cs @@ -0,0 +1,137 @@ +// 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.IO.Abstractions; +using System.Text.Json; + +namespace Elastic.Documentation.Configuration.Toc.CliReference; + +// Language-agnostic CLI schema spec (https://cli-schema.org) +// Schema v2: type uses JSON Schema primitives; v1 used kind-style strings +public record CliSchema( + int SchemaVersion, + string Name, + string? Description, + List GlobalOptions, + CliDefaultSchema? RootDefault, + List Commands, + List Namespaces, + string? Version = null, + string[]? ReservedMetaCommands = null, + string[]? Tags = null, + bool? RequiresAuth = null, + string[]? AuthCommands = null, + CliEnvironmentSchema? Environment = null +) +{ + public static CliSchema Load(IFileInfo schemaFile) + { + var json = schemaFile.FileSystem.File.ReadAllText(schemaFile.FullName); + return JsonSerializer.Deserialize(json, CliSchemaJsonContext.Default.CliSchema) + ?? throw new InvalidOperationException($"Failed to deserialize CLI schema from {schemaFile.FullName}"); + } +} + +public record CliCommandSchema( + string[] Path, + string Name, + string? Summary, + string? Notes, + string? Usage, + string[]? Examples, + List Parameters, + string[]? Aliases = null, + bool Hidden = false, + string[]? Tags = null, + CliDeprecatedSchema? Deprecated = null, + CliIntentSchema? Intent = null, + CliOutputSchema? Output = null, + bool Streaming = false, + bool LongRunning = false +); + +public record CliNamespaceSchema( + string Segment, + string? Summary, + string? Notes, + List Options, + CliDefaultSchema? DefaultCommand, + List Commands, + List Namespaces +); + +public record CliParamSchema( + string Role, + string Name, + string? ShortName, + // v2: type uses JSON Schema primitives ("string","integer","number","boolean","array","enum") + string Type, + bool Required, + string? Summary, + string? DefaultValue = null, + string[]? EnumValues = null, + string? ElementType = null, + bool Repeatable = false, + string? Separator = null, + string[]? Aliases = null, + bool Hidden = false, + bool Variadic = false, + CliDeprecatedSchema? Deprecated = null, + List? Validations = null +); + +public record CliDefaultSchema( + string? Summary, + string? Notes, + string? Usage, + string[]? Examples, + List Parameters, + string Kind = "", + bool Hidden = false +); + +public record CliValidationSchema( + string Kind, + string[]? Values = null, + string? Min = null, + string? Max = null, + string? Pattern = null +); + +public record CliDeprecatedSchema( + string? Message = null, + string? Since = null, + string? RemovedIn = null +); + +public record CliIntentSchema( + bool? Destructive = null, + bool? Idempotent = null, + string? Scope = null, + bool? RequiresConfirmation = null, + bool? RequiresAuth = null +); + +public record CliOutputSchema( + string[]? Formats = null, + string? FormatFlag = null +); + +public record CliEnvironmentSchema( + List? Variables = null, + List? ConfigFiles = null +); + +public record CliEnvVarSchema( + string Name, + string? Description = null, + bool Required = false, + string? DefaultValue = null +); + +public record CliConfigFileSchema( + string Path, + string? Description = null, + bool Required = false +); diff --git a/src/Elastic.Documentation.Configuration/Toc/CliReference/CliSchemaJsonContext.cs b/src/Elastic.Documentation.Configuration/Toc/CliReference/CliSchemaJsonContext.cs new file mode 100644 index 0000000000..672aa7bff1 --- /dev/null +++ b/src/Elastic.Documentation.Configuration/Toc/CliReference/CliSchemaJsonContext.cs @@ -0,0 +1,11 @@ +// 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.Text.Json.Serialization; + +namespace Elastic.Documentation.Configuration.Toc.CliReference; + +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +[JsonSerializable(typeof(CliSchema))] +internal sealed partial class CliSchemaJsonContext : JsonSerializerContext; diff --git a/src/Elastic.Documentation.Configuration/Toc/DocumentationSetFile.cs b/src/Elastic.Documentation.Configuration/Toc/DocumentationSetFile.cs index 8c4bf60647..3da8f21cd6 100644 --- a/src/Elastic.Documentation.Configuration/Toc/DocumentationSetFile.cs +++ b/src/Elastic.Documentation.Configuration/Toc/DocumentationSetFile.cs @@ -4,6 +4,7 @@ using System.IO.Abstractions; using Elastic.Documentation.Configuration.Products; +using Elastic.Documentation.Configuration.Toc.CliReference; using Elastic.Documentation.Configuration.Toc.DetectionRules; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.Extensions; @@ -82,6 +83,8 @@ public static FileRef[] GetFileRefs(ITableOfContentsItem item) return tocRef.Children.SelectMany(GetFileRefs).ToArray(); if (item is CrossLinkRef) return []; + if (item is CliReferenceRef cliRef2) + return cliRef2.Children.SelectMany(GetFileRefs).ToArray(); throw new Exception($"Unexpected item type {item.GetType().Name}"); } @@ -163,6 +166,7 @@ private static TableOfContents ResolveTableOfContents( { IsolatedTableOfContentsRef tocRef => ResolveIsolatedToc(collector, tocRef, baseDirectory, fileSystem, parentPath, containerPath, context, suppressDiagnostics), DetectionRuleOverviewRef ruleOverviewReference => ResolveRuleOverviewReference(collector, ruleOverviewReference, baseDirectory, fileSystem, parentPath, containerPath, context, suppressDiagnostics), + CliReferenceRef cliRef => ResolveCliReference(collector, cliRef, baseDirectory, fileSystem, parentPath, containerPath, context), FileRef fileRef => ResolveFileRef(collector, fileRef, baseDirectory, fileSystem, parentPath, containerPath, context, suppressDiagnostics), FolderRef folderRef => ResolveFolderRef(collector, folderRef, baseDirectory, fileSystem, parentPath, containerPath, context, suppressDiagnostics), CrossLinkRef crossLink => ResolveCrossLinkRef(collector, crossLink, baseDirectory, fileSystem, parentPath, containerPath, context), @@ -484,6 +488,66 @@ private static ITableOfContentsItem ResolveRuleOverviewReference(IDiagnosticsCol } + private static ITableOfContentsItem? ResolveCliReference( + IDiagnosticsCollector collector, + CliReferenceRef cliRef, + IDirectoryInfo baseDirectory, + IFileSystem fileSystem, + string parentPath, + string containerPath, + string context) + { + // Resolve schema path relative to docset root (context-relative for paths with '/') + string schemaFullPath; + if (cliRef.SchemaPath.Contains('/')) + { + var contextDir = fileSystem.Path.GetDirectoryName(context) ?? ""; + var contextRelativePath = fileSystem.Path.GetRelativePath(baseDirectory.FullName, contextDir); + if (contextRelativePath == ".") + contextRelativePath = ""; + schemaFullPath = string.IsNullOrEmpty(contextRelativePath) + ? cliRef.SchemaPath + : $"{contextRelativePath}/{cliRef.SchemaPath}"; + } + else + { + schemaFullPath = string.IsNullOrEmpty(parentPath) + ? cliRef.SchemaPath + : $"{parentPath}/{cliRef.SchemaPath}"; + } + + var schemaFileInfo = fileSystem.FileInfo.New(fileSystem.Path.Join(baseDirectory.FullName, schemaFullPath)); + if (!schemaFileInfo.Exists) + { + collector.EmitError(context, $"CLI schema file not found: {cliRef.SchemaPath}"); + return null; + } + + // Derive virtual root: use SupplementalFolder if set, otherwise schema path without extension + var virtualRoot = cliRef.SupplementalFolder is not null + ? cliRef.SupplementalFolder.TrimEnd('/') + : Path.ChangeExtension(schemaFullPath, null); + + var fullVirtualRoot = string.IsNullOrEmpty(parentPath) ? virtualRoot : $"{parentPath}/{virtualRoot}"; + var pathRelativeToContainer = string.IsNullOrEmpty(containerPath) + ? fullVirtualRoot + : fullVirtualRoot[(containerPath.Length + 1)..]; + + if (cliRef.SupplementalFolder is not null) + { + var supplementalDirPath = fileSystem.Path.Join(baseDirectory.FullName, cliRef.SupplementalFolder); + if (!fileSystem.Directory.Exists(supplementalDirPath)) + collector.EmitWarning(context, $"CLI supplemental docs folder not found: {cliRef.SupplementalFolder}"); + } + + // Resolve explicit children (regular docs + namespace/folder refs) relative to the virtual root + var resolvedChildren = cliRef.Children.Count > 0 + ? ResolveTableOfContents(collector, cliRef.Children, baseDirectory, fileSystem, fullVirtualRoot, containerPath, context) + : []; + + return new CliReferenceRef(schemaFullPath, cliRef.SupplementalFolder, fullVirtualRoot, pathRelativeToContainer, context, resolvedChildren); + } + /// /// Resolves a FolderRef by prepending the parent path to the folder path and recursively resolving children. /// If no children are defined, auto-discovers .md files in the folder directory. diff --git a/src/Elastic.Documentation.Configuration/Toc/TableOfContentsYamlConverters.cs b/src/Elastic.Documentation.Configuration/Toc/TableOfContentsYamlConverters.cs index 3cc11c7516..664c18085b 100644 --- a/src/Elastic.Documentation.Configuration/Toc/TableOfContentsYamlConverters.cs +++ b/src/Elastic.Documentation.Configuration/Toc/TableOfContentsYamlConverters.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 Elastic.Documentation.Configuration.Toc.CliReference; using Elastic.Documentation.Configuration.Toc.DetectionRules; using YamlDotNet.Core; using YamlDotNet.Core.Events; @@ -111,6 +112,14 @@ public class TocItemYamlConverter : IYamlTypeConverter // Capture exclude list for folder auto-discovery var exclude = dictionary.TryGetValue("exclude", out var excludeObj) && excludeObj is string[] excludeArr ? excludeArr : null; + // Check for CLI reference (cli: schema.json, optional folder: supplemental-docs/) + // Must come before the folder+file check to prevent folder: from being consumed by that branch + if (dictionary.TryGetValue("cli", out var cliSchemaPath) && cliSchemaPath is string cliSchema) + { + var supplementalFolder = dictionary.TryGetValue("folder", out var f) && f is string fStr ? fStr : null; + return new CliReferenceRef(cliSchema, supplementalFolder, cliSchema, cliSchema, placeholderContext, children); + } + // Check for folder+file combination (e.g., folder: getting-started, file: getting-started.md) // This represents a folder with a specific index file // The file becomes a child of the folder (as FolderIndexFileRef), and user-specified children follow diff --git a/src/Elastic.Documentation.Navigation/Isolated/Node/DocumentationSetNavigation.cs b/src/Elastic.Documentation.Navigation/Isolated/Node/DocumentationSetNavigation.cs index a211094019..b1746079a0 100644 --- a/src/Elastic.Documentation.Navigation/Isolated/Node/DocumentationSetNavigation.cs +++ b/src/Elastic.Documentation.Navigation/Isolated/Node/DocumentationSetNavigation.cs @@ -5,6 +5,7 @@ using System.Diagnostics; using System.IO.Abstractions; using Elastic.Documentation.Configuration.Toc; +using Elastic.Documentation.Configuration.Toc.CliReference; using Elastic.Documentation.Configuration.Toc.DetectionRules; using Elastic.Documentation.Extensions; using Elastic.Documentation.Links.CrossLinks; @@ -174,6 +175,7 @@ INavigationHomeAccessor homeAccessor CrossLinkRef crossLinkRef => CreateCrossLinkNavigation(crossLinkRef, index, parent, homeAccessor), FolderRef folderRef => CreateFolderNavigation(folderRef, index, context, parent, homeAccessor), IsolatedTableOfContentsRef tocRef => CreateTocNavigation(tocRef, index, context, parent, homeAccessor), + CliReferenceRef cliRef => CreateCliReferenceNavigation(cliRef, index, context, parent, homeAccessor), _ => null }; @@ -439,4 +441,169 @@ INavigationHomeAccessor homeAccessor return tocNavigation; } + private INavigationItem? CreateCliReferenceNavigation( + CliReferenceRef cliRef, + int index, + IDocumentationSetContext context, + INodeNavigationItem? parent, + INavigationHomeAccessor homeAccessor + ) + { + var schemaFileInfo = context.ReadFileSystem.FileInfo.New( + context.ReadFileSystem.Path.Join(context.DocumentationSourceDirectory.FullName, cliRef.SchemaPath)); + + CliSchema schema; + try + { + schema = CliSchema.Load(schemaFileInfo); + } + catch (Exception ex) + { + context.EmitError(context.ConfigurationPath, $"Failed to load CLI schema from {cliRef.SchemaPath}: {ex.Message}"); + return null; + } + + var virtualRoot = cliRef.PathRelativeToDocumentationSet; + var docSourceDir = context.DocumentationSourceDirectory.FullName; + + // Root folder navigation + var folderNavigation = new FolderNavigation(virtualRoot, parent, homeAccessor) { NavigationIndex = index }; + var children = new List(); + var childIndex = 0; + + // Root index file + var rootNav = MakeFileLeaf(docSourceDir, virtualRoot, [], isNamespace: true, childIndex++, folderNavigation, homeAccessor, context); + if (rootNav is not null) + children.Add(rootNav); + + // Explicit children are prepended before the schema-generated pages + foreach (var child in cliRef.Children) + { + var childNav = ConvertToNavigationItem(child, childIndex++, context, folderNavigation, homeAccessor); + if (childNav is not null) + children.Add(childNav); + } + + // All root commands + namespaces from the schema always follow + foreach (var cmd in schema.Commands) + { + var cmdNav = MakeFileLeaf(docSourceDir, virtualRoot, [cmd.Name], isNamespace: false, childIndex++, folderNavigation, homeAccessor, context); + if (cmdNav is not null) + children.Add(cmdNav); + } + foreach (var ns in schema.Namespaces) + { + var nsNav = BuildNamespaceNavigation(docSourceDir, virtualRoot, ns, [ns.Segment], childIndex++, folderNavigation, homeAccessor, context); + if (nsNav is not null) + children.Add(nsNav); + } + + if (children.Count == 0) + { + context.Collector.EmitError(cliRef.Context, $"CLI reference '{cliRef.SchemaPath}' produced no navigation items"); + return null; + } + + folderNavigation.SetNavigationItems(children); + return folderNavigation; + } + + private INavigationItem? BuildNamespaceNavigation( + string docSourceDir, + string virtualRoot, + CliNamespaceSchema ns, + string[] segments, + int index, + INodeNavigationItem parent, + INavigationHomeAccessor homeAccessor, + IDocumentationSetContext context + ) + { + // Create folder node for the namespace + var nsPath = string.Join("/", segments.Select(s => s)); + var nsFolderPath = $"{virtualRoot}/{nsPath}"; + var nsFolderNav = new FolderNavigation(nsFolderPath, parent, homeAccessor) { NavigationIndex = index }; + var children = new List(); + var childIndex = 0; + + // Namespace index file + var nsIndexNav = MakeFileLeaf(docSourceDir, virtualRoot, segments, isNamespace: true, childIndex++, nsFolderNav, homeAccessor, context); + if (nsIndexNav is not null) + children.Add(nsIndexNav); + + // Namespace commands + foreach (var cmd in ns.Commands) + { + var cmdSegments = segments.Append(cmd.Name).ToArray(); + var cmdNav = MakeFileLeaf(docSourceDir, virtualRoot, cmdSegments, isNamespace: false, childIndex++, nsFolderNav, homeAccessor, context); + if (cmdNav is not null) + children.Add(cmdNav); + } + + // Sub-namespaces + foreach (var subNs in ns.Namespaces) + { + var subSegments = segments.Append(subNs.Segment).ToArray(); + var subNav = BuildNamespaceNavigation(docSourceDir, virtualRoot, subNs, subSegments, childIndex++, nsFolderNav, homeAccessor, context); + if (subNav is not null) + children.Add(subNav); + } + + if (children.Count == 0) + return null; + + nsFolderNav.SetNavigationItems(children); + return nsFolderNav; + } + + private INavigationItem? MakeFileLeaf( + string docSourceDir, + string virtualRoot, + string[] segments, + bool isNamespace, + int index, + INodeNavigationItem parent, + INavigationHomeAccessor homeAccessor, + IDocumentationSetContext context + ) + { + // SyntheticPath in the extension now uses clean names (no cmd- prefix) + // so factory registration path and URL path are the same. + var syntheticPath = SyntheticRelativePath(virtualRoot, segments, isNamespace); + var absolutePath = Path.GetFullPath(Path.Join(docSourceDir, syntheticPath)); + var fileInfo = context.ReadFileSystem.FileInfo.New(absolutePath); + + var docFile = _factory.TryCreateDocumentationFile(fileInfo, context.ReadFileSystem); + if (docFile is null) + { + context.EmitError(context.ConfigurationPath, + $"CLI reference: could not create documentation file for '{syntheticPath}'"); + return null; + } + + var args = new FileNavigationArgs(syntheticPath, syntheticPath, false, index, parent, homeAccessor); + return DocumentationNavigationFactory.CreateFileNavigationLeaf(docFile, fileInfo, args); + } + + // Synthetic path — clean command names (no cmd- prefix) matching CliReferenceDocsBuilderExtension.SyntheticPath + private static string SyntheticRelativePath(string virtualRoot, string[] segments, bool isNamespace) + { + if (segments.Length == 0) + return $"{virtualRoot}/index.md"; + if (isNamespace) + { + var joined = string.Join("/", segments); + return $"{virtualRoot}/{joined}/index.md"; + } + else + { + // Keep cmd- prefix only for "index" commands to avoid collision with namespace index.md pages + var cmdName = segments[^1].Equals("index", StringComparison.OrdinalIgnoreCase) + ? $"cmd-{segments[^1]}" + : segments[^1]; + var parentPath = segments.Length > 1 ? string.Join("/", segments[..^1]) + "/" : string.Empty; + return $"{virtualRoot}/{parentPath}{cmdName}.md"; + } + } + } diff --git a/src/Elastic.Documentation.Site/Assets/styles.css b/src/Elastic.Documentation.Site/Assets/styles.css index 40eeccd25e..3d3edd26d9 100644 --- a/src/Elastic.Documentation.Site/Assets/styles.css +++ b/src/Elastic.Documentation.Site/Assets/styles.css @@ -420,3 +420,97 @@ math { scrollbar-width: thin; scrollbar-color: rgba(113, 134, 168, 0.5) transparent; } + +/* Heading anchor links — show # on hover */ +h1, +h2, +h3, +h4, +h5, +h6 { + position: relative; + + & .headerlink { + color: inherit; + text-decoration: none; + } + + & .headerlink::after { + content: '#'; + position: absolute; + right: calc(100% + 0.4rem); + top: 50%; + transform: translateY(-50%); + font-size: 0.85em; + font-weight: 400; + color: var(--color-grey-40, #9aa5b4); + opacity: 0; + transition: opacity 0.15s; + pointer-events: none; + } + + &:hover .headerlink::after, + & .headerlink:focus::after { + opacity: 1; + } +} + +/* Definition term anchor links — whole term is the link, # appears on the left on hover */ +dl dt[id] { + & a.paramlink { + color: inherit; + text-decoration: none; + position: relative; + + &::before { + content: '#'; + position: absolute; + right: calc(100% + 0.35rem); + top: 50%; + transform: translateY(-50%); + font-size: 0.85em; + font-weight: 400; + color: var(--color-grey-40, #9aa5b4); + opacity: 0; + transition: opacity 0.15s; + pointer-events: none; + } + } + + &:hover a.paramlink::before, + & a.paramlink:focus::before { + opacity: 1; + } +} + +/* CLI page type badge in

    — right-aligned span, colored by type */ +h1:has(.cli-badge-ns, .cli-badge-cmd) { + display: flex; + align-items: center; +} + +.cli-badge-ns, +.cli-badge-cmd { + margin-left: auto; + flex-shrink: 0; + font-size: 0.34em; + font-family: var(--font-body, sans-serif); + font-weight: 700; + padding: 1px 6px; + border-radius: 3px; + letter-spacing: 0.04em; + text-transform: lowercase; + line-height: 1.5; +} + +.cli-badge-ns { + background: #f5f3ff; + color: #7c3aed; + border: 1px solid #ddd6fe; +} + +.cli-badge-cmd { + background: #fffbeb; + color: #b45309; + border: 1px solid #fde68a; +} diff --git a/src/Elastic.Documentation.Site/Navigation/_TocTreeNav.cshtml b/src/Elastic.Documentation.Site/Navigation/_TocTreeNav.cshtml index 050262b088..cbd1dbfaa5 100644 --- a/src/Elastic.Documentation.Site/Navigation/_TocTreeNav.cshtml +++ b/src/Elastic.Documentation.Site/Navigation/_TocTreeNav.cshtml @@ -6,6 +6,20 @@ @{ var isTopLevel = Model.Level == 0; } +@functions { + static (string? badge, string label) ParseNavTitle(string raw) + { + if (raw.StartsWith("[ns]", StringComparison.Ordinal)) return ("ns", raw[4..]); + if (raw.StartsWith("[cmd]", StringComparison.Ordinal)) return ("cmd", raw[5..]); + return (null, raw); + } + + // Inline styles to avoid Tailwind purge — very muted tints, subtle border for definition + const string NsStyle = "background:#f5f3ff;color:#7c3aed;border:1px solid #ddd6fe;"; + const string CmdStyle = "background:#fffbeb;color:#b45309;border:1px solid #fde68a;"; + + static string BadgeStyle(string? badge) => badge == "ns" ? NsStyle : badge == "cmd" ? CmdStyle : ""; +} @if (isTopLevel && !Model.IsGlobalAssemblyBuild && !Model.IsPrimaryNavEnabled && !Model.SubTree.Index.Hidden) { var idx = Model.SubTree.Index; @@ -32,13 +46,16 @@ } if (item is INodeNavigationItem { NavigationItems.Count: 0 } group) { + var (groupBadge, groupLabel) = ParseNavTitle(group.NavigationTitle);
  • - @group.NavigationTitle + @groupLabel + @if (groupBadge == "ns") { ns } + else if (groupBadge == "cmd") { cmd }
  • } @@ -46,13 +63,16 @@ { var g = folder; var allHidden = folder.NavigationItems.All(n => n.Hidden); + var (folderBadge, folderLabel) = ParseNavTitle(g.NavigationTitle);
  • - @(g.NavigationTitle) + class="sidebar-link pr-2 content-center @(isTopLevel ? "font-semibold" : "") group-[.current]/li:text-blue-elastic! flex items-center gap-1"> + @folderLabel + @if (folderBadge == "ns") { ns } + else if (folderBadge == "cmd") { cmd } @if (!allHidden) { @@ -95,13 +115,16 @@ else if (item is ILeafNavigationItem leaf) { var hasSameTopLevelGroup = true; + var (leafBadge, leafLabel) = ParseNavTitle(leaf.NavigationTitle);
  • - @leaf.NavigationTitle + @leafLabel + @if (leafBadge == "ns") { ns } + else if (leafBadge == "cmd") { cmd }
  • } diff --git a/src/Elastic.Markdown/Extensions/CliReference/CliCommandFile.cs b/src/Elastic.Markdown/Extensions/CliReference/CliCommandFile.cs new file mode 100644 index 0000000000..4438f1cccc --- /dev/null +++ b/src/Elastic.Markdown/Extensions/CliReference/CliCommandFile.cs @@ -0,0 +1,61 @@ +// 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.IO.Abstractions; +using Elastic.Documentation.Configuration; +using Elastic.Documentation.Configuration.Toc.CliReference; +using Elastic.Markdown.Myst; +using Markdig.Syntax; + +namespace Elastic.Markdown.Extensions.CliReference; + +public record CliCommandFile : IO.MarkdownFile +{ + private readonly CliCommandSchema _command; + private readonly IFileInfo? _supplementalDoc; + private readonly string? _binaryName; + + private readonly string[] _fullPath; + + public CliCommandFile( + IFileInfo sourceFile, + IDirectoryInfo rootPath, + MarkdownParser parser, + BuildContext build, + CliCommandSchema command, + IFileInfo? supplementalDoc, + string[]? fullPath = null, + string? binaryName = null + ) : base(sourceFile, rootPath, parser, build) + { + _command = command; + _supplementalDoc = supplementalDoc; + _fullPath = fullPath ?? [command.Name]; + _binaryName = binaryName; + Title = command.Name; + } + + public override string NavigationTitle => $"[cmd]{_command.Name}"; + + protected override Task GetMinimalParseDocumentAsync(Cancel ctx) + { + Title = _command.Name; + var markdown = BuildMarkdown(); + return Task.FromResult(MarkdownParser.MinimalParseStringAsync(markdown, SourceFile, null)); + } + + protected override Task GetParseDocumentAsync(Cancel ctx) + { + var markdown = BuildMarkdown(); + return Task.FromResult(MarkdownParser.ParseStringAsync(markdown, SourceFile, null)); + } + + private string BuildMarkdown() + { + var supplemental = _supplementalDoc?.Exists == true + ? _supplementalDoc.FileSystem.File.ReadAllText(_supplementalDoc.FullName) + : null; + return CliMarkdownGenerator.CommandPage(_command, supplemental, _fullPath, _binaryName); + } +} diff --git a/src/Elastic.Markdown/Extensions/CliReference/CliMarkdownGenerator.cs b/src/Elastic.Markdown/Extensions/CliReference/CliMarkdownGenerator.cs new file mode 100644 index 0000000000..6e3b189041 --- /dev/null +++ b/src/Elastic.Markdown/Extensions/CliReference/CliMarkdownGenerator.cs @@ -0,0 +1,661 @@ +// 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.Text; +using System.Text.RegularExpressions; +using Elastic.Documentation.Configuration.Toc.CliReference; + +namespace Elastic.Markdown.Extensions.CliReference; + +internal static partial class CliMarkdownGenerator +{ + public static string RootPage(CliSchema schema, string? supplementalContent) + { + var sb = new StringBuilder(); + _ = sb.AppendLine($"# {schema.Name}"); + _ = sb.AppendLine(); + + if (supplementalContent is not null) + _ = sb.AppendLine(supplementalContent.Trim()); + else if (!string.IsNullOrWhiteSpace(schema.Description)) + _ = sb.AppendLine(schema.Description.Trim()); + + _ = sb.AppendLine(); + + if (schema.GlobalOptions.Count > 0) + { + _ = sb.AppendLine("## Global Options"); + _ = sb.AppendLine(); + AppendParameters(sb, schema.GlobalOptions); + } + + var visibleCommands = schema.Commands.Where(c => !c.Hidden).ToList(); + if (visibleCommands.Count > 0) + { + _ = sb.AppendLine("## Commands"); + _ = sb.AppendLine(); + foreach (var cmd in visibleCommands) + AppendPageCard(sb, cmd.Name, $"./{CommandPath(cmd.Name)}.md", cmd.Summary); + } + + if (schema.Namespaces.Count > 0) + { + _ = sb.AppendLine("## Namespaces"); + _ = sb.AppendLine(); + foreach (var ns in schema.Namespaces) + AppendPageCard(sb, ns.Segment, $"./{ns.Segment}/index.md", ns.Summary); + } + + if (schema.Environment?.Variables is { Count: > 0 } envVars) + { + _ = sb.AppendLine("## Environment Variables"); + _ = sb.AppendLine(); + foreach (var v in envVars) + { + var required = v.Required ? " **required**" : string.Empty; + _ = sb.AppendLine($"`{v.Name}`{required}"); + _ = sb.AppendLine($": {v.Description?.Trim() ?? string.Empty}"); + if (!string.IsNullOrWhiteSpace(v.DefaultValue)) + { + _ = sb.AppendLine(); + _ = sb.AppendLine($" **Default:** `{v.DefaultValue.Trim()}`"); + } + _ = sb.AppendLine(); + } + } + + if (schema.Environment?.ConfigFiles is { Count: > 0 } configFiles) + { + _ = sb.AppendLine("## Configuration Files"); + _ = sb.AppendLine(); + foreach (var f in configFiles) + { + var required = f.Required ? " **required**" : string.Empty; + _ = sb.AppendLine($"`{f.Path}`{required}"); + _ = sb.AppendLine($": {f.Description?.Trim() ?? string.Empty}"); + _ = sb.AppendLine(); + } + } + + return sb.ToString(); + } + + public static string NamespacePage(CliNamespaceSchema ns, string? supplementalContent, string[]? fullPath = null, string? binaryName = null) + { + var sb = new StringBuilder(); + var heading = fullPath is { Length: > 0 } ? string.Join(" ", fullPath) : ns.Segment; + _ = sb.AppendLine($"# {heading} cli namespace"); + _ = sb.AppendLine(); + + // Usage codeblock: binary full-path --help + _ = sb.AppendLine("```bash"); + _ = sb.AppendLine($"{binaryName ?? heading} {heading} --help"); + _ = sb.AppendLine("```"); + _ = sb.AppendLine(); + + if (supplementalContent is not null) + _ = sb.AppendLine(supplementalContent.Trim()); + else if (!string.IsNullOrWhiteSpace(ns.Summary)) + _ = sb.AppendLine(ns.Summary.Trim()); + + _ = sb.AppendLine(); + + if (ns.DefaultCommand is { Hidden: false } defaultCmd) + AppendDefaultCommand(sb, defaultCmd, fullPath, binaryName); + + var visibleCmds = ns.Commands.Where(c => !c.Hidden).ToList(); + if (visibleCmds.Count > 0) + { + _ = sb.AppendLine("## Commands"); + _ = sb.AppendLine(); + foreach (var cmd in visibleCmds) + AppendPageCard(sb, cmd.Name, $"./{CommandPath(cmd.Name)}.md", cmd.Summary); + } + + if (ns.Namespaces.Count > 0) + { + _ = sb.AppendLine("## Sub-namespaces"); + _ = sb.AppendLine(); + foreach (var sub in ns.Namespaces) + AppendPageCard(sb, sub.Segment, $"./{sub.Segment}/index.md", sub.Summary); + } + + if (ns.Options.Count > 0) + { + _ = sb.AppendLine("## Namespace Flags"); + _ = sb.AppendLine(); + AppendParameters(sb, ns.Options); + } + + if (!string.IsNullOrWhiteSpace(ns.Notes)) + { + _ = sb.AppendLine("## Notes"); + _ = sb.AppendLine(); + _ = sb.AppendLine(ns.Notes.Trim()); + _ = sb.AppendLine(); + } + + return sb.ToString(); + } + + public static string CommandPage(CliCommandSchema cmd, string? supplementalContent, string[]? fullPath = null, string? binaryName = null) + { + var sb = new StringBuilder(); + var heading = fullPath is { Length: > 0 } ? string.Join(" ", fullPath) : cmd.Name; + _ = sb.AppendLine($"# {heading} cli command"); + _ = sb.AppendLine(); + + var usage = !string.IsNullOrWhiteSpace(cmd.Usage) + ? cmd.Usage + : GenerateUsage(cmd, fullPath, binaryName); + + _ = sb.AppendLine("```bash"); + _ = sb.AppendLine(FormatUsage(usage)); + _ = sb.AppendLine("```"); + _ = sb.AppendLine(); + + AppendCommandCallouts(sb, cmd); + + if (supplementalContent is not null) + _ = sb.AppendLine(supplementalContent.Trim()); + else if (!string.IsNullOrWhiteSpace(cmd.Summary)) + _ = sb.AppendLine(CleanSummary(cmd.Summary).description.Trim()); + + _ = sb.AppendLine(); + + if (cmd.Parameters.Count > 0) + { + var positionals = cmd.Parameters.Where(p => p.Role == "positional").ToList(); + var flags = cmd.Parameters.Where(p => p.Role != "positional").ToList(); + + if (positionals.Count > 0) + { + _ = sb.AppendLine("## Arguments"); + _ = sb.AppendLine(); + AppendParameters(sb, positionals); + } + + if (flags.Count > 0) + { + _ = sb.AppendLine("## Options"); + _ = sb.AppendLine(); + AppendParameters(sb, flags); + } + } + + if (cmd.Examples is { Length: > 0 }) + { + _ = sb.AppendLine("## Examples"); + _ = sb.AppendLine(); + foreach (var example in cmd.Examples) + { + if (string.IsNullOrWhiteSpace(example)) + continue; + _ = sb.AppendLine("```"); + _ = sb.AppendLine(example.Trim()); + _ = sb.AppendLine("```"); + _ = sb.AppendLine(); + } + } + + if (!string.IsNullOrWhiteSpace(cmd.Notes)) + { + _ = sb.AppendLine("## Notes"); + _ = sb.AppendLine(); + _ = sb.AppendLine(cmd.Notes.Trim()); + _ = sb.AppendLine(); + } + + return sb.ToString(); + } + + private static void AppendCommandCallouts(StringBuilder sb, CliCommandSchema cmd) + { + if (cmd.Deprecated is not null) + { + var parts = new List { "**Deprecated**" }; + if (!string.IsNullOrWhiteSpace(cmd.Deprecated.Since)) + parts.Add($"since {cmd.Deprecated.Since}"); + if (!string.IsNullOrWhiteSpace(cmd.Deprecated.Message)) + parts.Add(cmd.Deprecated.Message.Trim().TrimEnd('.')); + if (!string.IsNullOrWhiteSpace(cmd.Deprecated.RemovedIn)) + parts.Add($"Removed in: {cmd.Deprecated.RemovedIn}"); + _ = sb.AppendLine(":::{warning}"); + _ = sb.AppendLine(string.Join(". ", parts) + "."); + _ = sb.AppendLine(":::"); + _ = sb.AppendLine(); + } + + if (cmd.Intent?.Destructive == true) + { + _ = sb.AppendLine(":::{warning}"); + _ = sb.AppendLine("**Destructive operation** — changes made by this command cannot be undone."); + _ = sb.AppendLine(":::"); + _ = sb.AppendLine(); + } + + var notes = new List(); + if (cmd.LongRunning) + notes.Add("This command may take a long time to complete."); + if (cmd.Streaming) + notes.Add("This command streams output continuously until stopped."); + if (cmd.Intent?.RequiresAuth == true) + notes.Add("Authentication is required."); + if (cmd.Intent?.RequiresConfirmation == true) + notes.Add("This command prompts for confirmation before proceeding."); + if (!string.IsNullOrWhiteSpace(cmd.Intent?.Scope)) + notes.Add($"Scope: `{cmd.Intent.Scope}`."); + + foreach (var note in notes) + { + _ = sb.AppendLine($"> {note}"); + _ = sb.AppendLine(); + } + + if (cmd.Output?.Formats is { Length: > 0 } formats) + { + _ = sb.AppendLine($"**Output formats:** {string.Join(", ", formats)}"); + _ = sb.AppendLine(); + } + } + + private static void AppendDefaultCommand(StringBuilder sb, CliDefaultSchema defaultCmd, string[]? fullPath, string? binaryName) + { + _ = sb.AppendLine("## Running without a subcommand"); + _ = sb.AppendLine(); + + if (!string.IsNullOrWhiteSpace(defaultCmd.Summary)) + { + _ = sb.AppendLine(defaultCmd.Summary.Trim()); + _ = sb.AppendLine(); + } + + var usageParts = new List(); + if (!string.IsNullOrWhiteSpace(binaryName)) + usageParts.Add(binaryName); + if (fullPath is { Length: > 0 }) + usageParts.AddRange(fullPath); + + var usageLine = !string.IsNullOrWhiteSpace(defaultCmd.Usage) + ? defaultCmd.Usage + : string.Join(" ", usageParts) + " [options]"; + + _ = sb.AppendLine("```bash"); + _ = sb.AppendLine(FormatUsage(usageLine)); + _ = sb.AppendLine("```"); + _ = sb.AppendLine(); + + if (defaultCmd.Parameters.Count > 0) + { + var positionals = defaultCmd.Parameters.Where(p => p.Role == "positional").ToList(); + var flags = defaultCmd.Parameters.Where(p => p.Role != "positional").ToList(); + + if (positionals.Count > 0) + { + _ = sb.AppendLine("### Arguments"); + _ = sb.AppendLine(); + AppendParameters(sb, positionals); + } + + if (flags.Count > 0) + { + _ = sb.AppendLine("### Options"); + _ = sb.AppendLine(); + AppendParameters(sb, flags); + } + } + + if (defaultCmd.Examples is { Length: > 0 }) + { + _ = sb.AppendLine("### Examples"); + _ = sb.AppendLine(); + foreach (var example in defaultCmd.Examples) + { + if (string.IsNullOrWhiteSpace(example)) + continue; + _ = sb.AppendLine("```"); + _ = sb.AppendLine(example.Trim()); + _ = sb.AppendLine("```"); + _ = sb.AppendLine(); + } + } + + if (!string.IsNullOrWhiteSpace(defaultCmd.Notes)) + { + _ = sb.AppendLine("### Notes"); + _ = sb.AppendLine(); + _ = sb.AppendLine(defaultCmd.Notes.Trim()); + _ = sb.AppendLine(); + } + } + + // Commands named "index" keep cmd- prefix to avoid collision with namespace index.md pages + private static string CommandPath(string name) => + name.Equals("index", StringComparison.OrdinalIgnoreCase) ? $"cmd-{name}" : name; + + private static void AppendPageCard(StringBuilder sb, string title, string url, string? summary) + { + var description = string.IsNullOrWhiteSpace(summary) ? string.Empty : CleanSummary(summary).description.Trim(); + _ = sb.AppendLine(":::{page-card} [" + title + "](" + url + ")"); + if (!string.IsNullOrEmpty(description)) + _ = sb.AppendLine(description); + _ = sb.AppendLine(":::"); + _ = sb.AppendLine(); + } + + private static void AppendParameters(StringBuilder sb, IEnumerable parameters) + { + foreach (var p in parameters.Where(p => p.Name != "_" && !p.Hidden)) + { + var isBool = IsBoolFlag(p.Type); + var flagName = FormatFlagName(p); + var typeHint = isBool ? string.Empty : $" `{FormatTypeHint(p)}`"; + var requiredMarker = p.Required ? " **required**" : string.Empty; + + _ = sb.AppendLine($"{flagName}{typeHint}{requiredMarker}"); + + // v2: summary may still embed "Values:" / "Default:" for legacy generators; + // prefer dedicated fields (EnumValues, DefaultValue) when present. + var (description, legacyValues, legacySummaryDefault) = CleanSummary(p.Summary); + + var descLine = description.Trim(); + + // Annotate special roles inline + if (p.Role == "confirmationSkip") + descLine = string.IsNullOrEmpty(descLine) + ? "Pass to skip the confirmation prompt." + : descLine + " (pass to skip the confirmation prompt)"; + else if (p.Role == "dryRun") + descLine = string.IsNullOrEmpty(descLine) + ? "Preview changes without applying them." + : descLine + " (preview changes without applying them)"; + + _ = sb.AppendLine($": {descLine}"); + + // Deprecated parameter + if (p.Deprecated is not null) + { + var parts = new List { "**Deprecated**" }; + if (!string.IsNullOrWhiteSpace(p.Deprecated.Since)) + parts.Add($"since {p.Deprecated.Since}"); + if (!string.IsNullOrWhiteSpace(p.Deprecated.Message)) + parts.Add(p.Deprecated.Message.Trim().TrimEnd('.')); + if (!string.IsNullOrWhiteSpace(p.Deprecated.RemovedIn)) + parts.Add($"Removed in: {p.Deprecated.RemovedIn}"); + _ = sb.AppendLine(); + _ = sb.AppendLine($" {string.Join(". ", parts)}."); + } + + // Enum values: prefer schema EnumValues, fall back to legacy embedded text + var values = p.EnumValues is { Length: > 0 } + ? string.Join(", ", p.EnumValues) + : legacyValues; + + if (!string.IsNullOrWhiteSpace(values)) + { + _ = sb.AppendLine(); + _ = sb.AppendLine($" **Values:** {values.Trim()}"); + } + + // Default: prefer dedicated schema field, fall back to legacy embedded text + // Skip "default" as a literal value — argh emits this for nullable booleans with no meaningful default + var defaultValue = (!string.IsNullOrWhiteSpace(p.DefaultValue) && !p.DefaultValue.Equals("default", StringComparison.OrdinalIgnoreCase)) + ? p.DefaultValue + : legacySummaryDefault; + if (!string.IsNullOrWhiteSpace(defaultValue)) + { + _ = sb.AppendLine(); + _ = sb.AppendLine($" **Default:** `{defaultValue.Trim()}`"); + } + + // Constraints from validations + var constraints = FormatConstraints(p.Validations); + if (!string.IsNullOrEmpty(constraints)) + { + _ = sb.AppendLine(); + _ = sb.AppendLine($" **Constraints:** {constraints}"); + } + + // Repeatable / variadic hints + if (p.Repeatable) + { + _ = sb.AppendLine(); + _ = sb.AppendLine($" **Repeatable:** pass `--{p.Name}` multiple times to supply more than one value"); + } + else if (p.Variadic) + { + _ = sb.AppendLine(); + _ = sb.AppendLine(" **Variadic:** accepts multiple values"); + } + + _ = sb.AppendLine(); + } + } + + private static string FormatConstraints(List? validations) + { + if (validations is not { Count: > 0 }) + return string.Empty; + + var parts = new List(); + foreach (var v in validations) + { + var phrase = v.Kind.ToLowerInvariant() switch + { + "existing" => "must exist", + "rejectsymboliclinks" => "symbolic links not allowed", + "expanduserprofile" => "supports `~` home expansion", + "urischeme" when v.Values is { Length: > 0 } => + $"must be a {string.Join(" or ", v.Values)} URI", + "range" when v.Min is not null && v.Max is not null => + $"between {v.Min} and {v.Max}", + "range" when v.Min is not null => $"minimum {v.Min}", + "range" when v.Max is not null => $"maximum {v.Max}", + "timespanrange" when v.Min is not null && v.Max is not null => + $"duration between {v.Min} and {v.Max}", + "fileextensions" when v.Values is { Length: > 0 } => + $"extensions: {string.Join(", ", v.Values)}", + "pattern" when v.Pattern is not null => $"must match `{v.Pattern}`", + _ => null + }; + if (phrase is not null) + parts.Add(phrase); + } + + return parts.Count > 0 ? string.Join(", ", parts) : string.Empty; + } + + private static string GenerateUsage(CliCommandSchema cmd, string[]? fullPath, string? binaryName) + { + var parts = new List(); + if (!string.IsNullOrWhiteSpace(binaryName)) + parts.Add(binaryName); + if (fullPath is { Length: > 0 }) + parts.AddRange(fullPath); + else + parts.Add(cmd.Name); + + var visible = cmd.Parameters.Where(p => p.Name != "_" && !p.Hidden).ToList(); + var positionals = visible.Where(p => p.Role == "positional").ToList(); + var requiredFlags = visible.Where(p => p.Role != "positional" && p.Required).ToList(); + var optionalFlags = visible.Where(p => p.Role != "positional" && !p.Required).ToList(); + + foreach (var p in positionals) + parts.Add(p.Required ? $"<{p.Name}>" : $"[<{p.Name}>]"); + + foreach (var p in requiredFlags) + { + if (IsBoolFlag(p.Type)) + parts.Add($"--{p.Name}"); + else + parts.Add($"--{p.Name} <{p.Name}>"); + } + + if (optionalFlags.Count > 0) + parts.Add("[options]"); + + return string.Join(" ", parts); + } + + private static string FormatFlagName(CliParamSchema p) + { + if (p.Role == "positional") + return $"`<{p.Name}>`"; + + var isBool = IsBoolFlag(p.Type); + var prefix = isBool ? "`--[no-]" : "`--"; + var shortPart = p.ShortName is not null ? $"`-{p.ShortName}` " : string.Empty; + + return $"{shortPart}{prefix}{p.Name}`"; + } + + // Parses optional "Values: ..." and "Default: ..." lines that argh embeds in summary text. + private static (string description, string values, string defaultValue) CleanSummary(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + return (string.Empty, string.Empty, string.Empty); + + // Collapse whitespace produced by XML doc indentation (newlines + leading spaces) + var normalized = WhitespaceRegex().Replace(raw.Trim(), " "); + + // Argh embeds "Values: X, Y. Default: A, B." at the end of summary text. + // Split on " Values: " first, then on " Default: " within the remainder. + const string valuesSep = " Values: "; + const string defaultSep = " Default: "; + + var valuesIdx = normalized.IndexOf(valuesSep, StringComparison.OrdinalIgnoreCase); + if (valuesIdx < 0) + { + // No Values/Default section; check for standalone Default + var defIdx = normalized.IndexOf(defaultSep, StringComparison.OrdinalIgnoreCase); + if (defIdx < 0) + return (normalized, string.Empty, string.Empty); + + return ( + normalized[..defIdx].Trim(), + string.Empty, + normalized[(defIdx + defaultSep.Length)..].Trim().TrimEnd('.') + ); + } + + var description = normalized[..valuesIdx].Trim(); + var remainder = normalized[(valuesIdx + valuesSep.Length)..]; + + var defInRemainder = remainder.IndexOf(defaultSep, StringComparison.OrdinalIgnoreCase); + if (defInRemainder < 0) + return (description, remainder.Trim().TrimEnd('.'), string.Empty); + + var values = remainder[..defInRemainder].Trim().TrimEnd('.'); + var defaultValue = remainder[(defInRemainder + defaultSep.Length)..].Trim().TrimEnd('.'); + return (description, values, defaultValue); + } + + // Schema v2 uses JSON Schema primitives: "boolean", "string", "integer", "number", "array", "enum" + // Schema v1 used "Primitive:bool", "Primitive:bool?", "Primitive" for booleans + private static bool IsBoolFlag(string type) => + type.Equals("boolean", StringComparison.OrdinalIgnoreCase) || + type.StartsWith("Primitive:bool", StringComparison.OrdinalIgnoreCase) || + type.Equals("Primitive", StringComparison.OrdinalIgnoreCase); + + private static string FormatTypeHint(CliParamSchema p) + { + var type = p.Type; + + // v2 JSON Schema primitives + return type.ToLowerInvariant() switch + { + "string" => "string", + "integer" => "int", + "number" => "number", + "boolean" => string.Empty, // shown as --[no-] prefix instead + "enum" => "enum", + "array" => p.ElementType switch + { + "enum" => "enum[]", + "integer" => "int[]", + _ => "string[]" + }, + // v1 fallback (kind-style strings) + _ => FormatKindV1(type) + }; + } + + private static string FormatKindV1(string kind) + { + var colon = kind.IndexOf(':'); + var left = colon >= 0 ? kind[..colon] : kind; + var right = colon >= 0 ? kind[(colon + 1)..] : string.Empty; + + return left switch + { + "Collection" or "Collection" => "enum[]", + "Collection" => "string[]", + "Collection" or "Collection" => "int[]", + "Enum" => right.Contains('.') ? right[(right.LastIndexOf('.') + 1)..] : right, + "Primitive" => right switch + { + "string" or "string?" => "string", + "int" or "int?" or "Int32" or "Int32?" => "int", + _ => string.Empty + }, + "FileInfo" => "path", + "DirectoryInfo" => "path", + _ when left.StartsWith("Collection<") => left["Collection<".Length..].TrimEnd('>') + "[]", + _ => left + }; + } + + // Wraps a usage line to multiline bash continuation format when it exceeds 80 chars. + // Groups flag+value pairs ("--flag ") together on the same line. + private static string FormatUsage(string usage) + { + if (usage.Length <= 80) + return usage; + + var tokens = usage.Split(' '); + var groups = new List(); + var i = 0; + + // Collect the command prefix (everything before the first flag or bracket) + var prefixParts = new List(); + while (i < tokens.Length && !tokens[i].StartsWith('-') && !tokens[i].StartsWith('[') && !tokens[i].StartsWith('<')) + { + prefixParts.Add(tokens[i]); + i++; + } + groups.Add(string.Join(" ", prefixParts)); + + // Group remaining tokens: --flag pairs stay together + while (i < tokens.Length) + { + var token = tokens[i]; + if ((token.StartsWith("--") || (token.StartsWith('-') && token.Length == 2)) + && i + 1 < tokens.Length + && (tokens[i + 1].StartsWith('<') || tokens[i + 1].StartsWith("[<"))) + { + groups.Add(token + " " + tokens[i + 1]); + i += 2; + } + else + { + groups.Add(token); + i++; + } + } + + var result = new StringBuilder(); + _ = result.Append(groups[0]); + for (var g = 1; g < groups.Count; g++) + { + _ = result.Append(" \\"); + _ = result.AppendLine(); + _ = result.Append(" "); + _ = result.Append(groups[g]); + } + return result.ToString(); + } + + [GeneratedRegex(@"\s{2,}")] + private static partial Regex WhitespaceRegex(); +} diff --git a/src/Elastic.Markdown/Extensions/CliReference/CliNamespaceFile.cs b/src/Elastic.Markdown/Extensions/CliReference/CliNamespaceFile.cs new file mode 100644 index 0000000000..dea2f84eb6 --- /dev/null +++ b/src/Elastic.Markdown/Extensions/CliReference/CliNamespaceFile.cs @@ -0,0 +1,61 @@ +// 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.IO.Abstractions; +using Elastic.Documentation.Configuration; +using Elastic.Documentation.Configuration.Toc.CliReference; +using Elastic.Markdown.Myst; +using Markdig.Syntax; + +namespace Elastic.Markdown.Extensions.CliReference; + +public record CliNamespaceFile : IO.MarkdownFile +{ + private readonly CliNamespaceSchema _namespace; + private readonly IFileInfo? _supplementalDoc; + private readonly string? _binaryName; + + private readonly string[] _fullPath; + + public CliNamespaceFile( + IFileInfo sourceFile, + IDirectoryInfo rootPath, + MarkdownParser parser, + BuildContext build, + CliNamespaceSchema @namespace, + IFileInfo? supplementalDoc, + string[]? fullPath = null, + string? binaryName = null + ) : base(sourceFile, rootPath, parser, build) + { + _namespace = @namespace; + _supplementalDoc = supplementalDoc; + _fullPath = fullPath ?? [@namespace.Segment]; + _binaryName = binaryName; + Title = @namespace.Segment; + } + + public override string NavigationTitle => $"[ns]{_namespace.Segment}"; + + protected override Task GetMinimalParseDocumentAsync(Cancel ctx) + { + Title = _namespace.Segment; + var markdown = BuildMarkdown(); + return Task.FromResult(MarkdownParser.MinimalParseStringAsync(markdown, SourceFile, null)); + } + + protected override Task GetParseDocumentAsync(Cancel ctx) + { + var markdown = BuildMarkdown(); + return Task.FromResult(MarkdownParser.ParseStringAsync(markdown, SourceFile, null)); + } + + private string BuildMarkdown() + { + var supplemental = _supplementalDoc?.Exists == true + ? _supplementalDoc.FileSystem.File.ReadAllText(_supplementalDoc.FullName) + : null; + return CliMarkdownGenerator.NamespacePage(_namespace, supplemental, _fullPath, _binaryName); + } +} diff --git a/src/Elastic.Markdown/Extensions/CliReference/CliReferenceDocsBuilderExtension.cs b/src/Elastic.Markdown/Extensions/CliReference/CliReferenceDocsBuilderExtension.cs new file mode 100644 index 0000000000..aaac32349e --- /dev/null +++ b/src/Elastic.Markdown/Extensions/CliReference/CliReferenceDocsBuilderExtension.cs @@ -0,0 +1,389 @@ +// 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.IO.Abstractions; +using Elastic.Documentation.Configuration; +using Elastic.Documentation.Configuration.Toc; +using Elastic.Documentation.Configuration.Toc.CliReference; +using Elastic.Documentation.Navigation; +using Elastic.Markdown.Exporters; +using Elastic.Markdown.IO; +using Elastic.Markdown.Myst; + +namespace Elastic.Markdown.Extensions.CliReference; + +internal sealed record CliEntityInfo( + CliSchema Schema, + object Entity, // CliSchema | CliNamespaceSchema | CliCommandSchema + IFileInfo? SupplementalDoc, + /// The clean synthetic file (no cmd- prefix) — used as the MarkdownFile source for correct URL generation. + IFileInfo? CleanSyntheticFile = null, + /// Full path segments used to build the page heading (e.g. ["assembler", "bloom-filter"]). + string[]? FullPath = null +); + +public class CliReferenceDocsBuilderExtension(BuildContext build) : IDocsBuilderExtension +{ + private BuildContext Build { get; } = build; + + private Dictionary? _syntheticFiles; + private List? _syntheticFileInfos; + // Maps physical supplemental file paths (cmd-*.md, index.md) → entity info with clean synthetic path + private Dictionary? _supplementalFiles; + // Cache of created MarkdownFile instances keyed by clean synthetic path — ensures the same instance + // is returned from both CreateMarkdownFile (supplemental) and CreateDocumentationFile (synthetic), + // so NavigationDocumentationFileLookup can find the file regardless of which key is used. + private readonly Dictionary _createdFiles = []; + + // Must be called before CreateMarkdownFile or CreateDocumentationFile can match anything. + // ScanDocumentationFiles calls this; CreateMarkdownFile also triggers it because the main + // directory scan runs before ScanDocumentationFiles, so index.md files are encountered first. + private void EnsureSyntheticFilesBuilt() + { + if (_syntheticFiles is not null) + return; + _syntheticFiles = []; + _supplementalFiles = []; + _syntheticFileInfos = BuildSyntheticFiles(); + } + + public IDocumentationFileExporter? FileExporter => null; + + public DocumentationFile? CreateDocumentationFile(IFileInfo file, MarkdownParser markdownParser) + { + EnsureSyntheticFilesBuilt(); + if (!_syntheticFiles!.TryGetValue(file.FullName, out var info)) + return null; + // Use the clean synthetic file as source if available (commands registered under clean path) + var sourceFile = info.CleanSyntheticFile ?? file; + // Return cached instance if CreateMarkdownFile already created it for this path + if (_createdFiles.TryGetValue(sourceFile.FullName, out var cached)) + return cached; + var result = CreateCliFileFromInfo(sourceFile, markdownParser, info); + if (result != null) + _createdFiles[sourceFile.FullName] = result; + return result; + } + + public MarkdownFile? CreateMarkdownFile(IFileInfo file, IDirectoryInfo sourceDirectory, MarkdownParser markdownParser) + { + // Physical CLI supplemental docs (index.md for namespaces, cmd-*.md for commands) need to be + // intercepted before the factory creates a plain MarkdownFile for them. + // EnsureSyntheticFilesBuilt() is called here because CreateMarkdownFile runs during the main + // directory scan, before ScanDocumentationFiles populates the lookups. + var name = file.Name; + if (name != "index.md" && !name.StartsWith("cmd-", StringComparison.OrdinalIgnoreCase)) + return null; + EnsureSyntheticFilesBuilt(); + var fullPath = Path.GetFullPath(file.FullName); + + // index.md: file path IS the synthetic path (namespace pages) + if (_syntheticFiles!.TryGetValue(fullPath, out var info)) + { + if (_createdFiles.TryGetValue(fullPath, out var cached)) + return cached; + var result = CreateCliFileFromInfo(file, markdownParser, info); + if (result != null) + _createdFiles[fullPath] = result; + return result; + } + + // cmd-*.md: physical supplemental file — render as the associated CLI command page + // using the clean synthetic path (no cmd- prefix) as the source file so RelativePath + // and thus the output URL are both clean (e.g. apply.md → /cli/.../apply). + if (_supplementalFiles!.TryGetValue(fullPath, out var suppInfo) && suppInfo.CleanSyntheticFile is not null) + { + var cleanPath = suppInfo.CleanSyntheticFile.FullName; + if (_createdFiles.TryGetValue(cleanPath, out var cached)) + return cached; + var result = CreateCliFileFromInfo(suppInfo.CleanSyntheticFile, markdownParser, suppInfo); + if (result != null) + _createdFiles[cleanPath] = result; + return result; + } + + return null; + } + + private MarkdownFile? CreateCliFileFromInfo(IFileInfo sourceFile, MarkdownParser markdownParser, CliEntityInfo info) => + info.Entity switch + { + CliSchema schema => new CliRootFile(sourceFile, Build.DocumentationSourceDirectory, markdownParser, Build, schema, info.SupplementalDoc), + CliNamespaceSchema ns => new CliNamespaceFile(sourceFile, Build.DocumentationSourceDirectory, markdownParser, Build, ns, info.SupplementalDoc, info.FullPath ?? [ns.Segment], info.Schema.Name), + CliCommandSchema cmd => new CliCommandFile(sourceFile, Build.DocumentationSourceDirectory, markdownParser, Build, cmd, info.SupplementalDoc, info.FullPath ?? [cmd.Name], info.Schema.Name), + _ => null + }; + + public void VisitNavigation(INavigationItem navigation, IDocumentationFile model) { } + + public bool TryGetDocumentationFileBySlug(DocumentationSet documentationSet, string slug, out DocumentationFile? documentationFile) + { + documentationFile = null; + return false; + } + + public IReadOnlyCollection<(IFileInfo, DocumentationFile)> ScanDocumentationFiles(Func defaultFileHandling) + { + EnsureSyntheticFilesBuilt(); + if (_syntheticFileInfos is not { Count: > 0 }) + return []; + + var results = new List<(IFileInfo, DocumentationFile)>(); + foreach (var fileInfo in _syntheticFileInfos) + { + // When a supplemental index.md physically exists at the synthetic path (e.g. changelog/index.md), + // skip it here — the factory's directory scan will find the real file and call CreateMarkdownFile, + // which picks up the CliNamespaceFile from _syntheticFiles. Registering both would cause duplicate keys. + if (fileInfo.Exists) + continue; + + // defaultFileHandling calls extension.CreateDocumentationFile(file, markdownParser) + // which routes back to our CreateDocumentationFile above — now with the MarkdownParser available + var doc = defaultFileHandling(fileInfo, Build.DocumentationSourceDirectory); + results.Add((fileInfo, doc)); + } + return results; + } + + private List BuildSyntheticFiles() + { + var cliRefs = FindCliReferenceRefs(Build.ConfigurationYaml.TableOfContents); + var fileInfos = new List(); + + foreach (var cliRef in cliRefs) + { + var schemaFileInfo = Build.ReadFileSystem.FileInfo.New( + Build.ReadFileSystem.Path.Join(Build.DocumentationSourceDirectory.FullName, cliRef.SchemaPath)); + + if (!schemaFileInfo.Exists) + continue; + + CliSchema schema; + try + { + schema = CliSchema.Load(schemaFileInfo); + } + catch (Exception ex) + { + Build.Collector.EmitError(schemaFileInfo, $"Failed to load CLI schema: {ex.Message}"); + continue; + } + + var virtualRoot = cliRef.PathRelativeToDocumentationSet; + var supplementalDirPath = cliRef.SupplementalFolder is not null + ? Build.ReadFileSystem.Path.Join(Build.DocumentationSourceDirectory.FullName, cliRef.SupplementalFolder) + : null; + + var matched = new HashSet(StringComparer.OrdinalIgnoreCase); + + // Root page + var rootSupplemental = FindSupplemental(supplementalDirPath, [], isNamespace: true, matched); + var rootSyntheticPath = SyntheticPath(Build.DocumentationSourceDirectory.FullName, virtualRoot, [], isNamespace: true); + var rootFileInfo = Build.ReadFileSystem.FileInfo.New(rootSyntheticPath); + var rootInfo = new CliEntityInfo(schema, schema, rootSupplemental, rootFileInfo); + _syntheticFiles![rootSyntheticPath] = rootInfo; + if (rootSupplemental != null) + _supplementalFiles![rootSupplemental.FullName] = rootInfo; + fileInfos.Add(rootFileInfo); + + // Root commands + foreach (var cmd in schema.Commands) + { + var path = SyntheticPath(Build.DocumentationSourceDirectory.FullName, virtualRoot, [cmd.Name], isNamespace: false); + var fileInfo = Build.ReadFileSystem.FileInfo.New(path); + var supplemental = FindSupplemental(supplementalDirPath, [cmd.Name], isNamespace: false, matched); + var cmdInfo = new CliEntityInfo(schema, cmd, supplemental, fileInfo, FullPath: [cmd.Name]); + _syntheticFiles[path] = cmdInfo; + if (supplemental != null) + _supplementalFiles![supplemental.FullName] = cmdInfo; + fileInfos.Add(fileInfo); + } + + // Namespaces (recursive) + CollectNamespaceFiles(Build.DocumentationSourceDirectory.FullName, virtualRoot, supplementalDirPath, schema.Namespaces, [], matched, fileInfos, schema); + + // Validate supplemental files + if (supplementalDirPath is not null && Build.ReadFileSystem.Directory.Exists(supplementalDirPath)) + ValidateSupplementalFiles(supplementalDirPath, matched, cliRef.Context); + } + + return fileInfos; + } + + private void CollectNamespaceFiles( + string docSourceDir, + string virtualRoot, + string? supplementalDirPath, + IReadOnlyCollection namespaces, + string[] nsPath, + HashSet matched, + List fileInfos, + CliSchema schema) + { + foreach (var ns in namespaces) + { + var fullNsPath = nsPath.Append(ns.Segment).ToArray(); + + var nsFilePath = SyntheticPath(docSourceDir, virtualRoot, fullNsPath, isNamespace: true); + var nsFileInfo = Build.ReadFileSystem.FileInfo.New(nsFilePath); + var nsSupplemental = FindSupplemental(supplementalDirPath, fullNsPath, isNamespace: true, matched); + var nsInfo = new CliEntityInfo(schema, ns, nsSupplemental, nsFileInfo, FullPath: fullNsPath); + _syntheticFiles![nsFilePath] = nsInfo; + if (nsSupplemental != null) + _supplementalFiles![nsSupplemental.FullName] = nsInfo; + fileInfos.Add(nsFileInfo); + + foreach (var cmd in ns.Commands) + { + var cmdSegments = fullNsPath.Append(cmd.Name).ToArray(); + var cmdPath = SyntheticPath(docSourceDir, virtualRoot, cmdSegments, isNamespace: false); + var cmdFileInfo = Build.ReadFileSystem.FileInfo.New(cmdPath); + var cmdSupplemental = FindSupplemental(supplementalDirPath, cmdSegments, isNamespace: false, matched); + var cmdInfo = new CliEntityInfo(schema, cmd, cmdSupplemental, cmdFileInfo, FullPath: cmdSegments); + _syntheticFiles[cmdPath] = cmdInfo; + if (cmdSupplemental != null) + _supplementalFiles![cmdSupplemental.FullName] = cmdInfo; + fileInfos.Add(cmdFileInfo); + } + + CollectNamespaceFiles(docSourceDir, virtualRoot, supplementalDirPath, ns.Namespaces, fullNsPath, matched, fileInfos, schema); + } + } + + // Clean synthetic path (no cmd- prefix) — used for URL generation and Files registration. + // GetFullPath normalizes any ".." segments so the key matches IFileInfo.FullName lookups. + internal static string SyntheticPath(string docSourceDir, string virtualRoot, string[] segments, bool isNamespace) + { + if (segments.Length == 0) + return Path.GetFullPath(Path.Join(docSourceDir, virtualRoot, "index.md")); + + if (isNamespace) + { + var joined = Path.Combine([.. segments]); + return Path.GetFullPath(Path.Join(docSourceDir, virtualRoot, joined, "index.md")); + } + else + { + // Commands use clean name (no cmd- prefix) for URL e.g. /cli/assembler/deploy/apply. + // Exception: commands named "index" must keep cmd- prefix to avoid collision with namespace index.md pages. + var name = segments[^1].Equals("index", StringComparison.OrdinalIgnoreCase) + ? $"cmd-{segments[^1]}" + : segments[^1]; + var parentSegments = segments.Length > 1 ? segments[..^1] : []; + var parentPath = parentSegments.Length > 0 ? Path.Combine([.. parentSegments]) : string.Empty; + return string.IsNullOrEmpty(parentPath) + ? Path.GetFullPath(Path.Join(docSourceDir, virtualRoot, name + ".md")) + : Path.GetFullPath(Path.Join(docSourceDir, virtualRoot, parentPath, name + ".md")); + } + } + + private IFileInfo? FindSupplemental(string? supplementalDirPath, string[] segments, bool isNamespace, HashSet matched) + { + if (supplementalDirPath is null) + return null; + + var candidates = isNamespace + ? HierarchyCandidates(segments, isNamespace: true).Concat(FlatPrefixCandidates(segments, isNamespace: true)) + : HierarchyCandidates(segments, isNamespace: false).Concat(FlatPrefixCandidates(segments, isNamespace: false)); + + foreach (var candidate in candidates) + { + var fullPath = Path.Join(supplementalDirPath, candidate); + var fileInfo = Build.ReadFileSystem.FileInfo.New(fullPath); + if (!fileInfo.Exists) + continue; + _ = matched.Add(fullPath); + return fileInfo; + } + return null; + } + + // hierarchy style for namespaces: changelog/index.md (natural folder layout) then changelog/ns-changelog.md + // hierarchy style for commands: assembler/cmd-build.md + private static IEnumerable HierarchyCandidates(string[] segments, bool isNamespace) + { + if (segments.Length == 0) + { + yield return "index.md"; + yield return "ns-root.md"; + yield break; + } + + if (isNamespace) + { + // index.md inside a subfolder named after the namespace path (e.g. changelog/index.md) + var joinedPath = string.Join("/", segments); + yield return $"{joinedPath}/index.md"; + } + + var prefix = isNamespace ? "ns-" : "cmd-"; + var lastName = segments[^1]; + var folder = segments.Length > 1 ? string.Join("/", segments[..^1]) + "/" : string.Empty; + yield return $"{folder}{prefix}{lastName}.md"; + } + + // flat style: ns-assembler-content-source.md, cmd-assembler-build.md + private static IEnumerable FlatPrefixCandidates(string[] segments, bool isNamespace) + { + if (segments.Length == 0) + yield break; + + var prefix = isNamespace ? "ns-" : "cmd-"; + var joined = string.Join("-", segments); + yield return $"{prefix}{joined}.md"; + } + + private void ValidateSupplementalFiles(string supplementalDirPath, HashSet matched, string context) + { + foreach (var file in Build.ReadFileSystem.Directory + .EnumerateFiles(supplementalDirPath, "*.md", SearchOption.AllDirectories)) + { + var name = Path.GetFileName(file); + var relPath = Path.GetRelativePath(supplementalDirPath, file); + + if (name == "index.md") + { + if (!matched.Contains(file)) + Build.Collector.EmitError(context, $"CLI supplemental 'index.md' at '{relPath}' does not match any CLI namespace or the CLI root page"); + continue; + } + + if (!name.StartsWith("ns-", StringComparison.OrdinalIgnoreCase) && + !name.StartsWith("cmd-", StringComparison.OrdinalIgnoreCase)) + continue; + + if (!matched.Contains(file)) + Build.Collector.EmitError(context, $"CLI supplemental file '{relPath}' does not match any CLI namespace or command"); + } + } + + private static IReadOnlyCollection FindCliReferenceRefs(IReadOnlyCollection items) + { + var found = new List(); + Traverse(items, found); + return found; + + static void Traverse(IReadOnlyCollection items, List found) + { + foreach (var item in items) + { + if (item is CliReferenceRef cliRef) + { + found.Add(cliRef); + continue; + } + + var children = item switch + { + FileRef f => f.Children, + FolderRef f => f.Children, + IsolatedTableOfContentsRef t => t.Children, + _ => null + }; + if (children is { Count: > 0 }) + Traverse(children, found); + } + } + } +} diff --git a/src/Elastic.Markdown/Extensions/CliReference/CliRootFile.cs b/src/Elastic.Markdown/Extensions/CliReference/CliRootFile.cs new file mode 100644 index 0000000000..9e18b3863b --- /dev/null +++ b/src/Elastic.Markdown/Extensions/CliReference/CliRootFile.cs @@ -0,0 +1,54 @@ +// 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.IO.Abstractions; +using Elastic.Documentation.Configuration; +using Elastic.Documentation.Configuration.Toc.CliReference; +using Elastic.Markdown.Myst; +using Markdig.Syntax; + +namespace Elastic.Markdown.Extensions.CliReference; + +public record CliRootFile : IO.MarkdownFile +{ + private readonly CliSchema _schema; + private readonly IFileInfo? _supplementalDoc; + + public CliRootFile( + IFileInfo sourceFile, + IDirectoryInfo rootPath, + MarkdownParser parser, + BuildContext build, + CliSchema schema, + IFileInfo? supplementalDoc + ) : base(sourceFile, rootPath, parser, build) + { + _schema = schema; + _supplementalDoc = supplementalDoc; + Title = schema.Name; + } + + public override string NavigationTitle => $"{_schema.Name} CLI"; + + protected override Task GetMinimalParseDocumentAsync(Cancel ctx) + { + Title = _schema.Name; + var markdown = BuildMarkdown(); + return Task.FromResult(MarkdownParser.MinimalParseStringAsync(markdown, SourceFile, null)); + } + + protected override Task GetParseDocumentAsync(Cancel ctx) + { + var markdown = BuildMarkdown(); + return Task.FromResult(MarkdownParser.ParseStringAsync(markdown, SourceFile, null)); + } + + private string BuildMarkdown() + { + var supplemental = _supplementalDoc?.Exists == true + ? _supplementalDoc.FileSystem.File.ReadAllText(_supplementalDoc.FullName) + : null; + return CliMarkdownGenerator.RootPage(_schema, supplemental); + } +} diff --git a/src/Elastic.Markdown/IO/DocumentationSet.cs b/src/Elastic.Markdown/IO/DocumentationSet.cs index 910678741d..9068220898 100644 --- a/src/Elastic.Markdown/IO/DocumentationSet.cs +++ b/src/Elastic.Markdown/IO/DocumentationSet.cs @@ -10,12 +10,15 @@ using Elastic.Documentation; using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Builder; +using Elastic.Documentation.Configuration.Toc; +using Elastic.Documentation.Configuration.Toc.CliReference; using Elastic.Documentation.Links; using Elastic.Documentation.Links.CrossLinks; using Elastic.Documentation.Navigation; using Elastic.Documentation.Navigation.Isolated.Node; using Elastic.Documentation.Site.Navigation; using Elastic.Markdown.Extensions; +using Elastic.Markdown.Extensions.CliReference; using Elastic.Markdown.Extensions.DetectionRules; using Elastic.Markdown.Myst; using Microsoft.Extensions.Logging; @@ -296,6 +299,30 @@ private IReadOnlyCollection InstantiateExtensions() } } + // Auto-enable CLI reference extension when the TOC contains a cli: entry + if (HasCliReferenceRef(Context.ConfigurationYaml.TableOfContents)) + list.Add(new CliReferenceDocsBuilderExtension(Context)); + return list.AsReadOnly(); } + + private static bool HasCliReferenceRef(IReadOnlyCollection items) + { + foreach (var item in items) + { + if (item is CliReferenceRef) + return true; + + var children = item switch + { + FileRef f => f.Children, + FolderRef f => f.Children, + IsolatedTableOfContentsRef t => t.Children, + _ => null + }; + if (children is { Count: > 0 } && HasCliReferenceRef(children)) + return true; + } + return false; + } } diff --git a/src/Elastic.Markdown/IO/MarkdownFile.cs b/src/Elastic.Markdown/IO/MarkdownFile.cs index bd60000099..86333aba6a 100644 --- a/src/Elastic.Markdown/IO/MarkdownFile.cs +++ b/src/Elastic.Markdown/IO/MarkdownFile.cs @@ -76,7 +76,7 @@ protected set public string? Description { get; private set; } [field: AllowNull, MaybeNull] - public string NavigationTitle + public virtual string NavigationTitle { get => !string.IsNullOrEmpty(field) ? field : Title ?? string.Empty; private set => field = value.StripMarkdown(); diff --git a/src/Elastic.Markdown/Layout/_Breadcrumbs.cshtml b/src/Elastic.Markdown/Layout/_Breadcrumbs.cshtml index 0ee990d0be..d239d1a963 100644 --- a/src/Elastic.Markdown/Layout/_Breadcrumbs.cshtml +++ b/src/Elastic.Markdown/Layout/_Breadcrumbs.cshtml @@ -1,4 +1,9 @@ @inherits RazorSlice +@functions { + static string StripBadge(string t) => + t.StartsWith("[ns]", StringComparison.Ordinal) ? t[4..] : + t.StartsWith("[cmd]", StringComparison.Ordinal) ? t[5..] : t; +} @{ var targets = Model.Breadcrumbs; var crumbs = targets.ToList(); @@ -16,7 +21,7 @@ href="@item.Url" @(i == 0 ? "hx-disable=true" : "") @Model.Htmx.GetHxAttributes(Model.CurrentNavigationItem?.NavigationRoot.Id == item.NavigationRoot.Id)> - @item.NavigationTitle + @StripBadge(item.NavigationTitle) @if (i < crumbs.Count - 1) { diff --git a/src/Elastic.Markdown/Layout/_PrevNextNav.cshtml b/src/Elastic.Markdown/Layout/_PrevNextNav.cshtml index e4b92fb47d..1f51d69090 100644 --- a/src/Elastic.Markdown/Layout/_PrevNextNav.cshtml +++ b/src/Elastic.Markdown/Layout/_PrevNextNav.cshtml @@ -1,4 +1,9 @@ @inherits RazorSlice +@functions { + static string StripBadge(string t) => + t.StartsWith("[ns]", StringComparison.Ordinal) ? t[4..] : + t.StartsWith("[cmd]", StringComparison.Ordinal) ? t[5..] : t; +}