Skip to content

Commit 87afe0c

Browse files
cottiMpdreamz
andauthored
Changelogs: Perform artifact handling on docs-builder (#3199)
* Reintroduce artifact handling commands * Fix scrubber * Fix build * using ordering * Improve guarding at EvaluateArtifact * Enable docs-preview-local S3 uploads (#3198) --------- Co-authored-by: Martijn Laarman <Mpdreamz@gmail.com>
1 parent dd6d8db commit 87afe0c

10 files changed

Lines changed: 1099 additions & 2 deletions
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
// Licensed to Elasticsearch B.V under one or more agreements.
2+
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
3+
// See the LICENSE file in the project root for more information
4+
5+
using System.Globalization;
6+
using System.IO.Abstractions;
7+
using System.Text.Json;
8+
using Actions.Core.Services;
9+
using Elastic.Changelog.Creation;
10+
using Elastic.Changelog.GitHub;
11+
using Elastic.Documentation.Diagnostics;
12+
using Elastic.Documentation.Services;
13+
using Microsoft.Extensions.Logging;
14+
15+
namespace Elastic.Changelog.Evaluation;
16+
17+
/// <summary>Service implementing the changelog evaluate-artifact CI command.</summary>
18+
public class ChangelogArtifactEvaluationService(
19+
ILoggerFactory logFactory,
20+
IGitHubPrService gitHubPrService,
21+
ICoreService coreService,
22+
IFileSystem? fileSystem = null
23+
) : IService
24+
{
25+
private readonly ILogger _logger = logFactory.CreateLogger<ChangelogArtifactEvaluationService>();
26+
private readonly IFileSystem _fileSystem = fileSystem ?? new FileSystem();
27+
28+
public async Task<bool> EvaluateArtifact(IDiagnosticsCollector collector, EvaluateArtifactArguments input, Cancel ctx)
29+
{
30+
ChangelogArtifactMetadata? metadata;
31+
try
32+
{
33+
var artifactMetadataJson = await _fileSystem.File.ReadAllTextAsync(input.MetadataPath, ctx);
34+
metadata = JsonSerializer.Deserialize(artifactMetadataJson, ChangelogArtifactMetadataJsonContext.Default.ChangelogArtifactMetadata);
35+
}
36+
catch (FileNotFoundException)
37+
{
38+
_logger.LogInformation("Metadata file not found at {Path}, nothing to evaluate", input.MetadataPath);
39+
return true;
40+
}
41+
catch (DirectoryNotFoundException)
42+
{
43+
_logger.LogInformation("Metadata file not found at {Path}, nothing to evaluate", input.MetadataPath);
44+
return true;
45+
}
46+
catch (IOException ex)
47+
{
48+
collector.EmitError(input.MetadataPath, $"Failed to read artifact metadata: {ex.Message}");
49+
return false;
50+
}
51+
catch (JsonException ex)
52+
{
53+
collector.EmitError(input.MetadataPath, $"Failed to deserialize artifact metadata: {ex.Message}");
54+
return false;
55+
}
56+
if (metadata is null)
57+
{
58+
collector.EmitError(input.MetadataPath, "Failed to deserialize artifact metadata");
59+
return false;
60+
}
61+
62+
var prInfo = await gitHubPrService.FetchPrInfoAsync(
63+
metadata.PrNumber.ToString(CultureInfo.InvariantCulture), input.Owner, input.Repo, ctx
64+
);
65+
if (prInfo is null)
66+
{
67+
collector.EmitError(input.MetadataPath, $"Failed to fetch PR #{metadata.PrNumber} from GitHub");
68+
return false;
69+
}
70+
71+
if (!string.Equals(prInfo.HeadSha, metadata.HeadSha, StringComparison.OrdinalIgnoreCase))
72+
{
73+
_logger.LogInformation("PR head has moved ({OldSha} → {NewSha}), skipping — newer run will handle it",
74+
metadata.HeadSha, prInfo.HeadSha);
75+
return true;
76+
}
77+
78+
if (PrInfoProcessor.AreAllProductsBlocked(prInfo.Labels.ToArray(), metadata.CreateRules))
79+
{
80+
_logger.LogInformation("Labels changed since generate, all products blocked — aborting gracefully");
81+
return true;
82+
}
83+
84+
var statusParsed = PrEvaluationResultExtensions.TryParse(
85+
metadata.Status, out var metadataStatus, ignoreCase: true, allowMatchingMetadataAttribute: true
86+
);
87+
88+
var shouldCommit = statusParsed && metadataStatus == PrEvaluationResult.Success && metadata.CanCommit;
89+
var shouldCommentSuccess = statusParsed && metadataStatus == PrEvaluationResult.Success && !metadata.CanCommit;
90+
var shouldCommentFailure = statusParsed && metadataStatus == PrEvaluationResult.NoLabel;
91+
92+
await coreService.SetOutputAsync("pr-number", metadata.PrNumber.ToString(CultureInfo.InvariantCulture));
93+
await coreService.SetOutputAsync("head-ref", metadata.HeadRef);
94+
await coreService.SetOutputAsync("head-sha", metadata.HeadSha);
95+
await coreService.SetOutputAsync("status", metadata.Status);
96+
await coreService.SetOutputAsync("is-fork", metadata.IsFork ? "true" : "false");
97+
await coreService.SetOutputAsync("head-repo", metadata.HeadRepo ?? string.Empty);
98+
await coreService.SetOutputAsync("config-file", metadata.ConfigFile ?? string.Empty);
99+
await coreService.SetOutputAsync("changelog-dir", metadata.ChangelogDir ?? string.Empty);
100+
await coreService.SetOutputAsync("changelog-filename", metadata.ChangelogFilename ?? string.Empty);
101+
await coreService.SetOutputAsync("label-table", metadata.LabelTable ?? string.Empty);
102+
await coreService.SetOutputAsync("product-label-table", metadata.ProductLabelTable ?? string.Empty);
103+
await coreService.SetOutputAsync("skip-labels", metadata.SkipLabels ?? string.Empty);
104+
await coreService.SetOutputAsync("should-commit", shouldCommit ? "true" : "false");
105+
await coreService.SetOutputAsync("should-comment-success", shouldCommentSuccess ? "true" : "false");
106+
await coreService.SetOutputAsync("should-comment-failure", shouldCommentFailure ? "true" : "false");
107+
108+
_logger.LogInformation(
109+
"Artifact evaluation complete: status={Status}, commit={Commit}, commentSuccess={CommentSuccess}, commentFailure={CommentFailure}",
110+
metadata.Status, shouldCommit, shouldCommentSuccess, shouldCommentFailure);
111+
112+
return true;
113+
}
114+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// Licensed to Elasticsearch B.V under one or more agreements.
2+
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
3+
// See the LICENSE file in the project root for more information
4+
5+
using System.Text.Json.Serialization;
6+
using Elastic.Documentation.Configuration.Changelog;
7+
using Elastic.Documentation.ReleaseNotes;
8+
9+
namespace Elastic.Changelog.Evaluation;
10+
11+
/// <summary>Artifact metadata transferred between the generate and commit CI workflows.</summary>
12+
public record ChangelogArtifactMetadata
13+
{
14+
public required int PrNumber { get; init; }
15+
public required string HeadRef { get; init; }
16+
public required string HeadSha { get; init; }
17+
public required string Status { get; init; }
18+
public required bool IsFork { get; init; }
19+
public required bool CanCommit { get; init; }
20+
public required bool MaintainerCanModify { get; init; }
21+
public string? HeadRepo { get; init; }
22+
public string? LabelTable { get; init; }
23+
public string? ProductLabelTable { get; init; }
24+
public string? SkipLabels { get; init; }
25+
public string? ConfigFile { get; init; }
26+
public string? ChangelogDir { get; init; }
27+
public string? ChangelogFilename { get; init; }
28+
public CreateRules? CreateRules { get; init; }
29+
}
30+
31+
[JsonSourceGenerationOptions(
32+
WriteIndented = true,
33+
UseStringEnumConverter = true,
34+
PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower
35+
)]
36+
[JsonSerializable(typeof(ChangelogArtifactMetadata))]
37+
[JsonSerializable(typeof(CreateRules))]
38+
[JsonSerializable(typeof(FieldMode))]
39+
[JsonSerializable(typeof(MatchMode))]
40+
public sealed partial class ChangelogArtifactMetadataJsonContext : JsonSerializerContext;
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
// Licensed to Elasticsearch B.V under one or more agreements.
2+
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
3+
// See the LICENSE file in the project root for more information
4+
5+
using System.IO.Abstractions;
6+
using System.Text.Json;
7+
using Actions.Core.Services;
8+
using Elastic.Changelog.Configuration;
9+
using Elastic.Documentation.Configuration;
10+
using Elastic.Documentation.Diagnostics;
11+
using Elastic.Documentation.Services;
12+
using Microsoft.Extensions.Logging;
13+
14+
namespace Elastic.Changelog.Evaluation;
15+
16+
/// <summary>Service implementing the changelog prepare-artifact CI command.</summary>
17+
public class ChangelogPrepareArtifactService(
18+
ILoggerFactory logFactory,
19+
IConfigurationContext configurationContext,
20+
ICoreService coreService,
21+
IFileSystem? fileSystem = null
22+
) : IService
23+
{
24+
private readonly ILogger _logger = logFactory.CreateLogger<ChangelogPrepareArtifactService>();
25+
private readonly IFileSystem _fileSystem = fileSystem ?? new FileSystem();
26+
private readonly ChangelogConfigurationLoader _configLoader = new(logFactory, configurationContext, fileSystem ?? new FileSystem());
27+
28+
public async Task<bool> PrepareArtifact(IDiagnosticsCollector collector, PrepareArtifactArguments input, Cancel ctx)
29+
{
30+
var status = ResolveStatus(input.EvaluateStatus, input.GenerateOutcome);
31+
_logger.LogInformation("Resolved artifact status: {Status} (evaluate={Evaluate}, generate={Generate})",
32+
status, input.EvaluateStatus, input.GenerateOutcome);
33+
34+
_ = _fileSystem.Directory.CreateDirectory(input.OutputDir);
35+
36+
string? changelogFilename = null;
37+
if (status == PrEvaluationResult.Success)
38+
{
39+
var sourceYaml = FindStagingYaml(input.StagingDir);
40+
41+
if (sourceYaml != null)
42+
{
43+
changelogFilename = input.ExistingChangelogFilename != null
44+
? _fileSystem.Path.GetFileName(input.ExistingChangelogFilename)
45+
: _fileSystem.Path.GetFileName(sourceYaml);
46+
47+
if (input.ExistingChangelogFilename != null)
48+
_logger.LogInformation("Reusing existing filename {Filename} for stable path on branch", changelogFilename);
49+
50+
var destYaml = _fileSystem.Path.Combine(input.OutputDir, changelogFilename);
51+
_fileSystem.File.Copy(sourceYaml, destYaml, overwrite: true);
52+
_logger.LogInformation("Copied changelog YAML: {Source} → {Dest}", sourceYaml, destYaml);
53+
}
54+
else
55+
{
56+
collector.EmitError(input.StagingDir, "No generated changelog YAML found in staging directory");
57+
status = PrEvaluationResult.Error;
58+
}
59+
}
60+
61+
var config = await _configLoader.LoadChangelogConfiguration(collector, input.Config, ctx);
62+
var createRules = config?.Rules?.Create;
63+
var changelogDir = config?.Bundle?.Directory ?? "docs/changelog";
64+
65+
var statusString = status.ToStringFast(true);
66+
var metadata = new ChangelogArtifactMetadata
67+
{
68+
PrNumber = input.PrNumber,
69+
HeadRef = input.HeadRef,
70+
HeadSha = input.HeadSha,
71+
Status = statusString,
72+
IsFork = input.IsFork,
73+
HeadRepo = input.HeadRepo,
74+
CanCommit = input.CanCommit,
75+
MaintainerCanModify = input.MaintainerCanModify,
76+
LabelTable = input.LabelTable,
77+
ProductLabelTable = input.ProductLabelTable,
78+
SkipLabels = input.SkipLabels,
79+
ConfigFile = input.Config,
80+
ChangelogDir = changelogDir,
81+
ChangelogFilename = changelogFilename,
82+
CreateRules = createRules
83+
};
84+
85+
var metadataPath = _fileSystem.Path.Combine(input.OutputDir, "metadata.json");
86+
var json = JsonSerializer.Serialize(metadata, ChangelogArtifactMetadataJsonContext.Default.ChangelogArtifactMetadata);
87+
await _fileSystem.File.WriteAllTextAsync(metadataPath, json, ctx);
88+
_logger.LogInformation("Wrote artifact metadata to {Path}", metadataPath);
89+
90+
await coreService.SetOutputAsync("status", statusString);
91+
92+
return true;
93+
}
94+
95+
private string? FindStagingYaml(string stagingDir)
96+
{
97+
if (!_fileSystem.Directory.Exists(stagingDir))
98+
return null;
99+
100+
var yamlFiles = _fileSystem.Directory.GetFiles(stagingDir, "*.yaml");
101+
if (yamlFiles.Length == 0)
102+
return null;
103+
104+
if (yamlFiles.Length > 1)
105+
{
106+
_logger.LogError("Multiple YAML files found in staging directory: {Files}", string.Join(", ", yamlFiles));
107+
return null;
108+
}
109+
110+
return yamlFiles[0];
111+
}
112+
113+
internal static PrEvaluationResult ResolveStatus(string evaluateStatus, string generateOutcome)
114+
{
115+
if (string.Equals(evaluateStatus, ChangelogPrEvaluationService.ProceedStatus, StringComparison.OrdinalIgnoreCase))
116+
{
117+
return generateOutcome.Equals("success", StringComparison.OrdinalIgnoreCase)
118+
? PrEvaluationResult.Success
119+
: PrEvaluationResult.Error;
120+
}
121+
122+
return PrEvaluationResultExtensions.TryParse(evaluateStatus, out var parsed, ignoreCase: true, allowMatchingMetadataAttribute: true)
123+
? parsed
124+
: PrEvaluationResult.Error;
125+
}
126+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// Licensed to Elasticsearch B.V under one or more agreements.
2+
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
3+
// See the LICENSE file in the project root for more information
4+
5+
namespace Elastic.Changelog.Evaluation;
6+
7+
/// <summary>Arguments for the changelog evaluate-artifact command (commit workflow).</summary>
8+
public record EvaluateArtifactArguments
9+
{
10+
public required string MetadataPath { get; init; }
11+
public required string Owner { get; init; }
12+
public required string Repo { get; init; }
13+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// Licensed to Elasticsearch B.V under one or more agreements.
2+
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
3+
// See the LICENSE file in the project root for more information
4+
5+
namespace Elastic.Changelog.Evaluation;
6+
7+
/// <summary>Arguments for the changelog prepare-artifact command.</summary>
8+
public record PrepareArtifactArguments
9+
{
10+
public required string StagingDir { get; init; }
11+
public required string OutputDir { get; init; }
12+
public required string EvaluateStatus { get; init; }
13+
public required string GenerateOutcome { get; init; }
14+
public required int PrNumber { get; init; }
15+
public required string HeadRef { get; init; }
16+
public required string HeadSha { get; init; }
17+
public bool IsFork { get; init; }
18+
public string? HeadRepo { get; init; }
19+
public bool CanCommit { get; init; }
20+
public bool MaintainerCanModify { get; init; }
21+
public string? LabelTable { get; init; }
22+
public string? ProductLabelTable { get; init; }
23+
public string? SkipLabels { get; init; }
24+
public string? Config { get; init; }
25+
26+
/// <summary>
27+
/// Filename of a previously committed changelog for this PR.
28+
/// When set, the staging file is renamed to match so the same path is overwritten on the branch.
29+
/// </summary>
30+
public string? ExistingChangelogFilename { get; init; }
31+
}

src/services/Elastic.Changelog/GitHub/GitHubPrService.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,6 @@ private sealed class GitHubCommitListItem
446446
[JsonSerializable(typeof(List<GitHubCommitListItem>))]
447447
private sealed partial class GitHubPrJsonContext : JsonSerializerContext;
448448

449-
[GeneratedRegex(@"https://github\.com/([a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+)/pull/(\d+)", RegexOptions.IgnoreCase, "en-CA")]
449+
[GeneratedRegex(@"https://github\.com/([a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+)/pull/(\d+)", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
450450
private static partial Regex MyRegex();
451451
}

0 commit comments

Comments
 (0)