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
5 changes: 5 additions & 0 deletions .changeset/minor-auto-hoist-run-expressions-codemod.md

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

25 changes: 13 additions & 12 deletions .github/workflows/daily-model-inventory.lock.yml

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

2 changes: 1 addition & 1 deletion docs/src/content/docs/reference/gh-aw-as-mcp-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ Apply automatic codemod-style fixes to workflow files.
- `write` (optional): Write changes to files (default is dry-run)
- `list_codemods` (optional): List available codemods and exit

Available codemods: `timeout-minutes-migration`, `network-firewall-migration`, `sandbox-agent-false-removal`, `mcp-scripts-mode-removal`.
Available codemods: `timeout-minutes-migration`, `network-firewall-migration`, `sandbox-agent-false-removal`, `mcp-scripts-mode-removal`, `steps-run-secrets-to-env`.

## Using GH-AW as an MCP from an Agentic Workflow

Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/setup/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ gh aw fix --list-codemods # List available codemods
Available codemods include:

- `expires-integer-to-string` — converts bare integer `expires` values (e.g., `expires: 7`) to the preferred day-string format (e.g., `expires: 7d`) in all `safe-outputs` blocks.
- `steps-run-secrets-to-env` — rewrites inline `${{ secrets.NAME }}` interpolations in step `run:` commands to `$NAME` and adds step-level `env` bindings. Required for strict-mode compliance.
- `steps-run-secrets-to-env` — rewrites **all** `${{ ... }}` expressions in step `run:` commands to `$VARNAME` references (or `$env:VARNAME` for PowerShell steps) and adds step-level `env` bindings. Secrets, `env.*`, and `github.token` use stable legacy names; all other expressions receive `EXPR_*` names. Required for strict-mode compliance.
- `engine-env-secrets-to-engine-config` — removes secret-bearing entries from `engine.env` that are unsafe under strict mode, preserving required engine credential keys.

Run `gh aw fix --list-codemods` to see all available codemods.
Expand Down
101 changes: 89 additions & 12 deletions pkg/cli/codemod_steps_run_secrets_env.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,24 @@ var (
stepsSecretRefExprRe = regexp.MustCompile(`\bsecrets\.([A-Za-z_][A-Za-z0-9_]*)\b`)
stepsEnvRefExprRe = regexp.MustCompile(`\benv\.([A-Za-z_][A-Za-z0-9_]*)\b`)
stepsGitHubTokenRe = regexp.MustCompile(`\bgithub\.token\b`)
// stepsGenericExprRe matches simple GitHub Actions property-access chains such as
// "github.repository", "inputs.my-input", "steps.my-step.outputs.result".
// Only word characters and hyphens separated by dots are allowed; anything
// containing spaces, operators, or other punctuation falls through to a
// hash-based name.
stepsGenericExprRe = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_-]*(\.[a-zA-Z_][a-zA-Z0-9_-]*)*$`)
)

// getStepsRunSecretsToEnvCodemod creates a codemod that moves secrets interpolated directly
// in run fields to step-level env bindings in steps-like sections.
// getStepsRunSecretsToEnvCodemod creates a codemod that moves all ${{ ... }}
// expressions interpolated directly in run fields to step-level env bindings.
// Secrets, env refs, and github.token are given stable legacy names; all other
// expressions receive an EXPR_* name. PowerShell steps (shell: pwsh / powershell)
// receive $env:VARNAME references instead of $VARNAME.
func getStepsRunSecretsToEnvCodemod() Codemod {
return Codemod{
ID: "steps-run-secrets-to-env",
Name: "Move step run secrets to env bindings",
Description: "Rewrites secrets interpolated directly in run commands to $VARS and adds step-level env bindings for strict-mode compatibility.",
Name: "Move step run expressions to env bindings",
Description: "Rewrites all ${{ ... }} expressions interpolated directly in run commands to $VARS (or $env:VARS for PowerShell steps) and adds step-level env bindings for strict-mode compatibility. Note: expressions inside single-quoted strings are rewritten too; since single quotes suppress shell variable expansion, those sections should be double-quoted if the substituted value is required.",
IntroducedIn: "0.26.0",
Apply: func(content string, frontmatter map[string]any) (string, bool, error) {
sections := []string{"pre-steps", "steps", "post-steps", "pre-agent-steps"}
Expand Down Expand Up @@ -153,6 +162,30 @@ func rewriteStepRunSecretsToEnv(stepLines []string, stepIndent string) ([]string
var envKeyIndentLen int
existingEnvKeys := make(map[string]bool)

// First pass: detect shell type so PowerShell steps get $env:VARNAME syntax.
// Restrict the scan to lines at the direct step-key indentation level so
// that a run-block body line that happens to contain a literal substring
// like "shell: pwsh" is not misclassified as PowerShell.
shellIsPowerShell := false
directKeyIndent := stepIndent + " "
for _, line := range stepLines {
trimmed := strings.TrimSpace(line)
indent := getIndentation(line)
// Accept only direct step-key lines: standard form at exactly stepIndent+" ",
// or list-item-inline form "- key:" at exactly stepIndent.
if indent != directKeyIndent && !(indent == stepIndent && strings.HasPrefix(trimmed, "- ")) {
continue
}
shellMatch, shellValue, _ := parseStepKeyLine(trimmed, indent, stepIndent, "shell")
if shellMatch {
v := strings.ToLower(strings.TrimSpace(shellValue))
if v == "pwsh" || v == "powershell" {
shellIsPowerShell = true
}
break
}
}
Comment on lines +165 to +187

for i := 0; i < len(stepLines); i++ {
line := stepLines[i]
trimmed := strings.TrimSpace(line)
Expand Down Expand Up @@ -198,7 +231,7 @@ func rewriteStepRunSecretsToEnv(stepLines []string, stepIndent string) ([]string
if effectiveStepLineIndentLen(t, getIndentation(stepLines[j]), stepIndent) <= runKeyIndentLen {
break
}
updatedLine, bindings := replaceStepExpressionRefs(stepLines[j])
updatedLine, bindings := replaceStepExpressionRefs(stepLines[j], shellIsPowerShell, bindingExprs)
if len(bindings) > 0 {
stepLines[j] = updatedLine
modified = true
Expand All @@ -214,7 +247,7 @@ func rewriteStepRunSecretsToEnv(stepLines []string, stepIndent string) ([]string
continue
}

newLine, bindings := replaceStepExpressionRefs(line)
newLine, bindings := replaceStepExpressionRefs(line, shellIsPowerShell, bindingExprs)
if len(bindings) > 0 {
stepLines[i] = newLine
modified = true
Expand Down Expand Up @@ -275,15 +308,23 @@ type stepExpressionBinding struct {
Expression string
}

func replaceStepExpressionRefs(line string) (string, []stepExpressionBinding) {
func replaceStepExpressionRefs(line string, shellIsPowerShell bool, existingBindings map[string]string) (string, []stepExpressionBinding) {
matches := stepsAnyExprRe.FindAllStringSubmatchIndex(line, -1)
if len(matches) == 0 {
return line, nil
}

var result strings.Builder
last := 0
seen := make(map[string]bool)
// bodyToName maps expression body → assigned env-var name for same-body dedup
// within this line (avoids re-computing the name for repeated occurrences).
bodyToName := make(map[string]string)
// localNames maps env-var name → canonical expression for within-line
// collision detection (two different bodies that sanitize to the same name).
localNames := make(map[string]string)
// registeredNames tracks which names already appear in ordered, so we never
// add a duplicate binding entry.
registeredNames := make(map[string]bool)
ordered := make([]stepExpressionBinding, 0, len(matches))

for _, match := range matches {
Expand All @@ -297,16 +338,44 @@ func replaceStepExpressionRefs(line string) (string, []stepExpressionBinding) {

result.WriteString(line[last:fullStart])

// Same expression body already resolved in this line – reuse the name.
if cachedName, done := bodyToName[body]; done {
if shellIsPowerShell {
result.WriteString("$env:" + cachedName)
} else {
result.WriteString("$" + cachedName)
}
last = fullEnd
continue
}

envName, canonicalExpression, ok := mapRunExpressionToEnvBinding(body)
if !ok {
result.WriteString(fullExpression)
last = fullEnd
continue
}

result.WriteString("$" + envName)
if !seen[envName] {
seen[envName] = true
// Collision guard: if this env-var name is already bound to a *different*
// expression (from a previous line in this step via existingBindings, or
// from an earlier occurrence within this line via localNames), fall back
// to a hash-based name so both expressions receive unique bindings.
if crossLine := existingBindings[envName]; (crossLine != "" && crossLine != canonicalExpression) ||
(localNames[envName] != "" && localNames[envName] != canonicalExpression) {
envName = hashedBindingName("EXPR", body)
canonicalExpression = fmt.Sprintf("${{ %s }}", body)
}

bodyToName[body] = envName
localNames[envName] = canonicalExpression

if shellIsPowerShell {
result.WriteString("$env:" + envName)
} else {
result.WriteString("$" + envName)
}
Comment on lines +372 to +376
if !registeredNames[envName] {
registeredNames[envName] = true
ordered = append(ordered, stepExpressionBinding{
Name: envName,
Expression: canonicalExpression,
Expand Down Expand Up @@ -346,7 +415,15 @@ func mapRunExpressionToEnvBinding(body string) (string, string, bool) {
return hashedBindingName("GH_AW_GITHUB_TOKEN", body), fmt.Sprintf("${{ %s }}", body), true
}

return "", "", false
// Catch-all: hoist any remaining expression using EXPR_ naming.
if stepsGenericExprRe.MatchString(body) {
replacer := strings.NewReplacer(".", "_", "-", "_")
name := "EXPR_" + strings.ToUpper(replacer.Replace(body))
return name, fmt.Sprintf("${{ %s }}", body), true
}
// Complex expression: use a hash suffix for collision safety.
name := hashedBindingName("EXPR", body)
return name, fmt.Sprintf("${{ %s }}", body), true
Comment on lines +418 to +426
}

// hashedBindingName returns a collision-resistant binding key by suffixing
Expand Down
Loading
Loading