Skip to content

perf(workflow): cut YAMLGeneration overhead in run-script expression guardrail#44438

Merged
pelikhan merged 3 commits into
mainfrom
copilot/performance-regression-yaml-generation
Jul 9, 2026
Merged

perf(workflow): cut YAMLGeneration overhead in run-script expression guardrail#44438
pelikhan merged 3 commits into
mainfrom
copilot/performance-regression-yaml-generation

Conversation

Copilot AI commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

BenchmarkYAMLGeneration regressed significantly (+143.6% vs historical baseline), with ~5x memory/alloc pressure reported. The hot path was the run-script expression guardrail doing expensive processing even when run blocks had no inline expressions.

  • Root-cause scope

    • validateNoGitHubExpressionsInRunScriptsFromParsed always performed heredoc/comment stripping and regex extraction per run block.
    • In the common case (no ${{ ... }}), that work is unnecessary and dominated allocation/CPU in YAML generation.
  • Targeted optimization

    • Added a zero-false-negative precheck (mayContainInlineExpression) in the parsed guardrail path.
    • Guardrail now short-circuits per run block when inline expressions cannot exist, avoiding downstream regex/string processing.
  • Regression guard

    • Added a focused benchmark for the no-expression parsed path:
      • BenchmarkValidateNoGitHubExpressionsInRunScriptsFromParsed_NoExpressions
    • This locks in the intended fast path and helps prevent future reintroduction of avoidable work.
for _, runContent := range runBlocks {
	if !mayContainInlineExpression(runContent) {
		continue
	}
	contentWithoutHeredocs := stripShellLineComments(removeHeredocContent(runContent))
	expressions := InlineExpressionPattern.FindAllString(contentWithoutHeredocs, -1)
	// ...
}

…sion exists

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix performance regression in YAML generation perf(workflow): cut YAMLGeneration overhead in run-script expression guardrail Jul 9, 2026
Copilot AI requested a review from pelikhan July 9, 2026 02:04
@pelikhan pelikhan marked this pull request as ready for review July 9, 2026 02:07
Copilot AI review requested due to automatic review settings July 9, 2026 02:07
@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 completed the 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.

No ADR enforcement needed: PR #44438 does not have the 'implementation' label and has only 36 new lines of code in business logic directories (threshold: 100).

@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Test Quality Sentinel completed test quality analysis.

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

Optimizes the run-script expression guardrail in YAML generation by adding a cheap precheck in the parsed-YAML validation path to avoid expensive heredoc/comment stripping and regex extraction when a run: block cannot contain inline GitHub expressions. Adds a benchmark intended to lock in the new fast path for the common “no expressions” case.

Changes:

  • Added mayContainInlineExpression short-circuit in validateNoGitHubExpressionsInRunScriptsFromParsed to skip heavy processing for run: blocks without possible ${{ ... }} usage.
  • Added BenchmarkValidateNoGitHubExpressionsInRunScriptsFromParsed_NoExpressions to regression-guard the new fast path.
Show a summary per file
File Description
pkg/workflow/template_injection_validation.go Adds a cheap precheck to skip expensive run-script scanning work when inline expressions cannot be present.
pkg/workflow/template_injection_validation_benchmark_test.go Adds a benchmark covering the no-expression parsed validation path.

Review details

Tip

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

  • Files reviewed: 2/2 changed files
  • Comments generated: 1
  • Review effort level: Low

Comment on lines +49 to +55
var parsed map[string]any
if err := yaml.Unmarshal([]byte(yamlContent), &parsed); err != nil {
b.Fatal(err)
}

b.ReportAllocs()
for b.Loop() {

@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(workflow): cut YAMLGeneration overhead in run-script expression guardrail

The optimization is correct and well-targeted. The mayContainInlineExpression precheck is zero-false-negative (requires both ${{" and }}` to be present), making the fast-path skip safe. The same pattern is already applied in other guardrail paths in this file (lines 144, 150, 183), so this aligns the parsed-path consistently.

One non-blocking suggestion: see inline comment on benchmark loop style — the new benchmark uses b.Loop() while the existing benchmark in the same file uses the for i := 0; i < b.N; i++ form with an explanatory nolint comment. Aligning the two would keep the file internally consistent.

🧵 Reviewed using Impeccable skills by Impeccable Skills Reviewer · 28.4 AIC · ⌖ 5.96 AIC · ⊞ 4.8K

}

b.ReportAllocs()
for b.Loop() {

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.

The new benchmark uses b.Loop() while the existing benchmark in this file uses for i := 0; i < b.N; i++ with an explicit nolint comment:

(nolint/redacted):intrange // Use the standard testing.B loop form for broad tool compatibility.
for i := 0; i < b.N; i++ {

Consider using the same b.N form here for consistency and to match the stated codebase policy:

(nolint/redacted):intrange // Use the standard testing.B loop form for broad tool compatibility.
for i := 0; i < b.N; i++ {
    if err := validateNoGitHubExpressionsInRunScriptsFromParsed(parsed); err != nil {
        b.Fatalf("validateNoGitHubExpressionsInRunScriptsFromParsed() error = %v", err)
    }
}

b.Loop() is fine in Go 1.24+ (this module declares go 1.26.3), but the inconsistency with the adjacent benchmark and its explanatory comment is worth aligning.

@copilot please address this.

@github-actions github-actions Bot mentioned this pull request Jul 9, 2026
@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 1 test(s): 1 design, 0 implementation, 0 violation(s).

📊 Metrics (1 test)
Metric Value
Analyzed 1 (Go: 1, JS: 0)
✅ Design 1 (100%)
⚠️ Implementation 0 (0%)
Edge/error coverage 1 (100%)
Duplicate clusters 0
Inflation YES (32 test lines / 4 prod lines = 8:1)
🚨 Violations 0
Test File Classification Issues
BenchmarkValidateNoGitHubExpressionsInRunScriptsFromParsed_NoExpressions template_injection_validation_benchmark_test.go:32 design_test / behavioral_contract Inflation (8:1), happy-path only
⚠️ Flagged Tests (1)

BenchmarkValidateNoGitHubExpressionsInRunScriptsFromParsed_NoExpressions (template_injection_validation_benchmark_test.go:32) — classified as design_test/behavioral_contract. The benchmark verifies the new fast-path optimization does not produce false positives: b.Fatalf on any unexpected error ensures correctness. Only the happy-path no-expression scenario is exercised; a complementary unit test covering the expression-present path would strengthen coverage. The 8:1 test-to-production line ratio is noted but typical for benchmark additions.

Verdict

Passed. 0% implementation tests (threshold: 30%). The added benchmark directly validates the behavioral invariant of the fast path introduced in this PR (no false positives when run-script steps contain no ${{ expressions). No coding-guideline violations detected.

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 · 37.4 AIC · ⌖ 11.5 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%). No violations.

@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 — no blocking changes, 3 observations inline.

📋 Key Themes & Highlights

Key Themes

  • Benchmark loop inconsistency: New benchmark uses b.Loop() while the sibling in the same file uses for i := 0; i < b.N; i++ — worth aligning.
  • Precheck asymmetry not documented: validateNoTemplateInjectionFromParsed (line 183) guards with a full regex; the updated function now uses the cheaper mayContainInlineExpression substring scan. The different strategies are fine but unannounced.
  • No direct unit tests for validateNoGitHubExpressionsInRunScriptsFromParsed: The function is only covered through compiler-level integration tests; a small focused table-driven test would lock in the fast-path behaviour.

Positive Highlights

  • ✅ The fix is minimal, surgical, and correctly scoped — no semantic changes to validation logic
  • mayContainInlineExpression is already well-documented as zero-false-negative; reuse here is the right call
  • ✅ Adding a focused benchmark for the specific fast path is excellent regression discipline — this directly pins the work we care about
  • ✅ PR description is thorough: it explains root cause, fix, and the quantified regression clearly

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 Matt Pocock's skills by Matt Pocock Skills Reviewer · 71.1 AIC · ⌖ 8.39 AIC · ⊞ 6.6K
Comment /matt to run again

}

b.ReportAllocs()
for b.Loop() {

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] Benchmark loop style is inconsistent with the sibling benchmark in this file — the existing BenchmarkValidateTemplateInjectionFastPath uses for i := 0; i < b.N; i++ with a //nolint:intrange comment explaining the choice for "broad tool compatibility".

💡 Options

b.Loop() is actually the better API (Go 1.24+): it resets timers correctly around the loop body. The rest of the codebase already uses it. Two paths forward:

  1. Preferred: Update the sibling BenchmarkValidateTemplateInjectionFastPath to for b.Loop() as a follow-up (the //nolint:intrange comment becomes unnecessary since b.Loop() is not a range form). This makes the file consistent and removes the stale nolint comment.
  2. Conservative: Add a brief comment to the new benchmark explaining the choice, e.g. // b.Loop() — preferred over b.N loop since Go 1.24 (resets timer correctly).

Neither blocks this PR, but leaving two incompatible patterns in the same file is a maintenance hazard.

@copilot please address this.

var violations []TemplateInjectionViolation

for _, runContent := range runBlocks {
if !mayContainInlineExpression(runContent) {

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 sibling function validateNoTemplateInjectionFromParsed (line 183) uses InlineExpressionPattern.MatchString(runContent) as its precheck, while this function now uses mayContainInlineExpression(runContent). These are semantically different — full regex vs cheap substring scan — and the asymmetry may confuse future readers.

💡 Suggested clarifying comment

A brief inline comment would make the intentional trade-off explicit:

// Use the cheap substring precheck (not InlineExpressionPattern.MatchString as in
// validateNoTemplateInjectionFromParsed) because for the common no-expression case
// this avoids regex engine overhead. mayContainInlineExpression is zero-false-negative:
// any real "${{ ... }}" must contain both substrings.
if !mayContainInlineExpression(runContent) {
    continue
}

Alternatively, the sibling function at line 183 could be updated to use mayContainInlineExpression for consistency — it would be a strict improvement there too.

@copilot please address this.

}
}

func BenchmarkValidateNoGitHubExpressionsInRunScriptsFromParsed_NoExpressions(b *testing.B) {

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 benchmark covers the no-expression fast path but there is no unit test that exercises validateNoGitHubExpressionsInRunScriptsFromParsed directly in template_injection_validation_test.go. Searching the test file shows no TestValidateNoGitHubExpressions* function at all — coverage of this function relies entirely on integration-level compiler tests.

💡 Suggested additions

Adding targeted unit tests would make the fast-path correctness guarantee explicit and catch regressions faster:

func TestValidateNoGitHubExpressionsInRunScriptsFromParsed(t *testing.T) {
    tests := []struct {
        name    string
        yaml    string
        wantErr bool
    }{
        {
            name: "no expressions — fast path, no error",
            yaml: `jobs:
  test:
    steps:
      - run: echo hello`,
            wantErr: false,
        },
        {
            name: "allowed generated expression passes",
            yaml: `jobs:
  test:
    steps:
      - run: node ${{ runner.temp }}/foo.cjs`,
            wantErr: false,
        },
        {
            name: "disallowed raw expression is rejected",
            yaml: `jobs:
  test:
    steps:
      - run: echo ${{ github.actor }}`,
            wantErr: true,
        },
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            var parsed map[string]any
            require.NoError(t, yaml.Unmarshal([]byte(tt.yaml), &parsed))
            err := validateNoGitHubExpressionsInRunScriptsFromParsed(parsed)
            if tt.wantErr {
                require.Error(t, err)
            } else {
                require.NoError(t, err)
            }
        })
    }
}

The no-expression case is particularly important to lock in the short-circuit behaviour introduced by this PR.

@copilot please address this.

@pelikhan

pelikhan commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

@copilot run pr-finisher skill

@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.

The optimization is correct and safe — mayContainInlineExpression is genuinely zero-false-negative against InlineExpressionPattern, and the fast-path guard is logically sound. Three non-blocking concerns noted.

### Review themes

Guard asymmetry (template_injection_validation.go line 235): The sibling function validateNoTemplateInjectionFromParsed uses the full compiled regex as its fast-path guard; this function now uses the cheaper string-based precheck. Both are safe, but the divergence is undocumented and will slow down future security audits. A one-line comment explaining the choice closes this.

Benchmark loop style inconsistency (benchmark file line 55): The existing benchmark in the same file deliberately uses for i := 0; i < b.N; i++ with a //nolint:intrange comment. The new benchmark silently uses b.Loop(). Pick one convention and document it.

Benchmark only covers the fast path (benchmark file line 35): The new benchmark measures strings.Cut + strings.Contains on a tiny fixed string — it does not cover the slow path with actual expressions present. The regression the PR claims to fix cannot be measured from this benchmark alone. A companion _WithExpressions benchmark is needed to make the optimization verifiable and to guard against future slow-path regressions.

🔎 Code quality review by PR Code Quality Reviewer · 126.6 AIC · ⌖ 6.77 AIC · ⊞ 5.4K
Comment /review to run again

var violations []TemplateInjectionViolation

for _, runContent := range runBlocks {
if !mayContainInlineExpression(runContent) {

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.

Guard asymmetry with sibling function makes security auditing harder: validateNoTemplateInjectionFromParsed (line 183) uses the full compiled regex InlineExpressionPattern.MatchString(runContent) as its fast-path guard, while this new guard uses the cheaper mayContainInlineExpression. There is no comment explaining the intentional divergence.

💡 Details

Both guards are safe in terms of false negatives — mayContainInlineExpression is documented as zero-false-negative. But the two functions now have subtly different skip conditions: mayContainInlineExpression accepts false positives (e.g. a run block containing ${{ in a heredoc body and }} elsewhere) that the full regex would reject. A security auditor comparing the two functions will see inconsistent guards and need to reason through both to confirm neither introduces a bypass. A short inline comment explaining why the cheaper check is correct here would close this gap:

// mayContainInlineExpression is zero-false-negative (see its doc), so it is
// safe to use here instead of the full InlineExpressionPattern regex.
if !mayContainInlineExpression(runContent) {
    continue
}

}

b.ReportAllocs()
for b.Loop() {

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.

b.Loop() inconsistent with the existing benchmark's explicit b.N style in the same file: The benchmark immediately above uses for i := 0; i < b.N; i++ with a //nolint:intrange comment explaining the rationale for that style ('broad tool compatibility'). This new benchmark silently adopts the opposite convention.

💡 Details

Either both benchmarks should use the same style, or this one should carry a comment explaining the intentional divergence. Without that, the next maintainer reading both benchmarks back-to-back will wonder which convention to follow when adding new benchmarks.

To stay consistent with the existing benchmark:

b.ReportAllocs()
(nolint/redacted):intrange // Use the standard testing.B loop form for broad tool compatibility.
for i := 0; i < b.N; i++ {
    if err := validateNoGitHubExpressionsInRunScriptsFromParsed(parsed); err != nil {
        b.Fatalf("validateNoGitHubExpressionsInRunScriptsFromParsed() error = %v", err)
    }
}

Or, if b.Loop() is the desired going-forward style, update the existing benchmark and remove its nolint comment too.

}
}

func BenchmarkValidateNoGitHubExpressionsInRunScriptsFromParsed_NoExpressions(b *testing.B) {

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.

Benchmark only covers the fast-path skip; the optimization's benefit is not measurable from this benchmark alone: The new benchmark measures a single strings.Cut + strings.Contains call on a short fixed string. It cannot demonstrate the actual performance gain, because there is no companion benchmark for the slow path (run blocks with expressions present).

💡 Details

The PR body claims a significant regression was fixed in BenchmarkYAMLGeneration. Without a slow-path counterpart (e.g. BenchmarkValidateNoGitHubExpressionsInRunScriptsFromParsed_WithExpressions), future contributors cannot:

  1. Verify the before/after improvement with go test -bench=.
  2. Detect a future regression where the slow path gets expensive again

A minimal companion benchmark:

func BenchmarkValidateNoGitHubExpressionsInRunScriptsFromParsed_WithExpressions(b *testing.B) {
    // workflow with allowed expressions present
    yamlContent := `name: with-expressions\non: workflow_dispatch\njobs:\n  test:\n    runs-on: ubuntu-latest\n    steps:\n      - run: echo ${{ env.FOO }}\n`
    var parsed map[string]any
    if err := yaml.Unmarshal([]byte(yamlContent), &parsed); err != nil {
        b.Fatal(err)
    }
    b.ReportAllocs()
    for i := 0; i < b.N; i++ {
        _ = validateNoGitHubExpressionsInRunScriptsFromParsed(parsed)
    }
}

This also locks in that the slow path itself does not regress in future refactors.

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-finisher skill

Added b.ResetTimer() after YAML parsing setup in the benchmark (commit test(benchmark): add b.ResetTimer() after YAML parsing setup). This makes the exclusion of setup time explicit, even though b.Loop() in Go 1.24+ already resets the timer before the first iteration.

@pelikhan pelikhan merged commit 9406290 into main Jul 9, 2026
29 checks passed
@pelikhan pelikhan deleted the copilot/performance-regression-yaml-generation branch July 9, 2026 02:38
@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

🎉 This pull request is included in a new release.

Release: v0.82.7

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 YAMLGeneration: +143.6% slower

3 participants