From 0f095225f06112ab80a629a94a07485776caf57e Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Wed, 29 Jul 2026 15:34:41 -0300 Subject: [PATCH 1/3] Fix redirects not written or deployed for isolated builds Isolated builds (e.g. docs-builder's own docs) were discarding the GenerationResult from GenerateAll with `_ = await`, so redirects.json was never produced and redirects defined in _redirects.yml had no effect. - Capture GenerateAll result in IsolatedBuildService.Build and write redirects.json with path-prefix-resolved absolute URLs - Add --no-delete flag to `assembler deploy update-redirects` so per-docset deploys only PUT new entries without wiping other repos' KVS redirects - Add Deploy redirects step in docs-preview-local.yml to push docs-builder's redirects.json to the CloudFront KVS on push to main Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .github/workflows/docs-preview-local.yml | 12 ++++ docs/cli-schema.json | 8 +++ .../Deploying/DeployUpdateRedirectsService.cs | 3 +- .../AwsCloudFrontKeyValueStoreProxy.cs | 46 +++++++++----- .../Elastic.Documentation.Isolated.csproj | 6 ++ .../IsolatedBuildService.cs | 62 ++++++++++++++++++- .../Commands/Assembler/DeployCommands.cs | 10 ++- .../Codex/CodexUpdateRedirectsCommand.cs | 2 +- .../IsolatedBuildRedirectTests.cs | 35 +++++++++++ 9 files changed, 164 insertions(+), 20 deletions(-) create mode 100644 tests/Elastic.Documentation.Build.Tests/IsolatedBuildRedirectTests.cs diff --git a/.github/workflows/docs-preview-local.yml b/.github/workflows/docs-preview-local.yml index d8e5232a3f..66a35c156a 100644 --- a/.github/workflows/docs-preview-local.yml +++ b/.github/workflows/docs-preview-local.yml @@ -352,6 +352,18 @@ jobs: --distribution-id EKT7LT5PM8RKS \ --paths "${PATH_PREFIX}" "${PATH_PREFIX}/*" + - name: Deploy redirects to CloudFront KVS + if: > + env.MATCH == 'true' + && !cancelled() + && needs.check.outputs.any_modified != 'false' + && github.event_name == 'push' + && steps.s3-upload.outcome == 'success' + run: | + if [ -f .artifacts/docs/html/redirects.json ]; then + dotnet run --project src/tooling/docs-builder -- assembler deploy update-redirects --no-delete preview --redirectsFile .artifacts/docs/html/redirects.json + fi + - name: Update Link Index if: > env.MATCH == 'true' diff --git a/docs/cli-schema.json b/docs/cli-schema.json index ef677c35be..2d0e359506 100644 --- a/docs/cli-schema.json +++ b/docs/cli-schema.json @@ -2596,6 +2596,14 @@ } ] }, + { + "role": "flag", + "name": "no-delete", + "type": "boolean", + "required": false, + "summary": "Only PUT the new entries without deleting any existing KVS keys.\nUse for per-docset isolated builds that should not remove other repos\u0027 redirects.", + "defaultValue": "false" + }, { "role": "flag", "name": "log-level", diff --git a/src/services/Elastic.Documentation.Assembler/Deploying/DeployUpdateRedirectsService.cs b/src/services/Elastic.Documentation.Assembler/Deploying/DeployUpdateRedirectsService.cs index 01af135702..84a0c4705f 100644 --- a/src/services/Elastic.Documentation.Assembler/Deploying/DeployUpdateRedirectsService.cs +++ b/src/services/Elastic.Documentation.Assembler/Deploying/DeployUpdateRedirectsService.cs @@ -22,6 +22,7 @@ public async Task UpdateRedirects( string? redirectsFile, string kvsNamePrefix = "elastic-docs-v3", string? defaultRedirectsFile = null, + bool noDelete = false, Cancel ctx = default) { redirectsFile ??= defaultRedirectsFile ?? ".artifacts/assembly/redirects.json"; @@ -44,7 +45,7 @@ public async Task UpdateRedirects( var kvsName = $"{kvsNamePrefix}-{environment}-redirects-kvs"; var cloudFrontClient = new AwsCloudFrontKeyValueStoreProxy(collector, logFactory, fileSystem.DirectoryInfo.New(fileSystem.Directory.GetCurrentDirectory())); - cloudFrontClient.UpdateRedirects(kvsName, sourcedRedirects); + cloudFrontClient.UpdateRedirects(kvsName, sourcedRedirects, noDelete); return collector.Errors == 0; } } diff --git a/src/services/Elastic.Documentation.Assembler/Deploying/Redirects/AwsCloudFrontKeyValueStoreProxy.cs b/src/services/Elastic.Documentation.Assembler/Deploying/Redirects/AwsCloudFrontKeyValueStoreProxy.cs index c9875a8799..3ce694fd7b 100644 --- a/src/services/Elastic.Documentation.Assembler/Deploying/Redirects/AwsCloudFrontKeyValueStoreProxy.cs +++ b/src/services/Elastic.Documentation.Assembler/Deploying/Redirects/AwsCloudFrontKeyValueStoreProxy.cs @@ -22,7 +22,11 @@ public class AwsCloudFrontKeyValueStoreProxy(IDiagnosticsCollector collector, IL /// protected override ILogger Logger { get; } = logFactory.CreateLogger(); - public void UpdateRedirects(string kvsName, IReadOnlyDictionary sourcedRedirects) + /// + /// When true, only PUT the new entries without deleting any existing KVS keys. + /// Use this for partial (isolated / per-docset) deploys that should not touch other repos' redirects. + /// + public void UpdateRedirects(string kvsName, IReadOnlyDictionary sourcedRedirects, bool noDelete = false) { var kvsArn = DescribeKeyValueStore(kvsName); if (string.IsNullOrEmpty(kvsArn)) @@ -32,25 +36,39 @@ public void UpdateRedirects(string kvsName, IReadOnlyDictionary if (string.IsNullOrEmpty(eTag)) return; - var listingSuccessful = TryListAllKeys(kvsArn, out var existingRedirects); + PutKeyRequestListItem[] toPut; + DeleteKeyRequestListItem[] toDelete; - if (!listingSuccessful) - return; - - if (RedirectKvsDiff.WouldWipeAllExisting(sourcedRedirects, existingRedirects)) + if (noDelete) { - Collector.EmitError("", $"Refusing to update redirects: sourced redirects are empty but the KVS contains {existingRedirects.Count} entries. " + - "This would wipe every redirect. Verify the assembler produced a non-empty redirects.json before retrying."); - return; + toPut = sourcedRedirects + .Select(kvp => new PutKeyRequestListItem { Key = kvp.Key, Value = kvp.Value }) + .ToArray(); + toDelete = []; } + else + { + var listingSuccessful = TryListAllKeys(kvsArn, out var existingRedirects); + if (!listingSuccessful) + return; - var (toPut, toDelete) = RedirectKvsDiff.ComputeBatchUpdates(sourcedRedirects, existingRedirects); + if (RedirectKvsDiff.WouldWipeAllExisting(sourcedRedirects, existingRedirects)) + { + Collector.EmitError("", $"Refusing to update redirects: sourced redirects are empty but the KVS contains {existingRedirects.Count} entries. " + + "This would wipe every redirect. Verify the assembler produced a non-empty redirects.json before retrying."); + return; + } + + (toPut, toDelete) = RedirectKvsDiff.ComputeBatchUpdates(sourcedRedirects, existingRedirects); + } - Logger.LogInformation("Computed redirect KVS diff: {ToPut} to put, {ToDelete} to delete (from {Existing} existing, {Sourced} sourced)", - toPut.Length, toDelete.Length, existingRedirects.Count, sourcedRedirects.Count); + Logger.LogInformation("Computed redirect KVS diff: {ToPut} to put, {ToDelete} to delete (noDelete={NoDelete})", + toPut.Length, toDelete.Length, noDelete); - eTag = ProcessBatchUpdates(kvsArn, eTag, toDelete, KvsOperation.Deletes); - _ = ProcessBatchUpdates(kvsArn, eTag, toPut, KvsOperation.Puts); + if (toDelete.Length > 0) + eTag = ProcessBatchUpdates(kvsArn, eTag, toDelete, KvsOperation.Deletes); + if (toPut.Length > 0) + _ = ProcessBatchUpdates(kvsArn, eTag, toPut, KvsOperation.Puts); } private string DescribeKeyValueStore(string kvsName) diff --git a/src/services/Elastic.Documentation.Isolated/Elastic.Documentation.Isolated.csproj b/src/services/Elastic.Documentation.Isolated/Elastic.Documentation.Isolated.csproj index 24607021b7..5670ea845e 100644 --- a/src/services/Elastic.Documentation.Isolated/Elastic.Documentation.Isolated.csproj +++ b/src/services/Elastic.Documentation.Isolated/Elastic.Documentation.Isolated.csproj @@ -8,6 +8,12 @@ true $(NoWarn);CS1591;CS1573;CS1572;CS1571;CS1570;CS1574 + + + + <_Parameter1>Elastic.Documentation.Build.Tests + + diff --git a/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs b/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs index f2422aa2b4..888760fe3f 100644 --- a/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs +++ b/src/services/Elastic.Documentation.Isolated/IsolatedBuildService.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information using System.IO.Abstractions; +using System.Text.Json; using Actions.Core.Services; using Elastic.ApiExplorer; using Elastic.Documentation; @@ -15,6 +16,7 @@ using Elastic.Documentation.Links; using Elastic.Documentation.Links.CrossLinks; using Elastic.Documentation.Navigation; +using Elastic.Documentation.Serialization; using Elastic.Documentation.Services; using Elastic.Documentation.Site.Navigation; using Elastic.Markdown; @@ -164,7 +166,7 @@ public async Task Build( var generator = new DocumentationGenerator(set, logFactory, set, null, null, markdownExporters.ToArray(), documentInferrer: documentInferrer); - _ = await generator.GenerateAll(ctx); + var result = await generator.GenerateAll(ctx); if (!skipOpenApi) { @@ -175,6 +177,9 @@ public async Task Build( if (runningOnCi) await githubActionsService.SetOutputAsync("landing-page-path", set.FirstInterestingUrl); + if (result.Redirects.Count > 0) + await WriteRedirectsAsync(result.Redirects, context, ctx); + var finishTasks = markdownExporters.Select(async e => await e.FinishExportAsync(context.OutputDirectory, ctx)); _ = await Task.WhenAll(finishTasks); @@ -185,6 +190,61 @@ public async Task Build( return strict.Value ? context.Collector.Errors + context.Collector.Warnings == 0 : context.Collector.Errors == 0; } + private async Task WriteRedirectsAsync(IReadOnlyDictionary redirects, BuildContext context, Cancel ctx) + { + var pathPrefix = (context.UrlPathPrefix ?? string.Empty).TrimEnd('/'); + var resolved = new Dictionary(); + + foreach (var (from, redirect) in redirects) + { + string? to = null; + if (redirect.To is not null) + to = redirect.To; + else if (redirect.Many is { Length: > 0 }) + to = redirect.Many.FirstOrDefault(r => r.To is not null)?.To; + + if (to is null || to.Contains("://")) + continue; + + var fromUrl = ToAbsoluteUrl(from, pathPrefix); + var toUrl = ToAbsoluteUrl(to, pathPrefix); + + if (!string.IsNullOrEmpty(fromUrl) && !string.IsNullOrEmpty(toUrl) + && !fromUrl.TrimEnd('/').Equals(toUrl.TrimEnd('/'), OrdinalIgnoreCase)) + { + resolved[fromUrl] = toUrl; + } + } + + if (resolved.Count == 0) + return; + + var redirectsFile = context.WriteFileSystem.FileInfo.New( + Path.Join(context.OutputDirectory.FullName, "redirects.json")); + _logger.LogInformation("Writing {Count} resolved redirects to {Path}", resolved.Count, redirectsFile.FullName); + + var json = JsonSerializer.Serialize(resolved, SourceGenerationContext.Default.DictionaryStringString); + await context.WriteFileSystem.File.WriteAllTextAsync(redirectsFile.FullName, json, ctx); + } + + internal static string ToAbsoluteUrl(string path, string pathPrefix) + { + pathPrefix = pathPrefix.TrimEnd('/'); + + if (path.EndsWith(".md", OrdinalIgnoreCase)) + path = path[..^3]; + + if (path.EndsWith("/index", OrdinalIgnoreCase)) + path = path[..^6]; + else if (path.Equals("index", OrdinalIgnoreCase)) + return string.IsNullOrEmpty(pathPrefix) ? "/" : pathPrefix; + + if (string.IsNullOrEmpty(path)) + return string.IsNullOrEmpty(pathPrefix) ? "/" : pathPrefix; + + return $"{pathPrefix}/{path}"; + } + /// /// Builds a pre-configured documentation set with optional injected navigation. /// Used by portal builds where navigation spans multiple documentation sets. diff --git a/src/tooling/docs-builder/Commands/Assembler/DeployCommands.cs b/src/tooling/docs-builder/Commands/Assembler/DeployCommands.cs index ed1b452197..bf70fb8e99 100644 --- a/src/tooling/docs-builder/Commands/Assembler/DeployCommands.cs +++ b/src/tooling/docs-builder/Commands/Assembler/DeployCommands.cs @@ -76,15 +76,19 @@ static async (s, collector, state, ctx) => await s.Apply(collector, state.contex /// Run after assembler build produces a redirects.json. /// Named deployment target. /// Path to redirects.json. Defaults to .artifacts/docs/redirects.json. + /// + /// Only PUT the new entries without deleting any existing KVS keys. + /// Use for per-docset isolated builds that should not remove other repos' redirects. + /// [NoOptionsInjection] - public async Task UpdateRedirects(string environment, [Existing, ExpandUserProfile, RejectSymbolicLinks, FileExtensions(Extensions = "json")] FileInfo? redirectsFile = null, CancellationToken ct = default) + public async Task UpdateRedirects(string environment, [Existing, ExpandUserProfile, RejectSymbolicLinks, FileExtensions(Extensions = "json")] FileInfo? redirectsFile = null, bool noDelete = false, CancellationToken ct = default) { await using var serviceInvoker = new ServiceInvoker(collector); var fs = FileSystemFactory.RealRead; var service = new DeployUpdateRedirectsService(logFactory, fs); - serviceInvoker.AddCommand(service, (environment, redirectsFile), - static async (s, collector, state, ctx) => await s.UpdateRedirects(collector, state.environment, state.redirectsFile?.FullName, ctx: ctx) + serviceInvoker.AddCommand(service, (environment, redirectsFile, noDelete), + static async (s, collector, state, ctx) => await s.UpdateRedirects(collector, state.environment, state.redirectsFile?.FullName, noDelete: state.noDelete, ctx: ctx) ); return await serviceInvoker.InvokeAsync(ct); } diff --git a/src/tooling/docs-builder/Commands/Codex/CodexUpdateRedirectsCommand.cs b/src/tooling/docs-builder/Commands/Codex/CodexUpdateRedirectsCommand.cs index b5027a11c5..4e6d07fc76 100644 --- a/src/tooling/docs-builder/Commands/Codex/CodexUpdateRedirectsCommand.cs +++ b/src/tooling/docs-builder/Commands/Codex/CodexUpdateRedirectsCommand.cs @@ -47,7 +47,7 @@ public async Task UpdateRedirects( var service = new DeployUpdateRedirectsService(logFactory, readFs); serviceInvoker.AddCommand(service, (environment: resolvedEnvironment, redirectsFile, kvsNamePrefix: "codex", defaultRedirectsFile: ".artifacts/codex/docs/redirects.json"), - static async (s, col, state, c) => await s.UpdateRedirects(col, state.environment, state.redirectsFile?.FullName, state.kvsNamePrefix, state.defaultRedirectsFile, c) + static async (s, col, state, c) => await s.UpdateRedirects(col, state.environment, state.redirectsFile?.FullName, state.kvsNamePrefix, state.defaultRedirectsFile, ctx: c) ); return await serviceInvoker.InvokeAsync(ct); } diff --git a/tests/Elastic.Documentation.Build.Tests/IsolatedBuildRedirectTests.cs b/tests/Elastic.Documentation.Build.Tests/IsolatedBuildRedirectTests.cs new file mode 100644 index 0000000000..9b1eeb26cd --- /dev/null +++ b/tests/Elastic.Documentation.Build.Tests/IsolatedBuildRedirectTests.cs @@ -0,0 +1,35 @@ +// 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 + +using AwesomeAssertions; +using Elastic.Documentation.Isolated; + +namespace Elastic.Documentation.Build.Tests; + +public class IsolatedBuildRedirectTests +{ + [Theory] + [InlineData("migration/freeze/gh-action.md", "/en/docs-builder", "/en/docs-builder/migration/freeze/gh-action")] + [InlineData("schema-support/cli-schema/index.md", "/en/docs-builder", "/en/docs-builder/schema-support/cli-schema")] + [InlineData("index.md", "/en/docs-builder", "/en/docs-builder")] + [InlineData("migration/freeze/index.md", "/en/docs-builder", "/en/docs-builder/migration/freeze")] + [InlineData("cli/installation.md", "/en/docs-builder", "/en/docs-builder/cli/installation")] + [InlineData("index.md", "", "/")] + [InlineData("index.md", "/", "/")] + [InlineData("migrate/index.md", "", "/migrate")] + [InlineData("migrate/index.md", "/", "/migrate")] + public void ToAbsoluteUrl_VariousPaths_ProducesExpectedUrl(string path, string pathPrefix, string expected) => + IsolatedBuildService.ToAbsoluteUrl(path, pathPrefix).Should().Be(expected); + + [Theory] + [InlineData("migration/freeze/gh-action.md", "migration/freeze/gh-action.md")] + [InlineData("cli/installation.md", "cli/installation.md")] + public void ToAbsoluteUrl_FromAndToEquivalent_SelfRedirectDetected(string path, string to) + { + var prefix = "/en/docs-builder"; + IsolatedBuildService.ToAbsoluteUrl(path, prefix) + .TrimEnd('/') + .Should().Be(IsolatedBuildService.ToAbsoluteUrl(to, prefix).TrimEnd('/')); + } +} From 65a49222c60f1fe1564d10c762db26f988ff56d0 Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Mon, 3 Aug 2026 05:48:18 -0300 Subject: [PATCH 2/3] =?UTF-8?q?Remove=20redirect=20KVS=20deploy=20step=20?= =?UTF-8?q?=E2=80=94=20preview=20env=20has=20no=20redirect=20infra?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The preview environment does not serve redirects from a CloudFront KVS, so deploying redirects.json there has no effect. The build-side changes (writing redirects.json, --no-delete flag) remain as groundwork for when a deploy target exists. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .github/workflows/docs-preview-local.yml | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/.github/workflows/docs-preview-local.yml b/.github/workflows/docs-preview-local.yml index 66a35c156a..d8e5232a3f 100644 --- a/.github/workflows/docs-preview-local.yml +++ b/.github/workflows/docs-preview-local.yml @@ -352,18 +352,6 @@ jobs: --distribution-id EKT7LT5PM8RKS \ --paths "${PATH_PREFIX}" "${PATH_PREFIX}/*" - - name: Deploy redirects to CloudFront KVS - if: > - env.MATCH == 'true' - && !cancelled() - && needs.check.outputs.any_modified != 'false' - && github.event_name == 'push' - && steps.s3-upload.outcome == 'success' - run: | - if [ -f .artifacts/docs/html/redirects.json ]; then - dotnet run --project src/tooling/docs-builder -- assembler deploy update-redirects --no-delete preview --redirectsFile .artifacts/docs/html/redirects.json - fi - - name: Update Link Index if: > env.MATCH == 'true' From 2a17be461c9e3dc05492bd72a73d45e3ec6475f6 Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Mon, 3 Aug 2026 06:01:41 -0300 Subject: [PATCH 3/3] =?UTF-8?q?Revert=20"Remove=20redirect=20KVS=20deploy?= =?UTF-8?q?=20step=20=E2=80=94=20preview=20env=20has=20no=20redirect=20inf?= =?UTF-8?q?ra"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 65a49222c60f1fe1564d10c762db26f988ff56d0. --- .github/workflows/docs-preview-local.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/docs-preview-local.yml b/.github/workflows/docs-preview-local.yml index d8e5232a3f..66a35c156a 100644 --- a/.github/workflows/docs-preview-local.yml +++ b/.github/workflows/docs-preview-local.yml @@ -352,6 +352,18 @@ jobs: --distribution-id EKT7LT5PM8RKS \ --paths "${PATH_PREFIX}" "${PATH_PREFIX}/*" + - name: Deploy redirects to CloudFront KVS + if: > + env.MATCH == 'true' + && !cancelled() + && needs.check.outputs.any_modified != 'false' + && github.event_name == 'push' + && steps.s3-upload.outcome == 'success' + run: | + if [ -f .artifacts/docs/html/redirects.json ]; then + dotnet run --project src/tooling/docs-builder -- assembler deploy update-redirects --no-delete preview --redirectsFile .artifacts/docs/html/redirects.json + fi + - name: Update Link Index if: > env.MATCH == 'true'