Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/cli/changelog/bundle.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,13 @@ The second argument (`[1]`) and optional third argument (`[2]`) accept the follo
- **Promotion report file** — A path to a downloaded `.html` file containing a promotion report.
- **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.

## General options

These options work with both profile-based and option-based modes.

`--plan`
: 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.

## Options

The following options are only valid in option-based mode (no profile argument).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,17 @@ public record BundleChangelogsArguments
public IReadOnlyList<string>? LinkAllowRepos { get; init; }
}

/// <summary>
/// Structured plan output for CI actions. Describes what Docker flags and output path to expect
/// without actually executing the bundle.
/// </summary>
public record BundlePlanResult
{
public bool NeedsNetwork { get; init; }
public bool NeedsGithubToken { get; init; }
public string? OutputPath { get; init; }
}

/// <summary>
/// Service for bundling changelog files
/// </summary>
Expand Down Expand Up @@ -438,6 +449,73 @@ private static BundleChangelogsArguments ApplyConfigDefaults(BundleChangelogsArg
};
}

/// <summary>
/// Resolves a bundle plan from config and profile metadata without executing any network calls or
/// file-scanning. Used by <c>--plan</c> mode to emit GitHub Actions step outputs
/// (<c>needs_network</c>, <c>needs_github_token</c>, <c>output_path</c>) that CI actions consume.
/// </summary>
public async Task<BundlePlanResult?> PlanBundleAsync(
IDiagnosticsCollector collector,
BundleChangelogsArguments input,
bool hasReleaseVersion,
Cancel ctx)
{
var needsNetwork = hasReleaseVersion;
var needsGithubToken = hasReleaseVersion;

ChangelogConfiguration? config = null;
if (!string.IsNullOrWhiteSpace(input.Profile))
{
if (_configLoader == null)
{
collector.EmitError(string.Empty, "Changelog configuration loader is required for profile-based bundling.");
return null;
}
config = string.IsNullOrWhiteSpace(input.Config)
? await _configLoader.LoadChangelogConfigurationForProfileMode(collector, ctx)
: await _configLoader.LoadChangelogConfigurationRequired(collector, input.Config, ctx);
if (config == null)
return null;
}
else if (_configLoader != null)
config = await _configLoader.LoadChangelogConfiguration(collector, input.Config, ctx);

BundleProfile? profileDef = null;
if (!string.IsNullOrWhiteSpace(input.Profile) &&
config?.Bundle?.Profiles?.TryGetValue(input.Profile, out profileDef) == true)
{
if (string.Equals(profileDef.Source, "github_release", StringComparison.OrdinalIgnoreCase))
{
needsNetwork = true;
needsGithubToken = true;
}
}

// Resolve output path — mirrors the logic in ProcessProfile + ApplyConfigDefaults.
var outputPath = input.Output;
if (string.IsNullOrWhiteSpace(outputPath) && profileDef?.Output != null)
{
var version = input.ProfileArgument ?? "unknown";
var lifecycle = VersionLifecycleInference.InferLifecycle(version);
var outputPattern = profileDef.Output
.Replace("{version}", version)
.Replace("{lifecycle}", lifecycle);
var outputDir = config?.Bundle?.OutputDirectory
?? config?.Bundle?.Directory
?? _fileSystem.Directory.GetCurrentDirectory();
outputPath = _fileSystem.Path.Join(outputDir, outputPattern);
}
else if (string.IsNullOrWhiteSpace(outputPath) && config?.Bundle?.OutputDirectory != null)
outputPath = _fileSystem.Path.Join(config.Bundle.OutputDirectory, "changelog-bundle.yaml");

return new BundlePlanResult
{
NeedsNetwork = needsNetwork,
NeedsGithubToken = needsGithubToken,
OutputPath = outputPath
};
}

private bool ValidateInput(IDiagnosticsCollector collector, BundleChangelogsArguments input)
{
if (string.IsNullOrWhiteSpace(input.Directory))
Expand Down
81 changes: 57 additions & 24 deletions src/services/Elastic.Changelog/Bundling/PromotionReportParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,32 @@ public partial class PromotionReportParser(ILoggerFactory logFactory, ScopedFile
{
private readonly ILogger _logger = logFactory.CreateLogger<PromotionReportParser>();
private readonly IFileSystem _fileSystem = fileSystem ?? FileSystemFactory.RealRead;
private static readonly HttpClient HttpClient = new();

static PromotionReportParser()
private static readonly string[] AllowedHosts = ["github.com", "buildkite.com"];

private static readonly HttpClient HttpClient = CreateHttpClient();

private static HttpClient CreateHttpClient()
{
HttpClient.DefaultRequestHeaders.Add("User-Agent", "docs-builder");
HttpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("text/html"));
var handler = new SocketsHttpHandler
{
AllowAutoRedirect = false,
ConnectTimeout = TimeSpan.FromSeconds(10),
UseProxy = false
};
var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) };
client.DefaultRequestHeaders.Add("User-Agent", "docs-builder");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("text/html"));
return client;
}

private static bool IsAllowedUrl(string url) =>
Uri.TryCreate(url, UriKind.Absolute, out var uri) &&
uri.Scheme == Uri.UriSchemeHttps &&
AllowedHosts.Any(domain =>
uri.Host.Equals(domain, StringComparison.OrdinalIgnoreCase) ||
uri.Host.EndsWith($".{domain}", StringComparison.OrdinalIgnoreCase));

[GeneratedRegex(@"github\.com/([^/]+)/([^/]+)/pull/(\d+)", RegexOptions.IgnoreCase)]
private static partial Regex GitHubPrUrlRegex();

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

if (source.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
source.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
source.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
// Fetch URL content
_logger.LogInformation("Fetching promotion report from URL: {Url}", source);
var response = await HttpClient.GetAsync(source, ctx);
if (!response.IsSuccessStatusCode)
{
return new PromotionReportResult
{
IsValid = false,
ErrorMessage = $"Failed to fetch promotion report from URL: {response.StatusCode}"
};
}
htmlContent = await response.Content.ReadAsStringAsync(ctx);
var (content, error) = await FetchReportUrlAsync(source, ctx);
if (error != null)
return new PromotionReportResult { IsValid = false, ErrorMessage = error };
htmlContent = content!;
}
else if (_fileSystem.File.Exists(source))
{
// Read local file
_logger.LogInformation("Reading promotion report from file: {FilePath}", source);
htmlContent = await _fileSystem.File.ReadAllTextAsync(source, ctx);
}
Expand All @@ -104,7 +113,6 @@ private async Task<PromotionReportResult> ParsePromotionReportAsync(string sourc
};
}

// Extract PR URLs from HTML content
var prUrls = ExtractPrUrlsFromHtml(htmlContent);

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

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

return new PromotionReportResult
{
IsValid = true,
PrUrls = prUrls
};
return new PromotionReportResult { IsValid = true, PrUrls = prUrls };
}
catch (HttpRequestException ex)
{
Expand All @@ -144,6 +148,35 @@ private async Task<PromotionReportResult> ParsePromotionReportAsync(string sourc
}
}

/// <summary>Returns (content, null) on success or (null, errorMessage) on failure.</summary>
private async Task<(string? Content, string? Error)> FetchReportUrlAsync(string url, Cancel ctx)
{
if (!IsAllowedUrl(url))
return (null, $"Report URL must use HTTPS and target an allowed domain ({string.Join(", ", AllowedHosts)}): {url}");

_logger.LogInformation("Fetching promotion report from URL: {Url}", url);
var response = await HttpClient.GetAsync(url, ctx);

if ((int)response.StatusCode is >= 300 and < 400 && response.Headers.Location != null)
{
var redirectTarget = response.Headers.Location.IsAbsoluteUri
? response.Headers.Location.ToString()
: new Uri(new Uri(url), response.Headers.Location).ToString();

if (!IsAllowedUrl(redirectTarget))
return (null, $"Report URL redirected to a disallowed domain: {redirectTarget}");

_logger.LogInformation("Following redirect to: {Url}", redirectTarget);
response = await HttpClient.GetAsync(redirectTarget, ctx);
}

if (!response.IsSuccessStatusCode)
return (null, $"Failed to fetch promotion report from URL: {response.StatusCode}");

var content = await response.Content.ReadAsStringAsync(ctx);
return (content, null);
}

private List<string> ExtractPrUrlsFromHtml(string html)
{
var prUrls = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
Expand Down
86 changes: 56 additions & 30 deletions src/tooling/docs-builder/Commands/ChangelogCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@ public Task<int> Init(
/// <param name="usePrNumber">Optional: Use PR numbers for filenames instead of timestamp-slug. With both --prs (which creates one changelog per specified PR) and --issues (which creates one changelog per specified issue), each changelog filename will be derived from its PR numbers. Requires --prs or --issues. Mutually exclusive with --use-issue-number.</param>
/// <param name="useIssueNumber">Optional: Use issue numbers for filenames instead of timestamp-slug. With both --prs (which creates one changelog per specified PR) and --issues (which creates one changelog per specified issue), each changelog filename will be derived from its issues. Requires --prs or --issues. Mutually exclusive with --use-pr-number.</param>
/// <param name="releaseVersion">Optional: GitHub release tag to fetch PRs from (e.g., "v9.2.0" or "latest"). When specified, creates one changelog per PR in the release notes. Requires --repo (or bundle.repo in changelog.yml). Mutually exclusive with --prs and --issues. Does not create a bundle; use 'changelog gh-release' for that.</param>
/// <param name="ctx"></param>
/// <param name="ctx">Cancellation token</param>
[Command("add")]
public async Task<int> Create(
[ProductInfoParser] List<ProductArgument>? products = null,
Expand Down Expand Up @@ -502,6 +502,7 @@ async static (s, collector, state, ctx) => await s.CreateChangelog(collector, st
/// <param name="releaseVersion">GitHub release tag to use as a filter source (for example, "v9.2.0" or "latest"). When specified, fetches the release, parses PR references from the release notes, and uses those PRs as the filter — equivalent to passing the PR list via --prs. When --output-products is not specified, it is inferred from the release tag and repository name.</param>
/// <param name="resolve">Optional: Copy the contents of each changelog file into the entries array. Uses config bundle.resolve or defaults to false.</param>
/// <param name="noResolve">Optional: Explicitly turn off resolve (overrides config).</param>
/// <param name="plan">Emit GitHub Actions step outputs (<c>needs_network</c>, <c>needs_github_token</c>, <c>output_path</c>) describing network requirements and the resolved output path, then exit without generating the bundle. Intended for CI actions.</param>
/// <param name="ctx"></param>
[Command("bundle")]
public async Task<int> Bundle(
Expand All @@ -517,6 +518,7 @@ public async Task<int> Bundle(
[ProductInfoParser] List<ProductArgument>? outputProducts = null,
string[]? issues = null,
string? owner = null,
bool plan = false,
string[]? prs = null,
string? releaseVersion = null,
string? repo = null,
Expand All @@ -542,39 +544,42 @@ public async Task<int> Bundle(
return 1;
}

// Precedence: --repo CLI > bundle.repo config; --owner CLI > bundle.owner config > "elastic"
var bundleConfig = await new ChangelogConfigurationLoader(logFactory, configurationContext, _fileSystem)
.LoadChangelogConfiguration(collector, config, ctx);
var resolvedRepo = !string.IsNullOrWhiteSpace(repo) ? repo : bundleConfig?.Bundle?.Repo;
var resolvedOwner = owner ?? bundleConfig?.Bundle?.Owner ?? "elastic";

if (string.IsNullOrWhiteSpace(resolvedRepo))
if (!plan)
{
collector.EmitError(string.Empty, "--release-version requires --repo to be specified (or bundle.repo set in changelog.yml).");
return 1;
}
// Precedence: --repo CLI > bundle.repo config; --owner CLI > bundle.owner config > "elastic"
var bundleConfig = await new ChangelogConfigurationLoader(logFactory, configurationContext, _fileSystem)
.LoadChangelogConfiguration(collector, config, ctx);
var resolvedRepo = !string.IsNullOrWhiteSpace(repo) ? repo : bundleConfig?.Bundle?.Repo;
var resolvedOwner = owner ?? bundleConfig?.Bundle?.Owner ?? "elastic";

IGitHubReleaseService releaseService = new GitHubReleaseService(logFactory);
var release = await releaseService.FetchReleaseAsync(resolvedOwner, resolvedRepo, releaseVersion, ctx);
if (release == null)
{
collector.EmitError(string.Empty,
$"Failed to fetch release '{releaseVersion}' for {resolvedOwner}/{resolvedRepo}. Ensure the tag exists and credentials are set.");
return 1;
}
if (string.IsNullOrWhiteSpace(resolvedRepo))
{
collector.EmitError(string.Empty, "--release-version requires --repo to be specified (or bundle.repo set in changelog.yml).");
return 1;
}

var parsedNotes = ReleaseNoteParser.Parse(release.Body);
if (parsedNotes.PrReferences.Count == 0)
{
collector.EmitWarning(string.Empty,
$"No PR references found in release notes for {resolvedOwner}/{resolvedRepo}@{release.TagName}. No bundle will be created.");
return 0;
}
IGitHubReleaseService releaseService = new GitHubReleaseService(logFactory);
var release = await releaseService.FetchReleaseAsync(resolvedOwner, resolvedRepo, releaseVersion, ctx);
if (release == null)
{
collector.EmitError(string.Empty,
$"Failed to fetch release '{releaseVersion}' for {resolvedOwner}/{resolvedRepo}. Ensure the tag exists and credentials are set.");
return 1;
}

// Build full PR URLs and inject them as the PR filter
prs = parsedNotes.PrReferences
.Select(r => $"https://github.com/{resolvedOwner}/{resolvedRepo}/pull/{r.PrNumber}")
.ToArray();
var parsedNotes = ReleaseNoteParser.Parse(release.Body);
if (parsedNotes.PrReferences.Count == 0)
{
collector.EmitWarning(string.Empty,
$"No PR references found in release notes for {resolvedOwner}/{resolvedRepo}@{release.TagName}. No bundle will be created.");
return 0;
}

// Build full PR URLs and inject them as the PR filter
prs = parsedNotes.PrReferences
.Select(r => $"https://github.com/{resolvedOwner}/{resolvedRepo}/pull/{r.PrNumber}")
.ToArray();
}
}

var allPrs = ExpandCommaSeparated(prs);
Expand Down Expand Up @@ -754,6 +759,27 @@ public async Task<int> Bundle(
}
}

// --plan mode: resolve config/profile metadata and set CI outputs without executing
if (plan)
{
var planInput = new BundleChangelogsArguments
{
Output = processedOutput,
Profile = profile,
ProfileArgument = profileArg,
Config = config
};
var planResult = await service.PlanBundleAsync(collector, planInput, releaseVersion != null, ctx);
if (planResult == null)
return 1;

await githubActionsService.SetOutputAsync("needs_network", planResult.NeedsNetwork ? "true" : "false");
await githubActionsService.SetOutputAsync("needs_github_token", planResult.NeedsGithubToken ? "true" : "false");
if (planResult.OutputPath != null)
await githubActionsService.SetOutputAsync("output_path", planResult.OutputPath);
return 0;
}

// Determine resolve: CLI --no-resolve and --resolve override config. null = use config default.
var shouldResolve = noResolve ? false : resolve;

Expand Down
Loading
Loading