Skip to content

Commit 8618d79

Browse files
committed
Fix CRLF documents collapsing to a single line in the workflow YAML scanner
Line.scan split on "\n" as a Character. Swift's Character is an extended grapheme cluster, and CR+LF is itself one grapheme cluster, so splitting on "\n" alone matches nothing in CRLF-encoded text: the whole document collapses into a single pseudo-line, and GitHub.ContinuousIntegration. Workflow.Document.jobs comes back empty. This is not platform-conditional code — it is a Unicode-model fact that fires on any platform whenever the input bytes are CRLF, which in practice means a Windows git checkout of an LF-committed workflow file (default core.autocrlf). Reproduced directly by feeding the parser CRLF text, no Windows machine required. Fix: normalize CRLF and bare CR to LF before splitting, which also removes the now-redundant per-line trailing-\r strip. Root cause for institute-continuous-integration#13's Windows-only .noJobs failures.
1 parent 215bee6 commit 8618d79

2 files changed

Lines changed: 78 additions & 2 deletions

File tree

Sources/GitHub Continuous Integration Workflow/GitHub.ContinuousIntegration.Workflow.YAML.Line.swift

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import Foundation
12
import GitHub_Continuous_Integration
23
import GitHub_Standard
34

@@ -31,10 +32,26 @@ extension GitHub.ContinuousIntegration.Workflow.YAML {
3132
}
3233

3334
static func scan(_ text: String) -> [Self] {
34-
text.split(separator: "\n", omittingEmptySubsequences: false)
35+
// Normalise line endings *before* splitting. Swift's `Character`
36+
// is an extended grapheme cluster, and CR+LF is itself one
37+
// grapheme cluster — so `text.split(separator: "\n")` on a
38+
// CRLF document matches nothing and the entire document
39+
// collapses into a single "line". This is not
40+
// platform-conditional code reading a platform fact; it is a
41+
// Unicode-model fact that fires identically on every OS
42+
// whenever the *input bytes* happen to be CRLF, which in
43+
// practice means every Windows `git checkout` of an
44+
// LF-committed workflow file (default `core.autocrlf`).
45+
// Normalizing first (CRLF, then any bare CR) removes both the
46+
// collapse and the per-line trailing-`\r` case entirely, so
47+
// there is nothing left for `raw`/`content` to strip.
48+
let normalized = text
49+
.replacingOccurrences(of: "\r\n", with: "\n")
50+
.replacingOccurrences(of: "\r", with: "\n")
51+
return normalized.split(separator: "\n", omittingEmptySubsequences: false)
3552
.enumerated()
3653
.map { offset, raw in
37-
let raw = raw.hasSuffix("\r") ? String(raw.dropLast()) : String(raw)
54+
let raw = String(raw)
3855
return Self(
3956
number: offset + 1,
4057
indent: raw.prefix(while: { $0 == " " }).count,
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import GitHub_Continuous_Integration
2+
import GitHub_Standard
3+
import Testing
4+
5+
@testable import GitHub_Continuous_Integration_Workflow
6+
7+
/// Regression coverage for the Windows-only `.noJobs` defect
8+
/// (institute-continuous-integration#13): a CRLF-encoded workflow
9+
/// document parsed to a single top-level key and zero jobs.
10+
///
11+
/// Root cause: `Line.scan` split on `"\n"` as a `Character`. Swift's
12+
/// `Character` is an extended grapheme cluster, and CR+LF is itself one
13+
/// grapheme cluster, so `text.split(separator: "\n")` on a CRLF document
14+
/// matches nothing and the whole document collapses into one pseudo-line.
15+
/// This is not Windows-conditional code — it is a Unicode-model fact that
16+
/// fires on any platform whenever the *input bytes* are CRLF, which in
17+
/// practice means every Windows `git checkout` of an LF-committed
18+
/// workflow file (default `core.autocrlf`). Reproduced here on macOS by
19+
/// feeding the parser CRLF text directly, with no Windows machine
20+
/// required.
21+
@Suite
22+
struct CIWorkflowCRLFTests {
23+
static let caller = """
24+
name: CI
25+
26+
on:
27+
push:
28+
branches: [main]
29+
pull_request:
30+
31+
jobs:
32+
ci:
33+
uses: swift-primitives/.github/.github/workflows/swift-ci.yml@main
34+
secrets: inherit
35+
lint:
36+
runs-on: ubuntu-latest
37+
steps:
38+
- uses: actions/checkout@v6
39+
"""
40+
41+
@Test func `CRLF document parses the same jobs as its LF original`() throws {
42+
let lf = Self.caller
43+
let crlf = lf.replacingOccurrences(of: "\n", with: "\r\n")
44+
45+
let lfDocument = try GitHub.ContinuousIntegration.Workflow.Document(name: "ci.yml", text: lf)
46+
let crlfDocument = try GitHub.ContinuousIntegration.Workflow.Document(name: "ci.yml", text: crlf)
47+
48+
#expect(crlfDocument.jobs.map(\.name) == lfDocument.jobs.map(\.name))
49+
#expect(crlfDocument.jobs.map(\.name) == ["ci", "lint"])
50+
}
51+
52+
@Test func `a lone CR line ending is also normalised`() throws {
53+
let lf = Self.caller
54+
let cr = lf.replacingOccurrences(of: "\n", with: "\r")
55+
56+
let document = try GitHub.ContinuousIntegration.Workflow.Document(name: "ci.yml", text: cr)
57+
#expect(document.jobs.map(\.name) == ["ci", "lint"])
58+
}
59+
}

0 commit comments

Comments
 (0)