Skip to content

Commit a5a8114

Browse files
cotticlaudecursoragent
committed
changelog: add CDN consumer mode to the {changelog} directive
Lets the {changelog} directive load bundles published to the public S3/CloudFront bucket via :cdn:, with optional :version: filtering, so a docset can render another product's changelog without the bundle files living in-repo. Also renames the per-product manifest from registry-index.json to registry.json, reserving "registry-index" for a possible future bucket-wide meta-registry. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 3a316ab commit a5a8114

22 files changed

Lines changed: 1119 additions & 150 deletions

File tree

docs/development/changelog-bundle-registry.md

Lines changed: 70 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,14 @@ navigation_title: Changelog bundle registry
55
# Changelog bundle registry and CDN delivery
66

77
This page describes how changelog **bundles** are published to a public, CDN-fronted
8-
S3 bucket, how the per-product `registry-index.json` manifest is produced, and the
9-
planned `cdn:` mode for the [`{changelog}` directive](/syntax/changelog.md) that will
10-
consume bundles directly from the CDN instead of from a local folder.
8+
S3 bucket, how the per-product `registry.json` manifest is produced, and the
9+
`cdn:` mode for the [`{changelog}` directive](/syntax/changelog.md) that consumes
10+
bundles directly from the CDN instead of from a local folder.
1111

1212
:::{note}
13-
The **producer** side (manifest generation + scrubber pass-through) is implemented.
14-
The **consumer** side (`{changelog}` directive `cdn:` mode) is **planned** and is
15-
documented here as a design, not as shipped behavior.
13+
Both sides are implemented: the **producer** (manifest generation + scrubber pass-through)
14+
and the **consumer** (`{changelog}` directive `cdn:` mode). Remaining follow-ups are listed
15+
under [Implementation notes](#implementation-notes).
1616
:::
1717

1818
## Motivation
@@ -35,34 +35,34 @@ copies, no cross-repo file syncing.
3535
│ (docs-actions)│ bundle ───────────▶ │ S3 bucket │ │ Lambda │
3636
└──────────────┘ │ │ └─────────┬─────────┘
3737
│ │ {product}/bundles/*.yaml │ scrub + copy
38-
│ also refreshes │ {product}/registry-index.json │ (pass-through for
39-
└──────────────────────────────▶ │ │ registry-index.json)
38+
│ also refreshes │ {product}/registry.json │ (pass-through for
39+
└──────────────────────────────▶ │ │ registry.json)
4040
└────────────────────┘ ▼
4141
┌───────────────────┐
42-
{changelog} directive (planned) │ Public bundles │
42+
{changelog} directive (cdn:) │ Public bundles │
4343
reads via CDN ◀─────────────── │ S3 bucket + CDN │
4444
└───────────────────┘
4545
```
4646

4747
1. **Producer**`changelog upload --artifact-type bundle --target s3` (invoked by the
4848
docs-actions changelog upload workflow) uploads each bundle to
4949
`{product}/bundles/{file}` in the **private** bucket, then refreshes
50-
`{product}/registry-index.json` for every product the run touched.
50+
`{product}/registry.json` for every product the run touched.
5151
2. **Scrubber Lambda** — triggered by `s3:ObjectCreated` on the private bucket, it scrubs
5252
private repository references out of bundle YAML and writes the sanitized copy to the
53-
**public** bucket. The `registry-index.json` object is copied through **verbatim**.
54-
3. **Consumer (planned)** — the `{changelog}` directive in `cdn:` mode reads
55-
`{product}/registry-index.json` from the CDN, then fetches each listed bundle.
53+
**public** bucket. The `registry.json` object is copied through **verbatim**.
54+
3. **Consumer** — the `{changelog}` directive in `cdn:` mode reads
55+
`{product}/registry.json` from the CDN, then fetches each listed bundle.
5656

5757
### Why a registry instead of an S3 listing
5858

5959
The public surface is a CDN (CloudFront) in front of S3. CloudFront does not expose bucket
6060
listing, so the consumer cannot enumerate `{product}/bundles/`. The registry is a stable,
6161
cacheable manifest at a predictable key that lists exactly which bundles exist for a product.
6262

63-
## `registry-index.json` format
63+
## `registry.json` format
6464

65-
Stored at `{product}/registry-index.json`. Serialized with `snake_case` keys.
65+
Stored at `{product}/registry.json`. Serialized with `snake_case` keys.
6666

6767
```json
6868
{
@@ -102,10 +102,10 @@ reads of the same bucket).
102102
## Producer details (implemented)
103103

104104
The refresh runs inside `ChangelogUploadService` after a successful **bundle** upload (it is
105-
skipped for `--artifact-type changelog`). `RegistryIndexBuilder`:
105+
skipped for `--artifact-type changelog`). `RegistryBuilder`:
106106

107107
- Groups the run's upload targets by product (from the `{product}/bundles/{file}` key).
108-
- For each product, derives one `registry-index` entry per bundle (file name, that product's
108+
- For each product, derives one `registry` entry per bundle (file name, that product's
109109
target, locally-computed S3 ETag).
110110
- Reads the existing manifest from S3, merges by file name (re-uploads replace their entry;
111111
others are preserved), and writes the merged manifest back.
@@ -149,19 +149,13 @@ for the producer**:
149149
covering the registry `CopyObject` pass-through and the `ObjectRemoved` delete.
150150
- A CloudFront cache policy tuned for the manifest already exists (default TTL 1h, min 60s).
151151

152-
The scrubber only passes through keys accepted by `RegistryIndexKey.IsRegistryIndex` (a single
153-
`{product}/registry-index.json` segment), so arbitrary JSON cannot reach the public surface.
152+
The scrubber only passes through keys accepted by `RegistryKey.IsRegistry` (a single
153+
`{product}/registry.json` segment), so arbitrary JSON cannot reach the public surface.
154154

155155
**No new docs-actions workflow logic is required** for the producer either: the refresh is a
156156
side-effect of the existing `changelog upload` step; docs-actions only needs a docs-builder
157157
build that includes this feature.
158158

159-
:::{note}
160-
The CDN cache policy comment refers to `registry.json` while the implementation uses
161-
`registry-index.json`. This is only a comment (there is no path-based cache behavior), so it is
162-
harmless, but the two should be aligned to avoid confusion.
163-
:::
164-
165159
### Consistency notes the consumer must tolerate
166160

167161
- The manifest pass-through and the per-bundle scrub are independent S3 events, so the index
@@ -171,59 +165,78 @@ harmless, but the two should be aligned to avoid confusion.
171165

172166
Consumers must therefore treat a missing bundle as non-fatal (skip + warn), not an error.
173167

174-
## Consumer: `{changelog}` directive `cdn:` mode (planned)
168+
## Consumer: `{changelog}` directive `cdn:` mode (implemented)
175169

176-
### Proposed syntax
170+
### Syntax
177171

178172
```markdown
179173
:::{changelog}
180174
:cdn: elasticsearch
181175
:::
182176
```
183177

184-
The directive would accept a `:cdn:` option naming the **product** to fetch. The CDN base
185-
URL is environment configuration (not authored per page), defaulting to the public changelog
186-
bundles distribution and overridable for staging/local.
178+
The directive accepts a `:cdn:` option naming the **product** to fetch (validated against
179+
`[a-zA-Z0-9_-]+`). The CDN base URL is environment configuration, not authored per page: it
180+
defaults to the public changelog bundles distribution and is overridable via the
181+
`DOCS_BUILDER_CHANGELOG_CDN` environment variable (absolute `http`/`https` URL) for
182+
staging, local development, and testing.
187183

188-
When `:cdn:` is set, the local-folder argument is ignored and the directive sources bundles
189-
from the CDN instead.
184+
When `:cdn:` is set, the local-folder argument is ignored (a warning is emitted if one is
185+
also given) and the directive sources bundles from the CDN instead.
190186

191187
### Fetch flow
192188

193-
1. `GET {cdnBase}/{product}/registry-index.json`.
189+
1. `GET {cdnBase}/{product}/registry.json`.
194190
2. Parse it; for each `bundles[].file`, `GET {cdnBase}/{product}/bundles/{file}`.
195191
3. Feed the downloaded YAML into the existing `BundleLoader``MergeBundlesByTarget`
196192
render pipeline. **Rendering is unchanged**; only the source of the bundle bytes differs.
197193

198-
Because public bundles are already scrubbed and resolved, the existing private-repo link and
199-
description visibility logic still applies via `assembler.yml`, exactly as for local bundles.
200-
201-
### Open design decisions
202-
203-
- **Build-time network access.** Fetching at build time makes builds depend on the CDN.
204-
Options: (a) fetch during the build with an on-disk cache under the docs-builder app-data
205-
directory (mirrors `CrossLinkFetcher`/link-index); (b) a separate fetch step that
206-
materializes bundles into the working tree before the build. Caching + ETag revalidation
207-
against the CDN is the likely answer.
208-
- **Local/offline development.** The directive must degrade gracefully when the CDN is
209-
unreachable (use cache; otherwise emit a clear, actionable diagnostic) so local builds and
210-
PR previews don't hard-fail on transient network issues.
211-
- **Missing/partial bundles.** Skip-and-warn per the consistency notes above; never fail the
212-
whole page on a single missing bundle.
213-
- **Schema evolution.** Honor `schema_version`; a newer major than the consumer understands
214-
should produce a clear error rather than a silent mis-parse.
194+
Implemented by `CdnChangelogFetcher` (in `Elastic.Documentation.Configuration`) and
195+
`BundleLoader.LoadBundlesFromContent`. Because public bundles are already scrubbed and
196+
**resolved** (entries are inline/self-contained), the fetcher never needs to download separate
197+
entry files; the existing private-repo link and description visibility logic still applies via
198+
`assembler.yml`, exactly as for local bundles.
199+
200+
### Behavior and decisions
201+
202+
- **Synchronous fetch at parse time.** Directive finalization runs inside the synchronous
203+
Markdig block parser — the same place the local-folder loader does its file reads — so the
204+
fetcher uses `HttpClient.Send` rather than introducing a separate async pre-parse phase.
205+
- **Per-build memoization.** Results are cached in-process keyed by base URL + product, so
206+
repeated `{changelog}` blocks (across pages) fetch each product only once per build.
207+
- **Missing/partial bundles.** A registry that cannot be fetched or parsed is a hard error
208+
(the block renders empty); an individual bundle that 404s or fails to parse is a warning and
209+
is skipped, per the [consistency notes](#consistency-notes-the-consumer-must-tolerate).
210+
- **Schema evolution.** A `schema_version` newer than the consumer understands produces a
211+
clear error rather than a silent mis-parse.
215212
- **Filtering.** `:type:`, `:link-visibility:`, `:description-visibility:`, `:dropdowns:` and
216213
`hide-features` apply identically to CDN-sourced bundles.
217-
- **Caching key.** Use the CDN response ETag (not the registry `etag` field) for revalidation.
214+
- **Version selection.** `:version:` renders a single target and works in both modes (shared match
215+
on registry `target` or bundle file name, see `ChangelogVersionMatch`). In CDN mode the fetcher
216+
uses it to download only the matching registry entry instead of every listed bundle.
217+
- **Security.** The base URL is trusted configuration; the product and registry-supplied bundle
218+
file names are validated to single path segments so neither can traverse outside
219+
`{product}/bundles/`.
220+
221+
### Follow-ups (not yet implemented) [implementation-notes]
222+
223+
- **Persistent / offline cache.** The first iteration memoizes per build but does not persist a
224+
disk cache, so a cold build always reaches the CDN and an unreachable CDN yields an empty
225+
render plus an error diagnostic. A follow-up should add an ETag-keyed on-disk cache under the
226+
docs-builder app-data directory (mirroring `CrossLinkFetcher`) with offline fallback.
227+
- **`serve` mode staleness.** Because the in-process cache has no invalidation, a long-running
228+
`serve` pins a product's CDN content for the process lifetime. Acceptable for now (serve
229+
targets local markdown authoring, not changelog bundles); revisit alongside the disk cache.
218230
- **CDN staleness.** The distribution caches the manifest with a 1h default TTL (60s min), so a
219-
freshly uploaded bundle may not appear in the CDN-served `registry-index.json` for up to an
220-
hour. Acceptable for release notes, but if faster propagation is needed the producer (or a
221-
docs-actions step) would have to issue a CloudFront invalidation on registry write. Out of
222-
scope for the first iteration.
231+
freshly uploaded bundle may not appear in the CDN-served `registry.json` for up to an
232+
hour. If faster propagation is needed the producer (or a docs-actions step) would issue a
233+
CloudFront invalidation on registry write.
234+
- **Caching key.** When the disk cache lands, use the CDN response ETag (not the registry
235+
`etag` field) for revalidation.
223236

224-
### Out of scope for the first iteration
237+
### Out of scope
225238

226-
- Cross-product aggregation in a single directive block (start with one product per block).
239+
- Cross-product aggregation in a single directive block (one product per block).
227240
- Authenticated/private CDN access (the public bucket is anonymous-read by design).
228241

229242
## Related

docs/syntax/changelog.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ The directive supports the following options:
2828
| `:description-visibility: value` | Visibility of changelog **record** descriptions (YAML `description` on each entry) | `auto` |
2929
| `:dropdowns:` | Render breaking changes, deprecations, known issues, and highlights as expandable dropdowns instead of flattened bulleted lists | false |
3030
| `:config: path` | Path to `changelog.yml` configuration | auto-discover |
31+
| `:cdn: product` | Source bundles for a product from the public changelog CDN instead of a local folder | (local folder) |
32+
| `:version: target` | Render only the single bundle matching this target/version | (all versions) |
3133

3234
### Example with options
3335

@@ -161,6 +163,43 @@ Explicit path to a `changelog.yml` or `changelog.yaml` configuration file, relat
161163

162164
Both explicit and auto-discovered paths must resolve within the repository checkout directory and must not traverse symlinks.
163165

166+
#### `:cdn:` [cdn]
167+
168+
Sources bundles for a single **product** from the public changelog CDN instead of a local folder, so a docset can render another product's release notes without vendoring bundle YAML.
169+
170+
```markdown
171+
:::{changelog}
172+
:cdn: elasticsearch
173+
:::
174+
```
175+
176+
The value names the product (must match `[a-zA-Z0-9_-]+`) and maps to `{product}/registry.json` plus the bundles it lists on the CDN. When `:cdn:` is set, the local-folder argument is ignored. All other options (`:type:`, `:link-visibility:`, `:description-visibility:`, `:dropdowns:`, `:subsections:`) and `hide-features` apply identically to CDN-sourced bundles.
177+
178+
The CDN base URL is build configuration, not authored per page: it defaults to the public changelog bundles distribution and can be overridden with the `DOCS_BUILDER_CHANGELOG_CDN` environment variable (an absolute `http`/`https` URL) for staging or local testing.
179+
180+
Fetching happens at build time. If the registry cannot be fetched the block renders empty and an error is emitted; an individual bundle that is missing from the CDN is skipped with a warning. For the full design — including the manifest format and infrastructure — see [Changelog bundle registry and CDN delivery](/development/changelog-bundle-registry.md).
181+
182+
#### `:version:` [version]
183+
184+
Renders only the **single** bundle whose target matches the given value, instead of every bundle for the source. A bundle matches when the value equals its declared `target` (for example `9.4.0`, or a date like `2026-04-09`) or its file name (with or without extension). Matching is case-insensitive.
185+
186+
```markdown
187+
:::{changelog}
188+
:version: 9.4.0
189+
:::
190+
```
191+
192+
This works for both local-folder and `:cdn:` sources. In `:cdn:` mode it is also an optimization: only the matching bundle is downloaded from the CDN rather than every file the registry lists.
193+
194+
```markdown
195+
:::{changelog}
196+
:cdn: elasticsearch
197+
:version: 9.4.0
198+
:::
199+
```
200+
201+
If no bundle matches, the directive renders nothing and emits a warning (it does not fall back to showing all versions).
202+
164203
## Filtering entries with bundle rules
165204

166205
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.

src/Elastic.Documentation.Configuration/Elastic.Documentation.Configuration.csproj

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@
1616
<ProjectReference Include="..\Elastic.Documentation.Tooling\Elastic.Documentation.Tooling.csproj" />
1717
</ItemGroup>
1818

19+
<ItemGroup>
20+
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
21+
<_Parameter1>Elastic.Markdown.Tests</_Parameter1>
22+
</AssemblyAttribute>
23+
</ItemGroup>
24+
1925
<ItemGroup>
2026
<PackageReference Include="DotNet.Glob" />
2127
<PackageReference Include="NetEscapades.EnumGenerators" />

src/Elastic.Documentation.Configuration/ReleaseNotes/BundleLoader.cs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,67 @@ public IReadOnlyList<LoadedBundle> LoadBundles(
5858
return loadedBundles;
5959
}
6060

61+
/// <summary>
62+
/// Loads bundles from in-memory YAML content rather than a folder. Used by the <c>changelog</c>
63+
/// directive in <c>cdn:</c> mode, where bundle files are fetched over HTTP. CDN bundles are
64+
/// self-contained (entries are inline), so no entry-file resolution against the filesystem occurs;
65+
/// any file-only entry reference is skipped with a warning. Amend files are still merged by name.
66+
/// </summary>
67+
/// <param name="bundles">Bundle file name and raw YAML content pairs.</param>
68+
/// <param name="emitWarning">Callback to emit warnings during loading.</param>
69+
/// <returns>A list of successfully loaded bundles.</returns>
70+
public IReadOnlyList<LoadedBundle> LoadBundlesFromContent(
71+
IReadOnlyList<(string FileName, string Content)> bundles,
72+
Action<string> emitWarning)
73+
{
74+
var loadedBundles = new List<LoadedBundle>(bundles.Count);
75+
76+
foreach (var (fileName, content) in bundles)
77+
{
78+
Bundle bundleData;
79+
try
80+
{
81+
bundleData = ReleaseNotesSerialization.DeserializeBundle(content);
82+
}
83+
catch (YamlException e)
84+
{
85+
emitWarning($"Failed to parse changelog bundle '{fileName}': {e.Message}");
86+
continue;
87+
}
88+
89+
var version = GetVersionFromBundle(bundleData) ?? fileSystem.Path.GetFileNameWithoutExtension(fileName);
90+
var repo = GetRepoFromBundle(bundleData);
91+
var owner = GetOwnerFromBundle(bundleData);
92+
var entries = ResolveInlineEntries(bundleData, fileName, emitWarning);
93+
94+
loadedBundles.Add(new LoadedBundle(version, repo, owner, bundleData, fileName, entries));
95+
}
96+
97+
return MergeAmendFiles(loadedBundles);
98+
}
99+
100+
/// <summary>
101+
/// Resolves only inline (self-describing) entries from a bundle. File-only references are not
102+
/// resolvable without the surrounding changelog directory, so they are skipped with a warning.
103+
/// </summary>
104+
private static List<ChangelogEntry> ResolveInlineEntries(Bundle bundleData, string fileName, Action<string> emitWarning)
105+
{
106+
var entries = new List<ChangelogEntry>(bundleData.Entries.Count);
107+
foreach (var entry in bundleData.Entries)
108+
{
109+
if (!string.IsNullOrWhiteSpace(entry.Title) && entry.Type != null)
110+
{
111+
entries.Add(ReleaseNotesSerialization.ConvertBundledEntry(entry));
112+
continue;
113+
}
114+
115+
emitWarning(
116+
$"Bundle '{fileName}' has a non-self-contained entry (file '{entry.File?.Name}'); CDN bundles must inline their entries. Skipping.");
117+
}
118+
119+
return entries;
120+
}
121+
61122
/// <summary>
62123
/// Resolves entries from a bundle, loading from file references if needed.
63124
/// </summary>

0 commit comments

Comments
 (0)