From c5edebac144b37c9d3bb784aebc5e8eafbeec583 Mon Sep 17 00:00:00 2001 From: lcawl Date: Wed, 16 Sep 2026 13:43:40 -0500 Subject: [PATCH 1/3] Support notes-product-version.json indices --- docs/cli/changelog/cmd-backfill.md | 2 +- docs/cli/changelog/cmd-note.md | 7 +- docs/development/changelog-bundle-registry.md | 39 +++- .../ReleaseNotes/CdnChangelogEntryFetcher.cs | 107 +++++++--- .../ReleaseNotes/ChangelogKeys.cs | 21 +- .../ReleaseNotes/NotesIndex.cs | 19 +- .../Bundling/ChangelogBundlingService.cs | 67 ++++-- .../Reconciliation/NotesIndexReconciler.cs | 196 ++++++++++++------ .../NotesIndexReconcilerTests.cs | 87 ++++++++ .../Scrubbing/ScrubberProcessorTests.cs | 3 +- .../CdnChangelogEntryFetcherTests.cs | 84 +++++++- .../ReleaseNotes/ChangelogKeysTests.cs | 22 ++ 12 files changed, 519 insertions(+), 135 deletions(-) diff --git a/docs/cli/changelog/cmd-backfill.md b/docs/cli/changelog/cmd-backfill.md index fa7f9395a8..d750e7a51b 100644 --- a/docs/cli/changelog/cmd-backfill.md +++ b/docs/cli/changelog/cmd-backfill.md @@ -8,7 +8,7 @@ For each product in scope the command: 2. Parses each `## {version}` section into typed entries. Unrecognized `### …` subsections whose body is bullets become `Other`-typed entries; prose subsections are preserved in the bundle description. 3. Writes bundle YAML to `{output}/{product}/changelog/bundles/{version}.yaml`. 4. Writes per-entry YAML files: `{pr}.yaml` for entries that reference a pull request, `note-{slug}.yaml` for entries with no PR reference. -5. Writes `notes-{target}.json` per version listing all `note-*.yaml` files for that target (the notes registry). +5. Writes `notes-{target}.json` per version listing all `note-*.yaml` files for that target (the notes registry). This is a **local** backfill artifact, not the CDN dual-write layout (`notes-{product}-{version}.json` plus legacy `notes-{version}.json`). 6. Prints a per-product report including entry counts and no-PR rates. The primary use case is measuring how much published release-notes content cannot be traced back to a PR — determining how much historical content needs the PR-less "note" format from [docs-eng-team#789](https://github.com/elastic/docs-eng-team/issues/789). diff --git a/docs/cli/changelog/cmd-note.md b/docs/cli/changelog/cmd-note.md index 9606745166..2463549d89 100644 --- a/docs/cli/changelog/cmd-note.md +++ b/docs/cli/changelog/cmd-note.md @@ -59,7 +59,12 @@ products: ## After creation Upload is the same as for other changelog YAML files. -An index at `changelog/{org}/{repo}/notes-{version}.json` lists every changelog "note" file that applies to each version. +The scrubber writes two indexes per note: + +- `changelog/{org}/{repo}/notes-{product}-{version}.json` — notes for that product and version. `changelog bundle` reads this key first. +- `changelog/{org}/{repo}/notes-{version}.json` — the union of notes for that version across products, kept for older CLI pins. + +`{changelog}` does not read these indexes. It loads published bundle YAML (and amend sidecars listed in `bundle/{product}/registry.json`). If the release bundle for that product and version or date has already shipped when you upload, the scrubber generates a `{parent}.amend-notes.yaml` sidecar so the changelog reaches published docs without a manual rerun. Don't run `changelog bundle-amend --add` for that file. Refer to [](/data/release-notes/bundle.md#changelog-bundle-notes-after-ship). diff --git a/docs/development/changelog-bundle-registry.md b/docs/development/changelog-bundle-registry.md index b8f89eb11f..3affe8e2a7 100644 --- a/docs/development/changelog-bundle-registry.md +++ b/docs/development/changelog-bundle-registry.md @@ -80,8 +80,9 @@ narrowed reconciliation to the bundle tree): events. Authors cannot issue those deletes through docs-builder today: `changelog upload` does not delete objects, and `changelog remove` is local-only. For the author-facing add and exclude path, see [](/data/release-notes/bundle.md#changelog-bundle-notes-after-ship). -- **Notes index** — `changelog/{org}/{repo}/notes-{version}.json`, one per version, **public - bucket only**, produced by the scrubber Lambda's `NotesIndexReconciler`. See +- **Notes index** — `changelog/{org}/{repo}/notes-{product}-{version}.json` (product-scoped) plus a dual-written + legacy `changelog/{org}/{repo}/notes-{version}.json` (version union), **public bucket only**, produced by + the scrubber Lambda's `NotesIndexReconciler`. See [Notes-index format](#notes-index-format) below. - **Changelog-entry index** — `changelog/{org}/{repo}/{branch}/registry.json`, a **legacy client-authored pass-through**: the current `changelog upload` never writes one, but manifests @@ -127,12 +128,24 @@ zero-bundle state. The reconciler deliberately restores the former. ## Notes-index format [notes-index-format] -For each release version that has at least one note, the scrubber Lambda writes a notes index at -`changelog/{org}/{repo}/notes-{version}.json`. Its schema (`schema_version: 1`): +For each product and release version that has at least one note, the scrubber Lambda writes a +product-scoped notes index at `changelog/{org}/{repo}/notes-{product}-{version}.json`. It also +dual-writes a legacy version-union index at `changelog/{org}/{repo}/notes-{version}.json` so +older `changelog bundle` clients that still GET the version-only key keep working. The product +and version segments are concatenated into the new filename; consumers must not parse that slug +apart. + +`changelog bundle` GETs the product-scoped key first and falls back to the version-only key +only on HTTP 404. An empty product-scoped index does not fall back. `{changelog}` does not read +these indexes; it loads bundle YAML from the product registry. + +Product-scoped schema (`schema_version: 1`): ```json { "schema_version": 1, + "product": "elasticsearch", + "version": "9.3.0", "notes": [ { "path": "main/note-esql-oom.yml", "bundle_seq": 2 }, { "path": "main/note-cve-2026-1234.yml", "bundle_seq": 1 } @@ -140,9 +153,13 @@ For each release version that has at least one note, the scrubber Lambda writes } ``` +The legacy `notes-{version}.json` body omits `product` and `version` and lists the union of note +paths for that version across products. + | Field | Meaning | |---|---| | `schema_version` | Schema version. Currently `1`. | +| `product` / `version` | Set on product-scoped indexes; omitted from the legacy version-union body. | | `notes[].path` | Pool-relative path of the note within `changelog/{org}/{repo}/`. The leading segment before the first `/` is the branch. | | `notes[].bundle_seq` | Derived reporting field: `0` = no bundle published for this version yet, `1` = note shipped in the original bundle, `2` = note carried by the Lambda-generated `.amend-notes` sidecar. | @@ -151,9 +168,11 @@ reconcile by comparing the notes index against the set of entries in the publish amend sidecars. Do not hand-edit `notes-{version}.json` or `.amend-notes` sidecars. Authors cannot delete pool objects through docs-builder today. -A 404 on a notes index means "no notes published for this version". An empty `notes` array never -appears — the index is deleted rather than emptied, following the same -[absent ≠ empty](#absent-empty) rule as the bundle registry. +A 404 on both the product-scoped index and the legacy version-union index means "no notes +published for this product and version". An empty `notes` array never appears on a successfully +reconciled index — the index is deleted rather than emptied, following the same +[absent ≠ empty](#absent-empty) rule as the bundle registry. Until older clients stop reading +`notes-{version}.json`, the Lambda keeps that key while any note still declares the version. ## Shallow per-tree change maps [shallow-maps] @@ -303,6 +322,12 @@ entry still missing after its retry budget fails the bundle (an incomplete relea ship silently). `CdnChangelogEntryFetcher` reuses a shared `HttpClient` in production and disposes an owned client only when a test injects a handler, mirroring `CdnChangelogFetcher`. +When CDN sourcing is on, the same fetcher also merges changelog notes for each concrete +`--output-products` pair: it GETs `changelog/{org}/{repo}/notes-{product}-{version}.json` first +and falls back to `notes-{version}.json` only on HTTP 404. See +[Notes-index format](#notes-index-format). Checksum dedup keeps a single copy if two products at +the same version still share the legacy index. + ## Consumer: `{changelog}` directive `cdn:` mode (implemented) ### Syntax diff --git a/src/Elastic.Documentation.Configuration/ReleaseNotes/CdnChangelogEntryFetcher.cs b/src/Elastic.Documentation.Configuration/ReleaseNotes/CdnChangelogEntryFetcher.cs index 10850ff074..cfb6ad9337 100644 --- a/src/Elastic.Documentation.Configuration/ReleaseNotes/CdnChangelogEntryFetcher.cs +++ b/src/Elastic.Documentation.Configuration/ReleaseNotes/CdnChangelogEntryFetcher.cs @@ -487,23 +487,18 @@ private static Uri CombineSegments(Uri baseUri, IReadOnlyList segments) } /// - /// - /// Fetches all note-*.yml entries for / at - /// from the CDN. Reads the notes-{version}.json index to enumerate - /// the pool-relative note paths; a missing index means no notes (not an error). A listed note that - /// cannot be fetched is a hard error — the index is an authoritative promise that the note exists. + /// Fetches all note-*.yml entries for / and + /// at from the CDN. Reads + /// notes-{product}-{version}.json first; on HTTP 404 only, falls back to the legacy + /// notes-{version}.json. A missing index (both 404) means no notes (not an error). + /// An empty product-scoped index does not fall back. A listed note that cannot be fetched + /// is a hard error — the index is an authoritative promise that the note exists. /// - /// CDN base URI. - /// Repository org (e.g. elastic). - /// Repository name (e.g. kibana). - /// Release version string (e.g. 9.0.0). - /// Called once per hard error; caller decides how to surface it. - /// Cancellation token. - /// The fetched note entries, keyed by pool-relative path (main/note-foo.yml). public async Task> FetchNotesAsync( Uri baseUri, string org, string repo, + string product, string version, Action emitError, Cancel ctx @@ -515,31 +510,87 @@ Cancel ctx return []; } - var indexUri = CombineSegments(baseUri, ["changelog", org, repo, $"notes-{version}.json"]); - NotesIndex? index; + if (!ChangelogKeys.IsValidProduct(product)) + { + emitError($"Invalid product '{product}' for notes fetch: must be a non-empty ASCII product segment."); + return []; + } + + var productIndexUri = CombineSegments(baseUri, ["changelog", org, repo, $"notes-{product}-{version}.json"]); + var (productMissing, productIndex) = await TryLoadNotesIndexAsync( + productIndexUri, + org, + repo, + version, + emitError, + ctx + ).ConfigureAwait(false); + if (productIndex is not null) + return await FetchListedNotesAsync(baseUri, org, repo, version, productIndex, emitError, ctx).ConfigureAwait(false); + if (!productMissing) + return []; + + var legacyIndexUri = CombineSegments(baseUri, ["changelog", org, repo, $"notes-{version}.json"]); + var (legacyMissing, legacyIndex) = await TryLoadNotesIndexAsync(legacyIndexUri, org, repo, version, emitError, ctx).ConfigureAwait( + false + ); + if (legacyIndex is not null) + return await FetchListedNotesAsync(baseUri, org, repo, version, legacyIndex, emitError, ctx).ConfigureAwait(false); + if (legacyMissing) + { + _logger.LogDebug( + "Notes index for {Org}/{Repo}/{Product}@{Version} not found at {ProductUri} or {LegacyUri}; no notes to bundle", + org, + repo, + product, + version, + productIndexUri, + legacyIndexUri + ); + } + return []; + } + + /// + /// Loads a notes index. Returns missing: true only on HTTP 404. A present but empty + /// or unparseable body is missing: false with a null index so callers do not fall back. + /// + private async Task<(bool Missing, NotesIndex? Index)> TryLoadNotesIndexAsync( + Uri indexUri, + string org, + string repo, + string version, + Action emitError, + Cancel ctx + ) + { try { var (notFound, content) = await FetchTextOrNotFoundAsync(indexUri, 1, ctx).ConfigureAwait(false); if (notFound) - { - _logger.LogDebug( - "Notes index for {Org}/{Repo}@{Version} not found at {Uri}; no notes to bundle", - org, - repo, - version, - indexUri - ); - return []; - } - index = JsonSerializer.Deserialize(content, NotesIndexJsonContext.Default.NotesIndex); + return (true, null); + + var index = JsonSerializer.Deserialize(content, NotesIndexJsonContext.Default.NotesIndex); + return (false, index); } catch (Exception ex) when (ex is not OperationCanceledException) { emitError($"Could not fetch notes index for {org}/{repo}@{version} from {indexUri}: {ex.Message}"); - return []; + return (false, null); } + } - if (index is null || index.Notes.Count == 0) + private async Task> FetchListedNotesAsync( + Uri baseUri, + string org, + string repo, + string version, + NotesIndex index, + Action emitError, + Cancel ctx + ) + { + if (index.Notes is not { Count: > 0 }) return []; var repoLabel = $"{org}/{repo}"; @@ -548,7 +599,6 @@ Cancel ctx { ctx.ThrowIfCancellationRequested(); - // Pool-relative path is "{branch}/note-{name}.yml"; split on first '/' only. var poolRelativePath = noteEntry.Path; var slash = poolRelativePath.IndexOf('/', StringComparison.Ordinal); if (slash <= 0 || slash == poolRelativePath.Length - 1) @@ -578,7 +628,6 @@ Cancel ctx continue; } - // The notes index asserts this note exists — a miss is a real pipeline error. emitError( $"Note '{poolRelativePath}' for {repoLabel}@{version} is listed in the notes index but could not be fetched from {noteUri}: {lastError}. " + "Ensure the note was uploaded and scrubbed; if it persists check the changelog scrubber pipeline." diff --git a/src/Elastic.Documentation.Configuration/ReleaseNotes/ChangelogKeys.cs b/src/Elastic.Documentation.Configuration/ReleaseNotes/ChangelogKeys.cs index 2900a5dc49..dbffdc39d5 100644 --- a/src/Elastic.Documentation.Configuration/ReleaseNotes/ChangelogKeys.cs +++ b/src/Elastic.Documentation.Configuration/ReleaseNotes/ChangelogKeys.cs @@ -93,17 +93,27 @@ public static string ChangelogFileKey(string org, string repo, string branch, st public static string ChangelogRegistryKey(string poolGroup) => $"{ChangelogPrefix}{poolGroup}/{RegistryFileName}"; /// - /// The notes-index key for one release version within a repo: changelog/{org}/{repo}/notes-{version}.json. - /// Repo-level and branch-agnostic — all notes for a version, regardless of which branch they were authored on. + /// The legacy notes-index key for one release version within a repo: + /// changelog/{org}/{repo}/notes-{version}.json. + /// Repo-level and branch-agnostic — the union of notes for that version across every product. /// /// /// The slug in the key is the release version (e.g. 9.3.0 or 2026-05-15). /// Previously this parameter was named target to match the obsolete target: YAML field; /// it was renamed to version when that field was replaced by versions: on notes. - /// The key layout (notes-{slug}.json) is unchanged — no migration is needed. + /// Older changelog bundle clients still GET this key; the scrubber dual-writes it + /// alongside . /// public static string NotesIndexKey(string org, string repo, string version) => $"{ChangelogPrefix}{org}/{repo}/notes-{version}.json"; + /// + /// The product-scoped notes-index key: + /// changelog/{org}/{repo}/notes-{product}-{version}.json. + /// Constructed from the two segments; callers must not parse the slug back apart. + /// + public static string NotesIndexKey(string org, string repo, string product, string version) => + $"{ChangelogPrefix}{org}/{repo}/notes-{product}-{version}.json"; + /// /// The S3 prefix that covers all branches and notes indexes of one repo: changelog/{org}/{repo}/. /// Used by the notes reconciler to list the full repo tree. @@ -112,8 +122,9 @@ public static string ChangelogFileKey(string org, string repo, string branch, st /// /// Returns true when is a notes-index key of the form - /// changelog/{org}/{repo}/notes-{target}.json (exactly two group segments, then - /// a notes--prefixed JSON file with a non-empty target slug). + /// changelog/{org}/{repo}/notes-{slug}.json (exactly two group segments, then + /// a notes--prefixed JSON file with a non-empty slug). Covers both the legacy + /// notes-{version}.json shape and the product-scoped notes-{product}-{version}.json shape. /// public static bool IsNotesIndex(string key) { diff --git a/src/Elastic.Documentation.Configuration/ReleaseNotes/NotesIndex.cs b/src/Elastic.Documentation.Configuration/ReleaseNotes/NotesIndex.cs index 734938d595..f4f68da423 100644 --- a/src/Elastic.Documentation.Configuration/ReleaseNotes/NotesIndex.cs +++ b/src/Elastic.Documentation.Configuration/ReleaseNotes/NotesIndex.cs @@ -41,14 +41,17 @@ public sealed record NoteIndexEntry } /// -/// Notes index published at changelog/{org}/{repo}/notes-{version}.json. -/// Lists all note-*.yml fragments for one release version, +/// Notes index published at changelog/{org}/{repo}/notes-{product}-{version}.json +/// (and dual-written to the legacy notes-{version}.json key). +/// Lists all note-*.yml fragments for one product and release version, /// across every branch of the repo. /// /// /// Contents are paths, not bodies — the note files remain the single source of truth. /// A stale index can only omit or over-list, never serve stale prose. Bundling a version /// is therefore 1 GET for the index + one GET per listed note. +/// and are set on product-scoped writes and +/// omitted from legacy version-union bodies so older deserializers keep working. /// public sealed record NotesIndex { @@ -58,6 +61,18 @@ public sealed record NotesIndex /// Current schema version constant. public const int CurrentSchemaVersion = 1; + /// + /// Bundle product id this index belongs to. Present on product-scoped keys; omitted on + /// the legacy version-union body. + /// + public string? Product { get; init; } + + /// + /// Release version this index belongs to. Present on product-scoped keys; omitted on + /// the legacy version-union body. + /// + public string? Version { get; init; } + /// /// Notes for this version. Each entry carries the pool-relative path, origin branch, /// and a derived bundle_seq. diff --git a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs index 071393c177..9e48cbe1c6 100644 --- a/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs +++ b/src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs @@ -1530,7 +1530,7 @@ Cancel ctx } /// - /// Fetches notes for from the CDN and converts them to matched entries. + /// Fetches notes for one output product/version from the CDN and converts them to matched entries. /// An absent notes index is not an error (most targets have no notes). Returns null after /// emitting an error when the index exists but a listed note cannot be fetched. /// @@ -1538,7 +1538,8 @@ Cancel ctx IDiagnosticsCollector collector, string? org, string? repo, - string target, + string product, + string version, Cancel ctx ) { @@ -1556,7 +1557,8 @@ Cancel ctx baseUri, resolvedOrg, repo, - target, + product, + version, msg => { hadError = true; @@ -1590,13 +1592,29 @@ Cancel ctx } catch (Exception ex) when (ex is not OperationCanceledException) { - _logger.LogWarning(ex, "Failed to parse note '{FileName}' for {Repo}@{Target}; skipping", entry.FileName, repo, target); - collector.EmitError(string.Empty, $"Note '{entry.FileName}' for {repo}@{target} could not be parsed: {ex.Message}"); + _logger.LogWarning( + ex, + "Failed to parse note '{FileName}' for {Repo}/{Product}@{Version}; skipping", + entry.FileName, + repo, + product, + version + ); + collector.EmitError( + string.Empty, + $"Note '{entry.FileName}' for {repo}/{product}@{version} could not be parsed: {ex.Message}" + ); return null; } } - _logger.LogInformation("Resolved {Count} note(s) for {Repo}@{Target} from CDN", matchedNotes.Count, repo, target); + _logger.LogInformation( + "Resolved {Count} note(s) for {Repo}/{Product}@{Version} from CDN", + matchedNotes.Count, + repo, + product, + version + ); return matchedNotes; } @@ -1623,17 +1641,19 @@ Cancel ctx return entries; // Dedup by checksum: a note body identical to a PR entry (edge case) should appear once. + // Two output products at the same version may both fall back to notes-{version}.json + // until product-scoped indexes exist; checksum skip keeps a single copy. var seen = new HashSet(entries.Select(e => e.Checksum), StringComparer.OrdinalIgnoreCase); var combined = new List(entries); - foreach (var noteTarget in noteTargets) + foreach (var (product, version) in noteTargets) { - var noteEntries = await FetchCdnNotesAsync(collector, org, repo, noteTarget, ctx); + var noteEntries = await FetchCdnNotesAsync(collector, org, repo, product, version, ctx); if (noteEntries == null) return null; // Backport collision: same leaf from different branches at the same version → prefer main/master. - var deduped = DeduplicateNotesByLeaf(noteEntries, noteTarget); + var deduped = DeduplicateNotesByLeaf(noteEntries, version); foreach (var note in deduped) { @@ -1970,20 +1990,29 @@ private static bool ValidatePlaceholderUsage(IDiagnosticsCollector collector, Bu } /// - /// Returns all distinct, explicit, non-wildcard targets from . - /// Notes are fetched for every resolved target so multi-target bundles are fully covered. - /// Returns an empty list when no concrete targets are available. + /// Returns distinct (product, version) pairs from . + /// Notes are fetched per product so two products at the same version do not share an index once + /// product-scoped keys exist. Returns an empty list when no concrete product/version is available. /// - private static IReadOnlyList ResolveNoteTargets(BundleChangelogsArguments input) + private static IReadOnlyList<(string Product, string Version)> ResolveNoteTargets(BundleChangelogsArguments input) { if (input.OutputProducts is not { Count: > 0 }) return []; - return input - .OutputProducts - .Where(p => !string.IsNullOrWhiteSpace(p.Target) && p.Target != "*") - .Select(p => p.Target!) - .Distinct(StringComparer.Ordinal) - .ToList(); + + var seen = new HashSet(StringComparer.Ordinal); + var targets = new List<(string Product, string Version)>(); + foreach (var p in input.OutputProducts) + { + if (string.IsNullOrWhiteSpace(p.Product) || p.Product == "*") + continue; + if (string.IsNullOrWhiteSpace(p.Target) || p.Target == "*") + continue; + var key = $"{p.Product}/{p.Target}"; + if (!seen.Add(key)) + continue; + targets.Add((p.Product, p.Target)); + } + return targets; } private static ChangelogFilterCriteria BuildFilterCriteria( diff --git a/src/services/Elastic.Changelog/Reconciliation/NotesIndexReconciler.cs b/src/services/Elastic.Changelog/Reconciliation/NotesIndexReconciler.cs index 7c2652340d..1e8f2d1f52 100644 --- a/src/services/Elastic.Changelog/Reconciliation/NotesIndexReconciler.cs +++ b/src/services/Elastic.Changelog/Reconciliation/NotesIndexReconciler.cs @@ -12,10 +12,11 @@ namespace Elastic.Changelog.Reconciliation; /// -/// Rebuilds the per-version notes-{version}.json indexes for one repository by listing -/// all note-*.yml objects under changelog/{org}/{repo}/, reading each to extract -/// its versions: values (falling back to the legacy target: field for backward -/// compatibility), and writing the affected indexes atomically with conditional S3 writes. +/// Rebuilds notes indexes for one repository by listing all note-*.yml objects under +/// changelog/{org}/{repo}/, reading each to extract (product, version) pairs +/// from products[] (versions, then legacy target), and writing both +/// product-scoped notes-{product}-{version}.json keys and the legacy +/// notes-{version}.json union keys with conditional S3 writes. /// /// /// A note may declare multiple versions, so one note can appear in several indexes. The index @@ -42,14 +43,13 @@ public sealed class NotesIndexReconciler( private readonly string _sourceBucketName = sourceBucketName ?? publicBucketName; /// - /// Rebuilds all notes-{version}.json indexes for the given repository scope. + /// Rebuilds product-scoped and legacy version-union notes indexes for the given repository scope. /// All currently published note-*.yml files across every branch are listed and - /// read to derive the version grouping; every affected index is then (re)written. + /// read to derive the grouping; every affected index is then (re)written. /// /// - /// A map of version → list of NoteIndexEntry for all versions found. - /// Returns an empty dictionary when no notes exist. Consumed by - /// in the same SQS-batch pass to compute bundle_seq and publish amend sidecars. + /// A map of version → list of NoteIndexEntry (the version-union, same shape NoteAmend + /// consumes today). Returns an empty dictionary when no notes exist. /// public async Task>> ReconcileRepoAsync(ChangelogScope notesScope, Cancel ctx) { @@ -58,75 +58,123 @@ public async Task>> Re _logger.LogInformation("Reconciling notes indexes for repo {Repo}", notesScope.Group); - // List every note-*.yml under changelog/{org}/{repo}/ (all branches). var noteObjects = await ListNoteFiles(notesScope, ctx); _logger.LogDebug("Found {Count} note file(s) for {Repo}", noteObjects.Count, notesScope.Group); - // Read each note to extract its versions. - // byVersion: version slug → list of NoteIndexEntry (bundle_seq defaulted to 0; filled by NoteAmendReconciler) + var byProductVersion = new Dictionary>(StringComparer.Ordinal); var byVersion = new Dictionary>(StringComparer.Ordinal); foreach (var obj in noteObjects) { ctx.ThrowIfCancellationRequested(); var poolRelativePath = obj.Key[notesScope.Prefix.Length..]; - var versions = await ExtractVersionsAsync(obj.Key, ctx); - foreach (var version in versions) + var pairs = await ExtractProductVersionsAsync(obj.Key, ctx); + foreach (var (product, version) in pairs) { - if (!byVersion.TryGetValue(version, out var entries)) - byVersion[version] = entries = []; - - // Deduplicate by path within the same version. - if (!entries.Any(e => e.Path == poolRelativePath)) - entries.Add(new NoteIndexEntry { Path = poolRelativePath, BundleSeq = 0 }); + AddIndexEntry(byProductVersion, ProductVersionGroupKey(product, version), poolRelativePath); + AddIndexEntry(byVersion, version, poolRelativePath); } } var groupParts = notesScope.Group.Split('/'); var (org, repo) = (groupParts[0], groupParts[1]); - // List existing notes-*.json indexes so we can remove obsolete ones. var existingIndexKeys = await ListExistingNotesIndexes(notesScope, ctx); + var intendedKeys = IntendedIndexKeys(org, repo, byProductVersion.Keys, byVersion.Keys); if (byVersion.Count == 0) { _logger.LogDebug("No versions found for repo {Repo}; removing any stale indexes", notesScope.Group); - await DeleteStaleIndexes(existingIndexKeys, [], org, repo, ctx); + await DeleteStaleIndexes(existingIndexKeys, intendedKeys, ctx); return new Dictionary>(); } - // Write one index per version. bundle_seq values default to 0 here; NoteAmendReconciler updates them. - // DeleteStaleIndexes runs even if some writes fail — stale deletion is safe because we only - // remove versions absent from byVersion.Keys, which is independent of write success. + var writes = BuildIndexWrites(org, repo, byProductVersion, byVersion); var written = new Dictionary>(StringComparer.Ordinal); try { - await Parallel.ForEachAsync(byVersion, new ParallelOptions + await Parallel.ForEachAsync(writes, new ParallelOptions { MaxDegreeOfParallelism = MaxParallelReads, CancellationToken = ctx - }, async (kvp, ct) => + }, async (write, ct) => { - var (version, entries) = kvp; - var indexKey = ChangelogKeys.NotesIndexKey(org, repo, version); - var sortedEntries = entries - .DistinctBy(e => e.Path, StringComparer.Ordinal) - .OrderBy(e => e.Path, StringComparer.Ordinal) - .ToList(); - await WriteIndexAsync(indexKey, sortedEntries, ct); + await WriteIndexAsync( + write.Key, + write.Entries, + ct, + write.Product is null ? null : new NotesIndexMetadata(write.Product, write.Version!) + ); + if (write.Product is not null) + return; lock (written) - written[version] = sortedEntries; + written[write.Version!] = write.Entries; }); } finally { - // Remove indexes whose versions are no longer present. - await DeleteStaleIndexes(existingIndexKeys, byVersion.Keys.ToHashSet(StringComparer.Ordinal), org, repo, ctx); + await DeleteStaleIndexes(existingIndexKeys, intendedKeys, ctx); } return written; } + private static void AddIndexEntry(Dictionary> map, string groupKey, string poolRelativePath) + { + if (!map.TryGetValue(groupKey, out var entries)) + map[groupKey] = entries = []; + + if (!entries.Any(e => e.Path == poolRelativePath)) + entries.Add(new NoteIndexEntry { Path = poolRelativePath, BundleSeq = 0 }); + } + + private static string ProductVersionGroupKey(string product, string version) => $"{product}/{version}"; + + private static HashSet IntendedIndexKeys( + string org, + string repo, + IEnumerable productVersionKeys, + IEnumerable versions + ) + { + var intended = new HashSet(StringComparer.Ordinal); + foreach (var groupKey in productVersionKeys) + { + var slash = groupKey.IndexOf('/', StringComparison.Ordinal); + _ = intended.Add(ChangelogKeys.NotesIndexKey(org, repo, groupKey[..slash], groupKey[(slash + 1)..])); + } + foreach (var version in versions) + _ = intended.Add(ChangelogKeys.NotesIndexKey(org, repo, version)); + return intended; + } + + private static List BuildIndexWrites( + string org, + string repo, + Dictionary> byProductVersion, + Dictionary> byVersion + ) + { + var writes = new List(byProductVersion.Count + byVersion.Count); + foreach (var (groupKey, entries) in byProductVersion) + { + var slash = groupKey.IndexOf('/', StringComparison.Ordinal); + var product = groupKey[..slash]; + var version = groupKey[(slash + 1)..]; + writes.Add( + new NotesIndexWrite(ChangelogKeys.NotesIndexKey(org, repo, product, version), SortEntries(entries), product, version) + ); + } + foreach (var (version, entries) in byVersion) + { + writes.Add(new NotesIndexWrite(ChangelogKeys.NotesIndexKey(org, repo, version), SortEntries(entries), Product: null, version)); + } + return writes; + } + + private static List SortEntries(IReadOnlyList entries) => + entries.DistinctBy(e => e.Path, StringComparer.Ordinal).OrderBy(e => e.Path, StringComparer.Ordinal).ToList(); + private async Task> ListExistingNotesIndexes(ChangelogScope notesScope, Cancel ctx) { var request = new ListObjectsV2Request { BucketName = publicBucketName, Prefix = notesScope.Prefix }; @@ -148,24 +196,11 @@ private async Task> ListExistingNotesIndexes(ChangelogScop return keys; } - private async Task DeleteStaleIndexes( - IReadOnlyList existingKeys, - HashSet currentVersions, - string org, - string repo, - Cancel ctx - ) + private async Task DeleteStaleIndexes(IReadOnlyList existingKeys, HashSet intendedKeys, Cancel ctx) { - // "changelog/{org}/{repo}/notes-" — the stable prefix shared by all notes-*.json keys for this repo. - var notesKeyPrefix = $"{ChangelogKeys.ChangelogPrefix}{org}/{repo}/notes-"; - foreach (var key in existingKeys) { - // Extract the version slug from the key to check if it's still needed. - if (!key.StartsWith(notesKeyPrefix, StringComparison.Ordinal)) - continue; - var versionSlug = key[notesKeyPrefix.Length..^".json".Length]; - if (currentVersions.Contains(versionSlug)) + if (intendedKeys.Contains(key)) continue; try @@ -221,11 +256,11 @@ private static bool IsNoteFileName(string fileName) => && !fileName.Contains('/', StringComparison.Ordinal); /// - /// Reads a note file and returns all version slugs it should be indexed under. - /// Prefers products[].versions; falls back to the legacy products[].target - /// for already-published notes that pre-date the versions: field. + /// Reads a note file and returns every valid (product, version) pair it should be + /// indexed under. Prefers products[].versions; falls back to the legacy + /// products[].target for already-published notes that pre-date the versions: field. /// - private async Task> ExtractVersionsAsync(string key, Cancel ctx) + private async Task> ExtractProductVersionsAsync(string key, Cancel ctx) { try { @@ -241,9 +276,27 @@ private async Task> ExtractVersionsAsync(string key, Cance if (dto.Products is not { Count: > 0 }) return []; - var valid = new List(); + var valid = new List<(string Product, string Version)>(); foreach (var productInfo in dto.Products) { + var productId = productInfo.Product?.Trim(); + if ( + string.IsNullOrEmpty(productId) + || productId.Contains('/', StringComparison.Ordinal) + || !ChangelogKeys.IsValidProduct(productId) + ) + { + if (!string.IsNullOrEmpty(productId)) + { + _logger.LogWarning( + "Note {Key} has product '{Product}' that is not a valid product segment; skipping", + key, + productId + ); + } + continue; + } + // Prefer the new `versions` list; fall back to the legacy `target` field for compat. #pragma warning disable CS0618 // reading obsolete Target for backward compat IEnumerable rawVersions = productInfo.Versions is { Count: > 0 } @@ -254,17 +307,13 @@ private async Task> ExtractVersionsAsync(string key, Cance foreach (var raw in rawVersions.Where(v => !string.IsNullOrWhiteSpace(v))) { var v = raw!.Trim(); - if (v.Contains('/', StringComparison.Ordinal)) + if (v.Contains('/', StringComparison.Ordinal) || !ChangelogKeys.IsValidRepo(v)) { - _logger.LogWarning( - "Note {Key} has version '{Version}' containing '/'; skipping — versions must be single path segments", - key, - v - ); + _logger.LogWarning("Note {Key} has version '{Version}' that is not a valid single path segment; skipping", key, v); continue; } - if (!valid.Contains(v, StringComparer.Ordinal)) - valid.Add(v); + if (!valid.Any(p => p.Product == productId && p.Version == v)) + valid.Add((productId, v)); } } return valid; @@ -288,10 +337,18 @@ private async Task> ExtractVersionsAsync(string key, Cance /// /// have their bundle_seq already set by the caller /// (0 from this reconciler; updated values from ). + /// Pass only for product-scoped keys; omit it for the legacy + /// version-union body so older deserializers keep seeing notes only. /// - public async Task WriteIndexAsync(string key, IReadOnlyList entries, Cancel ctx) + public async Task WriteIndexAsync(string key, IReadOnlyList entries, Cancel ctx, NotesIndexMetadata? metadata = null) { - var index = new NotesIndex { SchemaVersion = NotesIndex.CurrentSchemaVersion, Notes = entries }; + var index = new NotesIndex + { + SchemaVersion = NotesIndex.CurrentSchemaVersion, + Product = metadata?.Product, + Version = metadata?.Version, + Notes = entries + }; var newJson = JsonSerializer.Serialize(index, NotesIndexJsonContext.Default.NotesIndex); for (var attempt = 1; attempt <= MaxWriteAttempts; attempt++) @@ -395,4 +452,9 @@ public async Task WriteIndexAsync(string key, IReadOnlyList entr } } } + + private readonly record struct NotesIndexWrite(string Key, IReadOnlyList Entries, string? Product, string? Version); } + +/// Optional product and version written onto a product-scoped notes index body. +public sealed record NotesIndexMetadata(string Product, string Version); diff --git a/tests/Elastic.Changelog.Tests/Reconciliation/NotesIndexReconcilerTests.cs b/tests/Elastic.Changelog.Tests/Reconciliation/NotesIndexReconcilerTests.cs index 7d1f14a3a0..e2147b80fa 100644 --- a/tests/Elastic.Changelog.Tests/Reconciliation/NotesIndexReconcilerTests.cs +++ b/tests/Elastic.Changelog.Tests/Reconciliation/NotesIndexReconcilerTests.cs @@ -34,6 +34,12 @@ public class NotesIndexReconcilerTests + " - product: elasticsearch\n" + " versions: [9.0.0, 9.1.0]\n"; + private const string NoteYamlKibanaSameVersion = "title: Kibana known issue\n" + + "type: known-issue\n" + + "products:\n" + + " - product: kibana\n" + + " versions: [9.0.0]\n"; + private readonly FakeS3 _s3 = new(PublicBucket); private readonly NotesIndexReconciler _reconciler; @@ -55,6 +61,12 @@ private NotesIndex ReadIndex(string version) => NotesIndexJsonContext.Default.NotesIndex )!; + private NotesIndex ReadProductIndex(string product, string version) => + JsonSerializer.Deserialize( + _s3.ContentOf(PublicBucket, ChangelogKeys.NotesIndexKey("elastic", "elasticsearch", product, version)), + NotesIndexJsonContext.Default.NotesIndex + )!; + // Helper that projects entries to their paths for compact assertions. private static IEnumerable Paths(NotesIndex index) => index.Notes.Select(e => e.Path); @@ -100,6 +112,13 @@ public async Task ReconcileRepo_SingleNote_WritesIndex() .BeGreaterThan(0, $"reconciler should have written the index; ListCalls={_s3.ListCalls} Gets={_s3.Gets.Count}"); var index = ReadIndex("9.0.0"); Paths(index).Should().BeEquivalentTo(["main/note-slow-rollover.yml"]); + index.Product.Should().BeNull(); + index.Version.Should().BeNull(); + + var productIndex = ReadProductIndex("elasticsearch", "9.0.0"); + Paths(productIndex).Should().BeEquivalentTo(["main/note-slow-rollover.yml"]); + productIndex.Product.Should().Be("elasticsearch"); + productIndex.Version.Should().Be("9.0.0"); } [Fact] @@ -250,4 +269,72 @@ public async Task ReconcileRepo_NoNotes_DeletesAllExistingIndexes() // The stale index should be deleted. _s3.Deletes.Should().ContainSingle().Which.Key.Should().Be(ChangelogKeys.NotesIndexKey("elastic", "elasticsearch", "9.0.0")); } + + [Fact] + public async Task ReconcileRepo_TwoProductsSameVersion_ProductIndexesAreIsolated() + { + SeedNote("main", "note-slow-rollover.yml", NoteYaml); + SeedNote("main", "note-kibana.yml", NoteYamlKibanaSameVersion); + + await _reconciler.ReconcileRepoAsync(NotesScope(), TestContext.Current.CancellationToken); + + Paths(ReadProductIndex("elasticsearch", "9.0.0")).Should().BeEquivalentTo(["main/note-slow-rollover.yml"]); + Paths(ReadProductIndex("kibana", "9.0.0")).Should().BeEquivalentTo(["main/note-kibana.yml"]); + Paths(ReadIndex("9.0.0")).Should().BeEquivalentTo(["main/note-kibana.yml", "main/note-slow-rollover.yml"]); + } + + [Fact] + public async Task ReconcileRepo_ProductScopedStale_DeletedWithoutDroppingLegacyVersion() + { + _s3.Seed( + PublicBucket, + ChangelogKeys.NotesIndexKey("elastic", "elasticsearch", "kibana", "9.0.0"), + /*lang=json,strict*/ + """{"schema_version":1,"product":"kibana","version":"9.0.0","notes":[]}""" + ); + SeedNote("main", "note-slow-rollover.yml", NoteYaml); + + await _reconciler.ReconcileRepoAsync(NotesScope(), TestContext.Current.CancellationToken); + + _s3.Exists(PublicBucket, ChangelogKeys.NotesIndexKey("elastic", "elasticsearch", "9.0.0")).Should().BeTrue(); + _s3.Exists(PublicBucket, ChangelogKeys.NotesIndexKey("elastic", "elasticsearch", "elasticsearch", "9.0.0")).Should().BeTrue(); + _s3 + .Deletes + .Should() + .ContainSingle() + .Which + .Key + .Should() + .Be(ChangelogKeys.NotesIndexKey("elastic", "elasticsearch", "kibana", "9.0.0")); + } + + [Fact] + public async Task ReconcileRepo_NoNotes_DeletesProductScopedAndLegacyIndexes() + { + _s3.Seed( + PublicBucket, + ChangelogKeys.NotesIndexKey("elastic", "elasticsearch", "9.0.0"), + /*lang=json,strict*/ + """{"schema_version":1,"notes":[]}""" + ); + _s3.Seed( + PublicBucket, + ChangelogKeys.NotesIndexKey("elastic", "elasticsearch", "elasticsearch", "9.0.0"), + /*lang=json,strict*/ + """{"schema_version":1,"product":"elasticsearch","version":"9.0.0","notes":[]}""" + ); + _s3.Seed(PublicBucket, "changelog/elastic/elasticsearch/main/12345.yaml", "title: PR entry"); + + await _reconciler.ReconcileRepoAsync(NotesScope(), TestContext.Current.CancellationToken); + + _s3.Puts.Should().BeEmpty(); + _s3 + .Deletes + .Select(d => d.Key) + .Should() + .BeEquivalentTo([ + ChangelogKeys.NotesIndexKey("elastic", "elasticsearch", "9.0.0"), + ChangelogKeys.NotesIndexKey("elastic", "elasticsearch", "elasticsearch", "9.0.0") + ]); + } } diff --git a/tests/Elastic.Changelog.Tests/Scrubbing/ScrubberProcessorTests.cs b/tests/Elastic.Changelog.Tests/Scrubbing/ScrubberProcessorTests.cs index d9ed139fa7..7dcc42fa01 100644 --- a/tests/Elastic.Changelog.Tests/Scrubbing/ScrubberProcessorTests.cs +++ b/tests/Elastic.Changelog.Tests/Scrubbing/ScrubberProcessorTests.cs @@ -512,8 +512,9 @@ public async Task Process_NoteFile_ScrubbedAndNotesReconcileTriggered() failed.Should().BeEmpty(); // The note was scrubbed and copied to the public bucket _s3.ContentOf(PublicBucket, "changelog/elastic/elasticsearch/main/note-rollover.yml").Should().StartWith("scrubbed:"); - // The notes index was written (reconciler read the note and produced notes-9.0.0.json) + // The notes index was written (reconciler dual-writes notes-9.0.0.json and notes-elasticsearch-9.0.0.json) _s3.Exists(PublicBucket, "changelog/elastic/elasticsearch/notes-9.0.0.json").Should().BeTrue(); + _s3.Exists(PublicBucket, "changelog/elastic/elasticsearch/notes-elasticsearch-9.0.0.json").Should().BeTrue(); } [Fact] diff --git a/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/CdnChangelogEntryFetcherTests.cs b/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/CdnChangelogEntryFetcherTests.cs index a4efa83005..d1ed7999be 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/CdnChangelogEntryFetcherTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/CdnChangelogEntryFetcherTests.cs @@ -286,6 +286,7 @@ public async Task FetchNotesAsync_IndexAbsent_ReturnsEmptyWithNoError() BaseUri, "elastic", "elasticsearch", + "elasticsearch", "9.0.0", emitError, TestContext.Current.CancellationToken @@ -293,7 +294,10 @@ public async Task FetchNotesAsync_IndexAbsent_ReturnsEmptyWithNoError() entries.Should().BeEmpty(); errors.Should().BeEmpty("a missing index is expected for targets with no notes"); - handler.RequestedPaths.Should().ContainSingle().Which.Should().EndWith("/changelog/elastic/elasticsearch/notes-9.0.0.json"); + handler + .RequestedPaths + .Should() + .Equal("/changelog/elastic/elasticsearch/notes-elasticsearch-9.0.0.json", "/changelog/elastic/elasticsearch/notes-9.0.0.json"); } [Fact] @@ -302,7 +306,7 @@ public async Task FetchNotesAsync_HappyPath_FetchesAllListedNotes() var handler = new StubHandler(req => { var path = req.RequestUri!.AbsolutePath; - if (path.EndsWith("/notes-9.0.0.json", StringComparison.Ordinal)) + if (path.EndsWith("/notes-elasticsearch-9.0.0.json", StringComparison.Ordinal)) return Json(/*lang=json,strict*/ """{"schema_version":1,"notes":[{"path":"main/note-slow-rollover.yml","bundle_seq":0},{"path":"9.0/note-gap.yml","bundle_seq":0}]}""" ); @@ -315,6 +319,7 @@ public async Task FetchNotesAsync_HappyPath_FetchesAllListedNotes() BaseUri, "elastic", "elasticsearch", + "elasticsearch", "9.0.0", emitError, TestContext.Current.CancellationToken @@ -322,6 +327,8 @@ public async Task FetchNotesAsync_HappyPath_FetchesAllListedNotes() errors.Should().BeEmpty(); entries.Select(e => e.FileName).Should().BeEquivalentTo("main/note-slow-rollover.yml", "9.0/note-gap.yml"); + handler.RequestedPaths.Should().Contain(p => p.EndsWith("/notes-elasticsearch-9.0.0.json", StringComparison.Ordinal)); + handler.RequestedPaths.Should().NotContain(p => p.EndsWith("/notes-9.0.0.json", StringComparison.Ordinal)); // Verify the actual note URLs contain branch segments handler.RequestedPaths.Should().Contain(p => p.EndsWith("/main/note-slow-rollover.yml", StringComparison.Ordinal)); handler.RequestedPaths.Should().Contain(p => p.EndsWith("/9.0/note-gap.yml", StringComparison.Ordinal)); @@ -334,7 +341,7 @@ public async Task FetchNotesAsync_ListedNoteNotFound_EmitsErrorAndReturnsEmpty() var handler = new StubHandler(req => { var path = req.RequestUri!.AbsolutePath; - if (path.EndsWith("/notes-9.0.0.json", StringComparison.Ordinal)) + if (path.EndsWith("/notes-elasticsearch-9.0.0.json", StringComparison.Ordinal)) return Json(/*lang=json,strict*/ """{"schema_version":1,"notes":[{"path":"main/note-missing.yml","bundle_seq":0}]}"""); return new HttpResponseMessage(HttpStatusCode.NotFound); }); @@ -345,6 +352,7 @@ public async Task FetchNotesAsync_ListedNoteNotFound_EmitsErrorAndReturnsEmpty() BaseUri, "elastic", "elasticsearch", + "elasticsearch", "9.0.0", emitError, TestContext.Current.CancellationToken @@ -365,6 +373,75 @@ public async Task FetchNotesAsync_EmptyIndex_ReturnsEmpty() BaseUri, "elastic", "elasticsearch", + "elasticsearch", + "9.0.0", + emitError, + TestContext.Current.CancellationToken + ); + + entries.Should().BeEmpty(); + errors.Should().BeEmpty(); + handler.RequestedPaths.Should().ContainSingle().Which.Should().EndWith("/notes-elasticsearch-9.0.0.json"); + } + + [Fact] + public async Task FetchNotesAsync_ProductIndex404_FallsBackToLegacy() + { + var handler = new StubHandler(req => + { + var path = req.RequestUri!.AbsolutePath; + if ( + path.EndsWith("/notes-9.0.0.json", StringComparison.Ordinal) + && !path.Contains("notes-elasticsearch-", StringComparison.Ordinal) + ) + return Json(/*lang=json,strict*/ + """{"schema_version":1,"notes":[{"path":"main/note-slow-rollover.yml","bundle_seq":0}]}""" + ); + if (path.EndsWith("note-slow-rollover.yml", StringComparison.Ordinal)) + return Yaml(SampleEntry); + return new HttpResponseMessage(HttpStatusCode.NotFound); + }); + var (errors, _, emitError, _) = Diagnostics(); + + using var fetcher = CreateFetcher(handler); + var entries = await fetcher.FetchNotesAsync( + BaseUri, + "elastic", + "elasticsearch", + "elasticsearch", + "9.0.0", + emitError, + TestContext.Current.CancellationToken + ); + + errors.Should().BeEmpty(); + entries.Select(e => e.FileName).Should().BeEquivalentTo("main/note-slow-rollover.yml"); + handler.RequestedPaths.Should().Contain(p => p.EndsWith("/notes-elasticsearch-9.0.0.json", StringComparison.Ordinal)); + handler.RequestedPaths.Should().Contain(p => p.EndsWith("/notes-9.0.0.json", StringComparison.Ordinal)); + } + + [Fact] + public async Task FetchNotesAsync_EmptyProductIndex_DoesNotFallBackToLegacy() + { + var handler = new StubHandler(req => + { + var path = req.RequestUri!.AbsolutePath; + if (path.EndsWith("/notes-elasticsearch-9.0.0.json", StringComparison.Ordinal)) + return Json(/*lang=json,strict*/ """{"schema_version":1,"notes":[]}"""); + if (path.EndsWith("/notes-9.0.0.json", StringComparison.Ordinal)) + return Json(/*lang=json,strict*/ + """{"schema_version":1,"notes":[{"path":"main/note-slow-rollover.yml","bundle_seq":0}]}""" + ); + return Yaml(SampleEntry); + }); + var (errors, _, emitError, _) = Diagnostics(); + + using var fetcher = CreateFetcher(handler); + var entries = await fetcher.FetchNotesAsync( + BaseUri, + "elastic", + "elasticsearch", + "elasticsearch", "9.0.0", emitError, TestContext.Current.CancellationToken @@ -372,6 +449,7 @@ public async Task FetchNotesAsync_EmptyIndex_ReturnsEmpty() entries.Should().BeEmpty(); errors.Should().BeEmpty(); + handler.RequestedPaths.Should().ContainSingle().Which.Should().EndWith("/notes-elasticsearch-9.0.0.json"); } private static HttpResponseMessage Json(string body) => diff --git a/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/ChangelogKeysTests.cs b/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/ChangelogKeysTests.cs index 81d63c0bba..a45aaf2d31 100644 --- a/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/ChangelogKeysTests.cs +++ b/tests/Elastic.Documentation.Configuration.Tests/ReleaseNotes/ChangelogKeysTests.cs @@ -244,6 +244,28 @@ public void PoolSegments_ExpandsBranchSlashesIntoSegments() => [InlineData("changelog/elastic/elastic search/main/registry.json")] public void IsRegistry_InvalidKeys_ReturnsFalse(string key) => ChangelogKeys.IsRegistry(key).Should().BeFalse(); + [Theory] + [InlineData("changelog/elastic/elasticsearch/notes-9.0.0.json")] + [InlineData("changelog/elastic/elasticsearch/notes-elasticsearch-9.0.0.json")] + [InlineData("changelog/elastic/cloud/notes-cloud-enterprise-4.2.0.json")] + public void IsNotesIndex_ValidKeys_ReturnsTrue(string key) => ChangelogKeys.IsNotesIndex(key).Should().BeTrue(); + + [Theory] + [InlineData("changelog/elastic/elasticsearch/main/notes-9.0.0.json")] + [InlineData("changelog/elastic/elasticsearch/notes-.json")] + [InlineData("bundle/elasticsearch/notes-9.0.0.json")] + public void IsNotesIndex_InvalidKeys_ReturnsFalse(string key) => ChangelogKeys.IsNotesIndex(key).Should().BeFalse(); + + [Fact] + public void NotesIndexKey_LegacyAndProductScoped_ConstructsWithoutParsing() + { + ChangelogKeys.NotesIndexKey("elastic", "elasticsearch", "9.0.0").Should().Be("changelog/elastic/elasticsearch/notes-9.0.0.json"); + ChangelogKeys + .NotesIndexKey("elastic", "cloud", "cloud-enterprise", "4.2.0") + .Should() + .Be("changelog/elastic/cloud/notes-cloud-enterprise-4.2.0.json"); + } + [Theory] [InlineData("/bundle/elasticsearch/9.3.0.yaml", "elasticsearch", "9.3.0.yaml")] [InlineData("bundle/elasticsearch/9.3.0.yaml", "elasticsearch", "9.3.0.yaml")] From 1ab78e369cbb51dcc2355f11440a6fd5eb538f54 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 16:07:12 +0000 Subject: [PATCH 2/3] Add changelog for PR #4120 --- docs/changelog/4120.yaml | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 docs/changelog/4120.yaml diff --git a/docs/changelog/4120.yaml b/docs/changelog/4120.yaml new file mode 100644 index 0000000000..9e9e348b25 --- /dev/null +++ b/docs/changelog/4120.yaml @@ -0,0 +1,7 @@ +prs: +- https://github.com/elastic/docs-builder/pull/4120 +type: bug-fix +products: +- product: docs-builder + lifecycle: ga +title: Support notes-product-version.json indices From 09940a00597b5a9e5c52a7e6c666f5b78cd3d430 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Mon, 21 Sep 2026 18:32:04 +0200 Subject: [PATCH 3/3] Remove bot-generated changelog entry Co-Authored-By: Claude --- docs/changelog/4120.yaml | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 docs/changelog/4120.yaml diff --git a/docs/changelog/4120.yaml b/docs/changelog/4120.yaml deleted file mode 100644 index 9e9e348b25..0000000000 --- a/docs/changelog/4120.yaml +++ /dev/null @@ -1,7 +0,0 @@ -prs: -- https://github.com/elastic/docs-builder/pull/4120 -type: bug-fix -products: -- product: docs-builder - lifecycle: ga -title: Support notes-product-version.json indices