Skip to content
Merged
113 changes: 102 additions & 11 deletions actions/setup/js/resolve_host_repo.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,27 +10,100 @@
* 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:
* GITHUB_WORKFLOW_REF reflects the currently executing workflow file for most triggers, but
* in cross-org workflow_call scenarios it resolves to the TOP-LEVEL CALLER's workflow ref,
* not the reusable (callee) workflow being executed. 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.
* When the platform workflow runs cross-repo (called via uses: from the same org),
* 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.
*
* For cross-org workflow_call, GITHUB_WORKFLOW_REF and GITHUB_REPOSITORY both resolve to
* the caller's repo. In that case we fall back to the referenced_workflows API lookup to
* find the actual callee (platform) repo and ref.
*
* In a caller-hosted relay pinned to a feature branch (e.g. uses: platform/.github/workflows/
* gateway.lock.yml@feature-branch), the @feature-branch portion is encoded in
* GITHUB_WORKFLOW_REF. Emitting it as target_ref allows the activation checkout to use
* the correct branch rather than the platform repo's default branch.
*
* SEC-005: The targetRepo and targetRef values are resolved solely from trusted system
* environment variables (GITHUB_WORKFLOW_REF, GITHUB_REPOSITORY, GITHUB_REF) set by the
* GitHub Actions runtime. They are not derived from user-supplied input, so no allowlist
* check is required in this handler.
* environment variables (GITHUB_WORKFLOW_REF, GITHUB_REPOSITORY, GITHUB_REF) and the
* GitHub Actions API (referenced_workflows), set/provided by the GitHub Actions runtime.
* They are not derived from user-supplied input, so no allowlist check is required here.
*
* @safe-outputs-exempt SEC-005: values sourced from trusted runtime env vars only
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The SEC-005 note now says targetRepo/targetRef may come from the GitHub Actions API (referenced_workflows), but the @safe-outputs-exempt tag still claims the values are sourced from env vars only. Please update the exemption text to match the new behavior (or remove/adjust it if the exemption criteria changed).

Suggested change
* @safe-outputs-exempt SEC-005: values sourced from trusted runtime env vars only
* @safe-outputs-exempt SEC-005: values sourced from trusted GitHub Actions runtime env vars and referenced_workflows API only

Copilot uses AI. Check for mistakes.
*/

// Matches the "owner/repo" prefix from a GitHub workflow path of the form "owner/repo/...".
const REPO_PREFIX_RE = /^([^/]+\/[^/]+)\//;

/**
* Attempts to resolve the callee repository and ref from the referenced_workflows API.
*
* This is used as a fallback when GITHUB_WORKFLOW_REF points to the same repo as
* GITHUB_REPOSITORY (cross-org workflow_call scenario), because in that case
* GITHUB_WORKFLOW_REF reflects the caller's workflow ref, not the callee's.
*
* @param {string} currentRepo - The value of GITHUB_REPOSITORY (owner/repo)
* @returns {Promise<{repo: string, ref: string} | null>} Resolved callee repo and ref, or null
*/
async function resolveFromReferencedWorkflows(currentRepo) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot add more logging

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 49e9b1d: logs currentRepo and runId at function entry; dumps every referenced_workflow entry (path/sha/ref) as they're scanned; logs cross-repo candidate count after filtering; logs which ref source was used (sha/ref/path/none) when a single candidate is selected.

const rawRunId = process.env.GITHUB_RUN_ID;
const runId = rawRunId ? parseInt(rawRunId, 10) : typeof context.runId === "number" ? context.runId : NaN;
if (!Number.isFinite(runId)) {
core.info("Run ID is unavailable or invalid, cannot perform referenced_workflows lookup");
return null;
}

const [runOwner, runRepo] = currentRepo.split("/");
try {
core.info(`Checking for cross-org callee via referenced_workflows API (run ${runId})`);
const runResponse = await github.rest.actions.getWorkflowRun({
owner: runOwner,
repo: runRepo,
run_id: runId,
});

const referencedWorkflows = runResponse.data.referenced_workflows || [];
core.info(`Found ${referencedWorkflows.length} referenced workflow(s) in run`);

// Find the first referenced workflow from a different repo than the caller.
// In cross-org workflow_call, the callee (platform) repo is different from currentRepo
// (the caller's repo). For same-repo invocations there will be no cross-repo entry.
// Capture the repo from the path match so we don't run the regex twice.
let calleeRepo = "";
let matchingEntry = null;
for (const wf of referencedWorkflows) {
const pathRepoMatch = wf.path.match(REPO_PREFIX_RE);
const entryRepo = pathRepoMatch ? pathRepoMatch[1] : "";
if (entryRepo && entryRepo !== currentRepo) {
matchingEntry = wf;
calleeRepo = entryRepo;
break;
}
}

if (matchingEntry) {
// Prefer sha (immutable) over ref (branch/tag can drift) over path-parsed ref.
const pathRefMatch = matchingEntry.path.match(/@(.+)$/);
const calleeRef = matchingEntry.sha || matchingEntry.ref || (pathRefMatch ? pathRefMatch[1] : "");
core.info(`Resolved callee repo from referenced_workflows: ${calleeRepo} @ ${calleeRef || "(default branch)"}`);
core.info(` Referenced workflow path: ${matchingEntry.path}`);
return { repo: calleeRepo, ref: calleeRef };
} else {
core.info("No cross-org callee found in referenced_workflows, using current repo");
return null;
}
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolveFromReferencedWorkflows() selects the first referenced_workflows entry whose repo differs from currentRepo. If the caller workflow references multiple reusable workflows from different repos, or if a same-repo run references another reusable workflow in a different repo, this can resolve the wrong callee and cause activation to checkout the wrong repository. Consider matching the entry more specifically (e.g., by the workflow file/path when available), or adding a safeguard to avoid guessing when there are multiple cross-repo candidates.

This issue also appears on line 135 of the same file.

Suggested change
// Find the first referenced workflow from a different repo than the caller.
// In cross-org workflow_call, the callee (platform) repo is different from currentRepo
// (the caller's repo). For same-repo invocations there will be no cross-repo entry.
// Capture the repo from the path match so we don't run the regex twice.
let calleeRepo = "";
let matchingEntry = null;
for (const wf of referencedWorkflows) {
const pathRepoMatch = wf.path.match(REPO_PREFIX_RE);
const entryRepo = pathRepoMatch ? pathRepoMatch[1] : "";
if (entryRepo && entryRepo !== currentRepo) {
matchingEntry = wf;
calleeRepo = entryRepo;
break;
}
}
if (matchingEntry) {
// Prefer sha (immutable) over ref (branch/tag can drift) over path-parsed ref.
const pathRefMatch = matchingEntry.path.match(/@(.+)$/);
const calleeRef = matchingEntry.sha || matchingEntry.ref || (pathRefMatch ? pathRefMatch[1] : "");
core.info(`Resolved callee repo from referenced_workflows: ${calleeRepo} @ ${calleeRef || "(default branch)"}`);
core.info(` Referenced workflow path: ${matchingEntry.path}`);
return { repo: calleeRepo, ref: calleeRef };
} else {
core.info("No cross-org callee found in referenced_workflows, using current repo");
return null;
}
// Only resolve when there is exactly one cross-repo candidate.
// If multiple cross-repo reusable workflows are referenced, selecting the first one
// would be a guess and could resolve the wrong callee repository/ref.
const crossRepoCandidates = [];
for (const wf of referencedWorkflows) {
const pathRepoMatch = wf.path.match(REPO_PREFIX_RE);
const entryRepo = pathRepoMatch ? pathRepoMatch[1] : "";
if (entryRepo && entryRepo !== currentRepo) {
crossRepoCandidates.push({ wf, repo: entryRepo });
}
}
if (crossRepoCandidates.length === 0) {
core.info("No cross-org callee found in referenced_workflows, using current repo");
return null;
}
if (crossRepoCandidates.length > 1) {
core.info(
`Referenced workflows lookup is ambiguous; found ${crossRepoCandidates.length} cross-repo candidates, not selecting one`
);
for (const candidate of crossRepoCandidates) {
core.info(` Candidate referenced workflow path: ${candidate.wf.path}`);
}
return null;
}
const matchingEntry = crossRepoCandidates[0].wf;
const calleeRepo = crossRepoCandidates[0].repo;
// Prefer sha (immutable) over ref (branch/tag can drift) over path-parsed ref.
const pathRefMatch = matchingEntry.path.match(/@(.+)$/);
const calleeRef = matchingEntry.sha || matchingEntry.ref || (pathRefMatch ? pathRefMatch[1] : "");
core.info(`Resolved callee repo from referenced_workflows: ${calleeRepo} @ ${calleeRef || "(default branch)"}`);
core.info(` Referenced workflow path: ${matchingEntry.path}`);
return { repo: calleeRepo, ref: calleeRef };

Copilot uses AI. Check for mistakes.
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
core.info(`Could not fetch referenced_workflows from API: ${msg}, using current repo`);
return null;
}
}

/**
* @returns {Promise<void>}
*/
Expand All @@ -40,11 +113,11 @@ async function main() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The REPO_PREFIX_RE constant is a nice improvement — extracting this repeated regex into a named constant improves readability and ensures consistency. Consider adding a brief JSDoc comment explaining the expected format of the matched string for future maintainers.

// 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 repoMatch = workflowRef.match(/^([^/]+\/[^/]+)\//);
const repoMatch = workflowRef.match(REPO_PREFIX_RE);
const workflowRepo = repoMatch ? repoMatch[1] : "";

// Fall back to currentRepo when GITHUB_WORKFLOW_REF cannot be parsed
const targetRepo = workflowRepo || currentRepo;
let targetRepo = workflowRepo || currentRepo;

// Extract the ref portion after '@' from GITHUB_WORKFLOW_REF.
// GITHUB_WORKFLOW_REF format: owner/repo/.github/workflows/file.yml@ref
Expand All @@ -57,7 +130,25 @@ async function main() {
// scenarios GITHUB_REF is the *caller* repo's ref, not the callee's, and using it
// would check out the wrong branch.
const refMatch = workflowRef.match(/@(.+)$/);
const targetRef = refMatch ? refMatch[1] : "";
let targetRef = refMatch ? refMatch[1] : "";

// Cross-org workflow_call detection: when GITHUB_WORKFLOW_REF points to the same repo as
// GITHUB_REPOSITORY, it means GITHUB_WORKFLOW_REF is resolving to the caller's workflow
// (not the callee's). This happens in cross-org workflow_call invocations where GitHub
// Actions sets GITHUB_WORKFLOW_REF to the top-level caller's workflow ref rather than the
// reusable workflow being executed. In that case, fall back to the referenced_workflows API
// to find the actual callee (platform) repo and ref.
//
// Note: GITHUB_EVENT_NAME inside a reusable workflow reflects the ORIGINAL trigger event
// (e.g., "push", "issues"), NOT "workflow_call", so we cannot use event_name to detect
// this scenario.
if (workflowRepo && workflowRepo === currentRepo) {
const resolved = await resolveFromReferencedWorkflows(currentRepo);
if (resolved) {
targetRepo = resolved.repo;
targetRef = resolved.ref || targetRef;
}
}

core.info(`GITHUB_WORKFLOW_REF: ${workflowRef}`);
core.info(`GITHUB_REPOSITORY: ${currentRepo}`);
Expand Down
187 changes: 187 additions & 0 deletions actions/setup/js/resolve_host_repo.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,31 @@ const mockCore = {
},
};

const mockGetWorkflowRun = vi.fn();
const mockGithub = {
rest: {
actions: {
getWorkflowRun: mockGetWorkflowRun,
},
},
};

const mockContext = {
runId: 99999,
};

// Set up global mocks before importing the module
global.core = mockCore;
global.github = mockGithub;
global.context = mockContext;

/**
* Creates a default mock response for getWorkflowRun with no referenced workflows.
* Used for same-repo and same-org cross-repo tests where the API should not change the result.
*/
function mockNoReferencedWorkflows() {
mockGetWorkflowRun.mockResolvedValue({ data: { referenced_workflows: [] } });
}
Comment on lines +35 to +41
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mockNoReferencedWorkflows() uses mockResolvedValue(), and the suite uses vi.clearAllMocks() (which typically clears call history but can leave mock implementations in place). To prevent implementation leakage between tests, prefer mockResolvedValueOnce()/mockRejectedValueOnce() per test or reset the mock implementation in beforeEach (e.g., mockGetWorkflowRun.mockReset()/vi.resetAllMocks()).

Copilot uses AI. Check for mistakes.

describe("resolve_host_repo.cjs", () => {
let main;
Expand All @@ -33,6 +56,9 @@ describe("resolve_host_repo.cjs", () => {
delete process.env.GITHUB_WORKFLOW_REF;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good use of mockReset() in beforeEach as a defensive measure alongside *Once variants. The comment explaining the rationale is helpful — this pattern prevents subtle test pollution that could cause intermittent failures.

delete process.env.GITHUB_REPOSITORY;
delete process.env.GITHUB_REF;
delete process.env.GITHUB_RUN_ID;
// Reset context.runId to the default value to prevent test state leakage
mockContext.runId = 99999;
});

it("should output the platform repo when invoked cross-repo", async () => {
Expand All @@ -58,6 +84,7 @@ describe("resolve_host_repo.cjs", () => {
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";
mockNoReferencedWorkflows();

await main();

Expand All @@ -68,6 +95,7 @@ describe("resolve_host_repo.cjs", () => {
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";
mockNoReferencedWorkflows();

await main();

Expand Down Expand Up @@ -211,6 +239,7 @@ describe("resolve_host_repo.cjs", () => {
it("should output target_repo_name 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";
mockNoReferencedWorkflows();

await main();

Expand All @@ -235,4 +264,162 @@ describe("resolve_host_repo.cjs", () => {
expect(mockCore.summary.addRaw).toHaveBeenCalledWith(expect.stringContaining("refs/heads/feature-branch"));
expect(mockCore.summary.write).toHaveBeenCalled();
});

describe("cross-org workflow_call scenarios", () => {
it("should resolve callee repo via referenced_workflows API when GITHUB_WORKFLOW_REF matches GITHUB_REPOSITORY", async () => {
// Cross-org workflow_call: GITHUB_WORKFLOW_REF points to the caller's repo (not the callee),
// so workflowRepo === currentRepo. The referenced_workflows API returns the actual callee.
process.env.GITHUB_WORKFLOW_REF = "caller-org/caller-repo/.github/workflows/relay.yml@refs/heads/main";
process.env.GITHUB_REPOSITORY = "caller-org/caller-repo";
process.env.GITHUB_RUN_ID = "12345";

mockGetWorkflowRun.mockResolvedValue({
data: {
referenced_workflows: [
{
path: "platform-org/platform-repo/.github/workflows/gateway.lock.yml@refs/heads/main",
sha: "abc123def456",
ref: "refs/heads/main",
},
],
},
});

await main();

expect(mockGetWorkflowRun).toHaveBeenCalledWith({
owner: "caller-org",
repo: "caller-repo",
run_id: 12345,
});
expect(mockCore.setOutput).toHaveBeenCalledWith("target_repo", "platform-org/platform-repo");
expect(mockCore.setOutput).toHaveBeenCalledWith("target_repo_name", "platform-repo");
// sha is preferred over ref
expect(mockCore.setOutput).toHaveBeenCalledWith("target_ref", "abc123def456");
expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Resolved callee repo from referenced_workflows"));
});

it("should use ref from referenced_workflows entry when sha is absent", async () => {
process.env.GITHUB_WORKFLOW_REF = "caller-org/caller-repo/.github/workflows/relay.yml@refs/heads/main";
process.env.GITHUB_REPOSITORY = "caller-org/caller-repo";
process.env.GITHUB_RUN_ID = "12345";

mockGetWorkflowRun.mockResolvedValue({
data: {
referenced_workflows: [
{
path: "platform-org/platform-repo/.github/workflows/gateway.lock.yml@refs/heads/feature",
sha: undefined,
ref: "refs/heads/feature",
},
],
},
});

await main();

expect(mockCore.setOutput).toHaveBeenCalledWith("target_repo", "platform-org/platform-repo");
expect(mockCore.setOutput).toHaveBeenCalledWith("target_ref", "refs/heads/feature");
});

it("should fall back to path-parsed ref when sha and ref are absent in referenced_workflows", async () => {
process.env.GITHUB_WORKFLOW_REF = "caller-org/caller-repo/.github/workflows/relay.yml@refs/heads/main";
process.env.GITHUB_REPOSITORY = "caller-org/caller-repo";
process.env.GITHUB_RUN_ID = "12345";

mockGetWorkflowRun.mockResolvedValue({
data: {
referenced_workflows: [
{
path: "platform-org/platform-repo/.github/workflows/gateway.lock.yml@refs/heads/stable",
sha: undefined,
ref: undefined,
},
],
},
});

await main();

expect(mockCore.setOutput).toHaveBeenCalledWith("target_repo", "platform-org/platform-repo");
expect(mockCore.setOutput).toHaveBeenCalledWith("target_ref", "refs/heads/stable");
});

it("should log cross-repo detection and write step summary for cross-org callee", async () => {
process.env.GITHUB_WORKFLOW_REF = "caller-org/caller-repo/.github/workflows/relay.yml@refs/heads/main";
process.env.GITHUB_REPOSITORY = "caller-org/caller-repo";
process.env.GITHUB_RUN_ID = "12345";

mockGetWorkflowRun.mockResolvedValue({
data: {
referenced_workflows: [
{
path: "platform-org/platform-repo/.github/workflows/gateway.lock.yml@refs/heads/main",
sha: "abc123",
ref: "refs/heads/main",
},
],
},
});

await main();

expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Cross-repo invocation detected"));
expect(mockCore.summary.addRaw).toHaveBeenCalledWith(expect.stringContaining("platform-org/platform-repo"));
expect(mockCore.summary.write).toHaveBeenCalled();
});

it("should fall back to GITHUB_REPOSITORY when referenced_workflows has no cross-org entry", async () => {
// workflowRepo === currentRepo but no cross-org entry (same-org same-repo, no callee)
process.env.GITHUB_WORKFLOW_REF = "my-org/my-repo/.github/workflows/my-workflow.lock.yml@refs/heads/main";
process.env.GITHUB_REPOSITORY = "my-org/my-repo";
process.env.GITHUB_RUN_ID = "12345";

mockGetWorkflowRun.mockResolvedValue({ data: { referenced_workflows: [] } });

await main();

expect(mockCore.setOutput).toHaveBeenCalledWith("target_repo", "my-org/my-repo");
expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("No cross-org callee found in referenced_workflows"));
});

it("should fall back gracefully when referenced_workflows API call fails", async () => {
process.env.GITHUB_WORKFLOW_REF = "caller-org/caller-repo/.github/workflows/relay.yml@refs/heads/main";
process.env.GITHUB_REPOSITORY = "caller-org/caller-repo";
process.env.GITHUB_RUN_ID = "12345";

mockGetWorkflowRun.mockRejectedValue(new Error("API unavailable"));

await main();

// Should fall back to the currentRepo (caller) — not ideal but safe degradation
expect(mockCore.setOutput).toHaveBeenCalledWith("target_repo", "caller-org/caller-repo");
expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("API unavailable"));
});

it("should fall back gracefully when GITHUB_RUN_ID is missing", async () => {
process.env.GITHUB_WORKFLOW_REF = "caller-org/caller-repo/.github/workflows/relay.yml@refs/heads/main";
process.env.GITHUB_REPOSITORY = "caller-org/caller-repo";
delete process.env.GITHUB_RUN_ID;
mockContext.runId = NaN;

await main();

expect(mockGetWorkflowRun).not.toHaveBeenCalled();
expect(mockCore.setOutput).toHaveBeenCalledWith("target_repo", "caller-org/caller-repo");
expect(mockCore.info).toHaveBeenCalledWith(expect.stringContaining("Run ID is unavailable or invalid"));
});

it("should not call referenced_workflows API for normal cross-repo (same-org) invocations", async () => {
// workflowRepo !== currentRepo → no API call needed
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";
process.env.GITHUB_RUN_ID = "12345";

await main();

expect(mockGetWorkflowRun).not.toHaveBeenCalled();
expect(mockCore.setOutput).toHaveBeenCalledWith("target_repo", "my-org/platform-repo");
});
});
});
Loading