-
Notifications
You must be signed in to change notification settings - Fork 296
Description
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_branchinsidegetDefaultBranchRef()
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 stringtoDispatchWorkflowConfiginpkg/workflow/dispatch_workflow.go(or the struct's definition file). - In the compiler (
compiler_safe_outputs_config.go), alongsidesafeOutputsWithDispatchTargetRepo, inject atarget-refwhen the trigger isworkflow_call:safeOutputs = safeOutputsWithDispatchTargetRef(safeOutputs, "${{ needs.activation.outputs.target_ref }}")
- Pass
target-refthrough 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
make agent-finishThis 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-injectstarget-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>(onmain). -
Expected: The dispatched
worker-workflowruns onfeature-branch. -
Actual: The dispatched
worker-workflowruns onmain— the caller<app-repo>'sGITHUB_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).
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_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.