Skip to content
Merged
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
108 changes: 108 additions & 0 deletions docs/cli-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -4918,6 +4918,114 @@
}
]
},
{
"path": [
"changelog"
],
"name": "unpack",
"summary": "Recreate individual changelog YAML files from a bundle using changelog add and changelog note.",
"notes": "Writes one file per bundle entry through the same writers as changelog add (PR-anchored)\nand changelog note (no PR, or products with versions). Filenames follow add and note rules,\nnot the bundle file.name provenance. Checksums are not reproduced.\nBundles that were scrubbed during changelog upload (public-bucket copies) have their\nprivate PRs and issues removed, so they are less likely to unpack successfully. Prefer the\nprivate-side bundle YAML.",
"usage": "docs-builder changelog unpack \u003Cbundle\u003E [options]",
"examples": [],
"parameters": [
{
"role": "positional",
"name": "bundle",
"type": "string",
"required": true,
"summary": "Local bundle or amend YAML file to unpack. The file must exist on disk; CDN locators such as /bundle/{product}/{file}.yaml are not accepted. A parent bundle is merged with sibling .amend-* bundles first (same as changelog render). An amend bundle unpacks only that file\u0027s entries; exclude-entries are skipped.",
"validations": [
{
"kind": "rejectSymbolicLinks"
},
{
"kind": "existing"
},
{
"kind": "fileExtensions",
"values": [
"yml",
"yaml"
]
}
]
},
{
"role": "flag",
"name": "config",
"type": "string",
"required": false,
"summary": "Path to changelog.yml. Defaults to docs/changelog.yml. Type and area validation uses this configuration.",
"validations": [
{
"kind": "rejectSymbolicLinks"
},
{
"kind": "existing"
},
{
"kind": "fileExtensions",
"values": [
"yml",
"yaml"
]
}
]
},
{
"role": "flag",
"name": "output",
"type": "string",
"required": false,
"summary": "Directory for written changelog files. Defaults to bundle.directory in changelog.yml, then the current directory."
},
{
"role": "flag",
"name": "concise",
"type": "boolean",
"required": false,
"summary": "Omit schema reference comments from generated YAML, matching changelog add --concise.",
"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",
"type": "boolean",
"required": false,
"summary": "Skip cloning private repositories"
}
]
},
{
"path": [
"changelog"
Expand Down
2 changes: 1 addition & 1 deletion docs/cli/changelog/cmd-bundle.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ docs-builder changelog bundle serverless-release 2026-08-13 \

## Bundles are self-contained

Every bundle embeds the full content of each changelog entry (`title`, `type`, `products`, and so on), plus a `file` block recording the source file name and checksum for provenance. Rendering — via the `{changelog}` directive, `changelog render`, or the CDN pipeline — never reads the original changelog files, so you can clean them up with `docs-builder changelog remove` immediately after bundling.
Every bundle embeds the full content of each changelog entry (`title`, `type`, `products`, and so on), plus a `file` block recording the source file name and checksum for provenance. Rendering — via the `{changelog}` directive, `changelog render`, or the CDN pipeline — never reads the original changelog files, so you can clean them up with `docs-builder changelog remove` immediately after bundling. To recreate changelog files later, use [`changelog unpack`](/cli/changelog/unpack.md). Unpack does not restore the original checksums. Public bundles produced by the upload scrubber are less likely to unpack because private PRs and issues may have been removed.

When you bundle from a PR list or GitHub release and the command is sourcing from the CDN, it also adds uploaded changelogs whose `products[].versions` include that version (from `output_products`). Those files are appended to the same `entries` list; `rules.bundle` applies to them too. This automatic add does not apply to git-range bundles or `--force-local`. For more detail, see [Bundle changelogs](/data/release-notes/bundle.md).

Expand Down
36 changes: 36 additions & 0 deletions docs/cli/changelog/cmd-unpack.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
## Description

Recreate individual changelog YAML files from a bundle.

Each entry is written through the same path as [`changelog add`](/cli/changelog/add.md) (PR-anchored entries) or [`changelog note`](/cli/changelog/note.md) (entries with no PR, or products that include versions). The output is a new changelog YAML file, not a byte-for-byte copy of the original files. Bundle `file.checksum` values are provenance of the sourced YAML at bundle time and will not match the unpacked files.

When you pass a full bundle file, its `.amend-*` files are merged first, the same way [`changelog render`](/cli/changelog/render.md) does. When you pass an amendment bundle file, only that file's `entries` are unpacked. `exclude-entries` are skipped; they are name and checksum stubs, not changelog files.

:::{important}
The bundle argument must be a local `.yaml` or `.yml` file that exists on disk.

If you download a bundle, get it from the private CDN instead of the public CDN.
Bundles in the public CDN have the private pull request and issue links removed.
:::

## Filenames

Filenames follow `changelog add` and `changelog note` rules, for example:

- Add: `{pr}.yaml` (or `{pr}-{pr}.yaml` when one entry cites multiple PRs)
- Note: `note-{slug}.yml`

If that name differs from the bundle provenance `file.name`, the command emits a warning and still writes the `add` or `note` name.

## Examples

```sh
docs-builder changelog unpack ./docs/releases/elasticsearch-serverless-2026-09-08.yaml \
--output ./docs/changelog
```

```sh
docs-builder changelog unpack ./docs/releases/9.3.0.amend-1.yaml \
--output ./docs/changelog \
--concise
```
4 changes: 3 additions & 1 deletion docs/cli/changelog/cmd-upload.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

Upload changelog entries or bundle artifacts to S3 or Elasticsearch. The command discovers `.yaml` and `.yml` files in a local directory and uploads only files whose content hash changed since the last run. Changelog entries are uploaded once under `changelog/{org}/{repo}/{branch}/{file}`, keyed by the authoring owner, repository, and branch; bundles are uploaded under `bundle/{product}/{file}`, product-scoped from the bundle YAML.

A downstream scrubber copies published objects to the public bucket and removes pull request and issue links that are not on the allowlist (unlike bundle-time `# PRIVATE:` sentinels on the private side). Those public bundles are less likely to work with [`changelog unpack`](/cli/changelog/unpack.md).

To create bundles first, use [](/cli/changelog/bundle.md).
For the end-to-end workflow, see [](/data/release-notes/bundle.md).

Expand Down Expand Up @@ -79,7 +81,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.
- **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). 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
2 changes: 1 addition & 1 deletion docs/cli/changelog/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ The `changelog` commands manage a file-per-change workflow that produces release
## Typical workflow

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`. For items not tied to a PR (known issues, advisories), use `docs-builder changelog note`.
2. **Create** — add a changelog YAML for each notable PR: `docs-builder changelog add`. For items not tied to a PR (known issues, advisories), use `docs-builder changelog note`. To recreate files from an existing bundle, use `docs-builder changelog unpack`.
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`

Expand Down
10 changes: 9 additions & 1 deletion docs/data/release-notes/create.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,18 @@ If you already have automated release notes for GitHub releases, you can use the
Any command strings that contain special characters (such as backquotes) must be preceded with a backslash escape character (`\`).
:::

For the most up-to-date command syntax, use the `-h` option or refer to [](/cli/changelog/add.md) and [](/cli/changelog/note.md).
For the most up-to-date command syntax, use the `-h` option or refer to [](/cli/changelog/add.md) and [](/cli/changelog/note.md).

1. [Review the output file](#review).

## Recreate changelog files from a bundle [unpack]

If the original changelog files were deleted after bundling, you can recreate them from a bundle with [`changelog unpack`](/cli/changelog/unpack.md). The command maps each entry onto `changelog add` or `changelog note`; it does not restore original checksums or comments.

:::{tip}
Use local bundles or bundles downloaded from the private CDN. Bundles scrubbed during [`changelog upload`](/cli/changelog/upload.md) drop private PRs and issues, which means the `unpack` command will generate incomplete changelogs.
:::

## 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).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,35 @@ public static bool TitleNeedsDefensiveYamlQuoting(string? title)

private const string PrivateReferenceSentinelPrefix = "# PRIVATE:";

/// <summary>
/// Unwraps a <c># PRIVATE:</c> sentinel to the underlying PR or issue reference.
/// Returns the trimmed original when the value is not a sentinel.
/// </summary>
public static string? StripPrivateReferenceSentinel(string? reference)
{
if (string.IsNullOrWhiteSpace(reference))
return null;

var trimmed = reference.Trim();
if (!trimmed.StartsWith(PrivateReferenceSentinelPrefix, StringComparison.OrdinalIgnoreCase))
return trimmed;

var underlying = trimmed[PrivateReferenceSentinelPrefix.Length..].Trim();
return string.IsNullOrWhiteSpace(underlying) ? null : underlying;
}

/// <summary>
/// Unwraps <c># PRIVATE:</c> sentinels in a reference list. Empty after stripping are omitted.
/// </summary>
public static string[]? StripPrivateReferenceSentinels(IReadOnlyList<string>? references)
{
if (references is not { Count: > 0 })
return null;

var stripped = references.Select(StripPrivateReferenceSentinel).Where(r => !string.IsNullOrWhiteSpace(r)).Select(r => r!).ToArray();
return stripped.Length == 0 ? null : stripped;
}

/// <summary>
/// Returns the first repository segment from a bundle <paramref name="repo"/> string
/// (e.g. <c>elasticsearch+kibana</c> → <c>elasticsearch</c>) for defaulting bare numeric PR/issue refs.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,54 @@ public async Task<bool> CreateNote(IDiagnosticsCollector collector, CreateChange
}
}

/// <summary>
/// Writes a single changelog from fully populated fields without GitHub fetches or splitting
/// multi-PR inputs into one file per PR. Used by <c>changelog unpack</c> so a shipped bundle
/// entry round-trips as one add-shaped file.
/// </summary>
public async Task<bool> CreatePreparedChangelog(IDiagnosticsCollector collector, CreateChangelogArguments input, Cancel ctx)
{
try
{
var config = await _configLoader.LoadChangelogConfiguration(collector, input.Config, ctx);
if (config == null)
{
collector.EmitError(string.Empty, "Failed to load changelog configuration");
return false;
}

input = ApplyConfigDefaults(input, config) with { ExtractReleaseNotes = false, ExtractIssues = false };

if (input.Prs is { Length: > 1 })
{
if (!_validator.ValidateMultiplePrFormat(collector, input.Prs, input.Owner, input.Repo))
return false;
}
else if (!_validator.ValidatePrFormat(collector, input.Prs?.FirstOrDefault(), input.Owner, input.Repo))
return false;

if (input.Issues is { Length: > 1 })
{
if (!_validator.ValidateMultipleIssueFormat(collector, input.Issues, input.Owner, input.Repo))
return false;
}
else if (!_validator.ValidateIssueFormat(collector, input.Issues?.FirstOrDefault(), input.Owner, input.Repo))
return false;

return await WriteValidatedChangelog(collector, input, config, ctx);
}
catch (IOException ioEx)
{
collector.EmitError(string.Empty, $"IO error creating changelog: {ioEx.Message}", ioEx);
return false;
}
catch (UnauthorizedAccessException uaEx)
{
collector.EmitError(string.Empty, $"Access denied creating changelog: {uaEx.Message}", uaEx);
return false;
}
}

internal static CreateChangelogArguments ApplyConfigDefaults(CreateChangelogArguments input, ChangelogConfiguration config) =>
// Filename strategy is always Pr now; UsePrNumber is kept for backward compat but is effectively always true.
input with
Expand Down Expand Up @@ -390,27 +438,33 @@ private async Task<bool> CreateSingleChangelogAsync(
else if (!string.IsNullOrWhiteSpace(prUrl))
_logger.LogInformation("All required fields already provided, skipping PR API fetch for {PrUrl}", prUrl);

// If still no products, fall back to products.default or repo name inference
return await WriteValidatedChangelog(collector, input, config, ctx, prFetchFailed);
}

private async Task<bool> WriteValidatedChangelog(
IDiagnosticsCollector collector,
CreateChangelogArguments input,
ChangelogConfiguration config,
Cancel ctx,
bool prFetchFailed = false
)
{
if (input.Products.Count == 0)
{
var inferredProducts = InferProducts(config.ProductsConfiguration, input.Repo);
if (inferredProducts != null)
input = input with { Products = inferredProducts };
}

// Validate required fields
if (!_validator.ValidateRequiredFields(collector, input, prFetchFailed))
return false;

// Entries must not carry version targets; applicability comes from the origin branch
if (!_validator.ValidateNoVersionTarget(collector, input))
return false;

// Validate against configuration
if (!_validator.ValidateAgainstConfiguration(collector, input, config))
return false;

// Write changelog file
return await _fileWriter.WriteChangelogAsync(
collector,
input,
Expand Down
Loading
Loading