Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions pkg/parser/frontmatter_hash.go
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,10 @@ func extractRelevantTemplateExpressions(markdown string) []string {
// extractFrontmatterAndBodyText extracts frontmatter as raw text without parsing YAML
// Returns: frontmatterText, markdownBody, error
func extractFrontmatterAndBodyText(content string) (string, string, error) {
// Normalize CRLF to LF so that files with Windows line-endings produce the
// same frontmatter text (and therefore the same hash) as equivalent LF files.
content = strings.ReplaceAll(content, "\r\n", "\n")

lines := strings.Split(content, "\n")

// Check if content starts with frontmatter delimiter
Expand Down
160 changes: 160 additions & 0 deletions pkg/parser/frontmatter_hash_consistency_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,166 @@ engine: copilot
}
}

// TestHashConsistency_LFvsCRLF validates that workflow files with identical content but
// different newline conventions (LF vs CRLF) produce the same frontmatter hash.
// This is a regression test for cross-platform hash stability.
func TestHashConsistency_LFvsCRLF(t *testing.T) {
tempDir := t.TempDir()
cache := NewImportCache("")

testCases := []struct {
name string
content string // LF version; CRLF version is derived automatically
}{
{
name: "simple frontmatter",
content: `---
engine: copilot
description: Test workflow
on:
schedule: daily
---

# Test Workflow
`,
},
{
name: "complex frontmatter",
content: `---
engine: claude
description: Complex workflow
tracker-id: complex-test
timeout-minutes: 30
on:
schedule: daily
workflow_dispatch: true
permissions:
contents: read
actions: read
tools:
playwright:
version: v1.41.0
labels:
- test
- complex
bots:
- copilot
---

# Complex Workflow
`,
},
{
name: "frontmatter with env template expressions",
content: `---
engine: copilot
description: Workflow with template expressions
---

# Test

Use environment variable: ${{ env.MY_VAR }}
Use config variable: ${{ vars.MY_CONFIG }}
`,
},
{
name: "frontmatter with inlined-imports",
content: `---
engine: copilot
inlined-imports: true
description: Inlined imports workflow
---

# Inlined body content
Some multi-line
content here
`,
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
lfContent := tc.content
crlfContent := strings.ReplaceAll(tc.content, "\n", "\r\n")

lfFile := filepath.Join(tempDir, "lf-"+strings.ReplaceAll(tc.name, " ", "-")+".md")
crlfFile := filepath.Join(tempDir, "crlf-"+strings.ReplaceAll(tc.name, " ", "-")+".md")

err := os.WriteFile(lfFile, []byte(lfContent), 0644)
require.NoError(t, err, "Should write LF test file")
err = os.WriteFile(crlfFile, []byte(crlfContent), 0644)
require.NoError(t, err, "Should write CRLF test file")

lfHash, err := ComputeFrontmatterHashFromFile(lfFile, cache)
require.NoError(t, err, "Should compute hash for LF file")
assert.Len(t, lfHash, 64, "LF hash should be 64 characters")

crlfHash, err := ComputeFrontmatterHashFromFile(crlfFile, cache)
require.NoError(t, err, "Should compute hash for CRLF file")
assert.Len(t, crlfHash, 64, "CRLF hash should be 64 characters")

assert.Equal(t, lfHash, crlfHash, "LF and CRLF variants must produce identical hashes")
t.Logf(" ✓ LF=CRLF hash: %s", lfHash)
})
}
}

// TestHashConsistency_LFvsCRLF_WithImports validates that LF/CRLF invariance holds
// when workflows import other workflows.
func TestHashConsistency_LFvsCRLF_WithImports(t *testing.T) {
tempDir := t.TempDir()
sharedDir := filepath.Join(tempDir, "shared")
err := os.MkdirAll(sharedDir, 0755)
require.NoError(t, err, "Should create shared directory")

sharedLF := `---
tools:
playwright:
version: v1.41.0
labels:
- shared
- common
---

# Shared Content
`
mainLF := `---
engine: copilot
description: Main workflow
imports:
- shared/common.md
labels:
- main
---

# Main Workflow
`

cache := NewImportCache("")

// LF variant: both main and shared use LF
err = os.WriteFile(filepath.Join(sharedDir, "common.md"), []byte(sharedLF), 0644)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(tempDir, "main-lf.md"), []byte(mainLF), 0644)
require.NoError(t, err)
lfHash, err := ComputeFrontmatterHashFromFile(filepath.Join(tempDir, "main-lf.md"), cache)
require.NoError(t, err, "Should compute hash for LF files with imports")

// CRLF variant: both main and shared use CRLF
sharedCRLF := strings.ReplaceAll(sharedLF, "\n", "\r\n")
mainCRLF := strings.ReplaceAll(mainLF, "\n", "\r\n")
err = os.WriteFile(filepath.Join(sharedDir, "common.md"), []byte(sharedCRLF), 0644)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(tempDir, "main-crlf.md"), []byte(mainCRLF), 0644)
require.NoError(t, err)
crlfHash, err := ComputeFrontmatterHashFromFile(filepath.Join(tempDir, "main-crlf.md"), cache)
require.NoError(t, err, "Should compute hash for CRLF files with imports")

assert.Equal(t, lfHash, crlfHash, "LF and CRLF import variants must produce identical hashes")
t.Logf(" ✓ LF with imports hash: %s", lfHash)
t.Logf(" ✓ CRLF with imports hash: %s", crlfHash)
}

// computeHashViaNode computes the hash using the JavaScript implementation via Node.js
func computeHashViaNode(workflowPath string) (string, error) {
// Get working directory and construct path to JavaScript file
Expand Down
Loading