Skip to content

Unnamed custom checkout steps drop deferred runtime setup #47529

Description

@romainh-betclic

Summary

When a workflow declares checkout using the valid unnamed shorthand:

steps:
  - uses: actions/checkout@v6

gh-aw detects that custom steps contain checkout and defers automatic runtime setup until
after it. The insertion logic then fails to recognize checkout on the current - uses:
line, so the deferred runtime steps are dropped entirely.

This affects any detected runtime, not only Node.js or ARC/DinD runners. Compilation
succeeds, leaving the failure to occur later when a generated or custom step expects the
missing runtime.

This was reproduced with gh-aw v0.82.14 and re-verified on 2026-07-22 by exporting and
building the exact then-current origin/main commit
2868d6e080ff76e594e79cafbdfacd11bfa53d2b, then compiling the reproduction below with
that binary. The permalinks below pin
9df84ea4ecae5d9604499771694281dc4a9738f1; every referenced source file is identical
between those two commits.

Impact

  • A supported checkout syntax can silently remove required runtime bootstrap steps.
  • Compilation succeeds even though the generated workflow is incomplete.
  • Any automatically detected runtime can be affected.
  • Failures vary by runner image and may be masked when a runtime happens to be preinstalled.
  • Naming the checkout step or using compiler-managed checkout: changes generated behavior,
    although all forms should preserve the same runtime requirements.

Environment

  • gh-aw: v0.82.14
  • Re-verified at upstream commit: 2868d6e080ff76e594e79cafbdfacd11bfa53d2b
  • Engine: Copilot
  • Runner: any non-standard runner label, which causes Node.js runtime detection

Minimal reproduction

Create .github/workflows/repro.md:

---
on:
  workflow_dispatch:

engine: copilot
runs-on: self-hosted

permissions:
  contents: read
network: defaults

steps:
  - uses: actions/checkout@v6
    with:
      fetch-depth: 0
      persist-credentials: false
  - name: Prepare context
    run: echo ready
---

# Reproduction

Call `noop`.

Compile it:

gh aw compile repro --strict

Compilation succeeds with no errors (only a non-blocking staleness notice that
actions/checkout@v6 has a newer release).

Inspect the generated agent job:

rg -n -- "- uses: actions/checkout|name: (Setup Node|Prepare context)" \
  .github/workflows/repro.lock.yml

Output at 2868d6e0 — the custom checkout and the following custom step are
adjacent, with no Setup Node.js between them or anywhere else in the job:

451:      - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
455:      - name: Prepare context

A quick objective check: actions/setup-node appears nowhere in the generated lock file,
not even in its pinned-actions manifest header:

rg -c "actions/setup-node" .github/workflows/repro.lock.yml  # no matches

Expected behavior

The generated agent job should preserve this effective ordering:

  1. Custom checkout
  2. All automatically detected runtime setup steps, including Setup Node.js
  3. Remaining custom steps
  4. Agent execution

Both supported checkout forms should produce equivalent runtime setup:

- uses: actions/checkout@v6
- name: Checkout repository
  uses: actions/checkout@v6

Actual behavior

The generated agent job contains the unnamed custom checkout and subsequent custom steps,
but omits the deferred Setup Node.js step.

Changing the checkout to the named form makes runtime insertion work:

- name: Checkout repository
  uses: actions/checkout@v6

Agent analysis and root cause

1. Runtime detection works

DetectRuntimeRequirements correctly adds Node.js for a non-standard runner:

// When using a custom image runner, ensure Node.js is set up.
// Standard GitHub-hosted runners (ubuntu-*, windows-*) have Node.js pre-installed,
// but custom image runners (self-hosted, enterprise runners, non-standard labels) may not.
// Node.js is required for gh-aw scripts such as start_safe_outputs_server.sh and
// start_mcp_scripts_server.sh that invoke `node` directly.
if isCustomImageRunner(workflowData.RunsOn) {
runtimeSetupLog.Printf("Custom image runner detected (%q), ensuring Node.js is set up", workflowData.RunsOn)
nodeRuntime := findRuntimeByID("node")
if nodeRuntime != nil {
updateRequiredRuntime(nodeRuntime, "", requirements)
}

2. The first checkout detector recognizes the shorthand

prepareRuntimeSetupAndCheckoutInfo decides whether custom steps contain checkout via
ContainsCheckout. Its substring match recognizes uses: actions/checkout anywhere in
the custom steps:

// ContainsCheckout returns true if the given custom steps contain an actions/checkout step
func ContainsCheckout(customSteps string) bool {
if customSteps == "" {
return false
}
// Look for actions/checkout usage patterns
checkoutPatterns := []string{
"actions/checkout@",
"uses: actions/checkout",
"- uses: actions/checkout",
}
lowerSteps := strings.ToLower(customSteps)
for _, pattern := range checkoutPatterns {
if strings.Contains(lowerSteps, strings.ToLower(pattern)) {
compilerWorkflowHelpersLog.Print("Detected actions/checkout in custom steps")
return true
}
}
return false
}

Because custom checkout was detected, emitRuntimeSetupPrelude defers runtime setup:

runtimeStepsEmittedEarly := needsCheckout || !customStepsContainCheckout
if runtimeStepsEmittedEarly {
// Case 1 or 3: Add runtime steps before custom steps
// This ensures checkout -> runtime -> custom steps order
compilerYamlLog.Printf("Adding %d runtime steps before custom steps (needsCheckout=%t, !customStepsContainCheckout=%t)", len(runtimeSetupSteps), needsCheckout, !customStepsContainCheckout)
for _, step := range runtimeSetupSteps {
for _, line := range step {
yaml.WriteString(line)
yaml.WriteByte('\n')
}
}
}

emitCustomSteps delegates insertion to addCustomStepsWithRuntimeInsertion:

if customStepsContainCheckout && len(runtimeSetupSteps) > 0 {
// Custom steps contain checkout and we have runtime steps to insert
// Insert runtime steps after the first checkout step
compilerYamlLog.Printf("Calling addCustomStepsWithRuntimeInsertion: %d runtime steps to insert after checkout", len(runtimeSetupSteps))
c.addCustomStepsWithRuntimeInsertion(yaml, customStepsToEmit, runtimeSetupSteps, data.ParsedTools)

3. The insertion-side detector misses the same shorthand

addCustomStepsWithRuntimeInsertion treats both - name: and - uses: as step starts,
but detects checkout only by scanning subsequent lines:

// Check if this line starts a step with "- name:" or "- uses:"
trimmed := strings.TrimSpace(line)
isStepStart := strings.HasPrefix(trimmed, "- name:") || strings.HasPrefix(trimmed, "- uses:")
if isStepStart && !insertedRuntime {
// This is the start of a step, check if it's a checkout step
isCheckoutStep := false
// Look ahead to find "uses:" line with "checkout"
for j := i + 1; j < len(lines); j++ {
nextLine := lines[j]
nextTrimmed := strings.TrimSpace(nextLine)
// Stop if we hit the next step
if strings.HasPrefix(nextTrimmed, "- name:") || strings.HasPrefix(nextTrimmed, "- uses:") {
break
}
// Check if this is a uses line with checkout
if strings.Contains(nextTrimmed, "uses:") && strings.Contains(nextTrimmed, "checkout") {
isCheckoutStep = true
break
}
}

For:

- uses: actions/checkout@v6

the checkout action is on the current line. Look-ahead begins at i + 1, so it never
examines that line. The next - uses: or - name: terminates the scan. Runtime setup was
deferred by the first detector but is never inserted by the second.

The existing regression test uses a named checkout step and therefore does not cover this
form:

func TestCheckoutRuntimeOrderInCustomSteps(t *testing.T) {
workflowContent := `---
on: push
permissions:
contents: read
issues: read
pull-requests: read
engine: copilot
steps:
- name: Checkout code
uses: actions/checkout@v5
with:
persist-credentials: false
- name: Use Node
run: node --version
---
# Test workflow with checkout in custom steps
`
// Create temporary directory for test
tempDir, err := os.MkdirTemp("", "checkout-runtime-order-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
// Create workflows directory
workflowsDir := filepath.Join(tempDir, constants.GetWorkflowDir())
if err := os.MkdirAll(workflowsDir, 0755); err != nil {
t.Fatalf("Failed to create workflows directory: %v", err)
}
// Write test workflow file
workflowPath := filepath.Join(workflowsDir, "test-workflow.md")
if err := os.WriteFile(workflowPath, []byte(workflowContent), 0644); err != nil {
t.Fatalf("Failed to write workflow file: %v", err)
}
// Compile workflow
compiler := NewCompiler()
compiler.SetActionMode(ActionModeDev) // Use dev mode with local action paths
if err := compiler.CompileWorkflow(workflowPath); err != nil {
t.Fatalf("Failed to compile workflow: %v", err)
}
// Read generated lock file
lockPath := filepath.Join(workflowsDir, "test-workflow.lock.yml")
lockContent, err := os.ReadFile(lockPath)
if err != nil {
t.Fatalf("Failed to read lock file: %v", err)
}
lockStr := string(lockContent)
// Extract the agent job section
agentJobStart := strings.Index(lockStr, " agent:")
if agentJobStart == -1 {
t.Fatal("Could not find agent job in compiled workflow")
}
// Find the next job (starts with " " followed by a non-space character, e.g., " activation:")
// We need to skip the agent job content which has more indentation
remainingContent := lockStr[agentJobStart+10:]
nextJobStart := -1
lines := strings.Split(remainingContent, "\n")
for i, line := range lines {
// A new job starts with exactly 2 spaces followed by a letter/number (not more spaces)
if len(line) > 2 && line[0] == ' ' && line[1] == ' ' && line[2] != ' ' && line[2] != '\t' {
// Calculate the position in the original string
nextJobStart = 0
for j := range i {
nextJobStart += len(lines[j]) + 1 // +1 for newline
}
break
}
}
var agentJobSection string
if nextJobStart == -1 {
agentJobSection = lockStr[agentJobStart:]
} else {
agentJobSection = lockStr[agentJobStart : agentJobStart+10+nextJobStart]
}
// Debug: print first 1000 chars of agent job section
sampleSize := min(1000, len(agentJobSection))
t.Logf("Agent job section (first %d chars):\n%s", sampleSize, agentJobSection[:sampleSize])
// Find all step names in order
stepNames := []string{}
stepLines := strings.SplitSeq(agentJobSection, "\n")
for line := range stepLines {
// Check if line contains "- name:" (with any amount of leading whitespace)
if strings.Contains(line, "- name:") {
// Extract the name part after "- name:"
parts := strings.SplitN(line, "- name:", 2)
if len(parts) == 2 {
name := strings.TrimSpace(parts[1])
stepNames = append(stepNames, name)
}
}
}
t.Logf("Found %d steps: %v", len(stepNames), stepNames)
if len(stepNames) < 9 {
t.Fatalf("Expected at least 9 steps, got %d: %v", len(stepNames), stepNames)
}
// Verify the order in dev mode (when local actions are used):
// 1. First step should be "Checkout actions folder" (checkout local actions)
// 2. Second step should be "Setup Scripts" (use the checked out action)
// 3. Third step should be "Set runtime paths" (safe-outputs port, always injected)
// 4. Fourth step should be "Create gh-aw temp directory" (before custom steps)
// 5. Fifth step should be "Configure gh CLI for GitHub Enterprise" (GHE host setup)
// 6. Sixth step should be "Download activation artifact" (moved before custom steps)
// 7. Seventh step should be "Checkout code" (from custom steps - full checkout, no separate .github checkout needed)
// 8. Eighth step should be "Setup Node.js" (runtime setup, inserted after checkout)
// 9. Ninth step should be "Use Node" (from custom steps)
// NOTE: The .github sparse checkout is skipped because custom steps contain a full checkout
if stepNames[0] != "Checkout actions folder" {
t.Errorf("First step should be 'Checkout actions folder', got '%s'", stepNames[0])
}
if stepNames[1] != "Setup Scripts" {
t.Errorf("Second step should be 'Setup Scripts', got '%s'", stepNames[1])
}
if stepNames[2] != "Set runtime paths" {
t.Errorf("Third step should be 'Set runtime paths', got '%s'", stepNames[2])
}
if stepNames[3] != "Create gh-aw temp directory" {
t.Errorf("Fourth step should be 'Create gh-aw temp directory', got '%s'", stepNames[3])
}
if stepNames[4] != "Configure gh CLI for GitHub Enterprise" {
t.Errorf("Fifth step should be 'Configure gh CLI for GitHub Enterprise', got '%s'", stepNames[4])
}
if stepNames[5] != "Download activation artifact" {
t.Errorf("Sixth step should be 'Download activation artifact', got '%s'", stepNames[5])
}
if stepNames[6] != "Checkout code" {
t.Errorf("Seventh step should be 'Checkout code', got '%s'", stepNames[6])
}
if stepNames[7] != "Setup Node.js" {
t.Errorf("Eighth step should be 'Setup Node.js' (runtime setup after checkout), got '%s'", stepNames[7])
}
if stepNames[8] != "Use Node" {
t.Errorf("Ninth step should be 'Use Node', got '%s'", stepNames[8])
}
// Verify that .github checkout is NOT present (redundant with full checkout in custom steps)
for _, name := range stepNames {
if name == "Checkout .github folder" {
t.Error("Checkout .github folder should not be present when custom steps contain full repository checkout")
}
}
// Additional check: verify correct ordering of key steps
tempDirIndex := strings.Index(agentJobSection, "Create gh-aw temp directory")
configureGHEIndex := strings.Index(agentJobSection, "Configure gh CLI for GitHub Enterprise")
checkoutIndex := strings.Index(agentJobSection, "Checkout code")
setupNodeIndex := strings.Index(agentJobSection, "Setup Node.js")
if tempDirIndex == -1 {
t.Fatal("Could not find 'Create gh-aw temp directory' step in agent job")
}
if configureGHEIndex == -1 {
t.Fatal("Could not find 'Configure gh CLI for GitHub Enterprise' step in agent job")
}
if checkoutIndex == -1 {
t.Fatal("Could not find 'Checkout code' step in agent job")
}
if setupNodeIndex == -1 {
t.Fatal("Could not find 'Setup Node.js' step in agent job")
}
if tempDirIndex > configureGHEIndex {
t.Error("Create gh-aw temp directory appears after Configure gh CLI for GitHub Enterprise, should be before")
}
if configureGHEIndex > checkoutIndex {
t.Error("Configure gh CLI for GitHub Enterprise appears after Checkout code, should be before")
}
if setupNodeIndex < checkoutIndex {
t.Error("Setup Node.js appears before Checkout code, should be after")
}
t.Logf("Step order is correct:")
for i, name := range stepNames[:9] {
t.Logf(" %d. %s", i+1, name)
}
}

Current workaround

Use compiler-managed checkout:

checkout:
  fetch-depth: 0

Alternatively, give the custom checkout step a name. Compiler-managed checkout is
preferable when its supported fields cover the workflow's requirements.

Agentic implementation plan

Please implement the following changes.

  1. Add failing regression tests
    (pkg/workflow/checkout_runtime_order_test.go):

    • Add a custom checkout expressed as - uses: actions/checkout@... without name.
    • Confirm every detected runtime setup step is emitted after checkout.
    • Confirm subsequent custom steps retain their original order.
    • Confirm runtime setup is emitted exactly once.
  2. Unify checkout recognition
    (pkg/workflow/compiler_yaml_runtime_setup.go and the helper owning
    ContainsCheckout):

    • Ensure insertion-side detection accepts every checkout form that triggers deferral.
    • Prefer structured step inspection or a shared exact checkout-action helper.
    • Recognize checkout on the current - uses: actions/checkout@... line.
    • Preserve support for named checkout steps where uses: appears on a following line.
    • Avoid matching unrelated actions whose names merely contain checkout.
  3. Cover edge cases
    (pkg/workflow/checkout_runtime_order_test.go):

    • Checkout with with: options.
    • Checkout as the first custom step.
    • Checkout after another deterministic step.
    • Multiple checkout steps: insert runtime setup after the first applicable checkout,
      matching existing semantics.
    • Named and unnamed checkout forms generate equivalent runtime ordering.
    • Customized runtime setup actions retain existing deduplication behavior.
  4. Run project validation

    • Run go test ./pkg/workflow -run CheckoutRuntimeOrder.
    • Run make recompile and inspect generated workflow ordering.
    • Run make agent-finish before completion.

No new dependency or public configuration field should be required.

Acceptance criteria

  • Unnamed and named custom checkout forms produce equivalent runtime setup.
  • Every detected runtime setup step follows the first applicable custom checkout.
  • Subsequent custom-step ordering is unchanged.
  • Runtime setup is not duplicated.
  • Compiler-managed checkout behavior is unchanged.
  • Existing checkout ordering, runtime detection, strict compilation, and recompile tests pass.

Duplicate research

Searched open and closed issues and pull requests on 2026-07-22 for custom checkout,
runtime insertion, shorthand/unnamed checkout, and
addCustomStepsWithRuntimeInsertion. No existing issue covers this detector mismatch.

The behavior originated from the custom-checkout ordering implementation in #4388
("Fix runtime setup steps inserted before checkout in custom steps", merged 2025-11-20),
whose tests cover only the named checkout form. That PR added the logic to
compiler_yaml.go; it has since been moved into compiler_yaml_runtime_setup.go:

#4388

Metadata

Metadata

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions