// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Diagnostics;
using System.Text;
using Aspire.TestUtilities;
using Xunit;
namespace Infrastructure.Tests;
public sealed class ExtensionReleaseWorkflowTests(ITestOutputHelper testOutput)
{
private static readonly string s_releaseNotesGeneratorPath = Path.Combine(
RepoRoot.Path,
".github",
"workflows",
"extension-release",
"generate_deterministic_release_notes.py");
private static readonly string s_applyTriggerLabelScriptPath = Path.Combine(
RepoRoot.Path,
".github",
"workflows",
"extension-release",
"apply_extension_release_trigger_label.sh");
private static readonly string s_prBodyValidatorPath = Path.Combine(
RepoRoot.Path,
".github",
"workflows",
"extension-release",
"validate_github_pr_body.py");
[Fact]
[RequiresTools(["bash"])]
public async Task ApplyingTriggerLabelFailsWhenExistingLabelCannotBeRemoved()
{
var tempDirectory = Directory.CreateTempSubdirectory("extension-release-label-failure");
try
{
var fakeGh = await CreateFakeGhAsync(tempDirectory.FullName);
var result = await RunBashScriptAsync(
s_applyTriggerLabelScriptPath,
["123"],
new Dictionary<string, string?>
{
["GH_CALL_LOG"] = fakeGh.CallLogPath,
["GH_HAS_LABEL"] = "true",
["GH_REMOVE_LABEL_EXIT_CODE"] = "1",
["PATH"] = fakeGh.PathEnvironment,
});
Assert.NotEqual(0, result.ExitCode);
Assert.Contains("Failed to remove existing 'vscode-extension-release' label", result.Output, StringComparison.Ordinal);
Assert.False(
(await File.ReadAllTextAsync(fakeGh.CallLogPath)).Contains("--add-label vscode-extension-release", StringComparison.Ordinal),
"The helper must not re-add the label after a failed removal.");
}
finally
{
Directory.Delete(tempDirectory.FullName, recursive: true);
}
}
[Fact]
[RequiresTools(["bash"])]
public async Task ApplyingTriggerLabelAddsLabelWithoutRemovingWhenMissing()
{
var tempDirectory = Directory.CreateTempSubdirectory("extension-release-label-add");
try
{
var fakeGh = await CreateFakeGhAsync(tempDirectory.FullName);
var result = await RunBashScriptAsync(
s_applyTriggerLabelScriptPath,
["123"],
new Dictionary<string, string?>
{
["GH_CALL_LOG"] = fakeGh.CallLogPath,
["GH_HAS_LABEL"] = "false",
["PATH"] = fakeGh.PathEnvironment,
});
Assert.Equal(0, result.ExitCode);
var callLog = await File.ReadAllTextAsync(fakeGh.CallLogPath);
Assert.Contains("pr view 123 --json labels --jq .labels[].name", callLog, StringComparison.Ordinal);
Assert.DoesNotContain("--remove-label vscode-extension-release", callLog, StringComparison.Ordinal);
Assert.Contains("pr edit 123 --add-label vscode-extension-release", callLog, StringComparison.Ordinal);
}
finally
{
Directory.Delete(tempDirectory.FullName, recursive: true);
}
}
[Fact]
[RequiresTools(["bash"])]
public async Task ApplyingTriggerLabelReAddsExistingLabelAfterSuccessfulRemoval()
{
var tempDirectory = Directory.CreateTempSubdirectory("extension-release-label-readd");
try
{
var fakeGh = await CreateFakeGhAsync(tempDirectory.FullName);
var result = await RunBashScriptAsync(
s_applyTriggerLabelScriptPath,
["123"],
new Dictionary<string, string?>
{
["GH_CALL_LOG"] = fakeGh.CallLogPath,
["GH_HAS_LABEL"] = "true",
["PATH"] = fakeGh.PathEnvironment,
});
Assert.Equal(0, result.ExitCode);
var callLog = (await File.ReadAllTextAsync(fakeGh.CallLogPath)).ReplaceLineEndings("\n");
var removeIndex = callLog.IndexOf("pr edit 123 --remove-label vscode-extension-release", StringComparison.Ordinal);
var addIndex = callLog.IndexOf("pr edit 123 --add-label vscode-extension-release", StringComparison.Ordinal);
Assert.True(removeIndex >= 0, "Expected the helper to remove the existing trigger label before re-adding it.");
Assert.True(addIndex > removeIndex, "Expected the helper to re-add the trigger label after removing it.");
}
finally
{
Directory.Delete(tempDirectory.FullName, recursive: true);
}
}
[Fact]
[RequiresTools(["python"])]
[SkipOnPlatform(TestPlatforms.Linux | TestPlatforms.OSX | TestPlatforms.FreeBSD, "Uses the Windows Python executable.")]
public Task DeterministicFallbackIncludesEveryAcceptedCommitOnWindows()
=> DeterministicFallbackIncludesEveryAcceptedCommit("python");
[Fact]
[RequiresTools(["python3"])]
[SkipOnPlatform(TestPlatforms.Windows, "Uses the Unix Python executable.")]
public Task DeterministicFallbackIncludesEveryAcceptedCommitOnUnix()
=> DeterministicFallbackIncludesEveryAcceptedCommit("python3");
[Fact]
[RequiresTools(["python"])]
[SkipOnPlatform(TestPlatforms.Linux | TestPlatforms.OSX | TestPlatforms.FreeBSD, "Uses the Windows Python executable.")]
public Task DeterministicFallbackAllowsRenderedOutputLargerThanEightThousandBytesOnWindows()
=> DeterministicFallbackAllowsRenderedOutputLargerThanEightThousandBytes("python");
[Fact]
[RequiresTools(["python3"])]
[SkipOnPlatform(TestPlatforms.Windows, "Uses the Unix Python executable.")]
public Task DeterministicFallbackAllowsRenderedOutputLargerThanEightThousandBytesOnUnix()
=> DeterministicFallbackAllowsRenderedOutputLargerThanEightThousandBytes("python3");
[Fact]
[RequiresTools(["python"])]
[SkipOnPlatform(TestPlatforms.Linux | TestPlatforms.OSX | TestPlatforms.FreeBSD, "Uses the Windows Python executable.")]
public Task DeterministicFallbackSanitizesSplitlinesControlCharactersWithoutSplittingCommitsOnWindows()
=> DeterministicFallbackSanitizesSplitlinesControlCharactersWithoutSplittingCommits("python");
[Fact]
[RequiresTools(["python3"])]
[SkipOnPlatform(TestPlatforms.Windows, "Uses the Unix Python executable.")]
public Task DeterministicFallbackSanitizesSplitlinesControlCharactersWithoutSplittingCommitsOnUnix()
=> DeterministicFallbackSanitizesSplitlinesControlCharactersWithoutSplittingCommits("python3");
[Fact]
[RequiresTools(["python"])]
[SkipOnPlatform(TestPlatforms.Linux | TestPlatforms.OSX | TestPlatforms.FreeBSD, "Uses the Windows Python executable.")]
public Task DeterministicFallbackStripsPrSuffixFromCrLfInputOnWindows()
=> DeterministicFallbackStripsPrSuffixFromCrLfInput("python");
[Fact]
[RequiresTools(["python3"])]
[SkipOnPlatform(TestPlatforms.Windows, "Uses the Unix Python executable.")]
public Task DeterministicFallbackStripsPrSuffixFromCrLfInputOnUnix()
=> DeterministicFallbackStripsPrSuffixFromCrLfInput("python3");
[Fact]
[RequiresTools(["python"])]
[SkipOnPlatform(TestPlatforms.Linux | TestPlatforms.OSX | TestPlatforms.FreeBSD, "Uses the Windows Python executable.")]
public Task GitHubPullRequestBodyValidatorAcceptsBodiesAtLimitOnWindows()
=> GitHubPullRequestBodyValidatorAcceptsBodiesAtLimit("python");
[Fact]
[RequiresTools(["python3"])]
[SkipOnPlatform(TestPlatforms.Windows, "Uses the Unix Python executable.")]
public Task GitHubPullRequestBodyValidatorAcceptsBodiesAtLimitOnUnix()
=> GitHubPullRequestBodyValidatorAcceptsBodiesAtLimit("python3");
[Fact]
[RequiresTools(["python"])]
[SkipOnPlatform(TestPlatforms.Linux | TestPlatforms.OSX | TestPlatforms.FreeBSD, "Uses the Windows Python executable.")]
public Task GitHubPullRequestBodyValidatorRejectsBodiesOverLimitOnWindows()
=> GitHubPullRequestBodyValidatorRejectsBodiesOverLimit("python");
[Fact]
[RequiresTools(["python3"])]
[SkipOnPlatform(TestPlatforms.Windows, "Uses the Unix Python executable.")]
public Task GitHubPullRequestBodyValidatorRejectsBodiesOverLimitOnUnix()
=> GitHubPullRequestBodyValidatorRejectsBodiesOverLimit("python3");
private async Task DeterministicFallbackIncludesEveryAcceptedCommit(string pythonExecutable)
{
var output = await GenerateDeterministicReleaseNotesAsync(
pythonExecutable,
[
"1111111\tfeat: Add Aspire dashboard tree view (#100)",
"2222222\tfix(settings): Preserve <b>launch</b> profile labels (#101)",
"3333333\tdocs: Explain terminal attach behavior (#102)",
"4444444\tchore(extension): Surface package warnings\a in output (#103)",
"5555555\trefactor: Improve project detection after restore (#104)",
"6666666\tperf: Speed up solution scanning (#105)",
"7777777\tfeat(tree): Show Azure resources in explorer (#106)",
"8888888\tfix: Respect multi-root workspaces (#107)",
"9999999\tfeat: Add walkthrough links (#108)",
"aaaaaaa\tfix(debug): Keep env vars when reloading (#109)",
"bbbbbbb\tfeat(commands): Support remove service action (#110)",
"ccccccc\tchore: Handle workspace rename notifications (#111)",
"ddddddd\tRelease 1.10.1",
"eeeeeee\tBump package-lock.json",
"fffffff\tUpdate yarn.lock"
]);
var bulletLines = output.ReplaceLineEndings("\n")
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
.Where(line => line.StartsWith("- ", StringComparison.Ordinal))
.ToArray();
Assert.Equal(
[
"- Add Aspire dashboard tree view",
"- Preserve launch profile labels",
"- Explain terminal attach behavior",
"- Surface package warnings in output",
"- Improve project detection after restore",
"- Speed up solution scanning",
"- Show Azure resources in explorer",
"- Respect multi-root workspaces",
"- Add walkthrough links",
"- Keep env vars when reloading",
"- Support remove service action",
"- Handle workspace rename notifications"
],
bulletLines);
}
private async Task DeterministicFallbackAllowsRenderedOutputLargerThanEightThousandBytes(string pythonExecutable)
{
var commitLines = Enumerable.Range(1, 12)
.Select(index =>
{
var message = $"candidate note {index:D2} " + new string((char)('a' + (index % 26)), 720);
return $"{index:x7}\tfeat: {message}";
})
.ToArray();
var output = await GenerateDeterministicReleaseNotesAsync(pythonExecutable, commitLines);
var lastMessage = $"candidate note 12 {new string('m', 720)}";
Assert.True(
Encoding.UTF8.GetByteCount(output) > 8000,
$"Expected rendered fallback to exceed 8000 bytes, but it was {Encoding.UTF8.GetByteCount(output)} bytes.");
Assert.Contains($"- {lastMessage}", output, StringComparison.Ordinal);
}
private async Task DeterministicFallbackSanitizesSplitlinesControlCharactersWithoutSplittingCommits(string pythonExecutable)
{
var output = await GenerateDeterministicReleaseNotesAsync(
pythonExecutable,
[
"1111111\tfeat: Alpha\rBeta (#100)",
"2222222\tfix: Gamma\vDelta (#101)"
]);
var bulletLines = output.ReplaceLineEndings("\n")
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
.Where(line => line.StartsWith("- ", StringComparison.Ordinal))
.ToArray();
Assert.Equal(
[
"- AlphaBeta",
"- GammaDelta"
],
bulletLines);
}
private async Task DeterministicFallbackStripsPrSuffixFromCrLfInput(string pythonExecutable)
{
var output = await GenerateDeterministicReleaseNotesAsync(
pythonExecutable,
["1111111\tfeat: Alpha (#100)"],
lineEnding: "\r\n");
var bulletLines = output.ReplaceLineEndings("\n")
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
.Where(line => line.StartsWith("- ", StringComparison.Ordinal))
.ToArray();
Assert.Equal(["- Alpha"], bulletLines);
}
private async Task GitHubPullRequestBodyValidatorAcceptsBodiesAtLimit(string pythonExecutable)
{
var tempDirectory = Directory.CreateTempSubdirectory("extension-release-pr-body-limit-pass");
try
{
var bodyPath = Path.Combine(tempDirectory.FullName, "pr_body.md");
await File.WriteAllTextAsync(bodyPath, new string('a', 65_536));
var result = await RunPythonScriptAsync(pythonExecutable, s_prBodyValidatorPath, [bodyPath]);
Assert.Equal(0, result.ExitCode);
Assert.Contains("GitHub pull request body length", result.Output, StringComparison.Ordinal);
}
finally
{
Directory.Delete(tempDirectory.FullName, recursive: true);
}
}
private async Task GitHubPullRequestBodyValidatorRejectsBodiesOverLimit(string pythonExecutable)
{
var tempDirectory = Directory.CreateTempSubdirectory("extension-release-pr-body-limit-fail");
try
{
var bodyPath = Path.Combine(tempDirectory.FullName, "pr_body.md");
await File.WriteAllTextAsync(bodyPath, new string('a', 65_537));
var result = await RunPythonScriptAsync(pythonExecutable, s_prBodyValidatorPath, [bodyPath]);
Assert.NotEqual(0, result.ExitCode);
Assert.Contains("exceeds GitHub's 65536-character pull request body limit", result.Output, StringComparison.Ordinal);
}
finally
{
Directory.Delete(tempDirectory.FullName, recursive: true);
}
}
private async Task<string> GenerateDeterministicReleaseNotesAsync(string pythonExecutable, IEnumerable<string> commitLines, string? lineEnding = null)
{
Assert.True(File.Exists(s_releaseNotesGeneratorPath), $"Expected release notes generator at '{s_releaseNotesGeneratorPath}'.");
var tempDirectory = Directory.CreateTempSubdirectory("extension-release-notes");
try
{
var commitsPath = Path.Combine(tempDirectory.FullName, "commits.txt");
var outputPath = Path.Combine(tempDirectory.FullName, "release_notes.md");
await File.WriteAllTextAsync(
commitsPath,
string.Join(lineEnding ?? Environment.NewLine, commitLines) + (lineEnding ?? Environment.NewLine));
var startInfo = new ProcessStartInfo(pythonExecutable)
{
WorkingDirectory = RepoRoot.Path,
RedirectStandardError = true,
RedirectStandardOutput = true,
UseShellExecute = false,
};
startInfo.ArgumentList.Add(s_releaseNotesGeneratorPath);
startInfo.ArgumentList.Add(commitsPath);
startInfo.ArgumentList.Add(outputPath);
using var process = Process.Start(startInfo)
?? throw new InvalidOperationException($"Failed to start {pythonExecutable}.");
// Read both streams concurrently to avoid deadlock when a pipe buffer fills.
var stdoutTask = process.StandardOutput.ReadToEndAsync();
var stderrTask = process.StandardError.ReadToEndAsync();
using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(1));
try
{
await process.WaitForExitAsync(timeout.Token);
}
catch (OperationCanceledException)
{
process.Kill(entireProcessTree: true);
throw;
}
var stdout = await stdoutTask;
var stderr = await stderrTask;
testOutput.WriteLine(stdout);
testOutput.WriteLine(stderr);
Assert.True(
process.ExitCode == 0,
$"{pythonExecutable} exited with code {process.ExitCode}.{Environment.NewLine}{stdout}{Environment.NewLine}{stderr}");
return await File.ReadAllTextAsync(outputPath);
}
finally
{
Directory.Delete(tempDirectory.FullName, recursive: true);
}
}
private async Task<CommandResult> RunPythonScriptAsync(string pythonExecutable, string scriptPath, IEnumerable<string> args)
{
Assert.True(File.Exists(scriptPath), $"Expected helper script at '{scriptPath}'.");
using var process = new Process();
process.StartInfo = new ProcessStartInfo(pythonExecutable)
{
WorkingDirectory = RepoRoot.Path,
RedirectStandardError = true,
RedirectStandardOutput = true,
UseShellExecute = false,
};
process.StartInfo.ArgumentList.Add(scriptPath);
foreach (var arg in args)
{
process.StartInfo.ArgumentList.Add(arg);
}
process.Start();
// Read both streams concurrently to avoid deadlock when a helper prints diagnostics.
var stdoutTask = process.StandardOutput.ReadToEndAsync();
var stderrTask = process.StandardError.ReadToEndAsync();
using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(1));
await process.WaitForExitAsync(timeout.Token);
var output = await stdoutTask + await stderrTask;
testOutput.WriteLine(output);
return new CommandResult(process.ExitCode, output);
}
private async Task<CommandResult> RunBashScriptAsync(string scriptPath, IEnumerable<string> args, IReadOnlyDictionary<string, string?> environment)
{
Assert.True(File.Exists(scriptPath), $"Expected helper script at '{scriptPath}'.");
using var process = new Process();
process.StartInfo = new ProcessStartInfo("bash")
{
WorkingDirectory = RepoRoot.Path,
RedirectStandardError = true,
RedirectStandardOutput = true,
UseShellExecute = false,
};
process.StartInfo.ArgumentList.Add(scriptPath);
foreach (var arg in args)
{
process.StartInfo.ArgumentList.Add(arg);
}
foreach (var (key, value) in environment)
{
process.StartInfo.Environment[key] = value ?? string.Empty;
}
process.Start();
// Read both streams concurrently to avoid deadlock when bash emits diagnostics.
var stdoutTask = process.StandardOutput.ReadToEndAsync();
var stderrTask = process.StandardError.ReadToEndAsync();
using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(1));
await process.WaitForExitAsync(timeout.Token);
var output = await stdoutTask + await stderrTask;
testOutput.WriteLine(output);
return new CommandResult(process.ExitCode, output);
}
private async Task<FakeGhFixture> CreateFakeGhAsync(string rootDirectory)
{
var binDirectory = Path.Combine(rootDirectory, "bin");
Directory.CreateDirectory(binDirectory);
var callLogPath = Path.Combine(rootDirectory, "gh-call-log.txt");
var fakeGhPath = Path.Combine(binDirectory, "gh");
await File.WriteAllTextAsync(
fakeGhPath,
"""
#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' "$*" >> "${GH_CALL_LOG}"
if [[ "$1" == "pr" && "$2" == "view" ]]; then
if [[ "${GH_HAS_LABEL:-false}" == "true" ]]; then
printf '%s\n' "vscode-extension-release"
fi
exit 0
fi
if [[ "$1" == "pr" && "$2" == "edit" && "$4" == "--remove-label" ]]; then
exit "${GH_REMOVE_LABEL_EXIT_CODE:-0}"
fi
if [[ "$1" == "pr" && "$2" == "edit" && "$4" == "--add-label" ]]; then
exit "${GH_ADD_LABEL_EXIT_CODE:-0}"
fi
exit 0
""");
var chmodResult = await RunBashCommandAsync($"chmod +x \"{fakeGhPath}\"");
Assert.Equal(0, chmodResult.ExitCode);
return new FakeGhFixture(callLogPath, $"{binDirectory}{Path.PathSeparator}{Environment.GetEnvironmentVariable("PATH")}");
}
private async Task<CommandResult> RunBashCommandAsync(string command)
{
using var process = new Process();
process.StartInfo = new ProcessStartInfo("bash")
{
WorkingDirectory = RepoRoot.Path,
RedirectStandardError = true,
RedirectStandardOutput = true,
UseShellExecute = false,
};
process.StartInfo.ArgumentList.Add("-c");
process.StartInfo.ArgumentList.Add(command);
process.Start();
// Read both streams concurrently to avoid deadlock when bash emits diagnostics.
var stdoutTask = process.StandardOutput.ReadToEndAsync();
var stderrTask = process.StandardError.ReadToEndAsync();
using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(1));
await process.WaitForExitAsync(timeout.Token);
var output = await stdoutTask + await stderrTask;
testOutput.WriteLine(output);
return new CommandResult(process.ExitCode, output);
}
private sealed record FakeGhFixture(string CallLogPath, string PathEnvironment);
private sealed record CommandResult(int ExitCode, string Output);
}