Skip to content

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

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions