From 4c03622cda1c905cbd31cfeb16ac4ed8b1b25b84 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:37:56 +0000 Subject: [PATCH 1/5] Initial plan From e03cf11d217880d6129bbd0663a27af58339ddcd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:56:02 +0000 Subject: [PATCH 2/5] feat(dispatch-workflow): allow per-call ref override Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../smoke-copilot-aoai-apikey.lock.yml | 8 +++ .../smoke-copilot-aoai-entra.lock.yml | 8 +++ .github/workflows/smoke-copilot-arm.lock.yml | 8 +++ .github/workflows/smoke-copilot.lock.yml | 8 +++ actions/setup/js/dispatch_workflow.cjs | 19 +++--- actions/setup/js/dispatch_workflow.test.cjs | 67 +++++++++++++++++++ .../safe_outputs_validation_config.go | 1 + schemas/agent-output.json | 5 ++ 8 files changed, 116 insertions(+), 8 deletions(-) diff --git a/.github/workflows/smoke-copilot-aoai-apikey.lock.yml b/.github/workflows/smoke-copilot-aoai-apikey.lock.yml index bef4ba5f65a..5ec93972a4c 100644 --- a/.github/workflows/smoke-copilot-aoai-apikey.lock.yml +++ b/.github/workflows/smoke-copilot-aoai-apikey.lock.yml @@ -1057,6 +1057,14 @@ jobs: "inputs": { "type": "object" }, + "ref": { + "type": "string", + "sanitize": true, + "maxLength": 256, + "minLength": 1, + "pattern": ".*\\S.*", + "patternError": "must not be empty" + }, "workflow_name": { "required": true, "type": "string", diff --git a/.github/workflows/smoke-copilot-aoai-entra.lock.yml b/.github/workflows/smoke-copilot-aoai-entra.lock.yml index 93af89a87bf..67b2ab3c904 100644 --- a/.github/workflows/smoke-copilot-aoai-entra.lock.yml +++ b/.github/workflows/smoke-copilot-aoai-entra.lock.yml @@ -1074,6 +1074,14 @@ jobs: "inputs": { "type": "object" }, + "ref": { + "type": "string", + "sanitize": true, + "maxLength": 256, + "minLength": 1, + "pattern": ".*\\S.*", + "patternError": "must not be empty" + }, "workflow_name": { "required": true, "type": "string", diff --git a/.github/workflows/smoke-copilot-arm.lock.yml b/.github/workflows/smoke-copilot-arm.lock.yml index 3488db02de7..398523d37cb 100644 --- a/.github/workflows/smoke-copilot-arm.lock.yml +++ b/.github/workflows/smoke-copilot-arm.lock.yml @@ -947,6 +947,14 @@ jobs: "inputs": { "type": "object" }, + "ref": { + "type": "string", + "sanitize": true, + "maxLength": 256, + "minLength": 1, + "pattern": ".*\\S.*", + "patternError": "must not be empty" + }, "workflow_name": { "required": true, "type": "string", diff --git a/.github/workflows/smoke-copilot.lock.yml b/.github/workflows/smoke-copilot.lock.yml index 99f20d4b10e..c61248d98b1 100644 --- a/.github/workflows/smoke-copilot.lock.yml +++ b/.github/workflows/smoke-copilot.lock.yml @@ -1073,6 +1073,14 @@ jobs: "inputs": { "type": "object" }, + "ref": { + "type": "string", + "sanitize": true, + "maxLength": 256, + "minLength": 1, + "pattern": ".*\\S.*", + "patternError": "must not be empty" + }, "workflow_name": { "required": true, "type": "string", diff --git a/actions/setup/js/dispatch_workflow.cjs b/actions/setup/js/dispatch_workflow.cjs index de8e490bb8d..832cc72b4b6 100644 --- a/actions/setup/js/dispatch_workflow.cjs +++ b/actions/setup/js/dispatch_workflow.cjs @@ -107,23 +107,23 @@ async function main(config = {}) { // GITHUB_HEAD_REF which contains the actual PR branch name. // For cross-repo dispatch (workflow_call relay), the caller's GITHUB_REF has no meaning on // the target repository, so we use the compiler-injected target-ref instead. - let ref; + let defaultRef; if (config["target-ref"]) { // Compiler-injected target ref for cross-repo dispatch (workflow_call relay pattern). // Takes precedence over all environment variables to avoid using the caller's ref. - ref = config["target-ref"]; - core.info(`Using configured target-ref: ${ref}`); + defaultRef = config["target-ref"]; + core.info(`Using configured target-ref: ${defaultRef}`); } else if (process.env.GITHUB_HEAD_REF) { // We're in a pull_request event, use the PR branch ref - ref = `refs/heads/${process.env.GITHUB_HEAD_REF}`; - core.info(`Using PR branch ref: ${ref}`); + defaultRef = `refs/heads/${process.env.GITHUB_HEAD_REF}`; + core.info(`Using PR branch ref: ${defaultRef}`); } else if (process.env.GITHUB_REF || context.ref) { // Use GITHUB_REF for non-PR contexts (push, workflow_dispatch, etc.) - ref = process.env.GITHUB_REF || context.ref; + defaultRef = process.env.GITHUB_REF || context.ref; } else { // Last resort: fetch the repository's default branch - ref = await getDefaultBranchRef(); - core.info(`Using default branch ref: ${ref}`); + defaultRef = await getDefaultBranchRef(); + core.info(`Using default branch ref: ${defaultRef}`); } /** @@ -177,6 +177,9 @@ async function main(config = {}) { core.info(`Dispatching workflow: ${workflowName}`); + const outputRef = typeof message.ref === "string" ? message.ref.trim() : ""; + const ref = outputRef ? (outputRef.startsWith("refs/") ? outputRef : `refs/heads/${outputRef}`) : defaultRef; + // Prepare inputs - convert all values to strings as required by workflow_dispatch // and resolve any #temporary_id references before dispatching /** @type {Record} */ diff --git a/actions/setup/js/dispatch_workflow.test.cjs b/actions/setup/js/dispatch_workflow.test.cjs index 8bdd7b2d60a..56a25ef58e2 100644 --- a/actions/setup/js/dispatch_workflow.test.cjs +++ b/actions/setup/js/dispatch_workflow.test.cjs @@ -454,6 +454,73 @@ describe("dispatch_workflow handler factory", () => { }); }); + it("should prioritize message ref over configured and environment refs", async () => { + process.env.GITHUB_REF = "refs/heads/main"; + process.env.GITHUB_HEAD_REF = "pr-branch"; + + const config = { + "target-ref": "refs/heads/config-branch", + workflows: ["test-workflow"], + workflow_files: { + "test-workflow": ".lock.yml", + }, + aw_context_workflows: ["test-workflow"], + }; + const handler = await main(config); + + await handler( + { + type: "dispatch_workflow", + workflow_name: "test-workflow", + ref: "agent-branch", + inputs: {}, + }, + {} + ); + + expect(github.rest.actions.createWorkflowDispatch).toHaveBeenCalledWith({ + owner: "test-owner", + repo: "test-repo", + workflow_id: "test-workflow.lock.yml", + ref: "refs/heads/agent-branch", + inputs: expect.objectContaining({ aw_context: expect.any(String) }), + return_run_details: true, + }); + }); + + it("should use message ref as-is when it is already a full ref", async () => { + process.env.GITHUB_REF = "refs/heads/main"; + delete process.env.GITHUB_HEAD_REF; + + const config = { + workflows: ["test-workflow"], + workflow_files: { + "test-workflow": ".lock.yml", + }, + aw_context_workflows: ["test-workflow"], + }; + const handler = await main(config); + + await handler( + { + type: "dispatch_workflow", + workflow_name: "test-workflow", + ref: "refs/tags/v1.2.3", + inputs: {}, + }, + {} + ); + + expect(github.rest.actions.createWorkflowDispatch).toHaveBeenCalledWith({ + owner: "test-owner", + repo: "test-repo", + workflow_id: "test-workflow.lock.yml", + ref: "refs/tags/v1.2.3", + inputs: expect.objectContaining({ aw_context: expect.any(String) }), + return_run_details: true, + }); + }); + it("should handle PR context with slashes in branch names", async () => { process.env.GITHUB_REF = "refs/pull/456/merge"; process.env.GITHUB_HEAD_REF = "feature/add-new-feature"; diff --git a/pkg/workflow/safe_outputs_validation_config.go b/pkg/workflow/safe_outputs_validation_config.go index 0241b7fb233..19fb1844749 100644 --- a/pkg/workflow/safe_outputs_validation_config.go +++ b/pkg/workflow/safe_outputs_validation_config.go @@ -312,6 +312,7 @@ var ValidationConfig = map[string]TypeValidationConfig{ Fields: map[string]FieldValidation{ "workflow_name": {Required: true, Type: "string", Sanitize: true, MinLength: 1, MaxLength: 256, Pattern: ".*\\S.*", PatternError: "must not be empty"}, "inputs": {Type: "object"}, + "ref": {Type: "string", Sanitize: true, MinLength: 1, MaxLength: 256, Pattern: ".*\\S.*", PatternError: "must not be empty"}, }, }, "missing_tool": { diff --git a/schemas/agent-output.json b/schemas/agent-output.json index 455e6e8e17b..407df405804 100644 --- a/schemas/agent-output.json +++ b/schemas/agent-output.json @@ -846,6 +846,11 @@ "type": "object", "description": "Input parameters for the workflow_dispatch event, matching the workflow's input schema", "additionalProperties": true + }, + "ref": { + "type": "string", + "description": "Optional git ref or branch name to dispatch against (highest priority when provided)", + "minLength": 1 } }, "required": ["type", "workflow_name"], From 27f6cb61bc679f17e3765efa3e205c9e7718608e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 1 Aug 2026 01:00:59 +0000 Subject: [PATCH 3/5] fix(dispatch-workflow): require allowed-refs for message.ref override Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/dispatch_workflow.cjs | 68 ++++++++++++++++++- actions/setup/js/dispatch_workflow.test.cjs | 63 +++++++++++++++++ pkg/parser/schema_safe_outputs_target_test.go | 3 +- pkg/parser/schemas/main_workflow_schema.json | 16 +++++ pkg/workflow/dispatch_workflow.go | 2 + .../safe_outputs_cross_repo_config_test.go | 13 ++++ pkg/workflow/safe_outputs_handler_registry.go | 3 +- schemas/agent-output.json | 2 +- 8 files changed, 166 insertions(+), 4 deletions(-) diff --git a/actions/setup/js/dispatch_workflow.cjs b/actions/setup/js/dispatch_workflow.cjs index 832cc72b4b6..0869928585f 100644 --- a/actions/setup/js/dispatch_workflow.cjs +++ b/actions/setup/js/dispatch_workflow.cjs @@ -9,6 +9,7 @@ const HANDLER_TYPE = "dispatch_workflow"; const { getErrorMessage } = require("./error_helpers.cjs"); +const { globPatternToRegex } = require("./glob_pattern_helpers.cjs"); const { createAuthenticatedGitHubClient } = require("./handler_auth.cjs"); const { resolveTargetRepoConfig, parseRepoSlug, validateTargetRepo } = require("./repo_helpers.cjs"); const { logStagedPreviewInfo } = require("./staged_preview.cjs"); @@ -29,6 +30,8 @@ async function main(config = {}) { const awContextWorkflows = new Set(config.aw_context_workflows || []); // Workflows that accept aw_context input const githubClient = await createAuthenticatedGitHubClient(config); const { defaultTargetRepo, allowedRepos } = resolveTargetRepoConfig(config); + const allowedRefPatterns = parseAllowedRefPatterns(config.allowed_refs); + const allowedRefRegexes = allowedRefPatterns.map(pattern => globPatternToRegex(pattern, { pathMode: true, caseSensitive: true })); // Resolve the dispatch destination repository from target-repo config, falling back to context.repo const contextRepoSlug = `${context.repo.owner}/${context.repo.repo}`; @@ -178,7 +181,26 @@ async function main(config = {}) { core.info(`Dispatching workflow: ${workflowName}`); const outputRef = typeof message.ref === "string" ? message.ref.trim() : ""; - const ref = outputRef ? (outputRef.startsWith("refs/") ? outputRef : `refs/heads/${outputRef}`) : defaultRef; + let ref = defaultRef; + if (outputRef) { + ref = normalizeRef(outputRef); + if (allowedRefRegexes.length === 0) { + const error = "message.ref is not allowed unless 'allowed-refs' is configured in safe-outputs.dispatch-workflow"; + core.warning(error); + return { + success: false, + error, + }; + } + if (!allowedRefRegexes.some(pattern => pattern.test(ref))) { + const error = `Ref '${ref}' is not in allowed-refs: ${allowedRefPatterns.join(", ")}`; + core.warning(error); + return { + success: false, + error, + }; + } + } // Prepare inputs - convert all values to strings as required by workflow_dispatch // and resolve any #temporary_id references before dispatching @@ -323,4 +345,48 @@ async function main(config = {}) { }; } +/** + * @param {string[]|string|undefined} allowedRefsValue + * @returns {string[]} + */ +function parseAllowedRefPatterns(allowedRefsValue) { + /** @type {string[]} */ + const refs = []; + if (Array.isArray(allowedRefsValue)) { + for (const pattern of allowedRefsValue) { + if (typeof pattern === "string") { + const trimmed = pattern.trim(); + if (trimmed) { + refs.push(normalizeRefPattern(trimmed)); + } + } + } + return refs; + } + if (typeof allowedRefsValue === "string") { + return allowedRefsValue + .split(",") + .map(pattern => pattern.trim()) + .filter(Boolean) + .map(normalizeRefPattern); + } + return refs; +} + +/** + * @param {string} refOrBranch + * @returns {string} + */ +function normalizeRef(refOrBranch) { + return refOrBranch.startsWith("refs/") ? refOrBranch : `refs/heads/${refOrBranch}`; +} + +/** + * @param {string} pattern + * @returns {string} + */ +function normalizeRefPattern(pattern) { + return pattern.startsWith("refs/") ? pattern : `refs/heads/${pattern}`; +} + module.exports = { main }; diff --git a/actions/setup/js/dispatch_workflow.test.cjs b/actions/setup/js/dispatch_workflow.test.cjs index 56a25ef58e2..5747b9a5a7c 100644 --- a/actions/setup/js/dispatch_workflow.test.cjs +++ b/actions/setup/js/dispatch_workflow.test.cjs @@ -460,6 +460,7 @@ describe("dispatch_workflow handler factory", () => { const config = { "target-ref": "refs/heads/config-branch", + allowed_refs: ["refs/heads/agent-*"], workflows: ["test-workflow"], workflow_files: { "test-workflow": ".lock.yml", @@ -493,6 +494,7 @@ describe("dispatch_workflow handler factory", () => { delete process.env.GITHUB_HEAD_REF; const config = { + allowed_refs: ["refs/tags/*"], workflows: ["test-workflow"], workflow_files: { "test-workflow": ".lock.yml", @@ -521,6 +523,67 @@ describe("dispatch_workflow handler factory", () => { }); }); + it("should reject message ref when allowed-refs is not configured", async () => { + process.env.GITHUB_REF = "refs/heads/main"; + delete process.env.GITHUB_HEAD_REF; + + const config = { + workflows: ["test-workflow"], + workflow_files: { + "test-workflow": ".lock.yml", + }, + aw_context_workflows: ["test-workflow"], + }; + const handler = await main(config); + + const result = await handler( + { + type: "dispatch_workflow", + workflow_name: "test-workflow", + ref: "agent-branch", + inputs: {}, + }, + {} + ); + + expect(result).toEqual({ + success: false, + error: "message.ref is not allowed unless 'allowed-refs' is configured in safe-outputs.dispatch-workflow", + }); + expect(github.rest.actions.createWorkflowDispatch).not.toHaveBeenCalled(); + }); + + it("should reject message ref when it does not match allowed-refs", async () => { + process.env.GITHUB_REF = "refs/heads/main"; + delete process.env.GITHUB_HEAD_REF; + + const config = { + allowed_refs: ["release/*"], + workflows: ["test-workflow"], + workflow_files: { + "test-workflow": ".lock.yml", + }, + aw_context_workflows: ["test-workflow"], + }; + const handler = await main(config); + + const result = await handler( + { + type: "dispatch_workflow", + workflow_name: "test-workflow", + ref: "feature/new-ui", + inputs: {}, + }, + {} + ); + + expect(result).toEqual({ + success: false, + error: "Ref 'refs/heads/feature/new-ui' is not in allowed-refs: refs/heads/release/*", + }); + expect(github.rest.actions.createWorkflowDispatch).not.toHaveBeenCalled(); + }); + it("should handle PR context with slashes in branch names", async () => { process.env.GITHUB_REF = "refs/pull/456/merge"; process.env.GITHUB_HEAD_REF = "feature/add-new-feature"; diff --git a/pkg/parser/schema_safe_outputs_target_test.go b/pkg/parser/schema_safe_outputs_target_test.go index 4212e4f782b..a9fbee9488a 100644 --- a/pkg/parser/schema_safe_outputs_target_test.go +++ b/pkg/parser/schema_safe_outputs_target_test.go @@ -285,12 +285,13 @@ func TestMainWorkflowSchema_SafeOutputsTargetProperties(t *testing.T) { }, }, { - name: "dispatch-workflow with target-repo and allowed-repos", + name: "dispatch-workflow with target-repo, allowed-repos, and allowed-refs", safeOutputs: map[string]any{ "dispatch-workflow": map[string]any{ "workflows": []any{"worker"}, "target-repo": "github/github", "allowed-repos": []any{"github/docs"}, + "allowed-refs": []any{"refs/heads/release/*"}, }, }, }, diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 32c258c919c..7c180c2319a 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -9736,6 +9736,22 @@ } ] }, + "allowed-refs": { + "description": "List of allowed ref glob patterns for per-call dispatch_workflow message.ref overrides. Supports arrays and GitHub Actions expressions resolving to a comma-separated list (e.g. '${{ inputs['allowed-refs'] }}').", + "oneOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "string", + "pattern": "^\\$\\{\\{.*\\}\\}$", + "description": "GitHub Actions expression resolving to a comma-separated list of ref glob patterns (e.g. '${{ inputs['allowed-refs'] }}')" + } + ] + }, "target-ref": { "type": "string", "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." diff --git a/pkg/workflow/dispatch_workflow.go b/pkg/workflow/dispatch_workflow.go index ca9239ec83c..511ba4d8e92 100644 --- a/pkg/workflow/dispatch_workflow.go +++ b/pkg/workflow/dispatch_workflow.go @@ -14,6 +14,7 @@ type DispatchWorkflowConfig struct { AwContextWorkflows []string `yaml:"aw_context_workflows,omitempty"` // Workflows that declare aw_context in workflow_dispatch.inputs - populated at compile time TargetRepoSlug string `yaml:"target-repo,omitempty"` // Target repository for cross-repo dispatch (owner/repo or GitHub Actions expression) AllowedRepos []string `yaml:"allowed-repos,omitempty"` // Allowlist for cross-repository dispatch targets + AllowedRefs []string `yaml:"allowed-refs,omitempty"` // Allowlist of ref globs for per-call message.ref overrides TargetRef string `yaml:"target-ref,omitempty"` // Target ref for cross-repo dispatch; overrides the caller's GITHUB_REF } @@ -62,6 +63,7 @@ func (c *Compiler) parseDispatchWorkflowConfig(outputMap map[string]any) *Dispat // Parse target-repo (optional cross-repo dispatch target) dispatchWorkflowConfig.TargetRepoSlug = extractStringFromMap(configMap, "target-repo", dispatchWorkflowLog) dispatchWorkflowConfig.AllowedRepos = ParseStringArrayOrExprFromConfig(configMap, "allowed-repos", dispatchWorkflowLog) + dispatchWorkflowConfig.AllowedRefs = ParseStringArrayOrExprFromConfig(configMap, "allowed-refs", dispatchWorkflowLog) // Cap max at 50 (absolute maximum allowed) – only for literal integer values if maxVal := templatableIntValue(dispatchWorkflowConfig.Max); maxVal > 50 { diff --git a/pkg/workflow/safe_outputs_cross_repo_config_test.go b/pkg/workflow/safe_outputs_cross_repo_config_test.go index 906dd106f0f..541fcd0cffe 100644 --- a/pkg/workflow/safe_outputs_cross_repo_config_test.go +++ b/pkg/workflow/safe_outputs_cross_repo_config_test.go @@ -21,6 +21,7 @@ func TestDispatchWorkflowConfigTargetRepo(t *testing.T) { configMap map[string]any expectedRepo string expectedRepos []string + expectedRefs []string expectedToken string }{ { @@ -30,11 +31,13 @@ func TestDispatchWorkflowConfigTargetRepo(t *testing.T) { "workflows": []any{"worker"}, "target-repo": "githubnext/gh-aw-side-repo", "allowed-repos": []any{"githubnext/gh-aw-side-repo"}, + "allowed-refs": []any{"refs/heads/release/*"}, "github-token": "${{ secrets.TEMP_USER_PAT }}", }, }, expectedRepo: "githubnext/gh-aw-side-repo", expectedRepos: []string{"githubnext/gh-aw-side-repo"}, + expectedRefs: []string{"refs/heads/release/*"}, expectedToken: "${{ secrets.TEMP_USER_PAT }}", }, { @@ -48,6 +51,7 @@ func TestDispatchWorkflowConfigTargetRepo(t *testing.T) { }, expectedRepo: "org/primary-repo", expectedRepos: []string{"org/primary-repo", "org/secondary-repo"}, + expectedRefs: nil, expectedToken: "", }, { @@ -57,10 +61,12 @@ func TestDispatchWorkflowConfigTargetRepo(t *testing.T) { "workflows": []any{"worker"}, "target-repo": "${{ inputs.target_repo }}", "allowed-repos": "${{ inputs['allowed-repos'] }}", + "allowed-refs": "${{ inputs['allowed-refs'] }}", }, }, expectedRepo: "${{ inputs.target_repo }}", expectedRepos: []string{"${{ inputs['allowed-repos'] }}"}, + expectedRefs: []string{"${{ inputs['allowed-refs'] }}"}, expectedToken: "", }, { @@ -73,6 +79,7 @@ func TestDispatchWorkflowConfigTargetRepo(t *testing.T) { }, expectedRepo: "", expectedRepos: nil, + expectedRefs: nil, expectedToken: "", }, } @@ -84,6 +91,7 @@ func TestDispatchWorkflowConfigTargetRepo(t *testing.T) { require.NotNil(t, cfg, "config should not be nil") assert.Equal(t, tt.expectedRepo, cfg.TargetRepoSlug, "TargetRepoSlug mismatch") assert.Equal(t, tt.expectedRepos, cfg.AllowedRepos, "AllowedRepos mismatch") + assert.Equal(t, tt.expectedRefs, cfg.AllowedRefs, "AllowedRefs mismatch") assert.Equal(t, tt.expectedToken, cfg.GitHubToken, "GitHubToken mismatch") }) } @@ -607,6 +615,7 @@ func TestDispatchWorkflowCrossRepoInHandlerConfig(t *testing.T) { Workflows: []string{"worker"}, TargetRepoSlug: "githubnext/gh-aw-side-repo", AllowedRepos: []string{"githubnext/gh-aw-side-repo"}, + AllowedRefs: []string{"refs/heads/release/*"}, }, }, } @@ -626,6 +635,10 @@ func TestDispatchWorkflowCrossRepoInHandlerConfig(t *testing.T) { allowedRepos, ok := dispatchWorkflow["allowed_repos"] require.True(t, ok, "allowed_repos should be present") assert.Contains(t, allowedRepos, "githubnext/gh-aw-side-repo", "allowed_repos should contain the repo") + + allowedRefs, ok := dispatchWorkflow["allowed_refs"] + require.True(t, ok, "allowed_refs should be present") + assert.Contains(t, allowedRefs, "refs/heads/release/*", "allowed_refs should contain the ref glob") } // TestHandlerManagerStepPerOutputTokenInHandlerConfig verifies that per-output tokens diff --git a/pkg/workflow/safe_outputs_handler_registry.go b/pkg/workflow/safe_outputs_handler_registry.go index 5b41b2ad182..cd0c2aa36f9 100644 --- a/pkg/workflow/safe_outputs_handler_registry.go +++ b/pkg/workflow/safe_outputs_handler_registry.go @@ -700,7 +700,8 @@ var handlerRegistry = map[string]handlerBuilder{ AddTemplatableInt("max", c.Max). AddStringSlice("workflows", c.Workflows). AddIfNotEmpty("target-repo", c.TargetRepoSlug). - AddTemplatableStringSlice("allowed_repos", c.AllowedRepos) + AddTemplatableStringSlice("allowed_repos", c.AllowedRepos). + AddTemplatableStringSlice("allowed_refs", c.AllowedRefs) // Add workflow_files map if it has entries if len(c.WorkflowFiles) > 0 { diff --git a/schemas/agent-output.json b/schemas/agent-output.json index 407df405804..3923cc3398b 100644 --- a/schemas/agent-output.json +++ b/schemas/agent-output.json @@ -849,7 +849,7 @@ }, "ref": { "type": "string", - "description": "Optional git ref or branch name to dispatch against (highest priority when provided)", + "description": "Optional git ref or branch name to dispatch against (highest priority when provided). Requires safe-outputs.dispatch-workflow.allowed-refs configuration.", "minLength": 1 } }, From 3df3b7b97714997ad96637cd47b5cebaf5ef9e8e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 1 Aug 2026 02:05:35 +0000 Subject: [PATCH 4/5] fix(dispatch-workflow): remove Sanitize from ref field, fix tags/ normalization, update docs - Remove Sanitize: true from dispatch_workflow ref field validation (prevents @-chars in refs like refs/heads/release/@candidate from being mangled by markdown sanitizer); use a git-ref-safe pattern instead - Fix normalizeRef/normalizeRefPattern to expand tags/ prefix to refs/tags/ instead of refs/heads/ (silent misrouting bug for short-form tag patterns in allowed-refs) - Add test coverage for tags/ prefix normalization via allowed-refs - Add allowed-refs and per-call ref documentation to safe-outputs.md (Configuration section + new Per-call Ref Override section with ref resolution priority table) - Add allowed-refs to frontmatter-full.md dispatch-workflow config block - Regenerate affected .lock.yml files to reflect ref validation schema change Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../smoke-copilot-aoai-apikey.lock.yml | 5 ++- .../smoke-copilot-aoai-entra.lock.yml | 5 ++- .github/workflows/smoke-copilot-arm.lock.yml | 5 ++- .github/workflows/smoke-copilot.lock.yml | 5 ++- actions/setup/js/dispatch_workflow.cjs | 8 +++-- actions/setup/js/dispatch_workflow.test.cjs | 34 +++++++++++++++++++ .../docs/reference/frontmatter-full.md | 16 +++++++++ .../content/docs/reference/safe-outputs.md | 23 +++++++++++++ .../safe_outputs_validation_config.go | 2 +- 9 files changed, 88 insertions(+), 15 deletions(-) diff --git a/.github/workflows/smoke-copilot-aoai-apikey.lock.yml b/.github/workflows/smoke-copilot-aoai-apikey.lock.yml index 5ec93972a4c..5d915f33a49 100644 --- a/.github/workflows/smoke-copilot-aoai-apikey.lock.yml +++ b/.github/workflows/smoke-copilot-aoai-apikey.lock.yml @@ -1059,11 +1059,10 @@ jobs: }, "ref": { "type": "string", - "sanitize": true, "maxLength": 256, "minLength": 1, - "pattern": ".*\\S.*", - "patternError": "must not be empty" + "pattern": "^[^\\x00-\\x20\\x7f~^:?*\\[\\\\]+$", + "patternError": "must be a valid git ref" }, "workflow_name": { "required": true, diff --git a/.github/workflows/smoke-copilot-aoai-entra.lock.yml b/.github/workflows/smoke-copilot-aoai-entra.lock.yml index 67b2ab3c904..46f7a78b039 100644 --- a/.github/workflows/smoke-copilot-aoai-entra.lock.yml +++ b/.github/workflows/smoke-copilot-aoai-entra.lock.yml @@ -1076,11 +1076,10 @@ jobs: }, "ref": { "type": "string", - "sanitize": true, "maxLength": 256, "minLength": 1, - "pattern": ".*\\S.*", - "patternError": "must not be empty" + "pattern": "^[^\\x00-\\x20\\x7f~^:?*\\[\\\\]+$", + "patternError": "must be a valid git ref" }, "workflow_name": { "required": true, diff --git a/.github/workflows/smoke-copilot-arm.lock.yml b/.github/workflows/smoke-copilot-arm.lock.yml index 398523d37cb..a0e040545d1 100644 --- a/.github/workflows/smoke-copilot-arm.lock.yml +++ b/.github/workflows/smoke-copilot-arm.lock.yml @@ -949,11 +949,10 @@ jobs: }, "ref": { "type": "string", - "sanitize": true, "maxLength": 256, "minLength": 1, - "pattern": ".*\\S.*", - "patternError": "must not be empty" + "pattern": "^[^\\x00-\\x20\\x7f~^:?*\\[\\\\]+$", + "patternError": "must be a valid git ref" }, "workflow_name": { "required": true, diff --git a/.github/workflows/smoke-copilot.lock.yml b/.github/workflows/smoke-copilot.lock.yml index c61248d98b1..dbcb194b9d9 100644 --- a/.github/workflows/smoke-copilot.lock.yml +++ b/.github/workflows/smoke-copilot.lock.yml @@ -1075,11 +1075,10 @@ jobs: }, "ref": { "type": "string", - "sanitize": true, "maxLength": 256, "minLength": 1, - "pattern": ".*\\S.*", - "patternError": "must not be empty" + "pattern": "^[^\\x00-\\x20\\x7f~^:?*\\[\\\\]+$", + "patternError": "must be a valid git ref" }, "workflow_name": { "required": true, diff --git a/actions/setup/js/dispatch_workflow.cjs b/actions/setup/js/dispatch_workflow.cjs index 0869928585f..fd119730247 100644 --- a/actions/setup/js/dispatch_workflow.cjs +++ b/actions/setup/js/dispatch_workflow.cjs @@ -378,7 +378,9 @@ function parseAllowedRefPatterns(allowedRefsValue) { * @returns {string} */ function normalizeRef(refOrBranch) { - return refOrBranch.startsWith("refs/") ? refOrBranch : `refs/heads/${refOrBranch}`; + if (refOrBranch.startsWith("refs/")) return refOrBranch; + if (refOrBranch.startsWith("tags/")) return `refs/${refOrBranch}`; + return `refs/heads/${refOrBranch}`; } /** @@ -386,7 +388,9 @@ function normalizeRef(refOrBranch) { * @returns {string} */ function normalizeRefPattern(pattern) { - return pattern.startsWith("refs/") ? pattern : `refs/heads/${pattern}`; + if (pattern.startsWith("refs/")) return pattern; + if (pattern.startsWith("tags/")) return `refs/${pattern}`; + return `refs/heads/${pattern}`; } module.exports = { main }; diff --git a/actions/setup/js/dispatch_workflow.test.cjs b/actions/setup/js/dispatch_workflow.test.cjs index 5747b9a5a7c..1cc2bb057b4 100644 --- a/actions/setup/js/dispatch_workflow.test.cjs +++ b/actions/setup/js/dispatch_workflow.test.cjs @@ -523,6 +523,40 @@ describe("dispatch_workflow handler factory", () => { }); }); + it("should expand tags/ prefix in message ref to refs/tags/", async () => { + process.env.GITHUB_REF = "refs/heads/main"; + delete process.env.GITHUB_HEAD_REF; + + const config = { + allowed_refs: ["tags/v*"], + workflows: ["test-workflow"], + workflow_files: { + "test-workflow": ".lock.yml", + }, + aw_context_workflows: ["test-workflow"], + }; + const handler = await main(config); + + await handler( + { + type: "dispatch_workflow", + workflow_name: "test-workflow", + ref: "tags/v1.2.3", + inputs: {}, + }, + {} + ); + + expect(github.rest.actions.createWorkflowDispatch).toHaveBeenCalledWith({ + owner: "test-owner", + repo: "test-repo", + workflow_id: "test-workflow.lock.yml", + ref: "refs/tags/v1.2.3", + inputs: expect.objectContaining({ aw_context: expect.any(String) }), + return_run_details: true, + }); + }); + it("should reject message ref when allowed-refs is not configured", async () => { process.env.GITHUB_REF = "refs/heads/main"; delete process.env.GITHUB_HEAD_REF; diff --git a/docs/src/content/docs/reference/frontmatter-full.md b/docs/src/content/docs/reference/frontmatter-full.md index 254a690238f..20369a661e3 100644 --- a/docs/src/content/docs/reference/frontmatter-full.md +++ b/docs/src/content/docs/reference/frontmatter-full.md @@ -8998,6 +8998,22 @@ safe-outputs: # (optional) target-ref: "example-value" + # List of ref glob patterns the agent is allowed to supply via message.ref at + # runtime. Branch shorthand (e.g. 'feature/*') expands to refs/heads/feature/*, + # 'tags/v*' expands to refs/tags/v*; full refs/ patterns are used as-is. When + # omitted, per-call message.ref overrides are rejected. + # Supports arrays and GitHub Actions expressions resolving to a comma-separated list. + # (optional) + # Accepted formats: + + # Format 1: array + allowed-refs: [] + # Array items: string + + # Format 2: GitHub Actions expression resolving to a comma-separated list of + # ref glob patterns (e.g. '${{ inputs['allowed-refs'] }}') + allowed-refs: "example-value" + # When true, emit step summary messages instead of making GitHub API calls for # this specific output type (preview mode) # (optional) diff --git a/docs/src/content/docs/reference/safe-outputs.md b/docs/src/content/docs/reference/safe-outputs.md index 9dda0ef5f40..a7b8a22a0ce 100644 --- a/docs/src/content/docs/reference/safe-outputs.md +++ b/docs/src/content/docs/reference/safe-outputs.md @@ -1217,6 +1217,7 @@ safe-outputs: - **`target-repo`** (optional) - Target repository in `owner/repo` format for cross-repository dispatch. - **`allowed-repos`** (optional) - Allowlist of cross-repository dispatch targets. Required when `target-repo` points to a different repository. Supports repository slugs and wildcards such as `org/*`, or a GitHub Actions expression string (e.g. `"${{ inputs['allowed-repos'] }}"`) for dynamic allowlists. - **`target-ref`** (optional) - Git ref to dispatch on. In `workflow_call` relay scenarios, the compiler injects this automatically so the dispatch uses the target repository's branch or tag instead of the caller's `GITHUB_REF`. +- **`allowed-refs`** (optional) - List of ref glob patterns that the agent is allowed to supply via `message.ref` at runtime. Supports arrays and GitHub Actions expressions resolving to a comma-separated list (e.g. `"${{ inputs['allowed-refs'] }}"`). When this field is omitted, per-call `message.ref` overrides are rejected. Branch shorthand (`feature/*`) is automatically expanded to `refs/heads/feature/*`; `tags/v*` is expanded to `refs/tags/v*`; full `refs/…` patterns are used as-is. #### Validation Rules @@ -1230,6 +1231,28 @@ At runtime, when exactly one workflow is configured, the agent may omit `workflo With `dispatch-workflow: [workflow-handler]`, that item is normalized to target `workflow-handler` automatically before validation. +#### Per-call Ref Override + +When an agent needs to dispatch CI against a branch it just created, it can supply `ref` directly in the output payload: + +```json +{ + "type": "dispatch_workflow", + "workflow_name": "ci", + "ref": "feature/my-branch", + "inputs": { "reason": "validate new branch" } +} +``` + +For `message.ref` to be accepted, `allowed-refs` must be configured. The ref is normalized before matching: bare branch names are expanded to `refs/heads/`, `tags/…` to `refs/tags/…`, and full `refs/…` values are used as-is. Dispatches with a `message.ref` that does not match any pattern in `allowed-refs` are rejected at runtime. + +Ref resolution priority: +1. `message.ref` (highest — per-call override, requires `allowed-refs`) +2. `target-ref` from configuration +3. `GITHUB_HEAD_REF` (PR head branch) +4. `GITHUB_REF` or `context.ref` (push/default branch) +5. target repository default branch + #### Defining Workflow Inputs Define `workflow_dispatch` inputs in the target workflow so the agent can provide values when dispatching: diff --git a/pkg/workflow/safe_outputs_validation_config.go b/pkg/workflow/safe_outputs_validation_config.go index 19fb1844749..7537adf311b 100644 --- a/pkg/workflow/safe_outputs_validation_config.go +++ b/pkg/workflow/safe_outputs_validation_config.go @@ -312,7 +312,7 @@ var ValidationConfig = map[string]TypeValidationConfig{ Fields: map[string]FieldValidation{ "workflow_name": {Required: true, Type: "string", Sanitize: true, MinLength: 1, MaxLength: 256, Pattern: ".*\\S.*", PatternError: "must not be empty"}, "inputs": {Type: "object"}, - "ref": {Type: "string", Sanitize: true, MinLength: 1, MaxLength: 256, Pattern: ".*\\S.*", PatternError: "must not be empty"}, + "ref": {Type: "string", MinLength: 1, MaxLength: 256, Pattern: "^[^\\x00-\\x20\\x7f~^:?*\\[\\\\]+$", PatternError: "must be a valid git ref"}, }, }, "missing_tool": { From addc45c44b447387ce55adbf2965b6f9ec0f7259 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 1 Aug 2026 02:23:06 +0000 Subject: [PATCH 5/5] fix(dispatch-workflow): add non-string ref warning and tests for empty allowed-refs, bare-branch rejection Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- actions/setup/js/dispatch_workflow.cjs | 3 + actions/setup/js/dispatch_workflow.test.cjs | 92 +++++++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/actions/setup/js/dispatch_workflow.cjs b/actions/setup/js/dispatch_workflow.cjs index fd119730247..3c049eb04a6 100644 --- a/actions/setup/js/dispatch_workflow.cjs +++ b/actions/setup/js/dispatch_workflow.cjs @@ -180,6 +180,9 @@ async function main(config = {}) { core.info(`Dispatching workflow: ${workflowName}`); + if (message.ref !== undefined && message.ref !== null && typeof message.ref !== "string") { + core.warning(`message.ref must be a string; ignoring non-string value (type: ${typeof message.ref})`); + } const outputRef = typeof message.ref === "string" ? message.ref.trim() : ""; let ref = defaultRef; if (outputRef) { diff --git a/actions/setup/js/dispatch_workflow.test.cjs b/actions/setup/js/dispatch_workflow.test.cjs index 1cc2bb057b4..a48be3d1422 100644 --- a/actions/setup/js/dispatch_workflow.test.cjs +++ b/actions/setup/js/dispatch_workflow.test.cjs @@ -618,6 +618,98 @@ describe("dispatch_workflow handler factory", () => { expect(github.rest.actions.createWorkflowDispatch).not.toHaveBeenCalled(); }); + it("should reject message ref when allowed-refs is an empty array", async () => { + process.env.GITHUB_REF = "refs/heads/main"; + delete process.env.GITHUB_HEAD_REF; + + const config = { + allowed_refs: [], + workflows: ["test-workflow"], + workflow_files: { + "test-workflow": ".lock.yml", + }, + }; + const handler = await main(config); + + const result = await handler( + { + type: "dispatch_workflow", + workflow_name: "test-workflow", + ref: "some-branch", + inputs: {}, + }, + {} + ); + + expect(result).toEqual({ + success: false, + error: "message.ref is not allowed unless 'allowed-refs' is configured in safe-outputs.dispatch-workflow", + }); + expect(github.rest.actions.createWorkflowDispatch).not.toHaveBeenCalled(); + }); + + it("should warn and fall back to default ref when message.ref is a non-string", async () => { + process.env.GITHUB_REF = "refs/heads/main"; + delete process.env.GITHUB_HEAD_REF; + + const config = { + allowed_refs: ["refs/heads/*"], + workflows: ["test-workflow"], + workflow_files: { + "test-workflow": ".lock.yml", + }, + }; + const handler = await main(config); + + const result = await handler( + { + type: "dispatch_workflow", + workflow_name: "test-workflow", + ref: 42, + inputs: {}, + }, + {} + ); + + expect(result.success).toBe(true); + expect(core.warning).toHaveBeenCalledWith(expect.stringContaining("non-string")); + expect(github.rest.actions.createWorkflowDispatch).toHaveBeenCalledWith( + expect.objectContaining({ + ref: "refs/heads/main", + }) + ); + }); + + it("should reject bare branch name when allowed-refs only permits tags", async () => { + process.env.GITHUB_REF = "refs/heads/main"; + delete process.env.GITHUB_HEAD_REF; + + const config = { + allowed_refs: ["refs/tags/*"], + workflows: ["test-workflow"], + workflow_files: { + "test-workflow": ".lock.yml", + }, + }; + const handler = await main(config); + + const result = await handler( + { + type: "dispatch_workflow", + workflow_name: "test-workflow", + ref: "main", + inputs: {}, + }, + {} + ); + + expect(result).toEqual({ + success: false, + error: "Ref 'refs/heads/main' is not in allowed-refs: refs/tags/*", + }); + expect(github.rest.actions.createWorkflowDispatch).not.toHaveBeenCalled(); + }); + it("should handle PR context with slashes in branch names", async () => { process.env.GITHUB_REF = "refs/pull/456/merge"; process.env.GITHUB_HEAD_REF = "feature/add-new-feature";