Skip to content

Commit c7f9a73

Browse files
cotticlaudecursoragent
committed
changelog: align CDN registry paths with singular S3 key prefixes
Follows #3434, which moved bundle/changelog artifacts from {product}/bundles/ and {product}/changelogs/ to the singular {product}/bundle/ and {product}/changelog/. The CDN consumer fetched bundles from the old plural path, so it would 404 against the new layout; this points it at {product}/bundle/{file} and updates the registry docs/comments/tests to match. The directive's local-folder convention (changelog/bundles/) is unchanged. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 61e688c commit c7f9a73

9 files changed

Lines changed: 30 additions & 30 deletions

File tree

docs/development/changelog-bundle-registry.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ copies, no cross-repo file syncing.
3434
│ Client CI │ --artifact-type │ Private bundles │ ───────────────────▶ │ Changelog scrubber │
3535
│ (docs-actions)│ bundle ───────────▶ │ S3 bucket │ │ Lambda │
3636
└──────────────┘ │ │ └─────────┬─────────┘
37-
│ │ {product}/bundles/*.yaml │ scrub + copy
37+
│ │ {product}/bundle/*.yaml │ scrub + copy
3838
│ also refreshes │ {product}/registry.json │ (pass-through for
3939
└──────────────────────────────▶ │ │ registry.json)
4040
└────────────────────┘ ▼
@@ -46,7 +46,7 @@ copies, no cross-repo file syncing.
4646

4747
1. **Producer**`changelog upload --artifact-type bundle --target s3` (invoked by the
4848
docs-actions changelog upload workflow) uploads each bundle to
49-
`{product}/bundles/{file}` in the **private** bucket, then refreshes
49+
`{product}/bundle/{file}` in the **private** bucket, then refreshes
5050
`{product}/registry.json` for every product the run touched.
5151
2. **Scrubber Lambda** — triggered by `s3:ObjectCreated` on the private bucket, it scrubs
5252
private repository references out of bundle YAML and writes the sanitized copy to the
@@ -57,7 +57,7 @@ copies, no cross-repo file syncing.
5757
### Why a registry instead of an S3 listing
5858

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

6363
## `registry.json` format
@@ -81,7 +81,7 @@ Stored at `{product}/registry.json`. Serialized with `snake_case` keys.
8181
| `schema_version` | Bumped when consumers must change their parser. |
8282
| `product` | Product identifier; matches the first S3 key segment. |
8383
| `generated_at` | UTC timestamp of the last regeneration. |
84-
| `bundles[].file` | Bundle file name, resolved at `{product}/bundles/{file}`. |
84+
| `bundles[].file` | Bundle file name, resolved at `{product}/bundle/{file}`. |
8585
| `bundles[].target` | Target version/date from the bundle's declaration of **this** product (may be null). |
8686
| `bundles[].etag` | See the ETag caveat below. |
8787

@@ -104,7 +104,7 @@ reads of the same bucket).
104104
The refresh runs inside `ChangelogUploadService` after a successful **bundle** upload (it is
105105
skipped for `--artifact-type changelog`). `RegistryBuilder`:
106106

107-
- Groups the run's upload targets by product (from the `{product}/bundles/{file}` key).
107+
- Groups the run's upload targets by product (from the `{product}/bundle/{file}` key).
108108
- For each product, derives one `registry` entry per bundle (file name, that product's
109109
target, locally-computed S3 ETag).
110110
- Reads the existing manifest from S3, merges by file name (re-uploads replace their entry;
@@ -187,7 +187,7 @@ also given) and the directive sources bundles from the CDN instead.
187187
### Fetch flow
188188

189189
1. `GET {cdnBase}/{product}/registry.json`.
190-
2. Parse it; for each `bundles[].file`, `GET {cdnBase}/{product}/bundles/{file}`.
190+
2. Parse it; for each `bundles[].file`, `GET {cdnBase}/{product}/bundle/{file}`.
191191
3. Feed the downloaded YAML into the existing `BundleLoader``MergeBundlesByTarget`
192192
render pipeline. **Rendering is unchanged**; only the source of the bundle bytes differs.
193193

@@ -216,7 +216,7 @@ entry files; the existing private-repo link and description visibility logic sti
216216
uses it to download only the matching registry entry instead of every listed bundle.
217217
- **Security.** The base URL is trusted configuration; the product and registry-supplied bundle
218218
file names are validated to single path segments so neither can traverse outside
219-
`{product}/bundles/`.
219+
`{product}/bundle/`.
220220

221221
### Follow-ups (not yet implemented) [implementation-notes]
222222

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ namespace Elastic.Documentation.Configuration.ReleaseNotes;
1313
/// <summary>
1414
/// Fetches changelog bundles for a single product from the public CDN at build time, for the
1515
/// <c>changelog</c> directive in <c>cdn:</c> mode. It reads <c>{base}/{product}/registry.json</c>
16-
/// to enumerate bundles, downloads each <c>{base}/{product}/bundles/{file}</c>, and parses them via
16+
/// to enumerate bundles, downloads each <c>{base}/{product}/bundle/{file}</c>, and parses them via
1717
/// <see cref="BundleLoader.LoadBundlesFromContent"/>.
1818
/// </summary>
1919
/// <remarks>
@@ -156,7 +156,7 @@ private IReadOnlyList<LoadedBundle> FetchUncached(
156156
continue;
157157
}
158158

159-
var bundleUri = Combine(baseUri, product, "bundles", fileName);
159+
var bundleUri = Combine(baseUri, product, "bundle", fileName);
160160
try
161161
{
162162
contents.Add((fileName, FetchText(bundleUri, ctx)));
@@ -190,7 +190,7 @@ private static Uri Combine(Uri baseUri, params string[] segments)
190190

191191
/// <summary>
192192
/// Guards against path traversal or nested keys sneaking in via the registry: a bundle file name
193-
/// must be a single path segment (the producer always writes <c>{product}/bundles/{file}</c>).
193+
/// must be a single path segment (the producer always writes <c>{product}/bundle/{file}</c>).
194194
/// </summary>
195195
private static bool IsSafeBundleFileName(string fileName) =>
196196
!fileName.Contains('/', StringComparison.Ordinal)

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ public sealed record ChangelogRegistry
2828
/// <summary>One entry in <see cref="ChangelogRegistry.Bundles"/>.</summary>
2929
public sealed record ChangelogRegistryBundle
3030
{
31-
/// <summary>Bundle file name, resolved at <c>{product}/bundles/{file}</c> on the CDN.</summary>
31+
/// <summary>Bundle file name, resolved at <c>{product}/bundle/{file}</c> on the CDN.</summary>
3232
public string? File { get; init; }
3333

3434
/// <summary>Target version or release date declared by the bundle (e.g. <c>9.3.0</c>).</summary>

src/services/Elastic.Changelog/Uploading/Registry.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ public sealed record RegistryBundle
4646
{
4747
/// <summary>
4848
/// Bundle file name (e.g. <c>9.3.0.yaml</c> or <c>2025-11.yaml</c>),
49-
/// resolved at <c>{product}/bundles/{file}</c>.
49+
/// resolved at <c>{product}/bundle/{file}</c>.
5050
/// </summary>
5151
public required string File { get; init; }
5252

src/services/Elastic.Changelog/Uploading/RegistryBuilder.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ public async Task<RefreshResult> RefreshAsync(
5353
IReadOnlyList<UploadTarget> bundleTargets,
5454
Cancel ctx)
5555
{
56-
// Each upload target carries a "{product}/bundles/{file}" S3 key. Group by product
56+
// Each upload target carries a "{product}/bundle/{file}" S3 key. Group by product
5757
// so we can produce one manifest per affected product.
5858
var byProduct = bundleTargets
5959
.Select(t => (Target: t, Product: ExtractProduct(t.S3Key)))
@@ -95,7 +95,7 @@ public async Task<RefreshResult> RefreshAsync(
9595

9696
/// <summary>
9797
/// Extracts the leading <c>product</c> segment from an S3 key shaped like
98-
/// <c>{product}/bundles/{file}</c>. Returns null on unrecognized shapes.
98+
/// <c>{product}/bundle/{file}</c>. Returns null on unrecognized shapes.
9999
/// </summary>
100100
private static string? ExtractProduct(string s3Key)
101101
{

src/services/Elastic.Changelog/Uploading/RegistryKey.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ public static class RegistryKey
2121
/// <remarks>
2222
/// Used by the changelog scrubber Lambda to decide whether to pass an incoming
2323
/// <c>*.json</c> object through to the public bucket. Anything else (e.g. nested
24-
/// under a bundles/ prefix, or a multi-segment product) is rejected, which keeps
24+
/// under a bundle/ prefix, or a multi-segment product) is rejected, which keeps
2525
/// arbitrary JSON out of the public surface.
2626
/// </remarks>
2727
public static bool IsRegistry(string key)

tests/Elastic.Changelog.Tests/Uploading/ChangelogUploadServiceTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -492,7 +492,7 @@ public async Task Upload_BundleArtifactType_UploadsRegistryAlongsideBundle()
492492
_collector.Errors.Should().Be(0);
493493

494494
A.CallTo(() => _s3Client.PutObjectAsync(
495-
A<PutObjectRequest>.That.Matches(r => r.Key == "elasticsearch/bundles/9.3.0.yaml"),
495+
A<PutObjectRequest>.That.Matches(r => r.Key == "elasticsearch/bundle/9.3.0.yaml"),
496496
A<CancellationToken>._
497497
)).MustHaveHappenedOnceExactly();
498498

tests/Elastic.Changelog.Tests/Uploading/RegistryBuilderTests.cs

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ private static Registry Deserialize(string? json) =>
105105
public async Task Refresh_NoExistingManifest_CreatesManifestWithIfNoneMatch()
106106
{
107107
var path = AddBundle("9.3.0.yaml", "elasticsearch", "9.3.0");
108-
var targets = new List<UploadTarget> { new(path, "elasticsearch/bundles/9.3.0.yaml") };
108+
var targets = new List<UploadTarget> { new(path, "elasticsearch/bundle/9.3.0.yaml") };
109109
StubExistingManifestNotFound();
110110

111111
var result = await _builder.RefreshAsync(_collector, targets, TestContext.Current.CancellationToken);
@@ -145,8 +145,8 @@ public async Task Refresh_ExistingManifest_MergesByFileNameAndUsesIfMatch()
145145
var ten = AddBundle("9.4.0.yaml", "elasticsearch", "9.4.0");
146146
var targets = new List<UploadTarget>
147147
{
148-
new(newer, "elasticsearch/bundles/9.3.0.yaml"),
149-
new(ten, "elasticsearch/bundles/9.4.0.yaml")
148+
new(newer, "elasticsearch/bundle/9.3.0.yaml"),
149+
new(ten, "elasticsearch/bundle/9.4.0.yaml")
150150
};
151151

152152
var result = await _builder.RefreshAsync(_collector, targets, TestContext.Current.CancellationToken);
@@ -174,8 +174,8 @@ public async Task Refresh_MultipleProducts_WritesOneManifestPerProduct()
174174
var kb = AddBundle("kb-9.3.0.yaml", "kibana", "9.3.0");
175175
var targets = new List<UploadTarget>
176176
{
177-
new(es, "elasticsearch/bundles/9.3.0.yaml"),
178-
new(kb, "kibana/bundles/kb-9.3.0.yaml")
177+
new(es, "elasticsearch/bundle/9.3.0.yaml"),
178+
new(kb, "kibana/bundle/kb-9.3.0.yaml")
179179
};
180180
StubExistingManifestNotFound();
181181

@@ -212,8 +212,8 @@ public async Task Refresh_MultiProductBundle_RecordsTargetPerProduct()
212212
"""));
213213
var targets = new List<UploadTarget>
214214
{
215-
new(path, "elasticsearch/bundles/multi.yaml"),
216-
new(path, "kibana/bundles/multi.yaml")
215+
new(path, "elasticsearch/bundle/multi.yaml"),
216+
new(path, "kibana/bundle/multi.yaml")
217217
};
218218
StubExistingManifestNotFound();
219219

@@ -237,7 +237,7 @@ public async Task Refresh_ExistingManifestUnreadable_OverwritesUsingLiveETag()
237237
});
238238

239239
var path = AddBundle("9.3.0.yaml", "elasticsearch", "9.3.0");
240-
var targets = new List<UploadTarget> { new(path, "elasticsearch/bundles/9.3.0.yaml") };
240+
var targets = new List<UploadTarget> { new(path, "elasticsearch/bundle/9.3.0.yaml") };
241241

242242
var result = await _builder.RefreshAsync(_collector, targets, TestContext.Current.CancellationToken);
243243

@@ -257,7 +257,7 @@ public async Task Refresh_BundleWithoutTarget_RecordsEntryWithoutTarget()
257257
_mockFileSystem.AddFile(path, new MockFileData("""
258258
entries: []
259259
"""));
260-
var targets = new List<UploadTarget> { new(path, "elasticsearch/bundles/no-target.yaml") };
260+
var targets = new List<UploadTarget> { new(path, "elasticsearch/bundle/no-target.yaml") };
261261
StubExistingManifestNotFound();
262262

263263
var result = await _builder.RefreshAsync(_collector, targets, TestContext.Current.CancellationToken);
@@ -285,7 +285,7 @@ public async Task Refresh_UnchangedManifest_SkipsWrite()
285285
};
286286
StubExistingManifest("elasticsearch", existing, "\"manifest-v1\"");
287287

288-
var targets = new List<UploadTarget> { new(path, "elasticsearch/bundles/9.3.0.yaml") };
288+
var targets = new List<UploadTarget> { new(path, "elasticsearch/bundle/9.3.0.yaml") };
289289
var result = await _builder.RefreshAsync(_collector, targets, TestContext.Current.CancellationToken);
290290

291291
result.Unchanged.Should().Be(1);
@@ -324,7 +324,7 @@ public async Task Refresh_ConcurrentWrite_RetriesAfterPreconditionFailed()
324324
.Once();
325325

326326
var path = AddBundle("9.4.0.yaml", "elasticsearch", "9.4.0");
327-
var targets = new List<UploadTarget> { new(path, "elasticsearch/bundles/9.4.0.yaml") };
327+
var targets = new List<UploadTarget> { new(path, "elasticsearch/bundle/9.4.0.yaml") };
328328

329329
var result = await _builder.RefreshAsync(_collector, targets, TestContext.Current.CancellationToken);
330330

@@ -357,7 +357,7 @@ public async Task Refresh_PersistentConcurrentWrite_EmitsWarningAndReportsFailur
357357
.Throws(new AmazonS3Exception("Precondition Failed") { StatusCode = HttpStatusCode.PreconditionFailed });
358358

359359
var path = AddBundle("9.4.0.yaml", "elasticsearch", "9.4.0");
360-
var targets = new List<UploadTarget> { new(path, "elasticsearch/bundles/9.4.0.yaml") };
360+
var targets = new List<UploadTarget> { new(path, "elasticsearch/bundle/9.4.0.yaml") };
361361

362362
var result = await _builder.RefreshAsync(_collector, targets, TestContext.Current.CancellationToken);
363363

tests/Elastic.Changelog.Tests/Uploading/RegistryKeyTests.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,9 @@ public void IsRegistry_ValidProductKeys_ReturnsTrue(string key) =>
2222
[InlineData("")]
2323
[InlineData("registry.json")]
2424
[InlineData("/registry.json")]
25-
[InlineData("elasticsearch/bundles/registry.json")]
25+
[InlineData("elasticsearch/bundle/registry.json")]
2626
[InlineData("elasticsearch/registry.yaml")]
27-
[InlineData("elasticsearch/changelogs/registry.json")]
27+
[InlineData("elasticsearch/changelog/registry.json")]
2828
[InlineData("../registry.json")]
2929
[InlineData("elastic search/registry.json")]
3030
[InlineData("elastic.search/registry.json")]

0 commit comments

Comments
 (0)