Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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