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
54 changes: 54 additions & 0 deletions .github/workflows/ci.yml
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot revert this file

Original file line number Diff line number Diff line change
Expand Up @@ -311,8 +311,62 @@ jobs:
run: make recompile
env:
GH_TOKEN: ${{ github.token }}

validate-yaml:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5

- name: Check for ANSI escape sequences in YAML files
run: |
echo "🔍 Scanning YAML workflow files for ANSI escape sequences..."

# Find all YAML files in .github/workflows directory
YAML_FILES=$(find .github/workflows -type f \( -name "*.yml" -o -name "*.yaml" \) | sort)

# Track if any ANSI codes are found
FOUND_ANSI=0

# Check each file for ANSI escape sequences
for file in $YAML_FILES; do
# Use grep to find ANSI escape sequences (ESC [ ... letter)
# The pattern matches: \x1b followed by [ followed by optional digits/semicolons followed by a letter
if grep -P '\x1b\[[0-9;]*[a-zA-Z]' "$file" > /dev/null 2>&1; then
echo "❌ ERROR: Found ANSI escape sequences in: $file"
echo ""
echo "Lines with ANSI codes:"
grep -n -P '\x1b\[[0-9;]*[a-zA-Z]' "$file" || true
echo ""
FOUND_ANSI=1
fi
done

if [ $FOUND_ANSI -eq 1 ]; then
echo ""
echo "💡 ANSI escape sequences detected in YAML files!"
echo ""
echo "These are terminal color codes that break YAML parsing."
echo "Common causes:"
echo " - Copy-pasting from colored terminal output"
echo " - Text editors preserving ANSI codes"
echo " - Scripts generating colored output"
echo ""
echo "To fix:"
echo " 1. Remove the ANSI codes from the affected files"
echo " 2. Run 'make recompile' to regenerate workflow files"
echo " 3. Use '--no-color' flags when capturing command output"
echo ""
exit 1
fi

echo "✅ No ANSI escape sequences found in YAML files"

js:
runs-on: ubuntu-latest
needs: validate-yaml
permissions:
contents: read
concurrency:
Expand Down
36 changes: 36 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,42 @@ data := map[string]any{"key": "value"}
yamlBytes, err := yaml.Marshal(data)
```

### YAML File Editing - ANSI Escape Code Prevention

**CRITICAL**: When editing or generating YAML workflow files (`.github/workflows/*.yml`, `*.lock.yml`):

1. **NEVER copy-paste from colored terminal output** - Always use `--no-color` or `2>&1 | cat` to strip colors
2. **Validate YAML before committing** - The compiler automatically strips ANSI codes during workflow generation
3. **Check for invisible characters** - Use `cat -A file.yml | grep '\[m'` to detect ANSI escape sequences
4. **Run make recompile** - Always recompile workflows after editing .md files to regenerate clean .lock.yml files

**Why this matters:**
ANSI escape sequences (`\x1b[31m`, `\x1b[0m`, `\x1b[m`) are terminal color codes that break YAML parsing. They can accidentally be introduced through:
- Copy-pasting from colored terminal output
- Text editors that preserve ANSI codes
- Scripts that generate colored output

**Example of safe command usage**:
```bash
# ❌ BAD - May include ANSI color codes
npm view @github/copilot | tee output.txt

# ✅ GOOD - Strip colors before saving
npm view @github/copilot --no-color | tee output.txt
# OR
npm view @github/copilot 2>&1 | cat | tee output.txt
```

**Prevention layers:**
1. **Compiler sanitization**: The workflow compiler (`pkg/workflow/compiler_yaml.go`) automatically strips ANSI codes from descriptions, sources, and comments using `stringutil.StripANSIEscapeCodes()`
2. **CI validation**: The `validate-yaml` job in `.github/workflows/ci.yml` scans all YAML files for ANSI escape sequences before other jobs run
3. **Detection command**: Run `find .github/workflows -name "*.yml" -o -name "*.yaml" | xargs grep -P '\x1b\[[0-9;]*[a-zA-Z]'` to check for ANSI codes

**If you encounter ANSI codes in workflow files:**
1. Remove the ANSI codes from the source markdown file
2. Run `make recompile` to regenerate clean workflow files
3. The compiler will automatically strip any ANSI codes during compilation

### Type Patterns and Best Practices

Use appropriate type patterns to improve code clarity, maintainability, and type safety:
Expand Down
28 changes: 28 additions & 0 deletions pkg/stringutil/stringutil.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package stringutil

import (
"fmt"
"regexp"
"strings"
)

Expand Down Expand Up @@ -56,3 +57,30 @@ func ParseVersionValue(version any) string {
return ""
}
}

// ansiEscapePattern matches ANSI escape sequences
// Pattern matches: ESC [ <optional params> <command letter>
// Examples: \x1b[0m, \x1b[31m, \x1b[1;32m
var ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`)

// StripANSIEscapeCodes removes ANSI escape sequences from a string.
// This prevents terminal color codes and other control sequences from
// being accidentally included in generated files (e.g., YAML workflows).
//
// Common ANSI escape sequences that are removed:
// - Color codes: \x1b[31m (red), \x1b[0m (reset)
// - Text formatting: \x1b[1m (bold), \x1b[4m (underline)
// - Cursor control: \x1b[2J (clear screen)
//
// Example:
//
// input := "Hello \x1b[31mWorld\x1b[0m" // "Hello [red]World[reset]"
// output := StripANSIEscapeCodes(input) // "Hello World"
//
// This function is particularly important for:
// - Workflow descriptions copied from terminal output
// - Comments in generated YAML files
// - Any text that should be plain ASCII
func StripANSIEscapeCodes(s string) string {
return ansiEscapePattern.ReplaceAllString(s, "")
}
157 changes: 157 additions & 0 deletions pkg/stringutil/stringutil_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -411,3 +411,160 @@ func TestParseVersionValue(t *testing.T) {
})
}
}

func TestStripANSIEscapeCodes(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "no ANSI codes",
input: "Hello World",
expected: "Hello World",
},
{
name: "simple color reset",
input: "Hello World[m",
expected: "Hello World[m", // [m without ESC is not an ANSI code
},
{
name: "ANSI color reset",
input: "Hello World\x1b[m",
expected: "Hello World",
},
{
name: "ANSI color code with reset",
input: "Hello \x1b[31mWorld\x1b[0m",
expected: "Hello World",
},
{
name: "ANSI bold text",
input: "\x1b[1mBold text\x1b[0m",
expected: "Bold text",
},
{
name: "multiple ANSI codes",
input: "\x1b[1m\x1b[31mRed Bold\x1b[0m",
expected: "Red Bold",
},
{
name: "ANSI with parameters",
input: "Text \x1b[1;32mgreen bold\x1b[0m more text",
expected: "Text green bold more text",
},
{
name: "ANSI clear screen",
input: "\x1b[2JCleared",
expected: "Cleared",
},
{
name: "empty string",
input: "",
expected: "",
},
{
name: "only ANSI codes",
input: "\x1b[0m\x1b[31m\x1b[1m",
expected: "",
},
{
name: "real-world example from issue",
input: "2. **REQUIRED**: Run 'make recompile' to update workflows (MUST be run after any constant changes)\x1b[m",
expected: "2. **REQUIRED**: Run 'make recompile' to update workflows (MUST be run after any constant changes)",
},
{
name: "another real-world example",
input: "- **SAVE TO CACHE**: Store help outputs (main and all subcommands) and version check results in cache-memory\x1b[m",
expected: "- **SAVE TO CACHE**: Store help outputs (main and all subcommands) and version check results in cache-memory",
},
{
name: "ANSI underline",
input: "\x1b[4mUnderlined\x1b[0m text",
expected: "Underlined text",
},
{
name: "ANSI 256 color",
input: "\x1b[38;5;214mOrange\x1b[0m",
expected: "Orange",
},
{
name: "mixed content with newlines",
input: "Line 1\x1b[31m\nLine 2\x1b[0m\nLine 3",
expected: "Line 1\nLine 2\nLine 3",
},
{
name: "ANSI cursor movement",
input: "\x1b[2AMove up\x1b[3BMove down",
expected: "Move upMove down",
},
{
name: "ANSI erase in line",
input: "Start\x1b[KEnd",
expected: "StartEnd",
},
{
name: "consecutive ANSI codes",
input: "\x1b[1m\x1b[31m\x1b[4mRed Bold Underline\x1b[0m\x1b[0m\x1b[0m",
expected: "Red Bold Underline",
},
{
name: "ANSI with large parameter",
input: "\x1b[38;5;255mWhite\x1b[0m",
expected: "White",
},
{
name: "ANSI RGB color (24-bit)",
input: "\x1b[38;2;255;128;0mOrange RGB\x1b[0m",
expected: "Orange RGB",
},
{
name: "ANSI codes in the middle of words",
input: "hel\x1b[31mlo\x1b[0m wor\x1b[32mld\x1b[0m",
expected: "hello world",
},
{
name: "ANSI save/restore cursor",
input: "Text\x1b[s more text\x1b[u end",
expected: "Text more text end",
},
{
name: "ANSI cursor position",
input: "\x1b[H\x1b[2JClear and home",
expected: "Clear and home",
},
{
name: "long string with multiple ANSI codes",
input: "\x1b[1mThis\x1b[0m \x1b[31mis\x1b[0m \x1b[32ma\x1b[0m \x1b[33mvery\x1b[0m \x1b[34mlong\x1b[0m \x1b[35mstring\x1b[0m \x1b[36mwith\x1b[0m \x1b[37mmany\x1b[0m \x1b[1mANSI\x1b[0m \x1b[4mcodes\x1b[0m",
expected: "This is a very long string with many ANSI codes",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := StripANSIEscapeCodes(tt.input)
if result != tt.expected {
t.Errorf("StripANSIEscapeCodes(%q) = %q, expected %q", tt.input, result, tt.expected)
}

// Verify no ANSI escape sequences remain
if result != "" && strings.Contains(result, "\x1b[") {
t.Errorf("Result still contains ANSI escape sequences: %q", result)
}
})
}
}

func BenchmarkStripANSIEscapeCodes_Clean(b *testing.B) {
s := "This is a clean string without any ANSI codes"
for i := 0; i < b.N; i++ {
StripANSIEscapeCodes(s)
}
}

func BenchmarkStripANSIEscapeCodes_WithCodes(b *testing.B) {
s := "This \x1b[31mhas\x1b[0m some \x1b[1mANSI\x1b[0m codes"
for i := 0; i < b.N; i++ {
StripANSIEscapeCodes(s)
}
}
9 changes: 7 additions & 2 deletions pkg/workflow/compiler_activation_jobs.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (

"github.com/githubnext/gh-aw/pkg/constants"
"github.com/githubnext/gh-aw/pkg/logger"
"github.com/githubnext/gh-aw/pkg/stringutil"
)

var compilerActivationJobsLog = logger.New("workflow:compiler_activation_jobs")
Expand Down Expand Up @@ -93,7 +94,9 @@ func (c *Compiler) buildPreActivationJob(data *WorkflowData, needsPermissionChec
steps = append(steps, fmt.Sprintf(" id: %s\n", constants.CheckStopTimeStepID))
steps = append(steps, fmt.Sprintf(" uses: %s\n", GetActionPin("actions/github-script")))
steps = append(steps, " env:\n")
steps = append(steps, fmt.Sprintf(" GH_AW_STOP_TIME: %s\n", data.StopTime))
// Strip ANSI escape codes from stop-time value
cleanStopTime := stringutil.StripANSIEscapeCodes(data.StopTime)
steps = append(steps, fmt.Sprintf(" GH_AW_STOP_TIME: %s\n", cleanStopTime))
steps = append(steps, fmt.Sprintf(" GH_AW_WORKFLOW_NAME: %q\n", workflowName))
steps = append(steps, " with:\n")
steps = append(steps, " script: |\n")
Expand Down Expand Up @@ -576,7 +579,9 @@ func (c *Compiler) buildActivationJob(data *WorkflowData, preActivationJobCreate
// Set environment if manual-approval is configured
var environment string
if data.ManualApproval != "" {
environment = fmt.Sprintf("environment: %s", data.ManualApproval)
// Strip ANSI escape codes from manual-approval environment name
cleanManualApproval := stringutil.StripANSIEscapeCodes(data.ManualApproval)
environment = fmt.Sprintf("environment: %s", cleanManualApproval)
}

job := &Job{
Expand Down
Loading
Loading