Skip to content

dispatch-workflow uses caller's GITHUB_REF for cross-repo dispatch instead of target repo's default branch #20779

@johnwilliams-12

Description

@johnwilliams-12

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:

  1. Log a message about the target repo
  2. 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:

  1. Add TargetRef string to DispatchWorkflowConfig in pkg/workflow/dispatch_workflow.go (or the struct's definition file).
  2. 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 }}")
  3. Pass target-ref through the handler config builder:
    AddIfNotEmpty("target-ref", c.TargetRef)
  4. 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-finish

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

  1. Create two repositories: <org>/<platform-repo> (hosts the compiled workflow) and <org>/<app-repo> (hosts the relay).

  2. 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.

  3. 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
  4. Trigger the relay from <app-repo> (on main).

  5. Expected: The dispatched worker-workflow runs on feature-branch.

  6. 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).

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 hasWorkflowCallTriggersafeOutputsWithDispatchTargetRepo 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.

Metadata

Metadata

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions