Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 28 additions & 3 deletions docs/cli-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -2861,7 +2861,7 @@
"changelog"
],
"name": "bundle-amend",
"summary": "Append additional changelog entries to a published bundle without modifying it.",
"summary": "Append or exclude changelog entries in 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": [],
Expand Down Expand Up @@ -2893,7 +2893,16 @@
"name": "add",
"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.",
"summary": "Optional: Changelog YAML paths to add. Repeat --add or pass a comma-separated list in one value (for example, --add \u0022file1.yaml,file2.yaml\u0022). Supports tilde (~) expansion and relative paths.",
"repeatable": true,
"elementType": "string"
},
{
"role": "flag",
"name": "remove",
"type": "array",
"required": false,
"summary": "Optional: Changelog YAML paths to exclude from the effective bundle. Repeat --remove or pass a comma-separated list in one value. Supports tilde (~) expansion and relative paths.",
"repeatable": true,
"elementType": "string"
},
Expand All @@ -2902,9 +2911,25 @@
"name": "resolve",
"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).",
"summary": "Optional: When using --add, inline each added changelog\u0027s content in the amend file. Use --no-resolve to record file references only. When omitted, inferred from the parent bundle. Does not apply to --remove.",
"defaultValue": "default"
},
{
"role": "flag",
"name": "force",
"type": "boolean",
"required": false,
"summary": "Optional: When removing, match by file name even if the bundle checksum differs from the file on disk.",
"defaultValue": "false"
},
{
"role": "flag",
"name": "dry-run",
"type": "boolean",
"required": false,
"summary": "Optional: Preview changes without writing an amend file.",
"defaultValue": "false"
},
{
"role": "flag",
"name": "log-level",
Expand Down
76 changes: 71 additions & 5 deletions docs/cli/changelog/cmd-bundle-amend.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
## Description

Amend a bundle with additional changelog entries.
Amend a bundle with additional or excluded changelog entries without modifying the parent bundle file.
Amend bundles follow a specific naming convention: `{parent-bundle-name}.amend-{N}.yaml` where `{N}` is a sequence number.

Specify at least one of `--add` or `--remove`.

To create a bundle, use [](/cli/changelog/bundle.md).
For details and examples, go to [](/contribute/bundle-changelogs.md).

## Resolve behaviour

By default, the `bundle-amend` command **infers** whether to resolve entries from the original bundle.
By default, the `bundle-amend` command **infers** whether to resolve entries from the original bundle when you use `--add`.
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.

Expand All @@ -19,9 +21,13 @@ 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.

`--resolve` and `--no-resolve` apply only to `--add`. Removals always record `exclude-entries` with file name and checksum.

## Output

Amend bundles contain only the additional entries, not a full repetition of the original bundle. For example:
Amend bundles contain only the changes for that amend file, not a full repetition of the original bundle.

Additions:

```yaml
# 9.3.0.amend-1.yaml
Expand All @@ -31,8 +37,20 @@ entries:
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.
Removals:

```yaml
# 9.3.0.amend-2.yaml
exclude-entries:
- file:
name: 138723.yaml
checksum: def456abc123
```

An amend file can contain both `exclude-entries` and `entries`. Within each amend file, exclusions are applied before additions.

When bundles are loaded (either via the `changelog render` command or the `{changelog}` directive), amend files are **automatically merged** with their parent bundles in sequence (`amend-1`, `amend-2`, …).
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.
Expand All @@ -52,14 +70,53 @@ docs-builder changelog bundle-amend \

The new bundle automatically matches the resolve style of the original bundle.

### Remove a changelog from a bundle

```sh
docs-builder changelog bundle-amend \
./docs/changelog/bundles/9.3.0.yaml \
--remove ./docs/changelog/138723.yaml
```

The CLI computes the file checksum automatically and matches it against the effective bundle (parent plus any existing amend files).
If the bundle contains the file with a different checksum, the command fails unless you pass `--force` to remove by file name only.

### Add multiple changelogs to a bundle

Comma-separated list:

```sh
docs-builder changelog bundle-amend \
./docs/changelog/bundles/9.3.0.yaml \
--add "./docs/changelog/138723.yaml,./docs/changelog/1770424335.yaml"
```

Or repeat `--add`:

```sh
docs-builder changelog bundle-amend \
./docs/changelog/bundles/9.3.0.yaml \
--add ./docs/changelog/138723.yaml \
--add ./docs/changelog/1770424335.yaml
```

### Remove multiple changelogs from a bundle

```sh
docs-builder changelog bundle-amend \
./docs/changelog/bundles/9.3.0.yaml \
--remove "./docs/changelog/old-a.yaml,./docs/changelog/old-b.yaml"
```

### Replace an entry in one amend file

```sh
docs-builder changelog bundle-amend \
./docs/changelog/bundles/9.3.0.yaml \
--remove ./docs/changelog/old-entry.yaml \
--add ./docs/changelog/new-entry.yaml
```

### Force resolve or file-reference style

```sh
Expand All @@ -74,3 +131,12 @@ docs-builder changelog bundle-amend 9.3.0.yaml \
--add ./docs/changelog/late-addition.yaml \
--no-resolve
```

### Preview without writing an amend file

```sh
docs-builder changelog bundle-amend \
./docs/changelog/bundles/9.3.0.yaml \
--remove ./docs/changelog/138723.yaml \
--dry-run
```
10 changes: 10 additions & 0 deletions docs/cli/changelog/cmd-remove.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,16 @@ Before deleting anything, the command checks whether any matching files are refe

For more context, go to [](/contribute/bundle-changelogs.md#changelog-remove).

## Bundle dependency check

The dependency scan considers the **effective** entry list for each parent bundle: parent `entries` merged with amend sidecars (`{bundle}.amend-N.yaml`) in numeric order. Within each amend file, `exclude-entries` are applied before additions, matching how bundles are loaded for [`changelog render`](/cli/changelog/render.md) and the `{changelog}` directive.

A changelog file blocks deletion only when it still appears in that effective list as an **unresolved** entry (the bundle references the file by name and checksum rather than embedding inline title and type). Resolved entries do not require the source file on disk.

If you removed an entry from a bundle with [`changelog bundle-amend --remove`](/cli/changelog/bundle-amend.md), the corresponding `exclude-entries` record drops it from the effective list, so `changelog remove` can delete the source file even when the parent bundle still lists it.

Amend sidecar files are not scanned as parent bundles themselves—only the parent bundle file plus its amend chain is evaluated. If an amend file cannot be parsed, the command logs a warning and uses the parent bundle entries only for that dependency check.

## Directory resolution

Both modes use the same ordered fallback to locate changelog YAML files and existing bundles.
Expand Down
13 changes: 10 additions & 3 deletions docs/contribute/bundle-changelogs.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,9 +249,16 @@ docs-builder changelog bundle-amend \

Amend bundles follow a specific naming convention: `{parent-bundle-name}.amend-{N}.yaml` where `{N}` is a sequence number.

:::{note}
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.
:::
To remove entries from an existing bundle without editing the parent file, use `--remove` on the same command:

```sh
docs-builder changelog bundle-amend \
./docs/releases/9.3.0.yaml \
--remove "./docs/changelog/138723.yaml"
```

This creates an amend file with `exclude-entries` that is merged when the bundle is rendered.
After excluding an entry from unresolved bundles, you can use `changelog remove` to delete the source changelog file.

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.
Expand Down
9 changes: 6 additions & 3 deletions docs/syntax/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ Bundles with the same target version/date are automatically merged into a single

Bundles can have associated **amend files** that follow the naming pattern `{bundle-name}.amend-{N}.yaml` (e.g., `9.3.0.amend-1.yaml`). When loading bundles, the directive automatically discovers and merges amend files with their parent bundles.

This allows you to add late additions to a release without modifying the original bundle file:
This allows you to add or remove late changes to a release without modifying the original bundle file:

```
bundles/
Expand All @@ -229,6 +229,8 @@ bundles/
└── 9.3.0.amend-2.yaml # Second amend (auto-merged with parent)
```

Amend files may contain `entries` (additions) and `exclude-entries` (removals). Within each amend file, exclusions are applied before additions. Amend files are processed in numeric order.

All entries from the parent and amend bundles are rendered together as a single release section. The parent bundle's metadata (products, hide-features, repo) is preserved.

## Default folder structure
Expand Down Expand Up @@ -320,8 +322,9 @@ This prevents silent data loss where changelog entries would be quietly omitted
To fix this, either:

- Restore the missing changelog files, or
- Re-create the bundle with `--resolve` to embed entry content directly (making the bundle self-contained), or
- Remove the unresolvable entry from the bundle file.
- Re-create the bundle with `--resolve` to embed entry content directly (making the bundle self-contained).

`bundle-amend --remove` only applies when the source changelog file is still available (for example, to drop an entry from the effective bundle before you delete the file with `changelog remove`).

:::{tip}
In general, if you want to be able to remove changelog files after your releases, create your bundles with the `--resolve` option or set `bundle.resolve` to `true` in the changelog configuration file.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ public sealed record BundleDto
[YamlMember(Alias = "hide-features", ApplyNamingConventions = false)]
public List<string>? HideFeatures { get; set; }
public List<BundledEntryDto>? Entries { get; set; }
/// <summary>
/// Entries to exclude when this amend file is merged with its parent bundle.
/// </summary>
[YamlMember(Alias = "exclude-entries", ApplyNamingConventions = false)]
public List<BundledEntryDto>? ExcludeEntries { get; set; }
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// Licensed to Elasticsearch B.V under one or more agreements.
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
// See the LICENSE file in the project root for more information

using System.Globalization;
using System.Text.RegularExpressions;
using Elastic.Documentation.ReleaseNotes;

namespace Elastic.Documentation.Configuration.ReleaseNotes;

/// <summary>
/// Merges parent bundle entries with amend files (exclusions first, then additions per amend).
/// </summary>
public static partial class BundleAmendMerger
{
[GeneratedRegex(@"\.amend-(\d+)\.ya?ml$", RegexOptions.IgnoreCase)]
private static partial Regex AmendFileRegex();

/// <summary>Whether a path is an amend sidecar (<c>{name}.amend-{N}.yaml</c>).</summary>
public static bool IsAmendFile(string filePath) => AmendFileRegex().IsMatch(filePath);

/// <summary>Numeric suffix from an amend file path; <c>0</c> when not an amend file.</summary>
public static int GetAmendFileNumber(string filePath)
{
var match = AmendFileRegex().Match(filePath);
return match.Success && int.TryParse(match.Groups[1].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var number)
? number
: 0;
}

/// <summary>
/// Applies amend bundles in order to parent entries and returns the effective entry list.
/// </summary>
public static List<BundledEntry> MergeEntries(
IReadOnlyList<BundledEntry> parentEntries,
IReadOnlyList<Bundle> amendBundlesInOrder)
{
var current = parentEntries.ToList();
foreach (var amend in amendBundlesInOrder)
current = ApplySingleAmend(current, amend);
return current;
}

/// <summary>
/// Collects all exclusion keys already applied by prior amend files.
/// </summary>
public static HashSet<string> CollectAppliedExclusionKeys(
IReadOnlyList<Bundle> amendBundlesInOrder)
{
var keys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var amend in amendBundlesInOrder)
{
foreach (var exclusion in amend.ExcludeEntries)
_ = keys.Add(BuildExclusionKey(exclusion));
}
return keys;
}

/// <summary>Whether a bundled entry matches an exclusion record.</summary>
public static bool EntryMatchesExclusion(BundledEntry entry, BundledEntry exclusion)
{
var entryFileName = NormalizeFileName(entry.File?.Name);
var exclusionFileName = NormalizeFileName(exclusion.File?.Name);
if (string.IsNullOrEmpty(entryFileName) || string.IsNullOrEmpty(exclusionFileName))
return false;

if (!string.Equals(entryFileName, exclusionFileName, StringComparison.OrdinalIgnoreCase))
return false;

if (string.IsNullOrWhiteSpace(exclusion.File?.Checksum))
return true;

return string.Equals(entry.File?.Checksum, exclusion.File.Checksum, StringComparison.OrdinalIgnoreCase);
}

/// <summary>Builds a stable key for an exclusion record (file name + checksum).</summary>
public static string BuildExclusionKey(BundledEntry exclusion)
{
var name = NormalizeFileName(exclusion.File?.Name) ?? string.Empty;
var checksum = exclusion.File?.Checksum ?? string.Empty;
return $"{name}|{checksum}";
}

private static List<BundledEntry> ApplySingleAmend(IReadOnlyList<BundledEntry> entries, Bundle amend)
{
var result = ApplyExclusions(entries, amend.ExcludeEntries);
if (amend.Entries.Count > 0)
result.AddRange(amend.Entries);
return result;
}

private static List<BundledEntry> ApplyExclusions(
IReadOnlyList<BundledEntry> entries,
IReadOnlyList<BundledEntry> exclusions)
{
if (exclusions.Count == 0)
return entries.ToList();

return entries
.Where(entry => !exclusions.Any(exclusion => EntryMatchesExclusion(entry, exclusion)))
.ToList();
}

private static string? NormalizeFileName(string? fileName)
{
if (string.IsNullOrWhiteSpace(fileName))
return null;

var normalized = fileName.Replace('\\', '/');
var lastSlash = normalized.LastIndexOf('/');
return lastSlash >= 0 ? normalized[(lastSlash + 1)..] : normalized;
}
}
Loading
Loading