Summary
In gh-aw v0.58.0, when a compiled workflow is triggered cross-repo via workflow_call (CentralRepoOps relay pattern) and uses dispatch-workflow with a target-repo that differs from GITHUB_REPOSITORY, the dispatched workflow runs on the caller's branch (GITHUB_REF) instead of the target repo's default branch.
The compiler already detects the workflow_call + dispatch-workflow combination and auto-injects target-repo via ${{ needs.activation.outputs.target_repo }} (see safeOutputsWithDispatchTargetRepo in compiler_safe_outputs_config.go), correctly routing the dispatch to the target repository. However, the ref parameter still comes from the caller's environment, which has no meaning on the target repository.
Root Cause
In actions/setup/js/dispatch_workflow.cjs, the ref variable is resolved once at handler initialisation (lines 79–90) from GITHUB_HEAD_REF / GITHUB_REF / context.ref:
let ref;
if (process.env.GITHUB_HEAD_REF) {
ref = `refs/heads/${process.env.GITHUB_HEAD_REF}`;
} else if (process.env.GITHUB_REF || context.ref) {
ref = process.env.GITHUB_REF || context.ref;
} else {
ref = await getDefaultBranchRef();
}
In a workflow_call relay scenario all three env vars reflect the caller's context:
| Variable |
Value in cross-repo workflow_call |
GITHUB_REF |
Caller's triggering ref (e.g. refs/heads/main) |
GITHUB_HEAD_REF |
Empty (not a PR event for the reusable workflow) |
GITHUB_WORKFLOW_REF |
Caller's workflow file path (e.g. <org>/<app-repo>/.github/workflows/relay.yml@refs/heads/main) |
Because GITHUB_REF is set, the else branch that calls getDefaultBranchRef() — which already handles cross-repo correctly by querying the target repository's API — is never reached.
The isCrossRepoDispatch flag is correctly computed but is only used to:
- Log a message about the target repo
- Skip
context.payload.repository.default_branch inside getDefaultBranchRef()
It is not used to decide whether the caller's ref should be replaced.
Affected Code
Runtime: actions/setup/js/dispatch_workflow.cjs
The ref resolution block (lines 79–90) blindly uses GITHUB_REF regardless of whether the dispatch is same-repo or cross-repo. The downstream createWorkflowDispatch call (line 162) passes this caller-derived ref to the target repo:
response = await githubClient.rest.actions.createWorkflowDispatch({
owner: repo.owner, // ← target repo (correct)
repo: repo.repo, // ← target repo (correct)
workflow_id: workflowFile,
ref: ref, // ← caller's GITHUB_REF (WRONG for cross-repo)
inputs: inputs,
return_run_details: true,
});
Compiler: pkg/workflow/compiler_safe_outputs_config.go
The compiler correctly injects target-repo for workflow_call relays (line ~736):
if hasWorkflowCallTrigger(data.On) && safeOutputs.DispatchWorkflow != nil && safeOutputs.DispatchWorkflow.TargetRepoSlug == "" {
safeOutputs = safeOutputsWithDispatchTargetRepo(safeOutputs, "${{ needs.activation.outputs.target_repo }}")
}
But there is no equivalent injection for the dispatch ref. The DispatchWorkflowConfig struct has no ref / target-ref field.
Proposed Fix
Use a compiler + runtime fix that propagates the reusable workflow's intended
target ref explicitly instead of falling back to the target repository's default
branch.
Add a target-ref config field:
- Add
TargetRef string to DispatchWorkflowConfig in pkg/workflow/dispatch_workflow.go (or the struct's definition file).
- In the compiler (
compiler_safe_outputs_config.go), alongside safeOutputsWithDispatchTargetRepo, inject a target-ref when the trigger is workflow_call:
safeOutputs = safeOutputsWithDispatchTargetRef(safeOutputs, "${{ needs.activation.outputs.target_ref }}")
- Pass
target-ref through the handler config builder:
AddIfNotEmpty("target-ref", c.TargetRef)
- In
dispatch_workflow.cjs, read the config value and use it when present:
if (config['target-ref']) {
ref = config['target-ref'];
core.info(`Using configured target-ref: ${ref}`);
} else if (isCrossRepoDispatch) {
ref = await getDefaultBranchRef();
core.info(`Cross-repo dispatch: using target repo default branch: ${ref}`);
}
This avoids the current bug and also avoids silently dispatching to the target
repository's default branch (often main), which would still be wrong when the
reusable workflow is invoked from a non-default platform branch.
Implementation Plan
1. Fix the runtime handler
File: actions/setup/js/dispatch_workflow.cjs
Function: main() (top-level factory function)
Update the ref resolution so config-provided target-ref takes precedence for
cross-repo dispatch. Replace the current ref resolution block with:
if (config['target-ref']) {
ref = config['target-ref'];
core.info(`Using configured target-ref: ${ref}`);
} else if (process.env.GITHUB_HEAD_REF) {
ref = `refs/heads/${process.env.GITHUB_HEAD_REF}`;
} else if (process.env.GITHUB_REF || context.ref) {
ref = process.env.GITHUB_REF || context.ref;
} else {
ref = await getDefaultBranchRef();
}
If the maintainers want to keep a defensive fallback for hand-authored
cross-repo configurations that omit target-ref, they can still retain
getDefaultBranchRef() as the last resort, but the compiler-generated
workflow_call path should always provide target-ref.
2. Add tests for cross-repo ref resolution
File: actions/setup/js/dispatch_workflow.test.cjs
Add the following test cases inside the existing describe("dispatch_workflow handler factory", ...) block:
it("should use configured target-ref when dispatching cross-repo", async () => {
// Caller is on refs/heads/main, target workflow should run on feature-branch
process.env.GITHUB_REF = "refs/heads/main";
delete process.env.GITHUB_HEAD_REF;
const config = {
"target-repo": "other-org/other-repo",
"target-ref": "refs/heads/feature-branch",
workflows: ["target-workflow"],
workflow_files: { "target-workflow": ".lock.yml" },
};
const handler = await main(config);
await handler(
{ type: "dispatch_workflow", workflow_name: "target-workflow", inputs: {} },
{}
);
// Should dispatch to the configured target ref, NOT the caller's main
expect(github.rest.actions.createWorkflowDispatch).toHaveBeenCalledWith(
expect.objectContaining({
owner: "other-org",
repo: "other-repo",
ref: "refs/heads/feature-branch",
})
);
});
it("should use caller GITHUB_REF when dispatching to same repo", async () => {
process.env.GITHUB_REF = "refs/heads/feature-branch";
delete process.env.GITHUB_HEAD_REF;
const config = {
workflows: ["local-workflow"],
workflow_files: { "local-workflow": ".lock.yml" },
};
const handler = await main(config);
await handler(
{ type: "dispatch_workflow", workflow_name: "local-workflow", inputs: {} },
{}
);
// Same-repo dispatch should still use the caller's GITHUB_REF
expect(github.rest.actions.createWorkflowDispatch).toHaveBeenCalledWith(
expect.objectContaining({
owner: "test-owner",
repo: "test-repo",
ref: "refs/heads/feature-branch",
})
);
});
it("should prefer configured target-ref over GITHUB_HEAD_REF for cross-repo dispatch", async () => {
process.env.GITHUB_REF = "refs/pull/42/merge";
process.env.GITHUB_HEAD_REF = "pr-branch";
const config = {
"target-repo": "other-org/other-repo",
"target-ref": "refs/heads/feature-branch",
workflows: ["target-workflow"],
workflow_files: { "target-workflow": ".lock.yml" },
};
const handler = await main(config);
await handler(
{ type: "dispatch_workflow", workflow_name: "target-workflow", inputs: {} },
{}
);
// Cross-repo should use configured target-ref, not the PR branch
expect(github.rest.actions.createWorkflowDispatch).toHaveBeenCalledWith(
expect.objectContaining({
owner: "other-org",
repo: "other-repo",
ref: "refs/heads/feature-branch",
})
);
});
3. Update existing tests for cross-repo target-repo validation
The existing tests in dispatch_workflow.test.cjs that set target-repo to a different org should gain explicit assertions for target-ref precedence. Add one negative-path test covering cross-repo configuration without target-ref if the maintainers decide to keep a default-branch fallback.
4. Run full validation
This runs make build, make test, make lint, make recompile, make fmt, make lint-errors.
5. Golden fixture update (if applicable)
If there are golden/snapshot test fixtures for generated lockfiles that reference dispatch-workflow cross-repo dispatch, they may need updating. Search for fixtures containing dispatch_workflow and target-repo in the test fixtures directory.
Reproduction
-
Create two repositories: <org>/<platform-repo> (hosts the compiled workflow) and <org>/<app-repo> (hosts the relay).
-
In <org>/<platform-repo>, create a compiled workflow (gateway.md) with:
# frontmatter
safe-outputs:
dispatch-workflow:
workflows: [worker-workflow]
Compile: gh aw compile gateway --strict. The compiler auto-injects target-repo: ${{ needs.activation.outputs.target_repo }} into the lockfile.
-
In <org>/<app-repo>, create a relay:
# relay.yml
on:
workflow_dispatch:
jobs:
relay:
uses: <org>/<platform-repo>/.github/workflows/gateway.lock.yml@feature-branch
-
Trigger the relay from <app-repo> (on main).
-
Expected: The dispatched worker-workflow runs on feature-branch.
-
Actual: The dispatched worker-workflow runs on main — the caller <app-repo>'s GITHUB_REF.
Failing step: Process Safe Outputs in the safe-outputs job. The dispatch itself succeeds (HTTP 204) but targets the wrong ref. The symptom only becomes visible when the dispatched workflow's behaviour differs between branches.
Note: The original failing runs are in private repositories and cannot be linked publicly. The reproduction above is self-contained.
Version: gh-aw v0.58.0 (latest release as of 2025-03-13).
This issue belongs to the same class of bugs as #20508 (activation job checkout resolves wrong repo/ref in cross-repo workflow_call relay) and #20696 (activation checkout pointing to caller repo, closed). All three are symptoms of runtime variables reflecting the caller's context instead of the reusable workflow's context in workflow_call relay scenarios:
| Component |
Bug |
Status |
| Activation checkout — repo + ref |
#20508 / #20696 |
Open / Closed |
dispatch-workflow — ref |
This issue |
New |
The compiler's hasWorkflowCallTrigger → safeOutputsWithDispatchTargetRepo pattern that patches the activation target-repo could be extended to also inject a target-ref, addressing both this issue and #20508 in a unified way.
Summary
In gh-aw v0.58.0, when a compiled workflow is triggered cross-repo via
workflow_call(CentralRepoOps relay pattern) and usesdispatch-workflowwith atarget-repothat differs fromGITHUB_REPOSITORY, the dispatched workflow runs on the caller's branch (GITHUB_REF) instead of the target repo's default branch.The compiler already detects the
workflow_call+dispatch-workflowcombination and auto-injectstarget-repovia${{ needs.activation.outputs.target_repo }}(seesafeOutputsWithDispatchTargetRepoincompiler_safe_outputs_config.go), correctly routing the dispatch to the target repository. However, therefparameter still comes from the caller's environment, which has no meaning on the target repository.Root Cause
In
actions/setup/js/dispatch_workflow.cjs, therefvariable is resolved once at handler initialisation (lines 79–90) fromGITHUB_HEAD_REF/GITHUB_REF/context.ref:In a
workflow_callrelay scenario all three env vars reflect the caller's context:workflow_callGITHUB_REFrefs/heads/main)GITHUB_HEAD_REFGITHUB_WORKFLOW_REF<org>/<app-repo>/.github/workflows/relay.yml@refs/heads/main)Because
GITHUB_REFis set, theelsebranch that callsgetDefaultBranchRef()— which already handles cross-repo correctly by querying the target repository's API — is never reached.The
isCrossRepoDispatchflag is correctly computed but is only used to:context.payload.repository.default_branchinsidegetDefaultBranchRef()It is not used to decide whether the caller's
refshould be replaced.Affected Code
Runtime:
actions/setup/js/dispatch_workflow.cjsThe ref resolution block (lines 79–90) blindly uses
GITHUB_REFregardless of whether the dispatch is same-repo or cross-repo. The downstreamcreateWorkflowDispatchcall (line 162) passes this caller-derivedrefto the target repo:Compiler:
pkg/workflow/compiler_safe_outputs_config.goThe compiler correctly injects
target-repoforworkflow_callrelays (line ~736):But there is no equivalent injection for the dispatch ref. The
DispatchWorkflowConfigstruct has noref/target-reffield.Proposed Fix
Use a compiler + runtime fix that propagates the reusable workflow's intended
target ref explicitly instead of falling back to the target repository's default
branch.
Add a
target-refconfig field:TargetRef stringtoDispatchWorkflowConfiginpkg/workflow/dispatch_workflow.go(or the struct's definition file).compiler_safe_outputs_config.go), alongsidesafeOutputsWithDispatchTargetRepo, inject atarget-refwhen the trigger isworkflow_call:target-refthrough the handler config builder:dispatch_workflow.cjs, read the config value and use it when present:This avoids the current bug and also avoids silently dispatching to the target
repository's default branch (often
main), which would still be wrong when thereusable workflow is invoked from a non-default platform branch.
Implementation Plan
1. Fix the runtime handler
File:
actions/setup/js/dispatch_workflow.cjsFunction:
main()(top-level factory function)Update the ref resolution so config-provided
target-reftakes precedence forcross-repo dispatch. Replace the current ref resolution block with:
If the maintainers want to keep a defensive fallback for hand-authored
cross-repo configurations that omit
target-ref, they can still retaingetDefaultBranchRef()as the last resort, but the compiler-generatedworkflow_callpath should always providetarget-ref.2. Add tests for cross-repo ref resolution
File:
actions/setup/js/dispatch_workflow.test.cjsAdd the following test cases inside the existing
describe("dispatch_workflow handler factory", ...)block:3. Update existing tests for cross-repo target-repo validation
The existing tests in
dispatch_workflow.test.cjsthat settarget-repoto a different org should gain explicit assertions fortarget-refprecedence. Add one negative-path test covering cross-repo configuration withouttarget-refif the maintainers decide to keep a default-branch fallback.4. Run full validation
This runs
make build,make test,make lint,make recompile,make fmt,make lint-errors.5. Golden fixture update (if applicable)
If there are golden/snapshot test fixtures for generated lockfiles that reference
dispatch-workflowcross-repo dispatch, they may need updating. Search for fixtures containingdispatch_workflowandtarget-repoin the test fixtures directory.Reproduction
Create two repositories:
<org>/<platform-repo>(hosts the compiled workflow) and<org>/<app-repo>(hosts the relay).In
<org>/<platform-repo>, create a compiled workflow (gateway.md) with:Compile:
gh aw compile gateway --strict. The compiler auto-injectstarget-repo: ${{ needs.activation.outputs.target_repo }}into the lockfile.In
<org>/<app-repo>, create a relay:Trigger the relay from
<app-repo>(onmain).Expected: The dispatched
worker-workflowruns onfeature-branch.Actual: The dispatched
worker-workflowruns onmain— the caller<app-repo>'sGITHUB_REF.Failing step:
Process Safe Outputsin thesafe-outputsjob. The dispatch itself succeeds (HTTP 204) but targets the wrong ref. The symptom only becomes visible when the dispatched workflow's behaviour differs between branches.Note: The original failing runs are in private repositories and cannot be linked publicly. The reproduction above is self-contained.
Version: gh-aw v0.58.0 (latest release as of 2025-03-13).
Relationship to #20508 and #20696
This issue belongs to the same class of bugs as #20508 (activation job checkout resolves wrong repo/ref in cross-repo
workflow_callrelay) and #20696 (activation checkout pointing to caller repo, closed). All three are symptoms of runtime variables reflecting the caller's context instead of the reusable workflow's context inworkflow_callrelay scenarios:dispatch-workflow— refThe compiler's
hasWorkflowCallTrigger→safeOutputsWithDispatchTargetRepopattern that patches the activation target-repo could be extended to also inject atarget-ref, addressing both this issue and #20508 in a unified way.