diff --git a/docs/cli/changelog/cmd-bundle.md b/docs/cli/changelog/cmd-bundle.md
index 726c63025a..b54b27731f 100644
--- a/docs/cli/changelog/cmd-bundle.md
+++ b/docs/cli/changelog/cmd-bundle.md
@@ -43,13 +43,13 @@ Supply filter flags directly when you don't have a profile configured or need a
Exactly one of the following filter flags is required:
-- `--all` — include every changelog in the directory
-- `--input-products` — match by product, target date, and lifecycle (e.g. `"elasticsearch * *"`)
-- `--prs` — filter by PR URLs or a newline-delimited file of PR URLs
-- `--issues` — filter by issue URLs or a newline-delimited file of issue URLs
+- `--all` — include every changelog in the directory (local sourcing only; on the CDN this errors and asks for `--force-local`)
+- `--input-products` — match by product, target date, and lifecycle (e.g. `"elasticsearch * *"`). On the CDN this also requires a PR, issue, or file identity; product-only CDN runs error and ask for `--force-local`.
+- `--prs` — filter by PR URLs or a newline-delimited file of PR URLs. An entry matches when **leading filename digits** equal the PR number **or** YAML `prs:` contains it (the same join git-ref uses). Empty `prs:` is fine when the filename carries the PR (for example `12345.yaml` after public scrubbing).
+- `--issues` — filter by issue URLs or a newline-delimited file of issue URLs (YAML `issues:` on the downloaded pool)
- `--release-version` — fetch PR references from a GitHub release tag (e.g. `v9.2.0` or `latest`)
- `--report` — filter by PRs referenced in a promotion report (URL or local file)
-- `--files` — include specific changelog YAML paths, or a newline-delimited path list file
+- `--files` — include specific changelog YAML paths, or a newline-delimited path list file. On the CDN this GETs those basenames only and does not read `registry.json`.
- `--start-git-ref` + `--end-git-ref` — derive the PR list from a git commit range (see [Commit-range mode](#git-ref-mode))
`--force-local` is not a filter. It forces local entry sourcing for the run (equivalent to `bundle.use_local_changelogs: true` without editing config) and is allowed in both option-based and profile-based modes.
@@ -408,7 +408,7 @@ In profile mode, pass the same path list as a positional argument:
docs-builder changelog bundle serverless-release 2026-07-07 ./docs/temp/changelog_files.txt
```
-`--files` / path-list selection follows the standard entry-sourcing rules. When entries are sourced from the CDN (the default when `bundle.repo` resolves), the listed paths are matched to CDN pool entries by file name and do not need to exist locally — useful for private repositories whose entries exist only in S3 and whose public copies have PR/issue references scrubbed, so PR-based filters cannot match. With local sourcing (`--force-local`, `--directory`, or `bundle.use_local_changelogs`), the listed files are read from disk and must exist. In either mode, a listed entry that cannot be found fails the run, and `rules.bundle` still applies after selection.
+`--files` / path-list selection follows the standard entry-sourcing rules. When entries are sourced from the CDN (the default when `bundle.repo` resolves), the listed paths are **GET by file name** and do not need to exist locally — the pool `registry.json` is not read. This is useful for private repositories whose entries exist only in S3. With local sourcing (`--force-local`, `--directory`, or `bundle.use_local_changelogs`), the listed files are read from disk and must exist. In either mode, a listed entry that cannot be found fails the run, and `rules.bundle` still applies after selection.
### Force local entry sourcing [changelog-bundle-force-local]
diff --git a/docs/cli/changelog/cmd-upload.md b/docs/cli/changelog/cmd-upload.md
index 2fb23b63b7..7778760ef4 100644
--- a/docs/cli/changelog/cmd-upload.md
+++ b/docs/cli/changelog/cmd-upload.md
@@ -103,10 +103,9 @@ s3://{bucket}/bundle/{product}/{filename} # --artifact-type bun
Changelog entries are written once under the authoring org/repo/branch. A bundle that applies to multiple products is uploaded to multiple keys — one per product.
The command writes YAML objects only — it never writes a `registry.json`. The public
-`bundle/{product}/registry.json` manifests are produced exclusively by the scrubber Lambda,
-reconciled from public bucket state on the S3 events each upload emits; the
-`changelog/{org}/{repo}/{branch}/registry.json` pool manifests are legacy client-authored
-objects that only older CLI versions still write. See
+`bundle/{product}/registry.json` and `changelog/{org}/{repo}/{branch}/registry.json`
+manifests are produced exclusively by the scrubber Lambda, reconciled from public
+bucket state on the S3 events each upload emits. See
[Changelog bundle registry](/development/changelog-bundle-registry.md).
When several repositories publish bundles for the same shared product (for example `cloud-serverless`), use a `{repo}-{dateOrVersion}.yaml` bundle filename convention so they don't overwrite each other under `bundle/{product}/`.
diff --git a/docs/data/release-notes/configure-ref.md b/docs/data/release-notes/configure-ref.md
index 5e8add87d2..2ada9ccf0f 100644
--- a/docs/data/release-notes/configure-ref.md
+++ b/docs/data/release-notes/configure-ref.md
@@ -73,7 +73,7 @@ The authoring repo is resolved with the same precedence as `changelog upload`: `
Sourcing is decided per run:
- **Local folder.** Used when `bundle.use_local_changelogs: true`, when `--force-local` is passed, when `--directory` is passed, or when the authoring repo cannot be resolved. The folder must contain the changelog files.
-- **CDN (default when a repo resolves).** Used when the authoring repo resolves, local sourcing is not forced, and a CDN base URL is configured (`DOCS_BUILDER_CHANGELOG_CDN`, defaulting to the public distribution). The command fetches `changelog/{org}/{repo}/{branch}/registry.json` and the entries it lists, then applies the bundle's own product/PR/issue/file filters to the downloaded set. Path-list / `--files` filters match pool entries by file name, so the listed paths do not need to exist locally.
+- **CDN (default when a repo resolves).** Used when the authoring repo resolves, local sourcing is not forced, and a CDN base URL is configured (`DOCS_BUILDER_CHANGELOG_CDN`, defaulting to the public distribution). `--prs`, `--issues`, `--report`, `--release-version`, a URL list, and git-ref fetch `changelog/{org}/{repo}/{branch}/registry.json` and the entries it lists, then apply the same PR join locally and on the CDN: **filename-derived PR numbers or YAML `prs:`**. Path-list / `--files` GETs those pool objects by file name and does not read the registry. CDN `--all` and product-only filters (no PR, issue, or file identity) error; pass `--force-local` to read the local folder. Local `--all` is unchanged.
Use `--force-local` for uncommon ad hoc runs that need the local folder without editing `changelog.yml` — including path-list / `--files` runs that should read freshly authored files from disk instead of the CDN pool.
diff --git a/docs/development/changelog-bundle-registry.md b/docs/development/changelog-bundle-registry.md
index fa04e2ccf1..f9de2ef821 100644
--- a/docs/development/changelog-bundle-registry.md
+++ b/docs/development/changelog-bundle-registry.md
@@ -29,7 +29,7 @@ copies, no cross-repo file syncing.
flowchart LR
CI["Client CI
(docs-actions)"] -->|"changelog upload
(YAML objects only)"| Private["Private S3 bucket
bundle/{product}/*.yaml
changelog/{org}/{repo}/{branch}/*.yaml"]
Private -->|"s3:ObjectCreated / ObjectRemoved
→ SQS"| Scrubber["Changelog scrubber
Lambda"]
- Scrubber -->|"scrub + copy/delete,
then reconcile bundle registry.json
+ shallow maps from public listing"| Public["Public S3 bucket
+ CloudFront CDN
(incl. registry.json)"]
+ Scrubber -->|"scrub + copy/delete,
then reconcile bundle and pool registry.json
+ shallow maps from public listing"| Public["Public S3 bucket
+ CloudFront CDN
(incl. registry.json)"]
Public -->|"reads via CDN"| Directive["{changelog} directive
(cdn: mode)"]
```
@@ -60,21 +60,18 @@ Both indexes share this schema, serialized with `snake_case` keys.
### Ownership per tree [ownership-per-tree]
-The two trees part ways on who writes the manifest
-(the [2026-08-10 update on elastic/docs-eng-team#688](https://github.com/elastic/docs-eng-team/issues/688)
-narrowed reconciliation to the bundle tree):
+The two trees share a producer and differ only in what each listing records:
- **Bundle index** — `bundle/{product}/registry.json`, **public bucket only**, produced
- exclusively by the scrubber Lambda's `BundleRegistryReconciler`. 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 current `changelog upload` never writes one, but manifests
- written by older CLI versions are still mirrored verbatim from the private bucket, because
- [`changelog bundle` entry sourcing](#entry-sourcing) still enumerates a pool through its
- manifest. It is *not* reconciled — its `producer` is null and its recorded `etag` is 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](https://github.com/elastic/docs-eng-team/issues/698)).
+ exclusively by the scrubber Lambda's `BundleRegistryReconciler`. Each entry records a
+ `target` (version or date) so the `{changelog}` directive and external CDN consumers can
+ enumerate bundles. This is the subject of most of the rest of this page.
+- **Changelog-entry index** — `changelog/{org}/{repo}/{branch}/registry.json`, **public
+ bucket only**, produced by the same reconciler from the public YAML listing. It is
+ **listing-only** (`target` is always null). `changelog bundle --prs` and git-ref still
+ download this listing first (the public CDN cannot `ListObjects`); `--files` / a path
+ list GETs named objects and does not read it. Upload never writes this file. Stale
+ leftover client JSON is healed on the next YAML or registry-key event.
```json
{
@@ -149,19 +146,19 @@ event's *type* — an event only means "this key may have changed":
*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.
-2. **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.
+2. **Group reconcile** (`bundle/{product}/` and `changelog/{org}/{repo}/{branch}/` keys) —
+ list the group's public prefix (paginated), reuse entries whose recorded ETag still
+ matches the listing, GET and recompute bundle targets (pool listings skip the YAML GET
+ and record a null `target`), and write the manifest back.
3. **Shallow-map reconcile** — patch the touched tree's
[folder→token map](#shallow-maps) 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](#ownership-per-tree)). Any other `.json` key is skipped with a warning.
+Registry-key events never copy or delete the JSON object — the event only schedules the
+group reconcile, so client-authored JSON never reaches the tree consumers enumerate. Any
+other `.json` key is skipped with a warning.
### Concurrency: optimistic, conditional writes
@@ -235,8 +232,11 @@ The `changelog bundle` command aggregates individual changelog **entries**. It c
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](#ownership-per-tree); this enumeration is what keeps the
-pass-through alive until PR-list-driven discovery (RFC elastic/docs-eng-team#698) replaces it.
+[Lambda-owned listing](#ownership-per-tree). `--prs` and git-ref download that listing, then
+match by **filename-derived PR numbers or YAML `prs:`** (the same join git-ref uses). `--files`
+/ a path list GETs those basenames only and does not read the registry. CDN `--all` and
+product-only filters (no PR, issue, or file identity) are not supported yet — pass
+`--force-local`. Local `--all` is unchanged.
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:
diff --git a/src/Elastic.Documentation.Configuration/ReleaseNotes/CdnChangelogEntryFetcher.cs b/src/Elastic.Documentation.Configuration/ReleaseNotes/CdnChangelogEntryFetcher.cs
index c5eb895480..cc71c47251 100644
--- a/src/Elastic.Documentation.Configuration/ReleaseNotes/CdnChangelogEntryFetcher.cs
+++ b/src/Elastic.Documentation.Configuration/ReleaseNotes/CdnChangelogEntryFetcher.cs
@@ -16,9 +16,11 @@ namespace Elastic.Documentation.Configuration.ReleaseNotes;
///
/// Fetches the individual (scrubbed) changelog entries for a single authoring org/repo/branch pool from
/// the public CDN, for the changelog bundle command when sourcing entries from S3 rather than a
-/// local folder. It reads {base}/changelog/{org}/{repo}/{branch}/registry.json to enumerate entries
-/// and downloads each {base}/changelog/{org}/{repo}/{branch}/{file} as raw YAML; the bundle command
-/// then applies its usual filter (products / prs / issues) to the downloaded set.
+/// local folder. enumerates via the pool registry.json then downloads
+/// each listed YAML. GETs requested basenames only (used by
+/// --files / a path list) and does not read the registry. The bundle command then applies its
+/// usual filter (products / prs / issues) to the downloaded set, except --files which includes
+/// every fetched name.
///
///
///
@@ -39,6 +41,7 @@ public sealed class CdnChangelogEntryFetcher : IDisposable
private const int DefaultMaxAttempts = 4;
private const int BaseRetryDelayMs = 500;
private const int MaxRetryDelayMs = 2000;
+ private const int MaxParallelNamedReads = 4;
///
/// Bounds an individual registry/entry HTTP request so a stalled CDN connection cannot hang a bundle run.
@@ -178,6 +181,79 @@ public async Task> FetchAsync(
return entries;
}
+ ///
+ /// Downloads only the named changelog entries from the authoring pool, without reading
+ /// registry.json. Used by changelog bundle --files / a path list so a stale or
+ /// missing pool listing cannot hide a requested object. A requested name that 404s after the
+ /// retry budget is a hard error (fail-fast, same as a missing local file).
+ ///
+ public async Task> FetchNamedAsync(
+ Uri baseUri,
+ string org,
+ string repo,
+ string branch,
+ IReadOnlyList fileNames,
+ Action emitError,
+ Action emitWarning,
+ Cancel ctx)
+ {
+ _ = emitWarning;
+ var poolLabel = $"{org}/{repo}/{branch}";
+
+ if (!ChangelogKeys.IsValidOrg(org) || !ChangelogKeys.IsValidRepo(repo) || !ChangelogKeys.IsValidBranch(branch))
+ {
+ emitError(
+ $"Invalid changelog pool '{poolLabel}': the org, repo, and each '/'-delimited branch segment must be non-empty ASCII letters, digits, '.', '_' or '-' (org allows only letters, digits and '-') and must not be '.' or '..'.");
+ return [];
+ }
+
+ var poolSegments = ChangelogKeys.PoolSegments(org, repo, branch);
+ var built = new CdnChangelogEntry?[fileNames.Count];
+ var errors = new string?[fileNames.Count];
+
+ await Parallel.ForEachAsync(
+ Enumerable.Range(0, fileNames.Count),
+ new ParallelOptions { MaxDegreeOfParallelism = MaxParallelNamedReads, CancellationToken = ctx },
+ async (i, ct) =>
+ {
+ var fileName = fileNames[i];
+ if (!ChangelogKeys.IsSafeFileName(fileName))
+ {
+ errors[i] =
+ $"Changelog entry '{fileName}' for '{poolLabel}' is not a valid pool file name.";
+ return;
+ }
+
+ var entryUri = CombineSegments(baseUri, [.. poolSegments, fileName]);
+ var (fetched, content, lastError) = await TryFetchEntryAsync(entryUri, fileName, poolLabel, ct).ConfigureAwait(false);
+ if (fetched)
+ {
+ built[i] = new CdnChangelogEntry(fileName, content);
+ return;
+ }
+
+ errors[i] =
+ $"Changelog entry '{fileName}' for '{poolLabel}' could not be fetched from {entryUri} after {_maxAttempts} attempt(s): {lastError}. " +
+ "Ensure the entry was uploaded (changelog upload), or pass --force-local / --directory to bundle local files instead.";
+ }).ConfigureAwait(false);
+
+ var failed = false;
+ for (var i = 0; i < errors.Length; i++)
+ {
+ if (errors[i] is not { } message)
+ continue;
+ emitError(message);
+ failed = true;
+ }
+
+ if (failed)
+ return [];
+
+ var entries = built.Where(e => e is not null).Select(e => e!.Value).ToList();
+ _logger.LogInformation("Fetched {Count} named changelog entry(ies) for {Pool} from {BaseUri}", entries.Count, poolLabel, baseUri);
+ return entries;
+ }
+
///
/// Fetches a single entry, retrying transient failures (most importantly a not-yet-propagated 404)
/// up to times with exponential backoff. Retry requests are cache-busted
diff --git a/src/Elastic.Documentation.Configuration/ReleaseNotes/CdnChangelogFetcher.cs b/src/Elastic.Documentation.Configuration/ReleaseNotes/CdnChangelogFetcher.cs
index 08cda2c97f..2dbae858ca 100644
--- a/src/Elastic.Documentation.Configuration/ReleaseNotes/CdnChangelogFetcher.cs
+++ b/src/Elastic.Documentation.Configuration/ReleaseNotes/CdnChangelogFetcher.cs
@@ -21,7 +21,13 @@ namespace Elastic.Documentation.Configuration.ReleaseNotes;
///
/// Individual bundle files are cached locally keyed by {product}-{fileName}-{etag} so that
/// repeated builds (and dev-server reloads) do not re-download unchanged content from the CDN.
-/// The registry itself is always fetched (it's small and provides fresh ETags).
+/// The per-product registry is normally fetched every run (it's small and provides fresh ETags),
+/// with one opt-out: the scrubber maintains a shallow per-tree map at bundle/registry.json
+/// mapping each product folder to an opaque change token. The map is fetched once per fetcher run;
+/// when a product's token equals the token the local cache last saw, the cached registry is reused
+/// and the per-product registry fetch is skipped entirely. Tokens are opaque — compared for string
+/// equality only, never parsed — and a map that is absent (pre-cutover CDNs), unparseable, or
+/// unreachable degrades to exactly the pre-map behavior: every product registry is fetched.
///
///
/// Resilience follows the manifest's consistency model: a registry that cannot be fetched or parsed
@@ -60,6 +66,13 @@ public sealed class CdnChangelogFetcher : IDisposable
private readonly IFileSystem _fileSystem;
private readonly ConcurrentDictionary _memoryCache = new(StringComparer.Ordinal);
+ ///
+ /// Shallow-map fetches memoized per base URI, so one run consults the CDN once no matter how many
+ /// products it fetches. The map is intentionally never cached to disk: it is the freshness signal
+ /// itself, and a stale copy would defeat its purpose.
+ ///
+ private readonly ConcurrentDictionary?>>> _shallowMaps = new(StringComparer.Ordinal);
+
///
/// Non-null only when a caller injects its own (tests): in that case we
/// own a per-instance client and must dispose it. On the production path points
@@ -108,22 +121,30 @@ public async Task> FetchAsync(
}
var registryUri = Combine(baseUri, [.. ChangelogKeys.BundleSegments(product), ChangelogKeys.RegistryFileName]);
+ var shallowToken = await TryGetShallowTokenAsync(baseUri, product, ctx).ConfigureAwait(false);
- 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 [];
- }
-
+ var registry = TryGetCachedRegistry(product, shallowToken);
if (registry is null)
{
- emitError($"Changelog registry for product '{product}' at {registryUri} was empty or unparseable.");
- return [];
+ try
+ {
+ _logger.LogInformation("Fetching changelog registry {RegistryUri}", registryUri);
+ var registryText = await FetchTextAsync(registryUri, ctx).ConfigureAwait(false);
+ registry = JsonSerializer.Deserialize(registryText, ChangelogRegistryJsonContext.Default.ChangelogRegistry);
+ if (registry is not null && shallowToken is not null)
+ WriteCachedText(RegistryCacheKey(product, shallowToken), registryText);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ emitError($"Could not fetch changelog registry for product '{product}' from {registryUri}: {ex.Message}");
+ return [];
+ }
+
+ if (registry is null)
+ {
+ emitError($"Changelog registry for product '{product}' at {registryUri} was empty or unparseable.");
+ return [];
+ }
}
if (registry.SchemaVersion > SupportedSchemaVersion)
@@ -143,14 +164,73 @@ public async Task> FetchAsync(
return _bundleLoader.LoadBundlesFromContent(contents, emitWarning);
}
- private async Task FetchRegistryAsync(Uri registryUri, Cancel ctx)
+ ///
+ /// The product's opaque change token from the tree's shallow map, or null when the map is
+ /// unavailable or does not list the product — in which case the caller fetches the per-product
+ /// registry exactly as it did before the map existed.
+ ///
+ private async Task TryGetShallowTokenAsync(Uri baseUri, string product, Cancel ctx)
{
- _logger.LogInformation("Fetching changelog registry {RegistryUri}", registryUri);
- using var request = new HttpRequestMessage(HttpMethod.Get, registryUri);
- using var response = await _httpClient.SendAsync(request, ctx).ConfigureAwait(false);
- _ = response.EnsureSuccessStatusCode();
- await using var stream = await response.Content.ReadAsStreamAsync(ctx).ConfigureAwait(false);
- return await JsonSerializer.DeserializeAsync(stream, ChangelogRegistryJsonContext.Default.ChangelogRegistry, ctx).ConfigureAwait(false);
+ var lazyMap = _shallowMaps.GetOrAdd(
+ baseUri.AbsoluteUri,
+ _ => new Lazy?>>(() => FetchShallowMapAsync(baseUri, ctx)));
+ var map = await lazyMap.Value.ConfigureAwait(false);
+ if (map is null || !map.TryGetValue(product, out var token))
+ return null;
+
+ // The token is opaque but becomes part of a local cache file name; anything that is not a
+ // plain path segment is ignored rather than joined into a path.
+ return ChangelogKeys.IsSafeFileName(token) ? token : null;
+ }
+
+ ///
+ /// Fetches the tree's shallow map (bundle/registry.json) mapping each product folder to an
+ /// opaque change token. Every failure — absent on pre-cutover CDNs, unparseable, transport — degrades
+ /// to null so the run behaves exactly as it did before the map existed.
+ ///
+ private async Task?> FetchShallowMapAsync(Uri baseUri, Cancel ctx)
+ {
+ var mapUri = Combine(baseUri, ["bundle", ChangelogKeys.RegistryFileName]);
+ try
+ {
+ using var request = new HttpRequestMessage(HttpMethod.Get, mapUri);
+ using var response = await _httpClient.SendAsync(request, ctx).ConfigureAwait(false);
+ _ = response.EnsureSuccessStatusCode();
+ await using var stream = await response.Content.ReadAsStreamAsync(ctx).ConfigureAwait(false);
+ return await JsonSerializer.DeserializeAsync(stream, ChangelogRegistryJsonContext.Default.DictionaryStringString, ctx).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ _logger.LogDebug("Shallow changelog map at {MapUri} is unavailable ({Message}); fetching every product registry as usual", mapUri, ex.Message);
+ return null;
+ }
+ }
+
+ ///
+ /// The parsed registry from the token-keyed local cache, or null when there is no shallow token
+ /// for the product, no cached copy for that token, or the cached copy no longer parses — every
+ /// miss falls back to a normal registry fetch.
+ ///
+ private ChangelogRegistry? TryGetCachedRegistry(string product, string? shallowToken)
+ {
+ if (shallowToken is null)
+ return null;
+
+ var cached = TryGetCachedText(RegistryCacheKey(product, shallowToken));
+ if (cached is null)
+ return null;
+
+ try
+ {
+ var registry = JsonSerializer.Deserialize(cached, ChangelogRegistryJsonContext.Default.ChangelogRegistry);
+ if (registry is not null)
+ _logger.LogInformation("Changelog folder 'bundle/{Product}' is unchanged per the shallow map; using the cached registry", product);
+ return registry;
+ }
+ catch (JsonException)
+ {
+ return null;
+ }
}
private async Task> DownloadBundlesAsync(
@@ -257,12 +337,17 @@ private static Uri Combine(Uri baseUri, IReadOnlyList segments)
return new Uri($"{basePath}/{suffix}");
}
- private string? TryGetCachedBundle(string product, string fileName, string? etag)
+ private string? TryGetCachedBundle(string product, string fileName, string? etag) =>
+ string.IsNullOrWhiteSpace(etag) ? null : TryGetCachedText(BundleCacheKey(product, fileName, etag));
+
+ private void WriteCachedBundle(string product, string fileName, string? etag, string content)
{
- if (string.IsNullOrWhiteSpace(etag))
- return null;
+ if (!string.IsNullOrWhiteSpace(etag))
+ WriteCachedText(BundleCacheKey(product, fileName, etag), content);
+ }
- var cacheKey = CacheKey(product, fileName, etag);
+ private string? TryGetCachedText(string cacheKey)
+ {
if (_memoryCache.TryGetValue(cacheKey, out var cached))
return cached;
@@ -278,17 +363,13 @@ private static Uri Combine(Uri baseUri, IReadOnlyList segments)
}
catch (Exception e)
{
- _logger.LogError(e, "Failed to read cached changelog bundle {CachePath}", cachePath);
+ _logger.LogError(e, "Failed to read cached changelog file {CachePath}", cachePath);
return null;
}
}
- private void WriteCachedBundle(string product, string fileName, string? etag, string content)
+ private void WriteCachedText(string cacheKey, string content)
{
- if (string.IsNullOrWhiteSpace(etag))
- return;
-
- var cacheKey = CacheKey(product, fileName, etag);
_ = _memoryCache.TryAdd(cacheKey, content);
var cachePath = CachePath(cacheKey);
@@ -302,13 +383,21 @@ private void WriteCachedBundle(string product, string fileName, string? etag, st
}
catch (Exception e)
{
- _logger.LogError(e, "Failed to write cached changelog bundle {CachePath}", cachePath);
+ _logger.LogError(e, "Failed to write cached changelog file {CachePath}", cachePath);
}
}
- private static string CacheKey(string product, string fileName, string etag) =>
+ private static string BundleCacheKey(string product, string fileName, string etag) =>
$"changelog-{product}-{fileName}-{etag}";
+ ///
+ /// Registry cache entries embed the shallow token in the key: a token mismatch is simply a cache
+ /// miss under the new key, which re-fetches and records the fresh registry alongside it — the same
+ /// convention the ETag-keyed bundle cache follows.
+ ///
+ private static string RegistryCacheKey(string product, string token) =>
+ $"registry-{product}-{token}";
+
private static string CachePath(string cacheKey) =>
Path.Join(Paths.ApplicationData.FullName, "changelog-bundles", cacheKey);
diff --git a/src/Elastic.Documentation.Configuration/ReleaseNotes/ChangelogKeys.cs b/src/Elastic.Documentation.Configuration/ReleaseNotes/ChangelogKeys.cs
index dbbe3e22d9..e6dac1f5c8 100644
--- a/src/Elastic.Documentation.Configuration/ReleaseNotes/ChangelogKeys.cs
+++ b/src/Elastic.Documentation.Configuration/ReleaseNotes/ChangelogKeys.cs
@@ -12,7 +12,7 @@ namespace Elastic.Documentation.Configuration.ReleaseNotes;
/// changelog/{org}/{repo}/{branch}/{file}, and each grouping has a registry.json
/// manifest at its root. Centralizes key construction, group extraction, and per-segment
/// validation so the producer (ChangelogUploadService), the scrubber Lambda gate,
-/// the registry builder, and the CDN fetchers cannot drift apart.
+/// the registry reconciler, and the CDN fetchers cannot drift apart.
///
///
/// Segment character classes, from strictest to loosest: org is a GitHub login
diff --git a/src/Elastic.Documentation.Configuration/ReleaseNotes/ChangelogRegistry.cs b/src/Elastic.Documentation.Configuration/ReleaseNotes/ChangelogRegistry.cs
index 72dfc3c837..046e91da59 100644
--- a/src/Elastic.Documentation.Configuration/ReleaseNotes/ChangelogRegistry.cs
+++ b/src/Elastic.Documentation.Configuration/ReleaseNotes/ChangelogRegistry.cs
@@ -9,7 +9,7 @@ namespace Elastic.Documentation.Configuration.ReleaseNotes;
///
/// Consumer-side view of the bundle/{product}/registry.json manifest (or the
/// changelog/{org}/{repo}/{branch}/registry.json entry index) published alongside scrubbed changelog content.
-/// Mirrors the producer's shape (see the changelog upload service) but is intentionally lenient: only
+/// Mirrors the producer's shape (the scrubber Lambda's registry reconciler) but is intentionally lenient: only
/// the fields the changelog directive needs to enumerate bundles are declared, and nothing is
/// required so a partially-written or future-versioned manifest still deserializes.
///
@@ -43,7 +43,10 @@ public sealed record ChangelogRegistryBundle
public string? ETag { get; init; }
}
+// Dictionary is the shallow per-tree map (bundle/registry.json): folder → opaque
+// change token, maintained by the scrubber's ShallowRegistryReconciler.
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower)]
[JsonSerializable(typeof(ChangelogRegistry))]
[JsonSerializable(typeof(ChangelogRegistryBundle))]
+[JsonSerializable(typeof(Dictionary))]
internal sealed partial class ChangelogRegistryJsonContext : JsonSerializerContext;
diff --git a/src/infra/docs-lambda-changelog-scrubber/README.md b/src/infra/docs-lambda-changelog-scrubber/README.md
index 9326eafe88..8d26603965 100644
--- a/src/infra/docs-lambda-changelog-scrubber/README.md
+++ b/src/infra/docs-lambda-changelog-scrubber/README.md
@@ -3,8 +3,8 @@
SQS-triggered Lambda that reads changelog/bundle YAML from the private S3 bucket,
scrubs private repository references using `LinkAllowlistSanitizer`, writes
sanitized copies to the public S3 bucket, and is the **sole producer** of the public
-`bundle/{product}/registry.json` manifests and the shallow per-tree change maps,
-reconciled from actual public bucket state
+`bundle/{product}/registry.json` and `changelog/{org}/{repo}/{branch}/registry.json`
+manifests and the shallow per-tree change maps, reconciled from actual public bucket state
([elastic/docs-eng-team#688](https://github.com/elastic/docs-eng-team/issues/688)).
The handler logic lives in `Elastic.Changelog` (`Scrubbing/ScrubberProcessor`,
`Reconciliation/BundleRegistryReconciler`, `Reconciliation/ShallowRegistryReconciler`);
@@ -46,19 +46,18 @@ The `bootstrap` binary should be available under:
S3 events are *triggers, not instructions* — the event type is ignored and current bucket
state decides (events are at-least-once and can arrive out of order). Work is coalesced per
SQS batch: one object reconcile per distinct key, one registry reconcile per distinct
-`bundle/{product}/` group, one shallow-map reconcile per touched tree.
+group (`bundle/{product}/` or `changelog/{org}/{repo}/{branch}/`), one shallow-map reconcile
+per touched tree.
- **`.yaml`/`.yml` keys**: object-level reconcile — GET the key from the private bucket;
present → scrub the current content and PUT to public, absent → conditionally delete the
public copy. A post-write HEAD re-validates the source and redoes the work if a concurrent
- invocation raced it. A `bundle/{product}/` key then gets its group's `registry.json`
- reconciled from the public listing, and the touched tree's shallow folder→token map is
- patched.
-- **Registry keys** (`ChangelogKeys.IsRegistry`): the trees part ways. 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 changelog pool manifest is mirrored
- verbatim (legacy pass-through: `changelog bundle` still enumerates a pool through its
- manifest until PR-list discovery replaces it).
+ invocation raced it. The key's group then gets its `registry.json` reconciled from the
+ public listing, and the touched tree's shallow folder→token map is patched.
+- **Registry keys** (`ChangelogKeys.IsRegistry`): never copied or deleted — the event only
+ schedules the group reconcile, so client-authored JSON never reaches the tree consumers
+ enumerate. Pool listings are listing-only (`target` is null); bundle listings record
+ each file's `target`.
- **Other `.json` keys**: skipped with a warning; other extensions are skipped silently.
Registry and shallow-map writes use conditional PUT/DELETE (`If-Match` / `If-None-Match: *`)
diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs
index c9b9e65bda..e0d23bbab5 100644
--- a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs
+++ b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs
@@ -346,7 +346,7 @@ public async Task BundleChangelogs(IDiagnosticsCollector collector, Bundle
// Source and match changelog entries — from the CDN (default) or the local folder.
// Explicit --files / path-list selection bypasses content filters (IncludeAll): locally it loads
- // the named paths, in CDN mode it selects pool entries by file name.
+ // the named paths, in CDN mode it GETs those pool objects by file name (no registry listing).
var entryMatcher = new ChangelogEntryMatcher(_fileSystem, ReleaseNotesSerialization.GetEntryDeserializer(), _logger);
ChangelogMatchResult matchResult;
if (explicitFilePaths != null)
@@ -357,21 +357,32 @@ public async Task BundleChangelogs(IDiagnosticsCollector collector, Bundle
}
else if (useCdn)
{
- var contents = await FetchCdnEntriesAsync(collector, authoringOwner, authoringRepo, authoringBranch, ctx);
- if (contents == null)
+ if (requestedEntryNames is null && prsToMatch.Count == 0 && issuesToMatch.Count == 0)
+ {
+ collector.EmitError(string.Empty,
+ "CDN entry sourcing requires a PR, issue, or file identity (--prs, --issues, --files, a URL/path list, or --start-git-ref). " +
+ "--all and product-only filters are not supported on the CDN yet; pass --force-local to read the local folder.");
return false;
+ }
+
+ IReadOnlyList<(string FileName, string Content)>? contents;
if (requestedEntryNames is not null)
{
- var poolLabel = $"{authoringOwner}/{authoringRepo}/{authoringBranch}";
- var selected = SelectRequestedCdnEntries(collector, contents, requestedEntryNames, poolLabel);
- if (selected == null)
+ contents = await FetchCdnNamedEntriesAsync(
+ collector, authoringOwner, authoringRepo, authoringBranch, requestedEntryNames, ctx);
+ if (contents == null)
return false;
- _logger.LogInformation("Matching {Count} explicitly selected changelog entries from the CDN", selected.Count);
+ _logger.LogInformation("Matching {Count} explicitly selected changelog entries from the CDN", contents.Count);
var filesCriteria = filterCriteria with { IncludeAll = true };
- matchResult = entryMatcher.MatchChangelogContents(collector, selected, filesCriteria, ctx);
+ matchResult = entryMatcher.MatchChangelogContents(collector, contents, filesCriteria, ctx);
}
else
+ {
+ contents = await FetchCdnEntriesAsync(collector, authoringOwner, authoringRepo, authoringBranch, ctx);
+ if (contents == null)
+ return false;
matchResult = entryMatcher.MatchChangelogContents(collector, contents, filterCriteria, ctx);
+ }
}
else
{
@@ -1152,55 +1163,64 @@ private BundleChangelogsArguments ApplyConfigDefaults(BundleChangelogsArguments
return byName.Select(kv => (kv.Key, kv.Value)).ToList();
}
- /// Gate for repo-scoped CDN entry sourcing: true when the authoring repo resolves, local sourcing is not forced (bundle.use_local_changelogs/--force-local/--directory), and a CDN base is configured.
- private static bool ShouldSourceFromCdn(string? authoringRepo, bool useLocalChangelogs, bool explicitDirectory)
- {
- if (useLocalChangelogs || explicitDirectory || string.IsNullOrWhiteSpace(authoringRepo))
- return false;
- return ChangelogCdn.ResolveBaseUri() is not null;
- }
-
///
- /// Selects the CDN-sourced entries whose file names were explicitly requested via --files / a
- /// path list. Every requested name must exist in the pool: the registry is the source of truth for
- /// what was uploaded, so a missing name means the entry never reached S3 (or the name is wrong) and
- /// silently shipping an incomplete bundle is worse than failing the run. Returns null after
- /// emitting an error when any requested name is missing.
+ /// Downloads only the requested entry names from the CDN pool (no registry listing). Returns
+ /// null after emitting an error when any requested name cannot be fetched.
///
- private IReadOnlyList<(string FileName, string Content)>? SelectRequestedCdnEntries(
+ private async Task?> FetchCdnNamedEntriesAsync(
IDiagnosticsCollector collector,
- IReadOnlyList<(string FileName, string Content)> contents,
- IReadOnlyList requestedEntryNames,
- string poolLabel)
+ string? org,
+ string? repo,
+ string? branch,
+ IReadOnlyList fileNames,
+ Cancel ctx)
{
- var byName = new Dictionary(StringComparer.Ordinal);
- foreach (var (fileName, content) in contents)
- byName[fileName] = content;
-
- var selected = new List<(string FileName, string Content)>();
- var missing = new List();
- var seen = new HashSet(StringComparer.Ordinal);
- foreach (var name in requestedEntryNames)
+ if (string.IsNullOrWhiteSpace(repo))
{
- if (!seen.Add(name))
- continue;
- if (byName.TryGetValue(name, out var content))
- selected.Add((name, content));
- else
- missing.Add(name);
+ collector.EmitError(string.Empty,
+ "Sourcing changelog entries from the CDN requires a resolvable authoring repository. " +
+ "Set bundle.repo in changelog.yml (or pass --repo), or set bundle.use_local_changelogs: true " +
+ "in changelog.yml / pass --directory to bundle local changelog files.");
+ return null;
}
- if (missing.Count > 0)
+ var resolvedOrg = string.IsNullOrWhiteSpace(org) ? DefaultOwner : org;
+ var resolvedBranch = string.IsNullOrWhiteSpace(branch) ? DefaultBranch : branch;
+
+ var baseUri = ChangelogCdn.ResolveBaseUri();
+ if (baseUri is null)
{
collector.EmitError(string.Empty,
- $"Changelog entr{(missing.Count == 1 ? "y" : "ies")} not found in the CDN pool '{poolLabel}': {string.Join(", ", missing)}. " +
- "Ensure the entries were uploaded (changelog upload), or pass --force-local / --directory to bundle local files instead.");
+ $"No valid changelog CDN base URL is configured. Set the {ChangelogCdn.BaseUrlEnvironmentVariable} environment variable to an absolute http(s) URL.");
return null;
}
- _logger.LogInformation("Selected {Selected} of {Total} CDN entries by requested file name for {Pool}",
- selected.Count, contents.Count, poolLabel);
- return selected;
+ var fatalFailure = false;
+ var entries = await _entryFetcher.FetchNamedAsync(
+ baseUri,
+ resolvedOrg,
+ repo,
+ resolvedBranch,
+ fileNames,
+ msg => { fatalFailure = true; collector.EmitError(string.Empty, msg); },
+ msg => collector.EmitWarning(string.Empty, msg),
+ ctx);
+
+ if (fatalFailure)
+ return null;
+
+ _logger.LogInformation("Sourced {Count} named changelog entr(ies) from the CDN for {Pool}",
+ entries.Count, $"{resolvedOrg}/{repo}/{resolvedBranch}");
+
+ return entries.Select(e => (e.FileName, e.Content)).ToList();
+ }
+
+ /// Gate for repo-scoped CDN entry sourcing: true when the authoring repo resolves, local sourcing is not forced (bundle.use_local_changelogs/--force-local/--directory), and a CDN base is configured.
+ private static bool ShouldSourceFromCdn(string? authoringRepo, bool useLocalChangelogs, bool explicitDirectory)
+ {
+ if (useLocalChangelogs || explicitDirectory || string.IsNullOrWhiteSpace(authoringRepo))
+ return false;
+ return ChangelogCdn.ResolveBaseUri() is not null;
}
private bool ValidateInput(IDiagnosticsCollector collector, BundleChangelogsArguments input, bool requireDirectoryExists)
diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogEntryMatcher.cs b/src/services/Elastic.Changelog/Bundling/ChangelogEntryMatcher.cs
index 56f9f259ab..741fd63557 100644
--- a/src/services/Elastic.Changelog/Bundling/ChangelogEntryMatcher.cs
+++ b/src/services/Elastic.Changelog/Bundling/ChangelogEntryMatcher.cs
@@ -147,7 +147,7 @@ private static ChangelogMatchResult BuildResult(
return null;
}
- if (!MatchesFilter(yamlDto, criteria, matchedPrs, matchedIssues))
+ if (!MatchesFilter(yamlDto, fileName, criteria, matchedPrs, matchedIssues))
return null;
// Add to seen set
@@ -180,6 +180,7 @@ private static ChangelogMatchResult BuildResult(
private static bool MatchesFilter(
ChangelogEntryDto data,
+ string fileName,
ChangelogFilterCriteria criteria,
HashSet matchedPrs,
HashSet matchedIssues)
@@ -191,7 +192,7 @@ private static bool MatchesFilter(
return MatchesProductFilter(data, criteria.ProductFilters);
if (criteria.PrsToMatch.Count > 0)
- return MatchesPrFilter(data, criteria, matchedPrs);
+ return MatchesPrFilter(data, fileName, criteria, matchedPrs);
if (criteria.IssuesToMatch.Count > 0)
return MatchesIssueFilter(data, criteria, matchedIssues);
@@ -225,9 +226,22 @@ private static bool MatchesProductFilter(
private static bool MatchesPrFilter(
ChangelogEntryDto data,
+ string fileName,
ChangelogFilterCriteria criteria,
HashSet matchedPrs)
{
+ var fileNumbers = ChangelogPrIdentity.ParseLeadingPrNumbers(fileName);
+ foreach (var pr in criteria.PrsToMatch)
+ {
+ var normalizedPrToMatch = ChangelogBundlingService.NormalizePrForComparison(pr, criteria.DefaultOwner, criteria.DefaultRepo);
+ if (ChangelogPrIdentity.TryParseNumberFromNormalized(normalizedPrToMatch, out var prNumber)
+ && fileNumbers.Contains(prNumber))
+ {
+ _ = matchedPrs.Add(pr);
+ return true;
+ }
+ }
+
var prs = data.Prs ?? (data.Pr != null ? [data.Pr] : null);
if (prs is not { Count: > 0 })
return false;
diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogPrIdentity.cs b/src/services/Elastic.Changelog/Bundling/ChangelogPrIdentity.cs
new file mode 100644
index 0000000000..67265ce6c9
--- /dev/null
+++ b/src/services/Elastic.Changelog/Bundling/ChangelogPrIdentity.cs
@@ -0,0 +1,50 @@
+// 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
+
+namespace Elastic.Changelog.Bundling;
+
+///
+/// Shared identity for matching a changelog entry to a pull request: leading numeric filename
+/// segments (survive scrubbing) or normalized YAML prs: references.
+///
+internal static class ChangelogPrIdentity
+{
+ ///
+ /// Parses PR numbers from the leading dash-separated numeric segments of an entry file name,
+ /// covering the PR-number naming schemes (123.yaml, 123-456.yaml,
+ /// 123-bug-fix-slug.yaml). File names survive scrubbing, so this match works for
+ /// private pools whose prs references were removed from the public copies.
+ ///
+ public static IReadOnlyList ParseLeadingPrNumbers(string fileName)
+ {
+ var stem = fileName;
+ var extensionIndex = stem.LastIndexOf('.');
+ if (extensionIndex > 0)
+ stem = stem[..extensionIndex];
+
+ var numbers = new List();
+ foreach (var segment in stem.Split('-'))
+ {
+ if (segment.Length > 0 && segment.All(char.IsAsciiDigit) && int.TryParse(segment, out var number))
+ numbers.Add(number);
+ else
+ break;
+ }
+
+ return numbers;
+ }
+
+ ///
+ /// Extracts the PR number from a value already normalized by
+ /// (owner/repo#n).
+ ///
+ public static bool TryParseNumberFromNormalized(string normalized, out int number)
+ {
+ number = 0;
+ var hash = normalized.LastIndexOf('#');
+ if (hash < 0 || hash == normalized.Length - 1)
+ return false;
+ return int.TryParse(normalized[(hash + 1)..], out number);
+ }
+}
diff --git a/src/services/Elastic.Changelog/Bundling/GitRangeEntryResolver.cs b/src/services/Elastic.Changelog/Bundling/GitRangeEntryResolver.cs
index f4f4ed86fb..dbb3e05c83 100644
--- a/src/services/Elastic.Changelog/Bundling/GitRangeEntryResolver.cs
+++ b/src/services/Elastic.Changelog/Bundling/GitRangeEntryResolver.cs
@@ -218,7 +218,7 @@ private sealed record ParsedCandidate(string FileName, IReadOnlyList FileNa
private static ParsedCandidate ParseCandidate(string fileName, string content)
{
- var numbers = ParseLeadingPrNumbers(fileName);
+ var numbers = ChangelogPrIdentity.ParseLeadingPrNumbers(fileName);
try
{
var checksum = ChangelogBundlingService.ComputeSha1(content);
@@ -240,30 +240,9 @@ private static ParsedCandidate ParseCandidate(string fileName, string content)
}
}
- ///
- /// Parses PR numbers from the leading dash-separated numeric segments of an entry file name,
- /// covering the PR-number naming schemes (123.yaml, 123-456.yaml,
- /// 123-bug-fix-slug.yaml). File names survive scrubbing, so this match works for
- /// private pools whose prs references were removed from the public copies.
- ///
- internal static IReadOnlyList ParseLeadingPrNumbers(string fileName)
- {
- var stem = fileName;
- var extensionIndex = stem.LastIndexOf('.');
- if (extensionIndex > 0)
- stem = stem[..extensionIndex];
-
- var numbers = new List();
- foreach (var segment in stem.Split('-'))
- {
- if (segment.Length > 0 && segment.All(char.IsAsciiDigit) && int.TryParse(segment, out var number))
- numbers.Add(number);
- else
- break;
- }
-
- return numbers;
- }
+ ///
+ internal static IReadOnlyList ParseLeadingPrNumbers(string fileName) =>
+ ChangelogPrIdentity.ParseLeadingPrNumbers(fileName);
private static bool MatchesPr(ParsedCandidate candidate, int prNumber, GitRangeEntryResolutionOptions options)
{
diff --git a/src/services/Elastic.Changelog/Reconciliation/BundleRegistryReconciler.cs b/src/services/Elastic.Changelog/Reconciliation/BundleRegistryReconciler.cs
index 526854f54f..4d32b691dd 100644
--- a/src/services/Elastic.Changelog/Reconciliation/BundleRegistryReconciler.cs
+++ b/src/services/Elastic.Changelog/Reconciliation/BundleRegistryReconciler.cs
@@ -45,11 +45,10 @@ public sealed class ReconcileConflictException(string message) : Exception(messa
/// the change that triggered it.
///
///
-/// Scoped to the bundle/{product}/ tree only: the {changelog} directive and external
-/// CDN consumers need to enumerate bundles, and dates (serverless) are not derivable client-side.
-/// The changelog/… pool manifests are deliberately not reconciled — release-note
-/// discovery starts from PR lists, so those manifests stay client-authored pass-through until
-/// Phase 3 retires them entirely.
+/// Applies to both trees. Bundle manifests record a target per file (the
+/// {changelog} directive enumerates by version/date). Pool manifests are listing-only:
+/// target is always null — changelog bundle --prs and git-ref still need a complete
+/// candidate listing because the public CDN cannot ListObjects.
///
public sealed class BundleRegistryReconciler(
ILoggerFactory logFactory,
@@ -85,9 +84,6 @@ public sealed class BundleRegistryReconciler(
///
public async Task ReconcileGroupAsync(ChangelogScope scope, Cancel ctx)
{
- if (scope.Kind != ChangelogScopeKind.Bundle)
- throw new ArgumentException($"Group reconcile applies to the bundle tree only; got '{scope}'.", nameof(scope));
-
_metrics.IncrementGroupReconciles();
for (var attempt = 1; attempt <= MaxWriteAttempts; attempt++)
@@ -253,9 +249,10 @@ await Parallel.ForEachAsync(
var file = obj.Key[scope.Prefix.Length..];
var etag = NormalizeETag(obj.ETag);
- // Amends are never ETag-skipped: their target depends on the parent bundle too,
- // and a parent appearing or changing does not touch the amend's own ETag.
- if (!BundleAmendMerger.IsAmendFile(file)
+ // Bundle amends are never ETag-skipped: their target depends on the parent bundle too,
+ // and a parent appearing or changing does not touch the amend's own ETag. Pool
+ // listings have no target, so every YAML is ETag-skippable.
+ if ((scope.Kind == ChangelogScopeKind.Changelog || !BundleAmendMerger.IsAmendFile(file))
&& byFile.TryGetValue(file, out var previous)
&& string.Equals(previous.ETag, etag, StringComparison.Ordinal))
{
@@ -264,7 +261,9 @@ await Parallel.ForEachAsync(
return;
}
- var target = await ComputeTarget(scope, file, ct);
+ var target = scope.Kind == ChangelogScopeKind.Bundle
+ ? await ComputeTarget(scope, file, ct)
+ : null;
_metrics.IncrementEntriesRecomputed();
built[i] = new RegistryBundle { File = file, Target = target, ETag = etag };
});
diff --git a/src/services/Elastic.Changelog/Scrubbing/ScrubberProcessor.cs b/src/services/Elastic.Changelog/Scrubbing/ScrubberProcessor.cs
index bef62e55b7..fa7c9d120e 100644
--- a/src/services/Elastic.Changelog/Scrubbing/ScrubberProcessor.cs
+++ b/src/services/Elastic.Changelog/Scrubbing/ScrubberProcessor.cs
@@ -20,9 +20,10 @@ public sealed record ScrubberQueueMessage(string MessageId, string Body);
/// Program.cs so it is testable. Events are triggers, state decides: the handler never
/// acts on an event's type — an event means only "this key may have changed, look at
/// it". Every distinct key gets one object-level reconcile against the private bucket; every
-/// distinct bundle/{product}/ group then gets one registry reconcile against the public
-/// listing, and every touched tree gets one shallow-map reconcile. Out-of-order and
-/// at-least-once S3 notifications are harmless and each batch heals accumulated drift.
+/// distinct group then gets one registry reconcile against the public listing (bundle
+/// target metadata; pool listing-only), and every touched tree gets one
+/// shallow-map reconcile. Out-of-order and at-least-once S3 notifications are harmless
+/// and each batch heals accumulated drift.
///
public sealed class ScrubberProcessor(
ILoggerFactory logFactory,
@@ -46,7 +47,7 @@ private sealed class ObjectWork(string sourceBucket, bool passThrough)
{
public string SourceBucket { get; set; } = sourceBucket;
- /// True for a pool manifest copied verbatim; false for YAML content that is scrubbed.
+ /// True to copy JSON verbatim; false to scrub YAML. Registry keys no longer use this path.
public bool PassThrough { get; } = passThrough;
public HashSet MessageIds { get; } = [with(StringComparer.Ordinal)];
@@ -158,15 +159,9 @@ private void Classify(
if (!hasScope)
return;
- // The two trees part ways here. Bundle manifests are reconciler-owned: the event only
- // schedules a group reconcile, so client-authored JSON never reaches the public bucket
- // for the tree consumers enumerate. Pool manifests stay client-authored pass-through —
- // `changelog bundle` still enumerates a pool through its manifest today, and 404-probing
- // only works once entries are guaranteed one-per-PR — until Phase 3 retires them.
- if (scope!.Kind == ChangelogScopeKind.Bundle)
- AddGroup(groupWork, scope, messageId);
- else
- AddObject(objectWork, key, sourceBucket, messageId, passThrough: true);
+ // Both trees are reconciler-owned: the event only schedules a group reconcile, so
+ // client-authored JSON never reaches the public bucket consumers enumerate.
+ AddGroup(groupWork, scope!, messageId);
return;
}
@@ -188,9 +183,8 @@ private void Classify(
if (!hasScope)
return;
- if (scope!.Kind == ChangelogScopeKind.Bundle)
- AddGroup(groupWork, scope, messageId);
- AddShallow(shallowWork, scope, messageId);
+ AddGroup(groupWork, scope!, messageId);
+ AddShallow(shallowWork, scope!, messageId);
}
private static void AddObject(
@@ -234,7 +228,7 @@ private static void AddShallow(Dictionary shall
/// Order-independent object reconcile: the event type is ignored; the private bucket's current
/// state decides between copy and delete. A stale ObjectRemoved arriving after a
/// recreate re-copies the live object instead of deleting it. YAML content is scrubbed on the
- /// way through; a pass-through pool manifest is copied verbatim.
+ /// way through. Pass-through remains for unused JSON copy of non-registry keys.
///
private async Task ReconcileObjectAsync(string sourceBucket, string key, bool passThrough, Cancel ctx)
{
diff --git a/src/services/Elastic.Changelog/Uploading/Registry.cs b/src/services/Elastic.Changelog/Uploading/Registry.cs
index 17d16f3ee3..6970e2398c 100644
--- a/src/services/Elastic.Changelog/Uploading/Registry.cs
+++ b/src/services/Elastic.Changelog/Uploading/Registry.cs
@@ -14,10 +14,9 @@ namespace Elastic.Changelog.Uploading;
///
/// Stored at bundle/{product}/registry.json (bundle index) or
/// changelog/{org}/{repo}/{branch}/registry.json (changelog-entry index). Ownership differs
-/// per tree: public bundle indexes are produced by the scrubber Lambda's
-/// from public-bucket state, while
-/// changelog-entry indexes remain client-authored and are mirrored verbatim to the public bucket
-/// (pass-through) until Phase 3 of elastic/docs-eng-team#688 retires them.
+/// per tree: public indexes on both trees are produced by the scrubber Lambda's
+/// from public-bucket state. Upload writes
+/// YAML only; it never writes a registry.json.
///
public sealed record Registry
{
@@ -80,14 +79,12 @@ public sealed record RegistryBundle
/// the MD5 of the body.
///
///
- /// Whose ETag this is follows the manifest's producer. Bundle indexes written by the scrubber
+ /// Whose ETag this is follows the manifest's producer. Indexes written by the scrubber
/// Lambda's record the public
- /// (post-scrub) object's ETag, valid for HTTP cache validation against the CDN. Client-authored
- /// manifests — changelog-entry indexes, and everything in the private bucket — record the
- /// private (pre-scrub) upload's ETag: a best-effort change hint that will not
- /// match the public object for scrubbed content, so consumers MUST NOT use it for integrity
- /// checks or cache validation there. Either way it is safe for detecting that an entry changed
- /// between manifest reads.
+ /// (post-scrub) object's ETag, valid for HTTP cache validation against the CDN. Legacy
+ /// client-authored manifests in the private bucket (no longer produced) recorded the
+ /// private (pre-scrub) upload's ETag. Either way it is safe for detecting that an
+ /// entry changed between manifest reads.
///
public required string ETag { get; init; }
}
diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleCdnSourcingTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleCdnSourcingTests.cs
index 63b770eae0..3cd083e03f 100644
--- a/tests/Elastic.Changelog.Tests/Changelogs/BundleCdnSourcingTests.cs
+++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleCdnSourcingTests.cs
@@ -68,18 +68,25 @@ private static CdnChangelogEntryFetcher Fetcher(ITestOutputHelper output, StubHa
private string OutputPath() =>
FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml");
+ private static string[] BothPrs =>
+ [
+ "https://github.com/elastic/elasticsearch/pull/100",
+ "https://github.com/elastic/elasticsearch/pull/999"
+ ];
+
[Fact]
- public async Task OptionMode_RepoResolvable_SourcesAllEntriesFromRepoPoolOnCdn()
+ public async Task OptionMode_RepoResolvable_SourcesMatchingPrsFromRepoPoolOnCdn()
{
// Under the artifact-root layout the CDN entry pool is keyed by the authoring repo, not the
- // target product. A resolvable repo (here via --repo) is what enables CDN sourcing.
+ // target product. A resolvable repo (here via --repo) is what enables CDN sourcing. --prs
+ // downloads the pool via registry.json then matches filename digits or YAML prs:.
var handler = RegistryHandler();
var service = new ChangelogBundlingService(LoggerFactory, FileSystem, null, null, Fetcher(Output, handler));
var output = OutputPath();
var input = new BundleChangelogsArguments
{
- InputProducts = [new ProductArgument { Product = "elasticsearch", Target = "*", Lifecycle = "*" }],
+ Prs = BothPrs,
Output = output,
Repo = "elasticsearch"
};
@@ -89,7 +96,6 @@ public async Task OptionMode_RepoResolvable_SourcesAllEntriesFromRepoPoolOnCdn()
result.Should().BeTrue($"Errors: {string.Join("; ", Collector.Diagnostics.Where(d => d.Severity == Severity.Error).Select(d => d.Message))}");
Collector.Errors.Should().Be(0);
- // Entries are sourced from the authoring pool, with org/branch defaulting: changelog/{org}/{repo}/{branch}/...
handler.RequestedPaths.Should().Contain("/changelog/elastic/elasticsearch/main/registry.json");
var bundle = await FileSystem.File.ReadAllTextAsync(output, TestContext.Current.CancellationToken);
@@ -108,7 +114,7 @@ public async Task OptionMode_OwnerAndBranchOverride_SourcesFromThatPoolOnCdn()
var input = new BundleChangelogsArguments
{
- InputProducts = [new ProductArgument { Product = "elasticsearch", Target = "*", Lifecycle = "*" }],
+ Prs = BothPrs,
Output = output,
Owner = "acme-corp",
Repo = "elasticsearch",
@@ -133,7 +139,7 @@ public async Task OptionMode_OwnerFromCombinedRepo_SourcesFromThatPool()
var input = new BundleChangelogsArguments
{
- InputProducts = [new ProductArgument { Product = "elasticsearch", Target = "*", Lifecycle = "*" }],
+ Prs = BothPrs,
Output = output,
Repo = "acme-corp/widget"
};
@@ -230,7 +236,7 @@ public async Task RegistryFailure_FailsBundle()
var input = new BundleChangelogsArguments
{
- InputProducts = [new ProductArgument { Product = "elasticsearch", Target = "*", Lifecycle = "*" }],
+ Prs = BothPrs,
Output = OutputPath(),
Repo = "elasticsearch"
};
@@ -260,7 +266,7 @@ public async Task EntryMissingAfterRetries_FailsBundle()
var input = new BundleChangelogsArguments
{
- InputProducts = [new ProductArgument { Product = "elasticsearch", Target = "*", Lifecycle = "*" }],
+ Prs = BothPrs,
Output = OutputPath(),
Repo = "elasticsearch"
};
@@ -322,6 +328,127 @@ public async Task ProfileGitHubRelease_ScopesByOutputProductsAndFiltersByRelease
bundle.Should().NotContain("Bravo");
}
+ [Fact]
+ public async Task OptionMode_All_OnCdn_ErrorsAndDoesNotFetch()
+ {
+ var handler = RegistryHandler();
+ var service = new ChangelogBundlingService(LoggerFactory, FileSystem, null, null, Fetcher(Output, handler));
+
+ var input = new BundleChangelogsArguments
+ {
+ All = true,
+ Output = OutputPath(),
+ Repo = "elasticsearch"
+ };
+
+ var result = await service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken);
+
+ result.Should().BeFalse();
+ Collector.Diagnostics.Should().Contain(d =>
+ d.Severity == Severity.Error && d.Message.Contains("--force-local"));
+ handler.RequestedPaths.Should().BeEmpty("CDN --all must fail before any fetch");
+ }
+
+ [Fact]
+ public async Task OptionMode_InputProductsOnly_OnCdn_ErrorsAndDoesNotFetch()
+ {
+ var handler = RegistryHandler();
+ var service = new ChangelogBundlingService(LoggerFactory, FileSystem, null, null, Fetcher(Output, handler));
+
+ var input = new BundleChangelogsArguments
+ {
+ InputProducts = [new ProductArgument { Product = "elasticsearch", Target = "*", Lifecycle = "*" }],
+ Output = OutputPath(),
+ Repo = "elasticsearch"
+ };
+
+ var result = await service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken);
+
+ result.Should().BeFalse();
+ Collector.Diagnostics.Should().Contain(d =>
+ d.Severity == Severity.Error && d.Message.Contains("--force-local"));
+ handler.RequestedPaths.Should().BeEmpty("CDN product-only filters must fail before any fetch");
+ }
+
+ [Fact]
+ public async Task OptionMode_Prs_MatchesFilenameDigitsWhenYamlPrsEmpty()
+ {
+ const string scrubbed = """
+ title: Scrubbed
+ type: feature
+ products:
+ - product: elasticsearch
+ target: 9.3.0
+ lifecycle: ga
+ """;
+ var handler = new StubHandler(req =>
+ {
+ var path = req.RequestUri!.AbsolutePath;
+ if (path.EndsWith("/registry.json", StringComparison.Ordinal))
+ return Json(/*lang=json,strict*/ """{ "schema_version": 1, "product": "elasticsearch", "bundles": [ { "file": "12345.yaml" }, { "file": "1735-foo.yaml" } ] }""");
+ if (path.EndsWith("12345.yaml", StringComparison.Ordinal))
+ return Yaml(scrubbed);
+ if (path.EndsWith("1735-foo.yaml", StringComparison.Ordinal))
+ return Yaml(scrubbed + "\nprs:\n - https://github.com/elastic/elasticsearch/pull/99999\n");
+ return new HttpResponseMessage(HttpStatusCode.NotFound);
+ });
+ var service = new ChangelogBundlingService(LoggerFactory, FileSystem, null, null, Fetcher(Output, handler));
+ var output = OutputPath();
+
+ var input = new BundleChangelogsArguments
+ {
+ Prs = ["https://github.com/elastic/elasticsearch/pull/12345"],
+ Output = output,
+ Repo = "elasticsearch"
+ };
+
+ var result = await service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken);
+
+ result.Should().BeTrue($"Errors: {string.Join("; ", Collector.Diagnostics.Where(d => d.Severity == Severity.Error).Select(d => d.Message))}");
+ var bundle = await FileSystem.File.ReadAllTextAsync(output, TestContext.Current.CancellationToken);
+ bundle.Should().Contain("name: 12345.yaml");
+ bundle.Should().NotContain("name: 1735-foo.yaml");
+ }
+
+ [Fact]
+ public async Task OptionMode_Prs_MatchesTimestampFileViaYamlPrs()
+ {
+ const string timestamped = """
+ title: Timestamped
+ type: feature
+ products:
+ - product: elasticsearch
+ target: 9.3.0
+ lifecycle: ga
+ prs:
+ - https://github.com/elastic/elasticsearch/pull/12345
+ """;
+ var handler = new StubHandler(req =>
+ {
+ var path = req.RequestUri!.AbsolutePath;
+ if (path.EndsWith("/registry.json", StringComparison.Ordinal))
+ return Json(/*lang=json,strict*/ """{ "schema_version": 1, "product": "elasticsearch", "bundles": [ { "file": "1735-foo.yaml" } ] }""");
+ if (path.EndsWith("1735-foo.yaml", StringComparison.Ordinal))
+ return Yaml(timestamped);
+ return new HttpResponseMessage(HttpStatusCode.NotFound);
+ });
+ var service = new ChangelogBundlingService(LoggerFactory, FileSystem, null, null, Fetcher(Output, handler));
+ var output = OutputPath();
+
+ var input = new BundleChangelogsArguments
+ {
+ Prs = ["https://github.com/elastic/elasticsearch/pull/12345"],
+ Output = output,
+ Repo = "elasticsearch"
+ };
+
+ var result = await service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken);
+
+ result.Should().BeTrue($"Errors: {string.Join("; ", Collector.Diagnostics.Where(d => d.Severity == Severity.Error).Select(d => d.Message))}");
+ var bundle = await FileSystem.File.ReadAllTextAsync(output, TestContext.Current.CancellationToken);
+ bundle.Should().Contain("name: 1735-foo.yaml");
+ }
+
private static HttpResponseMessage Json(string body) =>
new(HttpStatusCode.OK) { Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json") };
diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs
index 8f195f8c52..bae4c3d9a6 100644
--- a/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs
+++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleChangelogsTests.cs
@@ -358,6 +358,68 @@ public async Task BundleChangelogs_WithPrsFilterAndUnmatchedPrs_EmitsWarnings()
d.Message.Contains("No changelog file found for PR: https://github.com/elastic/elasticsearch/pull/300"));
}
+ [Fact]
+ public async Task BundleChangelogs_WithPrsFilter_MatchesFilenameDigitsWhenYamlPrsEmpty()
+ {
+ var changelog =
+ """
+ title: Filename identity
+ type: feature
+ products:
+ - product: elasticsearch
+ target: 9.2.0
+ """;
+
+ var file = FileSystem.Path.Join(_changelogDir, "12345.yaml");
+ await FileSystem.File.WriteAllTextAsync(file, changelog, TestContext.Current.CancellationToken);
+
+ var input = new BundleChangelogsArguments
+ {
+ Directory = _changelogDir,
+ Prs = ["https://github.com/elastic/elasticsearch/pull/12345"],
+ Output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml")
+ };
+
+ var result = await Service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken);
+
+ result.Should().BeTrue();
+ Collector.Errors.Should().Be(0);
+ var bundleContent = await FileSystem.File.ReadAllTextAsync(input.Output, TestContext.Current.CancellationToken);
+ bundleContent.Should().Contain("name: 12345.yaml");
+ }
+
+ [Fact]
+ public async Task BundleChangelogs_WithPrsFilter_MatchesTimestampFileViaYamlPrs()
+ {
+ var changelog =
+ """
+ title: Timestamp identity
+ type: feature
+ products:
+ - product: elasticsearch
+ target: 9.2.0
+ prs:
+ - https://github.com/elastic/elasticsearch/pull/12345
+ """;
+
+ var file = FileSystem.Path.Join(_changelogDir, "1735-foo.yaml");
+ await FileSystem.File.WriteAllTextAsync(file, changelog, TestContext.Current.CancellationToken);
+
+ var input = new BundleChangelogsArguments
+ {
+ Directory = _changelogDir,
+ Prs = ["https://github.com/elastic/elasticsearch/pull/12345"],
+ Output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml")
+ };
+
+ var result = await Service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken);
+
+ result.Should().BeTrue();
+ Collector.Errors.Should().Be(0);
+ var bundleContent = await FileSystem.File.ReadAllTextAsync(input.Output, TestContext.Current.CancellationToken);
+ bundleContent.Should().Contain("name: 1735-foo.yaml");
+ }
+
[Fact]
public async Task BundleChangelogs_WithPrsFileFilter_FiltersCorrectly()
{
diff --git a/tests/Elastic.Changelog.Tests/Changelogs/BundleFilesFilterTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/BundleFilesFilterTests.cs
index 7ba1ab2e2e..8ed6e5bfee 100644
--- a/tests/Elastic.Changelog.Tests/Changelogs/BundleFilesFilterTests.cs
+++ b/tests/Elastic.Changelog.Tests/Changelogs/BundleFilesFilterTests.cs
@@ -321,7 +321,9 @@ public async Task Bundle_WithFiles_RepoResolves_MatchesCdnPoolByFileName()
var result = await service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken);
result.Should().BeTrue($"Errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}");
- handler.RequestedPaths.Should().Contain("/changelog/elastic/elasticsearch/main/registry.json");
+ handler.RequestedPaths.Should().NotContain(p => p.EndsWith("/registry.json", StringComparison.Ordinal));
+ handler.RequestedPaths.Should().Contain("/changelog/elastic/elasticsearch/main/keep.yaml");
+ handler.RequestedPaths.Should().NotContain(p => p.EndsWith("/skip.yaml", StringComparison.Ordinal));
var bundle = await FileSystem.File.ReadAllTextAsync(output, TestContext.Current.CancellationToken);
bundle.Should().Contain("name: keep.yaml");
bundle.Should().NotContain("name: skip.yaml");
@@ -344,7 +346,7 @@ public async Task Bundle_WithFiles_CdnPoolMissingRequestedName_FailsBundle()
result.Should().BeFalse();
Collector.Diagnostics.Should().Contain(d =>
- d.Severity == Severity.Error && d.Message.Contains("not found in the CDN pool") && d.Message.Contains("never-uploaded.yaml"));
+ d.Severity == Severity.Error && d.Message.Contains("could not be fetched") && d.Message.Contains("never-uploaded.yaml"));
}
[Fact]
@@ -384,7 +386,9 @@ public async Task Bundle_WithProfile_PathListFile_RepoResolves_SourcesFromCdn()
var result = await service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken);
result.Should().BeTrue($"Errors: {string.Join("; ", Collector.Diagnostics.Select(d => d.Message))}");
- handler.RequestedPaths.Should().Contain("/changelog/elastic/elasticsearch/main/registry.json");
+ handler.RequestedPaths.Should().NotContain(p => p.EndsWith("/registry.json", StringComparison.Ordinal));
+ handler.RequestedPaths.Should().Contain("/changelog/elastic/elasticsearch/main/keep.yaml");
+ handler.RequestedPaths.Should().NotContain(p => p.EndsWith("/skip.yaml", StringComparison.Ordinal));
var bundle = await FileSystem.File.ReadAllTextAsync(
FileSystem.Path.Join(outputDir, "bundle.yaml"), TestContext.Current.CancellationToken);
bundle.Should().Contain("name: keep.yaml");
diff --git a/tests/Elastic.Changelog.Tests/Changelogs/CloudProfileFixtureTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/CloudProfileFixtureTests.cs
index 32294e20b7..23b7b6a2b0 100644
--- a/tests/Elastic.Changelog.Tests/Changelogs/CloudProfileFixtureTests.cs
+++ b/tests/Elastic.Changelog.Tests/Changelogs/CloudProfileFixtureTests.cs
@@ -97,9 +97,15 @@ public async Task MonthlyProfile_SourcesFromRepoPool_ProducesSoundBundle()
var outputDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString());
FileSystem.Directory.CreateDirectory(outputDir);
+ var localDir = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "changelog");
+ FileSystem.Directory.CreateDirectory(localDir);
+ await FileSystem.File.WriteAllTextAsync(FileSystem.Path.Join(localDir, "1-feature.yaml"), FeatureEntry, TestContext.Current.CancellationToken);
+ await FileSystem.File.WriteAllTextAsync(FileSystem.Path.Join(localDir, "2-docs.yaml"), DocsEntry, TestContext.Current.CancellationToken);
+ await FileSystem.File.WriteAllTextAsync(FileSystem.Path.Join(localDir, "3-other.yaml"), OtherProductEntry, TestContext.Current.CancellationToken);
+
// language=yaml
var configContent =
- """
+ $$"""
products:
available:
- cloud-hosted
@@ -114,9 +120,11 @@ public async Task MonthlyProfile_SourcesFromRepoPool_ProducesSoundBundle()
bundle:
exclude_types: "docs"
bundle:
+ directory: {{localDir}}
output_directory: PLACEHOLDER
repo: widget
owner: elastic
+ use_local_changelogs: true
release_dates: false
link_allow_repos:
- elastic/elasticsearch
@@ -147,10 +155,9 @@ public async Task MonthlyProfile_SourcesFromRepoPool_ProducesSoundBundle()
result.Should().BeTrue($"Errors: {string.Join("; ", Collector.Diagnostics.Where(d => d.Severity == Severity.Error).Select(d => d.Message))}");
Collector.Errors.Should().Be(0);
- // Entries are sourced once from the authoring pool (changelog/{org}/{repo}/{branch}/...), not from
- // any product-scoped path. Owner comes from bundle.owner; branch defaults to "main".
- handler.RequestedPaths.Should().Contain($"/changelog/elastic/{AuthoringRepo}/main/registry.json");
- handler.RequestedPaths.Should().NotContain(p => p.Contains("/cloud-hosted/changelog/", StringComparison.Ordinal));
+ // Product-only CDN sourcing is deferred; this fixture forces local so it still covers
+ // exclude_types, link-allowlist scrubbing, and release_dates: false.
+ handler.RequestedPaths.Should().BeEmpty("use_local_changelogs must not reach the CDN");
var outputFiles = FileSystem.Directory.GetFiles(outputDir, "*.yaml");
outputFiles.Should().ContainSingle("the monthly profile writes a single bundle file");
diff --git a/tests/Elastic.Changelog.Tests/Reconciliation/BundleRegistryReconcilerTests.cs b/tests/Elastic.Changelog.Tests/Reconciliation/BundleRegistryReconcilerTests.cs
index 70c9851f00..6c0692faac 100644
--- a/tests/Elastic.Changelog.Tests/Reconciliation/BundleRegistryReconcilerTests.cs
+++ b/tests/Elastic.Changelog.Tests/Reconciliation/BundleRegistryReconcilerTests.cs
@@ -268,17 +268,53 @@ public async Task ReconcileGroup_ListingPaginates()
}
[Fact]
- public async Task ReconcileGroup_ChangelogScope_IsRejected()
+ public async Task ReconcileGroup_ChangelogScope_WritesListingOnlyManifest()
{
- // Pool manifests are not reconciled: they stay client-authored pass-through until Phase 3
- // retires them, so a changelog scope reaching the group reconciler is a programming error.
- var scope = ChangelogScopeFor("elastic", "repo", "main");
- _ = _s3.Seed(PublicBucket, scope.Prefix + "entry-a.yaml", "a: 1");
+ var scope = ChangelogScopeFor("elastic", "kibana", "main");
+ _ = _s3.Seed(PublicBucket, scope.Prefix + "100.yaml", "title: a");
+ _ = _s3.Seed(PublicBucket, scope.Prefix + "1735-foo.yaml", "title: b");
- var act = async () => await _reconciler.ReconcileGroupAsync(scope, Ctx);
+ var outcome = await _reconciler.ReconcileGroupAsync(scope, Ctx);
- _ = await act.Should().ThrowAsync();
- _s3.Puts.Should().BeEmpty();
+ outcome.Should().Be(GroupReconcileOutcome.Written);
+ var content = _s3.ContentOf(PublicBucket, scope.RegistryKey);
+ var manifest = JsonSerializer.Deserialize(content, RegistryJsonContext.Default.Registry)!;
+ manifest.Producer.Should().Be(BundleRegistryReconciler.Producer);
+ manifest.Product.Should().Be("elastic/kibana/main");
+ manifest.Bundles.Select(b => b.File).Should().Equal("100.yaml", "1735-foo.yaml");
+ manifest.Bundles.Should().OnlyContain(b => b.Target == null);
+ _s3.GetsFor(PublicBucket).Should().BeEquivalentTo([scope.RegistryKey],
+ "pool listings do not GET YAML to compute a target");
+ }
+
+ [Fact]
+ public async Task ReconcileGroup_ChangelogScope_HealsYamlMissingFromStaleClientManifest()
+ {
+ var scope = ChangelogScopeFor("elastic", "kibana", "main");
+ _ = _s3.Seed(PublicBucket, scope.Prefix + "100.yaml", "title: a");
+ _ = _s3.Seed(PublicBucket, scope.Prefix + "200.yaml", "title: b");
+ SeedManifest(scope, producer: null, schemaVersion: Registry.CurrentSchemaVersion,
+ new RegistryBundle { File = "100.yaml", Target = null, ETag = "stale" });
+
+ var outcome = await _reconciler.ReconcileGroupAsync(scope, Ctx);
+
+ outcome.Should().Be(GroupReconcileOutcome.Written);
+ var content = _s3.ContentOf(PublicBucket, scope.RegistryKey);
+ var manifest = JsonSerializer.Deserialize(content, RegistryJsonContext.Default.Registry)!;
+ manifest.Bundles.Select(b => b.File).Should().Equal("100.yaml", "200.yaml");
+ manifest.Producer.Should().Be(BundleRegistryReconciler.Producer);
+ }
+
+ [Fact]
+ public async Task ReconcileGroup_ChangelogScope_EmptyGroup_DeletesTheManifest()
+ {
+ var scope = ChangelogScopeFor("elastic", "kibana", "main");
+ SeedManifest(scope, producer: BundleRegistryReconciler.Producer, schemaVersion: Registry.CurrentSchemaVersion);
+
+ var outcome = await _reconciler.ReconcileGroupAsync(scope, Ctx);
+
+ outcome.Should().Be(GroupReconcileOutcome.Deleted);
+ _s3.Exists(PublicBucket, scope.RegistryKey).Should().BeFalse();
}
[Fact]
diff --git a/tests/Elastic.Changelog.Tests/Scrubbing/ScrubberProcessorTests.cs b/tests/Elastic.Changelog.Tests/Scrubbing/ScrubberProcessorTests.cs
index 7ae9d72f49..35d2488a1d 100644
--- a/tests/Elastic.Changelog.Tests/Scrubbing/ScrubberProcessorTests.cs
+++ b/tests/Elastic.Changelog.Tests/Scrubbing/ScrubberProcessorTests.cs
@@ -122,28 +122,29 @@ public async Task Process_BundleRegistryKeyEvents_NeverCopyOrDelete_OnlyTriggerA
}
[Fact]
- public async Task Process_PoolRegistryKeyEvents_ArePassedThroughVerbatim()
+ public async Task Process_PoolRegistryKeyEvents_NeverCopyOrDelete_OnlyTriggerAGroupReconcile()
{
- // Pool manifests stay client-authored until Phase 3: `changelog bundle` still enumerates a
- // pool through its manifest, so the private copy is mirrored verbatim — never scrubbed,
- // never reconciled.
+ // Pool manifests are reconciler-owned, same as bundles: the event only schedules a listing
+ // reconcile. Client JSON in the private bucket must never reach the public key.
const string poolRegistry = "changelog/elastic/kibana/main/registry.json";
- const string content = /*lang=json,strict*/ """{"schema_version":1,"bundles":[{"file":"100.yaml"}]}""";
- _ = _s3.Seed(PrivateBucket, poolRegistry, content);
+ _ = _s3.Seed(PrivateBucket, poolRegistry, /*lang=json,strict*/ """{"schema_version":1,"bundles":[{"file":"stale.yaml"}]}""");
+ _ = _s3.Seed(PublicBucket, "changelog/elastic/kibana/main/100.yaml", "scrubbed: entry");
var failed = await _processor.ProcessAsync([Message("ObjectCreated:Put", poolRegistry)], Ctx);
failed.Should().BeEmpty();
- _s3.ContentOf(PublicBucket, poolRegistry).Should().Be(content, "pass-through must not transform the manifest");
- _metrics.GroupReconciles.Should().Be(0, "pool manifests are not reconciled");
- _s3.Puts.Single(p => p.Key == poolRegistry).ContentType.Should().Be("application/json");
+ var manifest = PublicManifest(poolRegistry);
+ manifest.Producer.Should().Be(BundleRegistryReconciler.Producer);
+ manifest.Product.Should().Be("elastic/kibana/main");
+ manifest.Bundles.Select(b => b.File).Should().Equal("100.yaml");
+ manifest.Bundles.Should().OnlyContain(b => b.Target == null);
+ _s3.GetsFor(PrivateBucket).Should().BeEmpty("the private pool registry content must never be read");
}
[Fact]
- public async Task Process_PoolRegistryKeyEvents_WithPrivateGone_DeleteThePublicCopy()
+ public async Task Process_PoolRegistryKeyEvents_EmptyPublicGroup_DeletesTheManifest()
{
- // State decides for pass-through keys too: Phase 3's private-manifest cleanup deletes will
- // propagate and remove the public pool manifests with them.
+ // A registry-key event with no public YAML is an empty-group reconcile: absent ≠ empty.
const string poolRegistry = "changelog/elastic/kibana/main/registry.json";
_ = _s3.Seed(PublicBucket, poolRegistry, "{}");
@@ -154,7 +155,7 @@ public async Task Process_PoolRegistryKeyEvents_WithPrivateGone_DeleteThePublicC
}
[Fact]
- public async Task Process_PoolYamlEvents_ScrubAndUpdateTheShallowMap_ButWriteNoPoolManifest()
+ public async Task Process_PoolYamlEvents_ScrubAndReconcileThePoolManifest()
{
_ = _s3.Seed(PrivateBucket, "changelog/elastic/kibana/main/100.yaml", "entry");
@@ -162,9 +163,11 @@ public async Task Process_PoolYamlEvents_ScrubAndUpdateTheShallowMap_ButWriteNoP
failed.Should().BeEmpty();
_s3.ContentOf(PublicBucket, "changelog/elastic/kibana/main/100.yaml").Should().Be("scrubbed: entry");
- _s3.Exists(PublicBucket, "changelog/elastic/kibana/main/registry.json")
- .Should().BeFalse("the reconciler no longer produces pool manifests");
- _metrics.GroupReconciles.Should().Be(0);
+ var manifest = PublicManifest("changelog/elastic/kibana/main/registry.json");
+ manifest.Producer.Should().Be(BundleRegistryReconciler.Producer);
+ manifest.Bundles.Select(b => b.File).Should().Equal("100.yaml");
+ manifest.Bundles.Should().OnlyContain(b => b.Target == null);
+ _metrics.GroupReconciles.Should().Be(1);
var map = ShallowMap("changelog/registry.json");
map.Should().ContainKey("elastic/kibana/main");
diff --git a/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/CdnChangelogEntryFetcherTests.cs b/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/CdnChangelogEntryFetcherTests.cs
index 7d2f182786..f9fd63b3f3 100644
--- a/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/CdnChangelogEntryFetcherTests.cs
+++ b/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/CdnChangelogEntryFetcherTests.cs
@@ -188,6 +188,42 @@ public async Task FetchAsync_UnsafeFileName_EmitsWarningAndSkips()
warnings.Should().ContainSingle().Which.Should().Contain("escape.yaml");
}
+ [Fact]
+ public async Task FetchNamedAsync_HappyPath_GetsOnlyRequestedFilesWithoutRegistry()
+ {
+ var handler = new StubHandler(req =>
+ req.RequestUri!.AbsolutePath.EndsWith("/keep.yaml", StringComparison.Ordinal)
+ ? Yaml(SampleEntry)
+ : new HttpResponseMessage(HttpStatusCode.NotFound));
+ var (errors, warnings, emitError, emitWarning) = Diagnostics();
+
+ using var fetcher = CreateFetcher(handler);
+ var entries = await fetcher.FetchNamedAsync(
+ BaseUri, "elastic", "elasticsearch", "main", ["keep.yaml"], emitError, emitWarning, TestContext.Current.CancellationToken);
+
+ errors.Should().BeEmpty();
+ warnings.Should().BeEmpty();
+ entries.Select(e => e.FileName).Should().BeEquivalentTo("keep.yaml");
+ handler.RequestedPaths.Should().NotContain(p => p.EndsWith("/registry.json", StringComparison.Ordinal));
+ handler.RequestedPaths.Should().Contain("/changelog/elastic/elasticsearch/main/keep.yaml");
+ }
+
+ [Fact]
+ public async Task FetchNamedAsync_MissingAfterRetries_EmitsError()
+ {
+ var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound));
+ var (errors, _, emitError, emitWarning) = Diagnostics();
+
+ using var fetcher = CreateFetcher(handler, maxAttempts: 3);
+ var entries = await fetcher.FetchNamedAsync(
+ BaseUri, "elastic", "elasticsearch", "main", ["never-uploaded.yaml"], emitError, emitWarning, TestContext.Current.CancellationToken);
+
+ entries.Should().BeEmpty();
+ errors.Should().ContainSingle().Which.Should().Contain("never-uploaded.yaml");
+ handler.RequestedPaths.Count(p => p.EndsWith("/never-uploaded.yaml", StringComparison.Ordinal))
+ .Should().Be(3);
+ }
+
private static HttpResponseMessage Json(string body) =>
new(HttpStatusCode.OK) { Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json") };
diff --git a/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/CdnChangelogFetcherTests.cs b/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/CdnChangelogFetcherTests.cs
index b87cb5ba64..34d4523ca4 100644
--- a/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/CdnChangelogFetcherTests.cs
+++ b/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/CdnChangelogFetcherTests.cs
@@ -28,6 +28,9 @@ public class CdnChangelogFetcherTests
private static readonly Uri BaseUri = new("https://cdn.example");
+ /// The shallow per-tree map probed once per run before any per-product registry fetch.
+ private const string ShallowMapPath = "/bundle/registry.json";
+
private static CdnChangelogFetcher CreateFetcher(StubHandler handler) =>
new(NullLoggerFactory.Instance, new FileSystem(), handler);
@@ -289,10 +292,13 @@ public async Task FetchAsync_InvalidProduct_EmitsErrorAndDoesNotHitCdn(string pr
[Fact]
public async Task FetchAsync_WithETag_UsesCachedBundleOnSecondCall()
{
- var handler = new StubHandler(req =>
- req.RequestUri!.AbsolutePath.EndsWith("/registry.json", StringComparison.Ordinal)
- ? Json(/*lang=json,strict*/ """{ "schema_version": 1, "product": "elasticsearch", "bundles": [ { "file": "9.3.0.yaml", "target": "9.3.0", "etag": "abc123" } ] }""")
- : Yaml(SampleBundle));
+ var handler = new StubHandler(req => req.RequestUri!.AbsolutePath switch
+ {
+ ShallowMapPath => NotFound(),
+ var p when p.EndsWith("/registry.json", StringComparison.Ordinal) =>
+ Json(/*lang=json,strict*/ """{ "schema_version": 1, "product": "elasticsearch", "bundles": [ { "file": "9.3.0.yaml", "target": "9.3.0", "etag": "abc123" } ] }"""),
+ _ => Yaml(SampleBundle)
+ });
var (errors, warnings, emitError, emitWarning) = Diagnostics();
var fs = new MockFileSystem();
@@ -301,12 +307,12 @@ public async Task FetchAsync_WithETag_UsesCachedBundleOnSecondCall()
// First call — should fetch from CDN
var bundles = await fetcher.FetchAsync(BaseUri, "elasticsearch", version: null, emitError, emitWarning, TestContext.Current.CancellationToken);
bundles.Should().ContainSingle();
- handler.CallCount.Should().Be(2, "registry + bundle");
+ handler.CallCount.Should().Be(3, "shallow map probe + registry + bundle");
- // Second call — bundle should come from cache (only registry re-fetched)
+ // Second call — bundle should come from cache (only registry re-fetched; the map probe is memoized per run)
var bundles2 = await fetcher.FetchAsync(BaseUri, "elasticsearch", version: null, emitError, emitWarning, TestContext.Current.CancellationToken);
bundles2.Should().ContainSingle();
- handler.CallCount.Should().Be(3, "only registry fetched again; bundle served from memory cache");
+ handler.CallCount.Should().Be(4, "only registry fetched again; bundle served from memory cache");
errors.Should().BeEmpty();
}
@@ -337,27 +343,33 @@ public async Task FetchAsync_WithETag_ReadsCacheFromDisk()
fs.Directory.CreateDirectory(Path.GetDirectoryName(cachePath)!);
fs.File.WriteAllText(cachePath, SampleBundle);
- var handler = new StubHandler(req =>
- req.RequestUri!.AbsolutePath.EndsWith("/registry.json", StringComparison.Ordinal)
- ? Json(/*lang=json,strict*/ """{ "schema_version": 1, "product": "elasticsearch", "bundles": [ { "file": "9.3.0.yaml", "target": "9.3.0", "etag": "cached1" } ] }""")
- : throw new InvalidOperationException("Should not fetch bundle from CDN"));
+ var handler = new StubHandler(req => req.RequestUri!.AbsolutePath switch
+ {
+ ShallowMapPath => NotFound(),
+ var p when p.EndsWith("/registry.json", StringComparison.Ordinal) =>
+ Json(/*lang=json,strict*/ """{ "schema_version": 1, "product": "elasticsearch", "bundles": [ { "file": "9.3.0.yaml", "target": "9.3.0", "etag": "cached1" } ] }"""),
+ _ => throw new InvalidOperationException("Should not fetch bundle from CDN")
+ });
var (errors, warnings, emitError, emitWarning) = Diagnostics();
using var fetcher = new CdnChangelogFetcher(NullLoggerFactory.Instance, fs, handler);
var bundles = await fetcher.FetchAsync(BaseUri, "elasticsearch", version: null, emitError, emitWarning, TestContext.Current.CancellationToken);
bundles.Should().ContainSingle();
- handler.CallCount.Should().Be(1, "only registry should be fetched; bundle served from disk");
+ handler.CallCount.Should().Be(2, "only the map probe and registry should be fetched; bundle served from disk");
errors.Should().BeEmpty();
}
[Fact]
public async Task FetchAsync_NullETag_AlwaysFetchesFromCdn()
{
- var handler = new StubHandler(req =>
- req.RequestUri!.AbsolutePath.EndsWith("/registry.json", StringComparison.Ordinal)
- ? Json(/*lang=json,strict*/ """{ "schema_version": 1, "product": "elasticsearch", "bundles": [ { "file": "9.3.0.yaml", "target": "9.3.0", "etag": null } ] }""")
- : Yaml(SampleBundle));
+ var handler = new StubHandler(req => req.RequestUri!.AbsolutePath switch
+ {
+ ShallowMapPath => NotFound(),
+ var p when p.EndsWith("/registry.json", StringComparison.Ordinal) =>
+ Json(/*lang=json,strict*/ """{ "schema_version": 1, "product": "elasticsearch", "bundles": [ { "file": "9.3.0.yaml", "target": "9.3.0", "etag": null } ] }"""),
+ _ => Yaml(SampleBundle)
+ });
var (errors, _, emitError, emitWarning) = Diagnostics();
var fs = new MockFileSystem();
@@ -365,11 +377,11 @@ public async Task FetchAsync_NullETag_AlwaysFetchesFromCdn()
// First call
_ = await fetcher.FetchAsync(BaseUri, "elasticsearch", version: null, emitError, emitWarning, TestContext.Current.CancellationToken);
- handler.CallCount.Should().Be(2);
+ handler.CallCount.Should().Be(3, "map probe + registry + bundle");
- // Second call — no caching, so bundle is fetched again
+ // Second call — no caching, so bundle is fetched again (the map probe stays memoized)
_ = await fetcher.FetchAsync(BaseUri, "elasticsearch", version: null, emitError, emitWarning, TestContext.Current.CancellationToken);
- handler.CallCount.Should().Be(4, "without ETag, both registry and bundle are fetched each time");
+ handler.CallCount.Should().Be(5, "without ETag, both registry and bundle are fetched each time");
errors.Should().BeEmpty();
}
@@ -377,25 +389,200 @@ public async Task FetchAsync_NullETag_AlwaysFetchesFromCdn()
public async Task FetchAsync_ChangedETag_FetchesNewBundle()
{
var etag = "v1";
- var handler = new StubHandler(req =>
- req.RequestUri!.AbsolutePath.EndsWith("/registry.json", StringComparison.Ordinal)
- ? Json($$"""{ "schema_version": 1, "product": "elasticsearch", "bundles": [ { "file": "9.3.0.yaml", "target": "9.3.0", "etag": "{{etag}}" } ] }""")
- : Yaml(SampleBundle));
+ var handler = new StubHandler(req => req.RequestUri!.AbsolutePath switch
+ {
+ ShallowMapPath => NotFound(),
+ var p when p.EndsWith("/registry.json", StringComparison.Ordinal) =>
+ Json($$"""{ "schema_version": 1, "product": "elasticsearch", "bundles": [ { "file": "9.3.0.yaml", "target": "9.3.0", "etag": "{{etag}}" } ] }"""),
+ _ => Yaml(SampleBundle)
+ });
var (errors, _, emitError, emitWarning) = Diagnostics();
var fs = new MockFileSystem();
using var fetcher = new CdnChangelogFetcher(NullLoggerFactory.Instance, fs, handler);
_ = await fetcher.FetchAsync(BaseUri, "elasticsearch", version: null, emitError, emitWarning, TestContext.Current.CancellationToken);
- handler.CallCount.Should().Be(2);
+ handler.CallCount.Should().Be(3, "map probe + registry + bundle");
// Simulate a new etag by creating a new fetcher (in real usage the registry returns a different etag)
etag = "v2";
_ = await fetcher.FetchAsync(BaseUri, "elasticsearch", version: null, emitError, emitWarning, TestContext.Current.CancellationToken);
- handler.CallCount.Should().Be(4, "new ETag means cache miss, bundle re-downloaded");
+ handler.CallCount.Should().Be(5, "new ETag means cache miss, bundle re-downloaded");
errors.Should().BeEmpty();
}
+ // language=json
+ private const string EsRegistryJson =
+ """{ "schema_version": 1, "product": "elasticsearch", "bundles": [ { "file": "9.3.0.yaml", "target": "9.3.0", "etag": "abc123" } ] }""";
+
+ private static string CacheFilePath(string cacheKey) =>
+ Path.Join(Paths.ApplicationData.FullName, "changelog-bundles", cacheKey);
+
+ /// Seeds the disk cache as a previous run with shallow token would have left it.
+ private static MockFileSystem WarmCache(string token)
+ {
+ var fs = new MockFileSystem();
+ fs.Directory.CreateDirectory(Path.GetDirectoryName(CacheFilePath("x"))!);
+ fs.File.WriteAllText(CacheFilePath($"registry-elasticsearch-{token}"), EsRegistryJson);
+ fs.File.WriteAllText(CacheFilePath("changelog-elasticsearch-9.3.0.yaml-abc123"), SampleBundle);
+ return fs;
+ }
+
+ [Fact]
+ public async Task FetchAsync_ShallowMapAbsent_FetchesRegistryAndBundleAsBefore()
+ {
+ // Pre-cutover CDNs have no bundle/registry.json: a 404 must degrade to the full per-product flow.
+ var handler = new StubHandler(req => req.RequestUri!.AbsolutePath switch
+ {
+ ShallowMapPath => NotFound(),
+ var p when p.EndsWith("/registry.json", StringComparison.Ordinal) => Json(EsRegistryJson),
+ _ => Yaml(SampleBundle)
+ });
+ var (errors, warnings, emitError, emitWarning) = Diagnostics();
+
+ using var fetcher = new CdnChangelogFetcher(NullLoggerFactory.Instance, new MockFileSystem(), handler);
+ var bundles = await fetcher.FetchAsync(BaseUri, "elasticsearch", version: null, emitError, emitWarning, TestContext.Current.CancellationToken);
+
+ errors.Should().BeEmpty();
+ warnings.Should().BeEmpty();
+ bundles.Should().ContainSingle();
+ handler.RequestedPaths.Should().Contain("/bundle/elasticsearch/registry.json");
+ handler.RequestedPaths.Should().Contain("/bundle/elasticsearch/9.3.0.yaml");
+ }
+
+ [Fact]
+ public async Task FetchAsync_ShallowMapUnparseable_FetchesRegistryAndBundleAsBefore()
+ {
+ var handler = new StubHandler(req => req.RequestUri!.AbsolutePath switch
+ {
+ ShallowMapPath => Json("{ not valid json"),
+ var p when p.EndsWith("/registry.json", StringComparison.Ordinal) => Json(EsRegistryJson),
+ _ => Yaml(SampleBundle)
+ });
+ var (errors, warnings, emitError, emitWarning) = Diagnostics();
+
+ using var fetcher = new CdnChangelogFetcher(NullLoggerFactory.Instance, new MockFileSystem(), handler);
+ var bundles = await fetcher.FetchAsync(BaseUri, "elasticsearch", version: null, emitError, emitWarning, TestContext.Current.CancellationToken);
+
+ errors.Should().BeEmpty();
+ warnings.Should().BeEmpty();
+ bundles.Should().ContainSingle();
+ handler.RequestedPaths.Should().Contain("/bundle/elasticsearch/registry.json");
+ handler.RequestedPaths.Should().Contain("/bundle/elasticsearch/9.3.0.yaml");
+ }
+
+ [Fact]
+ public async Task FetchAsync_ShallowTokenMatchesWarmCache_MakesNoPerProductRequests()
+ {
+ var fs = WarmCache("tok1");
+ var handler = new StubHandler(req => req.RequestUri!.AbsolutePath == ShallowMapPath
+ ? Json(/*lang=json,strict*/ """{ "elasticsearch": "tok1" }""")
+ : throw new InvalidOperationException($"Unexpected per-folder request: {req.RequestUri}"));
+ var (errors, warnings, emitError, emitWarning) = Diagnostics();
+
+ using var fetcher = new CdnChangelogFetcher(NullLoggerFactory.Instance, fs, handler);
+ var bundles = await fetcher.FetchAsync(BaseUri, "elasticsearch", version: null, emitError, emitWarning, TestContext.Current.CancellationToken);
+
+ errors.Should().BeEmpty();
+ warnings.Should().BeEmpty();
+ bundles.Should().ContainSingle();
+ bundles[0].Entries.Should().ContainSingle().Which.Title.Should().Be("Sample enhancement");
+ handler.RequestedPaths.Should().Equal(ShallowMapPath);
+ }
+
+ [Fact]
+ public async Task FetchAsync_ShallowTokenMismatch_FetchesRegistryAndRecordsNewToken()
+ {
+ var fs = WarmCache("tok-old");
+ var handler = new StubHandler(req => req.RequestUri!.AbsolutePath switch
+ {
+ ShallowMapPath => Json(/*lang=json,strict*/ """{ "elasticsearch": "tok-new" }"""),
+ var p when p.EndsWith("/registry.json", StringComparison.Ordinal) => Json(EsRegistryJson),
+ _ => Yaml(SampleBundle)
+ });
+ var (errors, warnings, emitError, emitWarning) = Diagnostics();
+
+ using var fetcher = new CdnChangelogFetcher(NullLoggerFactory.Instance, fs, handler);
+ var bundles = await fetcher.FetchAsync(BaseUri, "elasticsearch", version: null, emitError, emitWarning, TestContext.Current.CancellationToken);
+
+ errors.Should().BeEmpty();
+ warnings.Should().BeEmpty();
+ bundles.Should().ContainSingle();
+ handler.RequestedPaths.Should().Contain("/bundle/elasticsearch/registry.json");
+ fs.File.Exists(CacheFilePath("registry-elasticsearch-tok-new"))
+ .Should().BeTrue("the fresh registry should be recorded under the new token for the next run");
+ }
+
+ [Fact]
+ public async Task FetchAsync_ShallowTokenWithColdCache_FetchesAsUsualThenSkipsOnNextRun()
+ {
+ var fs = new MockFileSystem();
+ var mapJson = /*lang=json,strict*/ """{ "elasticsearch": "tok1" }""";
+ var coldHandler = new StubHandler(req => req.RequestUri!.AbsolutePath switch
+ {
+ ShallowMapPath => Json(mapJson),
+ var p when p.EndsWith("/registry.json", StringComparison.Ordinal) => Json(EsRegistryJson),
+ _ => Yaml(SampleBundle)
+ });
+ var (errors, warnings, emitError, emitWarning) = Diagnostics();
+
+ // Cold cache: the token alone cannot satisfy a skip, so the flow is identical to today.
+ using (var fetcher = new CdnChangelogFetcher(NullLoggerFactory.Instance, fs, coldHandler))
+ {
+ var bundles = await fetcher.FetchAsync(BaseUri, "elasticsearch", version: null, emitError, emitWarning, TestContext.Current.CancellationToken);
+ bundles.Should().ContainSingle();
+ coldHandler.RequestedPaths.Should().Contain("/bundle/elasticsearch/registry.json");
+ }
+
+ // Next run (new fetcher, same disk cache): the unchanged token skips every per-product request.
+ var warmHandler = new StubHandler(req => req.RequestUri!.AbsolutePath == ShallowMapPath
+ ? Json(mapJson)
+ : throw new InvalidOperationException($"Unexpected per-folder request: {req.RequestUri}"));
+ using (var fetcher = new CdnChangelogFetcher(NullLoggerFactory.Instance, fs, warmHandler))
+ {
+ var bundles = await fetcher.FetchAsync(BaseUri, "elasticsearch", version: null, emitError, emitWarning, TestContext.Current.CancellationToken);
+ bundles.Should().ContainSingle();
+ warmHandler.RequestedPaths.Should().Equal(ShallowMapPath);
+ }
+
+ errors.Should().BeEmpty();
+ warnings.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task FetchAsync_ShallowMapPartialMatch_SkipsOnlyUnchangedFolders()
+ {
+ // One run over two products: elasticsearch has a warm cache and a matching token, kibana does
+ // not — only kibana's registry and bundle may hit the CDN, and the map is probed exactly once.
+ var fs = WarmCache("tok-es");
+ var handler = new StubHandler(req => req.RequestUri!.AbsolutePath switch
+ {
+ ShallowMapPath => Json(/*lang=json,strict*/ """{ "elasticsearch": "tok-es", "kibana": "tok-kb" }"""),
+ "/bundle/kibana/registry.json" =>
+ Json(/*lang=json,strict*/ """{ "schema_version": 1, "product": "kibana", "bundles": [ { "file": "9.3.0.yaml", "target": "9.3.0", "etag": "kb1" } ] }"""),
+ "/bundle/kibana/9.3.0.yaml" => Yaml(SampleBundle),
+ var p => throw new InvalidOperationException($"Unexpected per-folder request: {p}")
+ });
+ var (errors, warnings, emitError, emitWarning) = Diagnostics();
+
+ using var fetcher = new CdnChangelogFetcher(NullLoggerFactory.Instance, fs, handler);
+ var esBundles = await fetcher.FetchAsync(BaseUri, "elasticsearch", version: null, emitError, emitWarning, TestContext.Current.CancellationToken);
+ var kibanaBundles = await fetcher.FetchAsync(BaseUri, "kibana", version: null, emitError, emitWarning, TestContext.Current.CancellationToken);
+
+ errors.Should().BeEmpty();
+ warnings.Should().BeEmpty();
+ esBundles.Should().ContainSingle();
+ kibanaBundles.Should().ContainSingle();
+ handler.RequestedPaths.Count(p => p == ShallowMapPath).Should().Be(1, "the map is fetched once per run");
+ handler.RequestedPaths.Should().NotContain(p => p.StartsWith("/bundle/elasticsearch/", StringComparison.Ordinal));
+ handler.RequestedPaths.Should().Contain("/bundle/kibana/registry.json");
+ handler.RequestedPaths.Should().Contain("/bundle/kibana/9.3.0.yaml");
+ fs.File.Exists(CacheFilePath("registry-kibana-tok-kb"))
+ .Should().BeTrue("kibana's registry should be recorded under its token for the next run");
+ }
+
+ private static HttpResponseMessage NotFound() => new(HttpStatusCode.NotFound);
+
private static HttpResponseMessage Json(string body) =>
new(HttpStatusCode.OK) { Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json") };