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
3 changes: 2 additions & 1 deletion .github/workflows/smoke-workflow-call-with-inputs.lock.yml

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

3 changes: 2 additions & 1 deletion .github/workflows/smoke-workflow-call.lock.yml

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

84 changes: 68 additions & 16 deletions actions/setup/js/resolve_host_repo.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,46 +2,94 @@
/// <reference types="@actions/github-script" />

/**
* Resolves the target repository and ref for the activation job checkout.
* Resolves the target repository and refs for the activation job.
*
* Uses the job.workflow_* context fields to determine the platform (host)
* repository and pin the checkout to the exact executing commit SHA.
* repository and produce two distinct ref outputs for different consumers:
*
* - target_checkout_ref: the immutable commit SHA from job.workflow_sha, used
* by actions/checkout to pin the activation checkout to the exact executing
* revision rather than a moving branch/tag ref.
*
* - target_ref: the branch or tag ref parsed from job.workflow_ref (the
* substring after "@", e.g. "refs/heads/main" from
* "owner/repo/.github/workflows/file.yml@refs/heads/main"), used by
* dispatch_workflow.cjs as the `ref` argument to createWorkflowDispatch.
* The GitHub workflow dispatch API only accepts branch/tag refs; passing a
* commit SHA causes "No ref found for: <sha>" errors.
*
* These fields are passed via environment variables (JOB_WORKFLOW_REPOSITORY,
* JOB_WORKFLOW_SHA, etc.) to avoid shell injection — the ${{ }} expressions
* are evaluated in the env: block, not interpolated into script source.
* JOB_WORKFLOW_SHA, JOB_WORKFLOW_REF, etc.) to avoid shell injection — the
* ${{ }} expressions are evaluated in the env: block, not interpolated into
* script source.
*
* job.workflow_repository provides the owner/repo of the currently executing
* workflow file, correctly identifying the platform repo in all relay patterns:
* cross-repo workflow_call, event-driven relays (on: issue_comment, on: push),
* and cross-org scenarios.
*
* job.workflow_sha provides the immutable commit SHA of the workflow being
* executed, ensuring the activation checkout pins to the exact revision rather
* than a moving branch/tag ref.
*
* @safe-outputs-exempt SEC-005: values sourced from trusted GitHub Actions runner context via env vars only
*/

/**
* Parses the dispatch-compatible branch/tag ref from job.workflow_ref.
* job.workflow_ref has the form "owner/repo/.github/workflows/file.yml@refs/heads/main".
* Returns the substring after the last "@", or an empty string if missing, malformed,
* or if the extracted value looks like a commit SHA (40 lowercase hex characters —
* these are not accepted by the workflow dispatch API).
*
* Uses lastIndexOf to handle the unlikely case of an "@" in the workflow path.
*
* @param {string} workflowRef
* @returns {string}
*/
function parseDispatchRef(workflowRef) {
if (!workflowRef) {
return "";
}
const atIndex = workflowRef.lastIndexOf("@");
if (atIndex === -1) {
return "";
}
const ref = workflowRef.slice(atIndex + 1);
// Reject SHA-like values (40 lowercase hex chars). workflow_call can reference a
// workflow by commit SHA, but createWorkflowDispatch does not accept SHAs as refs.
if (/^[0-9a-f]{40}$/.test(ref)) {
return "";
}
return ref;
}

/**
* @returns {Promise<void>}
*/
async function main() {
const targetRepo = process.env.JOB_WORKFLOW_REPOSITORY || "";
const targetRef = process.env.JOB_WORKFLOW_SHA || "";
const targetCheckoutRef = process.env.JOB_WORKFLOW_SHA || "";
const workflowRef = process.env.JOB_WORKFLOW_REF || "";
const targetDispatchRef = parseDispatchRef(workflowRef);
const targetRepoName = targetRepo.split("/").pop() || "";
const currentRepo = process.env.GITHUB_REPOSITORY || "";

core.info("Resolving host repo via job.workflow_* context");
core.info(`job.workflow_repository = ${targetRepo}`);
core.info(`job.workflow_sha = ${targetRef}`);
core.info(`job.workflow_ref = ${process.env.JOB_WORKFLOW_REF || ""}`);
core.info(`job.workflow_sha = ${targetCheckoutRef}`);
core.info(`job.workflow_ref = ${workflowRef}`);
core.info(`job.workflow_file_path = ${process.env.JOB_WORKFLOW_FILE_PATH || ""}`);
core.info(`github.repository = ${currentRepo}`);
core.info("");
core.info(`Resolved target_repo = ${targetRepo}`);
core.info(`Resolved target_repo_name = ${targetRepoName}`);
core.info(`Resolved target_ref = ${targetRef}`);
core.info(`Resolved target_repo = ${targetRepo}`);
core.info(`Resolved target_repo_name = ${targetRepoName}`);
core.info(`Resolved target_checkout_ref = ${targetCheckoutRef}`);
core.info(`Resolved target_ref = ${targetDispatchRef}`);

if (!targetDispatchRef) {
core.warning(
`Could not parse a branch/tag ref from JOB_WORKFLOW_REF="${workflowRef}". ` +
"dispatch_workflow safe outputs may fail if they rely on target_ref. " +
"Falling back to empty string — do not use target_checkout_ref (SHA) as the dispatch ref."
);
}

if (targetRepo && targetRepo !== currentRepo) {
core.info(`Cross-repo invocation detected: platform repo "${targetRepo}" differs from caller "${currentRepo}"`);
Expand All @@ -51,7 +99,11 @@ async function main() {

core.setOutput("target_repo", targetRepo);
core.setOutput("target_repo_name", targetRepoName);
core.setOutput("target_ref", targetRef);
// target_checkout_ref: immutable SHA used by actions/checkout for exact-revision pinning.
core.setOutput("target_checkout_ref", targetCheckoutRef);
// target_ref: dispatch-compatible branch/tag ref used by dispatch_workflow.cjs.
// The GitHub workflow dispatch API requires a branch or tag, not a commit SHA.
core.setOutput("target_ref", targetDispatchRef);
}

module.exports = { main };
module.exports = { main, parseDispatchRef };
183 changes: 183 additions & 0 deletions actions/setup/js/resolve_host_repo.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
// @ts-check
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { main, parseDispatchRef } from "./resolve_host_repo.cjs";

describe("parseDispatchRef", () => {
it("parses refs/heads/main from a standard workflow_ref", () => {
expect(parseDispatchRef("owner/repo/.github/workflows/file.yml@refs/heads/main")).toBe("refs/heads/main");
});

it("parses refs/tags/v1.2.3 from a tag-triggered workflow_ref", () => {
expect(parseDispatchRef("owner/repo/.github/workflows/file.yml@refs/tags/v1.2.3")).toBe("refs/tags/v1.2.3");
});

it("parses refs/heads/feature/my-branch from a nested branch name", () => {
expect(parseDispatchRef("org/repo/.github/workflows/ci.yml@refs/heads/feature/my-branch")).toBe("refs/heads/feature/my-branch");
});

it("returns empty string when JOB_WORKFLOW_REF is empty", () => {
expect(parseDispatchRef("")).toBe("");
});

it("returns empty string when JOB_WORKFLOW_REF has no '@' separator", () => {
expect(parseDispatchRef("owner/repo/.github/workflows/file.yml")).toBe("");
});

it("returns empty string when JOB_WORKFLOW_REF ends with a 40-hex commit SHA", () => {
// workflow_call can reference a workflow by commit SHA, but createWorkflowDispatch
// rejects SHAs — parseDispatchRef must return "" to prevent dispatch failures.
const sha = "abc123def456abc123def456abc123def456abc1";
expect(parseDispatchRef(`owner/repo/.github/workflows/file.yml@${sha}`)).toBe("");
});

it("uses lastIndexOf so an '@' in the workflow path does not mis-parse the ref", () => {
// Pathological but valid: if the path segment contained '@', lastIndexOf ensures
// we capture the ref portion after the final '@'.
expect(parseDispatchRef("owner/repo@org/.github/workflows/file.yml@refs/heads/main")).toBe("refs/heads/main");
});

it("does not return a SHA-like value for a tag ref", () => {
const result = parseDispatchRef("owner/repo/.github/workflows/file.yml@refs/tags/v2.0.0");
expect(result).toBe("refs/tags/v2.0.0");
// Must not look like a SHA (40 hex chars)
expect(result).not.toMatch(/^[0-9a-f]{40}$/);
});
Comment on lines +5 to +44
});

describe("resolve_host_repo main", () => {
let outputs;
let warnings;
let infos;
let originalEnv;

beforeEach(() => {
outputs = {};
warnings = [];
infos = [];

global.core = {
info: vi.fn(msg => infos.push(msg)),
warning: vi.fn(msg => warnings.push(msg)),
error: vi.fn(),
setOutput: vi.fn((key, value) => {
outputs[key] = value;
}),
};

originalEnv = {
JOB_WORKFLOW_REPOSITORY: process.env.JOB_WORKFLOW_REPOSITORY,
JOB_WORKFLOW_SHA: process.env.JOB_WORKFLOW_SHA,
JOB_WORKFLOW_REF: process.env.JOB_WORKFLOW_REF,
JOB_WORKFLOW_FILE_PATH: process.env.JOB_WORKFLOW_FILE_PATH,
GITHUB_REPOSITORY: process.env.GITHUB_REPOSITORY,
};
});

afterEach(() => {
for (const [key, value] of Object.entries(originalEnv)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
});

it("emits target_checkout_ref as the commit SHA from JOB_WORKFLOW_SHA", async () => {
process.env.JOB_WORKFLOW_REPOSITORY = "owner/platform-repo";
process.env.JOB_WORKFLOW_SHA = "abc123def456abc123def456abc123def456abc1";
process.env.JOB_WORKFLOW_REF = "owner/platform-repo/.github/workflows/orchestrator.yml@refs/heads/main";
process.env.GITHUB_REPOSITORY = "owner/platform-repo";

await main();

expect(outputs["target_checkout_ref"]).toBe("abc123def456abc123def456abc123def456abc1");
});

it("emits target_ref as the dispatch-compatible branch ref from JOB_WORKFLOW_REF", async () => {
process.env.JOB_WORKFLOW_REPOSITORY = "owner/platform-repo";
process.env.JOB_WORKFLOW_SHA = "abc123def456abc123def456abc123def456abc1";
process.env.JOB_WORKFLOW_REF = "owner/platform-repo/.github/workflows/orchestrator.yml@refs/heads/main";
process.env.GITHUB_REPOSITORY = "owner/platform-repo";

await main();

expect(outputs["target_ref"]).toBe("refs/heads/main");
});

it("emits a tag as target_ref when workflow_ref contains a tag", async () => {
process.env.JOB_WORKFLOW_REPOSITORY = "owner/platform-repo";
process.env.JOB_WORKFLOW_SHA = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
process.env.JOB_WORKFLOW_REF = "owner/platform-repo/.github/workflows/release.yml@refs/tags/v1.2.3";
process.env.GITHUB_REPOSITORY = "owner/platform-repo";

await main();

expect(outputs["target_ref"]).toBe("refs/tags/v1.2.3");
});

it("target_ref is never a SHA when JOB_WORKFLOW_REF is provided", async () => {
const sha = "abc123def456abc123def456abc123def456abc1";
process.env.JOB_WORKFLOW_REPOSITORY = "owner/platform-repo";
process.env.JOB_WORKFLOW_SHA = sha;
process.env.JOB_WORKFLOW_REF = "owner/platform-repo/.github/workflows/file.yml@refs/heads/main";
process.env.GITHUB_REPOSITORY = "owner/platform-repo";

await main();

expect(outputs["target_ref"]).not.toBe(sha);
expect(outputs["target_ref"]).toBe("refs/heads/main");
});

it("emits target_ref as empty string and warns when JOB_WORKFLOW_REF is missing", async () => {
process.env.JOB_WORKFLOW_REPOSITORY = "owner/platform-repo";
process.env.JOB_WORKFLOW_SHA = "abc123def456abc123def456abc123def456abc1";
delete process.env.JOB_WORKFLOW_REF;
process.env.GITHUB_REPOSITORY = "owner/platform-repo";

await main();

expect(outputs["target_ref"]).toBe("");
expect(warnings.length).toBeGreaterThan(0);
expect(warnings[0]).toContain("Could not parse");
});

it("emits target_ref as empty string and warns when JOB_WORKFLOW_REF has no '@' separator", async () => {
process.env.JOB_WORKFLOW_REPOSITORY = "owner/platform-repo";
process.env.JOB_WORKFLOW_SHA = "abc123def456abc123def456abc123def456abc1";
process.env.JOB_WORKFLOW_REF = "owner/platform-repo/.github/workflows/file.yml";
process.env.GITHUB_REPOSITORY = "owner/platform-repo";

await main();

expect(outputs["target_ref"]).toBe("");
expect(warnings.length).toBeGreaterThan(0);
});

it("emits target_ref as empty string and warns when JOB_WORKFLOW_REF ends with a commit SHA", async () => {
// workflow_call can pin to a SHA; createWorkflowDispatch cannot accept a SHA as ref.
const sha = "abc123def456abc123def456abc123def456abc1";
process.env.JOB_WORKFLOW_REPOSITORY = "owner/platform-repo";
process.env.JOB_WORKFLOW_SHA = sha;
process.env.JOB_WORKFLOW_REF = `owner/platform-repo/.github/workflows/file.yml@${sha}`;
process.env.GITHUB_REPOSITORY = "owner/platform-repo";

await main();

expect(outputs["target_ref"]).toBe("");
expect(warnings.length).toBeGreaterThan(0);
expect(outputs["target_checkout_ref"]).toBe(sha);
});

it("emits target_repo and target_repo_name correctly", async () => {
process.env.JOB_WORKFLOW_REPOSITORY = "my-org/my-platform";
process.env.JOB_WORKFLOW_SHA = "abc123def456abc123def456abc123def456abc1";
process.env.JOB_WORKFLOW_REF = "my-org/my-platform/.github/workflows/file.yml@refs/heads/main";
process.env.GITHUB_REPOSITORY = "my-org/my-caller";

await main();

expect(outputs["target_repo"]).toBe("my-org/my-platform");
expect(outputs["target_repo_name"]).toBe("my-platform");
});
});
10 changes: 6 additions & 4 deletions pkg/workflow/checkout_manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,8 +149,10 @@ type CheckoutManager struct {
// performing .github/.agents sparse checkout steps for cross-repo workflow_call
// invocations pinned to a non-default branch.
//
// In the activation job this is set to "${{ steps.resolve-host-repo.outputs.target_ref }}".
// In the agent and safe_outputs jobs it is set to "${{ needs.activation.outputs.target_ref }}".
// Currently only set in the activation job to
// "${{ steps.resolve-host-repo.outputs.target_checkout_ref }}" (the immutable SHA).
// Downstream jobs (agent, safe_outputs) do not currently set this field — they rely
// on the default-branch checkout behaviour.
// An empty string means the checkout uses the repository's default branch.
crossRepoTargetRef string
}
Expand Down Expand Up @@ -190,8 +192,8 @@ func (cm *CheckoutManager) GetCrossRepoTargetRepo() string {
// .github/.agents sparse checkout steps. Call this when the workflow has a workflow_call
// trigger and the checkout should target a specific branch rather than the default branch.
//
// In the activation job pass "${{ steps.resolve-host-repo.outputs.target_ref }}".
// In downstream jobs (agent, safe_outputs) pass "${{ needs.activation.outputs.target_ref }}".
// In the activation job pass "${{ steps.resolve-host-repo.outputs.target_checkout_ref }}"
// (the immutable SHA for exact-revision pinning).
func (cm *CheckoutManager) SetCrossRepoTargetRef(ref string) {
checkoutManagerLog.Printf("Setting cross-repo target ref: %q", ref)
cm.crossRepoTargetRef = ref
Expand Down
Loading