Skip to content

Commit 6e45e1e

Browse files
Copilotpelikhan
andauthored
fix: restore actions/setup after external root checkout in agent job (#23751)
* Initial plan * fix: add restore actions folder step to agent job when external root checkout exists In dev mode, when a workflow's checkout config includes an external repository that checks out to the workspace root (no path specified), actions/checkout replaces the workspace content with that repo's files. This removes the actions/setup directory, causing the Setup Scripts post-step to fail with: 'Can't find action.yml under .../actions/setup' Fixes the 'Smoke Create Cross-Repo PR' and 'Smoke Update Cross-Repo PR' schedule failures (#23447, #23193). Changes: - Add HasExternalRootCheckout() to CheckoutManager (checkout_manager.go) - Add restore step in generateMainJobSteps when dev mode + external root checkout (compiler_yaml_main_job.go) - Recompile all 178 workflow lock files - Add unit tests for HasExternalRootCheckout and the new restore step behavior Agent-Logs-Url: https://github.com/github/gh-aw/sessions/310824d2-632b-4cb1-816c-f631047328f8 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> * refine: add clarifying comment about dot-path normalization in HasExternalRootCheckout Agent-Logs-Url: https://github.com/github/gh-aw/sessions/310824d2-632b-4cb1-816c-f631047328f8 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
1 parent 3803e52 commit 6e45e1e

6 files changed

Lines changed: 182 additions & 0 deletions

.github/workflows/smoke-create-cross-repo-pr.lock.yml

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.github/workflows/smoke-update-cross-repo-pr.lock.yml

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/workflow/checkout_manager.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,24 @@ func (cm *CheckoutManager) HasAppAuth() bool {
285285
return false
286286
}
287287

288+
// HasExternalRootCheckout returns true if any checkout entry targets an external
289+
// repository (non-empty repository field) and writes to the workspace root (empty path).
290+
// When such a checkout exists, the workspace root is replaced with the external
291+
// repository content, which removes any locally-checked-out actions/setup directory.
292+
// In dev mode, a "Restore actions folder" step must be added after such checkouts so
293+
// the runner's post-step for the Setup Scripts action can find action.yml.
294+
//
295+
// Note: the "." path is normalized to "" in add(), so both "" and "." are covered
296+
// by the entry.key.path == "" check.
297+
func (cm *CheckoutManager) HasExternalRootCheckout() bool {
298+
for _, entry := range cm.ordered {
299+
if entry.key.repository != "" && entry.key.path == "" {
300+
return true
301+
}
302+
}
303+
return false
304+
}
305+
288306
// GenerateCheckoutAppTokenSteps generates GitHub App token minting steps for all
289307
// checkout entries that use app authentication. Each app-authenticated checkout
290308
// gets its own minting step with a unique step ID, so the minted token can be

pkg/workflow/checkout_manager_test.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -987,3 +987,52 @@ func TestCrossRepoTargetRef(t *testing.T) {
987987
assert.NotContains(t, combined, "ref:", "checkout step should not include ref field when empty")
988988
})
989989
}
990+
991+
// TestHasExternalRootCheckout verifies detection of external checkouts targeting the workspace root.
992+
func TestHasExternalRootCheckout(t *testing.T) {
993+
t.Run("returns false for nil configs", func(t *testing.T) {
994+
cm := NewCheckoutManager(nil)
995+
assert.False(t, cm.HasExternalRootCheckout(), "should be false for nil configs")
996+
})
997+
998+
t.Run("returns false for empty configs", func(t *testing.T) {
999+
cm := NewCheckoutManager([]*CheckoutConfig{})
1000+
assert.False(t, cm.HasExternalRootCheckout(), "should be false for empty configs")
1001+
})
1002+
1003+
t.Run("returns false for default checkout only (no repository)", func(t *testing.T) {
1004+
cm := NewCheckoutManager([]*CheckoutConfig{
1005+
{GitHubToken: "${{ secrets.MY_PAT }}"},
1006+
})
1007+
assert.False(t, cm.HasExternalRootCheckout(), "should be false when only default checkout is configured")
1008+
})
1009+
1010+
t.Run("returns false for external checkout with non-root path", func(t *testing.T) {
1011+
cm := NewCheckoutManager([]*CheckoutConfig{
1012+
{Repository: "other/repo", Path: "libs/other"},
1013+
})
1014+
assert.False(t, cm.HasExternalRootCheckout(), "should be false when external repo uses a subdirectory path")
1015+
})
1016+
1017+
t.Run("returns true for external checkout without path (workspace root)", func(t *testing.T) {
1018+
cm := NewCheckoutManager([]*CheckoutConfig{
1019+
{Repository: "githubnext/gh-aw-side-repo", GitHubToken: "${{ secrets.SIDE_REPO_PAT }}"},
1020+
})
1021+
assert.True(t, cm.HasExternalRootCheckout(), "should be true when external repo checks out to workspace root")
1022+
})
1023+
1024+
t.Run("returns true for external checkout with explicit dot path", func(t *testing.T) {
1025+
cm := NewCheckoutManager([]*CheckoutConfig{
1026+
{Repository: "other/repo", Path: "."},
1027+
})
1028+
assert.True(t, cm.HasExternalRootCheckout(), "should be true when external repo uses '.' as path (workspace root)")
1029+
})
1030+
1031+
t.Run("returns true when one of multiple checkouts targets external root", func(t *testing.T) {
1032+
cm := NewCheckoutManager([]*CheckoutConfig{
1033+
{Repository: "other/repo", Path: "libs/other"},
1034+
{Repository: "githubnext/gh-aw-side-repo"},
1035+
})
1036+
assert.True(t, cm.HasExternalRootCheckout(), "should be true when any checkout targets external root")
1037+
})
1038+
}

pkg/workflow/compiler_yaml_main_job.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -543,6 +543,19 @@ func (c *Compiler) generateMainJobSteps(yaml *strings.Builder, data *WorkflowDat
543543
}
544544
}
545545

546+
// In dev mode the setup action is referenced via a local path (./actions/setup), so its files
547+
// live in the workspace. When a checkout: entry targets an external repository without a path
548+
// (e.g. "checkout: [{repository: owner/other-repo}]"), actions/checkout replaces the workspace
549+
// root with the external repository content, removing the actions/setup directory.
550+
// Without restoring it, the runner's post-step for Setup Scripts would fail with
551+
// "Can't find 'action.yml', 'action.yaml' or 'Dockerfile' under .../actions/setup".
552+
// We add a restore checkout step (if: always()) as the final step so the post-step
553+
// can always find action.yml and complete its /tmp/gh-aw cleanup.
554+
if c.actionMode.IsDev() && checkoutMgr.HasExternalRootCheckout() {
555+
yaml.WriteString(c.generateRestoreActionsSetupStep())
556+
compilerYamlLog.Print("Added restore actions folder step to agent job (dev mode with external root checkout)")
557+
}
558+
546559
// Validate step ordering - this is a compiler check to ensure security
547560
if err := c.stepOrderTracker.ValidateStepOrdering(); err != nil {
548561
// This is a compiler bug if validation fails

pkg/workflow/compiler_yaml_main_job_test.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -818,3 +818,87 @@ func TestShouldAddCheckoutStepEdgeCases(t *testing.T) {
818818
})
819819
}
820820
}
821+
822+
// TestGenerateMainJobStepsRestoreActionsFolder verifies that the "Restore actions folder" step
823+
// is added to the agent job in dev mode when an external repository checkout targets the
824+
// workspace root, and is NOT added when not in dev mode or when no such checkout exists.
825+
func TestGenerateMainJobStepsRestoreActionsFolder(t *testing.T) {
826+
makeData := func(checkoutConfigs []*CheckoutConfig) *WorkflowData {
827+
return &WorkflowData{
828+
Name: "Test Workflow",
829+
AI: "copilot",
830+
MarkdownContent: "Test prompt",
831+
EngineConfig: &EngineConfig{ID: "copilot"},
832+
ParsedTools: &ToolsConfig{},
833+
CheckoutConfigs: checkoutConfigs,
834+
}
835+
}
836+
837+
t.Run("restore step added in dev mode with external root checkout", func(t *testing.T) {
838+
compiler := NewCompiler()
839+
compiler.actionMode = ActionModeDev
840+
compiler.stepOrderTracker = NewStepOrderTracker()
841+
842+
data := makeData([]*CheckoutConfig{
843+
{Repository: "githubnext/gh-aw-side-repo", GitHubToken: "${{ secrets.SIDE_REPO_PAT }}"},
844+
})
845+
846+
var yaml strings.Builder
847+
err := compiler.generateMainJobSteps(&yaml, data)
848+
require.NoError(t, err, "generateMainJobSteps should not error")
849+
850+
result := yaml.String()
851+
assert.Contains(t, result, "Restore actions folder", "agent job should have restore step in dev mode with external root checkout")
852+
assert.Contains(t, result, "repository: github/gh-aw", "restore step should checkout github/gh-aw")
853+
assert.Contains(t, result, "actions/setup", "restore step should checkout actions/setup")
854+
})
855+
856+
t.Run("restore step NOT added in dev mode with external subdirectory checkout only", func(t *testing.T) {
857+
compiler := NewCompiler()
858+
compiler.actionMode = ActionModeDev
859+
compiler.stepOrderTracker = NewStepOrderTracker()
860+
861+
data := makeData([]*CheckoutConfig{
862+
{Repository: "other/repo", Path: "libs/other"},
863+
})
864+
865+
var yaml strings.Builder
866+
err := compiler.generateMainJobSteps(&yaml, data)
867+
require.NoError(t, err, "generateMainJobSteps should not error")
868+
869+
result := yaml.String()
870+
assert.NotContains(t, result, "Restore actions folder", "agent job should NOT have restore step when external checkout uses a subdirectory")
871+
})
872+
873+
t.Run("restore step NOT added in dev mode with no external checkouts", func(t *testing.T) {
874+
compiler := NewCompiler()
875+
compiler.actionMode = ActionModeDev
876+
compiler.stepOrderTracker = NewStepOrderTracker()
877+
878+
data := makeData(nil)
879+
880+
var yaml strings.Builder
881+
err := compiler.generateMainJobSteps(&yaml, data)
882+
require.NoError(t, err, "generateMainJobSteps should not error")
883+
884+
result := yaml.String()
885+
assert.NotContains(t, result, "Restore actions folder", "agent job should NOT have restore step when no external checkouts")
886+
})
887+
888+
t.Run("restore step NOT added in action mode even with external root checkout", func(t *testing.T) {
889+
compiler := NewCompiler()
890+
compiler.actionMode = ActionModeAction
891+
compiler.stepOrderTracker = NewStepOrderTracker()
892+
893+
data := makeData([]*CheckoutConfig{
894+
{Repository: "githubnext/gh-aw-side-repo"},
895+
})
896+
897+
var yaml strings.Builder
898+
err := compiler.generateMainJobSteps(&yaml, data)
899+
require.NoError(t, err, "generateMainJobSteps should not error")
900+
901+
result := yaml.String()
902+
assert.NotContains(t, result, "Restore actions folder", "agent job should NOT have restore step in action mode")
903+
})
904+
}

0 commit comments

Comments
 (0)