diff --git a/.github/workflows/smoke-create-cross-repo-pr.lock.yml b/.github/workflows/smoke-create-cross-repo-pr.lock.yml
index 25af4233516..31bd56d917c 100644
--- a/.github/workflows/smoke-create-cross-repo-pr.lock.yml
+++ b/.github/workflows/smoke-create-cross-repo-pr.lock.yml
@@ -310,23 +310,23 @@ jobs:
run: |
bash "${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh"
{
- cat << 'GH_AW_PROMPT_6e6e42239f1dbf7d_EOF'
+ cat << 'GH_AW_PROMPT_05bfbc22b85f9c4e_EOF'
- GH_AW_PROMPT_6e6e42239f1dbf7d_EOF
+ GH_AW_PROMPT_05bfbc22b85f9c4e_EOF
cat "${RUNNER_TEMP}/gh-aw/prompts/xpia.md"
cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md"
cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md"
cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md"
- cat << 'GH_AW_PROMPT_6e6e42239f1dbf7d_EOF'
+ cat << 'GH_AW_PROMPT_05bfbc22b85f9c4e_EOF'
Tools: add_comment(max:2), create_issue, create_pull_request, missing_tool, missing_data, noop
- GH_AW_PROMPT_6e6e42239f1dbf7d_EOF
+ GH_AW_PROMPT_05bfbc22b85f9c4e_EOF
cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_create_pull_request.md"
- cat << 'GH_AW_PROMPT_6e6e42239f1dbf7d_EOF'
+ cat << 'GH_AW_PROMPT_05bfbc22b85f9c4e_EOF'
- GH_AW_PROMPT_6e6e42239f1dbf7d_EOF
+ GH_AW_PROMPT_05bfbc22b85f9c4e_EOF
cat "${RUNNER_TEMP}/gh-aw/prompts/mcp_cli_tools_prompt.md"
- cat << 'GH_AW_PROMPT_6e6e42239f1dbf7d_EOF'
+ cat << 'GH_AW_PROMPT_05bfbc22b85f9c4e_EOF'
The following GitHub context information is available for this workflow:
{{#if github.actor}}
@@ -354,7 +354,7 @@ jobs:
- **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__
{{/if}}
- **checkouts**: The following repositories have been checked out and are available in the workspace:
- - repo `github/gh-aw-side-repo` → `$GITHUB_WORKSPACE` (cwd) [shallow clone, fetch-depth=1 (default)]
+ - repo `github/gh-aw-side-repo` → `$GITHUB_WORKSPACE/gh-aw-side-repo` [shallow clone, fetch-depth=1 (default)]
- **Note**: If a branch you need is not in the list above and is not listed as an additional fetched ref, it has NOT been checked out. For private repositories you cannot fetch it. If the branch is required and not available, exit with an error and ask the user to add it to the `fetch:` option of the `checkout:` configuration (e.g., `fetch: ["refs/pulls/open/*"]` for all open PR refs, or `fetch: ["main", "feature/my-branch"]` for specific branches).
- **Warning: No git credentials are available to the agent.** Credentials are
intentionally removed after the checkout step for security. This means any git
@@ -368,16 +368,16 @@ jobs:
stop immediately and report the limitation rather than spending turns trying to work around it.
- GH_AW_PROMPT_6e6e42239f1dbf7d_EOF
+ GH_AW_PROMPT_05bfbc22b85f9c4e_EOF
cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md"
if [ "$GITHUB_EVENT_NAME" = "issue_comment" ] && [ -n "$GH_AW_IS_PR_COMMENT" ] || [ "$GITHUB_EVENT_NAME" = "pull_request_review_comment" ] || [ "$GITHUB_EVENT_NAME" = "pull_request_review" ]; then
cat "${RUNNER_TEMP}/gh-aw/prompts/pr_context_prompt.md"
fi
- cat << 'GH_AW_PROMPT_6e6e42239f1dbf7d_EOF'
+ cat << 'GH_AW_PROMPT_05bfbc22b85f9c4e_EOF'
{{#runtime-import .github/workflows/shared/otlp.md}}
{{#runtime-import .github/workflows/smoke-create-cross-repo-pr.md}}
- GH_AW_PROMPT_6e6e42239f1dbf7d_EOF
+ GH_AW_PROMPT_05bfbc22b85f9c4e_EOF
} > "$GH_AW_PROMPT"
- name: Interpolate variables and render templates
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
@@ -538,11 +538,12 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- - name: Checkout github/gh-aw-side-repo
+ - name: Checkout github/gh-aw-side-repo into gh-aw-side-repo
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
repository: github/gh-aw-side-repo
+ path: gh-aw-side-repo
token: ${{ secrets.GH_AW_SIDE_REPO_PAT }}
- name: Build checkout manifest for safe-outputs handlers
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
@@ -550,7 +551,7 @@ jobs:
GH_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
GH_AW_CHECKOUT_MANIFEST_COUNT: "1"
GH_AW_CHECKOUT_REPO_0: "github/gh-aw-side-repo"
- GH_AW_CHECKOUT_PATH_0: ""
+ GH_AW_CHECKOUT_PATH_0: "gh-aw-side-repo"
GH_AW_CHECKOUT_TOKEN_0: ${{ secrets.GH_AW_SIDE_REPO_PAT }}
with:
script: |
@@ -1180,15 +1181,6 @@ jobs:
/tmp/gh-aw/sandbox/firewall/audit/
/tmp/gh-aw/sandbox/firewall/awf-reflect.json
if-no-files-found: ignore
- - name: Restore actions folder
- if: always()
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- with:
- repository: github/gh-aw
- sparse-checkout: |
- actions/setup
- sparse-checkout-cone-mode: true
- persist-credentials: false
conclusion:
needs:
@@ -1879,20 +1871,27 @@ jobs:
with:
persist-credentials: true
token: ${{ secrets.GH_AW_SIDE_REPO_PAT }}
- - name: Checkout github/gh-aw-side-repo
+ - name: Checkout github/gh-aw-side-repo into gh-aw-side-repo
if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request')
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: true
repository: github/gh-aw-side-repo
+ path: gh-aw-side-repo
token: ${{ secrets.GH_AW_SIDE_REPO_PAT }}
- name: Configure Git credentials
if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request')
env:
- GITHUB_REPOSITORY: github/gh-aw-side-repo
+ GITHUB_REPOSITORY: ${{ github.repository }}
GITHUB_SERVER_URL: ${{ github.server_url }}
GIT_TOKEN: ${{ secrets.GH_AW_SIDE_REPO_PAT }}
- run: bash "${RUNNER_TEMP}/gh-aw/actions/configure_git_credentials.sh"
+ GH_AW_SUBREPO_0: "github/gh-aw-side-repo"
+ run: |
+ bash "${RUNNER_TEMP}/gh-aw/actions/configure_git_credentials.sh"
+ GIT_SERVER_URL_STRIPPED="${GITHUB_SERVER_URL#https://}"
+ # Re-authenticate git for gh-aw-side-repo
+ git -C "gh-aw-side-repo" remote set-url origin "https://x-access-token:${GIT_TOKEN}@${GIT_SERVER_URL_STRIPPED}/${GH_AW_SUBREPO_0}.git"
+ echo "Git configured with standard GitHub Actions identity"
- name: Configure GH_HOST for enterprise compatibility
id: ghes-host-config
shell: bash
diff --git a/.github/workflows/smoke-update-cross-repo-pr.lock.yml b/.github/workflows/smoke-update-cross-repo-pr.lock.yml
index c0026342842..2b72ea47a52 100644
--- a/.github/workflows/smoke-update-cross-repo-pr.lock.yml
+++ b/.github/workflows/smoke-update-cross-repo-pr.lock.yml
@@ -310,24 +310,24 @@ jobs:
run: |
bash "${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh"
{
- cat << 'GH_AW_PROMPT_52010c97baa700b8_EOF'
+ cat << 'GH_AW_PROMPT_c24f28cebb8fc4f1_EOF'
- GH_AW_PROMPT_52010c97baa700b8_EOF
+ GH_AW_PROMPT_c24f28cebb8fc4f1_EOF
cat "${RUNNER_TEMP}/gh-aw/prompts/xpia.md"
cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md"
cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md"
cat "${RUNNER_TEMP}/gh-aw/prompts/cache_memory_prompt.md"
cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md"
- cat << 'GH_AW_PROMPT_52010c97baa700b8_EOF'
+ cat << 'GH_AW_PROMPT_c24f28cebb8fc4f1_EOF'
Tools: add_comment(max:2), create_issue, push_to_pull_request_branch, missing_tool, missing_data, noop
- GH_AW_PROMPT_52010c97baa700b8_EOF
+ GH_AW_PROMPT_c24f28cebb8fc4f1_EOF
cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_push_to_pr_branch.md"
- cat << 'GH_AW_PROMPT_52010c97baa700b8_EOF'
+ cat << 'GH_AW_PROMPT_c24f28cebb8fc4f1_EOF'
- GH_AW_PROMPT_52010c97baa700b8_EOF
+ GH_AW_PROMPT_c24f28cebb8fc4f1_EOF
cat "${RUNNER_TEMP}/gh-aw/prompts/mcp_cli_tools_prompt.md"
- cat << 'GH_AW_PROMPT_52010c97baa700b8_EOF'
+ cat << 'GH_AW_PROMPT_c24f28cebb8fc4f1_EOF'
The following GitHub context information is available for this workflow:
{{#if github.actor}}
@@ -355,7 +355,7 @@ jobs:
- **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__
{{/if}}
- **checkouts**: The following repositories have been checked out and are available in the workspace:
- - repo `github/gh-aw-side-repo` → `$GITHUB_WORKSPACE` (cwd) [full history, all branches available as remote-tracking refs] [additional refs fetched: main, refs/pulls/open/*]
+ - repo `github/gh-aw-side-repo` → `$GITHUB_WORKSPACE/gh-aw-side-repo` [full history, all branches available as remote-tracking refs] [additional refs fetched: main, refs/pulls/open/*]
- **Note**: If a branch you need is not in the list above and is not listed as an additional fetched ref, it has NOT been checked out. For private repositories you cannot fetch it. If the branch is required and not available, exit with an error and ask the user to add it to the `fetch:` option of the `checkout:` configuration (e.g., `fetch: ["refs/pulls/open/*"]` for all open PR refs, or `fetch: ["main", "feature/my-branch"]` for specific branches).
- **Warning: No git credentials are available to the agent.** Credentials are
intentionally removed after the checkout step for security. This means any git
@@ -369,7 +369,7 @@ jobs:
stop immediately and report the limitation rather than spending turns trying to work around it.
- GH_AW_PROMPT_52010c97baa700b8_EOF
+ GH_AW_PROMPT_c24f28cebb8fc4f1_EOF
cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md"
if [ "$GITHUB_EVENT_NAME" = "issue_comment" ] && [ -n "$GH_AW_IS_PR_COMMENT" ] || [ "$GITHUB_EVENT_NAME" = "pull_request_review_comment" ] || [ "$GITHUB_EVENT_NAME" = "pull_request_review" ]; then
cat "${RUNNER_TEMP}/gh-aw/prompts/pr_context_prompt.md"
@@ -377,11 +377,11 @@ jobs:
if [ "$GITHUB_EVENT_NAME" = "issue_comment" ] && [ -n "$GH_AW_IS_PR_COMMENT" ] || [ "$GITHUB_EVENT_NAME" = "pull_request_review_comment" ] || [ "$GITHUB_EVENT_NAME" = "pull_request_review" ]; then
cat "${RUNNER_TEMP}/gh-aw/prompts/pr_context_push_to_pr_branch_guidance.md"
fi
- cat << 'GH_AW_PROMPT_52010c97baa700b8_EOF'
+ cat << 'GH_AW_PROMPT_c24f28cebb8fc4f1_EOF'
{{#runtime-import .github/workflows/shared/otlp.md}}
{{#runtime-import .github/workflows/smoke-update-cross-repo-pr.md}}
- GH_AW_PROMPT_52010c97baa700b8_EOF
+ GH_AW_PROMPT_c24f28cebb8fc4f1_EOF
} > "$GH_AW_PROMPT"
- name: Interpolate variables and render templates
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
@@ -550,11 +550,12 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- - name: Checkout github/gh-aw-side-repo
+ - name: Checkout github/gh-aw-side-repo into gh-aw-side-repo
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
repository: github/gh-aw-side-repo
+ path: gh-aw-side-repo
token: ${{ secrets.GH_AW_SIDE_REPO_PAT }}
fetch-depth: 0
- name: Fetch additional refs for github/gh-aw-side-repo
@@ -562,14 +563,14 @@ jobs:
GH_AW_FETCH_TOKEN: ${{ secrets.GH_AW_SIDE_REPO_PAT }}
run: |
header=$(printf "x-access-token:%s" "${GH_AW_FETCH_TOKEN}" | base64 -w 0)
- git -c "http.extraheader=Authorization: Basic ${header}" fetch origin '+refs/heads/main:refs/remotes/origin/main' '+refs/pull/*/head:refs/remotes/origin/pull/*/head'
+ git -C "${{ github.workspace }}/gh-aw-side-repo" -c "http.extraheader=Authorization: Basic ${header}" fetch origin '+refs/heads/main:refs/remotes/origin/main' '+refs/pull/*/head:refs/remotes/origin/pull/*/head'
- name: Build checkout manifest for safe-outputs handlers
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
GH_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
GH_AW_CHECKOUT_MANIFEST_COUNT: "1"
GH_AW_CHECKOUT_REPO_0: "github/gh-aw-side-repo"
- GH_AW_CHECKOUT_PATH_0: ""
+ GH_AW_CHECKOUT_PATH_0: "gh-aw-side-repo"
GH_AW_CHECKOUT_TOKEN_0: ${{ secrets.GH_AW_SIDE_REPO_PAT }}
with:
script: |
@@ -1210,15 +1211,6 @@ jobs:
/tmp/gh-aw/sandbox/firewall/audit/
/tmp/gh-aw/sandbox/firewall/awf-reflect.json
if-no-files-found: ignore
- - name: Restore actions folder
- if: always()
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- with:
- repository: github/gh-aw
- sparse-checkout: |
- actions/setup
- sparse-checkout-cone-mode: true
- persist-credentials: false
conclusion:
needs:
@@ -1913,12 +1905,13 @@ jobs:
with:
persist-credentials: true
token: ${{ secrets.GH_AW_SIDE_REPO_PAT }}
- - name: Checkout github/gh-aw-side-repo
+ - name: Checkout github/gh-aw-side-repo into gh-aw-side-repo
if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'push_to_pull_request_branch')
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: true
repository: github/gh-aw-side-repo
+ path: gh-aw-side-repo
token: ${{ secrets.GH_AW_SIDE_REPO_PAT }}
fetch-depth: 0
- name: Fetch additional refs for github/gh-aw-side-repo
@@ -1927,14 +1920,20 @@ jobs:
GH_AW_FETCH_TOKEN: ${{ secrets.GH_AW_SIDE_REPO_PAT }}
run: |
header=$(printf "x-access-token:%s" "${GH_AW_FETCH_TOKEN}" | base64 -w 0)
- git -c "http.extraheader=Authorization: Basic ${header}" fetch origin '+refs/heads/main:refs/remotes/origin/main' '+refs/pull/*/head:refs/remotes/origin/pull/*/head'
+ git -C "${{ github.workspace }}/gh-aw-side-repo" -c "http.extraheader=Authorization: Basic ${header}" fetch origin '+refs/heads/main:refs/remotes/origin/main' '+refs/pull/*/head:refs/remotes/origin/pull/*/head'
- name: Configure Git credentials
if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'push_to_pull_request_branch')
env:
- GITHUB_REPOSITORY: github/gh-aw-side-repo
+ GITHUB_REPOSITORY: ${{ github.repository }}
GITHUB_SERVER_URL: ${{ github.server_url }}
GIT_TOKEN: ${{ secrets.GH_AW_SIDE_REPO_PAT }}
- run: bash "${RUNNER_TEMP}/gh-aw/actions/configure_git_credentials.sh"
+ GH_AW_SUBREPO_0: "github/gh-aw-side-repo"
+ run: |
+ bash "${RUNNER_TEMP}/gh-aw/actions/configure_git_credentials.sh"
+ GIT_SERVER_URL_STRIPPED="${GITHUB_SERVER_URL#https://}"
+ # Re-authenticate git for gh-aw-side-repo
+ git -C "gh-aw-side-repo" remote set-url origin "https://x-access-token:${GIT_TOKEN}@${GIT_SERVER_URL_STRIPPED}/${GH_AW_SUBREPO_0}.git"
+ echo "Git configured with standard GitHub Actions identity"
- name: Configure GH_HOST for enterprise compatibility
id: ghes-host-config
shell: bash
diff --git a/docs/adr/42257-auto-derive-checkout-path-for-cross-repo-entries.md b/docs/adr/42257-auto-derive-checkout-path-for-cross-repo-entries.md
new file mode 100644
index 00000000000..f02e0939817
--- /dev/null
+++ b/docs/adr/42257-auto-derive-checkout-path-for-cross-repo-entries.md
@@ -0,0 +1,43 @@
+# ADR-42257: Auto-Derive and Warn on Missing `path:` for Cross-Repo Checkout Entries
+
+**Date**: 2026-06-29
+**Status**: Draft
+**Deciders**: Unknown
+
+---
+
+### Context
+
+The gh-aw compiler emits a workflow manifest containing `GH_AW_CHECKOUT_PATH_N` environment variables for each checkout entry. When a `checkout:` block specifies a cross-repository entry (i.e., a non-empty `repository:` field) without an explicit `path:` field, the compiler was emitting `GH_AW_CHECKOUT_PATH_N: ""`. This empty value caused safe-outputs handlers such as `push_to_pull_request_branch` to fail at runtime with "Repository not found in workspace." The `actions/checkout` action itself recommends supplying a `path:` when checking out multiple repositories simultaneously, making explicit paths the expected convention. Dynamic repository expressions (e.g., `${{ github.event.inputs.repo }}`) cannot be resolved at compile time and must be excluded from this check.
+
+### Decision
+
+We will add a compiler validation pass (`validateCrossRepoCheckoutPaths`) that runs before checkout step generation. For each cross-repo checkout entry missing an explicit `path:`, the compiler will auto-derive a path from the repository-name segment of the `owner/repo` slug (e.g., `githubnext/gh-aw-side-repo` → `gh-aw-side-repo`), mutate the `CheckoutConfig` in place so that both the emitted `actions/checkout` step and the `GH_AW_CHECKOUT_PATH_N` manifest variable are non-empty, and emit a deprecation warning directing workflow authors to add an explicit `path:`. Dynamic expressions (those containing `${{`) are skipped because their target repository cannot be determined at compile time.
+
+### Alternatives Considered
+
+#### Alternative 1: Hard compilation error
+
+The compiler could reject workflow definitions where a cross-repo checkout entry lacks an explicit `path:`, treating it as an error rather than a warning. This would eliminate all ambiguity and force authors to be explicit upfront. It was rejected because it would break existing workflows that omit `path:` and currently expect the compiler to handle the case implicitly, creating a disruptive migration burden without a safe upgrade path.
+
+#### Alternative 2: Silent auto-derivation (no warning)
+
+The compiler could silently auto-derive the path without emitting any deprecation warning. This would be fully backward-compatible and produce no noise for existing users. It was rejected because it would perpetuate implicit behavior indefinitely — authors would never be incentivized to move to explicit `path:` declarations, and the implicit derivation rule would become a permanent, undocumented contract in the compiler.
+
+### Consequences
+
+#### Positive
+- Eliminates the `GH_AW_CHECKOUT_PATH_N: ""` root cause, preventing runtime "Repository not found in workspace" failures in safe-outputs handlers.
+- Provides actionable, inline deprecation warnings that guide authors toward explicit `path:` configuration, improving long-term workflow hygiene.
+
+#### Negative
+- Dynamic repository expressions (e.g., `${{ github.event.inputs.repo }}`) are silently skipped: if such an expression resolves to a cross-repo target at runtime, the manifest may still contain an empty path, leaving the runtime failure mode in place for that class of dynamic checkouts.
+- Introduces implicit compiler mutation of `CheckoutConfig` objects during the validation phase, which may surprise contributors who expect validation to be purely read-only and side-effect-free.
+
+#### Neutral
+- The auto-derivation logic (`strings.LastIndex(repo, "/") + 1`) mirrors the convention used by `actions/checkout` itself for multi-repo scenarios, so the derived paths are consistent with what authors would write manually.
+- The deprecation warning is emitted to stderr as a compiler warning (not an error), keeping the existing warning-count mechanism and not changing exit codes for currently-valid workflow definitions.
+
+---
+
+*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.*
diff --git a/pkg/workflow/checkout_config_parser.go b/pkg/workflow/checkout_config_parser.go
index 87d15032c84..8a7855236b2 100644
--- a/pkg/workflow/checkout_config_parser.go
+++ b/pkg/workflow/checkout_config_parser.go
@@ -101,6 +101,7 @@ func checkoutConfigFromMap(m map[string]any) (*CheckoutConfig, error) {
if !ok {
return nil, errors.New("checkout.path must be a string")
}
+ cfg.PathExplicit = true
// Normalize "." to empty string: both mean the workspace root and
// are treated identically by the checkout step generator.
if s == "." {
diff --git a/pkg/workflow/checkout_manager.go b/pkg/workflow/checkout_manager.go
index 22cfca330e4..10088468bc2 100644
--- a/pkg/workflow/checkout_manager.go
+++ b/pkg/workflow/checkout_manager.go
@@ -45,6 +45,11 @@ type CheckoutConfig struct {
// Path within GITHUB_WORKSPACE to place the checkout. Defaults to the workspace root.
Path string `json:"path,omitempty"`
+ // PathExplicit tracks whether the workflow frontmatter explicitly provided a
+ // path: field. This preserves the distinction between an omitted path and an
+ // explicit root checkout (path: .), which is normalized to Path == "".
+ PathExplicit bool `json:"-"`
+
// GitHubToken overrides the default GITHUB_TOKEN for authentication.
// Use ${{ secrets.MY_TOKEN }} to reference a repository secret.
// Maps to the "token" input of actions/checkout.
diff --git a/pkg/workflow/checkout_path_validation.go b/pkg/workflow/checkout_path_validation.go
new file mode 100644
index 00000000000..23e7b66d0c4
--- /dev/null
+++ b/pkg/workflow/checkout_path_validation.go
@@ -0,0 +1,78 @@
+package workflow
+
+import (
+ "fmt"
+ "os"
+ "strings"
+)
+
+var checkoutPathValidationLog = newValidationLogger("checkout_path")
+
+// deriveAndWarnCrossRepoCheckoutPaths emits warnings for cross-repository
+// checkout entries that have no explicit path: field, and auto-derives a path from
+// the repository name.
+//
+// When a checkout entry targets a different repository (repository: != "") without
+// an explicit path:, the compiler auto-derives one using the repository-name portion
+// of the "owner/repo" slug (e.g. "githubnext/gh-aw-side-repo" → "gh-aw-side-repo").
+// This mirrors what actions/checkout itself recommends when checking out multiple
+// repositories simultaneously.
+//
+// A warning is emitted for each affected entry so that workflow authors can add
+// the explicit path: field and opt out of the auto-derivation.
+//
+// Dynamic repository expressions (those containing "${{") are skipped: the compiler
+// cannot determine at compile time whether they target a different repository, and
+// common patterns such as `repository: ${{ github.repository }}` are valid
+// same-repository checkouts that should not receive a warning.
+//
+// NOTE: This pass intentionally mutates the provided CheckoutConfig entries before
+// YAML generation. It also intentionally remains warning-only, even in strict mode,
+// to preserve backward compatibility with existing workflows.
+func (c *Compiler) deriveAndWarnCrossRepoCheckoutPaths(checkoutConfigs []*CheckoutConfig, markdownPath string) {
+ for _, cfg := range checkoutConfigs {
+ if cfg == nil || cfg.Repository == "" || cfg.Path != "" || cfg.PathExplicit {
+ continue
+ }
+
+ // Dynamic expression: skip — cannot determine at compile time whether it
+ // targets a different repository (e.g. ${{ github.repository }} is the
+ // same repo checked out at a specific ref, which is a valid pattern for
+ // pull_request_target and should not trigger a warning).
+ repository := strings.TrimSpace(cfg.Repository)
+ if repository == "" || strings.Contains(repository, "${{") {
+ continue
+ }
+
+ repository = strings.TrimRight(repository, "/")
+ if repository == "" {
+ continue
+ }
+
+ checkoutPathValidationLog.Printf("cross-repo checkout %q has no explicit path", cfg.Repository)
+ // Static repository slug: auto-derive path from the repository-name portion.
+ repoName := repository
+ if slash := strings.LastIndex(repository, "/"); slash >= 0 {
+ repoName = repository[slash+1:]
+ }
+ if repoName == "" {
+ checkoutPathValidationLog.Printf("skipping auto-derivation for cross-repo checkout %q: empty repository name", cfg.Repository)
+ continue
+ }
+
+ checkoutPathValidationLog.Printf("auto-deriving path %q for cross-repo checkout %q", repoName, cfg.Repository)
+ cfg.Path = repoName
+
+ msg := strings.Join([]string{
+ fmt.Sprintf("checkout: repository %q has no explicit path: field.", cfg.Repository),
+ fmt.Sprintf("The compiler has auto-derived path: %q from the repository name.", repoName),
+ "Add an explicit path: field to silence this warning. Auto-derivation may become an error in a future release:",
+ "",
+ " checkout:",
+ " - repository: " + cfg.Repository,
+ " path: " + repoName,
+ }, "\n")
+ fmt.Fprintln(os.Stderr, formatCompilerMessage(markdownPath, "warning", msg))
+ c.IncrementWarningCount()
+ }
+}
diff --git a/pkg/workflow/checkout_path_validation_test.go b/pkg/workflow/checkout_path_validation_test.go
new file mode 100644
index 00000000000..232f4e74562
--- /dev/null
+++ b/pkg/workflow/checkout_path_validation_test.go
@@ -0,0 +1,253 @@
+//go:build !integration
+
+package workflow
+
+import (
+ "bytes"
+ "io"
+ "os"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestDeriveAndWarnCrossRepoCheckoutPaths verifies that cross-repo checkout entries
+// without an explicit path: get a warning and an auto-derived path.
+func TestDeriveAndWarnCrossRepoCheckoutPaths(t *testing.T) {
+ const markdownPath = "test-workflow.md"
+
+ t.Run("no checkout configs produces no warning", func(t *testing.T) {
+ c := NewCompiler()
+ c.deriveAndWarnCrossRepoCheckoutPaths(nil, markdownPath)
+ assert.Equal(t, 0, c.GetWarningCount())
+ })
+
+ t.Run("default-only checkout (no repository) produces no warning", func(t *testing.T) {
+ c := NewCompiler()
+ configs := []*CheckoutConfig{{Path: "."}}
+ c.deriveAndWarnCrossRepoCheckoutPaths(configs, markdownPath)
+ assert.Equal(t, 0, c.GetWarningCount())
+ assert.Equal(t, ".", configs[0].Path, "path must not be modified")
+ })
+
+ t.Run("cross-repo checkout with explicit path produces no warning", func(t *testing.T) {
+ c := NewCompiler()
+ configs := []*CheckoutConfig{
+ {Repository: "owner/repo", Path: "my-repo"},
+ }
+ c.deriveAndWarnCrossRepoCheckoutPaths(configs, markdownPath)
+ assert.Equal(t, 0, c.GetWarningCount())
+ assert.Equal(t, "my-repo", configs[0].Path, "explicit path must not be changed")
+ })
+
+ t.Run("cross-repo checkout with no path gets auto-derived path and warning", func(t *testing.T) {
+ c := NewCompiler()
+ configs := []*CheckoutConfig{
+ {Repository: "githubnext/gh-aw-side-repo"},
+ }
+ c.deriveAndWarnCrossRepoCheckoutPaths(configs, markdownPath)
+ assert.Equal(t, 1, c.GetWarningCount())
+ assert.Equal(t, "gh-aw-side-repo", configs[0].Path, "path must be auto-derived from repo name")
+ })
+
+ t.Run("auto-derived path is only the repo-name portion of owner/repo", func(t *testing.T) {
+ c := NewCompiler()
+ configs := []*CheckoutConfig{
+ {Repository: "some-org/my-awesome-tool"},
+ }
+ c.deriveAndWarnCrossRepoCheckoutPaths(configs, markdownPath)
+ require.Equal(t, 1, c.GetWarningCount())
+ assert.Equal(t, "my-awesome-tool", configs[0].Path)
+ })
+
+ t.Run("multiple cross-repo checkouts without path each get a warning and derived path", func(t *testing.T) {
+ c := NewCompiler()
+ configs := []*CheckoutConfig{
+ {Repository: "owner/alpha"},
+ {Repository: "owner/beta", Path: "explicit-beta"}, // already has path — unchanged
+ {Repository: "owner/gamma"},
+ }
+ c.deriveAndWarnCrossRepoCheckoutPaths(configs, markdownPath)
+ assert.Equal(t, 2, c.GetWarningCount())
+ assert.Equal(t, "alpha", configs[0].Path)
+ assert.Equal(t, "explicit-beta", configs[1].Path, "pre-set path must not be changed")
+ assert.Equal(t, "gamma", configs[2].Path)
+ })
+
+ t.Run("nil checkout entry in slice is skipped without panic", func(t *testing.T) {
+ c := NewCompiler()
+ configs := []*CheckoutConfig{nil}
+ require.NotPanics(t, func() { c.deriveAndWarnCrossRepoCheckoutPaths(configs, markdownPath) })
+ assert.Equal(t, 0, c.GetWarningCount())
+ })
+
+ t.Run("dynamic repository expression is skipped (no warning, no path change)", func(t *testing.T) {
+ c := NewCompiler()
+ configs := []*CheckoutConfig{
+ {Repository: "${{ github.event.inputs.repo }}"},
+ }
+ c.deriveAndWarnCrossRepoCheckoutPaths(configs, markdownPath)
+ assert.Equal(t, 0, c.GetWarningCount(), "dynamic expressions cannot be determined as cross-repo at compile time")
+ assert.Empty(t, configs[0].Path, "path must not be modified for dynamic repos")
+ })
+
+ t.Run("github.repository expression (same-repo trusted checkout) is skipped", func(t *testing.T) {
+ c := NewCompiler()
+ configs := []*CheckoutConfig{
+ {Repository: "${{ github.repository }}"},
+ }
+ c.deriveAndWarnCrossRepoCheckoutPaths(configs, markdownPath)
+ assert.Equal(t, 0, c.GetWarningCount(), "github.repository is a same-repo pattern used in pull_request_target")
+ assert.Empty(t, configs[0].Path)
+ })
+
+ t.Run("warning message for static repo contains suggested path", func(t *testing.T) {
+ path, stderrMsg, warnCount := warnCrossRepoPath(t, "acme/widget-service")
+ assert.Equal(t, "widget-service", path)
+ assert.Equal(t, 1, warnCount)
+ assert.Contains(t, stderrMsg, `checkout: repository "acme/widget-service" has no explicit path: field.`)
+ assert.Contains(t, stderrMsg, "path: widget-service")
+ })
+
+ t.Run("explicit root path remains opt-in when normalized to empty string", func(t *testing.T) {
+ c := NewCompiler()
+ configs := []*CheckoutConfig{
+ {Repository: "owner/repo", PathExplicit: true},
+ }
+ c.deriveAndWarnCrossRepoCheckoutPaths(configs, markdownPath)
+ assert.Equal(t, 0, c.GetWarningCount(), "explicit root path must not trigger auto-derivation")
+ assert.Empty(t, configs[0].Path, "explicit root path should stay at workspace root")
+ })
+}
+
+// TestDeriveAndWarnCrossRepoCheckoutPathsViaCompiler verifies that the warning and path
+// auto-derivation are triggered end-to-end when compiling a workflow.
+func TestDeriveAndWarnCrossRepoCheckoutPathsViaCompiler(t *testing.T) {
+ t.Run("compiler emits warning for cross-repo checkout without path", func(t *testing.T) {
+ c := NewCompiler()
+
+ workflowData := minimalWorkflowData()
+ workflowData.CheckoutConfigs = []*CheckoutConfig{
+ {Repository: "owner/side-repo"},
+ }
+
+ markdownPath := "test.md"
+ err := c.validateWorkflowData(workflowData, markdownPath)
+ require.NoError(t, err, "missing path should be a warning, not an error")
+ assert.Equal(t, 1, c.GetWarningCount(), "expected one warning for missing path")
+ // Confirm auto-derivation happened
+ assert.Equal(t, "side-repo", workflowData.CheckoutConfigs[0].Path)
+ })
+
+ t.Run("compiler emits no warning when path is explicitly set", func(t *testing.T) {
+ c := NewCompiler()
+
+ workflowData := minimalWorkflowData()
+ workflowData.CheckoutConfigs = []*CheckoutConfig{
+ {Repository: "owner/side-repo", Path: "side-repo"},
+ }
+
+ markdownPath := "test.md"
+ err := c.validateWorkflowData(workflowData, markdownPath)
+ require.NoError(t, err)
+ assert.Equal(t, 0, c.GetWarningCount())
+ })
+}
+
+// minimalWorkflowData returns a WorkflowData suitable for use in compiler validation tests.
+func minimalWorkflowData() *WorkflowData {
+ return &WorkflowData{
+ Name: "Test Workflow",
+ MarkdownContent: "Test content.",
+ On: "push:",
+ Permissions: "contents: read",
+ }
+}
+
+// warnCrossRepoPath returns the derived path, emitted warning text, and warning count.
+func warnCrossRepoPath(t *testing.T, repository string) (path string, stderrMsg string, warnCount int) {
+ t.Helper()
+ origStderr := os.Stderr
+ r, w, err := os.Pipe()
+ require.NoError(t, err)
+ os.Stderr = w
+ defer func() { os.Stderr = origStderr }()
+
+ c := NewCompiler()
+ cfg := &CheckoutConfig{Repository: repository}
+ c.deriveAndWarnCrossRepoCheckoutPaths([]*CheckoutConfig{cfg}, "test.md")
+
+ require.NoError(t, w.Close())
+ var buf bytes.Buffer
+ _, err = io.Copy(&buf, r)
+ require.NoError(t, err)
+ require.NoError(t, r.Close())
+
+ return cfg.Path, buf.String(), c.GetWarningCount()
+}
+
+func TestWarnCrossRepoPath_RepoNameExtraction(t *testing.T) {
+ tests := []struct {
+ repository string
+ expectedPath string
+ expectedWarn int
+ }{
+ {"owner/repo", "repo", 1},
+ {"github/copilot", "copilot", 1},
+ {"githubnext/gh-aw-side-repo", "gh-aw-side-repo", 1},
+ {"a/b/c", "c", 1}, // only the last segment
+ {"just-repo-no-owner", "just-repo-no-owner", 1}, // no slash: use full string
+ {"owner/", "owner", 1}, // trailing slash is trimmed before derivation
+ {" ", "", 0}, // whitespace-only repository is ignored
+ }
+ for _, tt := range tests {
+ t.Run(tt.repository, func(t *testing.T) {
+ path, _, count := warnCrossRepoPath(t, tt.repository)
+ assert.Equal(t, tt.expectedPath, path)
+ assert.Equal(t, tt.expectedWarn, count)
+ })
+ }
+}
+
+// TestCrossRepoCheckoutPathAppearsInManifestStep confirms that after auto-derivation the
+// CheckoutManager emits a non-empty GH_AW_CHECKOUT_PATH_N in the manifest step.
+func TestCrossRepoCheckoutPathAppearsInManifestStep(t *testing.T) {
+ getActionPin := func(action string) string { return action + "@pin" }
+
+ t.Run("auto-derived path appears in manifest step", func(t *testing.T) {
+ // Simulate what the compiler does: validate configs first (derives path), then pass to manager.
+ configs := []*CheckoutConfig{
+ {Repository: "githubnext/gh-aw-side-repo"},
+ }
+ c := NewCompiler()
+ c.deriveAndWarnCrossRepoCheckoutPaths(configs, "test.md")
+
+ cm := NewCheckoutManager(configs)
+ steps := cm.GenerateCheckoutManifestStep(getActionPin)
+ require.Len(t, steps, 1)
+ out := steps[0]
+ assert.Contains(t, out, `GH_AW_CHECKOUT_REPO_0: "githubnext/gh-aw-side-repo"`)
+ assert.Contains(t, out, `GH_AW_CHECKOUT_PATH_0: "gh-aw-side-repo"`)
+ assert.NotContains(t, out, `GH_AW_CHECKOUT_PATH_0: ""`, "path must not be empty after auto-derivation")
+ })
+}
+
+// TestCrossRepoCheckoutPathAppearsInCheckoutStep confirms that the checkout step emits
+// a path: field after auto-derivation.
+func TestCrossRepoCheckoutPathAppearsInCheckoutStep(t *testing.T) {
+ getActionPin := func(action string) string { return action + "@pin" }
+
+ configs := []*CheckoutConfig{
+ {Repository: "acme/my-lib"},
+ }
+ c := NewCompiler()
+ c.deriveAndWarnCrossRepoCheckoutPaths(configs, "test.md")
+
+ cm := NewCheckoutManager(configs)
+ lines := cm.GenerateAdditionalCheckoutSteps(getActionPin)
+ combined := strings.Join(lines, "")
+ assert.Contains(t, combined, "repository: acme/my-lib")
+ assert.Contains(t, combined, "path: my-lib", "checkout step must include the auto-derived path")
+}
diff --git a/pkg/workflow/compiler_validators.go b/pkg/workflow/compiler_validators.go
index a06ea9dd629..15b163d193f 100644
--- a/pkg/workflow/compiler_validators.go
+++ b/pkg/workflow/compiler_validators.go
@@ -179,6 +179,8 @@ func (c *Compiler) validateCoreToolConfiguration(workflowData *WorkflowData, mar
fmt.Fprintln(os.Stderr, formatCompilerMessage(markdownPath, "warning", err.Error()))
c.IncrementWarningCount()
}
+ workflowLog.Printf("Validating cross-repo checkout paths")
+ c.deriveAndWarnCrossRepoCheckoutPaths(workflowData.CheckoutConfigs, markdownPath)
workflowLog.Printf("Validating push-to-pull-request-branch configuration")
c.validatePushToPullRequestBranchWarnings(workflowData.SafeOutputs, workflowData.CheckoutConfigs)
for _, validation := range validations {