Skip to content

fix(template): escape GitHub Actions expressions + transactional deploy (#733)#736

Merged
GoosLab merged 1 commit intomainfrom
fix/issue-733
Apr 27, 2026
Merged

fix(template): escape GitHub Actions expressions + transactional deploy (#733)#736
GoosLab merged 1 commit intomainfrom
fix/issue-733

Conversation

@GoosLab
Copy link
Copy Markdown
Collaborator

@GoosLab GoosLab commented Apr 27, 2026

Summary

  • Template escaping fix: Fixed unescaped ${{ }} GitHub Actions expressions in llm-panel.yml.tmpl that collided with Go text/template delimiters, causing parse failure on moai update
  • Transactional deploy: Added ValidateAll() pre-flight step to update pipeline, ensuring templates are validated BEFORE managed files are cleaned — prevents broken project state on parse errors

Changes

Template Fix

  • internal/template/templates/.github/workflows/llm-panel.yml.tmpl: Properly escape ${{ }} using Go template string literals ({{ "{{" }} and {{ "}}" }}). Replaced broken multi-line Liquid-style conditionals with JavaScript ternary operators.

Transactional Deploy

  • internal/template/deployer.go: Added ValidateAll(ctx, tmplCtx) method — renders all templates in memory without writing files
  • internal/template/deployer_mode.go: Added ValidateAll to modeAwareDeployer
  • internal/cli/update.go: Added "Validate Templates" step BEFORE "Clean Managed Paths" step

Tests

Test plan

  • go test ./internal/template/... — all pass
  • go test ./internal/core/project/... — all pass
  • go build ./... — clean build
  • Pre-existing TTY test (TestRunInit/default_execution) unrelated — fails with/without changes
  • make build && moai update — manual verification (requires binary rebuild)

Fixes #733

🗿 MoAI email@mo.ai.kr

Summary by CodeRabbit

Release Notes

  • New Features

    • Added pre-deployment template validation that checks all templates for errors before syncing, preventing broken deployments.
  • Bug Fixes

    • Improved LLM panel template to correctly handle GitHub Actions expressions.

…ransactional deploy (#733)

The embedded template llm-panel.yml.tmpl had unescaped GitHub Actions
${{ }} expressions that collided with Go text/template delimiters,
causing a parse error on line 28 during `moai update`.

Additionally, the update pipeline cleaned managed files BEFORE template
rendering, leaving the project in a broken state if rendering failed.

Changes:
- Fix template escaping in llm-panel.yml.tmpl using proper Go template
  string literals ({{ "{{" }} and {{ "}}" }})
- Replace multi-line Liquid-style conditionals with JavaScript ternary
  operators compatible with actions/github-script
- Add Deployer.ValidateAll() method for pre-flight template validation
- Add "Validate Templates" step to update pipeline BEFORE clean step
- Update mock deployers in initializer_test.go for new interface method
- Add reproduction test (llm_panel_test.go), integration test, and
  transactional deploy test (deployer_transactional_test.go)

Fixes #733

🗿 MoAI <email@mo.ai.kr>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 27, 2026

Walkthrough

This PR fixes issue #733 by introducing pre-deployment template validation to prevent mid-flight failures. The workflow now validates all embedded templates before destructive operations (managed-path cleanup), and corrects the broken llm-panel.yml.tmpl by escaping unescaped GitHub Actions expressions that collide with Go template delimiters.

Changes

Cohort / File(s) Summary
Template Validation - Core Interface & Implementation
internal/template/deployer.go, internal/template/deployer_mode.go
Added ValidateAll method to the Deployer contract. Implementation traverses the embedded filesystem, filters to *.tmpl files, renders each template using the configured renderer to surface parse/render errors without producing outputs. Respects context cancellation and returns the first validation error encountered.
CLI Integration
internal/cli/update.go
Inserted a new "Validate Templates" step in the template sync workflow after Backup and before managed-path cleanup. Constructs a TemplateContext and invokes deployer.ValidateAll(); validation failures abort the sync with a wrapped error before any destructive operations occur.
Test Mock Updates
internal/core/project/initializer_test.go
Extended mockDeployer and trackingMockDeployer test types with no-op ValidateAll methods returning nil to ensure test compilation with production code changes.
Validation Test Coverage
internal/template/deployer_transactional_test.go, internal/template/llm_panel_integration_test.go, internal/template/llm_panel_test.go
Added comprehensive unit and integration tests covering valid/invalid template scenarios, ensuring ValidateAll correctly detects syntax errors without disk writes, and integration tests verify the fixed llm-panel.yml.tmpl validates successfully.
Template Bug Fix
internal/template/templates/.github/workflows/llm-panel.yml.tmpl
Corrected unescaped GitHub Actions expressions (${{ ... }}) that conflicted with Go template delimiters by escaping them. Simplified multi-line conditional logic into compact single-line expressions for provider availability checks.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Suggested labels

go, tests, ci

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately captures the two primary changes: escaping GitHub Actions expressions in llm-panel.yml.tmpl and implementing transactional deployment validation to prevent broken project state.
Linked Issues check ✅ Passed All key requirements from #733 are met: unescaped GitHub Actions expressions are escaped in llm-panel.yml.tmpl, ValidateAll method validates all templates in memory, and CLI validates templates before cleaning managed paths.
Out of Scope Changes check ✅ Passed All changes directly address #733: template escaping, deployer validation interface, CLI workflow integration, and comprehensive test coverage including reproduction and integration tests.
Docstring Coverage ✅ Passed Docstring coverage is 88.89% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/issue-733

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/template/templates/.github/workflows/llm-panel.yml.tmpl (1)

19-19: ⚠️ Potential issue | 🔴 Critical

Triple closing brace renders an extra } into the workflow.

Tracing the Go template render of ${{ "{{" }} secrets.GITHUB_TOKEN }}}:

  • ${{ "{{" }}${{
  • secrets.GITHUB_TOKEN }}} is literal text → secrets.GITHUB_TOKEN }}}

Final rendered YAML: github-token: ${{ secrets.GITHUB_TOKEN }}} — three closing braces. GitHub Actions will evaluate ${{ secrets.GITHUB_TOKEN }} and concatenate a literal } to the token value, breaking actions/github-script@v7 authentication.

The PR objective explicitly calls for auditing all .yml.tmpl escapes; line 22 already uses the correct two-brace form (}} ), and the ternary lines 28–30 use the consistent {{ "}}" }} escape. Line 19 should match.

🐛 Proposed fix (use the same escape pattern as lines 28–30)
-          github-token: ${{ "{{" }} secrets.GITHUB_TOKEN }}}
+          github-token: ${{ "{{" }} secrets.GITHUB_TOKEN {{ "}}" }}

Or simply drop one brace:

-          github-token: ${{ "{{" }} secrets.GITHUB_TOKEN }}}
+          github-token: ${{ "{{" }} secrets.GITHUB_TOKEN }}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/template/templates/.github/workflows/llm-panel.yml.tmpl` at line 19,
The GitHub Actions token template emits an extra closing brace due to the `${{
"{{" }} secrets.GITHUB_TOKEN }}}` escape; fix the github-token line in
internal/template/templates/.github/workflows/llm-panel.yml.tmpl by replacing
the triple closing brace escape with the correct two-brace escape (match the `{{
"}}" }}` pattern used on lines 28–30) so the rendered value becomes `${{
secrets.GITHUB_TOKEN }}` and does not append an extra `}`; update the
github-token entry (search for the `github-token` key or the `${{ "{{" }}
secrets.GITHUB_TOKEN }}}` fragment) accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/cli/update.go`:
- Around line 522-543: The TemplateContext construction in the Validate
Templates step is duplicated in the Deploy Templates step; extract a single
helper (e.g., buildUpdateTemplateContext) that calls userHomeDir(),
detectGoBinPathForUpdate(), and returns template.NewTemplateContext(...) with
the same
template.WithGoBinPath/WithHomeDir/WithSmartPATH/WithPlatform/WithVersion
options, then replace the inline constructions in both the "Validate Templates"
and "Deploy Templates" steps with tmplCtx := buildUpdateTemplateContext() so
both use the identical context builder.

In `@internal/template/deployer_mode.go`:
- Around line 178-222: Extract the duplicated logic in (*deployer).ValidateAll
and (*modeAwareDeployer).ValidateAll into a package-level helper function
validateAllTemplates(ctx context.Context, fsys fs.FS, renderer Renderer, tmplCtx
*TemplateContext) that performs the WalkDir, context cancellation check, *.tmpl
filtering, calls renderer.Render and aggregates errors with errors.Join (return
nil if renderer is nil); then change both ValidateAll methods to a one-line
return of validateAllTemplates(ctx, d.fsys, d.renderer, tmplCtx). Also add the
same `@MX`:NOTE annotation above the new helper (mirroring the `@MX`:NOTE on
(*deployer).ValidateAll) to keep annotations consistent.

In `@internal/template/deployer_transactional_test.go`:
- Around line 17-54: The test currently relies on lexicographic visit order;
make the ordering explicit and rename the test to reflect that Deploy alone is
not transactional: in the test function (was
validation_happens_before_file_writes) rename it to something like
TestDeployIsNotTransactionalWhenRunDirectly, rename "valid.tmpl" to
"a-valid.tmpl" in the fstest.MapFS so the valid file is visited and written
before the invalid one, and update the assertion messages to match the new
expectation (i.e., expect at least one file written when a valid entry precedes
the invalid entry). Reference symbols: the test function name, NewRenderer,
NewDeployerWithRenderer, and the Deploy call.

In `@internal/template/deployer.go`:
- Around line 229-264: The validation currently accumulates errors in
validationErrors inside the fs.WalkDir loop (in deployTemplates / the validation
block using d.renderer.Render) but returns only validationErrors[0]; change the
final return to surface all problems by returning
errors.Join(validationErrors...) when len(validationErrors) > 0, and add
"errors" to the imports. Alternatively, short-circuit by returning the first
renderErr from inside the WalkDir callback, but preferred is using errors.Join
to return the combined error so users see every template failure at once.

In `@internal/template/llm_panel_test.go`:
- Line 86: The "fixed" subtest in internal/template/llm_panel_test.go currently
allows the bad three-brace token because it only checks that the rendered text
contains `${{ secrets.GITHUB_TOKEN }}` as a substring; update the test to both
(1) fix the template file so the token is `${{ secrets.GITHUB_TOKEN }}` (no
extra `}`) and (2) tighten the assertions that reference the GitHub token (the
assertions that currently look for `${{ secrets.GITHUB_TOKEN }}` in the "fixed"
subtest and the sibling assertion) to verify exact presence and absence: assert
the rendered output contains the exact `${{ secrets.GITHUB_TOKEN }}` sequence
and assert that it does NOT contain `${{ secrets.GITHUB_TOKEN }}}` (or simply
assert exact equality for the whole line if the test builds the full expected
snippet) so the triple-`}` regression will fail the test.
- Around line 132-134: The custom recursive contains function is inefficient and
risky; replace the function contains(s, substr string) with calls to the
standard library strings.Contains and remove the hand-rolled implementation:
delete the contains function definition and update any calls like
contains(output, ...) to strings.Contains(output, ...), and add the "strings"
import to the test file's import block so tests compile.

---

Outside diff comments:
In `@internal/template/templates/.github/workflows/llm-panel.yml.tmpl`:
- Line 19: The GitHub Actions token template emits an extra closing brace due to
the `${{ "{{" }} secrets.GITHUB_TOKEN }}}` escape; fix the github-token line in
internal/template/templates/.github/workflows/llm-panel.yml.tmpl by replacing
the triple closing brace escape with the correct two-brace escape (match the `{{
"}}" }}` pattern used on lines 28–30) so the rendered value becomes `${{
secrets.GITHUB_TOKEN }}` and does not append an extra `}`; update the
github-token entry (search for the `github-token` key or the `${{ "{{" }}
secrets.GITHUB_TOKEN }}}` fragment) accordingly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 0521ac8f-56ca-4477-83b0-0ba294d0a89c

📥 Commits

Reviewing files that changed from the base of the PR and between 70a5346 and 017b5d9.

📒 Files selected for processing (8)
  • internal/cli/update.go
  • internal/core/project/initializer_test.go
  • internal/template/deployer.go
  • internal/template/deployer_mode.go
  • internal/template/deployer_transactional_test.go
  • internal/template/llm_panel_integration_test.go
  • internal/template/llm_panel_test.go
  • internal/template/templates/.github/workflows/llm-panel.yml.tmpl

Comment thread internal/cli/update.go
Comment on lines +522 to +543
{
name: "Validate Templates",
message: "Validating all templates before deployment",
execute: func() error {
homeDir, _ := userHomeDir()
goBinPath := detectGoBinPathForUpdate(homeDir)
tmplCtx := template.NewTemplateContext(
template.WithGoBinPath(goBinPath),
template.WithHomeDir(homeDir),
template.WithSmartPATH(template.BuildSmartPATH()),
template.WithPlatform(runtime.GOOS),
template.WithVersion(version.GetVersion()),
)

if validateErr := deployer.ValidateAll(ctx, tmplCtx); validateErr != nil {
_, _ = fmt.Fprintf(out, "\r %s Template validation failed: %v\n", symError(), validateErr)
return fmt.Errorf("template validation: %w", validateErr)
}
_, _ = fmt.Fprintf(out, "\r %s All templates validated\n", symSuccess())
return nil
},
},
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Validate-before-clean ordering is the right fix; consider extracting the context builder.

Placement after Backup and before Clean Managed Paths correctly closes the transactional gap from #733: a parse error now aborts before any destructive op runs.

The TemplateContext construction here (lines 526–534) is byte-for-byte identical to the one inside the Deploy Templates step at lines 558–566. If a future field (e.g., WithUserName) is added in one place and forgotten in the other, validation and deployment will diverge silently — exactly the kind of drift this PR is trying to prevent.

♻️ Proposed refactor — extract a single context builder
// buildUpdateTemplateContext constructs the TemplateContext used by both
// the Validate Templates and Deploy Templates steps.
func buildUpdateTemplateContext() *template.TemplateContext {
	homeDir, _ := userHomeDir()
	goBinPath := detectGoBinPathForUpdate(homeDir)
	return template.NewTemplateContext(
		template.WithGoBinPath(goBinPath),
		template.WithHomeDir(homeDir),
		template.WithSmartPATH(template.BuildSmartPATH()),
		template.WithPlatform(runtime.GOOS),
		template.WithVersion(version.GetVersion()),
	)
}

Then both steps call tmplCtx := buildUpdateTemplateContext().

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/cli/update.go` around lines 522 - 543, The TemplateContext
construction in the Validate Templates step is duplicated in the Deploy
Templates step; extract a single helper (e.g., buildUpdateTemplateContext) that
calls userHomeDir(), detectGoBinPathForUpdate(), and returns
template.NewTemplateContext(...) with the same
template.WithGoBinPath/WithHomeDir/WithSmartPATH/WithPlatform/WithVersion
options, then replace the inline constructions in both the "Validate Templates"
and "Deploy Templates" steps with tmplCtx := buildUpdateTemplateContext() so
both use the identical context builder.

Comment on lines +178 to +222
// ValidateAll validates all templates without writing any files.
func (d *modeAwareDeployer) ValidateAll(ctx context.Context, tmplCtx *TemplateContext) error {
// Only validate if we have a renderer configured
if d.renderer == nil {
return nil
}

var validationErrors []error
walkErr := fs.WalkDir(d.fsys, ".", func(path string, entry fs.DirEntry, err error) error {
if err != nil {
return err
}

// Check context cancellation
select {
case <-ctx.Done():
return ctx.Err()
default:
}

// Skip directories and non-templates
if path == "." || entry.IsDir() || !strings.HasSuffix(path, ".tmpl") {
return nil
}

// Try to render the template (this will catch parse errors)
_, renderErr := d.renderer.Render(path, tmplCtx)
if renderErr != nil {
validationErrors = append(validationErrors,
fmt.Errorf("template %q: %w", path, renderErr))
}

return nil
})

if walkErr != nil {
return walkErr
}

if len(validationErrors) > 0 {
return validationErrors[0]
}

return nil
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Duplicated ValidateAll — extract a shared helper.

This implementation is identical to (*deployer).ValidateAll in internal/template/deployer.go:223-267. Both struct types share fsys fs.FS and renderer Renderer fields, so a package-level helper avoids divergence drift (e.g., if one side adds parallelism, retry, or errors.Join and the other doesn't).

♻️ Proposed shared helper
// validateAllTemplates renders every *.tmpl file in fsys with the provided
// renderer/tmplCtx and returns aggregated parse/render errors, if any.
// Returns nil if renderer is nil (no rendering configured).
func validateAllTemplates(ctx context.Context, fsys fs.FS, renderer Renderer, tmplCtx *TemplateContext) error {
	if renderer == nil {
		return nil
	}

	var validationErrors []error
	walkErr := fs.WalkDir(fsys, ".", func(path string, entry fs.DirEntry, err error) error {
		if err != nil {
			return err
		}
		select {
		case <-ctx.Done():
			return ctx.Err()
		default:
		}
		if path == "." || entry.IsDir() || !strings.HasSuffix(path, ".tmpl") {
			return nil
		}
		if _, renderErr := renderer.Render(path, tmplCtx); renderErr != nil {
			validationErrors = append(validationErrors,
				fmt.Errorf("template %q: %w", path, renderErr))
		}
		return nil
	})
	if walkErr != nil {
		return walkErr
	}
	return errors.Join(validationErrors...)
}

Then both (*deployer).ValidateAll and (*modeAwareDeployer).ValidateAll become one-liners delegating to this helper.

Also: as per coding guidelines, please add an @MX:NOTE annotation here to mirror the one on (*deployer).ValidateAll in internal/template/deployer.go:219 for consistency.

As per coding guidelines: Create and update @mxcode annotations:@mx:NOTE for context, @mx:WARN for danger zones (requires @mx:REASON), @mx:ANCHOR for invariant contracts, @mx:TODO for incomplete work.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/template/deployer_mode.go` around lines 178 - 222, Extract the
duplicated logic in (*deployer).ValidateAll and (*modeAwareDeployer).ValidateAll
into a package-level helper function validateAllTemplates(ctx context.Context,
fsys fs.FS, renderer Renderer, tmplCtx *TemplateContext) that performs the
WalkDir, context cancellation check, *.tmpl filtering, calls renderer.Render and
aggregates errors with errors.Join (return nil if renderer is nil); then change
both ValidateAll methods to a one-line return of validateAllTemplates(ctx,
d.fsys, d.renderer, tmplCtx). Also add the same `@MX`:NOTE annotation above the
new helper (mirroring the `@MX`:NOTE on (*deployer).ValidateAll) to keep
annotations consistent.

Comment on lines +17 to +54
t.Run("validation_happens_before_file_writes", func(t *testing.T) {
// Create a filesystem with one valid template and one invalid template
fs := fstest.MapFS{
"valid.tmpl": &fstest.MapFile{
Data: []byte("Hello {{.ProjectName}}"),
},
"invalid.tmpl": &fstest.MapFile{
// This template has a syntax error: unescaped }}
Data: []byte("Broken {{ } } template"),
},
}

r := NewRenderer(fs)
d := NewDeployerWithRenderer(fs, r)

// Create a temp directory for deployment
tempDir := t.TempDir()
mgr := manifest.NewManager()

ctx := context.Background()
tmplCtx := NewTemplateContext(WithProject("Test", tempDir))

// Deploy should fail during validation
err := d.Deploy(ctx, tempDir, mgr, tmplCtx)
if err == nil {
t.Error("expected error for invalid template, got nil")
}

// CRITICAL: No files should have been written to disk
// because validation failed BEFORE the write phase
entries, _ := os.ReadDir(tempDir)
if len(entries) > 0 {
t.Errorf("expected no files written after validation failure, got %d files", len(entries))
for _, e := range entries {
t.Logf(" - %s", e.Name())
}
}
})
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

This test passes by alphabetical luck — Deploy is not transactional on its own.

Deploy in internal/template/deployer.go:72-185 renders and writes each file inside the fs.WalkDir callback; there is no upfront validation pass. The reason no files appear on disk here is that fs.WalkDir visits entries lexically and "invalid.tmpl" < "valid.tmpl", so the broken template is hit first and aborts the walk before valid.tmpl is ever reached.

Rename valid.tmpl to a-valid.tmpl and the assertion len(entries) > 0 fails — a-valid.txt would be written to disk before invalid.tmpl errors out. The actual transactional guarantee in this PR lives at the CLI layer (internal/cli/update.go calls ValidateAll before Deploy); this test should reflect that.

🐛 Two ways to fix

Option A — Test the real transactional pattern (preferred):

 		fs := fstest.MapFS{
-			"valid.tmpl": &fstest.MapFile{
+			"a-valid.tmpl": &fstest.MapFile{
 				Data: []byte("Hello {{.ProjectName}}"),
 			},
-			"invalid.tmpl": &fstest.MapFile{
+			"b-invalid.tmpl": &fstest.MapFile{
 				Data: []byte("Broken {{ } } template"),
 			},
 		}
 		...
-		// Deploy should fail during validation
-		err := d.Deploy(ctx, tempDir, mgr, tmplCtx)
+		// Transactional pattern: ValidateAll first, then Deploy only if valid.
+		if err := d.ValidateAll(ctx, tmplCtx); err == nil {
+			t.Fatal("expected ValidateAll to reject invalid template")
+		}
+		// Caller must NOT proceed to Deploy after validation failure.
+		// Verify nothing was written by ValidateAll itself.
+		entries, _ := os.ReadDir(tempDir)
+		if len(entries) > 0 {
+			t.Errorf("ValidateAll must not write files, got %d", len(entries))
+		}

Option B — Keep the Deploy-only test but make ordering explicit (renames force the valid file to come first so a regression in transactionality is actually observable).

Either way, the current test name validation_happens_before_file_writes should change to match what's actually being asserted.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
t.Run("validation_happens_before_file_writes", func(t *testing.T) {
// Create a filesystem with one valid template and one invalid template
fs := fstest.MapFS{
"valid.tmpl": &fstest.MapFile{
Data: []byte("Hello {{.ProjectName}}"),
},
"invalid.tmpl": &fstest.MapFile{
// This template has a syntax error: unescaped }}
Data: []byte("Broken {{ } } template"),
},
}
r := NewRenderer(fs)
d := NewDeployerWithRenderer(fs, r)
// Create a temp directory for deployment
tempDir := t.TempDir()
mgr := manifest.NewManager()
ctx := context.Background()
tmplCtx := NewTemplateContext(WithProject("Test", tempDir))
// Deploy should fail during validation
err := d.Deploy(ctx, tempDir, mgr, tmplCtx)
if err == nil {
t.Error("expected error for invalid template, got nil")
}
// CRITICAL: No files should have been written to disk
// because validation failed BEFORE the write phase
entries, _ := os.ReadDir(tempDir)
if len(entries) > 0 {
t.Errorf("expected no files written after validation failure, got %d files", len(entries))
for _, e := range entries {
t.Logf(" - %s", e.Name())
}
}
})
t.Run("validation_happens_before_file_writes", func(t *testing.T) {
// Create a filesystem with one valid template and one invalid template
fs := fstest.MapFS{
"a-valid.tmpl": &fstest.MapFile{
Data: []byte("Hello {{.ProjectName}}"),
},
"b-invalid.tmpl": &fstest.MapFile{
// This template has a syntax error: unescaped }}
Data: []byte("Broken {{ } } template"),
},
}
r := NewRenderer(fs)
d := NewDeployerWithRenderer(fs, r)
// Create a temp directory for deployment
tempDir := t.TempDir()
mgr := manifest.NewManager()
ctx := context.Background()
tmplCtx := NewTemplateContext(WithProject("Test", tempDir))
// Transactional pattern: ValidateAll first, then Deploy only if valid.
if err := d.ValidateAll(ctx, tmplCtx); err == nil {
t.Fatal("expected ValidateAll to reject invalid template")
}
// Caller must NOT proceed to Deploy after validation failure.
// Verify nothing was written by ValidateAll itself.
entries, _ := os.ReadDir(tempDir)
if len(entries) > 0 {
t.Errorf("ValidateAll must not write files, got %d", len(entries))
}
})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/template/deployer_transactional_test.go` around lines 17 - 54, The
test currently relies on lexicographic visit order; make the ordering explicit
and rename the test to reflect that Deploy alone is not transactional: in the
test function (was validation_happens_before_file_writes) rename it to something
like TestDeployIsNotTransactionalWhenRunDirectly, rename "valid.tmpl" to
"a-valid.tmpl" in the fstest.MapFS so the valid file is visited and written
before the invalid one, and update the assertion messages to match the new
expectation (i.e., expect at least one file written when a valid entry precedes
the invalid entry). Reference symbols: the test function name, NewRenderer,
NewDeployerWithRenderer, and the Deploy call.

Comment on lines +229 to +264
var validationErrors []error
walkErr := fs.WalkDir(d.fsys, ".", func(path string, entry fs.DirEntry, err error) error {
if err != nil {
return err
}

// Check context cancellation during validation
select {
case <-ctx.Done():
return ctx.Err()
default:
}

// Skip directories and non-templates
if path == "." || entry.IsDir() || !strings.HasSuffix(path, ".tmpl") {
return nil
}

// Try to render the template (this will catch parse errors)
_, renderErr := d.renderer.Render(path, tmplCtx)
if renderErr != nil {
validationErrors = append(validationErrors,
fmt.Errorf("template %q: %w", path, renderErr))
}

return nil
})

if walkErr != nil {
return walkErr
}

if len(validationErrors) > 0 {
// Return the first validation error
return validationErrors[0]
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

🧩 Analysis chain

🌐 Web query:

Is errors.Join available in the Go standard library and what is the minimum Go version that supports it?

💡 Result:

Yes, errors.Join is available in the Go standard library in the errors package. It was added in Go 1.20 and is the minimum version that supports it.

Citations:


🏁 Script executed:

# Check Go version requirement
cat go.mod | head -5

Repository: modu-ai/moai-adk

Length of output: 115


🏁 Script executed:

# Check imports in deployer.go
head -50 internal/template/deployer.go | grep -E "^import|errors"

Repository: modu-ai/moai-adk

Length of output: 69


🏁 Script executed:

# Get the full import block from deployer.go
sed -n '1,30p' internal/template/deployer.go

Repository: modu-ai/moai-adk

Length of output: 1338


🏁 Script executed:

# Check the actual code at lines 229-264
sed -n '229,264p' internal/template/deployer.go

Repository: modu-ai/moai-adk

Length of output: 874


Consider reporting all validation errors, not just the first.

The walk continues past the first failure and accumulates validationErrors, but only validationErrors[0] is returned. Either short-circuit on the first error or use errors.Join to surface the full list. Returning all errors is friendlier: when a template audit catches three broken .tmpl files, the user fixes them in one cycle instead of three.

♻️ Proposed change
 	if walkErr != nil {
 		return walkErr
 	}

-	if len(validationErrors) > 0 {
-		// Return the first validation error
-		return validationErrors[0]
-	}
-
-	return nil
+	return errors.Join(validationErrors...)
 }

(Add "errors" to the import block.)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/template/deployer.go` around lines 229 - 264, The validation
currently accumulates errors in validationErrors inside the fs.WalkDir loop (in
deployTemplates / the validation block using d.renderer.Render) but returns only
validationErrors[0]; change the final return to surface all problems by
returning errors.Join(validationErrors...) when len(validationErrors) > 0, and
add "errors" to the imports. Alternatively, short-circuit by returning the first
renderErr from inside the WalkDir callback, but preferred is using errors.Join
to return the combined error so users see every template failure at once.

- name: Post LLM Panel Comment
uses: actions/github-script@v7
with:
github-token: ${{ "{{" }} secrets.GITHUB_TOKEN }}}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Test passes despite the same }}} bug flagged in llm-panel.yml.tmpl.

Line 86 mirrors the production template's three-brace defect, and the substring-based assertions on lines 123 and 126 happily match ${{ secrets.GITHUB_TOKEN }} as a prefix of the actually-rendered ${{ secrets.GITHUB_TOKEN }}}. The "fixed" subtest therefore reports green while the rendered YAML is still wrong.

Once the template is fixed, please tighten the assertions so a regression doesn't slip through:

🐛 Proposed fix
-          github-token: ${{ "{{" }} secrets.GITHUB_TOKEN }}}
+          github-token: ${{ "{{" }} secrets.GITHUB_TOKEN {{ "}}" }}
-		if !contains(output, "${{ secrets.GITHUB_TOKEN }}") {
-			t.Error("output should contain escaped GitHub Actions token reference")
-		}
+		if !strings.Contains(output, "${{ secrets.GITHUB_TOKEN }} ") {
+			t.Errorf("output should contain exactly two closing braces around secrets.GITHUB_TOKEN; got:\n%s", output)
+		}
+		if strings.Contains(output, "${{ secrets.GITHUB_TOKEN }}}") {
+			t.Errorf("output contains stray trailing brace after secrets.GITHUB_TOKEN; got:\n%s", output)
+		}

Also applies to: 123-128

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/template/llm_panel_test.go` at line 86, The "fixed" subtest in
internal/template/llm_panel_test.go currently allows the bad three-brace token
because it only checks that the rendered text contains `${{ secrets.GITHUB_TOKEN
}}` as a substring; update the test to both (1) fix the template file so the
token is `${{ secrets.GITHUB_TOKEN }}` (no extra `}`) and (2) tighten the
assertions that reference the GitHub token (the assertions that currently look
for `${{ secrets.GITHUB_TOKEN }}` in the "fixed" subtest and the sibling
assertion) to verify exact presence and absence: assert the rendered output
contains the exact `${{ secrets.GITHUB_TOKEN }}` sequence and assert that it
does NOT contain `${{ secrets.GITHUB_TOKEN }}}` (or simply assert exact equality
for the whole line if the test builds the full expected snippet) so the
triple-`}` regression will fail the test.

Comment on lines +132 to +134
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && (s[:len(substr)] == substr || contains(s[1:], substr)))
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Replace the hand-rolled recursive contains with strings.Contains.

The recursion does contains(s[1:], substr) on every miss, which is O(n·m) time and — more importantly — O(n) stack depth. For a multi-KB rendered template this risks both poor performance and stack overflow. The standard library already does this correctly.

♻️ Proposed refactor
-import (
-	"testing"
-	"testing/fstest"
-)
+import (
+	"strings"
+	"testing"
+	"testing/fstest"
+)
-func contains(s, substr string) bool {
-	return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && (s[:len(substr)] == substr || contains(s[1:], substr)))
-}
+// contains is replaced by strings.Contains at all call sites.

Replace contains(output, …) with strings.Contains(output, …).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/template/llm_panel_test.go` around lines 132 - 134, The custom
recursive contains function is inefficient and risky; replace the function
contains(s, substr string) with calls to the standard library strings.Contains
and remove the hand-rolled implementation: delete the contains function
definition and update any calls like contains(output, ...) to
strings.Contains(output, ...), and add the "strings" import to the test file's
import block so tests compile.

@GoosLab
Copy link
Copy Markdown
Collaborator Author

GoosLab commented Apr 27, 2026

@claude CI 빌드가 실패했습니다. 오류를 분석하고 수정해주세요.

실패한 워크플로우: CI
브랜치: fix/issue-733
실패 로그: https://github.com/modu-ai/moai-adk/actions/runs/24998069373

실패 로그를 확인하고 근본 원인을 파악한 뒤 수정해주세요.

Latte AI CI Monitor • latte@mo.ai.kr

@GoosLab GoosLab merged commit 2df67a8 into main Apr 27, 2026
15 of 19 checks passed
@GoosLab GoosLab deleted the fix/issue-733 branch April 27, 2026 15:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

moai update 2.18.0: embedded template .github/workflows/llm-panel.yml.tmpl:28 parse failure — unexpected "}"

1 participant