|
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) |
|
} |
|
} |
Summary
When a workflow declares checkout using the valid unnamed shorthand:
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/maincommit2868d6e080ff76e594e79cafbdfacd11bfa53d2b, then compiling the reproduction below withthat binary. The permalinks below pin
9df84ea4ecae5d9604499771694281dc4a9738f1; every referenced source file is identicalbetween those two commits.
Impact
checkout:changes generated behavior,although all forms should preserve the same runtime requirements.
Environment
2868d6e080ff76e594e79cafbdfacd11bfa53d2bMinimal reproduction
Create
.github/workflows/repro.md:Compile it:
Compilation succeeds with no errors (only a non-blocking staleness notice that
actions/checkout@v6has a newer release).Inspect the generated agent job:
rg -n -- "- uses: actions/checkout|name: (Setup Node|Prepare context)" \ .github/workflows/repro.lock.ymlOutput at
2868d6e0— the custom checkout and the following custom step areadjacent, with no
Setup Node.jsbetween them or anywhere else in the job:A quick objective check:
actions/setup-nodeappears nowhere in the generated lock file,not even in its pinned-actions manifest header:
Expected behavior
The generated agent job should preserve this effective ordering:
Setup Node.jsBoth supported checkout forms should produce equivalent runtime setup:
Actual behavior
The generated agent job contains the unnamed custom checkout and subsequent custom steps,
but omits the deferred
Setup Node.jsstep.Changing the checkout to the named form makes runtime insertion work:
Agent analysis and root cause
1. Runtime detection works
DetectRuntimeRequirementscorrectly adds Node.js for a non-standard runner:gh-aw/pkg/workflow/runtime_detection.go
Lines 63 to 73 in 9df84ea
2. The first checkout detector recognizes the shorthand
prepareRuntimeSetupAndCheckoutInfodecides whether custom steps contain checkout viaContainsCheckout. Its substring match recognizesuses: actions/checkoutanywhere inthe custom steps:
gh-aw/pkg/workflow/compiler_workflow_helpers.go
Lines 12 to 34 in 9df84ea
Because custom checkout was detected,
emitRuntimeSetupPreludedefers runtime setup:gh-aw/pkg/workflow/compiler_yaml_runtime_setup.go
Lines 118 to 129 in 9df84ea
emitCustomStepsdelegates insertion toaddCustomStepsWithRuntimeInsertion:gh-aw/pkg/workflow/compiler_yaml_runtime_setup.go
Lines 181 to 185 in 9df84ea
3. The insertion-side detector misses the same shorthand
addCustomStepsWithRuntimeInsertiontreats both- name:and- uses:as step starts,but detects checkout only by scanning subsequent lines:
gh-aw/pkg/workflow/compiler_yaml_runtime_setup.go
Lines 351 to 374 in 9df84ea
For:
the checkout action is on the current line. Look-ahead begins at
i + 1, so it neverexamines that line. The next
- uses:or- name:terminates the scan. Runtime setup wasdeferred 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:
gh-aw/pkg/workflow/checkout_runtime_order_test.go
Lines 19 to 221 in 9df84ea
Current workaround
Use compiler-managed checkout:
Alternatively, give the custom checkout step a
name. Compiler-managed checkout ispreferable when its supported fields cover the workflow's requirements.
Agentic implementation plan
Please implement the following changes.
Add failing regression tests
(
pkg/workflow/checkout_runtime_order_test.go):- uses: actions/checkout@...withoutname.Unify checkout recognition
(
pkg/workflow/compiler_yaml_runtime_setup.goand the helper owningContainsCheckout):- uses: actions/checkout@...line.uses:appears on a following line.checkout.Cover edge cases
(
pkg/workflow/checkout_runtime_order_test.go):with:options.matching existing semantics.
Run project validation
go test ./pkg/workflow -run CheckoutRuntimeOrder.make recompileand inspect generated workflow ordering.make agent-finishbefore completion.No new dependency or public configuration field should be required.
Acceptance criteria
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 intocompiler_yaml_runtime_setup.go:#4388