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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .changeset/patch-remove-serena-local-mode.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

72 changes: 0 additions & 72 deletions actions/setup/sh/start_serena_server.sh

This file was deleted.

4 changes: 2 additions & 2 deletions docs/src/content/docs/reference/frontmatter-full.md
Original file line number Diff line number Diff line change
Expand Up @@ -1640,8 +1640,8 @@ tools:
# (optional)
version: null

# Serena execution mode: 'docker' (default, runs in container) or 'local' (runs
# locally with uvx and HTTP transport)
# Serena execution mode ('docker' is the only supported mode, runs in
# container)
# (optional)
mode: "docker"

Expand Down
162 changes: 162 additions & 0 deletions pkg/cli/codemod_serena_local_mode.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
package cli

import (
"strings"

"github.com/github/gh-aw/pkg/logger"
)

var serenaLocalModeCodemodLog = logger.New("cli:codemod_serena_local_mode")

// getSerenaLocalModeCodemod creates a codemod that replaces 'mode: local' with 'mode: docker'
// in tools.serena configurations. The 'local' mode executed serena via uvx directly from an
// unpinned git repository (supply chain risk) and has been removed. Docker is now the only
// supported mode.
func getSerenaLocalModeCodemod() Codemod {
return Codemod{
ID: "serena-local-to-docker",
Name: "Migrate Serena 'mode: local' to 'mode: docker'",
Description: "Replaces 'mode: local' with 'mode: docker' in tools.serena configurations. The 'local' mode has been removed as it executed serena from an unpinned git repository (supply chain risk). Docker is now the only supported mode.",
IntroducedIn: "0.17.0",
Apply: func(content string, frontmatter map[string]any) (string, bool, error) {
// Check if tools.serena exists and has mode: local
toolsValue, hasTools := frontmatter["tools"]
if !hasTools {
return content, false, nil
}

toolsMap, ok := toolsValue.(map[string]any)
if !ok {
return content, false, nil
}

serenaValue, hasSerena := toolsMap["serena"]
if !hasSerena {
return content, false, nil
}

serenaMap, ok := serenaValue.(map[string]any)
if !ok {
return content, false, nil
}

modeValue, hasMode := serenaMap["mode"]
if !hasMode {
return content, false, nil
Copy link
Contributor

Choose a reason for hiding this comment

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

The content-based string replacement approach is straightforward and effective for this migration. One consideration: if a workflow has multiple serena: blocks or if mode: local appears in a comment or string value unrelated to serena config, the regex could match false positives. The frontmatter check above helps guard against this, but the actual replacement relies solely on string matching. Consider adding a more precise regex that requires the mode: line to be within a serena configuration context (e.g., checking indentation).

}

modeStr, ok := modeValue.(string)
if !ok || modeStr != "local" {
return content, false, nil
}

newContent, applied, err := applyFrontmatterLineTransform(content, replaceSerenaLocalModeWithDocker)
if applied {
serenaLocalModeCodemodLog.Print("Applied Serena local-to-docker migration")
}
return newContent, applied, err
},
}
}

// replaceSerenaLocalModeWithDocker replaces 'mode: local' with 'mode: docker' within the
// tools.serena block in frontmatter lines.
func replaceSerenaLocalModeWithDocker(lines []string) ([]string, bool) {
var result []string
var modified bool
var inTools bool
var toolsIndent string
var inSerena bool
var serenaIndent string

for i, line := range lines {
trimmedLine := strings.TrimSpace(line)

// Track entering the tools block
if strings.HasPrefix(trimmedLine, "tools:") && !inTools {
inTools = true
toolsIndent = getIndentation(line)
result = append(result, line)
continue
}

// Check if we've left the tools block
if inTools && len(trimmedLine) > 0 && !strings.HasPrefix(trimmedLine, "#") {
if hasExitedBlock(line, toolsIndent) {
inTools = false
inSerena = false
}
}

// Track entering the serena sub-block inside tools
if inTools && strings.HasPrefix(trimmedLine, "serena:") {
inSerena = true
serenaIndent = getIndentation(line)
result = append(result, line)
continue
}

// Check if we've left the serena block
if inSerena && len(trimmedLine) > 0 && !strings.HasPrefix(trimmedLine, "#") {
if hasExitedBlock(line, serenaIndent) {
inSerena = false
}
}

// Replace 'mode: local' with 'mode: docker' inside tools.serena
if inSerena && strings.HasPrefix(trimmedLine, "mode:") {
if strings.Contains(trimmedLine, "local") {
newLine, replaced := findAndReplaceValueInLine(line, "mode", "local", "docker")
if replaced {
result = append(result, newLine)
modified = true
serenaLocalModeCodemodLog.Printf("Replaced 'mode: local' with 'mode: docker' on line %d", i+1)
continue
}
}
}

result = append(result, line)
}

return result, modified
}

// findAndReplaceValueInLine replaces oldValue with newValue for a specific key in a YAML line,
// preserving indentation and inline comments.
func findAndReplaceValueInLine(line, key, oldValue, newValue string) (string, bool) {
trimmedLine := strings.TrimSpace(line)
if !strings.HasPrefix(trimmedLine, key+":") {
return line, false
}

leadingSpace := getIndentation(line)
_, afterColon, found := strings.Cut(line, ":")
if !found {
return line, false
}

// Split on the first '#' to separate value from inline comment
commentIdx := strings.Index(afterColon, "#")
var valueSection, commentSection string
if commentIdx >= 0 {
valueSection = afterColon[:commentIdx]
commentSection = afterColon[commentIdx:]
} else {
valueSection = afterColon
commentSection = ""
}

trimmedValue := strings.TrimSpace(valueSection)
if trimmedValue != oldValue {
return line, false
}

// Preserve the whitespace between the colon and the value
spaceBeforeValue := valueSection[:strings.Index(valueSection, trimmedValue)]
newLine := leadingSpace + key + ":" + spaceBeforeValue + newValue
Comment on lines +150 to +157
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

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

The codemod only replaces unquoted mode: local. If a workflow uses quoted YAML like mode: "local" / mode: 'local' (common given docs show quoted strings), findAndReplaceValueInLine() won't match and the migration silently won't apply, leaving the workflow failing schema validation. Consider normalizing the value by stripping optional quotes before comparing, and preserve the original quoting style when writing the replacement.

Suggested change
trimmedValue := strings.TrimSpace(valueSection)
if trimmedValue != oldValue {
return line, false
}
// Preserve the whitespace between the colon and the value
spaceBeforeValue := valueSection[:strings.Index(valueSection, trimmedValue)]
newLine := leadingSpace + key + ":" + spaceBeforeValue + newValue
rawValue := strings.TrimSpace(valueSection)
// Detect optional surrounding quotes and normalize value for comparison.
normalizedValue := rawValue
var quoteChar byte
if len(rawValue) >= 2 {
first := rawValue[0]
last := rawValue[len(rawValue)-1]
if (first == '"' || first == '\'') && first == last {
quoteChar = first
normalizedValue = rawValue[1 : len(rawValue)-1]
}
}
if normalizedValue != oldValue {
return line, false
}
// Preserve the whitespace between the colon and the value by finding
// the index of the first non-space character in the value section.
spaceIdx := 0
for spaceIdx < len(valueSection) && (valueSection[spaceIdx] == ' ' || valueSection[spaceIdx] == '\t') {
spaceIdx++
}
spaceBeforeValue := valueSection[:spaceIdx]
// Preserve original quoting style, if any, when writing the new value.
replacementValue := newValue
if quoteChar != 0 {
replacementValue = string(quoteChar) + newValue + string(quoteChar)
}
newLine := leadingSpace + key + ":" + spaceBeforeValue + replacementValue

Copilot uses AI. Check for mistakes.
if commentSection != "" {
newLine += " " + commentSection
}
return newLine, true
}
Loading
Loading