Skip to content

Commit a6ff9c1

Browse files
authored
fix: cross-repo dispatch-workflow uses caller's GITHUB_REF instead of target repo's ref (#20790)
1 parent d60d644 commit a6ff9c1

6 files changed

Lines changed: 136 additions & 12 deletions

File tree

actions/setup/js/dispatch_workflow.cjs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,15 @@ async function main(config = {}) {
8282
// When running in a PR context, GITHUB_REF points to the merge ref (refs/pull/{PR_NUMBER}/merge)
8383
// which is not a valid branch ref for dispatching workflows. Instead, we need to use
8484
// GITHUB_HEAD_REF which contains the actual PR branch name.
85+
// For cross-repo dispatch (workflow_call relay), the caller's GITHUB_REF has no meaning on
86+
// the target repository, so we use the compiler-injected target-ref instead.
8587
let ref;
86-
if (process.env.GITHUB_HEAD_REF) {
88+
if (config["target-ref"]) {
89+
// Compiler-injected target ref for cross-repo dispatch (workflow_call relay pattern).
90+
// Takes precedence over all environment variables to avoid using the caller's ref.
91+
ref = config["target-ref"];
92+
core.info(`Using configured target-ref: ${ref}`);
93+
} else if (process.env.GITHUB_HEAD_REF) {
8794
// We're in a pull_request event, use the PR branch ref
8895
ref = `refs/heads/${process.env.GITHUB_HEAD_REF}`;
8996
core.info(`Using PR branch ref: ${ref}`);

actions/setup/js/dispatch_workflow.test.cjs

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -659,4 +659,75 @@ describe("dispatch_workflow handler factory", () => {
659659
})
660660
);
661661
});
662+
663+
it("should use configured target-ref when dispatching cross-repo", async () => {
664+
// Caller is on refs/heads/main, target workflow should run on feature-branch
665+
process.env.GITHUB_REF = "refs/heads/main";
666+
delete process.env.GITHUB_HEAD_REF;
667+
668+
const config = {
669+
"target-repo": "other-org/other-repo",
670+
"target-ref": "refs/heads/feature-branch",
671+
workflows: ["target-workflow"],
672+
workflow_files: { "target-workflow": ".lock.yml" },
673+
};
674+
const handler = await main(config);
675+
676+
await handler({ type: "dispatch_workflow", workflow_name: "target-workflow", inputs: {} }, {});
677+
678+
// Should dispatch to the configured target ref, NOT the caller's main
679+
expect(github.rest.actions.createWorkflowDispatch).toHaveBeenCalledWith(
680+
expect.objectContaining({
681+
owner: "other-org",
682+
repo: "other-repo",
683+
ref: "refs/heads/feature-branch",
684+
})
685+
);
686+
});
687+
688+
it("should use caller GITHUB_REF when dispatching to same repo without target-ref", async () => {
689+
process.env.GITHUB_REF = "refs/heads/feature-branch";
690+
delete process.env.GITHUB_HEAD_REF;
691+
692+
const config = {
693+
workflows: ["local-workflow"],
694+
workflow_files: { "local-workflow": ".lock.yml" },
695+
};
696+
const handler = await main(config);
697+
698+
await handler({ type: "dispatch_workflow", workflow_name: "local-workflow", inputs: {} }, {});
699+
700+
// Same-repo dispatch should still use the caller's GITHUB_REF
701+
expect(github.rest.actions.createWorkflowDispatch).toHaveBeenCalledWith(
702+
expect.objectContaining({
703+
owner: "test-owner",
704+
repo: "test-repo",
705+
ref: "refs/heads/feature-branch",
706+
})
707+
);
708+
});
709+
710+
it("should prefer configured target-ref over GITHUB_HEAD_REF for cross-repo dispatch", async () => {
711+
process.env.GITHUB_REF = "refs/pull/42/merge";
712+
process.env.GITHUB_HEAD_REF = "pr-branch";
713+
714+
const config = {
715+
"target-repo": "other-org/other-repo",
716+
"target-ref": "refs/heads/feature-branch",
717+
workflows: ["target-workflow"],
718+
workflow_files: { "target-workflow": ".lock.yml" },
719+
};
720+
const handler = await main(config);
721+
722+
await handler({ type: "dispatch_workflow", workflow_name: "target-workflow", inputs: {} }, {});
723+
724+
// Cross-repo should use configured target-ref, not the PR branch
725+
expect(github.rest.actions.createWorkflowDispatch).toHaveBeenCalledWith(
726+
expect.objectContaining({
727+
owner: "other-org",
728+
repo: "other-repo",
729+
ref: "refs/heads/feature-branch",
730+
})
731+
);
732+
});
662733
});

docs/src/content/docs/reference/safe-outputs-specification.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3074,13 +3074,15 @@ safe-outputs:
30743074
- `max`: Operation limit (default: 3)
30753075
- `workflows`: Allowlist of workflow names that may be dispatched
30763076
- `target-repo`: Cross-repository target (owner/repo)
3077+
- `target-ref`: Git ref (branch, tag, or SHA) to use when dispatching the workflow. In `workflow_call` relay scenarios this is auto-injected by the compiler from `needs.activation.outputs.target_ref`, ensuring the correct platform branch is used instead of the caller's `GITHUB_REF`.
30773078
- `allowed-repos`: Cross-repo allowlist (supports wildcards, e.g. `org/*`)
30783079

30793080
**Notes**:
30803081
- Requires ONLY `actions: write` permission (no `contents: read` needed)
30813082
- Target workflow must support `workflow_dispatch` trigger
30823083
- Workflow inputs are validated against target workflow's input schema
30833084
- Cross-repository dispatch requires appropriate `actions: write` permissions in the target repository
3085+
- In `workflow_call` relay (CentralRepoOps) scenarios, the compiler automatically injects both `target-repo` and `target-ref` from `needs.activation.outputs.*` so the dispatch targets the correct platform repository and branch
30843086

30853087
---
30863088

pkg/parser/schemas/main_workflow_schema.json

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5341,7 +5341,7 @@
53415341
"items": {
53425342
"type": "string"
53435343
},
5344-
"description": "Exclusive allowlist of glob patterns. When set, every file in the patch must match at least one pattern files outside the list are always refused, including normal source files. This is a restriction, not an exception: setting allowed-files: [\".github/workflows/*\"] blocks all other files. To allow multiple sets of files, list all patterns explicitly. Acts independently of the protected-files policy; both checks must pass. To modify a protected file, it must both match allowed-files and be permitted by protected-files (e.g. protected-files: allowed). Supports * (any characters except /) and ** (any characters including /)."
5344+
"description": "Exclusive allowlist of glob patterns. When set, every file in the patch must match at least one pattern \u2014 files outside the list are always refused, including normal source files. This is a restriction, not an exception: setting allowed-files: [\".github/workflows/*\"] blocks all other files. To allow multiple sets of files, list all patterns explicitly. Acts independently of the protected-files policy; both checks must pass. To modify a protected file, it must both match allowed-files and be permitted by protected-files (e.g. protected-files: allowed). Supports * (any characters except /) and ** (any characters including /)."
53455345
}
53465346
},
53475347
"additionalProperties": false,
@@ -6393,7 +6393,7 @@
63936393
"items": {
63946394
"type": "string"
63956395
},
6396-
"description": "Exclusive allowlist of glob patterns. When set, every file in the patch must match at least one pattern files outside the list are always refused, including normal source files. This is a restriction, not an exception: setting allowed-files: [\".github/workflows/*\"] blocks all other files. To allow multiple sets of files, list all patterns explicitly. Acts independently of the protected-files policy; both checks must pass. To modify a protected file, it must both match allowed-files and be permitted by protected-files (e.g. protected-files: allowed). Supports * (any characters except /) and ** (any characters including /)."
6396+
"description": "Exclusive allowlist of glob patterns. When set, every file in the patch must match at least one pattern \u2014 files outside the list are always refused, including normal source files. This is a restriction, not an exception: setting allowed-files: [\".github/workflows/*\"] blocks all other files. To allow multiple sets of files, list all patterns explicitly. Acts independently of the protected-files policy; both checks must pass. To modify a protected file, it must both match allowed-files and be permitted by protected-files (e.g. protected-files: allowed). Supports * (any characters except /) and ** (any characters including /)."
63976397
}
63986398
},
63996399
"additionalProperties": false
@@ -6541,6 +6541,14 @@
65416541
"github-token": {
65426542
"$ref": "#/$defs/github_token",
65436543
"description": "GitHub token to use for dispatching workflows. Overrides global github-token if specified."
6544+
},
6545+
"target-repo": {
6546+
"type": "string",
6547+
"description": "Target repository in format 'owner/repo' for cross-repository workflow dispatch. When specified, the workflow will be dispatched to the target repository instead of the current one."
6548+
},
6549+
"target-ref": {
6550+
"type": "string",
6551+
"description": "Git ref (branch, tag, or SHA) to use when dispatching the workflow. For workflow_call relay scenarios this is auto-injected by the compiler from needs.activation.outputs.target_ref. Overrides the caller's GITHUB_REF."
65446552
}
65456553
},
65466554
"required": ["workflows"],
@@ -7659,7 +7667,13 @@
76597667
},
76607668
"dependencies": {
76617669
"description": "APM package references to install. Supports array format (list of package slugs) or object format with packages and isolated fields.",
7662-
"examples": [["microsoft/apm-sample-package", "acme/custom-tools"], { "packages": ["microsoft/apm-sample-package"], "isolated": true }],
7670+
"examples": [
7671+
["microsoft/apm-sample-package", "acme/custom-tools"],
7672+
{
7673+
"packages": ["microsoft/apm-sample-package"],
7674+
"isolated": true
7675+
}
7676+
],
76637677
"oneOf": [
76647678
{
76657679
"type": "array",
@@ -8219,12 +8233,16 @@
82198233
"query": {
82208234
"type": "object",
82218235
"description": "Static query parameters",
8222-
"additionalProperties": { "type": "string" }
8236+
"additionalProperties": {
8237+
"type": "string"
8238+
}
82238239
},
82248240
"body-inject": {
82258241
"type": "object",
82268242
"description": "Key/value pairs injected into the JSON request body",
8227-
"additionalProperties": { "type": "string" }
8243+
"additionalProperties": {
8244+
"type": "string"
8245+
}
82288246
}
82298247
},
82308248
"additionalProperties": false
@@ -8242,15 +8260,17 @@
82428260
},
82438261
"supported": {
82448262
"type": "array",
8245-
"items": { "type": "string" },
8263+
"items": {
8264+
"type": "string"
8265+
},
82468266
"description": "List of supported model identifiers"
82478267
}
82488268
},
82498269
"additionalProperties": false
82508270
},
82518271
"auth": {
82528272
"type": "array",
8253-
"description": "Authentication bindings maps logical roles (e.g. 'api-key') to GitHub Actions secret names",
8273+
"description": "Authentication bindings \u2014 maps logical roles (e.g. 'api-key') to GitHub Actions secret names",
82548274
"items": {
82558275
"type": "object",
82568276
"properties": {

pkg/workflow/compiler_safe_outputs_config.go

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -577,6 +577,7 @@ var handlerRegistry = map[string]handlerBuilder{
577577
builder.AddDefault("workflow_files", c.WorkflowFiles)
578578
}
579579

580+
builder.AddIfNotEmpty("target-ref", c.TargetRef)
580581
builder.AddIfNotEmpty("github-token", c.GitHubToken)
581582
return builder.Build()
582583
},
@@ -731,12 +732,18 @@ func (c *Compiler) addHandlerManagerConfigEnvVar(steps *[]string, data *Workflow
731732
fullManifestFiles := getAllManifestFiles(extraManifestFiles...)
732733
fullPathPrefixes := getProtectedPathPrefixes(extraPathPrefixes...)
733734

734-
// For workflow_call relay workflows, inject the resolved platform repo into the
735+
// For workflow_call relay workflows, inject the resolved platform repo and ref into the
735736
// dispatch_workflow handler config so dispatch targets the host repo, not the caller's.
736737
safeOutputs := data.SafeOutputs
737-
if hasWorkflowCallTrigger(data.On) && safeOutputs.DispatchWorkflow != nil && safeOutputs.DispatchWorkflow.TargetRepoSlug == "" {
738-
safeOutputs = safeOutputsWithDispatchTargetRepo(safeOutputs, "${{ needs.activation.outputs.target_repo }}")
739-
compilerSafeOutputsConfigLog.Print("Injecting target_repo into dispatch_workflow config for workflow_call relay")
738+
if hasWorkflowCallTrigger(data.On) && safeOutputs.DispatchWorkflow != nil {
739+
if safeOutputs.DispatchWorkflow.TargetRepoSlug == "" {
740+
safeOutputs = safeOutputsWithDispatchTargetRepo(safeOutputs, "${{ needs.activation.outputs.target_repo }}")
741+
compilerSafeOutputsConfigLog.Print("Injecting target_repo into dispatch_workflow config for workflow_call relay")
742+
}
743+
if safeOutputs.DispatchWorkflow.TargetRef == "" {
744+
safeOutputs = safeOutputsWithDispatchTargetRef(safeOutputs, "${{ needs.activation.outputs.target_ref }}")
745+
compilerSafeOutputsConfigLog.Print("Injecting target_ref into dispatch_workflow config for workflow_call relay")
746+
}
740747
}
741748

742749
// Build configuration for each handler using the registry
@@ -784,6 +791,17 @@ func safeOutputsWithDispatchTargetRepo(cfg *SafeOutputsConfig, targetRepo string
784791
return &configCopy
785792
}
786793

794+
// safeOutputsWithDispatchTargetRef returns a shallow copy of cfg with the dispatch_workflow
795+
// TargetRef overridden to targetRef. Only DispatchWorkflow is deep-copied; all other
796+
// pointer fields remain shared. This avoids mutating the original config.
797+
func safeOutputsWithDispatchTargetRef(cfg *SafeOutputsConfig, targetRef string) *SafeOutputsConfig {
798+
dispatchCopy := *cfg.DispatchWorkflow
799+
dispatchCopy.TargetRef = targetRef
800+
configCopy := *cfg
801+
configCopy.DispatchWorkflow = &dispatchCopy
802+
return &configCopy
803+
}
804+
787805
// getEngineAgentFileInfo returns the engine-specific manifest filenames and path prefixes
788806
// by type-asserting the active engine to AgentFileProvider. Returns empty slices when
789807
// the engine is not set or does not implement the interface.

pkg/workflow/dispatch_workflow.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ type DispatchWorkflowConfig struct {
1212
Workflows []string `yaml:"workflows,omitempty"` // List of workflow names (without .md extension) to allow dispatching
1313
WorkflowFiles map[string]string `yaml:"workflow_files,omitempty"` // Map of workflow name to file extension (.lock.yml or .yml) - populated at compile time
1414
TargetRepoSlug string `yaml:"target-repo,omitempty"` // Target repository for cross-repo dispatch (owner/repo or GitHub Actions expression)
15+
TargetRef string `yaml:"target-ref,omitempty"` // Target ref for cross-repo dispatch; overrides the caller's GITHUB_REF
1516
}
1617

1718
// parseDispatchWorkflowConfig handles dispatch-workflow configuration
@@ -51,6 +52,11 @@ func (c *Compiler) parseDispatchWorkflowConfig(outputMap map[string]any) *Dispat
5152
// Parse common base fields with default max of 1
5253
c.parseBaseSafeOutputConfig(configMap, &dispatchWorkflowConfig.BaseSafeOutputConfig, 1)
5354

55+
// Parse target-ref (optional ref for cross-repo dispatch)
56+
if targetRef, ok := configMap["target-ref"].(string); ok && targetRef != "" {
57+
dispatchWorkflowConfig.TargetRef = targetRef
58+
}
59+
5460
// Cap max at 50 (absolute maximum allowed) – only for literal integer values
5561
if maxVal := templatableIntValue(dispatchWorkflowConfig.Max); maxVal > 50 {
5662
dispatchWorkflowLog.Printf("Max value %d exceeds limit, capping at 50", maxVal)

0 commit comments

Comments
 (0)