diff --git a/.github/workflows/smoke-workflow-call.lock.yml b/.github/workflows/smoke-workflow-call.lock.yml
index 07050efaea..1b82300939 100644
--- a/.github/workflows/smoke-workflow-call.lock.yml
+++ b/.github/workflows/smoke-workflow-call.lock.yml
@@ -56,6 +56,7 @@ jobs:
comment_repo: ""
model: ${{ steps.generate_aw_info.outputs.model }}
secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }}
+ target_repo: ${{ steps.resolve-host-repo.outputs.target_repo }}
steps:
- name: Checkout actions folder
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -67,6 +68,15 @@ jobs:
uses: ./actions/setup
with:
destination: /opt/gh-aw/actions
+ - name: Resolve host repo for activation checkout
+ id: resolve-host-repo
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ with:
+ script: |
+ const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io);
+ const { main } = require('/opt/gh-aw/actions/resolve_host_repo.cjs');
+ await main();
- name: Generate agentic run info
id: generate_aw_info
env:
@@ -85,6 +95,7 @@ jobs:
GH_AW_INFO_AWMG_VERSION: ""
GH_AW_INFO_FIREWALL_TYPE: "squid"
GH_AW_COMPILED_STRICT: "true"
+ GH_AW_INFO_TARGET_REPO: ${{ steps.resolve-host-repo.outputs.target_repo }}
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
script: |
@@ -96,7 +107,7 @@ jobs:
env:
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
- name: Cross-repo setup guidance
- if: failure() && github.event_name == 'workflow_call'
+ if: failure() && steps.resolve-host-repo.outputs.target_repo != github.repository
run: |
echo "::error::COPILOT_GITHUB_TOKEN must be configured in the CALLER repository's secrets."
echo "::error::For cross-repo workflow_call, secrets must be set in the repository that triggers the workflow."
@@ -105,7 +116,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- repository: ${{ github.event_name == 'workflow_call' && github.action_repository || github.repository }}
+ repository: ${{ steps.resolve-host-repo.outputs.target_repo }}
sparse-checkout: |
.github
.agents
@@ -1105,7 +1116,9 @@ jobs:
await main();
safe_outputs:
- needs: agent
+ needs:
+ - activation
+ - agent
if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (needs.agent.outputs.detection_success == 'true')
runs-on: ubuntu-slim
permissions:
diff --git a/actions/setup/js/generate_aw_info.cjs b/actions/setup/js/generate_aw_info.cjs
index e561c92a39..b10d2e5aab 100644
--- a/actions/setup/js/generate_aw_info.cjs
+++ b/actions/setup/js/generate_aw_info.cjs
@@ -64,6 +64,7 @@ async function main(core, ctx) {
sha: ctx.sha,
actor: ctx.actor,
event_name: ctx.eventName,
+ target_repo: process.env.GH_AW_INFO_TARGET_REPO || "",
staged: process.env.GH_AW_INFO_STAGED === "true",
allowed_domains: allowedDomains,
firewall_enabled: process.env.GH_AW_INFO_FIREWALL_ENABLED === "true",
diff --git a/actions/setup/js/resolve_host_repo.cjs b/actions/setup/js/resolve_host_repo.cjs
new file mode 100644
index 0000000000..3840be84f1
--- /dev/null
+++ b/actions/setup/js/resolve_host_repo.cjs
@@ -0,0 +1,51 @@
+// @ts-check
+///
+
+/**
+ * Resolves the target repository for the activation job checkout.
+ *
+ * Uses GITHUB_WORKFLOW_REF to determine the platform (host) repository regardless
+ * of the triggering event. This fixes cross-repo activation for event-driven relays
+ * (e.g. on: issue_comment, on: push) where github.event_name is NOT 'workflow_call',
+ * so the expression introduced in #20301 incorrectly fell back to github.repository
+ * (the caller's repo) instead of the platform repo.
+ *
+ * GITHUB_WORKFLOW_REF always reflects the currently executing workflow file, not the
+ * triggering event. Its format is:
+ * owner/repo/.github/workflows/file.yml@refs/heads/main
+ *
+ * When the platform workflow runs cross-repo (called via uses:), GITHUB_WORKFLOW_REF
+ * starts with the platform repo slug, while GITHUB_REPOSITORY is the caller repo.
+ * Comparing the two lets us detect cross-repo invocations without relying on event_name.
+ */
+
+/**
+ * @returns {Promise}
+ */
+async function main() {
+ const workflowRef = process.env.GITHUB_WORKFLOW_REF || "";
+ const currentRepo = process.env.GITHUB_REPOSITORY || "";
+
+ // GITHUB_WORKFLOW_REF format: owner/repo/.github/workflows/file.yml@ref
+ // The regex captures everything before the third slash segment (i.e., the owner/repo prefix).
+ const match = workflowRef.match(/^([^/]+\/[^/]+)\//);
+ const workflowRepo = match ? match[1] : "";
+
+ // Fall back to currentRepo when GITHUB_WORKFLOW_REF cannot be parsed
+ const targetRepo = workflowRepo || currentRepo;
+
+ core.info(`GITHUB_WORKFLOW_REF: ${workflowRef}`);
+ core.info(`GITHUB_REPOSITORY: ${currentRepo}`);
+ core.info(`Resolved host repo for activation checkout: ${targetRepo}`);
+
+ if (targetRepo !== currentRepo && targetRepo !== "") {
+ core.info(`Cross-repo invocation detected: platform repo is "${targetRepo}", caller is "${currentRepo}"`);
+ await core.summary.addRaw(`**Activation Checkout**: Checking out platform repo \`${targetRepo}\` (caller: \`${currentRepo}\`)`).write();
+ } else {
+ core.info(`Same-repo invocation: checking out ${targetRepo}`);
+ }
+
+ core.setOutput("target_repo", targetRepo);
+}
+
+module.exports = { main };
diff --git a/actions/setup/js/resolve_host_repo.test.cjs b/actions/setup/js/resolve_host_repo.test.cjs
new file mode 100644
index 0000000000..a2b7d5cdb0
--- /dev/null
+++ b/actions/setup/js/resolve_host_repo.test.cjs
@@ -0,0 +1,128 @@
+// @ts-check
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
+
+// Mock the global objects that GitHub Actions provides
+const mockCore = {
+ info: vi.fn(),
+ warning: vi.fn(),
+ error: vi.fn(),
+ setFailed: vi.fn(),
+ setOutput: vi.fn(),
+ summary: {
+ addRaw: vi.fn().mockReturnThis(),
+ write: vi.fn().mockResolvedValue(undefined),
+ },
+};
+
+// Set up global mocks before importing the module
+global.core = mockCore;
+
+describe("resolve_host_repo.cjs", () => {
+ let main;
+
+ beforeEach(async () => {
+ vi.clearAllMocks();
+ mockCore.summary.addRaw.mockReturnThis();
+ mockCore.summary.write.mockResolvedValue(undefined);
+
+ const module = await import("./resolve_host_repo.cjs");
+ main = module.main;
+ });
+
+ afterEach(() => {
+ delete process.env.GITHUB_WORKFLOW_REF;
+ delete process.env.GITHUB_REPOSITORY;
+ });
+
+ it("should output the platform repo when invoked cross-repo", async () => {
+ process.env.GITHUB_WORKFLOW_REF = "my-org/platform-repo/.github/workflows/gateway.lock.yml@refs/heads/main";
+ process.env.GITHUB_REPOSITORY = "my-org/app-repo";
+
+ await main();
+
+ expect(mockCore.setOutput).toHaveBeenCalledWith("target_repo", "my-org/platform-repo");
+ });
+
+ it("should log a cross-repo detection message and write step summary", async () => {
+ process.env.GITHUB_WORKFLOW_REF = "my-org/platform-repo/.github/workflows/gateway.lock.yml@refs/heads/main";
+ process.env.GITHUB_REPOSITORY = "my-org/app-repo";
+
+ await main();
+
+ expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Cross-repo invocation detected"));
+ expect(mockCore.summary.addRaw).toHaveBeenCalled();
+ expect(mockCore.summary.write).toHaveBeenCalled();
+ });
+
+ it("should output the current repo when same-repo invocation", async () => {
+ process.env.GITHUB_WORKFLOW_REF = "my-org/platform-repo/.github/workflows/gateway.lock.yml@refs/heads/main";
+ process.env.GITHUB_REPOSITORY = "my-org/platform-repo";
+
+ await main();
+
+ expect(mockCore.setOutput).toHaveBeenCalledWith("target_repo", "my-org/platform-repo");
+ expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Same-repo invocation"));
+ });
+
+ it("should not write step summary for same-repo invocations", async () => {
+ process.env.GITHUB_WORKFLOW_REF = "my-org/platform-repo/.github/workflows/gateway.lock.yml@refs/heads/main";
+ process.env.GITHUB_REPOSITORY = "my-org/platform-repo";
+
+ await main();
+
+ expect(mockCore.summary.write).not.toHaveBeenCalled();
+ });
+
+ it("should fall back to GITHUB_REPOSITORY when GITHUB_WORKFLOW_REF is empty", async () => {
+ process.env.GITHUB_WORKFLOW_REF = "";
+ process.env.GITHUB_REPOSITORY = "my-org/fallback-repo";
+
+ await main();
+
+ expect(mockCore.setOutput).toHaveBeenCalledWith("target_repo", "my-org/fallback-repo");
+ });
+
+ it("should fall back to GITHUB_REPOSITORY when GITHUB_WORKFLOW_REF has unexpected format", async () => {
+ process.env.GITHUB_WORKFLOW_REF = "not-a-valid-ref";
+ process.env.GITHUB_REPOSITORY = "my-org/fallback-repo";
+
+ await main();
+
+ expect(mockCore.setOutput).toHaveBeenCalledWith("target_repo", "my-org/fallback-repo");
+ });
+
+ it("should handle event-driven relay (issue_comment) that calls a cross-repo workflow", async () => {
+ // This is the exact scenario from the bug report:
+ // An issue_comment event in app-repo triggers a relay that calls the platform workflow.
+ // GITHUB_WORKFLOW_REF reflects the platform workflow, GITHUB_REPOSITORY is the caller.
+ process.env.GITHUB_WORKFLOW_REF = "my-org/platform-repo/.github/workflows/my-workflow.lock.yml@main";
+ process.env.GITHUB_REPOSITORY = "my-org/app-repo";
+
+ await main();
+
+ expect(mockCore.setOutput).toHaveBeenCalledWith("target_repo", "my-org/platform-repo");
+ expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Cross-repo invocation detected"));
+ });
+
+ it("should fall back to empty string when GITHUB_REPOSITORY is also undefined", async () => {
+ process.env.GITHUB_WORKFLOW_REF = "my-org/platform-repo/.github/workflows/gateway.lock.yml@refs/heads/main";
+ delete process.env.GITHUB_REPOSITORY;
+
+ await main();
+
+ // workflowRepo parsed from GITHUB_WORKFLOW_REF is "my-org/platform-repo"
+ // currentRepo is "" since env var is deleted
+ // targetRepo = workflowRepo || currentRepo = "my-org/platform-repo"
+ expect(mockCore.setOutput).toHaveBeenCalledWith("target_repo", "my-org/platform-repo");
+ });
+
+ it("should log GITHUB_WORKFLOW_REF and GITHUB_REPOSITORY", async () => {
+ process.env.GITHUB_WORKFLOW_REF = "my-org/platform-repo/.github/workflows/gateway.lock.yml@refs/heads/main";
+ process.env.GITHUB_REPOSITORY = "my-org/app-repo";
+
+ await main();
+
+ expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("GITHUB_WORKFLOW_REF:"));
+ expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("GITHUB_REPOSITORY:"));
+ });
+});
diff --git a/pkg/workflow/checkout_manager.go b/pkg/workflow/checkout_manager.go
index b1db1d65aa..04890002d1 100644
--- a/pkg/workflow/checkout_manager.go
+++ b/pkg/workflow/checkout_manager.go
@@ -131,6 +131,13 @@ type CheckoutManager struct {
ordered []*resolvedCheckout
// index maps checkoutKey to the position in ordered
index map[checkoutKey]int
+ // crossRepoTargetRepo holds the platform (host) repository to use when performing
+ // .github/.agents sparse checkout steps for cross-repo workflow_call invocations.
+ //
+ // In the activation job this is set to "${{ steps.resolve-host-repo.outputs.target_repo }}".
+ // In the agent and safe_outputs jobs it is set to "${{ needs.activation.outputs.target_repo }}".
+ // An empty string means the checkout targets the current repository (github.repository).
+ crossRepoTargetRepo string
}
// NewCheckoutManager creates a new CheckoutManager pre-loaded with user-supplied
@@ -146,6 +153,24 @@ func NewCheckoutManager(userCheckouts []*CheckoutConfig) *CheckoutManager {
return cm
}
+// SetCrossRepoTargetRepo stores the platform (host) repository expression used for
+// .github/.agents sparse checkout steps. Call this when the workflow has a workflow_call
+// trigger and the checkout should target the platform repo rather than github.repository.
+//
+// In the activation job pass "${{ steps.resolve-host-repo.outputs.target_repo }}".
+// In downstream jobs (agent, safe_outputs) pass "${{ needs.activation.outputs.target_repo }}".
+func (cm *CheckoutManager) SetCrossRepoTargetRepo(repo string) {
+ checkoutManagerLog.Printf("Setting cross-repo target: %q", repo)
+ cm.crossRepoTargetRepo = repo
+}
+
+// GetCrossRepoTargetRepo returns the platform repo expression previously set by
+// SetCrossRepoTargetRepo, or an empty string if no cross-repo target was set
+// (same-repo invocation or inlined imports).
+func (cm *CheckoutManager) GetCrossRepoTargetRepo() string {
+ return cm.crossRepoTargetRepo
+}
+
// add processes a single CheckoutConfig and either creates a new entry or merges
// it into an existing entry with the same key.
func (cm *CheckoutManager) add(cfg *CheckoutConfig) {
@@ -249,7 +274,9 @@ func (cm *CheckoutManager) GenerateCheckoutAppTokenSteps(c *Compiler, permission
continue
}
checkoutManagerLog.Printf("Generating app token minting step for checkout index=%d repo=%q", i, entry.key.repository)
- appSteps := c.buildGitHubAppTokenMintStep(entry.githubApp, permissions)
+ // Pass empty fallback so the app token defaults to github.event.repository.name.
+ // Checkout-specific cross-repo scoping is handled via the explicit repository field.
+ appSteps := c.buildGitHubAppTokenMintStep(entry.githubApp, permissions, "")
stepID := fmt.Sprintf("checkout-app-token-%d", i)
for _, step := range appSteps {
modified := strings.ReplaceAll(step, "id: safe-outputs-app-token", "id: "+stepID)
diff --git a/pkg/workflow/checkout_manager_test.go b/pkg/workflow/checkout_manager_test.go
index 888300edde..7963550d98 100644
--- a/pkg/workflow/checkout_manager_test.go
+++ b/pkg/workflow/checkout_manager_test.go
@@ -910,3 +910,34 @@ func TestAdditionalCheckoutWithAppAuth(t *testing.T) {
assert.Contains(t, combined, "other/repo", "should reference the additional repo")
})
}
+
+// TestCrossRepoTargetRepo verifies the SetCrossRepoTargetRepo/GetCrossRepoTargetRepo lifecycle.
+func TestCrossRepoTargetRepo(t *testing.T) {
+ t.Run("default is empty string (same-repo)", func(t *testing.T) {
+ cm := NewCheckoutManager(nil)
+ assert.Empty(t, cm.GetCrossRepoTargetRepo(), "new checkout manager should have no cross-repo target")
+ })
+
+ t.Run("activation job expression is stored and retrievable", func(t *testing.T) {
+ cm := NewCheckoutManager(nil)
+ cm.SetCrossRepoTargetRepo("${{ steps.resolve-host-repo.outputs.target_repo }}")
+ assert.Equal(t, "${{ steps.resolve-host-repo.outputs.target_repo }}", cm.GetCrossRepoTargetRepo())
+ })
+
+ t.Run("downstream job expression (needs.activation.outputs) is stored and retrievable", func(t *testing.T) {
+ cm := NewCheckoutManager(nil)
+ cm.SetCrossRepoTargetRepo("${{ needs.activation.outputs.target_repo }}")
+ assert.Equal(t, "${{ needs.activation.outputs.target_repo }}", cm.GetCrossRepoTargetRepo())
+ })
+
+ t.Run("GenerateGitHubFolderCheckoutStep uses stored value", func(t *testing.T) {
+ cm := NewCheckoutManager(nil)
+ cm.SetCrossRepoTargetRepo("${{ needs.activation.outputs.target_repo }}")
+
+ lines := cm.GenerateGitHubFolderCheckoutStep(cm.GetCrossRepoTargetRepo(), GetActionPin)
+ combined := strings.Join(lines, "")
+
+ assert.Contains(t, combined, "repository: ${{ needs.activation.outputs.target_repo }}",
+ "checkout step should use the cross-repo target")
+ })
+}
diff --git a/pkg/workflow/compiler_activation_job.go b/pkg/workflow/compiler_activation_job.go
index a25d009c1c..fb46daadeb 100644
--- a/pkg/workflow/compiler_activation_job.go
+++ b/pkg/workflow/compiler_activation_job.go
@@ -34,6 +34,18 @@ func (c *Compiler) buildActivationJob(data *WorkflowData, preActivationJobCreate
// Activation job doesn't need project support (no safe outputs processed here)
steps = append(steps, c.generateSetupStep(setupActionRef, SetupActionDestination, false)...)
+ // When a workflow_call trigger is present, resolve the platform (host) repository before
+ // generating aw_info so that target_repo can be included in aw_info.json and used by
+ // the checkout step. This is necessary for event-driven relays (e.g. on: issue_comment)
+ // where github.event_name is not 'workflow_call', making the previous expression
+ // (github.event_name == 'workflow_call' && github.action_repository || github.repository)
+ // unreliable. GITHUB_WORKFLOW_REF always reflects the executing workflow's repo regardless
+ // of how it was triggered.
+ if hasWorkflowCallTrigger(data.On) && !data.InlinedImports {
+ compilerActivationJobLog.Print("Adding resolve-host-repo step for workflow_call trigger")
+ steps = append(steps, c.generateResolveHostRepoStep())
+ }
+
// Generate agentic run info immediately after setup so aw_info.json is ready as early as possible.
// This ensures it is available for prompt generation and can be uploaded together with prompt.txt.
engine, err := c.getAgenticEngine(data.AI)
@@ -47,6 +59,13 @@ func (c *Compiler) buildActivationJob(data *WorkflowData, preActivationJobCreate
// Expose the model output from the activation job so downstream jobs can reference it
outputs["model"] = "${{ steps.generate_aw_info.outputs.model }}"
+ // Expose the resolved platform (host) repository so agent and safe_outputs jobs can use
+ // needs.activation.outputs.target_repo for any checkout that must target the platform repo
+ // rather than github.repository (the caller's repo in cross-repo workflow_call scenarios).
+ if hasWorkflowCallTrigger(data.On) && !data.InlinedImports {
+ outputs["target_repo"] = "${{ steps.resolve-host-repo.outputs.target_repo }}"
+ }
+
// Add secret validation step before context variable validation.
// This validates that the required engine secrets are available before any other checks.
secretValidationStep := engine.GetSecretValidationStep(data)
@@ -61,12 +80,15 @@ func (c *Compiler) buildActivationJob(data *WorkflowData, preActivationJobCreate
}
// Add cross-repo setup guidance when workflow_call is a trigger.
- // This step only runs when secret validation fails in a workflow_call context,
+ // This step only runs when secret validation fails in a cross-repo context,
// providing actionable guidance to the caller team about configuring secrets.
+ // Use steps.resolve-host-repo.outputs.target_repo != github.repository instead of
+ // github.event_name == 'workflow_call': the latter never fires for event-driven relays
+ // (issue_comment/push → workflow_call) where the event_name is the originating event.
if hasWorkflowCallTrigger(data.On) {
compilerActivationJobLog.Print("Adding cross-repo setup guidance step for workflow_call trigger")
steps = append(steps, " - name: Cross-repo setup guidance\n")
- steps = append(steps, " if: failure() && github.event_name == 'workflow_call'\n")
+ steps = append(steps, " if: failure() && steps.resolve-host-repo.outputs.target_repo != github.repository\n")
steps = append(steps, " run: |\n")
steps = append(steps, " echo \"::error::COPILOT_GITHUB_TOKEN must be configured in the CALLER repository's secrets.\"\n")
steps = append(steps, " echo \"::error::For cross-repo workflow_call, secrets must be set in the repository that triggers the workflow.\"\n")
@@ -434,6 +456,33 @@ func (c *Compiler) generatePromptInActivationJob(steps *[]string, data *Workflow
compilerActivationJobLog.Print("Prompt generation steps added to activation job")
}
+// generateResolveHostRepoStep generates a step that resolves the platform (host) repository
+// for the activation job checkout by inspecting GITHUB_WORKFLOW_REF at runtime.
+//
+// This step replaces the previous compile-time expression
+//
+// github.event_name == 'workflow_call' && github.action_repository || github.repository
+//
+// which only worked when the outermost trigger was workflow_call. For event-driven relays
+// (e.g. on: issue_comment, on: push) the event_name is the native event, so the old
+// expression always fell back to github.repository (the caller's repo), causing the
+// activation job to check out the wrong repository.
+//
+// GITHUB_WORKFLOW_REF always contains the path of the currently executing workflow file
+// (owner/repo/.github/workflows/file.yml@ref), regardless of the triggering event.
+// Comparing its owner/repo prefix with GITHUB_REPOSITORY reliably detects cross-repo
+// invocations for all relay patterns.
+func (c *Compiler) generateResolveHostRepoStep() string {
+ var step strings.Builder
+ step.WriteString(" - name: Resolve host repo for activation checkout\n")
+ step.WriteString(" id: resolve-host-repo\n")
+ step.WriteString(fmt.Sprintf(" uses: %s\n", GetActionPin("actions/github-script")))
+ step.WriteString(" with:\n")
+ step.WriteString(" script: |\n")
+ step.WriteString(generateGitHubScriptWithRequire("resolve_host_repo.cjs"))
+ return step.String()
+}
+
// generateCheckoutGitHubFolderForActivation generates the checkout step for .github and .agents folders
// specifically for the activation job. Unlike generateCheckoutGitHubFolder, this method doesn't skip
// the checkout when the agent job will have a full repository checkout, because the activation job
@@ -457,18 +506,19 @@ func (c *Compiler) generateCheckoutGitHubFolderForActivation(data *WorkflowData)
// but the activation job will always have it for GitHub API access and runtime imports.
// The agent job uses only the user-specified permissions (no automatic contents:read augmentation).
- // For workflow_call triggers, checkout the callee repository using a conditional expression.
- // github.action_repository points to the callee (platform) repo during workflow_call;
- // for other event types the explicit event_name check short-circuits to falsy and we
- // fall back to github.repository. This supports mixed triggers (e.g., workflow_call + workflow_dispatch).
+ // For workflow_call triggers, checkout the callee (platform) repository using the target_repo
+ // output from the resolve-host-repo step. That step parses GITHUB_WORKFLOW_REF at runtime to
+ // determine the platform repo, correctly handling event-driven relays where event_name is not
+ // 'workflow_call' (e.g. on: issue_comment, on: push).
//
// Skip when inlined-imports is enabled: content is embedded at compile time and no
// runtime-import macros are used, so the callee's .md files are not needed at runtime.
cm := NewCheckoutManager(nil)
if data != nil && hasWorkflowCallTrigger(data.On) && !data.InlinedImports {
compilerActivationJobLog.Print("Adding cross-repo-aware .github checkout for workflow_call trigger")
+ cm.SetCrossRepoTargetRepo("${{ steps.resolve-host-repo.outputs.target_repo }}")
return cm.GenerateGitHubFolderCheckoutStep(
- "${{ github.event_name == 'workflow_call' && github.action_repository || github.repository }}",
+ cm.GetCrossRepoTargetRepo(),
GetActionPin,
)
}
diff --git a/pkg/workflow/compiler_activation_job_test.go b/pkg/workflow/compiler_activation_job_test.go
index e027259961..4da5a73799 100644
--- a/pkg/workflow/compiler_activation_job_test.go
+++ b/pkg/workflow/compiler_activation_job_test.go
@@ -12,7 +12,10 @@ import (
// workflowCallRepo is the expression injected into the repository: field of the
// activation-job checkout step when a workflow_call trigger is detected.
-const workflowCallRepo = "${{ github.event_name == 'workflow_call' && github.action_repository || github.repository }}"
+// The resolve-host-repo step (which runs before checkout) parses GITHUB_WORKFLOW_REF
+// at runtime to determine the platform repo, correctly handling both pure workflow_call
+// relays and event-driven relays (e.g. on: issue_comment) where event_name != 'workflow_call'.
+const workflowCallRepo = "${{ steps.resolve-host-repo.outputs.target_repo }}"
func TestGenerateCheckoutGitHubFolderForActivation_WorkflowCall(t *testing.T) {
tests := []struct {
@@ -165,7 +168,7 @@ func TestGenerateGitHubFolderCheckoutStep(t *testing.T) {
wantRepoValue: "org/platform-repo",
},
{
- name: "GitHub Actions expression for cross-repo",
+ name: "step output expression for cross-repo",
repository: workflowCallRepo,
wantRepository: true,
wantRepoValue: workflowCallRepo,
@@ -200,3 +203,124 @@ func TestGenerateGitHubFolderCheckoutStep(t *testing.T) {
})
}
}
+
+// TestGenerateResolveHostRepoStep verifies that the resolve-host-repo step is correctly
+// generated and does not contain the broken event_name-based expression.
+func TestGenerateResolveHostRepoStep(t *testing.T) {
+ c := NewCompilerWithVersion("dev")
+ c.SetActionMode(ActionModeDev)
+
+ result := c.generateResolveHostRepoStep()
+
+ assert.Contains(t, result, "resolve-host-repo",
+ "step should have the correct id")
+ assert.Contains(t, result, "Resolve host repo for activation checkout",
+ "step should have the correct name")
+ assert.Contains(t, result, "actions/github-script",
+ "step should use actions/github-script")
+ assert.Contains(t, result, "resolve_host_repo.cjs",
+ "step should require resolve_host_repo.cjs")
+
+ // Verify the broken event_name expression is NOT present
+ assert.NotContains(t, result, "github.event_name == 'workflow_call'",
+ "step must not use the broken event_name-based expression")
+ assert.NotContains(t, result, "github.action_repository",
+ "step must not use github.action_repository (unreliable for event-driven relays)")
+}
+
+// TestCheckoutDoesNotUseEventNameExpression verifies that the checkout step for
+// workflow_call triggers uses the resolve-host-repo step output instead of the
+// broken event_name == 'workflow_call' expression.
+func TestCheckoutDoesNotUseEventNameExpression(t *testing.T) {
+ c := NewCompilerWithVersion("dev")
+ c.SetActionMode(ActionModeDev)
+
+ data := &WorkflowData{
+ On: `"on":
+ workflow_call:`,
+ }
+
+ result := c.generateCheckoutGitHubFolderForActivation(data)
+ combined := strings.Join(result, "")
+
+ // Must use the step output, not the broken expression
+ assert.Contains(t, combined, "steps.resolve-host-repo.outputs.target_repo",
+ "checkout must reference the resolve-host-repo step output")
+
+ // Must NOT use the old broken event_name expression
+ assert.NotContains(t, combined, "github.event_name == 'workflow_call'",
+ "checkout must not use the broken event_name-based expression")
+ assert.NotContains(t, combined, "github.action_repository",
+ "checkout must not use github.action_repository")
+}
+
+// TestActivationJobTargetRepoOutput verifies that the activation job exposes target_repo as an
+// output when a workflow_call trigger is present (without inlined imports), so that agent and
+// safe_outputs jobs can reference needs.activation.outputs.target_repo.
+func TestActivationJobTargetRepoOutput(t *testing.T) {
+ tests := []struct {
+ name string
+ onSection string
+ inlinedImports bool
+ expectTargetRepo bool
+ }{
+ {
+ name: "workflow_call trigger - target_repo output added",
+ onSection: `"on":
+ workflow_call:`,
+ expectTargetRepo: true,
+ },
+ {
+ name: "mixed triggers with workflow_call - target_repo output added",
+ onSection: `"on":
+ issue_comment:
+ types: [created]
+ workflow_call:`,
+ expectTargetRepo: true,
+ },
+ {
+ name: "workflow_call with inlined-imports - no target_repo output",
+ onSection: `"on":
+ workflow_call:`,
+ inlinedImports: true,
+ expectTargetRepo: false,
+ },
+ {
+ name: "no workflow_call - no target_repo output",
+ onSection: `"on":
+ issues:
+ types: [opened]`,
+ expectTargetRepo: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ compiler := NewCompilerWithVersion("dev")
+ compiler.SetActionMode(ActionModeDev)
+
+ data := &WorkflowData{
+ Name: "test-workflow",
+ On: tt.onSection,
+ InlinedImports: tt.inlinedImports,
+ AI: "copilot",
+ }
+
+ job, err := compiler.buildActivationJob(data, false, "", "test.lock.yml")
+ require.NoError(t, err, "buildActivationJob should succeed")
+ require.NotNil(t, job, "activation job should not be nil")
+
+ if tt.expectTargetRepo {
+ assert.Contains(t, job.Outputs, "target_repo",
+ "activation job should expose target_repo output for downstream jobs")
+ assert.Equal(t,
+ "${{ steps.resolve-host-repo.outputs.target_repo }}",
+ job.Outputs["target_repo"],
+ "target_repo output should reference resolve-host-repo step")
+ } else {
+ assert.NotContains(t, job.Outputs, "target_repo",
+ "activation job should not expose target_repo when workflow_call is absent or inlined-imports enabled")
+ }
+ })
+ }
+}
diff --git a/pkg/workflow/compiler_safe_outputs_config.go b/pkg/workflow/compiler_safe_outputs_config.go
index c601e7e966..647f6d0a9f 100644
--- a/pkg/workflow/compiler_safe_outputs_config.go
+++ b/pkg/workflow/compiler_safe_outputs_config.go
@@ -567,7 +567,8 @@ var handlerRegistry = map[string]handlerBuilder{
c := cfg.DispatchWorkflow
builder := newHandlerConfigBuilder().
AddTemplatableInt("max", c.Max).
- AddStringSlice("workflows", c.Workflows)
+ AddStringSlice("workflows", c.Workflows).
+ AddIfNotEmpty("target-repo", c.TargetRepoSlug)
// Add workflow_files map if it has entries
if len(c.WorkflowFiles) > 0 {
@@ -728,9 +729,17 @@ func (c *Compiler) addHandlerManagerConfigEnvVar(steps *[]string, data *Workflow
fullManifestFiles := getAllManifestFiles(extraManifestFiles...)
fullPathPrefixes := getProtectedPathPrefixes(extraPathPrefixes...)
+ // For workflow_call relay workflows, inject the resolved platform repo into the
+ // dispatch_workflow handler config so dispatch targets the host repo, not the caller's.
+ safeOutputs := data.SafeOutputs
+ if hasWorkflowCallTrigger(data.On) && safeOutputs.DispatchWorkflow != nil && safeOutputs.DispatchWorkflow.TargetRepoSlug == "" {
+ safeOutputs = safeOutputsWithDispatchTargetRepo(safeOutputs, "${{ needs.activation.outputs.target_repo }}")
+ compilerSafeOutputsConfigLog.Print("Injecting target_repo into dispatch_workflow config for workflow_call relay")
+ }
+
// Build configuration for each handler using the registry
for handlerName, builder := range handlerRegistry {
- handlerConfig := builder(data.SafeOutputs)
+ handlerConfig := builder(safeOutputs)
// Include handler if:
// 1. It returns a non-nil config (explicitly enabled, even if empty)
// 2. For auto-enabled handlers, include even with empty config
@@ -762,6 +771,17 @@ func (c *Compiler) addHandlerManagerConfigEnvVar(steps *[]string, data *Workflow
}
}
+// safeOutputsWithDispatchTargetRepo returns a shallow copy of cfg with the dispatch_workflow
+// TargetRepoSlug overridden to targetRepo. Only DispatchWorkflow is deep-copied; all other
+// pointer fields remain shared. This avoids mutating the original config.
+func safeOutputsWithDispatchTargetRepo(cfg *SafeOutputsConfig, targetRepo string) *SafeOutputsConfig {
+ dispatchCopy := *cfg.DispatchWorkflow
+ dispatchCopy.TargetRepoSlug = targetRepo
+ configCopy := *cfg
+ configCopy.DispatchWorkflow = &dispatchCopy
+ return &configCopy
+}
+
// getEngineAgentFileInfo returns the engine-specific manifest filenames and path prefixes
// by type-asserting the active engine to AgentFileProvider. Returns empty slices when
// the engine is not set or does not implement the interface.
diff --git a/pkg/workflow/compiler_safe_outputs_job.go b/pkg/workflow/compiler_safe_outputs_job.go
index 4c870c1328..ba818a72a6 100644
--- a/pkg/workflow/compiler_safe_outputs_job.go
+++ b/pkg/workflow/compiler_safe_outputs_job.go
@@ -244,7 +244,13 @@ func (c *Compiler) buildConsolidatedSafeOutputsJob(data *WorkflowData, mainJobNa
// Add GitHub App token minting step at the beginning if app is configured
if data.SafeOutputs.GitHubApp != nil {
- appTokenSteps := c.buildGitHubAppTokenMintStep(data.SafeOutputs.GitHubApp, permissions)
+ // For workflow_call relay workflows, scope the token to the platform repo so that
+ // API calls targeting the host repo (e.g. dispatch_workflow) are authorized.
+ var appTokenFallbackRepo string
+ if hasWorkflowCallTrigger(data.On) {
+ appTokenFallbackRepo = "${{ needs.activation.outputs.target_repo }}"
+ }
+ appTokenSteps := c.buildGitHubAppTokenMintStep(data.SafeOutputs.GitHubApp, permissions, appTokenFallbackRepo)
// Calculate insertion index: after setup action (if present) and artifact downloads, but before checkout and safe output steps
insertIndex := 0
@@ -314,8 +320,11 @@ func (c *Compiler) buildConsolidatedSafeOutputsJob(data *WorkflowData, mainJobNa
// Build dependencies — detection is now inline in the agent job, no separate dependency needed
needs := []string{mainJobName}
- // Add activation job dependency for jobs that need it (create_pull_request, push_to_pull_request_branch, lock-for-agent)
- if usesPatchesAndCheckouts(data.SafeOutputs) || data.LockForAgent {
+ // Add activation job dependency when:
+ // - create_pull_request or push_to_pull_request_branch (need the activation artifact)
+ // - lock-for-agent (need the activation lock)
+ // - workflow_call trigger (need needs.activation.outputs.target_repo for cross-repo token/dispatch)
+ if usesPatchesAndCheckouts(data.SafeOutputs) || data.LockForAgent || hasWorkflowCallTrigger(data.On) {
needs = append(needs, string(constants.ActivationJobName))
}
// Add unlock job dependency if lock-for-agent is enabled
diff --git a/pkg/workflow/compiler_yaml.go b/pkg/workflow/compiler_yaml.go
index 86acf0b5ef..7a583c3f34 100644
--- a/pkg/workflow/compiler_yaml.go
+++ b/pkg/workflow/compiler_yaml.go
@@ -646,6 +646,11 @@ func (c *Compiler) generateCreateAwInfo(yaml *strings.Builder, data *WorkflowDat
// validateLockdownRequirements uses this to enforce strict: true for public repositories.
// Use effectiveStrictMode to infer strictness from the source (frontmatter), not just the CLI flag.
fmt.Fprintf(yaml, " GH_AW_COMPILED_STRICT: \"%t\"\n", c.effectiveStrictMode(data.RawFrontmatter))
+ // When a workflow_call trigger is present, pass the target_repo resolved by the
+ // resolve-host-repo step so it can be stored in aw_info.json for observability.
+ if hasWorkflowCallTrigger(data.On) && !data.InlinedImports {
+ yaml.WriteString(" GH_AW_INFO_TARGET_REPO: ${{ steps.resolve-host-repo.outputs.target_repo }}\n")
+ }
// Include lockdown validation env vars when lockdown is explicitly enabled.
// validateLockdownRequirements is called from generate_aw_info.cjs and uses these vars.
githubTool, hasGitHub := data.Tools["github"]
diff --git a/pkg/workflow/compiler_yaml_main_job.go b/pkg/workflow/compiler_yaml_main_job.go
index 13876fbde9..e01fd73548 100644
--- a/pkg/workflow/compiler_yaml_main_job.go
+++ b/pkg/workflow/compiler_yaml_main_job.go
@@ -20,6 +20,14 @@ func (c *Compiler) generateMainJobSteps(yaml *strings.Builder, data *WorkflowDat
// Build a CheckoutManager with any user-configured checkouts
checkoutMgr := NewCheckoutManager(data.CheckoutConfigs)
+ // Propagate the platform (host) repo resolved by the activation job so that
+ // checkout steps in this job and in safe_outputs can use the correct repository
+ // for .github/.agents sparse checkouts when called cross-repo.
+ // The activation job exposes this as needs.activation.outputs.target_repo.
+ if hasWorkflowCallTrigger(data.On) && !data.InlinedImports {
+ checkoutMgr.SetCrossRepoTargetRepo("${{ needs.activation.outputs.target_repo }}")
+ }
+
// Generate GitHub App token minting steps for checkouts with app auth
// These must be emitted BEFORE the checkout steps that reference them
if checkoutMgr.HasAppAuth() {
diff --git a/pkg/workflow/dispatch_workflow.go b/pkg/workflow/dispatch_workflow.go
index c5d7114b21..84aa36512a 100644
--- a/pkg/workflow/dispatch_workflow.go
+++ b/pkg/workflow/dispatch_workflow.go
@@ -11,6 +11,7 @@ type DispatchWorkflowConfig struct {
BaseSafeOutputConfig `yaml:",inline"`
Workflows []string `yaml:"workflows,omitempty"` // List of workflow names (without .md extension) to allow dispatching
WorkflowFiles map[string]string `yaml:"workflow_files,omitempty"` // Map of workflow name to file extension (.lock.yml or .yml) - populated at compile time
+ TargetRepoSlug string `yaml:"target-repo,omitempty"` // Target repository for cross-repo dispatch (owner/repo or GitHub Actions expression)
}
// parseDispatchWorkflowConfig handles dispatch-workflow configuration
diff --git a/pkg/workflow/mcp_github_config.go b/pkg/workflow/mcp_github_config.go
index 8f839f2453..06d05b996c 100644
--- a/pkg/workflow/mcp_github_config.go
+++ b/pkg/workflow/mcp_github_config.go
@@ -434,7 +434,7 @@ func (c *Compiler) generateGitHubMCPAppTokenMintingStep(yaml *strings.Builder, d
}
// Generate the token minting step using the existing helper from safe_outputs_app.go
- steps := c.buildGitHubAppTokenMintStep(app, permissions)
+ steps := c.buildGitHubAppTokenMintStep(app, permissions, "")
// Modify the step ID to differentiate from safe-outputs app token
// Replace "safe-outputs-app-token" with "github-mcp-app-token"
diff --git a/pkg/workflow/notify_comment.go b/pkg/workflow/notify_comment.go
index eca0f41962..baad8503de 100644
--- a/pkg/workflow/notify_comment.go
+++ b/pkg/workflow/notify_comment.go
@@ -50,7 +50,12 @@ func (c *Compiler) buildConclusionJob(data *WorkflowData, mainJobName string, sa
if data.SafeOutputs.GitHubApp != nil {
// Compute permissions based on configured safe outputs (principle of least privilege)
permissions := ComputePermissionsForSafeOutputs(data.SafeOutputs)
- steps = append(steps, c.buildGitHubAppTokenMintStep(data.SafeOutputs.GitHubApp, permissions)...)
+ // For workflow_call relay workflows, scope the token to the platform repo.
+ var appTokenFallbackRepo string
+ if hasWorkflowCallTrigger(data.On) {
+ appTokenFallbackRepo = "${{ needs.activation.outputs.target_repo }}"
+ }
+ steps = append(steps, c.buildGitHubAppTokenMintStep(data.SafeOutputs.GitHubApp, permissions, appTokenFallbackRepo)...)
}
// Add artifact download steps once (shared by noop and conclusion steps)
diff --git a/pkg/workflow/safe_outputs_config.go b/pkg/workflow/safe_outputs_config.go
index c3c18d1aea..38f489c69d 100644
--- a/pkg/workflow/safe_outputs_config.go
+++ b/pkg/workflow/safe_outputs_config.go
@@ -717,8 +717,11 @@ func (c *Compiler) mergeAppFromIncludedConfigs(topSafeOutputs *SafeOutputsConfig
// ========================================
// buildGitHubAppTokenMintStep generates the step to mint a GitHub App installation access token
-// Permissions are automatically computed from the safe output job requirements
-func (c *Compiler) buildGitHubAppTokenMintStep(app *GitHubAppConfig, permissions *Permissions) []string {
+// Permissions are automatically computed from the safe output job requirements.
+// fallbackRepoExpr overrides the default ${{ github.event.repository.name }} fallback when
+// no explicit repositories are configured (e.g. pass needs.activation.outputs.target_repo for
+// workflow_call relay workflows so the token is scoped to the platform repo, not the caller's).
+func (c *Compiler) buildGitHubAppTokenMintStep(app *GitHubAppConfig, permissions *Permissions, fallbackRepoExpr string) []string {
safeOutputsAppLog.Printf("Building GitHub App token mint step: owner=%s, repos=%d", app.Owner, len(app.Repositories))
var steps []string
@@ -741,7 +744,7 @@ func (c *Compiler) buildGitHubAppTokenMintStep(app *GitHubAppConfig, permissions
// - If repositories is a single value, use inline format
// - If repositories has multiple values, use block scalar format (newline-separated)
// to ensure clarity and proper parsing by actions/create-github-app-token
- // - If repositories is empty/not specified, default to current repository
+ // - If repositories is empty/not specified, default to fallbackRepoExpr or the current repository
if len(app.Repositories) == 1 && app.Repositories[0] == "*" {
// Org-wide access: omit repositories field entirely
safeOutputsAppLog.Print("Using org-wide GitHub App token (repositories: *)")
@@ -756,9 +759,14 @@ func (c *Compiler) buildGitHubAppTokenMintStep(app *GitHubAppConfig, permissions
steps = append(steps, fmt.Sprintf(" %s\n", repo))
}
} else {
- // Extract repo name from github.repository (which is "owner/repo")
- // Using GitHub Actions expression: split(github.repository, '/')[1]
- steps = append(steps, " repositories: ${{ github.event.repository.name }}\n")
+ // No explicit repositories: use fallback expression, or default to the triggering repo's name.
+ // For workflow_call relay scenarios the caller passes needs.activation.outputs.target_repo so
+ // the token is scoped to the platform (host) repo rather than the caller repo.
+ repoExpr := fallbackRepoExpr
+ if repoExpr == "" {
+ repoExpr = "${{ github.event.repository.name }}"
+ }
+ steps = append(steps, fmt.Sprintf(" repositories: %s\n", repoExpr))
}
// Always add github-api-url from environment variable
diff --git a/pkg/workflow/safe_outputs_jobs.go b/pkg/workflow/safe_outputs_jobs.go
index 3bca5e4f3b..0a5dd648c7 100644
--- a/pkg/workflow/safe_outputs_jobs.go
+++ b/pkg/workflow/safe_outputs_jobs.go
@@ -61,7 +61,12 @@ func (c *Compiler) buildSafeOutputJob(data *WorkflowData, config SafeOutputJobCo
// Add GitHub App token minting step if app is configured
if data.SafeOutputs != nil && data.SafeOutputs.GitHubApp != nil {
safeOutputsJobsLog.Print("Adding GitHub App token minting step with auto-computed permissions")
- steps = append(steps, c.buildGitHubAppTokenMintStep(data.SafeOutputs.GitHubApp, config.Permissions)...)
+ // For workflow_call relay workflows, scope the token to the platform repo.
+ var appTokenFallbackRepo string
+ if hasWorkflowCallTrigger(data.On) {
+ appTokenFallbackRepo = "${{ needs.activation.outputs.target_repo }}"
+ }
+ steps = append(steps, c.buildGitHubAppTokenMintStep(data.SafeOutputs.GitHubApp, config.Permissions, appTokenFallbackRepo)...)
}
// Add pre-steps if provided (e.g., checkout, git config for create-pull-request)