Changelog bundle registry and CDN delivery
This page describes how changelog bundles are published to a public, CDN-fronted
S3 bucket, how the per-product registry.json manifest and the shallow per-tree change
maps are produced by the scrubber Lambda (their sole writer — see
elastic/docs-eng-team#688), and the
cdn: mode for the {changelog} directive that consumes
bundles directly from the CDN instead of from a local folder.
Today the {changelog} directive only renders bundles that live in a folder inside the
docset (default changelog/bundles/). That requires every consuming repository to vendor
a copy of the bundle YAML it wants to render.
The link service (link infrastructure) already demonstrates the pattern we want: an S3 bucket fronted by CloudFront, publicly readable, with a small JSON index at a well-known key. We apply the same approach to changelog bundles so a docset can render another product's release notes by pointing the directive at the CDN — no vendored copies, no cross-repo file syncing.
- Uploader —
changelog upload --target s3(invoked by the docs-actions changelog upload workflow) writes bundle YAML tobundle/{product}/{file}and changelog-entry YAML tochangelog/{org}/{repo}/{branch}/{file}in the private bucket. That is all it does: it never writes aregistry.json(see Ownership per tree). - Scrubber Lambda (the registry's sole producer) — S3 events from the private bucket are
triggers, not instructions: for each affected key the Lambda reconciles the public object
against current private-bucket state (present → scrub and copy; absent → delete the public
copy), then rebuilds the affected group's
registry.jsonfrom the public bucket's actual listing (registry = f(state), neverf(event)). Any successful reconcile repairs all accumulated drift in the group, not just the change that triggered it. The touched tree's shallow change map is patched from the same listing pass. - Consumer — for each product declared under
release_notesindocset.yml, docs-builder readsbundle/{product}/registry.jsonfrom the CDN at build startup and fetches each listed bundle; the{changelog}directive incdn:mode then renders from the prefetched result.
The public surface is a CDN (CloudFront) in front of S3. CloudFront does not expose bucket
listing, so the consumer cannot enumerate bundle/{product}/. The registry is a stable,
cacheable manifest at a predictable key that lists exactly which bundles exist for a product.
Both indexes share this schema, serialized with snake_case keys.
The two trees part ways on who writes the manifest (the 2026-08-10 update on elastic/docs-eng-team#688 narrowed reconciliation to the bundle tree):
- Bundle index —
bundle/{product}/registry.json, public bucket only, produced exclusively by the scrubber Lambda'sBundleRegistryReconciler. This is the manifest the{changelog}directive and external CDN consumers enumerate, and the subject of the rest of this page. - Changelog-entry index —
changelog/{org}/{repo}/{branch}/registry.json, a legacy client-authored pass-through: the currentchangelog uploadnever writes one, but manifests written by older CLI versions are still mirrored verbatim from the private bucket, becausechangelog bundleentry sourcing still enumerates a pool through its manifest. It is not reconciled — itsproduceris null and its recordedetagis the old pre-scrub private-object hash (consumers ignore it). It goes away entirely once release-note discovery starts from PR lists (RFC elastic/docs-eng-team#698).
{
"schema_version": 1,
"producer": "changelog-scrubber-reconcile/1",
"product": "elasticsearch",
"generated_at": "2026-05-06T12:00:00+00:00",
"bundles": [
{ "file": "9.4.0.yaml", "target": "9.4.0", "etag": "…" },
{ "file": "9.3.0.yaml", "target": "9.3.0", "etag": "…" }
]
}
| Field | Meaning |
|---|---|
schema_version |
Bumped when consumers must change their parser. A manifest declaring a newer schema than the reconciler understands is reported and left untouched, never downgraded. |
producer |
The reconcile algorithm version that wrote the manifest (BundleRegistryReconciler.Producer, currently changelog-scrubber-reconcile/1). A mismatch forces a full metadata recompute and a rewrite even when entries come out identical — this is how metadata-logic fixes roll out to every group. Null on legacy client-written manifests. Consumers should ignore it. |
product |
Grouping identifier — the product for a bundle index (bundle/{product}/…) or the {org}/{repo}/{branch} prefix for a changelog-entry index (changelog/{org}/{repo}/{branch}/…). |
generated_at |
UTC timestamp of the last reconcile that wrote the manifest. Never the only thing that changes — a reconcile whose entries are identical skips the write. |
bundles[].file |
Bundle file name, resolved at bundle/{product}/{file} (or entry file at changelog/{org}/{repo}/{branch}/{file} for the entry index). |
bundles[].target |
Target version/date from the bundle's declaration of this product (may be null). For an amend sidecar ({name}.amend-{N}.yaml), recomputed on every reconcile against its parent in the same public prefix; a missing parent records null and self-corrects once the parent lands. Entry indexes record no target. |
bundles[].etag |
The public (CDN) object's ETag, recorded verbatim from the public listing. Usable for HTTP cache revalidation against the CDN and as the reconciler's cheap change detector. |
Bundles are sorted by target descending (newest first) with a deterministic tiebreak on
file, so the JSON is stable across reruns.
A group whose last public object is deleted gets its manifest deleted, not emptied: a
registry 404 means "unpublished" and is a fail-fast error for declared consumers (exactly as
for a product that was declared under release_notes but never published — the signal to
remove the declaration), while a manifest with an empty bundles list would read as a valid
zero-bundle state. The reconciler deliberately restores the former.
Alongside the per-group manifests, the scrubber maintains one shallow map per tree, at the
tree roots: bundle/registry.json and changelog/registry.json — not to be confused with the
per-group bundle/{product}/registry.json one level down. Each is a flat JSON object mapping
every folder (a product, or an {org}/{repo}/{branch} pool) to an opaque change token:
{
"elastic/elasticsearch/main": "9c01f2…",
"elastic/kibana/main": "3f2ab8…"
}
The token is a digest over the folder's full sorted file/ETag listing — deliberately not the last-touched object's ETag, which goes stale when an older object is deleted (the newest object, and therefore the value, would not change). Consumers must treat it as opaque: compare, never parse.
The maps exist for cache opt-out only: a consumer that caches a folder's content can GET one small object and skip the folder entirely when its token is unchanged. They are not a discovery mechanism — they list folders, not files. Consumer-side adoption is tracked in elastic/docs-builder#3801.
Like the group reconcile, the maps are f(state): touched folders are re-listed and the map is
patched with optimistic concurrency (ShallowRegistryReconciler). An absent or unparseable map
is rebuilt from a full tree listing — which is also how the maps were seeded on first deploy.
The bundle manifest is produced exclusively by the scrubber Lambda's BundleRegistryReconciler
(Elastic.Changelog/Reconciliation/; the handler pipeline is ScrubberProcessor). S3 event
notifications are at-least-once and can arrive out of order, so the handler never acts on an
event's type — an event only means "this key may have changed":
- Object-level reconcile — GET the key from the private bucket: present → scrub the current content and PUT to public; 404 → conditionally delete the public copy. After the write, a HEAD re-validates that the private object still matches the snapshot the write was derived from, redoing the reconcile if a concurrent invocation raced it.
- Group reconcile (
bundle/{product}/keys only — the pool tree has none) — list the group's public prefix (paginated), reuse entries whose recorded ETag still matches the listing, GET and recompute the rest (amends always recomputed), and write the manifest back. - Shallow-map reconcile — patch the touched tree's folder→token map from the same public listings.
Within an SQS batch this work is coalesced: one object reconcile per distinct key, one group reconcile per distinct group, one shallow-map reconcile per touched tree.
Registry-key events split by tree. A bundle manifest is never copied or deleted — the event
only schedules the group reconcile, so client-authored JSON never reaches the tree consumers
enumerate. A pool manifest is mirrored verbatim (the
legacy pass-through). Any other .json key is skipped with a warning.
Concurrent reconciles of one group (parallel uploads, redeliveries) are serialized through S3 conditional writes:
- On update:
If-Match: <etag-from-read>— only succeeds if the manifest hasn't changed. - On create:
If-None-Match: *— only succeeds if the manifest still doesn't exist. - On group emptied: conditional DELETE with
If-Match, so a stale empty observation can't destroy a manifest a concurrent reconciler just rebuilt.
A 412 Precondition Failed (or 409 conditional-write conflict) means another writer won the
race; the reconciler re-lists, rebuilds, and retries with jittered backoff (bounded). On
exhaustion the SQS message fails and is redelivered. If the rebuilt manifest equals what's
published (same schema_version, producer, product, and entry list), the write is skipped —
generated_at alone never causes churn.
S3 has no cross-key atomicity — the public YAML write and the registry write are separate operations, and a reconcile can fail between them. The guarantee is therefore:
The public registry converges to the exact public bucket state once the scrubber queue drains successfully. Any successfully processed event for a group repairs all accumulated drift in that group, not just the event's own key.
Consumers must tolerate the convergence window: the manifest may briefly reference a bundle that is not yet (or no longer) on the public bucket — treat a listed-but-missing bundle as non-fatal (skip + warn), not an error. A bundle that fails scrubbing (private references that cannot be allowlisted) is never written to the public bucket; its message lands in the DLQ (alerting and the triage/redrive runbook are tracked in elastic/docs-eng-team#692), and the manifest — describing actual public state — never lists it.
Failures no longer surface in any CI log, and drift can also be introduced out-of-band (manual
S3 operations, lost events). There is deliberately no operator CLI: the planned
changelog registry reconcile/verify commands were dropped together with the pool-reconcile
model (elastic/docs-builder#3741, closed
unmerged — see the 2026-08-10 update on elastic/docs-eng-team#688). Repair rides the normal
event path instead: any successfully processed event for a group rebuilds its manifest from the
live listing, so re-running the group's upload with --skip-etag-check (which re-PUTs every
discovered file even when its content hash matches) forces a full re-scrub and reconcile of
that group.
The uploader (GitHub Actions OIDC role) writes YAML to the private bucket
(elastic-docs-v3-changelog-bundles-private) only. The scrubber Lambda is the sole writer to
the public bucket (elastic-docs-v3-changelog-bundles, served via CloudFront + OAC), which
preserves the invariant that everything on the public surface has been vetted.
Infrastructure lives in docs-infra (aws/elastic-web/us-east-1/elastic-docs-v3-changelog-bundles/):
- Private-bucket S3 → SQS notifications on
s3:ObjectCreated:*/s3:ObjectRemoved:*trigger the Lambda (batch size 10, 5 s batching window — multi-file uploads to one group tend to coalesce into a single reconcile). - The scrubber role has
s3:GetObjecton private,s3:GetObject/s3:PutObject/s3:DeleteObjecton public, ands3:ListBucketon the public bucket only (group and shallow-map reconciles list public state; the Lambda never lists the private bucket). - Queue metrics (main and DLQ) are streamed to the docs-o11y Elastic project
(
observability.tf); the alert rules and the triage/redrive runbook are tracked in elastic/docs-eng-team#692. - CloudFront caching is disabled (
Managed-CachingDisabled), so a written manifest is visible on the CDN immediately.
The changelog bundle command aggregates individual changelog entries. It can read those
entries from the local folder or fetch the authoring pool's published entries from the CDN
(changelog/{org}/{repo}/{branch}/registry.json → changelog/{org}/{repo}/{branch}/{file}, via
CdnChangelogEntryFetcher). The pool manifest it enumerates is the
legacy client-authored index; this enumeration is what keeps the
pass-through alive until PR-list-driven discovery (RFC elastic/docs-eng-team#698) replaces it.
Under the artifact-root layout, entries are org/repo/branch-scoped — not product-scoped — so CDN
entry sourcing keys off the resolvable authoring pool (repo with the same precedence as upload:
--repo > bundle.repo > git remote; owner from --owner > bundle.owner, default elastic;
branch from --branch > bundle.branch, default main), not off the bundle's target products.
This is what lets one repo (for example kibana) produce a bundle for a shared product (for example
cloud-serverless) while sourcing its own entries from changelog/elastic/kibana/main/, without that
product appearing in the repo's own docset.yml. The decision is made per run by
ChangelogBundlingService:
- Local folder when
bundle.use_local_changelogs: true, when--directoryis passed, or when the authoring repo cannot be resolved. - CDN when the authoring repo resolves, local sourcing is not forced, and a CDN base is
configured (
DOCS_BUILDER_CHANGELOG_CDN, defaulting to the public distribution).
The same gate drives the --plan needs_network output, so a planning step and the actual bundle
run agree on whether the Docker bundle needs network access. The registry-fetch is fail-fast and an
entry still missing after its retry budget fails the bundle (an incomplete release would otherwise
ship silently). CdnChangelogEntryFetcher reuses a shared HttpClient in production and disposes an
owned client only when a test injects a handler, mirroring CdnChangelogFetcher.
:::{changelog}
:cdn: elasticsearch
:::
The directive accepts a :cdn: option naming the product to render (validated against
[a-zA-Z0-9_-]+). It is a selector over bundles that were prefetched at build startup, so the
product must be declared under release_notes in docset.yml (see
Declaration and prefetch). The product is optional: a valueless
:cdn: infers the product from the current repository (BuildContext.Git.RepositoryName),
mapped to its canonical product id via products.yml (for example the elastic-otel-java repo →
edot-java product). Multi-product repositories (for example cloud, which publishes
cloud-hosted, cloud-serverless, and cloud-enterprise) must name the product explicitly. When
the product cannot be inferred (git information unavailable) or is not declared under
release_notes, the directive emits an error.
The CDN base URL is environment configuration, not authored per page: it
defaults to the public changelog bundles distribution and is overridable via the
DOCS_BUILDER_CHANGELOG_CDN environment variable (absolute http/https URL) for
staging, local development, and testing.
When :cdn: is set, the local-folder argument is ignored (a warning is emitted if one is
also given) and the directive renders the prefetched CDN bundles instead.
CDN-sourced products are declared once per docset under release_notes in docset.yml, mirroring
the cross_links mechanism:
# docset.yml
release_notes:
- product: elasticsearch
- product: edot-java
Each entry is validated against products.yml (the id must exist and carry the release-notes
feature). At build startup — before any markdown is parsed — ReleaseNotesFetcher fetches the
registry and bundles for every declared product concurrently, stores the result in an immutable
FetchedReleaseNotes, and exposes it through IReleaseNotesResolver. The resolver is threaded into
the parser via DocumentationSet/ParserContext, so the {changelog} directive's :cdn: mode is
a pure in-memory lookup with no network I/O at parse time. Build paths that do not source release
notes use NoopReleaseNotesResolver.
GET {cdnBase}/bundle/{product}/registry.json.- Parse it; for each
bundles[].file,GET {cdnBase}/bundle/{product}/{file}. - Feed the downloaded YAML into the existing
BundleLoader→MergeBundlesByTarget→ render pipeline. Rendering is unchanged; only the source of the bundle bytes differs.
Implemented by CdnChangelogFetcher (a stateless async fetch engine in
Elastic.Documentation.Configuration) and BundleLoader.LoadBundlesFromContent. Because public
bundles are already scrubbed and resolved (entries are inline/self-contained), the fetcher never
needs to download separate entry files; the existing private-repo link and description visibility
logic still applies via assembler.yml, exactly as for local bundles.
- Async prefetch at startup. Bundles are fetched once per declared product before parsing, via
HttpClient.GetAsync, rather than synchronously inside the Markdig block parser. The directive then selects from the prefetched, immutableFetchedReleaseNotes. - Fail-fast registry, tolerant bundles. A declared product whose registry cannot be fetched or parsed fails the build; an individual bundle that 404s or fails to parse is a warning and is skipped, per Consistency: convergence, not atomicity.
- Undeclared product. A
:cdn:directive naming a product not declared underrelease_notesis an error — its bundles were never prefetched — which keeps network sources auditable in one place. - Schema evolution. A
schema_versionnewer than the consumer understands produces a clear error rather than a silent mis-parse. - Filtering.
:type:,:link-visibility:,:description-visibility:,:dropdowns:andhide-featuresapply identically to CDN-sourced bundles. - Version selection.
:version:renders a single target and works in both modes (shared match on registrytargetor bundle file name, seeChangelogVersionMatch). In CDN mode it filters the prefetched bundles at render time. When the fetcher itself is asked for a single version, an amend sidecar is additionally downloaded when its parent bundle matches, so amends published withoutproducts(null registrytarget) still reach version-filtered consumers. - Security. The base URL is trusted configuration; the product and registry-supplied bundle
file names are validated to single path segments so neither can traverse outside
bundle/{product}/.
- Persistent / offline cache. Each build prefetches declared products once into memory but does
not persist a disk cache, so a cold build always reaches the CDN and an unreachable declared
product fails the build. A follow-up should add an ETag-keyed on-disk cache under the
docs-builder app-data directory (mirroring
CrossLinkFetcher) with offline fallback. servemode staleness. The prefetch runs per reload, but within a singleserveprocess a product's CDN content is pinned until the next reload. Acceptable for now (serve targets local markdown authoring, not changelog bundles); revisit alongside the disk cache.- CDN latency. CloudFront caching is disabled (
Managed-CachingDisabled), so a written manifest is visible immediately; the only delay between an upload and its appearance inregistry.jsonis the scrubber pipeline itself (SQS batching window + reconcile). - Caching key. When the disk cache lands, use the CDN response ETag (not the registry
etagfield) for revalidation.
- Cross-product aggregation in a single directive block (one product per block).
- Authenticated/private CDN access (the public bucket is anonymous-read by design).
- Changelog directive — current (local-folder) behavior.
- Publish changelogs — the upload workflow.
- Link infrastructure — the S3 + CloudFront pattern this reuses.