// 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.RegularExpressions;
using Aspire.TestUtilities;
using Xunit;
using YamlDotNet.RepresentationModel;
namespace Infrastructure.Tests;
/// <summary>
/// Guards the always-on PR merge gate that keeps the VS Code extension changelog check red until
/// generated placeholder release notes are replaced with finalized content.
/// </summary>
public sealed class ExtensionChangelogFinalizedWorkflowTests(ITestOutputHelper output) : IDisposable
{
private const string WorkflowRelativePath = ".github/workflows/extension-changelog-finalized.yml";
private const string ScriptRelativePath = ".github/workflows/check-extension-changelog-finalized.sh";
private readonly TemporaryWorkspace _workspace = TemporaryWorkspace.Create(output);
public void Dispose() => _workspace.Dispose();
[Fact]
public void WorkflowRunsOnEveryPullRequestWithLeastPrivilegeAndStableCheckName()
{
var workflowText = ReadWorkflowText();
var trigger = GetTopLevelSection(workflowText, "on");
Assert.Equal(
" pull_request:\n",
trigger.ReplaceLineEndings("\n"));
var workflow = LoadWorkflow();
var root = (YamlMappingNode)workflow.Documents[0].RootNode;
var permissions = (YamlMappingNode)root.Children[new YamlScalarNode("permissions")];
Assert.Collection(
permissions.Children,
entry =>
{
Assert.Equal("contents", ((YamlScalarNode)entry.Key).Value);
Assert.Equal("read", ((YamlScalarNode)entry.Value).Value);
});
var jobs = (YamlMappingNode)root.Children[new YamlScalarNode("jobs")];
var gateJob = Assert.IsType<YamlMappingNode>(Assert.Single(jobs.Children).Value);
Assert.Equal("Extension changelog finalized", Scalar(gateJob, "name"));
Assert.Contains("strict/up-to-date semantics", workflowText, StringComparison.Ordinal);
var steps = ((YamlSequenceNode)gateJob.Children[new YamlScalarNode("steps")]).Cast<YamlMappingNode>();
var checkoutStep = Assert.Single(steps, step => Scalar(step, "uses")?.Contains("actions/checkout", StringComparison.Ordinal) == true);
var checkoutWith = Assert.IsType<YamlMappingNode>(checkoutStep.Children[new YamlScalarNode("with")]);
Assert.Equal("${{ github.event.pull_request.head.sha }}", Scalar(checkoutWith, "ref"));
Assert.Equal(
"${{ startsWith(github.head_ref, 'extension-release/') && github.base_ref == 'main' && '0' || '1' }}",
Scalar(checkoutWith, "fetch-depth"));
Assert.Equal("false", Scalar(checkoutWith, "persist-credentials"));
Assert.DoesNotContain(
steps,
step => Scalar(step, "name") == "Preload trusted base history for release-branch stale range checks");
Assert.DoesNotContain("git fetch", workflowText, StringComparison.Ordinal);
var verifyStep = Assert.Single(
steps,
step => Scalar(step, "run")?.Contains("bash .github/workflows/check-extension-changelog-finalized.sh", StringComparison.Ordinal) == true);
var verifyEnv = Assert.IsType<YamlMappingNode>(verifyStep.Children[new YamlScalarNode("env")]);
Assert.Equal("${{ github.head_ref }}", Scalar(verifyEnv, "PR_HEAD_REF"));
Assert.Equal("${{ github.base_ref }}", Scalar(verifyEnv, "PR_BASE_REF"));
Assert.Equal("${{ github.event.pull_request.base.sha }}", Scalar(verifyEnv, "PR_BASE_SHA"));
Assert.Contains(
steps,
step => Scalar(step, "run")?.Contains("bash .github/workflows/check-extension-changelog-finalized.sh", StringComparison.Ordinal) == true);
}
[Theory]
[InlineData("<!-- aspire-ext-changelog from=123 to=456 base=1.2.3 -->", "pending release-notes marker", 3)]
[InlineData("_Release notes are being generated automatically and will replace this placeholder shortly._", "autogenerated placeholder prose", 3)]
[RequiresTools(["bash"])]
public async Task PlaceholderContentFailsWithActionableAnnotation(string placeholder, string expectedReason, int expectedLine)
{
await WriteChangelogAsync($"""
# Aspire VS Code Extension Changelog
{placeholder}
""");
var result = await RunGateScriptAsync();
Assert.NotEqual(0, result.ExitCode);
Assert.Contains(
$"::error file=extension/CHANGELOG.md,line={expectedLine},title=Finalize VS Code extension changelog::",
result.Output,
StringComparison.Ordinal);
Assert.Contains(expectedReason, result.Output, StringComparison.Ordinal);
Assert.Contains(
"Replace the placeholder with finalized release notes before merging.",
result.Output,
StringComparison.Ordinal);
}
[Fact]
[RequiresTools(["bash"])]
public async Task FinalizedChangelogPasses()
{
await WriteChangelogAsync("""
# Aspire VS Code Extension Changelog
## v1.99.0
### Features
- Ship finalized extension release notes.
""");
var result = await RunGateScriptAsync();
Assert.Equal(0, result.ExitCode);
Assert.Equal("extension/CHANGELOG.md is finalized.\n", result.Output.ReplaceLineEndings("\n"));
}
[Fact]
[RequiresTools(["bash", "git"])]
public async Task ReleaseBranchWithFinalizedMarkerAndNonExtensionBaseAdvancePasses()
{
var repository = await CreateReleaseBranchRepositoryAsync(
currentSectionBodyFactory: (fromSha, toSha) => $$"""
<!-- aspire-ext-changelog-finalized from={{fromSha}} to={{toSha}} base=1.98.0 -->
### Fixes
- Ship finalized extension release notes.
""",
addExtensionChangeOnMain: false);
var result = await RunGateScriptAsync(repository.ReleaseBranchEnvironment);
Assert.Equal(0, result.ExitCode);
Assert.Equal("extension/CHANGELOG.md is finalized.\n", result.Output.ReplaceLineEndings("\n"));
}
[Fact]
[RequiresTools(["bash", "git"])]
public async Task ReleaseBranchAcceptsLegacyDoneMarkerFromBaseBranchWorkflow()
{
var repository = await CreateReleaseBranchRepositoryAsync(
currentSectionBodyFactory: (fromSha, toSha) => $$"""
<!-- aspire-ext-changelog-done from={{fromSha}} to={{toSha}} base=1.98.0 -->
### Fixes
- Ship finalized extension release notes.
""",
addExtensionChangeOnMain: false);
var result = await RunGateScriptAsync(repository.ReleaseBranchEnvironment);
Assert.Equal(0, result.ExitCode);
Assert.Equal("extension/CHANGELOG.md is finalized.\n", result.Output.ReplaceLineEndings("\n"));
}
[Fact]
[RequiresTools(["bash", "git"])]
public async Task ReleaseBranchAllowsSyntacticallyValidButMissingFromSha()
{
const string missingFromSha = "1111111111111111111111111111111111111111";
var repository = await CreateReleaseBranchRepositoryAsync(
currentSectionBodyFactory: (_, toSha) => $$"""
<!-- aspire-ext-changelog-finalized from={{missingFromSha}} to={{toSha}} base=1.98.0 -->
### Fixes
- Ship finalized extension release notes.
""",
addExtensionChangeOnMain: false);
var result = await RunGateScriptAsync(repository.ReleaseBranchEnvironment);
Assert.Equal(0, result.ExitCode);
Assert.Equal("extension/CHANGELOG.md is finalized.\n", result.Output.ReplaceLineEndings("\n"));
}
[Fact]
[RequiresTools(["bash", "git"])]
public async Task ReleaseBranchIgnoresHistoricalFinalizedMarkersOutsideTheCurrentSection()
{
var repository = await CreateReleaseBranchRepositoryAsync(
currentSectionBodyFactory: (fromSha, toSha) => $$"""
<!-- aspire-ext-changelog-finalized from={{fromSha}} to={{toSha}} base=1.98.0 -->
### Fixes
- Ship finalized extension release notes.
""",
addExtensionChangeOnMain: false,
olderSectionBodyFactory: (fromSha, toSha) => $$"""
<!-- aspire-ext-changelog-finalized from={{fromSha}} to={{toSha}} base=1.97.0 -->
### Fixes
- Previous finalized release note.
""");
var result = await RunGateScriptAsync(repository.ReleaseBranchEnvironment);
Assert.Equal(0, result.ExitCode);
Assert.Equal("extension/CHANGELOG.md is finalized.\n", result.Output.ReplaceLineEndings("\n"));
}
[Fact]
[RequiresTools(["bash", "git"])]
public async Task ReleaseBranchWithStaleExtensionBaseAdvanceFails()
{
var repository = await CreateReleaseBranchRepositoryAsync(
currentSectionBodyFactory: (fromSha, toSha) => $$"""
<!-- aspire-ext-changelog-finalized from={{fromSha}} to={{toSha}} base=1.98.0 -->
### Fixes
- Ship finalized extension release notes.
""",
addExtensionChangeOnMain: true);
var result = await RunGateScriptAsync(repository.ReleaseBranchEnvironment);
Assert.NotEqual(0, result.ExitCode);
Assert.Contains("Finalized changelog range ends at", result.Output, StringComparison.Ordinal);
Assert.Contains("Restore the pending placeholder entry", result.Output, StringComparison.Ordinal);
Assert.Contains("re-add the `vscode-extension-release` label", result.Output, StringComparison.Ordinal);
}
[Fact]
[RequiresTools(["bash", "git"])]
public async Task ReleaseBranchRequiresExactlyOneFinalizedMarker()
{
var repository = await CreateReleaseBranchRepositoryAsync(
currentSectionBodyFactory: (_, _) => """
### Fixes
- Ship finalized extension release notes.
""",
addExtensionChangeOnMain: false);
var result = await RunGateScriptAsync(repository.ReleaseBranchEnvironment);
Assert.NotEqual(0, result.ExitCode);
Assert.Contains("Expected exactly one finalized release marker", result.Output, StringComparison.Ordinal);
Assert.Contains("Restore the pending placeholder entry", result.Output, StringComparison.Ordinal);
}
[Fact]
[RequiresTools(["bash", "git"])]
public async Task ReleaseBranchCurrentVersionSectionRequiresReleaseNoteBullets()
{
var repository = await CreateReleaseBranchRepositoryAsync(
currentSectionBodyFactory: (fromSha, toSha) => $$"""
<!-- aspire-ext-changelog-finalized from={{fromSha}} to={{toSha}} base=1.98.0 -->
""",
addExtensionChangeOnMain: false);
var result = await RunGateScriptAsync(repository.ReleaseBranchEnvironment);
Assert.NotEqual(0, result.ExitCode);
Assert.Contains("must contain at least one '- ' release-note bullet with non-whitespace content", result.Output, StringComparison.Ordinal);
Assert.Contains("Restore the pending placeholder entry", result.Output, StringComparison.Ordinal);
}
[Fact]
[RequiresTools(["bash", "git"])]
public async Task ReleaseBranchRejectsWhitespaceOnlyReleaseNoteBullets()
{
var repository = await CreateReleaseBranchRepositoryAsync(
currentSectionBodyFactory: (fromSha, toSha) => $$"""
<!-- aspire-ext-changelog-finalized from={{fromSha}} to={{toSha}} base=1.98.0 -->
{{ "- " }}
""",
addExtensionChangeOnMain: false);
var result = await RunGateScriptAsync(repository.ReleaseBranchEnvironment);
Assert.NotEqual(0, result.ExitCode);
Assert.Contains("must contain at least one '- ' release-note bullet with non-whitespace content", result.Output, StringComparison.Ordinal);
Assert.Contains("Restore the pending placeholder entry", result.Output, StringComparison.Ordinal);
}
[Fact]
[RequiresTools(["bash", "git"])]
public async Task ReleaseBranchGatePassesWhenFinalizedRangeIsAvailable()
{
var repository = await CreateReleaseBranchRepositoryAsync(
currentSectionBodyFactory: (fromSha, toSha) => $$"""
<!-- aspire-ext-changelog-finalized from={{fromSha}} to={{toSha}} base=1.98.0 -->
### Fixes
- Current release note.
""",
addExtensionChangeOnMain: false);
var gateResult = await RunGateScriptAsync(repository.ReleaseBranchEnvironment);
Assert.Equal(0, gateResult.ExitCode);
}
private async Task WriteChangelogAsync(string content)
{
var changelogPath = Path.Combine(_workspace.Path, "extension", "CHANGELOG.md");
Directory.CreateDirectory(Path.GetDirectoryName(changelogPath)!);
await File.WriteAllTextAsync(changelogPath, content.ReplaceLineEndings("\n"));
}
private async Task<CommandResult> RunGateScriptAsync(IReadOnlyDictionary<string, string?>? environment = null)
=> await RunGateScriptAsync(_workspace.Path, environment);
private static async Task<CommandResult> RunGateScriptAsync(string workingDirectory, IReadOnlyDictionary<string, string?>? environment = null)
{
var scriptPath = Path.Combine(RepoRoot.Path, ScriptRelativePath);
Assert.True(File.Exists(scriptPath), $"Expected helper script at '{ScriptRelativePath}'.");
using var process = new Process();
process.StartInfo.FileName = "bash";
process.StartInfo.ArgumentList.Add(scriptPath);
process.StartInfo.WorkingDirectory = workingDirectory;
process.StartInfo.RedirectStandardError = true;
process.StartInfo.RedirectStandardOutput = true;
process.StartInfo.UseShellExecute = false;
if (environment is not null)
{
foreach (var (key, value) in environment)
{
process.StartInfo.Environment[key] = value ?? string.Empty;
}
}
process.Start();
// Read both streams concurrently to avoid deadlock when the shell emits an annotation.
var stdoutTask = process.StandardOutput.ReadToEndAsync();
var stderrTask = process.StandardError.ReadToEndAsync();
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await process.WaitForExitAsync(timeout.Token);
return new CommandResult(process.ExitCode, await stdoutTask + await stderrTask);
}
private async Task<ReleaseBranchRepository> CreateReleaseBranchRepositoryAsync(
Func<string, string, string> currentSectionBodyFactory,
bool addExtensionChangeOnMain,
Func<string, string, string>? olderSectionBodyFactory = null)
{
await InitializeGitRepositoryAsync(_workspace.Path);
await WritePackageJsonAsync(_workspace.Path, "1.98.0");
await WriteChangelogAsync(_workspace.Path, """
# Aspire VS Code Extension Changelog
## v1.98.0
""");
await WriteWorkspaceFileAsync(_workspace.Path, Path.Combine("extension", "src", "baseline.txt"), "baseline");
await CommitAllAsync(_workspace.Path, "Create baseline changelog");
var fromSha = await GetHeadShaAsync();
await WriteChangelogAsync(
_workspace.Path,
$$"""
# Aspire VS Code Extension Changelog
## v1.98.0
{{olderSectionBodyFactory?.Invoke(fromSha, fromSha) ?? """
### Fixes
- Previous release note.
"""}}
""");
await CommitAllAsync(_workspace.Path, "Finalize prior release changelog");
await WriteWorkspaceFileAsync(_workspace.Path, Path.Combine("extension", "src", "feature.txt"), "feature-one");
await CommitAllAsync(_workspace.Path, "Add extension feature");
var toSha = await GetHeadShaAsync();
await RunGitAsync(_workspace.Path, "checkout", "-b", "extension-release/v1.99.0");
await WritePackageJsonAsync(_workspace.Path, "1.99.0");
await WriteChangelogAsync(_workspace.Path, $$"""
# Aspire VS Code Extension Changelog
## v1.99.0
{{currentSectionBodyFactory(fromSha, toSha)}}
## v1.98.0
{{olderSectionBodyFactory?.Invoke(fromSha, fromSha) ?? """
### Fixes
- Previous release note.
"""}}
""");
await CommitAllAsync(_workspace.Path, "Prepare release branch changelog");
await RunGitAsync(_workspace.Path, "checkout", "main");
if (addExtensionChangeOnMain)
{
await WriteWorkspaceFileAsync(_workspace.Path, Path.Combine("extension", "src", "late-main.txt"), "late extension change");
}
else
{
await WriteWorkspaceFileAsync(_workspace.Path, Path.Combine("docs", "late-main.md"), "non-extension main change");
}
await CommitAllAsync(_workspace.Path, "Advance main");
var baseSha = await GetHeadShaAsync(_workspace.Path);
await RunGitAsync(_workspace.Path, "checkout", "extension-release/v1.99.0");
return new ReleaseBranchRepository(
_workspace.Path,
new Dictionary<string, string?>
{
["PR_HEAD_REF"] = "extension-release/v1.99.0",
["PR_BASE_REF"] = "main",
["PR_BASE_SHA"] = baseSha,
},
fromSha,
toSha,
baseSha);
}
private async Task InitializeGitRepositoryAsync(string workingDirectory)
{
await RunGitAsync(workingDirectory, "init");
await RunGitAsync(workingDirectory, "checkout", "-b", "main");
await RunGitAsync(workingDirectory, "config", "user.email", "test@example.com");
await RunGitAsync(workingDirectory, "config", "user.name", "Test User");
}
private static async Task WritePackageJsonAsync(string workingDirectory, string version)
{
await WriteWorkspaceFileAsync(
workingDirectory,
Path.Combine("extension", "package.json"),
$$"""
{
"name": "aspire-vscode",
"version": "{{version}}"
}
""");
}
private static async Task WriteChangelogAsync(string workingDirectory, string content)
=> await WriteWorkspaceFileAsync(workingDirectory, Path.Combine("extension", "CHANGELOG.md"), content);
private static async Task WriteWorkspaceFileAsync(string workingDirectory, string relativePath, string content)
{
var fullPath = Path.Combine(workingDirectory, relativePath);
Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
await File.WriteAllTextAsync(fullPath, content.ReplaceLineEndings("\n"));
}
private async Task CommitAllAsync(string workingDirectory, string message)
{
await RunGitAsync(workingDirectory, "add", ".");
await RunGitAsync(workingDirectory, "commit", "-m", message);
}
private async Task<string> GetHeadShaAsync()
=> await GetHeadShaAsync(_workspace.Path);
private async Task<string> GetHeadShaAsync(string workingDirectory)
{
var result = await RunProcessAsync("git", ["rev-parse", "HEAD"], workingDirectory);
Assert.Equal(0, result.ExitCode);
return result.Output.Trim();
}
private async Task RunGitAsync(string workingDirectory, params string[] args)
{
var result = await RunProcessAsync("git", args, workingDirectory);
Assert.True(
result.ExitCode == 0,
$"git {string.Join(' ', args)} failed with exit code {result.ExitCode}.{Environment.NewLine}{result.Output}");
}
private async Task<CommandResult> RunProcessAsync(string fileName, IEnumerable<string> args, string workingDirectory)
{
using var process = new Process();
process.StartInfo.FileName = fileName;
process.StartInfo.WorkingDirectory = workingDirectory;
process.StartInfo.RedirectStandardError = true;
process.StartInfo.RedirectStandardOutput = true;
process.StartInfo.UseShellExecute = false;
foreach (var arg in args)
{
process.StartInfo.ArgumentList.Add(arg);
}
process.Start();
var stdoutTask = process.StandardOutput.ReadToEndAsync();
var stderrTask = process.StandardError.ReadToEndAsync();
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await process.WaitForExitAsync(timeout.Token);
var outputText = await stdoutTask + await stderrTask;
output.WriteLine(outputText);
return new CommandResult(process.ExitCode, outputText);
}
private static YamlStream LoadWorkflow()
{
var yaml = new YamlStream();
using var reader = new StringReader(ReadWorkflowText());
yaml.Load(reader);
return yaml;
}
private static string ReadWorkflowText()
{
var workflowPath = Path.Combine(RepoRoot.Path, WorkflowRelativePath);
Assert.True(File.Exists(workflowPath), $"Expected workflow file at '{WorkflowRelativePath}'.");
return File.ReadAllText(workflowPath);
}
private static string GetTopLevelSection(string text, string key)
{
var match = Regex.Match(
text,
$"(?m)^{Regex.Escape(key)}:\\r?\\n(?<value>(?:^[ ].*\\r?\\n)*)",
RegexOptions.CultureInvariant);
Assert.True(match.Success, $"Could not find top-level workflow section '{key}'.");
return match.Groups["value"].Value;
}
private static string? Scalar(YamlMappingNode node, string key)
=> node.Children.TryGetValue(new YamlScalarNode(key), out var value) && value is YamlScalarNode scalar
? scalar.Value
: null;
private sealed record ReleaseBranchRepository(
string WorkingDirectory,
IReadOnlyDictionary<string, string?> ReleaseBranchEnvironment,
string FromSha,
string ToSha,
string BaseSha);
private sealed record CommandResult(int ExitCode, string Output);
}