Skip to content
Merged
19 changes: 16 additions & 3 deletions .github/workflows/smoke-workflow-call.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions actions/setup/js/generate_aw_info.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
51 changes: 51 additions & 0 deletions actions/setup/js/resolve_host_repo.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// @ts-check
/// <reference types="@actions/github-script" />

/**
* 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<void>}
*/
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 };
128 changes: 128 additions & 0 deletions actions/setup/js/resolve_host_repo.test.cjs
Original file line number Diff line number Diff line change
@@ -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:"));
});
});
29 changes: 28 additions & 1 deletion pkg/workflow/checkout_manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -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)
Expand Down
31 changes: 31 additions & 0 deletions pkg/workflow/checkout_manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
})
}
Loading
Loading