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
23 changes: 22 additions & 1 deletion docs/cli/changelog/cmd-bundle-amend.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,32 @@ Amend bundles created by older docs-builder versions may omit `products`; they a

`--add` and `--remove` follow the same entry-sourcing gate as [](/cli/changelog/bundle.md): CDN by default when `bundle.repo` or the parent bundle's `repo` resolves; local disk when `--force-local` or `bundle.use_local_changelogs` is set, or when no authoring repo can be resolved. In CDN mode, paths are matched by file name (including CDN paths such as `/changelog/elastic/kibana/main/247279.yaml`) and do not need to exist locally. Use `--force-local` to read local changelogs from disk.

The parent bundle argument is always a local file. The command writes `{parent}.amend-N.yaml` next to it and does not fetch the parent from the CDN.
The parent may be a local bundle file or a published CDN locator (`/bundle/{product}/{file}.yaml`, leading slash optional). A local parent writes `{parent}.amend-N.yaml` next to that file. A CDN parent fetches the published bundle and any existing `amend-N` sidecars, then writes only the new sidecar locally — it does not download-and-rewrite the parent, and it does not upload. `--output` (a directory, or the exact `{parent}.amend-N.yaml` name for the next unused N) selects the write location for a CDN parent; when omitted, the command uses `bundle.output_directory` from `changelog.yml`, then the current directory. `--output` is ignored for a local parent.
:::

## Examples

### Amend a published CDN bundle

Pass a CDN locator as the parent. `--add` can be a CDN entry path (matched by file name) when entry sourcing uses the CDN:

```sh
docs-builder changelog bundle-amend \
/bundle/kibana/9.3.0.yaml \
--add /changelog/elastic/kibana/main/138723.yaml \
--output ./docs/releases
```

This writes `9.3.0.amend-1.yaml` (or the next unused N) under `./docs/releases`. Upload is a separate step; the sidecar is uploaded like any other bundle YAML:

```sh
docs-builder changelog upload \
--artifact-type bundle \
--directory ./docs/releases \
--target s3 \
--s3-bucket-name my-changelog-bundles
```

### Add a changelog from the CDN

The first argument is the local parent bundle. `--add` can be a CDN path (matched by file name) when entry sourcing uses the CDN:
Expand Down
2 changes: 1 addition & 1 deletion docs/cli/changelog/cmd-upload.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ Use `--artifact-type` to choose what to upload:
Keying differs by artifact type:

- **Changelog entries** are uploaded **once** under the authoring owner/repo/branch, regardless of how many products they list (or none). The owner is resolved from `--owner`, then `bundle.owner` in `changelog.yml`, then the git remote origin; the repo from `--repo`, then `bundle.repo`, then the git remote origin; the branch from `--branch`, then the current checkout's branch. The branch is stored verbatim, so a branch name containing `/` (for example `feature/foo`) becomes additional key segments.
- **Bundles** are uploaded once per product listed in the bundle's `products[].product` field (a bundle that declares multiple products is written under each product prefix).
- **Bundles** are uploaded once per product listed in the bundle's `products[].product` field (a bundle that declares multiple products is written under each product prefix). Amend sidecars produced from a CDN parent (`changelog bundle-amend /bundle/{product}/{file}.yaml`) are uploaded like any other bundle YAML.

## Upload targets

Expand Down
15 changes: 12 additions & 3 deletions docs/data/release-notes/bundle.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,16 +286,25 @@ To apply additional filtering by the changelog type, areas, or products, add [bu
## Amend bundles [changelog-bundle-amend]

When you need to add changelogs to an existing bundle, you can use the `docs-builder changelog bundle-amend` command, which creates _amend bundles_.
The parent bundle path must be a local file (the amend sidecar is written next to it). `--add` and `--remove` accept the same CDN paths as `changelog bundle --files` when the authoring repo resolves. For example:
The parent may be a local bundle file (the sidecar is written next to it) or a published CDN locator such as `/bundle/kibana/9.3.0.yaml`. `--add` and `--remove` accept the same CDN entry paths as `changelog bundle --files` when the authoring repo resolves. For example:

```sh
docs-builder changelog bundle-amend \
/bundle/kibana/9.3.0.yaml \
--add /changelog/elastic/kibana/main/138723.yaml \
--output ./docs/releases
```

That writes `9.3.0.amend-1.yaml` under `./docs/releases`. Upload the sidecar with [](/cli/changelog/upload.md) (`--artifact-type bundle`); the command does not upload it for you.

To read local changelog files from disk instead of the CDN, pass `--force-local`. A local parent file still works as before:

```sh
docs-builder changelog bundle-amend \
./docs/releases/9.3.0.yaml \
--add /changelog/elastic/kibana/main/138723.yaml
```

To read local changelog files from disk instead of the CDN, pass `--force-local`.

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

To remove entries from an existing bundle without editing the parent file, use `--remove` on the same command:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@

namespace Elastic.Documentation.Configuration.ReleaseNotes;

/// <summary>
/// One named parent bundle plus its amend sidecars, fetched from the product bundle tree
/// (<c>bundle/{product}/</c>) without downloading the rest of the catalog.
/// </summary>
public readonly record struct CdnNamedBundle(
string FileName,
string Content,
IReadOnlyList<CdnChangelogEntry> AmendSidecars);

/// <summary>
/// Fetches changelog bundles for a single product from the public CDN. It reads
/// <c>{base}/bundle/{product}/registry.json</c> to enumerate bundles, downloads each
Expand Down Expand Up @@ -143,6 +152,95 @@ public async Task<IReadOnlyList<LoadedBundle>> FetchAsync(
return _bundleLoader.LoadBundlesFromContent(contents, emitWarning);
}

/// <summary>
/// Fetches a single parent bundle and its listed <c>{name}.amend-N.yaml</c> sidecars from the
/// product tree. Reads <c>bundle/{product}/registry.json</c> (the scrubber-maintained bundle
/// index, not the changelog-entry pool) so sibling amends can be enumerated without downloading
/// the rest of the catalog. Returns <c>null</c> after emitting an error when the registry cannot
/// be read, the file is not listed, or a listed parent/amend cannot be fetched.
/// </summary>
public async Task<CdnNamedBundle?> FetchNamedBundleAsync(
Uri baseUri,
string product,
string fileName,
Action<string> emitError,
Cancel ctx)
{
if (!ChangelogKeys.IsValidProduct(product))
{
emitError($"Invalid changelog product '{product}': must be non-empty ASCII letters, digits, '_' or '-'.");
return null;
}

if (!ChangelogKeys.IsSafeFileName(fileName))
{
emitError($"Invalid changelog bundle file name '{fileName}'.");
return null;
}

var registryUri = Combine(baseUri, [.. ChangelogKeys.BundleSegments(product), ChangelogKeys.RegistryFileName]);

ChangelogRegistry? registry;
try
{
registry = await FetchRegistryAsync(registryUri, ctx).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
emitError($"Could not fetch changelog registry for product '{product}' from {registryUri}: {ex.Message}");
return null;
}

if (registry is null)
{
emitError($"Changelog registry for product '{product}' at {registryUri} was empty or unparseable.");
return null;
}

if (registry.SchemaVersion > SupportedSchemaVersion)
{
emitError(
$"Changelog registry for product '{product}' uses schema version {registry.SchemaVersion}, but this build only understands version {SupportedSchemaVersion}. Update docs-builder.");
return null;
}

var listed = registry.Bundles
.Where(b => ChangelogKeys.IsSafeFileName(b.File))
.ToList();

var parentEntry = listed.Find(b => string.Equals(b.File, fileName, StringComparison.OrdinalIgnoreCase));
if (parentEntry?.File is null)
{
emitError($"Bundle '{fileName}' is not listed in the changelog registry for product '{product}'.");
return null;
}

var parent = await DownloadOrCacheBundleAsync(baseUri, product, parentEntry.File, parentEntry.ETag, emitError, ctx)
.ConfigureAwait(false);
if (parent is null)
return null;

var amendEntries = listed
.Where(b => b.File is not null
&& BundleAmendMerger.IsAmendFile(b.File)
&& string.Equals(BundleAmendMerger.GetParentBundlePath(b.File), parent.Value.FileName, StringComparison.OrdinalIgnoreCase))
.OrderBy(b => BundleAmendMerger.GetAmendFileNumber(b.File!))
.ToList();

var amends = new List<CdnChangelogEntry>(amendEntries.Count);
foreach (var amend in amendEntries)
{
ctx.ThrowIfCancellationRequested();
var fetched = await DownloadOrCacheBundleAsync(baseUri, product, amend.File!, amend.ETag, emitError, ctx)
.ConfigureAwait(false);
if (fetched is null)
return null;
amends.Add(new CdnChangelogEntry(fetched.Value.FileName, fetched.Value.Content));
}

return new CdnNamedBundle(parent.Value.FileName, parent.Value.Content, amends);
}

private async Task<ChangelogRegistry?> FetchRegistryAsync(Uri registryUri, Cancel ctx)
{
_logger.LogInformation("Fetching changelog registry {RegistryUri}", registryUri);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,58 @@ public static string BundleRegistryKey(string productGroup) =>
public static string ChangelogRegistryKey(string poolGroup) =>
$"{ChangelogPrefix}{poolGroup}/{RegistryFileName}";

/// <summary>
/// Parses a CDN bundle locator into product and file name. Accepts
/// <c>/bundle/{product}/{file}</c> (leading slash optional) or an absolute http(s) URL whose path
/// contains that layout. Returns false for changelog-pool paths. Amend sidecars still parse as
/// locators; callers that need a parent should reject them with <c>BundleAmendMerger.IsAmendFile</c>.
/// </summary>
public static bool TryParseBundleLocator(
string? input,
[NotNullWhen(true)] out string? product,
[NotNullWhen(true)] out string? fileName)
{
product = null;
fileName = null;
if (string.IsNullOrWhiteSpace(input))
return false;

var trimmed = input.Trim();
string key;
if (Uri.TryCreate(trimmed, UriKind.Absolute, out var uri) && uri.Scheme is "http" or "https")
{
var path = uri.AbsolutePath.TrimStart('/');
var prefixAt = path.IndexOf(BundlePrefix, StringComparison.Ordinal);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TryParseBundleLocator currently accepts any absolute URL path containing the substring bundle/. For example, (cdn.example/redacted) is treated as a valid bundle locator because IndexOf("bundle/") matches inside notbundle/. That input then proceeds as CDN-parent flow instead of being rejected.

Can we tighten this to segment-aware matching (for example path starts with bundle/ or contains /bundle/)?

if (prefixAt < 0)
return false;
key = path[prefixAt..];
}
else
{
key = trimmed.TrimStart('/');
if (!key.StartsWith(BundlePrefix, StringComparison.Ordinal))
return false;
}

product = ExtractBundleGroup(key);
if (product is null)
return false;

var fileStart = BundlePrefix.Length + product.Length + 1;
if (key.Length <= fileStart)
return false;

fileName = key[fileStart..];
if (!IsSafeFileName(fileName))
{
product = null;
fileName = null;
return false;
}

return true;
}

/// <summary>
/// Extracts the product group from a <c>bundle/{product}/{file}</c> key, or null when
/// <paramref name="s3Key"/> is not a bundle key with a valid product segment ahead of the file name.
Expand Down
Loading
Loading