diff --git a/src/Runner.Worker/Dap/JobExecutionViewRenderer.cs b/src/Runner.Worker/Dap/JobExecutionViewRenderer.cs index 1bf59b528..d01743b84 100644 --- a/src/Runner.Worker/Dap/JobExecutionViewRenderer.cs +++ b/src/Runner.Worker/Dap/JobExecutionViewRenderer.cs @@ -367,6 +367,11 @@ namespace GitHub.Runner.Worker.Dap } using var sw = new StringWriter(CultureInfo.InvariantCulture); + // Force LF line breaks; YamlDotNet's Emitter calls WriteLine, + // which would otherwise produce CRLF on Windows and break + // both our document-end stripping below and downstream + // consumers that assume a single line-break convention. + sw.NewLine = "\n"; var emitter = new Emitter(sw); emitter.Emit(new StreamStart()); emitter.Emit(new DocumentStart(null, null, true)); diff --git a/src/Runner.Worker/Dap/TemplateTokenYamlAdapter.cs b/src/Runner.Worker/Dap/TemplateTokenYamlAdapter.cs index 262dc20bc..973ee7ee4 100644 --- a/src/Runner.Worker/Dap/TemplateTokenYamlAdapter.cs +++ b/src/Runner.Worker/Dap/TemplateTokenYamlAdapter.cs @@ -95,6 +95,11 @@ namespace GitHub.Runner.Worker.Dap } using var sw = new StringWriter(CultureInfo.InvariantCulture); + // Force LF line breaks; YamlDotNet's Emitter calls WriteLine, + // which would otherwise produce CRLF on Windows and corrupt + // both the document-end stripping below and the per-line + // indentation pass that follows. + sw.NewLine = "\n"; var emitter = new Emitter(sw); var adapter = new TemplateTokenYamlAdapter(emitter); adapter.WriteStart(); diff --git a/src/Test/L0/Worker/JobExecutionViewRendererL0.cs b/src/Test/L0/Worker/JobExecutionViewRendererL0.cs index 02d42d940..608c71e3d 100644 --- a/src/Test/L0/Worker/JobExecutionViewRendererL0.cs +++ b/src/Test/L0/Worker/JobExecutionViewRendererL0.cs @@ -613,5 +613,31 @@ namespace GitHub.Runner.Common.Tests.Worker // entry gets an inline skipped annotation. Assert.Equal(unmarked.EntryStartLines[1], marked.EntryStartLines[1]); } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Render_AlwaysUsesLfLineBreaks() + { + // Regression: YamlDotNet's Emitter calls WriteLine, which on + // Windows produces CRLF (the host's Environment.NewLine). + // FormatScalar / TemplateTokenYamlAdapter.Serialize must force + // LF so the rendered view round-trips regardless of platform. + var entry = new JobExecutionViewEntry(JobExecutionPhase.Main, "with: colon", id: "step-1", uses: "actions/checkout@v4"); + var result = JobExecutionViewRenderer.Render("job-1", new[] { entry }); + Assert.DoesNotContain("\r", result.Yaml); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void FormatScalar_AlwaysUsesLfLineBreaks() + { + // Direct check on FormatScalar to guard against future refactors + // that bypass the full Render path but still emit through + // YamlDotNet. + Assert.DoesNotContain("\r", JobExecutionViewRenderer.FormatScalar("with: colon")); + Assert.DoesNotContain("\r", JobExecutionViewRenderer.FormatScalar("hello")); + } } } diff --git a/src/Test/L0/Worker/TemplateTokenYamlAdapterL0.cs b/src/Test/L0/Worker/TemplateTokenYamlAdapterL0.cs index c6726d71f..8406bae66 100644 --- a/src/Test/L0/Worker/TemplateTokenYamlAdapterL0.cs +++ b/src/Test/L0/Worker/TemplateTokenYamlAdapterL0.cs @@ -170,5 +170,22 @@ namespace GitHub.Runner.Common.Tests.Worker string yaml = TemplateTokenYamlAdapter.Serialize(token, 0); Assert.False(yaml.EndsWith("\n")); } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Serialize_AlwaysUsesLfLineBreaks() + { + // Regression: YamlDotNet's Emitter calls WriteLine, which on + // Windows produces CRLF (the host's Environment.NewLine). + // Serialize must force LF so the rendered view round-trips + // regardless of platform. + var map = new MappingToken(null, null, null); + map.Add(Str("k1"), Str("v1")); + map.Add(Str("k2"), Num(2)); + map.Add(Str("k3"), Bool(true)); + string yaml = TemplateTokenYamlAdapter.Serialize(map, indentSpaces: 2); + Assert.DoesNotContain("\r", yaml); + } } }