From 2126f3879d5b188bc583f351f1e8b3121dc05ae7 Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Tue, 28 Apr 2026 11:36:40 -0300 Subject: [PATCH 1/6] Reintroduce artifact handling commands --- .../ChangelogArtifactEvaluationService.cs | 96 +++++++ .../Evaluation/ChangelogArtifactMetadata.cs | 40 +++ .../ChangelogPrepareArtifactService.cs | 122 +++++++++ .../Evaluation/EvaluateArtifactArguments.cs | 13 + .../Evaluation/PrepareArtifactArguments.cs | 31 +++ .../docs-builder/Commands/ChangelogCommand.cs | 113 +++++++- ...ChangelogArtifactEvaluationServiceTests.cs | 239 +++++++++++++++++ .../ChangelogArtifactMetadataTests.cs | 160 +++++++++++ .../ChangelogPrepareArtifactServiceTests.cs | 251 ++++++++++++++++++ 9 files changed, 1064 insertions(+), 1 deletion(-) create mode 100644 src/services/Elastic.Changelog/Evaluation/ChangelogArtifactEvaluationService.cs create mode 100644 src/services/Elastic.Changelog/Evaluation/ChangelogArtifactMetadata.cs create mode 100644 src/services/Elastic.Changelog/Evaluation/ChangelogPrepareArtifactService.cs create mode 100644 src/services/Elastic.Changelog/Evaluation/EvaluateArtifactArguments.cs create mode 100644 src/services/Elastic.Changelog/Evaluation/PrepareArtifactArguments.cs create mode 100644 tests/Elastic.Changelog.Tests/Evaluation/ChangelogArtifactEvaluationServiceTests.cs create mode 100644 tests/Elastic.Changelog.Tests/Evaluation/ChangelogArtifactMetadataTests.cs create mode 100644 tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrepareArtifactServiceTests.cs diff --git a/src/services/Elastic.Changelog/Evaluation/ChangelogArtifactEvaluationService.cs b/src/services/Elastic.Changelog/Evaluation/ChangelogArtifactEvaluationService.cs new file mode 100644 index 0000000000..6c48f7b4b3 --- /dev/null +++ b/src/services/Elastic.Changelog/Evaluation/ChangelogArtifactEvaluationService.cs @@ -0,0 +1,96 @@ +// 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 System.Globalization; +using System.IO.Abstractions; +using System.Text.Json; +using Actions.Core.Services; +using Elastic.Changelog.Creation; +using Elastic.Changelog.GitHub; +using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.Services; +using Microsoft.Extensions.Logging; + +namespace Elastic.Changelog.Evaluation; + +/// Service implementing the changelog evaluate-artifact CI command. +public class ChangelogArtifactEvaluationService( + ILoggerFactory logFactory, + IGitHubPrService gitHubPrService, + ICoreService coreService, + IFileSystem? fileSystem = null +) : IService +{ + private readonly ILogger _logger = logFactory.CreateLogger(); + private readonly IFileSystem _fileSystem = fileSystem ?? new FileSystem(); + + public async Task EvaluateArtifact(IDiagnosticsCollector collector, EvaluateArtifactArguments input, Cancel ctx) + { + if (!_fileSystem.File.Exists(input.MetadataPath)) + { + _logger.LogInformation("Metadata file not found at {Path}, nothing to evaluate", input.MetadataPath); + return true; + } + + var artifactMetadataJson = await _fileSystem.File.ReadAllTextAsync(input.MetadataPath, ctx); + var metadata = JsonSerializer.Deserialize(artifactMetadataJson, ChangelogArtifactMetadataJsonContext.Default.ChangelogArtifactMetadata); + if (metadata is null) + { + collector.EmitError(input.MetadataPath, "Failed to deserialize artifact metadata"); + return false; + } + + var prInfo = await gitHubPrService.FetchPrInfoAsync( + metadata.PrNumber.ToString(CultureInfo.InvariantCulture), input.Owner, input.Repo, ctx + ); + if (prInfo is null) + { + collector.EmitError(input.MetadataPath, $"Failed to fetch PR #{metadata.PrNumber} from GitHub"); + return false; + } + + if (!string.Equals(prInfo.HeadSha, metadata.HeadSha, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogInformation("PR head has moved ({OldSha} → {NewSha}), skipping — newer run will handle it", + metadata.HeadSha, prInfo.HeadSha); + return true; + } + + if (PrInfoProcessor.AreAllProductsBlocked(prInfo.Labels.ToArray(), metadata.CreateRules)) + { + _logger.LogInformation("Labels changed since generate, all products blocked — aborting gracefully"); + return true; + } + + var statusParsed = PrEvaluationResultExtensions.TryParse( + metadata.Status, out var metadataStatus, ignoreCase: true, allowMatchingMetadataAttribute: true + ); + + var shouldCommit = statusParsed && metadataStatus == PrEvaluationResult.Success && metadata.CanCommit; + var shouldCommentSuccess = statusParsed && metadataStatus == PrEvaluationResult.Success && !metadata.CanCommit; + var shouldCommentFailure = statusParsed && metadataStatus == PrEvaluationResult.NoLabel; + + await coreService.SetOutputAsync("pr-number", metadata.PrNumber.ToString(CultureInfo.InvariantCulture)); + await coreService.SetOutputAsync("head-ref", metadata.HeadRef); + await coreService.SetOutputAsync("head-sha", metadata.HeadSha); + await coreService.SetOutputAsync("status", metadata.Status); + await coreService.SetOutputAsync("is-fork", metadata.IsFork ? "true" : "false"); + await coreService.SetOutputAsync("head-repo", metadata.HeadRepo ?? string.Empty); + await coreService.SetOutputAsync("config-file", metadata.ConfigFile ?? string.Empty); + await coreService.SetOutputAsync("changelog-dir", metadata.ChangelogDir ?? string.Empty); + await coreService.SetOutputAsync("changelog-filename", metadata.ChangelogFilename ?? string.Empty); + await coreService.SetOutputAsync("label-table", metadata.LabelTable ?? string.Empty); + await coreService.SetOutputAsync("product-label-table", metadata.ProductLabelTable ?? string.Empty); + await coreService.SetOutputAsync("skip-labels", metadata.SkipLabels ?? string.Empty); + await coreService.SetOutputAsync("should-commit", shouldCommit ? "true" : "false"); + await coreService.SetOutputAsync("should-comment-success", shouldCommentSuccess ? "true" : "false"); + await coreService.SetOutputAsync("should-comment-failure", shouldCommentFailure ? "true" : "false"); + + _logger.LogInformation( + "Artifact evaluation complete: status={Status}, commit={Commit}, commentSuccess={CommentSuccess}, commentFailure={CommentFailure}", + metadata.Status, shouldCommit, shouldCommentSuccess, shouldCommentFailure); + + return true; + } +} diff --git a/src/services/Elastic.Changelog/Evaluation/ChangelogArtifactMetadata.cs b/src/services/Elastic.Changelog/Evaluation/ChangelogArtifactMetadata.cs new file mode 100644 index 0000000000..dede86bf8a --- /dev/null +++ b/src/services/Elastic.Changelog/Evaluation/ChangelogArtifactMetadata.cs @@ -0,0 +1,40 @@ +// 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 System.Text.Json.Serialization; +using Elastic.Documentation.Configuration.Changelog; +using Elastic.Documentation.ReleaseNotes; + +namespace Elastic.Changelog.Evaluation; + +/// Artifact metadata transferred between the generate and commit CI workflows. +public record ChangelogArtifactMetadata +{ + public required int PrNumber { get; init; } + public required string HeadRef { get; init; } + public required string HeadSha { get; init; } + public required string Status { get; init; } + public required bool IsFork { get; init; } + public required bool CanCommit { get; init; } + public required bool MaintainerCanModify { get; init; } + public string? HeadRepo { get; init; } + public string? LabelTable { get; init; } + public string? ProductLabelTable { get; init; } + public string? SkipLabels { get; init; } + public string? ConfigFile { get; init; } + public string? ChangelogDir { get; init; } + public string? ChangelogFilename { get; init; } + public CreateRules? CreateRules { get; init; } +} + +[JsonSourceGenerationOptions( + WriteIndented = true, + UseStringEnumConverter = true, + PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower +)] +[JsonSerializable(typeof(ChangelogArtifactMetadata))] +[JsonSerializable(typeof(CreateRules))] +[JsonSerializable(typeof(FieldMode))] +[JsonSerializable(typeof(MatchMode))] +public sealed partial class ChangelogArtifactMetadataJsonContext : JsonSerializerContext; diff --git a/src/services/Elastic.Changelog/Evaluation/ChangelogPrepareArtifactService.cs b/src/services/Elastic.Changelog/Evaluation/ChangelogPrepareArtifactService.cs new file mode 100644 index 0000000000..4a26b7630c --- /dev/null +++ b/src/services/Elastic.Changelog/Evaluation/ChangelogPrepareArtifactService.cs @@ -0,0 +1,122 @@ +// 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 System.IO.Abstractions; +using System.Text.Json; +using Actions.Core.Services; +using Elastic.Changelog.Configuration; +using Elastic.Documentation.Configuration; +using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.Services; +using Microsoft.Extensions.Logging; + +namespace Elastic.Changelog.Evaluation; + +/// Service implementing the changelog prepare-artifact CI command. +public class ChangelogPrepareArtifactService( + ILoggerFactory logFactory, + IConfigurationContext configurationContext, + ICoreService coreService, + IFileSystem? fileSystem = null +) : IService +{ + private readonly ILogger _logger = logFactory.CreateLogger(); + private readonly IFileSystem _fileSystem = fileSystem ?? new FileSystem(); + private readonly ChangelogConfigurationLoader _configLoader = new(logFactory, configurationContext, fileSystem ?? new FileSystem()); + + public async Task PrepareArtifact(IDiagnosticsCollector collector, PrepareArtifactArguments input, Cancel ctx) + { + var status = ResolveStatus(input.EvaluateStatus, input.GenerateOutcome); + _logger.LogInformation("Resolved artifact status: {Status} (evaluate={Evaluate}, generate={Generate})", + status, input.EvaluateStatus, input.GenerateOutcome); + + _ = _fileSystem.Directory.CreateDirectory(input.OutputDir); + + string? changelogFilename = null; + if (status == PrEvaluationResult.Success) + { + var sourceYaml = FindStagingYaml(input.StagingDir); + + if (sourceYaml != null) + { + changelogFilename = input.ExistingChangelogFilename + ?? _fileSystem.Path.GetFileName(sourceYaml); + + if (input.ExistingChangelogFilename != null) + _logger.LogInformation("Reusing existing filename {Filename} for stable path on branch", changelogFilename); + + var destYaml = _fileSystem.Path.Combine(input.OutputDir, changelogFilename); + _fileSystem.File.Copy(sourceYaml, destYaml, overwrite: true); + _logger.LogInformation("Copied changelog YAML: {Source} → {Dest}", sourceYaml, destYaml); + } + else + { + collector.EmitError(input.StagingDir, "No generated changelog YAML found in staging directory"); + status = PrEvaluationResult.Error; + } + } + + var config = await _configLoader.LoadChangelogConfiguration(collector, input.Config, ctx); + var createRules = config?.Rules?.Create; + var changelogDir = config?.Bundle?.Directory ?? "docs/changelog"; + + var statusString = status.ToStringFast(true); + var metadata = new ChangelogArtifactMetadata + { + PrNumber = input.PrNumber, + HeadRef = input.HeadRef, + HeadSha = input.HeadSha, + Status = statusString, + IsFork = input.IsFork, + HeadRepo = input.HeadRepo, + CanCommit = input.CanCommit, + MaintainerCanModify = input.MaintainerCanModify, + LabelTable = input.LabelTable, + ProductLabelTable = input.ProductLabelTable, + SkipLabels = input.SkipLabels, + ConfigFile = input.Config, + ChangelogDir = changelogDir, + ChangelogFilename = changelogFilename, + CreateRules = createRules + }; + + var metadataPath = _fileSystem.Path.Combine(input.OutputDir, "metadata.json"); + var json = JsonSerializer.Serialize(metadata, ChangelogArtifactMetadataJsonContext.Default.ChangelogArtifactMetadata); + await _fileSystem.File.WriteAllTextAsync(metadataPath, json, ctx); + _logger.LogInformation("Wrote artifact metadata to {Path}", metadataPath); + + await coreService.SetOutputAsync("status", statusString); + + return true; + } + + private string? FindStagingYaml(string stagingDir) + { + if (!_fileSystem.Directory.Exists(stagingDir)) + return null; + + var yamlFiles = _fileSystem.Directory.GetFiles(stagingDir, "*.yaml"); + if (yamlFiles.Length == 0) + return null; + + if (yamlFiles.Length > 1) + _logger.LogWarning("Multiple YAML files found in staging directory, using first: {File}", yamlFiles[0]); + + return yamlFiles[0]; + } + + internal static PrEvaluationResult ResolveStatus(string evaluateStatus, string generateOutcome) + { + if (string.Equals(evaluateStatus, ChangelogPrEvaluationService.ProceedStatus, StringComparison.OrdinalIgnoreCase)) + { + return generateOutcome.Equals("success", StringComparison.OrdinalIgnoreCase) + ? PrEvaluationResult.Success + : PrEvaluationResult.Error; + } + + return PrEvaluationResultExtensions.TryParse(evaluateStatus, out var parsed, ignoreCase: true, allowMatchingMetadataAttribute: true) + ? parsed + : PrEvaluationResult.Error; + } +} diff --git a/src/services/Elastic.Changelog/Evaluation/EvaluateArtifactArguments.cs b/src/services/Elastic.Changelog/Evaluation/EvaluateArtifactArguments.cs new file mode 100644 index 0000000000..e25aee1ec6 --- /dev/null +++ b/src/services/Elastic.Changelog/Evaluation/EvaluateArtifactArguments.cs @@ -0,0 +1,13 @@ +// 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.Evaluation; + +/// Arguments for the changelog evaluate-artifact command (commit workflow). +public record EvaluateArtifactArguments +{ + public required string MetadataPath { get; init; } + public required string Owner { get; init; } + public required string Repo { get; init; } +} diff --git a/src/services/Elastic.Changelog/Evaluation/PrepareArtifactArguments.cs b/src/services/Elastic.Changelog/Evaluation/PrepareArtifactArguments.cs new file mode 100644 index 0000000000..36348d37b4 --- /dev/null +++ b/src/services/Elastic.Changelog/Evaluation/PrepareArtifactArguments.cs @@ -0,0 +1,31 @@ +// 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.Evaluation; + +/// Arguments for the changelog prepare-artifact command. +public record PrepareArtifactArguments +{ + public required string StagingDir { get; init; } + public required string OutputDir { get; init; } + public required string EvaluateStatus { get; init; } + public required string GenerateOutcome { get; init; } + public required int PrNumber { get; init; } + public required string HeadRef { get; init; } + public required string HeadSha { get; init; } + public bool IsFork { get; init; } + public string? HeadRepo { get; init; } + public bool CanCommit { get; init; } + public bool MaintainerCanModify { get; init; } + public string? LabelTable { get; init; } + public string? ProductLabelTable { get; init; } + public string? SkipLabels { get; init; } + public string? Config { get; init; } + + /// + /// Filename of a previously committed changelog for this PR. + /// When set, the staging file is renamed to match so the same path is overwritten on the branch. + /// + public string? ExistingChangelogFilename { get; init; } +} diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index a47cd20ec8..66e200c0f3 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -48,7 +48,7 @@ IEnvironmentVariables environmentVariables [Command("")] public Task Default() { - collector.EmitError(string.Empty, "Please specify a subcommand. Available subcommands:\n - 'changelog add': Create a new changelog from command-line input\n - 'changelog bundle': Create a consolidated list of changelog files\n - 'changelog init': Initialize changelog configuration and folder structure\n - 'changelog render': Render a bundled changelog to markdown or asciidoc files\n - 'changelog upload': Upload changelog or bundle artifacts to S3 or Elasticsearch\n - 'changelog gh-release': Create changelogs from a GitHub release\n - 'changelog evaluate-pr': (CI) Evaluate a PR for changelog generation eligibility\n\nRun 'changelog --help' for usage information."); + collector.EmitError(string.Empty, "Please specify a subcommand. Available subcommands:\n - 'changelog add': Create a new changelog from command-line input\n - 'changelog bundle': Create a consolidated list of changelog files\n - 'changelog init': Initialize changelog configuration and folder structure\n - 'changelog render': Render a bundled changelog to markdown or asciidoc files\n - 'changelog upload': Upload changelog or bundle artifacts to S3 or Elasticsearch\n - 'changelog gh-release': Create changelogs from a GitHub release\n - 'changelog evaluate-pr': (CI) Evaluate a PR for changelog generation eligibility\n - 'changelog prepare-artifact': (CI) Package changelog artifact for cross-workflow transfer\n - 'changelog evaluate-artifact': (CI) Evaluate downloaded artifact in commit workflow\n\nRun 'changelog --help' for usage information."); return Task.FromResult(1); } @@ -1341,6 +1341,117 @@ async static (s, collector, state, ctx) => await s.EvaluatePr(collector, state, return await serviceInvoker.InvokeAsync(ctx); } + /// + /// (CI) Package changelog artifact for cross-workflow transfer. Resolves final status from + /// evaluate-pr + changelog add outcomes, copies generated YAML, writes metadata.json, and + /// sets GitHub Actions outputs. Always succeeds (exit 0) so the upload step runs. + /// + /// Directory where changelog add wrote the generated YAML + /// Directory to write the artifact (metadata.json + YAML) + /// Status output from the evaluate-pr step + /// Outcome of the changelog add step (success/failure) + /// Pull request number + /// PR head branch ref + /// PR head commit SHA + /// Whether the PR is from a fork + /// Whether the commit strategy allows committing + /// Whether the fork PR allows maintainer edits + /// Fork repository full name (owner/repo) + /// Optional: markdown label table from evaluate-pr + /// Optional: markdown product label table from evaluate-pr + /// Optional: comma-separated skip labels from evaluate-pr + /// Optional: path to changelog.yml + /// Optional: filename of a previously committed changelog for this PR + /// + [Command("prepare-artifact")] + public async Task PrepareArtifact( + string stagingDir, + string outputDir, + string evaluateStatus, + string generateOutcome, + int prNumber, + string headRef, + string headSha, + bool isFork = false, + bool canCommit = false, + bool maintainerCanModify = false, + string? headRepo = null, + string? labelTable = null, + string? productLabelTable = null, + string? skipLabels = null, + string? config = null, + string? existingChangelogFilename = null, + Cancel ctx = default + ) + { + await using var serviceInvoker = new ServiceInvoker(collector); + + var fs = FileSystemFactory.RealGitRootForPathWrite(null, outputDir); + var service = new ChangelogPrepareArtifactService(logFactory, configurationContext, githubActionsService, fs); + + var args = new PrepareArtifactArguments + { + StagingDir = stagingDir, + OutputDir = outputDir, + EvaluateStatus = evaluateStatus, + GenerateOutcome = generateOutcome, + PrNumber = prNumber, + HeadRef = headRef, + HeadSha = headSha, + IsFork = isFork, + HeadRepo = headRepo, + CanCommit = canCommit, + MaintainerCanModify = maintainerCanModify, + LabelTable = labelTable, + ProductLabelTable = productLabelTable, + SkipLabels = skipLabels, + Config = config, + ExistingChangelogFilename = existingChangelogFilename + }; + + serviceInvoker.AddCommand(service, args, + async static (s, collector, state, ctx) => await s.PrepareArtifact(collector, state, ctx) + ); + + return await serviceInvoker.InvokeAsync(ctx); + } + + /// + /// (CI) Evaluate downloaded artifact in the resolving workflow. Reads metadata, validates + /// PR state (SHA, labels), and sets GitHub Actions outputs for downstream steps (commit, comment). + /// + /// Path to the downloaded metadata.json file + /// GitHub repository owner + /// GitHub repository name + /// + [Command("evaluate-artifact")] + public async Task EvaluateArtifact( + string metadata, + string owner, + string repo, + Cancel ctx = default + ) + { + await using var serviceInvoker = new ServiceInvoker(collector); + + var fs = FileSystemFactory.RealGitRootForPathWrite(null, metadata); + IGitHubPrService prService = new GitHubPrService(logFactory); + var service = new ChangelogArtifactEvaluationService(logFactory, prService, githubActionsService, fs); + + var args = new EvaluateArtifactArguments + { + MetadataPath = metadata, + Owner = owner, + Repo = repo + }; + + serviceInvoker.AddCommand(service, args, + async static (s, collector, state, ctx) => await s.EvaluateArtifact(collector, state, ctx) + ); + + return await serviceInvoker.InvokeAsync(ctx); + } + /// /// Expands a CLI array parameter where each element may be comma-separated into a flat list of values. /// Filters out blank entries. diff --git a/tests/Elastic.Changelog.Tests/Evaluation/ChangelogArtifactEvaluationServiceTests.cs b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogArtifactEvaluationServiceTests.cs new file mode 100644 index 0000000000..0f2db608da --- /dev/null +++ b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogArtifactEvaluationServiceTests.cs @@ -0,0 +1,239 @@ +// 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 System.Text.Json; +using Actions.Core.Services; +using Elastic.Changelog.Evaluation; +using Elastic.Changelog.GitHub; +using Elastic.Changelog.Tests.Changelogs; +using Elastic.Documentation.Configuration.Changelog; +using Elastic.Documentation.ReleaseNotes; +using FakeItEasy; +using FluentAssertions; + +namespace Elastic.Changelog.Tests.Evaluation; + +public class ChangelogArtifactEvaluationServiceTests(ITestOutputHelper output) : ChangelogTestBase(output) +{ + private readonly IGitHubPrService _mockGitHub = A.Fake(); + private readonly ICoreService _mockCore = A.Fake(); + + private ChangelogArtifactEvaluationService CreateService() => + new(LoggerFactory, _mockGitHub, _mockCore, FileSystem); + + private static EvaluateArtifactArguments DefaultArgs() => + new() + { + MetadataPath = "/artifact/metadata.json", + Owner = "elastic", + Repo = "test-repo" + }; + + private async Task WriteMetadata(ChangelogArtifactMetadata metadata, string path = "/artifact/metadata.json") + { + var dir = FileSystem.Path.GetDirectoryName(path)!; + FileSystem.Directory.CreateDirectory(dir); + var json = JsonSerializer.Serialize(metadata, ChangelogArtifactMetadataJsonContext.Default.ChangelogArtifactMetadata); + await FileSystem.File.WriteAllTextAsync(path, json); + } + + private static ChangelogArtifactMetadata DefaultMetadata( + string status = "success", + string? changelogFilename = "42.yaml", + bool canCommit = true + ) => + new() + { + PrNumber = 42, + HeadRef = "feature/test", + HeadSha = "abc123", + Status = status, + IsFork = false, + CanCommit = canCommit, + MaintainerCanModify = false, + ConfigFile = "docs/changelog.yml", + ChangelogDir = "changelogs", + ChangelogFilename = changelogFilename, + CreateRules = new CreateRules { Labels = ["changelog:skip"], Mode = FieldMode.Exclude } + }; + + private void SetupPrInfo(string headSha = "abc123", bool isFork = false, string[]? labels = null) => + A.CallTo(() => _mockGitHub.FetchPrInfoAsync("42", "elastic", "test-repo", A._)) + .Returns(new GitHubPrInfo + { + Title = "Test PR", + HeadSha = headSha, + HeadRef = "feature/test", + IsFork = isFork, + Labels = labels ?? ["type:feature"] + }); + + private void VerifyOutputSet(string name, string value) => + A.CallTo(() => _mockCore.SetOutputAsync(name, value)).MustHaveHappened(); + + [Fact] + public async Task EvaluateArtifact_MissingMetadata_ReturnsTrue() + { + var service = CreateService(); + + var result = await service.EvaluateArtifact(Collector, DefaultArgs(), CancellationToken.None); + + result.Should().BeTrue(); + A.CallTo(() => _mockGitHub.FetchPrInfoAsync(A._, A._, A._, A._)) + .MustNotHaveHappened(); + } + + [Fact] + public async Task EvaluateArtifact_FetchPrFails_ReturnsFalse() + { + await WriteMetadata(DefaultMetadata()); + A.CallTo(() => _mockGitHub.FetchPrInfoAsync("42", "elastic", "test-repo", A._)) + .Returns((GitHubPrInfo?)null); + + var service = CreateService(); + var result = await service.EvaluateArtifact(Collector, DefaultArgs(), CancellationToken.None); + + result.Should().BeFalse(); + } + + [Fact] + public async Task EvaluateArtifact_HeadShaMoved_ReturnsTrueWithoutSettingFlags() + { + await WriteMetadata(DefaultMetadata()); + SetupPrInfo(headSha: "different-sha"); + + var service = CreateService(); + var result = await service.EvaluateArtifact(Collector, DefaultArgs(), CancellationToken.None); + + result.Should().BeTrue(); + A.CallTo(() => _mockCore.SetOutputAsync("should-commit", A._)).MustNotHaveHappened(); + } + + [Fact] + public async Task EvaluateArtifact_AllProductsBlocked_ReturnsTrueGracefully() + { + var metadata = DefaultMetadata() with + { + CreateRules = new CreateRules { Labels = ["changelog:skip"], Mode = FieldMode.Exclude } + }; + await WriteMetadata(metadata); + SetupPrInfo(labels: ["changelog:skip", "type:feature"]); + + var service = CreateService(); + var result = await service.EvaluateArtifact(Collector, DefaultArgs(), CancellationToken.None); + + result.Should().BeTrue(); + A.CallTo(() => _mockCore.SetOutputAsync("should-commit", A._)).MustNotHaveHappened(); + } + + [Fact] + public async Task EvaluateArtifact_SuccessCanCommit_SetsCommitFlag() + { + await WriteMetadata(DefaultMetadata(canCommit: true)); + SetupPrInfo(); + + var service = CreateService(); + var result = await service.EvaluateArtifact(Collector, DefaultArgs(), CancellationToken.None); + + result.Should().BeTrue(); + VerifyOutputSet("should-commit", "true"); + VerifyOutputSet("should-comment-success", "false"); + VerifyOutputSet("should-comment-failure", "false"); + VerifyOutputSet("pr-number", "42"); + VerifyOutputSet("head-ref", "feature/test"); + VerifyOutputSet("head-sha", "abc123"); + VerifyOutputSet("status", "success"); + VerifyOutputSet("changelog-filename", "42.yaml"); + } + + [Fact] + public async Task EvaluateArtifact_SuccessCannotCommit_SetsCommentSuccessFlag() + { + await WriteMetadata(DefaultMetadata(canCommit: false)); + SetupPrInfo(); + + var service = CreateService(); + var result = await service.EvaluateArtifact(Collector, DefaultArgs(), CancellationToken.None); + + result.Should().BeTrue(); + VerifyOutputSet("should-commit", "false"); + VerifyOutputSet("should-comment-success", "true"); + } + + [Fact] + public async Task EvaluateArtifact_ForkCanCommit_SetsCommitFlag() + { + var metadata = DefaultMetadata(canCommit: true) with + { + IsFork = true, + HeadRepo = "contributor/repo", + MaintainerCanModify = true + }; + await WriteMetadata(metadata); + SetupPrInfo(); + + var service = CreateService(); + var result = await service.EvaluateArtifact(Collector, DefaultArgs(), CancellationToken.None); + + result.Should().BeTrue(); + VerifyOutputSet("should-commit", "true"); + VerifyOutputSet("is-fork", "true"); + VerifyOutputSet("head-repo", "contributor/repo"); + } + + [Fact] + public async Task EvaluateArtifact_ForkCannotCommit_SetsCommentSuccessFlag() + { + var metadata = DefaultMetadata(canCommit: false) with + { + IsFork = true, + HeadRepo = "contributor/repo", + MaintainerCanModify = false + }; + await WriteMetadata(metadata); + SetupPrInfo(); + + var service = CreateService(); + var result = await service.EvaluateArtifact(Collector, DefaultArgs(), CancellationToken.None); + + result.Should().BeTrue(); + VerifyOutputSet("should-commit", "false"); + VerifyOutputSet("should-comment-success", "true"); + VerifyOutputSet("is-fork", "true"); + } + + [Fact] + public async Task EvaluateArtifact_NoLabel_SetsCommentFailureFlag() + { + await WriteMetadata(DefaultMetadata(status: "no-label", canCommit: false) with + { + LabelTable = "| Label | Type |", + ProductLabelTable = "| Label | Product |", + SkipLabels = "changelog:skip" + }); + SetupPrInfo(); + + var service = CreateService(); + var result = await service.EvaluateArtifact(Collector, DefaultArgs(), CancellationToken.None); + + result.Should().BeTrue(); + VerifyOutputSet("should-commit", "false"); + VerifyOutputSet("should-comment-failure", "true"); + VerifyOutputSet("label-table", "| Label | Type |"); + VerifyOutputSet("product-label-table", "| Label | Product |"); + VerifyOutputSet("skip-labels", "changelog:skip"); + } + + [Fact] + public async Task EvaluateArtifact_TimestampFilename_OutputsOriginalFilename() + { + await WriteMetadata(DefaultMetadata(changelogFilename: "1735689600-fix-search.yaml")); + SetupPrInfo(); + + var service = CreateService(); + await service.EvaluateArtifact(Collector, DefaultArgs(), CancellationToken.None); + + VerifyOutputSet("changelog-filename", "1735689600-fix-search.yaml"); + } +} diff --git a/tests/Elastic.Changelog.Tests/Evaluation/ChangelogArtifactMetadataTests.cs b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogArtifactMetadataTests.cs new file mode 100644 index 0000000000..b9eb395c5c --- /dev/null +++ b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogArtifactMetadataTests.cs @@ -0,0 +1,160 @@ +// 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 System.Text.Json; +using Elastic.Changelog.Evaluation; +using Elastic.Documentation.Configuration.Changelog; +using Elastic.Documentation.ReleaseNotes; +using FluentAssertions; + +namespace Elastic.Changelog.Tests.Evaluation; + +public class ChangelogArtifactMetadataTests +{ + [Fact] + public void SerializationRoundTrip_WithAllFields_PreservesValues() + { + var metadata = new ChangelogArtifactMetadata + { + PrNumber = 42, + HeadRef = "feature/test", + HeadSha = "abc123def456", + Status = "success", + IsFork = true, + CanCommit = true, + MaintainerCanModify = true, + HeadRepo = "contributor/repo", + LabelTable = "| label | type |\n| --- | --- |", + ProductLabelTable = "| label | product |\n| --- | --- |", + SkipLabels = "changelog:skip,skip-ci", + ConfigFile = "changelog.yml", + ChangelogDir = "changelogs", + CreateRules = new CreateRules + { + Labels = ["changelog:skip", "no-changelog"], + Mode = FieldMode.Exclude, + Match = MatchMode.Any, + ByProduct = new Dictionary + { + ["elasticsearch"] = new() + { + Labels = ["es:skip"], + Mode = FieldMode.Exclude, + Match = MatchMode.All + } + } + } + }; + + var json = JsonSerializer.Serialize(metadata, ChangelogArtifactMetadataJsonContext.Default.ChangelogArtifactMetadata); + var deserialized = JsonSerializer.Deserialize(json, ChangelogArtifactMetadataJsonContext.Default.ChangelogArtifactMetadata); + + deserialized.Should().NotBeNull(); + deserialized!.PrNumber.Should().Be(42); + deserialized.HeadRef.Should().Be("feature/test"); + deserialized.HeadSha.Should().Be("abc123def456"); + deserialized.Status.Should().Be("success"); + deserialized.IsFork.Should().BeTrue(); + deserialized.CanCommit.Should().BeTrue(); + deserialized.MaintainerCanModify.Should().BeTrue(); + deserialized.HeadRepo.Should().Be("contributor/repo"); + deserialized.LabelTable.Should().Be("| label | type |\n| --- | --- |"); + deserialized.ProductLabelTable.Should().Be("| label | product |\n| --- | --- |"); + deserialized.SkipLabels.Should().Be("changelog:skip,skip-ci"); + deserialized.ConfigFile.Should().Be("changelog.yml"); + deserialized.ChangelogDir.Should().Be("changelogs"); + deserialized.CreateRules.Should().NotBeNull(); + deserialized.CreateRules!.Labels.Should().BeEquivalentTo(["changelog:skip", "no-changelog"]); + deserialized.CreateRules.Mode.Should().Be(FieldMode.Exclude); + deserialized.CreateRules.Match.Should().Be(MatchMode.Any); + deserialized.CreateRules.ByProduct.Should().ContainKey("elasticsearch"); + } + + [Fact] + public void SerializationRoundTrip_WithNullOptionalFields_PreservesNulls() + { + var metadata = new ChangelogArtifactMetadata + { + PrNumber = 1, + HeadRef = "main", + HeadSha = "deadbeef", + Status = "no-label", + IsFork = false, + CanCommit = false, + MaintainerCanModify = false + }; + + var json = JsonSerializer.Serialize(metadata, ChangelogArtifactMetadataJsonContext.Default.ChangelogArtifactMetadata); + var deserialized = JsonSerializer.Deserialize(json, ChangelogArtifactMetadataJsonContext.Default.ChangelogArtifactMetadata); + + deserialized.Should().NotBeNull(); + deserialized!.PrNumber.Should().Be(1); + deserialized.IsFork.Should().BeFalse(); + deserialized.CanCommit.Should().BeFalse(); + deserialized.HeadRepo.Should().BeNull(); + deserialized.LabelTable.Should().BeNull(); + deserialized.ProductLabelTable.Should().BeNull(); + deserialized.SkipLabels.Should().BeNull(); + deserialized.ConfigFile.Should().BeNull(); + deserialized.ChangelogDir.Should().BeNull(); + deserialized.CreateRules.Should().BeNull(); + } + + [Fact] + public void Serialization_UsesSnakeCasePropertyNames() + { + var metadata = new ChangelogArtifactMetadata + { + PrNumber = 99, + HeadRef = "fix/bug", + HeadSha = "aabbcc", + Status = "skipped", + IsFork = true, + CanCommit = false, + MaintainerCanModify = true, + HeadRepo = "user/repo", + ChangelogDir = "changelogs" + }; + + var json = JsonSerializer.Serialize(metadata, ChangelogArtifactMetadataJsonContext.Default.ChangelogArtifactMetadata); + + json.Should().Contain("\"pr_number\""); + json.Should().Contain("\"head_ref\""); + json.Should().Contain("\"head_sha\""); + json.Should().Contain("\"is_fork\""); + json.Should().Contain("\"can_commit\""); + json.Should().Contain("\"maintainer_can_modify\""); + json.Should().Contain("\"head_repo\""); + json.Should().Contain("\"changelog_dir\""); + json.Should().NotContain("\"PrNumber\""); + json.Should().NotContain("\"IsFork\""); + json.Should().NotContain("\"CanCommit\""); + } + + [Fact] + public void Serialization_EnumsUseStringValues() + { + var metadata = new ChangelogArtifactMetadata + { + PrNumber = 1, + HeadRef = "main", + HeadSha = "abc", + Status = "success", + IsFork = false, + CanCommit = true, + MaintainerCanModify = false, + CreateRules = new CreateRules + { + Labels = ["skip"], + Mode = FieldMode.Include, + Match = MatchMode.All + } + }; + + var json = JsonSerializer.Serialize(metadata, ChangelogArtifactMetadataJsonContext.Default.ChangelogArtifactMetadata); + + json.Should().Contain("\"Include\""); + json.Should().Contain("\"All\""); + } +} diff --git a/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrepareArtifactServiceTests.cs b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrepareArtifactServiceTests.cs new file mode 100644 index 0000000000..b10375b719 --- /dev/null +++ b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrepareArtifactServiceTests.cs @@ -0,0 +1,251 @@ +// 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 System.Text.Json; +using Actions.Core.Services; +using Elastic.Changelog.Evaluation; +using Elastic.Changelog.Tests.Changelogs; +using Elastic.Documentation.ReleaseNotes; +using FakeItEasy; +using FluentAssertions; + +namespace Elastic.Changelog.Tests.Evaluation; + +public class ChangelogPrepareArtifactServiceTests(ITestOutputHelper output) : ChangelogTestBase(output) +{ + private readonly ICoreService _mockCore = A.Fake(); + + private const string MinimalConfig = """ + pivot: + types: + feature: "type:feature" + bug-fix: "type:bug" + breaking-change: "type:breaking" + enhancement: + deprecation: + docs: + known-issue: + other: + regression: + security: + rules: + create: + exclude: "changelog:skip" + """; + + private ChangelogPrepareArtifactService CreateService() => + new(LoggerFactory, ConfigurationContext, _mockCore, FileSystem); + + private PrepareArtifactArguments DefaultArgs( + string evaluateStatus = "proceed", + string generateOutcome = "success", + string? config = null + ) => + new() + { + StagingDir = "/staging", + OutputDir = "/output", + EvaluateStatus = evaluateStatus, + GenerateOutcome = generateOutcome, + PrNumber = 42, + HeadRef = "feature/test", + HeadSha = "abc123", + LabelTable = null, + Config = config ?? "/config/changelog.yml" + }; + + private async Task SetupStagingYaml(string? filename = null) + { + FileSystem.Directory.CreateDirectory("/staging"); + filename ??= "42.yaml"; + await FileSystem.File.WriteAllTextAsync($"/staging/{filename}", "title: test changelog"); + } + + private async Task SetupConfig(string configPath = "/config/changelog.yml") + { + var dir = FileSystem.Path.GetDirectoryName(configPath)!; + FileSystem.Directory.CreateDirectory(dir); + await FileSystem.File.WriteAllTextAsync(configPath, MinimalConfig); + } + + private ChangelogArtifactMetadata ReadMetadata() + { + var json = FileSystem.File.ReadAllText("/output/metadata.json"); + return JsonSerializer.Deserialize(json, ChangelogArtifactMetadataJsonContext.Default.ChangelogArtifactMetadata)!; + } + + [Fact] + public async Task PrepareArtifact_GenerateSuccess_CopiesYamlAndWritesMetadata() + { + await SetupStagingYaml(); + await SetupConfig(); + var service = CreateService(); + + var result = await service.PrepareArtifact(Collector, DefaultArgs(), CancellationToken.None); + + result.Should().BeTrue(); + FileSystem.File.Exists("/output/42.yaml").Should().BeTrue(); + var metadata = ReadMetadata(); + metadata.Status.Should().Be("success"); + metadata.PrNumber.Should().Be(42); + metadata.HeadRef.Should().Be("feature/test"); + metadata.HeadSha.Should().Be("abc123"); + metadata.ChangelogFilename.Should().Be("42.yaml"); + A.CallTo(() => _mockCore.SetOutputAsync("status", "success")).MustHaveHappened(); + } + + [Fact] + public async Task PrepareArtifact_ForkFields_PersistedInMetadata() + { + await SetupStagingYaml(); + await SetupConfig(); + var service = CreateService(); + var args = DefaultArgs() with + { + IsFork = true, + HeadRepo = "contributor/repo", + CanCommit = true, + MaintainerCanModify = true + }; + + await service.PrepareArtifact(Collector, args, CancellationToken.None); + + var metadata = ReadMetadata(); + metadata.IsFork.Should().BeTrue(); + metadata.HeadRepo.Should().Be("contributor/repo"); + metadata.CanCommit.Should().BeTrue(); + metadata.MaintainerCanModify.Should().BeTrue(); + } + + [Fact] + public async Task PrepareArtifact_ForkNoMaintainerEdits_CanCommitFalse() + { + await SetupStagingYaml(); + await SetupConfig(); + var service = CreateService(); + var args = DefaultArgs() with + { + IsFork = true, + HeadRepo = "contributor/repo", + CanCommit = false, + MaintainerCanModify = false + }; + + await service.PrepareArtifact(Collector, args, CancellationToken.None); + + var metadata = ReadMetadata(); + metadata.IsFork.Should().BeTrue(); + metadata.CanCommit.Should().BeFalse(); + metadata.MaintainerCanModify.Should().BeFalse(); + } + + [Fact] + public async Task PrepareArtifact_ProductLabelTableAndSkipLabels_PersistedInMetadata() + { + await SetupStagingYaml(); + await SetupConfig(); + var service = CreateService(); + var args = DefaultArgs() with + { + ProductLabelTable = "| Label | Product |\n| --- | --- |", + SkipLabels = "changelog:skip,skip-ci" + }; + + await service.PrepareArtifact(Collector, args, CancellationToken.None); + + var metadata = ReadMetadata(); + metadata.ProductLabelTable.Should().Contain("Product"); + metadata.SkipLabels.Should().Be("changelog:skip,skip-ci"); + } + + [Fact] + public async Task PrepareArtifact_GenerateFailure_StatusError() + { + await SetupConfig(); + var service = CreateService(); + var args = DefaultArgs(evaluateStatus: "proceed", generateOutcome: "failure"); + + var result = await service.PrepareArtifact(Collector, args, CancellationToken.None); + + result.Should().BeTrue(); + FileSystem.File.Exists("/output/42.yaml").Should().BeFalse(); + var metadata = ReadMetadata(); + metadata.Status.Should().Be("error"); + } + + [Fact] + public async Task PrepareArtifact_NoLabel_WritesMetadataWithoutYaml() + { + await SetupConfig(); + var service = CreateService(); + var args = DefaultArgs(evaluateStatus: "no-label") with { LabelTable = "| Label | Type |\n| --- | --- |" }; + + var result = await service.PrepareArtifact(Collector, args, CancellationToken.None); + + result.Should().BeTrue(); + FileSystem.File.Exists("/output/42.yaml").Should().BeFalse(); + var metadata = ReadMetadata(); + metadata.Status.Should().Be("no-label"); + metadata.LabelTable.Should().Contain("Label"); + metadata.ChangelogFilename.Should().BeNull(); + } + + [Fact] + public async Task PrepareArtifact_MetadataContainsCreateRules() + { + await SetupStagingYaml(); + await SetupConfig(); + var service = CreateService(); + + await service.PrepareArtifact(Collector, DefaultArgs(), CancellationToken.None); + + var metadata = ReadMetadata(); + metadata.CreateRules.Should().NotBeNull(); + metadata.CreateRules!.Labels.Should().Contain("changelog:skip"); + metadata.CreateRules.Mode.Should().Be(FieldMode.Exclude); + } + + [Fact] + public async Task PrepareArtifact_ExistingFilename_RenamesStagingFileToMatch() + { + await SetupStagingYaml("1735700000-new-title.yaml"); + await SetupConfig(); + var service = CreateService(); + var args = DefaultArgs() with { ExistingChangelogFilename = "1735689600-old-title.yaml" }; + + var result = await service.PrepareArtifact(Collector, args, CancellationToken.None); + + result.Should().BeTrue(); + FileSystem.File.Exists("/output/1735689600-old-title.yaml").Should().BeTrue(); + FileSystem.File.Exists("/output/1735700000-new-title.yaml").Should().BeFalse(); + var metadata = ReadMetadata(); + metadata.ChangelogFilename.Should().Be("1735689600-old-title.yaml"); + } + + [Fact] + public async Task PrepareArtifact_MissingStagingYaml_StatusError() + { + await SetupConfig(); + var service = CreateService(); + + await service.PrepareArtifact(Collector, DefaultArgs(), CancellationToken.None); + + var metadata = ReadMetadata(); + metadata.Status.Should().Be("error"); + } + + [Theory] + [InlineData("proceed", "success", PrEvaluationResult.Success)] + [InlineData("proceed", "failure", PrEvaluationResult.Error)] + [InlineData("no-label", "success", PrEvaluationResult.NoLabel)] + [InlineData("no-title", "success", PrEvaluationResult.NoTitle)] + [InlineData("skipped", "success", PrEvaluationResult.Skipped)] + [InlineData("manually-edited", "success", PrEvaluationResult.ManuallyEdited)] + [InlineData("unknown", "success", PrEvaluationResult.Error)] + public void ResolveStatus_ReturnsExpected(string evaluateStatus, string generateOutcome, PrEvaluationResult expected) + { + var result = ChangelogPrepareArtifactService.ResolveStatus(evaluateStatus, generateOutcome); + result.Should().Be(expected); + } +} From 7ee73eaca1c489a909b22d3afecf5873da54552c Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Tue, 28 Apr 2026 11:42:15 -0300 Subject: [PATCH 2/6] Fix scrubber --- src/services/Elastic.Changelog/GitHub/GitHubPrService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/Elastic.Changelog/GitHub/GitHubPrService.cs b/src/services/Elastic.Changelog/GitHub/GitHubPrService.cs index 1820eea174..9a537742f0 100644 --- a/src/services/Elastic.Changelog/GitHub/GitHubPrService.cs +++ b/src/services/Elastic.Changelog/GitHub/GitHubPrService.cs @@ -446,6 +446,6 @@ private sealed class GitHubCommitListItem [JsonSerializable(typeof(List))] private sealed partial class GitHubPrJsonContext : JsonSerializerContext; - [GeneratedRegex(@"https://github\.com/([a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+)/pull/(\d+)", RegexOptions.IgnoreCase, "en-CA")] + [GeneratedRegex(@"https://github\.com/([a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+)/pull/(\d+)", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] private static partial Regex MyRegex(); } From cd41cba42036295bebac5dd578974b25d1e3300a Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Tue, 28 Apr 2026 15:09:03 -0300 Subject: [PATCH 3/6] Fix build --- .../ChangelogArtifactEvaluationService.cs | 18 ++++++++-- .../ChangelogPrepareArtifactService.cs | 10 ++++-- ...ChangelogArtifactEvaluationServiceTests.cs | 11 ++++-- .../ChangelogArtifactMetadataTests.cs | 8 ++--- .../ChangelogPrepareArtifactServiceTests.cs | 35 +++++++++++-------- 5 files changed, 56 insertions(+), 26 deletions(-) diff --git a/src/services/Elastic.Changelog/Evaluation/ChangelogArtifactEvaluationService.cs b/src/services/Elastic.Changelog/Evaluation/ChangelogArtifactEvaluationService.cs index 6c48f7b4b3..3040991f38 100644 --- a/src/services/Elastic.Changelog/Evaluation/ChangelogArtifactEvaluationService.cs +++ b/src/services/Elastic.Changelog/Evaluation/ChangelogArtifactEvaluationService.cs @@ -33,8 +33,22 @@ public async Task EvaluateArtifact(IDiagnosticsCollector collector, Evalua return true; } - var artifactMetadataJson = await _fileSystem.File.ReadAllTextAsync(input.MetadataPath, ctx); - var metadata = JsonSerializer.Deserialize(artifactMetadataJson, ChangelogArtifactMetadataJsonContext.Default.ChangelogArtifactMetadata); + ChangelogArtifactMetadata? metadata; + try + { + var artifactMetadataJson = await _fileSystem.File.ReadAllTextAsync(input.MetadataPath, ctx); + metadata = JsonSerializer.Deserialize(artifactMetadataJson, ChangelogArtifactMetadataJsonContext.Default.ChangelogArtifactMetadata); + } + catch (IOException ex) + { + collector.EmitError(input.MetadataPath, $"Failed to read artifact metadata: {ex.Message}"); + return false; + } + catch (JsonException ex) + { + collector.EmitError(input.MetadataPath, $"Failed to deserialize artifact metadata: {ex.Message}"); + return false; + } if (metadata is null) { collector.EmitError(input.MetadataPath, "Failed to deserialize artifact metadata"); diff --git a/src/services/Elastic.Changelog/Evaluation/ChangelogPrepareArtifactService.cs b/src/services/Elastic.Changelog/Evaluation/ChangelogPrepareArtifactService.cs index 4a26b7630c..cf0305b29f 100644 --- a/src/services/Elastic.Changelog/Evaluation/ChangelogPrepareArtifactService.cs +++ b/src/services/Elastic.Changelog/Evaluation/ChangelogPrepareArtifactService.cs @@ -40,8 +40,9 @@ public async Task PrepareArtifact(IDiagnosticsCollector collector, Prepare if (sourceYaml != null) { - changelogFilename = input.ExistingChangelogFilename - ?? _fileSystem.Path.GetFileName(sourceYaml); + changelogFilename = input.ExistingChangelogFilename != null + ? _fileSystem.Path.GetFileName(input.ExistingChangelogFilename) + : _fileSystem.Path.GetFileName(sourceYaml); if (input.ExistingChangelogFilename != null) _logger.LogInformation("Reusing existing filename {Filename} for stable path on branch", changelogFilename); @@ -101,7 +102,10 @@ public async Task PrepareArtifact(IDiagnosticsCollector collector, Prepare return null; if (yamlFiles.Length > 1) - _logger.LogWarning("Multiple YAML files found in staging directory, using first: {File}", yamlFiles[0]); + { + _logger.LogError("Multiple YAML files found in staging directory: {Files}", string.Join(", ", yamlFiles)); + return null; + } return yamlFiles[0]; } diff --git a/tests/Elastic.Changelog.Tests/Evaluation/ChangelogArtifactEvaluationServiceTests.cs b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogArtifactEvaluationServiceTests.cs index 0f2db608da..f138936f74 100644 --- a/tests/Elastic.Changelog.Tests/Evaluation/ChangelogArtifactEvaluationServiceTests.cs +++ b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogArtifactEvaluationServiceTests.cs @@ -7,10 +7,11 @@ using Elastic.Changelog.Evaluation; using Elastic.Changelog.GitHub; using Elastic.Changelog.Tests.Changelogs; +using Elastic.Documentation.Configuration; using Elastic.Documentation.Configuration.Changelog; using Elastic.Documentation.ReleaseNotes; using FakeItEasy; -using FluentAssertions; +using AwesomeAssertions; namespace Elastic.Changelog.Tests.Evaluation; @@ -19,19 +20,23 @@ public class ChangelogArtifactEvaluationServiceTests(ITestOutputHelper output) : private readonly IGitHubPrService _mockGitHub = A.Fake(); private readonly ICoreService _mockCore = A.Fake(); + private static readonly string Root = Paths.WorkingDirectoryRoot.FullName; + private static readonly string MetadataFilePath = Path.Join(Root, "artifact/metadata.json"); + private ChangelogArtifactEvaluationService CreateService() => new(LoggerFactory, _mockGitHub, _mockCore, FileSystem); private static EvaluateArtifactArguments DefaultArgs() => new() { - MetadataPath = "/artifact/metadata.json", + MetadataPath = MetadataFilePath, Owner = "elastic", Repo = "test-repo" }; - private async Task WriteMetadata(ChangelogArtifactMetadata metadata, string path = "/artifact/metadata.json") + private async Task WriteMetadata(ChangelogArtifactMetadata metadata, string? path = null) { + path ??= MetadataFilePath; var dir = FileSystem.Path.GetDirectoryName(path)!; FileSystem.Directory.CreateDirectory(dir); var json = JsonSerializer.Serialize(metadata, ChangelogArtifactMetadataJsonContext.Default.ChangelogArtifactMetadata); diff --git a/tests/Elastic.Changelog.Tests/Evaluation/ChangelogArtifactMetadataTests.cs b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogArtifactMetadataTests.cs index b9eb395c5c..421b459f84 100644 --- a/tests/Elastic.Changelog.Tests/Evaluation/ChangelogArtifactMetadataTests.cs +++ b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogArtifactMetadataTests.cs @@ -6,7 +6,7 @@ using Elastic.Changelog.Evaluation; using Elastic.Documentation.Configuration.Changelog; using Elastic.Documentation.ReleaseNotes; -using FluentAssertions; +using AwesomeAssertions; namespace Elastic.Changelog.Tests.Evaluation; @@ -51,7 +51,7 @@ public void SerializationRoundTrip_WithAllFields_PreservesValues() var deserialized = JsonSerializer.Deserialize(json, ChangelogArtifactMetadataJsonContext.Default.ChangelogArtifactMetadata); deserialized.Should().NotBeNull(); - deserialized!.PrNumber.Should().Be(42); + deserialized.PrNumber.Should().Be(42); deserialized.HeadRef.Should().Be("feature/test"); deserialized.HeadSha.Should().Be("abc123def456"); deserialized.Status.Should().Be("success"); @@ -65,7 +65,7 @@ public void SerializationRoundTrip_WithAllFields_PreservesValues() deserialized.ConfigFile.Should().Be("changelog.yml"); deserialized.ChangelogDir.Should().Be("changelogs"); deserialized.CreateRules.Should().NotBeNull(); - deserialized.CreateRules!.Labels.Should().BeEquivalentTo(["changelog:skip", "no-changelog"]); + deserialized.CreateRules.Labels.Should().BeEquivalentTo(["changelog:skip", "no-changelog"]); deserialized.CreateRules.Mode.Should().Be(FieldMode.Exclude); deserialized.CreateRules.Match.Should().Be(MatchMode.Any); deserialized.CreateRules.ByProduct.Should().ContainKey("elasticsearch"); @@ -89,7 +89,7 @@ public void SerializationRoundTrip_WithNullOptionalFields_PreservesNulls() var deserialized = JsonSerializer.Deserialize(json, ChangelogArtifactMetadataJsonContext.Default.ChangelogArtifactMetadata); deserialized.Should().NotBeNull(); - deserialized!.PrNumber.Should().Be(1); + deserialized.PrNumber.Should().Be(1); deserialized.IsFork.Should().BeFalse(); deserialized.CanCommit.Should().BeFalse(); deserialized.HeadRepo.Should().BeNull(); diff --git a/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrepareArtifactServiceTests.cs b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrepareArtifactServiceTests.cs index b10375b719..23ec00ba79 100644 --- a/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrepareArtifactServiceTests.cs +++ b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrepareArtifactServiceTests.cs @@ -4,11 +4,12 @@ using System.Text.Json; using Actions.Core.Services; +using AwesomeAssertions; using Elastic.Changelog.Evaluation; using Elastic.Changelog.Tests.Changelogs; +using Elastic.Documentation.Configuration; using Elastic.Documentation.ReleaseNotes; using FakeItEasy; -using FluentAssertions; namespace Elastic.Changelog.Tests.Evaluation; @@ -16,6 +17,11 @@ public class ChangelogPrepareArtifactServiceTests(ITestOutputHelper output) : Ch { private readonly ICoreService _mockCore = A.Fake(); + private static readonly string Root = Paths.WorkingDirectoryRoot.FullName; + private static readonly string StagingDir = Path.Join(Root, "staging"); + private static readonly string OutputDir = Path.Join(Root, "output"); + private static readonly string ConfigPath = Path.Join(Root, "config/changelog.yml"); + private const string MinimalConfig = """ pivot: types: @@ -44,26 +50,27 @@ private PrepareArtifactArguments DefaultArgs( ) => new() { - StagingDir = "/staging", - OutputDir = "/output", + StagingDir = StagingDir, + OutputDir = OutputDir, EvaluateStatus = evaluateStatus, GenerateOutcome = generateOutcome, PrNumber = 42, HeadRef = "feature/test", HeadSha = "abc123", LabelTable = null, - Config = config ?? "/config/changelog.yml" + Config = config ?? ConfigPath }; private async Task SetupStagingYaml(string? filename = null) { - FileSystem.Directory.CreateDirectory("/staging"); + FileSystem.Directory.CreateDirectory(StagingDir); filename ??= "42.yaml"; - await FileSystem.File.WriteAllTextAsync($"/staging/{filename}", "title: test changelog"); + await FileSystem.File.WriteAllTextAsync(Path.Join(StagingDir, filename), "title: test changelog"); } - private async Task SetupConfig(string configPath = "/config/changelog.yml") + private async Task SetupConfig(string? configPath = null) { + configPath ??= ConfigPath; var dir = FileSystem.Path.GetDirectoryName(configPath)!; FileSystem.Directory.CreateDirectory(dir); await FileSystem.File.WriteAllTextAsync(configPath, MinimalConfig); @@ -71,7 +78,7 @@ private async Task SetupConfig(string configPath = "/config/changelog.yml") private ChangelogArtifactMetadata ReadMetadata() { - var json = FileSystem.File.ReadAllText("/output/metadata.json"); + var json = FileSystem.File.ReadAllText(Path.Join(OutputDir, "metadata.json")); return JsonSerializer.Deserialize(json, ChangelogArtifactMetadataJsonContext.Default.ChangelogArtifactMetadata)!; } @@ -85,7 +92,7 @@ public async Task PrepareArtifact_GenerateSuccess_CopiesYamlAndWritesMetadata() var result = await service.PrepareArtifact(Collector, DefaultArgs(), CancellationToken.None); result.Should().BeTrue(); - FileSystem.File.Exists("/output/42.yaml").Should().BeTrue(); + FileSystem.File.Exists(Path.Join(OutputDir, "42.yaml")).Should().BeTrue(); var metadata = ReadMetadata(); metadata.Status.Should().Be("success"); metadata.PrNumber.Should().Be(42); @@ -169,7 +176,7 @@ public async Task PrepareArtifact_GenerateFailure_StatusError() var result = await service.PrepareArtifact(Collector, args, CancellationToken.None); result.Should().BeTrue(); - FileSystem.File.Exists("/output/42.yaml").Should().BeFalse(); + FileSystem.File.Exists(Path.Join(OutputDir, "42.yaml")).Should().BeFalse(); var metadata = ReadMetadata(); metadata.Status.Should().Be("error"); } @@ -184,7 +191,7 @@ public async Task PrepareArtifact_NoLabel_WritesMetadataWithoutYaml() var result = await service.PrepareArtifact(Collector, args, CancellationToken.None); result.Should().BeTrue(); - FileSystem.File.Exists("/output/42.yaml").Should().BeFalse(); + FileSystem.File.Exists(Path.Join(OutputDir, "42.yaml")).Should().BeFalse(); var metadata = ReadMetadata(); metadata.Status.Should().Be("no-label"); metadata.LabelTable.Should().Contain("Label"); @@ -202,7 +209,7 @@ public async Task PrepareArtifact_MetadataContainsCreateRules() var metadata = ReadMetadata(); metadata.CreateRules.Should().NotBeNull(); - metadata.CreateRules!.Labels.Should().Contain("changelog:skip"); + metadata.CreateRules.Labels.Should().Contain("changelog:skip"); metadata.CreateRules.Mode.Should().Be(FieldMode.Exclude); } @@ -217,8 +224,8 @@ public async Task PrepareArtifact_ExistingFilename_RenamesStagingFileToMatch() var result = await service.PrepareArtifact(Collector, args, CancellationToken.None); result.Should().BeTrue(); - FileSystem.File.Exists("/output/1735689600-old-title.yaml").Should().BeTrue(); - FileSystem.File.Exists("/output/1735700000-new-title.yaml").Should().BeFalse(); + FileSystem.File.Exists(Path.Join(OutputDir, "1735689600-old-title.yaml")).Should().BeTrue(); + FileSystem.File.Exists(Path.Join(OutputDir, "1735700000-new-title.yaml")).Should().BeFalse(); var metadata = ReadMetadata(); metadata.ChangelogFilename.Should().Be("1735689600-old-title.yaml"); } From a584d49f0638cb1ed6d41352a8dccd589b7020bc Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Tue, 28 Apr 2026 15:13:53 -0300 Subject: [PATCH 4/6] using ordering --- .../Evaluation/ChangelogArtifactEvaluationServiceTests.cs | 2 +- .../Evaluation/ChangelogArtifactMetadataTests.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Elastic.Changelog.Tests/Evaluation/ChangelogArtifactEvaluationServiceTests.cs b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogArtifactEvaluationServiceTests.cs index f138936f74..7d662e4c8c 100644 --- a/tests/Elastic.Changelog.Tests/Evaluation/ChangelogArtifactEvaluationServiceTests.cs +++ b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogArtifactEvaluationServiceTests.cs @@ -4,6 +4,7 @@ using System.Text.Json; using Actions.Core.Services; +using AwesomeAssertions; using Elastic.Changelog.Evaluation; using Elastic.Changelog.GitHub; using Elastic.Changelog.Tests.Changelogs; @@ -11,7 +12,6 @@ using Elastic.Documentation.Configuration.Changelog; using Elastic.Documentation.ReleaseNotes; using FakeItEasy; -using AwesomeAssertions; namespace Elastic.Changelog.Tests.Evaluation; diff --git a/tests/Elastic.Changelog.Tests/Evaluation/ChangelogArtifactMetadataTests.cs b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogArtifactMetadataTests.cs index 421b459f84..fe17f8817f 100644 --- a/tests/Elastic.Changelog.Tests/Evaluation/ChangelogArtifactMetadataTests.cs +++ b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogArtifactMetadataTests.cs @@ -3,10 +3,10 @@ // See the LICENSE file in the project root for more information using System.Text.Json; +using AwesomeAssertions; using Elastic.Changelog.Evaluation; using Elastic.Documentation.Configuration.Changelog; using Elastic.Documentation.ReleaseNotes; -using AwesomeAssertions; namespace Elastic.Changelog.Tests.Evaluation; From 90b2d4edc362c32d50b7362d759470f40b5d14ba Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Tue, 28 Apr 2026 15:23:12 -0300 Subject: [PATCH 5/6] Improve guarding at EvaluateArtifact --- .../ChangelogArtifactEvaluationService.cs | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/services/Elastic.Changelog/Evaluation/ChangelogArtifactEvaluationService.cs b/src/services/Elastic.Changelog/Evaluation/ChangelogArtifactEvaluationService.cs index 3040991f38..0f57cb0032 100644 --- a/src/services/Elastic.Changelog/Evaluation/ChangelogArtifactEvaluationService.cs +++ b/src/services/Elastic.Changelog/Evaluation/ChangelogArtifactEvaluationService.cs @@ -27,18 +27,22 @@ public class ChangelogArtifactEvaluationService( public async Task EvaluateArtifact(IDiagnosticsCollector collector, EvaluateArtifactArguments input, Cancel ctx) { - if (!_fileSystem.File.Exists(input.MetadataPath)) - { - _logger.LogInformation("Metadata file not found at {Path}, nothing to evaluate", input.MetadataPath); - return true; - } - ChangelogArtifactMetadata? metadata; try { var artifactMetadataJson = await _fileSystem.File.ReadAllTextAsync(input.MetadataPath, ctx); metadata = JsonSerializer.Deserialize(artifactMetadataJson, ChangelogArtifactMetadataJsonContext.Default.ChangelogArtifactMetadata); } + catch (FileNotFoundException) + { + _logger.LogInformation("Metadata file not found at {Path}, nothing to evaluate", input.MetadataPath); + return true; + } + catch (DirectoryNotFoundException) + { + _logger.LogInformation("Metadata file not found at {Path}, nothing to evaluate", input.MetadataPath); + return true; + } catch (IOException ex) { collector.EmitError(input.MetadataPath, $"Failed to read artifact metadata: {ex.Message}"); From 906ab18917eead0c657d4f21c7c40bdddc5da2d4 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Tue, 28 Apr 2026 20:09:47 +0200 Subject: [PATCH 6/6] Enable docs-preview-local S3 uploads (#3198) --- .github/workflows/docs-preview-local.yml | 11 +++-------- docs/index.md | 1 + 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/.github/workflows/docs-preview-local.yml b/.github/workflows/docs-preview-local.yml index 7830f04578..5eb51bf3a1 100644 --- a/.github/workflows/docs-preview-local.yml +++ b/.github/workflows/docs-preview-local.yml @@ -258,11 +258,9 @@ jobs: - name: Generate env.PATH_PREFIX id: generate-path-prefix - # disabled: preview path prefix is not needed on this branch if: > - false - && env.MATCH == 'true' - && steps.deployment.outputs.result + env.MATCH == 'true' + && needs.check.outputs.any_modified != 'false' env: PR_NUMBER: ${{ github.event.pull_request.number }} GITHUB_REF_NAME: ${{ github.ref_name }} @@ -336,13 +334,10 @@ jobs: - name: Upload to S3 id: s3-upload - # disabled: preview uploads are not enabled on this branch if: > - false - && env.MATCH == 'true' + env.MATCH == 'true' && !cancelled() && steps.internal-docs-build.outputs.skip != 'true' - && steps.deployment.outputs.result && steps.internal-docs-build.outcome == 'success' env: AWS_RETRY_MODE: standard diff --git a/docs/index.md b/docs/index.md index acbc29ef51..22927502ff 100644 --- a/docs/index.md +++ b/docs/index.md @@ -11,5 +11,6 @@ Elastic Docs V3 is our next-generation documentation platform designed to improv * [Contribute to Elastic documentation](https://www.elastic.co/docs/contribute-docs/) * [Learn about V3 syntax](./syntax/index.md) * [Configure content sets in V3](./configure/index.md) +* [Explore CLI commands](./cli/index.md) * [Contribute to V3 (developer guide)](./development/index.md) * [Learn about migration to Elastic Docs V3](./migration/index.md)