Skip to content

Commit 3ea7f85

Browse files
authored
changelogs: Add --plan flag to changelog bundle (#3028)
* Add --plan flag to bundle * Update bundle docs * use UrlPath * Fix merge mishap * Add allowlist and https checks for report URLs * Use ScopedFileSystem, update xml docs * Adjust test names * Fix tests on Windows * Sanitize paths * Use OptionalWindowsReplace
1 parent 0c2f2c0 commit 3ea7f85

6 files changed

Lines changed: 399 additions & 58 deletions

File tree

docs/cli/changelog/bundle.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,13 @@ The second argument (`[1]`) and optional third argument (`[2]`) accept the follo
5454
- **Promotion report file** — A path to a downloaded `.html` file containing a promotion report.
5555
- **URL list file** — A path to a plain-text file containing one fully-qualified GitHub PR or issue URL per line. For example, `https://github.com/elastic/elasticsearch/pull/123`. The file must contain only PR URLs or only issue URLs, not a mix. Bare numbers and short forms such as `owner/repo#123` are not allowed.
5656

57+
## General options
58+
59+
These options work with both profile-based and option-based modes.
60+
61+
`--plan`
62+
: Output a structured set of CI step outputs (`needs_network`, `needs_github_token`, `output_path`) describing Docker flags, network requirements, and the resolved output path, then exit without generating the bundle. Intended for CI actions that need to determine container configuration before running the actual bundle step. When running outside GitHub Actions, the output is written to stdout.
63+
5764
## Options
5865

5966
The following options are only valid in option-based mode (no profile argument).

src/services/Elastic.Changelog/Bundling/ChangelogBundlingService.cs

Lines changed: 82 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,17 @@ public record BundleChangelogsArguments
9090
public IReadOnlyList<string>? LinkAllowRepos { get; init; }
9191
}
9292

93+
/// <summary>
94+
/// Structured plan output for CI actions. Describes what Docker flags and output path to expect
95+
/// without actually executing the bundle.
96+
/// </summary>
97+
public record BundlePlanResult
98+
{
99+
public bool NeedsNetwork { get; init; }
100+
public bool NeedsGithubToken { get; init; }
101+
public string? OutputPath { get; init; }
102+
}
103+
93104
/// <summary>
94105
/// Service for bundling changelog files
95106
/// </summary>
@@ -368,7 +379,7 @@ public async Task<bool> BundleChangelogs(IDiagnosticsCollector collector, Bundle
368379
?? input.OutputDirectory
369380
?? config.Bundle.Directory
370381
?? _fileSystem.Directory.GetCurrentDirectory();
371-
outputPath = _fileSystem.Path.Join(outputDir, outputPattern);
382+
outputPath = _fileSystem.Path.Join(outputDir, outputPattern).OptionalWindowsReplace();
372383
}
373384

374385
// Parse output_products pattern with version/lifecycle substitution
@@ -407,18 +418,18 @@ public async Task<bool> BundleChangelogs(IDiagnosticsCollector collector, Bundle
407418
};
408419
}
409420

410-
private static BundleChangelogsArguments ApplyConfigDefaults(BundleChangelogsArguments input, ChangelogConfiguration? config)
421+
private BundleChangelogsArguments ApplyConfigDefaults(BundleChangelogsArguments input, ChangelogConfiguration? config)
411422
{
412423
// Apply directory: CLI takes precedence. Only use config when --directory not specified.
413-
var directory = input.Directory ?? config?.Bundle?.Directory ?? Directory.GetCurrentDirectory();
424+
var directory = input.Directory ?? config?.Bundle?.Directory ?? _fileSystem.Directory.GetCurrentDirectory();
414425

415426
if (config?.Bundle == null)
416427
return input with { Directory = directory, LinkAllowRepos = null };
417428

418429
// Apply output default when --output not specified: use bundle.output_directory if set
419430
var output = input.Output;
420431
if (string.IsNullOrWhiteSpace(output) && !string.IsNullOrWhiteSpace(config.Bundle.OutputDirectory))
421-
output = Path.Join(config.Bundle.OutputDirectory, "changelog-bundle.yaml");
432+
output = _fileSystem.Path.Join(config.Bundle.OutputDirectory, "changelog-bundle.yaml").OptionalWindowsReplace();
422433

423434
// Apply resolve: CLI takes precedence over config. Only use config when CLI did not specify.
424435
var resolve = input.Resolve ?? config.Bundle.Resolve;
@@ -438,6 +449,73 @@ private static BundleChangelogsArguments ApplyConfigDefaults(BundleChangelogsArg
438449
};
439450
}
440451

452+
/// <summary>
453+
/// Resolves a bundle plan from config and profile metadata without executing any network calls or
454+
/// file-scanning. Used by <c>--plan</c> mode to emit GitHub Actions step outputs
455+
/// (<c>needs_network</c>, <c>needs_github_token</c>, <c>output_path</c>) that CI actions consume.
456+
/// </summary>
457+
public async Task<BundlePlanResult?> PlanBundleAsync(
458+
IDiagnosticsCollector collector,
459+
BundleChangelogsArguments input,
460+
bool hasReleaseVersion,
461+
Cancel ctx)
462+
{
463+
var needsNetwork = hasReleaseVersion;
464+
var needsGithubToken = hasReleaseVersion;
465+
466+
ChangelogConfiguration? config = null;
467+
if (!string.IsNullOrWhiteSpace(input.Profile))
468+
{
469+
if (_configLoader == null)
470+
{
471+
collector.EmitError(string.Empty, "Changelog configuration loader is required for profile-based bundling.");
472+
return null;
473+
}
474+
config = string.IsNullOrWhiteSpace(input.Config)
475+
? await _configLoader.LoadChangelogConfigurationForProfileMode(collector, ctx)
476+
: await _configLoader.LoadChangelogConfigurationRequired(collector, input.Config, ctx);
477+
if (config == null)
478+
return null;
479+
}
480+
else if (_configLoader != null)
481+
config = await _configLoader.LoadChangelogConfiguration(collector, input.Config, ctx);
482+
483+
BundleProfile? profileDef = null;
484+
if (!string.IsNullOrWhiteSpace(input.Profile) &&
485+
config?.Bundle?.Profiles?.TryGetValue(input.Profile, out profileDef) == true)
486+
{
487+
if (string.Equals(profileDef.Source, "github_release", StringComparison.OrdinalIgnoreCase))
488+
{
489+
needsNetwork = true;
490+
needsGithubToken = true;
491+
}
492+
}
493+
494+
// Resolve output path — mirrors the logic in ProcessProfile + ApplyConfigDefaults.
495+
var outputPath = input.Output;
496+
if (string.IsNullOrWhiteSpace(outputPath) && profileDef?.Output != null)
497+
{
498+
var version = input.ProfileArgument ?? "unknown";
499+
var lifecycle = VersionLifecycleInference.InferLifecycle(version);
500+
var outputPattern = profileDef.Output
501+
.Replace("{version}", version)
502+
.Replace("{lifecycle}", lifecycle);
503+
var outputDir = config?.Bundle?.OutputDirectory
504+
?? config?.Bundle?.Directory
505+
?? _fileSystem.Directory.GetCurrentDirectory();
506+
outputPath = _fileSystem.Path.Join(outputDir, outputPattern).OptionalWindowsReplace();
507+
}
508+
else if (string.IsNullOrWhiteSpace(outputPath) && config?.Bundle?.OutputDirectory != null)
509+
outputPath = _fileSystem.Path.Join(config.Bundle.OutputDirectory, "changelog-bundle.yaml").OptionalWindowsReplace();
510+
511+
return new BundlePlanResult
512+
{
513+
NeedsNetwork = needsNetwork,
514+
NeedsGithubToken = needsGithubToken,
515+
OutputPath = outputPath
516+
};
517+
}
518+
441519
private bool ValidateInput(IDiagnosticsCollector collector, BundleChangelogsArguments input)
442520
{
443521
if (string.IsNullOrWhiteSpace(input.Directory))

src/services/Elastic.Changelog/Bundling/PromotionReportParser.cs

Lines changed: 57 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,32 @@ public partial class PromotionReportParser(ILoggerFactory logFactory, ScopedFile
1919
{
2020
private readonly ILogger _logger = logFactory.CreateLogger<PromotionReportParser>();
2121
private readonly IFileSystem _fileSystem = fileSystem ?? FileSystemFactory.RealRead;
22-
private static readonly HttpClient HttpClient = new();
2322

24-
static PromotionReportParser()
23+
private static readonly string[] AllowedHosts = ["github.com", "buildkite.com"];
24+
25+
private static readonly HttpClient HttpClient = CreateHttpClient();
26+
27+
private static HttpClient CreateHttpClient()
2528
{
26-
HttpClient.DefaultRequestHeaders.Add("User-Agent", "docs-builder");
27-
HttpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("text/html"));
29+
var handler = new SocketsHttpHandler
30+
{
31+
AllowAutoRedirect = false,
32+
ConnectTimeout = TimeSpan.FromSeconds(10),
33+
UseProxy = false
34+
};
35+
var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) };
36+
client.DefaultRequestHeaders.Add("User-Agent", "docs-builder");
37+
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("text/html"));
38+
return client;
2839
}
2940

41+
private static bool IsAllowedUrl(string url) =>
42+
Uri.TryCreate(url, UriKind.Absolute, out var uri) &&
43+
uri.Scheme == Uri.UriSchemeHttps &&
44+
AllowedHosts.Any(domain =>
45+
uri.Host.Equals(domain, StringComparison.OrdinalIgnoreCase) ||
46+
uri.Host.EndsWith($".{domain}", StringComparison.OrdinalIgnoreCase));
47+
3048
[GeneratedRegex(@"github\.com/([^/]+)/([^/]+)/pull/(\d+)", RegexOptions.IgnoreCase)]
3149
private static partial Regex GitHubPrUrlRegex();
3250

@@ -74,24 +92,15 @@ private async Task<PromotionReportResult> ParsePromotionReportAsync(string sourc
7492
string htmlContent;
7593

7694
if (source.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
77-
source.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
95+
source.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
7896
{
79-
// Fetch URL content
80-
_logger.LogInformation("Fetching promotion report from URL: {Url}", source);
81-
var response = await HttpClient.GetAsync(source, ctx);
82-
if (!response.IsSuccessStatusCode)
83-
{
84-
return new PromotionReportResult
85-
{
86-
IsValid = false,
87-
ErrorMessage = $"Failed to fetch promotion report from URL: {response.StatusCode}"
88-
};
89-
}
90-
htmlContent = await response.Content.ReadAsStringAsync(ctx);
97+
var (content, error) = await FetchReportUrlAsync(source, ctx);
98+
if (error != null)
99+
return new PromotionReportResult { IsValid = false, ErrorMessage = error };
100+
htmlContent = content!;
91101
}
92102
else if (_fileSystem.File.Exists(source))
93103
{
94-
// Read local file
95104
_logger.LogInformation("Reading promotion report from file: {FilePath}", source);
96105
htmlContent = await _fileSystem.File.ReadAllTextAsync(source, ctx);
97106
}
@@ -104,7 +113,6 @@ private async Task<PromotionReportResult> ParsePromotionReportAsync(string sourc
104113
};
105114
}
106115

107-
// Extract PR URLs from HTML content
108116
var prUrls = ExtractPrUrlsFromHtml(htmlContent);
109117

110118
if (prUrls.Count == 0)
@@ -118,11 +126,7 @@ private async Task<PromotionReportResult> ParsePromotionReportAsync(string sourc
118126

119127
_logger.LogInformation("Extracted {Count} PR URLs from promotion report", prUrls.Count);
120128

121-
return new PromotionReportResult
122-
{
123-
IsValid = true,
124-
PrUrls = prUrls
125-
};
129+
return new PromotionReportResult { IsValid = true, PrUrls = prUrls };
126130
}
127131
catch (HttpRequestException ex)
128132
{
@@ -144,6 +148,35 @@ private async Task<PromotionReportResult> ParsePromotionReportAsync(string sourc
144148
}
145149
}
146150

151+
/// <summary>Returns (content, null) on success or (null, errorMessage) on failure.</summary>
152+
private async Task<(string? Content, string? Error)> FetchReportUrlAsync(string url, Cancel ctx)
153+
{
154+
if (!IsAllowedUrl(url))
155+
return (null, $"Report URL must use HTTPS and target an allowed domain ({string.Join(", ", AllowedHosts)}): {url}");
156+
157+
_logger.LogInformation("Fetching promotion report from URL: {Url}", url);
158+
var response = await HttpClient.GetAsync(url, ctx);
159+
160+
if ((int)response.StatusCode is >= 300 and < 400 && response.Headers.Location != null)
161+
{
162+
var redirectTarget = response.Headers.Location.IsAbsoluteUri
163+
? response.Headers.Location.ToString()
164+
: new Uri(new Uri(url), response.Headers.Location).ToString();
165+
166+
if (!IsAllowedUrl(redirectTarget))
167+
return (null, $"Report URL redirected to a disallowed domain: {redirectTarget}");
168+
169+
_logger.LogInformation("Following redirect to: {Url}", redirectTarget);
170+
response = await HttpClient.GetAsync(redirectTarget, ctx);
171+
}
172+
173+
if (!response.IsSuccessStatusCode)
174+
return (null, $"Failed to fetch promotion report from URL: {response.StatusCode}");
175+
176+
var content = await response.Content.ReadAsStringAsync(ctx);
177+
return (content, null);
178+
}
179+
147180
private List<string> ExtractPrUrlsFromHtml(string html)
148181
{
149182
var prUrls = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,8 @@ public async Task<bool> Upload(IDiagnosticsCollector collector, ChangelogUploadA
9999

100100
internal IReadOnlyList<UploadTarget> DiscoverUploadTargets(IDiagnosticsCollector collector, string changelogDir)
101101
{
102+
var rootDir = _fileSystem.DirectoryInfo.New(changelogDir);
103+
102104
var yamlFiles = _fileSystem.Directory.GetFiles(changelogDir, "*.yaml", SearchOption.TopDirectoryOnly)
103105
.Concat(_fileSystem.Directory.GetFiles(changelogDir, "*.yml", SearchOption.TopDirectoryOnly))
104106
.ToList();
@@ -107,6 +109,13 @@ internal IReadOnlyList<UploadTarget> DiscoverUploadTargets(IDiagnosticsCollector
107109

108110
foreach (var filePath in yamlFiles)
109111
{
112+
var fileInfo = _fileSystem.FileInfo.New(filePath);
113+
if (SymlinkValidator.ValidateFileAccess(fileInfo, rootDir) is { } accessError)
114+
{
115+
collector.EmitWarning(filePath, $"Skipping: {accessError}");
116+
continue;
117+
}
118+
110119
var products = ReadProductsFromFragment(filePath);
111120
if (products.Count == 0)
112121
{

0 commit comments

Comments
 (0)