File: WorkflowScripts\ExtensionReleaseWorkflowTests.cs
Web Access
Project: src\tests\Infrastructure.Tests\Infrastructure.Tests.csproj (Infrastructure.Tests)
// 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;
using YamlDotNet.RepresentationModel;
 
namespace Infrastructure.Tests;
 
public sealed class ExtensionReleaseWorkflowTests(ITestOutputHelper testOutput)
{
    private static readonly string s_releaseWorkflowPath = Path.Combine(RepoRoot.Path, ".github", "workflows", "extension-release.yml");
    private static readonly string s_changelogWorkflowPath = Path.Combine(RepoRoot.Path, ".github", "workflows", "extension-changelog.md");
    private static readonly string s_changelogWorkflowLockPath = Path.Combine(RepoRoot.Path, ".github", "workflows", "extension-changelog.lock.yml");
    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]
    public void ExtensionReleaseWorkflowUsesSharedGeneratorWithoutSilentCapsOrTruncation()
    {
        var workflow = File.ReadAllText(s_releaseWorkflowPath);
 
        Assert.Contains("generate_deterministic_release_notes.py", workflow, StringComparison.Ordinal);
        Assert.False(
            workflow.Contains("""printf '%.8000s'""", StringComparison.Ordinal),
            "The workflow must not silently truncate the fallback release notes.");
        Assert.False(
            workflow.Contains("""if [ "$NOTE_COUNT" -ge 8 ]""", StringComparison.Ordinal),
            "The workflow must not silently stop after eight accepted entries.");
    }
 
    [Fact]
    public void ExtensionChangelogPromptRequiresLocalFullRangeEnumerationAndRejectsPrBodyAsAuthority()
    {
        var prompt = File.ReadAllText(s_changelogWorkflowPath);
        var compiledWorkflow = File.ReadAllText(s_changelogWorkflowLockPath);
 
        Assert.Contains(
            "The PR body, description, and deterministic fallback notes are presentation-only",
            prompt,
            StringComparison.Ordinal);
        Assert.Contains(
            "MUST NOT be used to discover the change set.",
            prompt,
            StringComparison.Ordinal);
        Assert.Contains(
            "`${RUNNER_TEMP}/gh-aw/extension-changelog-candidates.tsv`",
            prompt,
            StringComparison.Ordinal);
        Assert.Contains(
            "A deterministic pre-agent step already validated and materialized the",
            prompt,
            StringComparison.Ordinal);
        Assert.Contains(
            "Do not run `git` or",
            prompt,
            StringComparison.Ordinal);
        Assert.Contains(
            "perform any network fetch in the agent step.",
            prompt,
            StringComparison.Ordinal);
        Assert.Contains(
            "Each line contains the 40-character commit SHA",
            prompt,
            StringComparison.Ordinal);
        Assert.Contains(
            "Count every line and keep that exact candidate count",
            prompt,
            StringComparison.Ordinal);
        Assert.Contains(
            "Consider and classify **every** candidate",
            prompt,
            StringComparison.Ordinal);
        Assert.Contains(
            "You may group related user-facing commits into one final note",
            prompt,
            StringComparison.Ordinal);
        Assert.Contains(
            "MUST NOT stop after a fixed number",
            prompt,
            StringComparison.Ordinal);
        Assert.Contains(
            "report the exact candidate count from Step 4",
            prompt,
            StringComparison.Ordinal);
        Assert.Contains(
            "how many candidates were included in the final notes and how many were excluded",
            prompt,
            StringComparison.Ordinal);
        Assert.Contains(
            "the included/excluded totals (or equivalent auditable classification totals)",
            prompt,
            StringComparison.Ordinal);
        Assert.Contains(
            "The authoritative candidate file is missing or unreadable after pre-agent",
            prompt,
            StringComparison.Ordinal);
        Assert.Contains(
            "materialization, or required enrichment searches fail outright",
            prompt,
            StringComparison.Ordinal);
        Assert.Contains(
            "<!-- aspire-ext-changelog-finalized from=<FROM_SHA> to=<TO_SHA> base=<BASE_VERSION> -->",
            prompt,
            StringComparison.Ordinal);
        Assert.Contains(
            "The pending `aspire-ext-changelog` marker MUST NOT survive",
            prompt,
            StringComparison.Ordinal);
        Assert.Contains(
            "replaced with the finalized marker and your generated notes",
            prompt,
            StringComparison.Ordinal);
        Assert.False(
            prompt.Contains("Use the compare API to get the commits between the validated SHAs:", StringComparison.Ordinal),
            "The prompt must not treat API pagination as the authoritative candidate set.");
        Assert.False(
            prompt.Contains("If the checkout is shallow or either SHA is missing locally, fetch the exact", StringComparison.Ordinal),
            "The prompt must not ask the agent to fetch history after credentials are removed.");
        Assert.False(
            prompt.Contains("after explicit fetch", StringComparison.Ordinal),
            "The prompt must not describe failure handling in terms of an agent-side fetch that is no longer allowed.");
        Assert.False(
            prompt.Contains("Use local git as the authoritative source", StringComparison.Ordinal),
            "The prompt must consume the pre-agent candidate file instead of invoking Git.");
        Assert.False(
            prompt.Contains("`git log", StringComparison.Ordinal),
            "The prompt must not expose broad Git execution to the agent.");
        Assert.DoesNotContain(
            "/tmp/gh-aw/agent/extension-changelog-candidates.tsv",
            prompt,
            StringComparison.Ordinal);
        var tools = GetSection(prompt, "^tools:", "^pre-agent-steps:");
        Assert.DoesNotContain("\"git\"", tools, StringComparison.Ordinal);
        Assert.Contains(
            "--mount \"${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro\"",
            compiledWorkflow,
            StringComparison.Ordinal);
        Assert.DoesNotContain(
            "/tmp/gh-aw/agent/extension-changelog-candidates.tsv",
            compiledWorkflow,
            StringComparison.Ordinal);
        Assert.Contains("{{#runtime-import .github/workflows/extension-changelog.md}}", compiledWorkflow, StringComparison.Ordinal);
    }
 
    [Fact]
    public void ExtensionReleaseWorkflowValidatesPrBodyLengthAndRelabelsAtomically()
    {
        var workflow = File.ReadAllText(s_releaseWorkflowPath);
 
        Assert.Contains("validate_github_pr_body.py", workflow, StringComparison.Ordinal);
        Assert.Contains("apply_extension_release_trigger_label.sh", workflow, StringComparison.Ordinal);
        Assert.False(
            workflow.Contains("""gh pr edit "$PR_NUMBER" --remove-label vscode-extension-release >/dev/null 2>&1 || true""", StringComparison.Ordinal),
            "The workflow must not swallow a failed label removal before re-adding the trigger label.");
    }
 
    [Fact]
    public void ExtensionReleaseWorkflowChecksOutHelpersFromWorkflowDefinitionCommit()
    {
        var yaml = new YamlStream();
        using var reader = new StringReader(File.ReadAllText(s_releaseWorkflowPath));
        yaml.Load(reader);
 
        var root = (YamlMappingNode)yaml.Documents[0].RootNode;
        var jobs = (YamlMappingNode)root.Children[new YamlScalarNode("jobs")];
        var prepareReleaseJob = (YamlMappingNode)jobs.Children[new YamlScalarNode("prepare-release")];
        var steps = ((YamlSequenceNode)prepareReleaseJob.Children[new YamlScalarNode("steps")]).Cast<YamlMappingNode>().ToList();
 
        var checkoutRepositoryStep = Assert.Single(steps, step => Scalar(step, "name") == "Checkout Repository");
        Assert.Equal("actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd", Scalar(checkoutRepositoryStep, "uses"));
        var checkoutRepositoryWith = Assert.IsType<YamlMappingNode>(checkoutRepositoryStep.Children[new YamlScalarNode("with")]);
        Assert.Equal("main", Scalar(checkoutRepositoryWith, "ref"));
        Assert.Equal("0", Scalar(checkoutRepositoryWith, "fetch-depth"));
        Assert.Equal("false", Scalar(checkoutRepositoryWith, "persist-credentials"));
 
        var helperCheckoutStep = Assert.Single(steps, step => Scalar(step, "name") == "Checkout workflow helper scripts");
        Assert.Equal("actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd", Scalar(helperCheckoutStep, "uses"));
        var helperCheckoutWith = Assert.IsType<YamlMappingNode>(helperCheckoutStep.Children[new YamlScalarNode("with")]);
        Assert.Equal("${{ github.workflow_sha }}", Scalar(helperCheckoutWith, "ref"));
        Assert.Equal(".extension-release-workflow-source", Scalar(helperCheckoutWith, "path"));
        Assert.Equal("1", Scalar(helperCheckoutWith, "fetch-depth"));
        Assert.Equal("false", Scalar(helperCheckoutWith, "persist-credentials"));
        Assert.Equal(".github/workflows/extension-release\n", Scalar(helperCheckoutWith, "sparse-checkout")?.ReplaceLineEndings("\n"));
 
        var workflow = File.ReadAllText(s_releaseWorkflowPath);
        Assert.Contains(
            "python3 .extension-release-workflow-source/.github/workflows/extension-release/generate_deterministic_release_notes.py",
            workflow,
            StringComparison.Ordinal);
        Assert.Contains(
            "python3 .extension-release-workflow-source/.github/workflows/extension-release/validate_github_pr_body.py",
            workflow,
            StringComparison.Ordinal);
        Assert.Contains(
            "bash .extension-release-workflow-source/.github/workflows/extension-release/apply_extension_release_trigger_label.sh",
            workflow,
            StringComparison.Ordinal);
        Assert.DoesNotContain(
            "python3 .github/workflows/extension-release/generate_deterministic_release_notes.py",
            workflow,
            StringComparison.Ordinal);
        Assert.DoesNotContain(
            "python3 .github/workflows/extension-release/validate_github_pr_body.py",
            workflow,
            StringComparison.Ordinal);
        Assert.DoesNotContain(
            "bash .github/workflows/extension-release/apply_extension_release_trigger_label.sh",
            workflow,
            StringComparison.Ordinal);
    }
 
    [Fact]
    public void CompiledWorkflowPreloadsAuthoritativeRangeBeforeCleaningCredentials()
    {
        var workflow = File.ReadAllText(s_changelogWorkflowLockPath);
        var preloadScript = GetCompiledWorkflowRunScript("Preload authoritative marker range for local changelog enumeration");
 
        var configureGitCredentialsIndex = FindRequiredText(workflow, "Configure Git credentials");
        var checkoutPrBranchIndex = FindRequiredText(workflow, "Checkout PR branch");
        var preloadIndex = FindRequiredText(workflow, "Preload authoritative marker range for local changelog enumeration");
        var cleanCredentialsIndex = FindRequiredText(workflow, "Clean credentials");
 
        Assert.True(
            preloadIndex > configureGitCredentialsIndex,
            "The authoritative range preload must run after Git credentials are configured.");
        Assert.True(
            preloadIndex > checkoutPrBranchIndex,
            "The authoritative range preload must run after the PR branch checkout.");
        Assert.True(
            preloadIndex < cleanCredentialsIndex,
            "The authoritative range preload must finish before gh-aw removes Git credentials.");
        Assert.Contains("extension/CHANGELOG.md", preloadScript, StringComparison.Ordinal);
        Assert.Contains("aspire-ext-changelog", preloadScript, StringComparison.Ordinal);
        Assert.Contains("git fetch --no-tags", preloadScript, StringComparison.Ordinal);
        Assert.Contains(
            "CANDIDATES_FILE=\"${RUNNER_TEMP}/gh-aw/extension-changelog-candidates.tsv\"",
            preloadScript,
            StringComparison.Ordinal);
        Assert.Contains("git log --format='%H%x09%s' --no-merges", preloadScript, StringComparison.Ordinal);
        Assert.Contains("${FROM_SHA}..${TO_SHA}", preloadScript, StringComparison.Ordinal);
        Assert.Contains("-- extension/ > \"${CANDIDATES_FILE}\"", preloadScript, StringComparison.Ordinal);
        Assert.Contains("candidate_count=\"$(wc -l < \"${CANDIDATES_FILE}\")\"", preloadScript, StringComparison.Ordinal);
    }
 
    [Fact]
    [RequiresTools(["bash"])]
    public async Task CompiledWorkflowPreloadScriptHasValidBashSyntax()
    {
        var script = GetCompiledWorkflowRunScript("Preload authoritative marker range for local changelog enumeration");
        var result = await RunBashSyntaxCheckAsync(script);
 
        Assert.True(
            result.ExitCode == 0,
            $"Expected compiled preload script to pass 'bash -n'.{Environment.NewLine}{result.Output}");
    }
 
    [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 static string GetSection(string text, string startPattern, string endPattern)
    {
        var match = System.Text.RegularExpressions.Regex.Match(
            text,
            $"(?ms){startPattern}\\r?\\n.*?(?={endPattern})",
            System.Text.RegularExpressions.RegexOptions.CultureInvariant);
        Assert.True(match.Success, $"Could not find workflow section starting with '{startPattern}'.");
        return match.Value;
    }
 
    private static int FindRequiredText(string contents, string text)
    {
        var index = contents.IndexOf(text, StringComparison.Ordinal);
 
        Assert.True(index >= 0, $"Expected to find '{text}'.");
 
        return index;
    }
 
    private static string GetCompiledWorkflowRunScript(string stepName)
    {
        var yaml = new YamlStream();
        using var reader = new StringReader(File.ReadAllText(s_changelogWorkflowLockPath));
        yaml.Load(reader);
 
        var root = (YamlMappingNode)yaml.Documents[0].RootNode;
        var jobs = (YamlMappingNode)root.Children[new YamlScalarNode("jobs")];
 
        foreach (var jobEntry in jobs.Children)
        {
            if (jobEntry.Value is not YamlMappingNode job
                || !job.Children.TryGetValue(new YamlScalarNode("steps"), out var stepsNode)
                || stepsNode is not YamlSequenceNode steps)
            {
                continue;
            }
 
            foreach (var step in steps.Children.OfType<YamlMappingNode>())
            {
                if (Scalar(step, "name") == stepName)
                {
                    var run = Scalar(step, "run");
                    Assert.False(string.IsNullOrEmpty(run), $"Expected step '{stepName}' to define a run script.");
                    return run!;
                }
            }
        }
 
        Assert.Fail($"Could not find workflow step '{stepName}'.");
        return null!;
    }
 
    private async Task<CommandResult> RunBashSyntaxCheckAsync(string script)
    {
        using var process = new Process();
        process.StartInfo.FileName = "bash";
        process.StartInfo.ArgumentList.Add("-n");
        process.StartInfo.WorkingDirectory = RepoRoot.Path;
        process.StartInfo.RedirectStandardError = true;
        process.StartInfo.RedirectStandardInput = true;
        process.StartInfo.RedirectStandardOutput = true;
        process.StartInfo.UseShellExecute = false;
 
        process.Start();
        await process.StandardInput.WriteAsync(script);
        process.StandardInput.Close();
 
        // Read both streams concurrently to avoid deadlock when bash emits parser diagnostics.
        var stdoutTask = process.StandardOutput.ReadToEndAsync();
        var stderrTask = process.StandardError.ReadToEndAsync();
        using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
        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 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 FakeGhFixture(string CallLogPath, string PathEnvironment);
    private sealed record CommandResult(int ExitCode, string Output);
}