From 79174acfed318b372caa7198fb34faf6efde69c9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:30:23 +0000 Subject: [PATCH 01/10] Support templatable staged safe outputs Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/create_pr_review_comment.cjs | 2 +- actions/setup/js/handler_scaffold.test.cjs | 14 ++ actions/setup/js/safe_output_helpers.cjs | 2 +- actions/setup/js/submit_pr_review.cjs | 2 +- actions/setup/js/upload_artifact.cjs | 2 +- pkg/parser/schema_test.go | 20 ++ pkg/parser/schemas/main_workflow_schema.json | 198 +++++++++--------- pkg/workflow/compiler_safe_output_jobs.go | 2 +- .../compiler_safe_outputs_config_test.go | 69 ++++-- .../compiler_safe_outputs_env_test.go | 54 +++-- pkg/workflow/compiler_safe_outputs_job.go | 18 +- .../compiler_safe_outputs_job_test.go | 4 +- pkg/workflow/compiler_types.go | 4 +- pkg/workflow/compiler_yaml.go | 4 +- pkg/workflow/dispatch_repository.go | 18 +- pkg/workflow/frontmatter_types_test.go | 2 +- pkg/workflow/imports.go | 2 +- pkg/workflow/safe_output_handlers.go | 32 ++- pkg/workflow/safe_output_helpers_test.go | 10 +- .../safe_outputs_allow_workflows_test.go | 2 +- pkg/workflow/safe_outputs_config.go | 22 +- .../safe_outputs_config_generation_test.go | 2 +- .../safe_outputs_config_helpers_test.go | 16 +- pkg/workflow/safe_outputs_env.go | 25 ++- pkg/workflow/safe_outputs_handler_registry.go | 96 ++++----- pkg/workflow/safe_outputs_import_test.go | 4 +- pkg/workflow/safe_outputs_permissions.go | 4 +- pkg/workflow/safe_outputs_permissions_test.go | 30 +-- pkg/workflow/safe_outputs_runtime.go | 4 +- pkg/workflow/safe_outputs_test.go | 2 +- pkg/workflow/staged_awinfo_test.go | 4 +- pkg/workflow/staged_test.go | 16 +- pkg/workflow/templatables.go | 69 ++++++ pkg/workflow/test_helpers_shared_test.go | 6 + 34 files changed, 485 insertions(+), 276 deletions(-) diff --git a/actions/setup/js/create_pr_review_comment.cjs b/actions/setup/js/create_pr_review_comment.cjs index 431ece890a2..80ba0cc5565 100644 --- a/actions/setup/js/create_pr_review_comment.cjs +++ b/actions/setup/js/create_pr_review_comment.cjs @@ -59,7 +59,7 @@ async function main(config = {}) { } // Propagate per-handler staged flag to the shared PR review buffer - if (config.staged === true) { + if (config.staged === true || config.staged === "true") { buffer.setStaged(true); } if (isStagedMode(config)) { diff --git a/actions/setup/js/handler_scaffold.test.cjs b/actions/setup/js/handler_scaffold.test.cjs index 6c446456f9d..5c6acaa96db 100644 --- a/actions/setup/js/handler_scaffold.test.cjs +++ b/actions/setup/js/handler_scaffold.test.cjs @@ -75,6 +75,20 @@ describe("handler_scaffold", () => { expect(setupSpy).toHaveBeenCalledWith(config, 5, true); }); + it('should pass isStaged as true when config.staged is the string "true"', async () => { + const setupSpy = vi.fn().mockResolvedValue(async () => ({ success: true })); + + const factory = createCountGatedHandler({ + handlerType: "test_handler", + setup: setupSpy, + }); + + const config = { max: 5, staged: "true" }; + await factory(config); + + expect(setupSpy).toHaveBeenCalledWith(config, 5, true); + }); + it("should delegate to handleItem when under the limit", async () => { const handleItem = vi.fn().mockResolvedValue({ success: true, data: "result" }); diff --git a/actions/setup/js/safe_output_helpers.cjs b/actions/setup/js/safe_output_helpers.cjs index c89ab62d2b9..b93050cce8c 100644 --- a/actions/setup/js/safe_output_helpers.cjs +++ b/actions/setup/js/safe_output_helpers.cjs @@ -429,7 +429,7 @@ function loadCustomSafeOutputActionHandlers() { * @returns {boolean} */ function isStagedMode(config) { - return process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true" || (config != null && config.staged === true); + return process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true" || (config != null && (config.staged === true || config.staged === "true")); } /** diff --git a/actions/setup/js/submit_pr_review.cjs b/actions/setup/js/submit_pr_review.cjs index 2fffdcda07e..effeadb0763 100644 --- a/actions/setup/js/submit_pr_review.cjs +++ b/actions/setup/js/submit_pr_review.cjs @@ -60,7 +60,7 @@ async function main(config = {}) { } // Propagate per-handler staged flag to the shared PR review buffer - if (config.staged === true) { + if (config.staged === true || config.staged === "true") { buffer.setStaged(true); } if (isStagedMode(config)) { diff --git a/actions/setup/js/upload_artifact.cjs b/actions/setup/js/upload_artifact.cjs index b1335e80c77..90593187c7f 100644 --- a/actions/setup/js/upload_artifact.cjs +++ b/actions/setup/js/upload_artifact.cjs @@ -449,7 +449,7 @@ async function main(config = {}) { const allowedPaths = Array.isArray(config["allowed-paths"]) ? config["allowed-paths"] : []; const filtersInclude = Array.isArray(config["filters-include"]) ? config["filters-include"] : []; const filtersExclude = Array.isArray(config["filters-exclude"]) ? config["filters-exclude"] : []; - const isStaged = config["staged"] === true || process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; + const isStaged = config["staged"] === true || config["staged"] === "true" || process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; core.info(`upload_artifact handler: max_uploads=${maxUploads}, retention_days=${retentionDays}, skip_archive=${skipArchive}`); core.info(`Allowed paths: ${allowedPaths.length > 0 ? allowedPaths.join(", ") : "(none – all staging files allowed)"}`); diff --git a/pkg/parser/schema_test.go b/pkg/parser/schema_test.go index 7d81b1deb7b..478ee52c9c9 100644 --- a/pkg/parser/schema_test.go +++ b/pkg/parser/schema_test.go @@ -578,6 +578,7 @@ func TestValidateMainWorkflowFrontmatterWithSchemaAndLocation_ThreatDetectionMax }, }, } + err := ValidateMainWorkflowFrontmatterWithSchemaAndLocation(validFrontmatter, "/tmp/gh-aw/threat-detection-max-ai-credits-"+name+"-test.md") if err != nil { t.Fatalf("expected safe-outputs.threat-detection.max-ai-credits=%v (%s) to pass schema validation, got: %v", raw, name, err) @@ -586,6 +587,25 @@ func TestValidateMainWorkflowFrontmatterWithSchemaAndLocation_ThreatDetectionMax } } +func TestValidateMainWorkflowFrontmatterWithSchemaAndLocation_SafeOutputsStagedExpressionAccepted(t *testing.T) { + t.Parallel() + + validFrontmatter := map[string]any{ + "on": "push", + "safe-outputs": map[string]any{ + "staged": "${{ inputs.staged }}", + "create-issue": map[string]any{ + "staged": "${{ inputs.issue-staged }}", + }, + }, + } + + err := ValidateMainWorkflowFrontmatterWithSchemaAndLocation(validFrontmatter, "/tmp/gh-aw/safe-outputs-staged-expression-test.md") + if err != nil { + t.Fatalf("expected safe-outputs staged expressions to pass schema validation, got: %v", err) + } +} + func TestValidateMainWorkflowFrontmatterWithSchemaAndLocation_MaxAICreditsZeroInvalid(t *testing.T) { t.Parallel() diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 3c1211b2923..0e425654458 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -4412,8 +4412,8 @@ "description": "GitHub token to use for comment-memory operations. Overrides global github-token if specified." }, "staged": { - "type": "boolean", - "description": "If true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", + "$ref": "#/$defs/templatable_boolean", + "description": "When true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", "examples": [true, false] }, "samples": { @@ -5031,8 +5031,8 @@ "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, "staged": { - "type": "boolean", - "description": "If true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", + "$ref": "#/$defs/templatable_boolean", + "description": "When true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", "examples": [true, false] }, "samples": { @@ -5130,8 +5130,8 @@ "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, "staged": { - "type": "boolean", - "description": "If true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", + "$ref": "#/$defs/templatable_boolean", + "description": "When true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", "examples": [true, false] }, "samples": { @@ -5201,8 +5201,8 @@ "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, "staged": { - "type": "boolean", - "description": "If true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", + "$ref": "#/$defs/templatable_boolean", + "description": "When true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", "examples": [true, false] }, "samples": { @@ -5358,8 +5358,8 @@ } }, "staged": { - "type": "boolean", - "description": "If true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", + "$ref": "#/$defs/templatable_boolean", + "description": "When true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", "examples": [true, false] }, "samples": { @@ -5494,8 +5494,8 @@ } }, "staged": { - "type": "boolean", - "description": "If true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", + "$ref": "#/$defs/templatable_boolean", + "description": "When true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", "examples": [true, false] }, "samples": { @@ -5560,8 +5560,8 @@ "examples": ["https://github.com/orgs/myorg/projects/123", "https://github.com/users/username/projects/456"] }, "staged": { - "type": "boolean", - "description": "If true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", + "$ref": "#/$defs/templatable_boolean", + "description": "When true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", "examples": [true, false] }, "samples": { @@ -5710,8 +5710,8 @@ "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, "staged": { - "type": "boolean", - "description": "If true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", + "$ref": "#/$defs/templatable_boolean", + "description": "When true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", "examples": [true, false] }, "samples": { @@ -5826,8 +5826,8 @@ "description": "List of additional repositories in format 'owner/repo' that discussions can be closed in. The target repository is always implicitly allowed." }, "staged": { - "type": "boolean", - "description": "If true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", + "$ref": "#/$defs/templatable_boolean", + "description": "When true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", "examples": [true, false] }, "samples": { @@ -5926,8 +5926,8 @@ "default": true }, "staged": { - "type": "boolean", - "description": "If true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", + "$ref": "#/$defs/templatable_boolean", + "description": "When true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", "examples": [true, false] }, "samples": { @@ -6014,8 +6014,8 @@ "description": "List of additional repositories in format 'owner/repo' that issues can be closed in. When specified, the agent can use a 'repo' field in the output to specify which repository to close the issue in. The target repository (current or target-repo) is always implicitly allowed." }, "staged": { - "type": "boolean", - "description": "If true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", + "$ref": "#/$defs/templatable_boolean", + "description": "When true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", "examples": [true, false] }, "samples": { @@ -6117,8 +6117,8 @@ "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, "staged": { - "type": "boolean", - "description": "If true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", + "$ref": "#/$defs/templatable_boolean", + "description": "When true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", "examples": [true, false] }, "samples": { @@ -6214,8 +6214,8 @@ "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, "staged": { - "type": "boolean", - "description": "If true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", + "$ref": "#/$defs/templatable_boolean", + "description": "When true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", "examples": [true, false] }, "samples": { @@ -6367,8 +6367,8 @@ "description": "Title prefix constraint: the issue/PR title must start with this prefix for the operation to proceed." }, "staged": { - "type": "boolean", - "description": "If true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", + "$ref": "#/$defs/templatable_boolean", + "description": "When true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", "examples": [true, false] }, "samples": { @@ -6762,8 +6762,8 @@ "default": true }, "staged": { - "type": "boolean", - "description": "If true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", + "$ref": "#/$defs/templatable_boolean", + "description": "When true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", "examples": [true, false] }, "samples": { @@ -6862,8 +6862,8 @@ "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, "staged": { - "type": "boolean", - "description": "If true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", + "$ref": "#/$defs/templatable_boolean", + "description": "When true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", "examples": [true, false] }, "samples": { @@ -6971,8 +6971,8 @@ "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, "staged": { - "type": "boolean", - "description": "If true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", + "$ref": "#/$defs/templatable_boolean", + "description": "When true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", "examples": [true, false] }, "samples": { @@ -7058,8 +7058,8 @@ "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, "staged": { - "type": "boolean", - "description": "If true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", + "$ref": "#/$defs/templatable_boolean", + "description": "When true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", "examples": [true, false] }, "samples": { @@ -7140,8 +7140,8 @@ "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, "staged": { - "type": "boolean", - "description": "If true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", + "$ref": "#/$defs/templatable_boolean", + "description": "When true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", "examples": [true, false] }, "samples": { @@ -7221,8 +7221,8 @@ "description": "List of additional repositories in format 'owner/repo' that code scanning alerts can be created in. When specified, the agent can use a 'repo' field in the output to specify which repository to create the alert in. The target repository (current or target-repo) is always implicitly allowed." }, "staged": { - "type": "boolean", - "description": "If true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", + "$ref": "#/$defs/templatable_boolean", + "description": "When true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", "examples": [true, false] }, "samples": { @@ -7276,8 +7276,8 @@ "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, "staged": { - "type": "boolean", - "description": "If true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", + "$ref": "#/$defs/templatable_boolean", + "description": "When true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", "examples": [true, false] }, "samples": { @@ -7335,8 +7335,8 @@ "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, "staged": { - "type": "boolean", - "description": "If true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", + "$ref": "#/$defs/templatable_boolean", + "description": "When true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", "examples": [true, false] }, "samples": { @@ -7461,8 +7461,8 @@ "description": "Title prefix constraint: the issue/PR title must start with this prefix for the operation to proceed." }, "staged": { - "type": "boolean", - "description": "If true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", + "$ref": "#/$defs/templatable_boolean", + "description": "When true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", "examples": [true, false] }, "samples": { @@ -7562,8 +7562,8 @@ "description": "Title prefix constraint: the issue/PR title must start with this prefix for the operation to proceed." }, "staged": { - "type": "boolean", - "description": "If true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", + "$ref": "#/$defs/templatable_boolean", + "description": "When true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", "examples": [true, false] }, "samples": { @@ -7686,8 +7686,8 @@ "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, "staged": { - "type": "boolean", - "description": "If true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", + "$ref": "#/$defs/templatable_boolean", + "description": "When true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", "examples": [true, false] }, "samples": { @@ -7776,8 +7776,8 @@ "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, "staged": { - "type": "boolean", - "description": "If true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", + "$ref": "#/$defs/templatable_boolean", + "description": "When true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", "examples": [true, false] }, "samples": { @@ -7900,8 +7900,8 @@ "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, "staged": { - "type": "boolean", - "description": "If true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", + "$ref": "#/$defs/templatable_boolean", + "description": "When true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", "examples": [true, false] }, "samples": { @@ -7989,8 +7989,8 @@ "description": "List of allowed repositories in format 'owner/repo' for cross-repository user assignment operations. Use with 'repo' field in tool calls." }, "staged": { - "type": "boolean", - "description": "If true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", + "$ref": "#/$defs/templatable_boolean", + "description": "When true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", "examples": [true, false] }, "samples": { @@ -8084,8 +8084,8 @@ "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, "staged": { - "type": "boolean", - "description": "If true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", + "$ref": "#/$defs/templatable_boolean", + "description": "When true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", "examples": [true, false] }, "samples": { @@ -8192,8 +8192,8 @@ "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, "staged": { - "type": "boolean", - "description": "If true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", + "$ref": "#/$defs/templatable_boolean", + "description": "When true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", "examples": [true, false] }, "samples": { @@ -8281,8 +8281,8 @@ "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, "staged": { - "type": "boolean", - "description": "If true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", + "$ref": "#/$defs/templatable_boolean", + "description": "When true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", "examples": [true, false] }, "samples": { @@ -8385,8 +8385,8 @@ "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, "staged": { - "type": "boolean", - "description": "If true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", + "$ref": "#/$defs/templatable_boolean", + "description": "When true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", "examples": [true, false] }, "samples": { @@ -8495,8 +8495,8 @@ "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, "staged": { - "type": "boolean", - "description": "If true, evaluate merge gates and emit preview results without executing the merge API call.", + "$ref": "#/$defs/templatable_boolean", + "description": "When true, evaluate merge gates and emit preview results without executing the merge API call.", "examples": [true, false] }, "samples": { @@ -8630,8 +8630,8 @@ "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, "staged": { - "type": "boolean", - "description": "If true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", + "$ref": "#/$defs/templatable_boolean", + "description": "When true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", "examples": [true, false] }, "samples": { @@ -8831,8 +8831,8 @@ "description": "Controls whether the workflow requests discussions:write permission for hide-comment. Default: true (includes discussions:write). Set to false if your GitHub App lacks Discussions permission to prevent 422 errors during token generation." }, "staged": { - "type": "boolean", - "description": "If true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", + "$ref": "#/$defs/templatable_boolean", + "description": "When true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", "examples": [true, false] }, "samples": { @@ -8921,8 +8921,8 @@ "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, "staged": { - "type": "boolean", - "description": "If true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", + "$ref": "#/$defs/templatable_boolean", + "description": "When true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", "examples": [true, false] }, "samples": { @@ -9009,8 +9009,8 @@ "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, "staged": { - "type": "boolean", - "description": "If true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", + "$ref": "#/$defs/templatable_boolean", + "description": "When true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", "examples": [true, false] }, "samples": { @@ -9107,8 +9107,8 @@ "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." }, "staged": { - "type": "boolean", - "description": "If true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", + "$ref": "#/$defs/templatable_boolean", + "description": "When true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", "examples": [true, false] }, "samples": { @@ -9233,8 +9233,8 @@ "description": "GitHub token to use for dispatching. Overrides global github-token if specified." }, "staged": { - "type": "boolean", - "description": "If true, emit step summary messages instead of making GitHub API calls (preview mode)", + "$ref": "#/$defs/templatable_boolean", + "description": "When true, emit step summary messages instead of making GitHub API calls (preview mode)", "examples": [true, false] } }, @@ -9279,8 +9279,8 @@ "description": "GitHub token passed to called workflows. Overrides global github-token if specified." }, "staged": { - "type": "boolean", - "description": "If true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", + "$ref": "#/$defs/templatable_boolean", + "description": "When true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", "examples": [true, false] }, "samples": { @@ -9359,8 +9359,8 @@ "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, "staged": { - "type": "boolean", - "description": "If true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", + "$ref": "#/$defs/templatable_boolean", + "description": "When true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", "examples": [true, false] }, "samples": { @@ -9437,8 +9437,8 @@ "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, "staged": { - "type": "boolean", - "description": "If true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", + "$ref": "#/$defs/templatable_boolean", + "description": "When true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", "examples": [true, false] }, "samples": { @@ -9503,8 +9503,8 @@ "default": true }, "staged": { - "type": "boolean", - "description": "If true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", + "$ref": "#/$defs/templatable_boolean", + "description": "When true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", "examples": [true, false] }, "samples": { @@ -9584,8 +9584,8 @@ "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, "staged": { - "type": "boolean", - "description": "If true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", + "$ref": "#/$defs/templatable_boolean", + "description": "When true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", "examples": [true, false] }, "samples": { @@ -9705,8 +9705,8 @@ "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, "staged": { - "type": "boolean", - "description": "If true, emit step summary messages instead of making GitHub Actions artifact uploads (preview mode)", + "$ref": "#/$defs/templatable_boolean", + "description": "When true, emit step summary messages instead of making GitHub Actions artifact uploads (preview mode)", "examples": [true, false] } }, @@ -9752,8 +9752,8 @@ "default": true }, "staged": { - "type": "boolean", - "description": "If true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", + "$ref": "#/$defs/templatable_boolean", + "description": "When true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", "examples": [true, false] }, "samples": { @@ -9783,8 +9783,8 @@ "description": "Enable AI agents to edit and update GitHub release content, including release notes, assets, and metadata." }, "staged": { - "type": "boolean", - "description": "If true, emit step summary messages instead of making GitHub API calls (preview mode)", + "$ref": "#/$defs/templatable_boolean", + "description": "When true, emit step summary messages instead of making GitHub API calls (preview mode)", "examples": [true, false] }, "env": { @@ -10516,8 +10516,8 @@ "description": "GitHub token to use for this specific output type. Overrides global github-token if specified." }, "staged": { - "type": "boolean", - "description": "If true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", + "$ref": "#/$defs/templatable_boolean", + "description": "When true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", "examples": [true, false] }, "samples": { @@ -10563,7 +10563,7 @@ "properties": { "allowed-transitions": { "type": "array", - "description": "Optional list of allowed label state transitions. Each entry specifies a (from, to) pair that is permitted. When specified, the agent may ONLY perform the listed transitions, regardless of allowed-add/allowed-remove. Useful for enforcing a strict state machine (e.g. only 'in-review' → 'approved' is allowed).", + "description": "Optional list of allowed label state transitions. Each entry specifies a (from, to) pair that is permitted. When specified, the agent may ONLY perform the listed transitions, regardless of allowed-add/allowed-remove. Useful for enforcing a strict state machine (e.g. only 'in-review' \u2192 'approved' is allowed).", "items": { "type": "object", "properties": { @@ -10656,8 +10656,8 @@ "description": "Title prefix constraint: the issue/PR title must start with this prefix for the operation to proceed." }, "staged": { - "type": "boolean", - "description": "If true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", + "$ref": "#/$defs/templatable_boolean", + "description": "When true, emit step summary messages instead of making GitHub API calls for this specific output type (preview mode)", "examples": [true, false] }, "samples": { diff --git a/pkg/workflow/compiler_safe_output_jobs.go b/pkg/workflow/compiler_safe_output_jobs.go index 5cadadb1830..541f620c469 100644 --- a/pkg/workflow/compiler_safe_output_jobs.go +++ b/pkg/workflow/compiler_safe_output_jobs.go @@ -96,7 +96,7 @@ func (c *Compiler) buildSafeOutputsJobs(data *WorkflowData, jobName, markdownPat // It is separate to avoid the checkout step (needed to restore HEAD to github.sha) from // interfering with other safe-output operations in the consolidated safe_outputs job. if data.SafeOutputs != nil && data.SafeOutputs.CreateCodeScanningAlerts != nil && - !isHandlerStaged(false || data.SafeOutputs.Staged, data.SafeOutputs.CreateCodeScanningAlerts.Staged) { + !isHandlerStaged(templatableBoolIsTrue(data.SafeOutputs.Staged), data.SafeOutputs.CreateCodeScanningAlerts.Staged) { compilerSafeOutputJobsLog.Print("Building separate upload_code_scanning_sarif job") codeScanningJob, err := c.buildCodeScanningUploadJob(data) if err != nil { diff --git a/pkg/workflow/compiler_safe_outputs_config_test.go b/pkg/workflow/compiler_safe_outputs_config_test.go index eedc6916fca..6480a4ba895 100644 --- a/pkg/workflow/compiler_safe_outputs_config_test.go +++ b/pkg/workflow/compiler_safe_outputs_config_test.go @@ -188,7 +188,7 @@ func TestAddHandlerManagerConfigEnvVar(t *testing.T) { safeOutputs: &SafeOutputsConfig{ PushToPullRequestBranch: &PushToPullRequestBranchConfig{ BaseSafeOutputConfig: BaseSafeOutputConfig{ - Staged: true, + Staged: templatableBoolPtr("true"), }, Target: "*", IfNoChanges: "warn", @@ -206,7 +206,7 @@ func TestAddHandlerManagerConfigEnvVar(t *testing.T) { ClosePullRequests: &ClosePullRequestsConfig{ BaseSafeOutputConfig: BaseSafeOutputConfig{ Max: strPtr("1"), - Staged: true, + Staged: templatableBoolPtr("true"), }, }, }, @@ -2388,7 +2388,7 @@ func TestHandlerConfigStagedMode(t *testing.T) { safeOutputs: &SafeOutputsConfig{ PushToPullRequestBranch: &PushToPullRequestBranchConfig{ BaseSafeOutputConfig: BaseSafeOutputConfig{ - Staged: true, + Staged: templatableBoolPtr("true"), }, Target: "*", IfNoChanges: "warn", @@ -2402,7 +2402,7 @@ func TestHandlerConfigStagedMode(t *testing.T) { ClosePullRequests: &ClosePullRequestsConfig{ BaseSafeOutputConfig: BaseSafeOutputConfig{ Max: strPtr("1"), - Staged: true, + Staged: templatableBoolPtr("true"), }, }, }, @@ -2413,7 +2413,7 @@ func TestHandlerConfigStagedMode(t *testing.T) { safeOutputs: &SafeOutputsConfig{ CreateIssues: &CreateIssuesConfig{ BaseSafeOutputConfig: BaseSafeOutputConfig{ - Staged: true, + Staged: templatableBoolPtr("true"), }, }, }, @@ -2424,7 +2424,7 @@ func TestHandlerConfigStagedMode(t *testing.T) { safeOutputs: &SafeOutputsConfig{ AddComments: &AddCommentsConfig{ BaseSafeOutputConfig: BaseSafeOutputConfig{ - Staged: true, + Staged: templatableBoolPtr("true"), }, }, }, @@ -2435,7 +2435,7 @@ func TestHandlerConfigStagedMode(t *testing.T) { safeOutputs: &SafeOutputsConfig{ CreatePullRequests: &CreatePullRequestsConfig{ BaseSafeOutputConfig: BaseSafeOutputConfig{ - Staged: true, + Staged: templatableBoolPtr("true"), }, }, }, @@ -2447,7 +2447,7 @@ func TestHandlerConfigStagedMode(t *testing.T) { UpdateIssues: &UpdateIssuesConfig{ UpdateEntityConfig: UpdateEntityConfig{ BaseSafeOutputConfig: BaseSafeOutputConfig{ - Staged: true, + Staged: templatableBoolPtr("true"), }, }, }, @@ -2460,7 +2460,7 @@ func TestHandlerConfigStagedMode(t *testing.T) { UpdatePullRequests: &UpdatePullRequestsConfig{ UpdateEntityConfig: UpdateEntityConfig{ BaseSafeOutputConfig: BaseSafeOutputConfig{ - Staged: true, + Staged: templatableBoolPtr("true"), }, }, }, @@ -2473,7 +2473,7 @@ func TestHandlerConfigStagedMode(t *testing.T) { UpdateDiscussions: &UpdateDiscussionsConfig{ UpdateEntityConfig: UpdateEntityConfig{ BaseSafeOutputConfig: BaseSafeOutputConfig{ - Staged: true, + Staged: templatableBoolPtr("true"), }, }, }, @@ -2485,7 +2485,7 @@ func TestHandlerConfigStagedMode(t *testing.T) { safeOutputs: &SafeOutputsConfig{ AddLabels: &AddLabelsConfig{ BaseSafeOutputConfig: BaseSafeOutputConfig{ - Staged: true, + Staged: templatableBoolPtr("true"), }, }, }, @@ -2496,7 +2496,7 @@ func TestHandlerConfigStagedMode(t *testing.T) { safeOutputs: &SafeOutputsConfig{ DispatchWorkflow: &DispatchWorkflowConfig{ BaseSafeOutputConfig: BaseSafeOutputConfig{ - Staged: true, + Staged: templatableBoolPtr("true"), }, Workflows: []string{"my-workflow"}, }, @@ -2508,7 +2508,7 @@ func TestHandlerConfigStagedMode(t *testing.T) { safeOutputs: &SafeOutputsConfig{ CallWorkflow: &CallWorkflowConfig{ BaseSafeOutputConfig: BaseSafeOutputConfig{ - Staged: true, + Staged: templatableBoolPtr("true"), }, Workflows: []string{"my-workflow"}, }, @@ -2552,10 +2552,53 @@ func TestHandlerConfigStagedMode(t *testing.T) { assert.Equal(t, true, stagedVal, "staged field should be true") } } + }) } } +func TestHandlerConfigStagedExpression(t *testing.T) { + compiler := NewCompiler() + workflowData := &WorkflowData{ + Name: "Test Workflow", + SafeOutputs: &SafeOutputsConfig{ + CreateIssues: &CreateIssuesConfig{ + BaseSafeOutputConfig: BaseSafeOutputConfig{ + Staged: templatableBoolPtr("${{ inputs.staged }}"), + }, + }, + }, + } + + var steps []string + compiler.addHandlerManagerConfigEnvVar(&steps, workflowData) + require.NotEmpty(t, steps, "Steps should not be empty") + + for _, step := range steps { + if !strings.Contains(step, "GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG") { + continue + } + + parts := strings.Split(step, "GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: ") + require.Len(t, parts, 2, "Should have two parts") + + jsonStr := strings.TrimSpace(parts[1]) + jsonStr = strings.Trim(jsonStr, "\"") + jsonStr = strings.ReplaceAll(jsonStr, "\\\"", "\"") + + var config map[string]map[string]any + err := json.Unmarshal([]byte(jsonStr), &config) + require.NoError(t, err, "Handler config JSON should be valid") + + handlerConfig, ok := config["create_issue"] + require.True(t, ok, "Should have create_issue handler") + assert.Equal(t, "${{ inputs.staged }}", handlerConfig["staged"]) + return + } + + t.Fatal("GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG not found") +} + // TestAddHandlerManagerConfigEnvVar_CallWorkflow asserts that GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG // contains call_workflow, workflows, and workflow_files when SafeOutputs.CallWorkflow is configured. func TestAddHandlerManagerConfigEnvVar_CallWorkflow(t *testing.T) { diff --git a/pkg/workflow/compiler_safe_outputs_env_test.go b/pkg/workflow/compiler_safe_outputs_env_test.go index 75e4849eee8..3dd94ed7930 100644 --- a/pkg/workflow/compiler_safe_outputs_env_test.go +++ b/pkg/workflow/compiler_safe_outputs_env_test.go @@ -23,7 +23,7 @@ func TestAddAllSafeOutputConfigEnvVars(t *testing.T) { { name: "create issues with staged flag", safeOutputs: &SafeOutputsConfig{ - Staged: true, + Staged: templatableBoolPtr("true"), CreateIssues: &CreateIssuesConfig{ TitlePrefix: "[Test] ", }, @@ -35,7 +35,7 @@ func TestAddAllSafeOutputConfigEnvVars(t *testing.T) { { name: "create issues without staged flag", safeOutputs: &SafeOutputsConfig{ - Staged: false, + Staged: templatableBoolPtr("false"), CreateIssues: &CreateIssuesConfig{ TitlePrefix: "[Test] ", }, @@ -44,10 +44,22 @@ func TestAddAllSafeOutputConfigEnvVars(t *testing.T) { "GH_AW_SAFE_OUTPUTS_STAGED", }, }, + { + name: "create issues with staged expression", + safeOutputs: &SafeOutputsConfig{ + Staged: templatableBoolPtr("${{ inputs.staged }}"), + CreateIssues: &CreateIssuesConfig{ + TitlePrefix: "[Test] ", + }, + }, + checkContains: []string{ + "GH_AW_SAFE_OUTPUTS_STAGED: ${{ inputs.staged }}", + }, + }, { name: "add comments with staged flag", safeOutputs: &SafeOutputsConfig{ - Staged: true, + Staged: templatableBoolPtr("true"), AddComments: &AddCommentsConfig{ BaseSafeOutputConfig: BaseSafeOutputConfig{ Max: strPtr("5"), @@ -61,7 +73,7 @@ func TestAddAllSafeOutputConfigEnvVars(t *testing.T) { { name: "add labels with staged flag", safeOutputs: &SafeOutputsConfig{ - Staged: true, + Staged: templatableBoolPtr("true"), AddLabels: &AddLabelsConfig{ Allowed: []string{"bug"}, }, @@ -73,7 +85,7 @@ func TestAddAllSafeOutputConfigEnvVars(t *testing.T) { { name: "update issues with staged flag", safeOutputs: &SafeOutputsConfig{ - Staged: true, + Staged: templatableBoolPtr("true"), UpdateIssues: &UpdateIssuesConfig{}, }, checkContains: []string{ @@ -83,7 +95,7 @@ func TestAddAllSafeOutputConfigEnvVars(t *testing.T) { { name: "update discussions with staged flag", safeOutputs: &SafeOutputsConfig{ - Staged: true, + Staged: templatableBoolPtr("true"), UpdateDiscussions: &UpdateDiscussionsConfig{}, }, checkContains: []string{ @@ -93,7 +105,7 @@ func TestAddAllSafeOutputConfigEnvVars(t *testing.T) { { name: "create pull requests with staged flag", safeOutputs: &SafeOutputsConfig{ - Staged: true, + Staged: templatableBoolPtr("true"), CreatePullRequests: &CreatePullRequestsConfig{ TitlePrefix: "[PR] ", }, @@ -105,7 +117,7 @@ func TestAddAllSafeOutputConfigEnvVars(t *testing.T) { { name: "multiple types only add staged flag once", safeOutputs: &SafeOutputsConfig{ - Staged: true, + Staged: templatableBoolPtr("true"), CreateIssues: &CreateIssuesConfig{ TitlePrefix: "[Issue] ", }, @@ -123,7 +135,7 @@ func TestAddAllSafeOutputConfigEnvVars(t *testing.T) { name: "trial mode does not add staged flag", trialMode: true, safeOutputs: &SafeOutputsConfig{ - Staged: true, + Staged: templatableBoolPtr("true"), CreateIssues: &CreateIssuesConfig{ TitlePrefix: "[Test] ", }, @@ -136,7 +148,7 @@ func TestAddAllSafeOutputConfigEnvVars(t *testing.T) { // staged is independent of target-repo: staged flag is emitted even when target-repo is set name: "target-repo specified still adds staged flag", safeOutputs: &SafeOutputsConfig{ - Staged: true, + Staged: templatableBoolPtr("true"), CreateIssues: &CreateIssuesConfig{ TargetRepoSlug: "org/repo", TitlePrefix: "[Test] ", @@ -183,7 +195,7 @@ func TestStagedFlagOnlyAddedOnce(t *testing.T) { workflowData := &WorkflowData{ Name: "Test Workflow", SafeOutputs: &SafeOutputsConfig{ - Staged: true, + Staged: templatableBoolPtr("true"), CreateIssues: &CreateIssuesConfig{ TitlePrefix: "[Issue] ", }, @@ -251,7 +263,7 @@ func TestStagedFlagWithTargetRepo(t *testing.T) { workflowData := &WorkflowData{ Name: "Test Workflow", SafeOutputs: &SafeOutputsConfig{ - Staged: true, + Staged: templatableBoolPtr("true"), CreateIssues: &CreateIssuesConfig{ TargetRepoSlug: tt.targetRepo, }, @@ -281,7 +293,7 @@ func TestTrialModeOverridesStagedFlag(t *testing.T) { workflowData := &WorkflowData{ Name: "Test Workflow", SafeOutputs: &SafeOutputsConfig{ - Staged: true, + Staged: templatableBoolPtr("true"), CreateIssues: &CreateIssuesConfig{ TitlePrefix: "[Test] ", }, @@ -304,7 +316,7 @@ func TestEnvVarsWithMultipleSafeOutputTypes(t *testing.T) { workflowData := &WorkflowData{ Name: "Test Workflow", SafeOutputs: &SafeOutputsConfig{ - Staged: true, + Staged: templatableBoolPtr("true"), CreateIssues: &CreateIssuesConfig{ TitlePrefix: "[Issue] ", }, @@ -343,7 +355,7 @@ func TestEnvVarsWithNoStagedConfig(t *testing.T) { workflowData := &WorkflowData{ Name: "Test Workflow", SafeOutputs: &SafeOutputsConfig{ - Staged: false, + Staged: templatableBoolPtr("false"), CreateIssues: &CreateIssuesConfig{ TitlePrefix: "[Test] ", }, @@ -371,7 +383,7 @@ func TestEnvVarFormatting(t *testing.T) { workflowData := &WorkflowData{ Name: "Test Workflow", SafeOutputs: &SafeOutputsConfig{ - Staged: true, + Staged: templatableBoolPtr("true"), CreateIssues: &CreateIssuesConfig{ TitlePrefix: "[Test] ", }, @@ -448,7 +460,7 @@ func TestStagedFlagPrecedence(t *testing.T) { workflowData := &WorkflowData{ Name: "Test Workflow", SafeOutputs: &SafeOutputsConfig{ - Staged: tt.staged, + Staged: templatableBoolPtr(map[bool]string{true: "true", false: "false"}[tt.staged]), CreateIssues: &CreateIssuesConfig{ TargetRepoSlug: tt.targetRepo, }, @@ -476,7 +488,7 @@ func TestAddCommentsTargetRepoStagedBehavior(t *testing.T) { workflowData := &WorkflowData{ Name: "Test Workflow", SafeOutputs: &SafeOutputsConfig{ - Staged: true, + Staged: templatableBoolPtr("true"), AddComments: &AddCommentsConfig{ TargetRepoSlug: "org/target", BaseSafeOutputConfig: BaseSafeOutputConfig{ @@ -502,7 +514,7 @@ func TestAddLabelsTargetRepoStagedBehavior(t *testing.T) { workflowData := &WorkflowData{ Name: "Test Workflow", SafeOutputs: &SafeOutputsConfig{ - Staged: true, + Staged: templatableBoolPtr("true"), AddLabels: &AddLabelsConfig{ Allowed: []string{"bug"}, SafeOutputTargetConfig: SafeOutputTargetConfig{ @@ -534,7 +546,7 @@ func TestStagedFlagForAllHandlerTypes(t *testing.T) { f, ok := soType.FieldByName(fieldName) require.True(t, ok, "safeOutputFieldMapping references field %q which does not exist in SafeOutputsConfig", fieldName) - so := &SafeOutputsConfig{Staged: true} + so := &SafeOutputsConfig{Staged: templatableBoolPtr("true")} soVal := reflect.ValueOf(so).Elem() field := soVal.FieldByName(fieldName) require.True(t, field.IsValid(), "Field %q not found in SafeOutputsConfig", fieldName) @@ -563,7 +575,7 @@ func TestStagedFlagForAllHandlerTypes(t *testing.T) { compiler := NewCompiler() workflowData := &WorkflowData{ Name: "Test Workflow", - SafeOutputs: &SafeOutputsConfig{Staged: true}, + SafeOutputs: &SafeOutputsConfig{Staged: templatableBoolPtr("true")}, } var steps []string diff --git a/pkg/workflow/compiler_safe_outputs_job.go b/pkg/workflow/compiler_safe_outputs_job.go index 78ce61a5d16..19534323276 100644 --- a/pkg/workflow/compiler_safe_outputs_job.go +++ b/pkg/workflow/compiler_safe_outputs_job.go @@ -322,7 +322,7 @@ func (c *Compiler) buildSafeOutputsHandlerOutputsAndActionSteps(data *WorkflowDa // NOTE: We do NOT export checkout_token as a job output. GitHub Actions masks output // values that contain secret references, so the downstream job would receive an empty // string. The upload job computes the token directly from static secret references. - if data.SafeOutputs.CreateCodeScanningAlerts != nil && !isHandlerStaged(c.trialMode || data.SafeOutputs.Staged, data.SafeOutputs.CreateCodeScanningAlerts.Staged) { + if data.SafeOutputs.CreateCodeScanningAlerts != nil && !isHandlerStaged(c.trialMode || templatableBoolIsTrue(data.SafeOutputs.Staged), data.SafeOutputs.CreateCodeScanningAlerts.Staged) { consolidatedSafeOutputsJobLog.Print("Exposing sarif_file output for upload_code_scanning_sarif job") outputs["sarif_file"] = "${{ steps.process_safe_outputs.outputs.sarif_file }}" @@ -511,7 +511,7 @@ func (c *Compiler) buildSafeOutputsJobFromParts( // This step runs even if previous steps fail, ensuring the audit trail // is always available for the audit command to display. // In staged mode, no items are actually created in GitHub so there is nothing to record. - isStaged := c.trialMode || data.SafeOutputs.Staged + isStaged := c.trialMode || templatableBoolIsTrue(data.SafeOutputs.Staged) if !isStaged { steps = append(steps, buildSafeOutputItemsManifestUploadStep(agentArtifactPrefix, c.getActionPin)...) } @@ -729,8 +729,18 @@ func (c *Compiler) buildJobLevelSafeOutputEnvVars(data *WorkflowData, workflowID } // Add safe output job environment variables (staged/target repo) - if data.SafeOutputs != nil && (c.trialMode || data.SafeOutputs.Staged) { - envVars["GH_AW_SAFE_OUTPUTS_STAGED"] = "\"true\"" + if data.SafeOutputs != nil { + if c.trialMode { + envVars["GH_AW_SAFE_OUTPUTS_STAGED"] = "\"true\"" + } else if templatableBoolNeedsRuntimeEnv(data.SafeOutputs.Staged) { + if value := templatableBoolPtrString(data.SafeOutputs.Staged); value != nil { + if isExpression(*value) { + envVars["GH_AW_SAFE_OUTPUTS_STAGED"] = *value + } else { + envVars["GH_AW_SAFE_OUTPUTS_STAGED"] = "\"true\"" + } + } + } } // Set GH_AW_TARGET_REPO_SLUG - prefer trial target repo (applies to all steps) diff --git a/pkg/workflow/compiler_safe_outputs_job_test.go b/pkg/workflow/compiler_safe_outputs_job_test.go index 4ca8953063b..118e0675d3e 100644 --- a/pkg/workflow/compiler_safe_outputs_job_test.go +++ b/pkg/workflow/compiler_safe_outputs_job_test.go @@ -522,7 +522,7 @@ func TestBuildJobLevelSafeOutputEnvVars(t *testing.T) { workflowData: &WorkflowData{ Name: "Test Workflow", SafeOutputs: &SafeOutputsConfig{ - Staged: true, + Staged: templatableBoolPtr("true"), }, }, workflowID: "test-workflow", @@ -1313,7 +1313,7 @@ func TestCreateCodeScanningAlertUploadJob(t *testing.T) { name: "staged mode does not create upload job", config: &CreateCodeScanningAlertsConfig{ BaseSafeOutputConfig: BaseSafeOutputConfig{ - Staged: true, + Staged: templatableBoolPtr("true"), }, }, expectUploadJob: false, diff --git a/pkg/workflow/compiler_types.go b/pkg/workflow/compiler_types.go index e08fc457af3..34b1425ecdc 100644 --- a/pkg/workflow/compiler_types.go +++ b/pkg/workflow/compiler_types.go @@ -661,7 +661,7 @@ type BaseSafeOutputConfig struct { Max *string `yaml:"max,omitempty"` // Maximum number of items to create (supports integer or GitHub Actions expression) GitHubToken string `yaml:"github-token,omitempty"` // GitHub token for this specific output type GitHubApp *GitHubAppConfig `yaml:"github-app,omitempty"` // GitHub App credentials for minting a per-handler installation access token - Staged bool `yaml:"staged,omitempty"` // If true, emit step summary messages instead of making GitHub API calls for this specific output type + Staged *TemplatableBool `yaml:"staged,omitempty"` // Templatable preview-only mode for this specific output type NormalizeClosingKeywords *bool `yaml:"normalize-closing-keywords,omitempty"` // When true for this output type, strip backticks from recognized issue-closing keywords in body fields. // Samples carries deterministic replay samples for the hidden `gh aw compile --use-samples` flag. Each entry is the JSON object passed to the corresponding MCP tool's `tools/call` arguments. Sample-only sidecar fields (e.g. `patch` for create_pull_request) are stripped before the call and used by the replay driver. Samples []map[string]any `yaml:"samples,omitempty"` @@ -723,7 +723,7 @@ type SafeOutputsConfig struct { URLs string `yaml:"urls,omitempty"` // URL sanitization policy: SafeOutputsURLsPolicyAllowedOnly (default) or SafeOutputsURLsPolicyAllowedOrCodeRegion AllowedDomains []string `yaml:"allowed-domains,omitempty"` // Allowed domains for URL redaction, unioned with network.allowed; supports ecosystem identifiers AllowGitHubReferences []string `yaml:"allowed-github-references,omitempty"` // Allowed repositories for GitHub references (e.g., ["repo", "org/repo2"]) - Staged bool `yaml:"staged,omitempty"` // If true, emit step summary messages instead of making GitHub API calls + Staged *TemplatableBool `yaml:"staged,omitempty"` // Templatable preview-only mode for all safe outputs Env map[string]string `yaml:"env,omitempty"` // Environment variables to pass to safe output jobs GitHubToken string `yaml:"github-token,omitempty"` // GitHub token for safe output jobs MaximumPatchSize int `yaml:"max-patch-size,omitempty"` // Maximum allowed patch size in KB (defaults to 4096) diff --git a/pkg/workflow/compiler_yaml.go b/pkg/workflow/compiler_yaml.go index 397f6853e47..113cea33e4b 100644 --- a/pkg/workflow/compiler_yaml.go +++ b/pkg/workflow/compiler_yaml.go @@ -840,8 +840,8 @@ func (c *Compiler) generateCreateAwInfo(yaml *strings.Builder, data *WorkflowDat // Staged value from safe-outputs configuration stagedValue := "false" - if data.SafeOutputs != nil && data.SafeOutputs.Staged { - stagedValue = "true" + if data.SafeOutputs != nil && data.SafeOutputs.Staged != nil { + stagedValue = data.SafeOutputs.Staged.String() } // Network configuration diff --git a/pkg/workflow/dispatch_repository.go b/pkg/workflow/dispatch_repository.go index 05bcdd558e2..60acd1d7008 100644 --- a/pkg/workflow/dispatch_repository.go +++ b/pkg/workflow/dispatch_repository.go @@ -9,15 +9,15 @@ var dispatchRepositoryLog = logger.New("workflow:dispatch_repository") // DispatchRepositoryToolConfig defines a single repository dispatch tool within dispatch_repository type DispatchRepositoryToolConfig struct { - Description string `yaml:"description,omitempty"` // Human-readable description - Workflow string `yaml:"workflow"` // Target workflow name (for traceability and payload) - EventType string `yaml:"event_type"` // repository_dispatch event_type - Repository string `yaml:"repository,omitempty"` // Single target repository (owner/repo) - AllowedRepositories []string `yaml:"allowed_repositories,omitempty"` // Multiple allowed target repositories - Inputs map[string]any `yaml:"inputs,omitempty"` // Input schema (similar to workflow_dispatch inputs) - Max *string `yaml:"max,omitempty"` // Max dispatch executions (templatable int) - GitHubToken string `yaml:"github-token,omitempty"` // Optional override token - Staged bool `yaml:"staged,omitempty"` // If true, preview-only mode + Description string `yaml:"description,omitempty"` // Human-readable description + Workflow string `yaml:"workflow"` // Target workflow name (for traceability and payload) + EventType string `yaml:"event_type"` // repository_dispatch event_type + Repository string `yaml:"repository,omitempty"` // Single target repository (owner/repo) + AllowedRepositories []string `yaml:"allowed_repositories,omitempty"` // Multiple allowed target repositories + Inputs map[string]any `yaml:"inputs,omitempty"` // Input schema (similar to workflow_dispatch inputs) + Max *string `yaml:"max,omitempty"` // Max dispatch executions (templatable int) + GitHubToken string `yaml:"github-token,omitempty"` // Optional override token + Staged *TemplatableBool `yaml:"staged,omitempty"` // Templatable preview-only mode } // DispatchRepositoryConfig holds configuration for dispatching repository_dispatch events diff --git a/pkg/workflow/frontmatter_types_test.go b/pkg/workflow/frontmatter_types_test.go index 477ac3ea9dc..18a4a890dfe 100644 --- a/pkg/workflow/frontmatter_types_test.go +++ b/pkg/workflow/frontmatter_types_test.go @@ -918,7 +918,7 @@ func TestFrontmatterConfigTypeSafety(t *testing.T) { } // Access staged flag directly - if !config.SafeOutputs.Staged { + if !templatableBoolIsTrue(config.SafeOutputs.Staged) { t.Error("Staged should be true") } }) diff --git a/pkg/workflow/imports.go b/pkg/workflow/imports.go index fd586b83b22..e662678ac37 100644 --- a/pkg/workflow/imports.go +++ b/pkg/workflow/imports.go @@ -433,7 +433,7 @@ func mergeSafeOutputConfig(result *SafeOutputsConfig, config map[string]any, c * if result.URLs == "" && importedConfig.URLs != "" { result.URLs = importedConfig.URLs } - if !result.Staged && importedConfig.Staged { + if result.Staged == nil && importedConfig.Staged != nil { result.Staged = importedConfig.Staged } if len(result.Env) == 0 && len(importedConfig.Env) > 0 { diff --git a/pkg/workflow/safe_output_handlers.go b/pkg/workflow/safe_output_handlers.go index f4b81f66dc3..30116acea69 100644 --- a/pkg/workflow/safe_output_handlers.go +++ b/pkg/workflow/safe_output_handlers.go @@ -677,32 +677,44 @@ func isSafeOutputHandlerEnabledAndUnstaged(safeOutputs *SafeOutputsConfig, field return false } - return !isHandlerStaged(safeOutputs.Staged, safeOutputHandlerStaged(field)) + return !isHandlerStaged(templatableBoolIsTrue(safeOutputs.Staged), safeOutputHandlerStaged(field)) } -func safeOutputHandlerStaged(field reflect.Value) bool { +func safeOutputHandlerStaged(field reflect.Value) *TemplatableBool { if !field.IsValid() || field.IsNil() { - return false + return nil } elem := field.Elem() if !elem.IsValid() || elem.Kind() != reflect.Struct { - return false + return nil } - if staged := elem.FieldByName("Staged"); staged.IsValid() && staged.Kind() == reflect.Bool { - return staged.Bool() + if staged := elem.FieldByName("Staged"); staged.IsValid() { + if value, ok := templatableBoolFromReflectValue(staged); ok { + return value + } } baseConfig := elem.FieldByName("BaseSafeOutputConfig") if !baseConfig.IsValid() || baseConfig.Kind() != reflect.Struct { - return false + return nil } staged := baseConfig.FieldByName("Staged") - if !staged.IsValid() || staged.Kind() != reflect.Bool { - return false + if value, ok := templatableBoolFromReflectValue(staged); ok { + return value } + return nil +} - return staged.Bool() +func templatableBoolFromReflectValue(value reflect.Value) (*TemplatableBool, bool) { + if !value.IsValid() { + return nil, false + } + if value.Kind() != reflect.Pointer || value.IsNil() { + return nil, false + } + typed, ok := value.Interface().(*TemplatableBool) + return typed, ok } diff --git a/pkg/workflow/safe_output_helpers_test.go b/pkg/workflow/safe_output_helpers_test.go index 323f0f35da9..e5f66d26a53 100644 --- a/pkg/workflow/safe_output_helpers_test.go +++ b/pkg/workflow/safe_output_helpers_test.go @@ -233,7 +233,7 @@ func TestApplySafeOutputEnvToMap(t *testing.T) { name: "safe outputs with staged flag", workflowData: &WorkflowData{ SafeOutputs: &SafeOutputsConfig{ - Staged: true, + Staged: templatableBoolPtr("true"), }, }, expected: map[string]string{ @@ -530,10 +530,14 @@ func TestBuildSafeOutputJobEnvVars(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + var staged *TemplatableBool + if tt.staged { + staged = templatableBoolPtr("true") + } result := buildSafeOutputJobEnvVars( tt.trialMode, tt.trialLogicalRepoSlug, - tt.staged, + staged, tt.targetRepoSlug, ) @@ -651,7 +655,7 @@ func TestEnginesUseSameHelperLogic(t *testing.T) { TrialMode: true, TrialLogicalRepo: "owner/trial-repo", SafeOutputs: &SafeOutputsConfig{ - Staged: true, + Staged: templatableBoolPtr("true"), UploadAssets: &UploadAssetsConfig{ BranchName: "gh-aw-assets", MaxSizeKB: 10240, diff --git a/pkg/workflow/safe_outputs_allow_workflows_test.go b/pkg/workflow/safe_outputs_allow_workflows_test.go index 270bd813d8e..123860ac6eb 100644 --- a/pkg/workflow/safe_outputs_allow_workflows_test.go +++ b/pkg/workflow/safe_outputs_allow_workflows_test.go @@ -68,7 +68,7 @@ func TestAllowWorkflowsPermission(t *testing.T) { name: "staged create-pull-request with allow-workflows does not add workflows write", safeOutputs: &SafeOutputsConfig{ CreatePullRequests: &CreatePullRequestsConfig{ - BaseSafeOutputConfig: BaseSafeOutputConfig{Staged: true}, + BaseSafeOutputConfig: BaseSafeOutputConfig{Staged: templatableBoolPtr("true")}, AllowWorkflows: true, }, }, diff --git a/pkg/workflow/safe_outputs_config.go b/pkg/workflow/safe_outputs_config.go index 475df12b9fe..fff80d1cd1b 100644 --- a/pkg/workflow/safe_outputs_config.go +++ b/pkg/workflow/safe_outputs_config.go @@ -418,13 +418,18 @@ func (c *Compiler) extractSafeOutputsConfig(frontmatter map[string]any) *SafeOut } // Handle staged flag + if err := preprocessBoolFieldAsString(outputMap, "staged", safeOutputsConfigLog); err != nil { + safeOutputsConfigLog.Printf("staged: %v", err) + } if staged, exists := outputMap["staged"]; exists { - if stagedBool, ok := staged.(bool); ok { - config.Staged = stagedBool + if stagedStr, ok := staged.(string); ok && stagedStr != "" { + value := TemplatableBool(stagedStr) + config.Staged = &value } } if c.forceStaged { - config.Staged = true + value := TemplatableBool("true") + config.Staged = &value } // Handle env configuration @@ -836,10 +841,13 @@ func (c *Compiler) parseBaseSafeOutputConfig(configMap map[string]any, config *B } // Parse staged flag (per-handler staged mode) - if staged, exists := configMap["staged"]; exists { - if stagedBool, ok := staged.(bool); ok { - safeOutputsConfigLog.Printf("Parsed staged flag: %t", stagedBool) - config.Staged = stagedBool + if err := preprocessBoolFieldAsString(configMap, "staged", safeOutputsConfigLog); err != nil { + safeOutputsConfigLog.Printf("Invalid staged value: %v", err) + } else if staged, exists := configMap["staged"]; exists { + if stagedStr, ok := staged.(string); ok && stagedStr != "" { + safeOutputsConfigLog.Printf("Parsed staged flag: %s", stagedStr) + value := TemplatableBool(stagedStr) + config.Staged = &value } } diff --git a/pkg/workflow/safe_outputs_config_generation_test.go b/pkg/workflow/safe_outputs_config_generation_test.go index c1555d48616..e2a91370512 100644 --- a/pkg/workflow/safe_outputs_config_generation_test.go +++ b/pkg/workflow/safe_outputs_config_generation_test.go @@ -985,7 +985,7 @@ func TestGenerateSafeOutputsConfigClosePullRequestStaged(t *testing.T) { SafeOutputs: &SafeOutputsConfig{ ClosePullRequests: &ClosePullRequestsConfig{ BaseSafeOutputConfig: BaseSafeOutputConfig{ - Staged: true, + Staged: templatableBoolPtr("true"), }, }, }, diff --git a/pkg/workflow/safe_outputs_config_helpers_test.go b/pkg/workflow/safe_outputs_config_helpers_test.go index f1282bf8c18..ada4f3137e0 100644 --- a/pkg/workflow/safe_outputs_config_helpers_test.go +++ b/pkg/workflow/safe_outputs_config_helpers_test.go @@ -89,7 +89,7 @@ func TestUsesPatchesAndCheckouts(t *testing.T) { { name: "returns false when CreatePullRequests is globally staged", safeOutputs: &SafeOutputsConfig{ - Staged: true, + Staged: templatableBoolPtr("true"), CreatePullRequests: &CreatePullRequestsConfig{}, }, expected: false, @@ -97,7 +97,7 @@ func TestUsesPatchesAndCheckouts(t *testing.T) { { name: "returns false when PushToPullRequestBranch is globally staged", safeOutputs: &SafeOutputsConfig{ - Staged: true, + Staged: templatableBoolPtr("true"), PushToPullRequestBranch: &PushToPullRequestBranchConfig{}, }, expected: false, @@ -105,7 +105,7 @@ func TestUsesPatchesAndCheckouts(t *testing.T) { { name: "returns false when both PR handlers are globally staged", safeOutputs: &SafeOutputsConfig{ - Staged: true, + Staged: templatableBoolPtr("true"), CreatePullRequests: &CreatePullRequestsConfig{}, PushToPullRequestBranch: &PushToPullRequestBranchConfig{}, }, @@ -114,15 +114,15 @@ func TestUsesPatchesAndCheckouts(t *testing.T) { { name: "returns false when CreatePullRequests is per-handler staged", safeOutputs: &SafeOutputsConfig{ - CreatePullRequests: &CreatePullRequestsConfig{BaseSafeOutputConfig: BaseSafeOutputConfig{Staged: true}}, + CreatePullRequests: &CreatePullRequestsConfig{BaseSafeOutputConfig: BaseSafeOutputConfig{Staged: templatableBoolPtr("true")}}, }, expected: false, }, { name: "returns false when both PR handlers are per-handler staged", safeOutputs: &SafeOutputsConfig{ - CreatePullRequests: &CreatePullRequestsConfig{BaseSafeOutputConfig: BaseSafeOutputConfig{Staged: true}}, - PushToPullRequestBranch: &PushToPullRequestBranchConfig{BaseSafeOutputConfig: BaseSafeOutputConfig{Staged: true}}, + CreatePullRequests: &CreatePullRequestsConfig{BaseSafeOutputConfig: BaseSafeOutputConfig{Staged: templatableBoolPtr("true")}}, + PushToPullRequestBranch: &PushToPullRequestBranchConfig{BaseSafeOutputConfig: BaseSafeOutputConfig{Staged: templatableBoolPtr("true")}}, }, expected: false, }, @@ -130,14 +130,14 @@ func TestUsesPatchesAndCheckouts(t *testing.T) { name: "returns true when CreatePullRequests is not staged but PushToPullRequestBranch is staged", safeOutputs: &SafeOutputsConfig{ CreatePullRequests: &CreatePullRequestsConfig{}, - PushToPullRequestBranch: &PushToPullRequestBranchConfig{BaseSafeOutputConfig: BaseSafeOutputConfig{Staged: true}}, + PushToPullRequestBranch: &PushToPullRequestBranchConfig{BaseSafeOutputConfig: BaseSafeOutputConfig{Staged: templatableBoolPtr("true")}}, }, expected: true, }, { name: "returns true when PushToPullRequestBranch is not staged but CreatePullRequests is staged", safeOutputs: &SafeOutputsConfig{ - CreatePullRequests: &CreatePullRequestsConfig{BaseSafeOutputConfig: BaseSafeOutputConfig{Staged: true}}, + CreatePullRequests: &CreatePullRequestsConfig{BaseSafeOutputConfig: BaseSafeOutputConfig{Staged: templatableBoolPtr("true")}}, PushToPullRequestBranch: &PushToPullRequestBranchConfig{}, }, expected: true, diff --git a/pkg/workflow/safe_outputs_env.go b/pkg/workflow/safe_outputs_env.go index 30a8ab26ba7..28ca2158978 100644 --- a/pkg/workflow/safe_outputs_env.go +++ b/pkg/workflow/safe_outputs_env.go @@ -47,13 +47,21 @@ func applySafeOutputEnvToMap(env map[string]string, data *WorkflowData) { return } - safeOutputsEnvLog.Printf("Applying safe output env vars: trial_mode=%t, staged=%t", data.TrialMode, data.SafeOutputs.Staged) + safeOutputsEnvLog.Printf("Applying safe output env vars: trial_mode=%t, staged=%v", data.TrialMode, data.SafeOutputs.Staged) env["GH_AW_SAFE_OUTPUTS"] = "${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }}" // Add staged flag if specified - if data.TrialMode || data.SafeOutputs.Staged { + if data.TrialMode { env["GH_AW_SAFE_OUTPUTS_STAGED"] = "true" + } else if templatableBoolNeedsRuntimeEnv(data.SafeOutputs.Staged) { + if value := templatableBoolPtrString(data.SafeOutputs.Staged); value != nil { + if isExpression(*value) { + env["GH_AW_SAFE_OUTPUTS_STAGED"] = *value + } else { + env["GH_AW_SAFE_OUTPUTS_STAGED"] = "true" + } + } } if data.TrialMode && data.TrialLogicalRepo != "" { env["GH_AW_TARGET_REPO_SLUG"] = data.TrialLogicalRepo @@ -106,13 +114,16 @@ func buildWorkflowMetadataEnvVarsWithTrackerID(workflowName string, workflowSour // buildSafeOutputJobEnvVars builds environment variables for safe-output jobs with staged/target repo handling // This extracts the duplicated env setup logic in safe-output job builders (create_issue, add_comment, etc.) -func buildSafeOutputJobEnvVars(trialMode bool, trialLogicalRepoSlug string, staged bool, targetRepoSlug string) []string { +func buildSafeOutputJobEnvVars(trialMode bool, trialLogicalRepoSlug string, staged *TemplatableBool, targetRepoSlug string) []string { var customEnvVars []string // Pass the staged flag if it's set to true - if trialMode || staged { - safeOutputsEnvLog.Printf("Setting staged flag: trial_mode=%t, staged=%t", trialMode, staged) + if trialMode { + safeOutputsEnvLog.Printf("Setting staged flag: trial_mode=%t, staged=%v", trialMode, staged) customEnvVars = append(customEnvVars, " GH_AW_SAFE_OUTPUTS_STAGED: \"true\"\n") + } else if templatableBoolNeedsRuntimeEnv(staged) { + safeOutputsEnvLog.Printf("Setting staged flag: trial_mode=%t, staged=%v", trialMode, staged) + customEnvVars = append(customEnvVars, buildTemplatableBoolEnvVar("GH_AW_SAFE_OUTPUTS_STAGED", templatableBoolPtrString(staged))...) } // Set GH_AW_TARGET_REPO_SLUG - prefer target-repo config over trial target repo @@ -274,8 +285,8 @@ func (c *Compiler) addAllSafeOutputConfigEnvVars(steps *[]string, data *Workflow // Add the global staged env var once if staged mode is enabled, not in trial mode, // and at least one handler is configured. Staged mode is independent of target-repo. - if !c.trialMode && data.SafeOutputs.Staged && hasAnySafeOutputEnabled(data.SafeOutputs) { - *steps = append(*steps, " GH_AW_SAFE_OUTPUTS_STAGED: \"true\"\n") + if !c.trialMode && templatableBoolNeedsRuntimeEnv(data.SafeOutputs.Staged) && hasAnySafeOutputEnabled(data.SafeOutputs) { + *steps = append(*steps, buildTemplatableBoolEnvVar("GH_AW_SAFE_OUTPUTS_STAGED", templatableBoolPtrString(data.SafeOutputs.Staged))...) safeOutputsEnvLog.Print("Added staged flag") } diff --git a/pkg/workflow/safe_outputs_handler_registry.go b/pkg/workflow/safe_outputs_handler_registry.go index 9f1f17b0275..8917dd94086 100644 --- a/pkg/workflow/safe_outputs_handler_registry.go +++ b/pkg/workflow/safe_outputs_handler_registry.go @@ -27,7 +27,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). AddIfNotEmpty("github-token", c.GitHubToken). AddBoolPtr("normalize_closing_keywords", c.NormalizeClosingKeywords). - AddIfTrue("staged", c.Staged). + AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). AddBoolOrInt("deduplicate_by_title", c.DeduplicateByTitle) return builder.Build() }, @@ -49,7 +49,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddBoolPtr("normalize_closing_keywords", c.NormalizeClosingKeywords). AddStringSlice("required_labels", c.RequiredLabels). AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix). - AddIfTrue("staged", c.Staged). + AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). Build() }, "comment_memory": func(cfg *SafeOutputsConfig) map[string]any { @@ -65,7 +65,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("memory_id", c.MemoryID). AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). + AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). Build() }, "create_discussion": func(cfg *SafeOutputsConfig) map[string]any { @@ -89,7 +89,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("target-repo", c.TargetRepoSlug). AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). + AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). Build() }, "close_issue": func(cfg *SafeOutputsConfig) map[string]any { @@ -106,7 +106,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("state_reason", c.StateReason). AddBoolPtr("allow_body", c.AllowBody). - AddIfTrue("staged", c.Staged). + AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). Build() }, "close_discussion": func(cfg *SafeOutputsConfig) map[string]any { @@ -122,7 +122,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("target-repo", c.TargetRepoSlug). AddStringSlice("allowed_repos", c.AllowedRepos). AddBoolPtr("allow_body", c.AllowBody). - AddIfTrue("staged", c.Staged). + AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). Build() }, "add_labels": func(cfg *SafeOutputsConfig) map[string]any { @@ -140,7 +140,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddStringSlice("required_labels", c.RequiredLabels). AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix). AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). + AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). Build() // If config is empty, it means add_labels was explicitly configured with no options // (null config), which means "allow any labels". Return non-nil empty map to @@ -166,7 +166,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddStringSlice("required_labels", c.RequiredLabels). AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix). AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). + AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). Build() }, "replace_label": func(cfg *SafeOutputsConfig) map[string]any { @@ -190,7 +190,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddStringSlice("required_labels", c.RequiredLabels). AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix). AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). + AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). Build() // If config is empty, it means replace_label was explicitly configured with no options // (null config), which means "allow any labels". Return non-nil empty map to @@ -213,7 +213,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix).AddIfNotEmpty("target-repo", c.TargetRepoSlug). AddStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). + AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). Build() }, "assign_milestone": func(cfg *SafeOutputsConfig) map[string]any { @@ -228,7 +228,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix).AddIfNotEmpty("target-repo", c.TargetRepoSlug). AddStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). + AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). AddIfTrue("auto_create", c.AutoCreate). Build() }, @@ -245,7 +245,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("target-repo", c.TargetRepoSlug). AddStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). + AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). Build() }, "create_code_scanning_alert": func(cfg *SafeOutputsConfig) map[string]any { @@ -259,7 +259,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("target-repo", c.TargetRepoSlug). AddStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). + AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). Build() }, "create_check_run": func(cfg *SafeOutputsConfig) map[string]any { @@ -271,7 +271,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddTemplatableInt("max", c.Max). AddIfNotEmpty("target", c.Target). AddIfNotEmpty("name", c.Name). - AddIfTrue("staged", c.Staged) + AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)) if c.Output != nil { builder. AddIfNotEmpty("output_title", c.Output.Title). @@ -300,7 +300,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("target-repo", c.TargetRepoSlug). AddStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). + AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). Build() }, "update_issue": func(cfg *SafeOutputsConfig) map[string]any { @@ -328,7 +328,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("github-token", c.GitHubToken). AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). - AddIfTrue("staged", c.Staged). + AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). Build() }, "update_discussion": func(cfg *SafeOutputsConfig) map[string]any { @@ -355,7 +355,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("github-token", c.GitHubToken). AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). - AddIfTrue("staged", c.Staged). + AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). Build() }, "link_sub_issue": func(cfg *SafeOutputsConfig) map[string]any { @@ -372,7 +372,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("target-repo", c.TargetRepoSlug). AddStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). + AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). Build() }, "update_release": func(cfg *SafeOutputsConfig) map[string]any { @@ -384,7 +384,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddTemplatableInt("max", c.Max). AddIfNotEmpty("github-token", c.GitHubToken). AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). - AddIfTrue("staged", c.Staged). + AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). Build() }, "create_pull_request_review_comment": func(cfg *SafeOutputsConfig) map[string]any { @@ -401,7 +401,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("target-repo", c.TargetRepoSlug). AddStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). + AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). Build() }, "submit_pull_request_review": func(cfg *SafeOutputsConfig) map[string]any { @@ -418,7 +418,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfTrue("supersede_older_reviews", c.SupersedeOlderReviews).AddStringSlice("required_labels", c.RequiredLabels). AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix).AddIfNotEmpty("github-token", c.GitHubToken). AddStringPtr("footer", getEffectiveFooterString(c.Footer, cfg.Footer)). - AddIfTrue("staged", c.Staged). + AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). Build() }, "reply_to_pull_request_review_comment": func(cfg *SafeOutputsConfig) map[string]any { @@ -435,7 +435,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("github-token", c.GitHubToken). AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). - AddIfTrue("staged", c.Staged). + AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). Build() }, "resolve_pull_request_review_thread": func(cfg *SafeOutputsConfig) map[string]any { @@ -451,7 +451,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("target-repo", c.TargetRepoSlug). AddStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). + AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). Build() }, "create_pull_request": func(cfg *SafeOutputsConfig) map[string]any { @@ -517,7 +517,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddBoolPtr("signed_commits", c.SignedCommits). AddTemplatableBool("close_older_pull_requests", c.CloseOlderPullRequests). AddIfNotEmpty("close_older_key", c.CloseOlderKey). - AddIfTrue("staged", c.Staged) + AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)) return builder.Build() }, "push_to_pull_request_branch": func(cfg *SafeOutputsConfig) map[string]any { @@ -545,7 +545,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("base_branch", c.BaseBranch). AddTemplatableStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). + AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). AddStringPtr("protected_files_policy", c.ManifestFilesPolicy). AddStringSlice("protected_files", getAllManifestFiles()). AddStringSlice("protected_path_prefixes", getProtectedPathPrefixes()). @@ -575,7 +575,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix).AddIfNotEmpty("target-repo", c.TargetRepoSlug). AddStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). + AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). Build() }, "merge_pull_request": func(cfg *SafeOutputsConfig) map[string]any { @@ -590,7 +590,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("target-repo", c.TargetRepoSlug). AddStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). + AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). Build() }, "close_pull_request": func(cfg *SafeOutputsConfig) map[string]any { @@ -606,7 +606,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("target-repo", c.TargetRepoSlug). AddStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). + AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). Build() }, "hide_comment": func(cfg *SafeOutputsConfig) map[string]any { @@ -621,7 +621,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix).AddIfNotEmpty("target-repo", c.TargetRepoSlug). AddStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). + AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). Build() }, "dispatch_workflow": func(cfg *SafeOutputsConfig) map[string]any { @@ -647,7 +647,7 @@ var handlerRegistry = map[string]handlerBuilder{ builder.AddIfNotEmpty("target-ref", c.TargetRef) builder.AddIfNotEmpty("github-token", c.GitHubToken) - builder.AddIfTrue("staged", c.Staged) + builder.AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)) return builder.Build() }, "dispatch_repository": func(cfg *SafeOutputsConfig) map[string]any { @@ -664,7 +664,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddStringSlice("allowed_repositories", tool.AllowedRepositories). AddTemplatableInt("max", tool.Max). AddIfNotEmpty("github-token", tool.GitHubToken). - AddIfTrue("staged", tool.Staged). + AddTemplatableBool("staged", templatableBoolPtrString(tool.Staged)). Build() tools[toolKey] = toolConfig } @@ -684,7 +684,7 @@ var handlerRegistry = map[string]handlerBuilder{ builder.AddDefault("workflow_files", c.WorkflowFiles) } - builder.AddIfTrue("staged", c.Staged) + builder.AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)) return builder.Build() }, "missing_tool": func(cfg *SafeOutputsConfig) map[string]any { @@ -695,7 +695,7 @@ var handlerRegistry = map[string]handlerBuilder{ return newHandlerConfigBuilder(). AddTemplatableInt("max", c.Max). AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). + AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). Build() }, "missing_data": func(cfg *SafeOutputsConfig) map[string]any { @@ -706,7 +706,7 @@ var handlerRegistry = map[string]handlerBuilder{ return newHandlerConfigBuilder(). AddTemplatableInt("max", c.Max). AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). + AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). Build() }, "noop": func(cfg *SafeOutputsConfig) map[string]any { @@ -717,7 +717,7 @@ var handlerRegistry = map[string]handlerBuilder{ return newHandlerConfigBuilder(). AddTemplatableInt("max", c.Max). AddStringPtr("report-as-issue", c.ReportAsIssue). - AddIfTrue("staged", c.Staged). + AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). Build() }, "report_incomplete": func(cfg *SafeOutputsConfig) map[string]any { @@ -728,7 +728,7 @@ var handlerRegistry = map[string]handlerBuilder{ return newHandlerConfigBuilder(). AddTemplatableInt("max", c.Max). AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). + AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). Build() }, "create_report_incomplete_issue": func(cfg *SafeOutputsConfig) map[string]any { @@ -747,7 +747,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("title-prefix", c.TitlePrefix). AddStringSlice("labels", c.Labels). AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged) + AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)) // When create-issue is a GitHub Actions expression, embed it in the handler config. // GitHub Actions evaluates the expression before the handler runs; the JavaScript // handler then parses the resolved value via parseBoolTemplatable at runtime. @@ -776,7 +776,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddStringSlice("allowed-pull-request-repos", c.AllowedPullRequestRepos). AddIfNotEmpty("base-branch", c.BaseBranch). AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). + AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). Build() }, "upload_asset": func(cfg *SafeOutputsConfig) map[string]any { @@ -790,7 +790,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfPositive("max-size", c.MaxSizeKB). AddStringSlice("allowed-exts", c.AllowedExts). AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). + AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). Build() }, "upload_artifact": func(cfg *SafeOutputsConfig) map[string]any { @@ -804,7 +804,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddTemplatableInt("retention-days", c.RetentionDays). AddTemplatableBool("skip-archive", c.SkipArchive). AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged) + AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)) if c.MaxSizeBytes > 0 { b = b.AddDefault("max-size-bytes", c.MaxSizeBytes) } @@ -834,7 +834,7 @@ var handlerRegistry = map[string]handlerBuilder{ return newHandlerConfigBuilder(). AddTemplatableInt("max", c.Max). AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). + AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). Build() }, // Note: create_project, update_project and create_project_status_update are handled by the unified handler, @@ -855,7 +855,7 @@ var handlerRegistry = map[string]handlerBuilder{ if len(c.FieldDefinitions) > 0 { builder.AddDefault("field_definitions", c.FieldDefinitions) } - builder.AddIfTrue("staged", c.Staged) + builder.AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)) return builder.Build() }, "update_project": func(cfg *SafeOutputsConfig) map[string]any { @@ -875,7 +875,7 @@ var handlerRegistry = map[string]handlerBuilder{ if len(c.FieldDefinitions) > 0 { builder.AddDefault("field_definitions", c.FieldDefinitions) } - builder.AddIfTrue("staged", c.Staged) + builder.AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)) return builder.Build() }, "assign_to_user": func(cfg *SafeOutputsConfig) map[string]any { @@ -892,7 +892,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("github-token", c.GitHubToken). AddTemplatableBool("unassign_first", c.UnassignFirst). - AddIfTrue("staged", c.Staged). + AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). Build() }, "unassign_from_user": func(cfg *SafeOutputsConfig) map[string]any { @@ -910,7 +910,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("target-repo", c.TargetRepoSlug). AddStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). + AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). Build() }, "create_project_status_update": func(cfg *SafeOutputsConfig) map[string]any { @@ -922,7 +922,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddTemplatableInt("max", c.Max). AddIfNotEmpty("github-token", c.GitHubToken). AddIfNotEmpty("project", c.Project). - AddIfTrue("staged", c.Staged). + AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). Build() }, "set_issue_type": func(cfg *SafeOutputsConfig) map[string]any { @@ -937,7 +937,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("target-repo", c.TargetRepoSlug). AddStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). + AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). Build() // If config is empty, it means set_issue_type was explicitly configured with no options // (null config), which means "allow any type". Return non-nil empty map to @@ -959,7 +959,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix).AddIfNotEmpty("target-repo", c.TargetRepoSlug). AddStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). + AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). Build() if len(config) == 0 { return make(map[string]any) diff --git a/pkg/workflow/safe_outputs_import_test.go b/pkg/workflow/safe_outputs_import_test.go index 4e2ae9576a2..e7bf08bbf94 100644 --- a/pkg/workflow/safe_outputs_import_test.go +++ b/pkg/workflow/safe_outputs_import_test.go @@ -782,7 +782,7 @@ This workflow uses the imported meta configuration. // Verify imported meta fields assert.Equal(t, []string{"example.com", "api.example.com"}, workflowData.SafeOutputs.AllowedDomains, "AllowedDomains should be imported") - assert.True(t, workflowData.SafeOutputs.Staged, "Staged should be imported and set to true") + assert.True(t, templatableBoolIsTrue(workflowData.SafeOutputs.Staged), "Staged should be imported and set to true") assert.Equal(t, map[string]string{"TEST_VAR": "test_value"}, workflowData.SafeOutputs.Env, "Env should be imported") assert.Equal(t, "${{ secrets.CUSTOM_TOKEN }}", workflowData.SafeOutputs.GitHubToken, "GitHubToken should be imported") // Note: When main workflow has safe-outputs section, extractSafeOutputsConfig sets MaximumPatchSize default (4096) @@ -928,7 +928,7 @@ This workflow uses only imported safe-outputs configuration. assert.Equal(t, []string{"import.example.com"}, workflowData.SafeOutputs.AllowedDomains, "AllowedDomains should be imported") assert.Equal(t, "${{ secrets.IMPORT_TOKEN }}", workflowData.SafeOutputs.GitHubToken, "GitHubToken should be imported") assert.Equal(t, 4096, workflowData.SafeOutputs.MaximumPatchSize, "MaximumPatchSize should be imported") - assert.True(t, workflowData.SafeOutputs.Staged, "Staged should be imported and set to true") + assert.True(t, templatableBoolIsTrue(workflowData.SafeOutputs.Staged), "Staged should be imported and set to true") assert.Equal(t, "runs-on: ubuntu-22.04", workflowData.SafeOutputs.RunsOn, "RunsOn should be imported") } diff --git a/pkg/workflow/safe_outputs_permissions.go b/pkg/workflow/safe_outputs_permissions.go index 6b12b3a313c..70bfdb18c7b 100644 --- a/pkg/workflow/safe_outputs_permissions.go +++ b/pkg/workflow/safe_outputs_permissions.go @@ -47,8 +47,8 @@ func stepsRequireIDToken(steps []any) bool { // (i.e., it will only emit preview output, not make real API calls). A handler is // staged when either the global safe-outputs staged flag is true, or the // per-handler staged flag is true. Staged handlers do not require write permissions. -func isHandlerStaged(globalStaged, handlerStaged bool) bool { - return globalStaged || handlerStaged +func isHandlerStaged(globalStaged bool, handlerStaged *TemplatableBool) bool { + return globalStaged || templatableBoolIsTrue(handlerStaged) } // getPushFallbackAsPullRequest returns the effective fallback-as-pull-request setting (defaults to true). diff --git a/pkg/workflow/safe_outputs_permissions_test.go b/pkg/workflow/safe_outputs_permissions_test.go index 3bec6094c31..906a8b6b279 100644 --- a/pkg/workflow/safe_outputs_permissions_test.go +++ b/pkg/workflow/safe_outputs_permissions_test.go @@ -684,7 +684,7 @@ func TestComputePermissionsForSafeOutputs_Staged(t *testing.T) { { name: "global staged=true - no permissions for any handler", safeOutputs: &SafeOutputsConfig{ - Staged: true, + Staged: templatableBoolPtr("true"), CreateIssues: &CreateIssuesConfig{}, CreateDiscussions: &CreateDiscussionsConfig{}, AddLabels: &AddLabelsConfig{}, @@ -695,7 +695,7 @@ func TestComputePermissionsForSafeOutputs_Staged(t *testing.T) { name: "per-handler staged=true - staged handler contributes no permissions", safeOutputs: &SafeOutputsConfig{ CreateIssues: &CreateIssuesConfig{ - BaseSafeOutputConfig: BaseSafeOutputConfig{Staged: true}, + BaseSafeOutputConfig: BaseSafeOutputConfig{Staged: templatableBoolPtr("true")}, }, AddLabels: &AddLabelsConfig{}, }, @@ -710,10 +710,10 @@ func TestComputePermissionsForSafeOutputs_Staged(t *testing.T) { name: "all handlers per-handler staged - no permissions", safeOutputs: &SafeOutputsConfig{ CreateIssues: &CreateIssuesConfig{ - BaseSafeOutputConfig: BaseSafeOutputConfig{Staged: true}, + BaseSafeOutputConfig: BaseSafeOutputConfig{Staged: templatableBoolPtr("true")}, }, CreateDiscussions: &CreateDiscussionsConfig{ - BaseSafeOutputConfig: BaseSafeOutputConfig{Staged: true}, + BaseSafeOutputConfig: BaseSafeOutputConfig{Staged: templatableBoolPtr("true")}, }, }, expected: map[PermissionScope]PermissionLevel{}, @@ -721,9 +721,9 @@ func TestComputePermissionsForSafeOutputs_Staged(t *testing.T) { { name: "global staged=true overrides per-handler staged=false", safeOutputs: &SafeOutputsConfig{ - Staged: true, + Staged: templatableBoolPtr("true"), CreatePullRequests: &CreatePullRequestsConfig{ - BaseSafeOutputConfig: BaseSafeOutputConfig{Staged: false}, + BaseSafeOutputConfig: BaseSafeOutputConfig{Staged: templatableBoolPtr("false")}, }, DispatchWorkflow: &DispatchWorkflowConfig{}, }, @@ -732,9 +732,9 @@ func TestComputePermissionsForSafeOutputs_Staged(t *testing.T) { { name: "global staged=false, one handler staged=true", safeOutputs: &SafeOutputsConfig{ - Staged: false, + Staged: templatableBoolPtr("false"), CreatePullRequests: &CreatePullRequestsConfig{ - BaseSafeOutputConfig: BaseSafeOutputConfig{Staged: true}, + BaseSafeOutputConfig: BaseSafeOutputConfig{Staged: templatableBoolPtr("true")}, }, CloseIssues: &CloseIssuesConfig{}, }, @@ -747,7 +747,7 @@ func TestComputePermissionsForSafeOutputs_Staged(t *testing.T) { { name: "global staged=true - upload-asset staged, no contents:write", safeOutputs: &SafeOutputsConfig{ - Staged: true, + Staged: templatableBoolPtr("true"), UploadAssets: &UploadAssetsConfig{}, }, expected: map[PermissionScope]PermissionLevel{}, @@ -755,7 +755,7 @@ func TestComputePermissionsForSafeOutputs_Staged(t *testing.T) { { name: "pr review operations - all staged via global flag", safeOutputs: &SafeOutputsConfig{ - Staged: true, + Staged: templatableBoolPtr("true"), CreatePullRequestReviewComments: &CreatePullRequestReviewCommentsConfig{}, SubmitPullRequestReview: &SubmitPullRequestReviewConfig{}, }, @@ -765,7 +765,7 @@ func TestComputePermissionsForSafeOutputs_Staged(t *testing.T) { name: "pr review operations - one staged, one not", safeOutputs: &SafeOutputsConfig{ CreatePullRequestReviewComments: &CreatePullRequestReviewCommentsConfig{ - BaseSafeOutputConfig: BaseSafeOutputConfig{Staged: true}, + BaseSafeOutputConfig: BaseSafeOutputConfig{Staged: templatableBoolPtr("true")}, }, SubmitPullRequestReview: &SubmitPullRequestReviewConfig{}, }, @@ -810,7 +810,7 @@ func TestComputePermissionsForSafeOutputs_StagedYAMLRendering(t *testing.T) { { name: "globally staged - renders permissions: {}", safeOutputs: &SafeOutputsConfig{ - Staged: true, + Staged: templatableBoolPtr("true"), CreateIssues: &CreateIssuesConfig{}, AddLabels: &AddLabelsConfig{}, }, @@ -819,15 +819,15 @@ func TestComputePermissionsForSafeOutputs_StagedYAMLRendering(t *testing.T) { { name: "all per-handler staged - renders permissions: {}", safeOutputs: &SafeOutputsConfig{ - CreateIssues: &CreateIssuesConfig{BaseSafeOutputConfig: BaseSafeOutputConfig{Staged: true}}, - AddLabels: &AddLabelsConfig{BaseSafeOutputConfig: BaseSafeOutputConfig{Staged: true}}, + CreateIssues: &CreateIssuesConfig{BaseSafeOutputConfig: BaseSafeOutputConfig{Staged: templatableBoolPtr("true")}}, + AddLabels: &AddLabelsConfig{BaseSafeOutputConfig: BaseSafeOutputConfig{Staged: templatableBoolPtr("true")}}, }, expectedRendered: "permissions: {}", }, { name: "staged PR handlers - renders permissions: {}", safeOutputs: &SafeOutputsConfig{ - Staged: true, + Staged: templatableBoolPtr("true"), CreatePullRequests: &CreatePullRequestsConfig{}, }, expectedRendered: "permissions: {}", diff --git a/pkg/workflow/safe_outputs_runtime.go b/pkg/workflow/safe_outputs_runtime.go index 6899b5834e6..b565aa80d1d 100644 --- a/pkg/workflow/safe_outputs_runtime.go +++ b/pkg/workflow/safe_outputs_runtime.go @@ -45,8 +45,8 @@ func usesPatchesAndCheckouts(safeOutputs *SafeOutputsConfig) bool { if safeOutputs == nil { return false } - createPRNeedsCheckout := safeOutputs.CreatePullRequests != nil && !isHandlerStaged(safeOutputs.Staged, safeOutputs.CreatePullRequests.Staged) - pushToPRNeedsCheckout := safeOutputs.PushToPullRequestBranch != nil && !isHandlerStaged(safeOutputs.Staged, safeOutputs.PushToPullRequestBranch.Staged) + createPRNeedsCheckout := safeOutputs.CreatePullRequests != nil && !isHandlerStaged(templatableBoolIsTrue(safeOutputs.Staged), safeOutputs.CreatePullRequests.Staged) + pushToPRNeedsCheckout := safeOutputs.PushToPullRequestBranch != nil && !isHandlerStaged(templatableBoolIsTrue(safeOutputs.Staged), safeOutputs.PushToPullRequestBranch.Staged) result := createPRNeedsCheckout || pushToPRNeedsCheckout safeOutputsRuntimeLog.Printf("usesPatchesAndCheckouts: createPR=%v(needsCheckout=%v), pushToPRBranch=%v(needsCheckout=%v), result=%v", safeOutputs.CreatePullRequests != nil, createPRNeedsCheckout, diff --git a/pkg/workflow/safe_outputs_test.go b/pkg/workflow/safe_outputs_test.go index 67799b7e0a3..6599573f23e 100644 --- a/pkg/workflow/safe_outputs_test.go +++ b/pkg/workflow/safe_outputs_test.go @@ -919,7 +919,7 @@ func TestBuildStandardSafeOutputEnvVars(t *testing.T) { workflowData: &WorkflowData{ Name: "Test Workflow", SafeOutputs: &SafeOutputsConfig{ - Staged: true, + Staged: templatableBoolPtr("true"), }, }, targetRepoSlug: "", diff --git a/pkg/workflow/staged_awinfo_test.go b/pkg/workflow/staged_awinfo_test.go index 2357fb5d4d2..d7352aeb154 100644 --- a/pkg/workflow/staged_awinfo_test.go +++ b/pkg/workflow/staged_awinfo_test.go @@ -16,7 +16,7 @@ func TestGenerateCreateAwInfoWithStaged(t *testing.T) { Name: "test-workflow", SafeOutputs: &SafeOutputsConfig{ CreateIssues: &CreateIssuesConfig{BaseSafeOutputConfig: BaseSafeOutputConfig{Max: strPtr("1")}}, - Staged: true, // pointer to true + Staged: templatableBoolPtr("true"), }, } @@ -34,7 +34,7 @@ func TestGenerateCreateAwInfoWithStaged(t *testing.T) { } // Test with staged: false - workflowData.SafeOutputs.Staged = false + workflowData.SafeOutputs.Staged = templatableBoolPtr("false") yaml.Reset() c.generateCreateAwInfo(&yaml, workflowData, engine) diff --git a/pkg/workflow/staged_test.go b/pkg/workflow/staged_test.go index 7676308be49..39fafe76bb0 100644 --- a/pkg/workflow/staged_test.go +++ b/pkg/workflow/staged_test.go @@ -25,7 +25,7 @@ func TestStagedFlag(t *testing.T) { t.Fatal("Expected config to be parsed") } - if !config.Staged { + if !templatableBoolIsTrue(config.Staged) { t.Fatal("Expected staged flag to be true") } @@ -53,7 +53,7 @@ func TestStagedFlagDefault(t *testing.T) { } // Verify staged flag is false - if config.Staged { + if config.Staged != nil { t.Fatal("Expected staged flag to be false when not specified") } } @@ -76,7 +76,7 @@ func TestStagedFlagFalse(t *testing.T) { t.Fatal("Expected config to be parsed") } - if config.Staged { + if templatableBoolIsTrue(config.Staged) { t.Fatal("Expected staged flag to be false") } } @@ -94,7 +94,7 @@ func TestStagedFlagForcedByCompiler(t *testing.T) { if config == nil { t.Fatal("Expected config to be parsed") } - if !config.Staged { + if !templatableBoolIsTrue(config.Staged) { t.Fatal("Expected compiler force-staged flag to override frontmatter staged: false") } } @@ -111,7 +111,7 @@ func TestStagedFlagForcedByCompilerWhenUnset(t *testing.T) { if config == nil { t.Fatal("Expected config to be parsed") } - if !config.Staged { + if !templatableBoolIsTrue(config.Staged) { t.Fatal("Expected compiler force-staged flag to set staged mode when frontmatter omits it") } } @@ -124,7 +124,7 @@ func TestClaudeEngineWithStagedFlag(t *testing.T) { Name: "test-workflow", SafeOutputs: &SafeOutputsConfig{ CreateIssues: &CreateIssuesConfig{BaseSafeOutputConfig: BaseSafeOutputConfig{Max: strPtr("1")}}, - Staged: true, // pointer to true + Staged: templatableBoolPtr("true"), }, } @@ -142,7 +142,7 @@ func TestClaudeEngineWithStagedFlag(t *testing.T) { } // Test with staged flag false - workflowData.SafeOutputs.Staged = false // pointer to false + workflowData.SafeOutputs.Staged = templatableBoolPtr("false") // pointer to false steps = engine.GetExecutionSteps(workflowData, "test-log") stepContent = strings.Join([]string(steps[0]), "\n") @@ -162,7 +162,7 @@ func TestCodexEngineWithStagedFlag(t *testing.T) { Name: "test-workflow", SafeOutputs: &SafeOutputsConfig{ CreateIssues: &CreateIssuesConfig{BaseSafeOutputConfig: BaseSafeOutputConfig{Max: strPtr("1")}}, - Staged: true, // pointer to true + Staged: templatableBoolPtr("true"), }, } diff --git a/pkg/workflow/templatables.go b/pkg/workflow/templatables.go index eac01fc2d9e..578963628ad 100644 --- a/pkg/workflow/templatables.go +++ b/pkg/workflow/templatables.go @@ -28,10 +28,12 @@ package workflow import ( "encoding/json" + "errors" "fmt" "strconv" "github.com/github/gh-aw/pkg/logger" + "gopkg.in/yaml.v3" ) var templatablesLog = logger.New("workflow:templatables") @@ -138,6 +140,54 @@ func (t *TemplatableInt32) Ptr() *TemplatableInt32 { // "false", expressions verbatim. type TemplatableBool string +// UnmarshalJSON allows TemplatableBool to accept both JSON booleans and JSON +// strings that are GitHub Actions expressions. +func (t *TemplatableBool) UnmarshalJSON(data []byte) error { + var b bool + if err := json.Unmarshal(data, &b); err == nil { + if b { + *t = TemplatableBool("true") + } else { + *t = TemplatableBool("false") + } + return nil + } + + var s string + if err := json.Unmarshal(data, &s); err != nil { + return fmt.Errorf("staged must be a boolean or a GitHub Actions expression (e.g. '${{ inputs.staged }}'), got %s", data) + } + if !isExpression(s) { + return fmt.Errorf("staged must be a boolean or a GitHub Actions expression (e.g. '${{ inputs.staged }}'), got string %q", s) + } + *t = TemplatableBool(s) + return nil +} + +// UnmarshalYAML allows TemplatableBool to accept both YAML booleans and GitHub +// Actions expression strings. +func (t *TemplatableBool) UnmarshalYAML(node *yaml.Node) error { + switch node.Kind { + case yaml.ScalarNode: + switch node.Tag { + case "!!bool": + if node.Value == "true" { + *t = TemplatableBool("true") + } else { + *t = TemplatableBool("false") + } + return nil + case "!!str": + if !isExpression(node.Value) { + return fmt.Errorf("staged must be a boolean or a GitHub Actions expression (e.g. '${{ inputs.staged }}'), got string %q", node.Value) + } + *t = TemplatableBool(node.Value) + return nil + } + } + return errors.New("staged must be a boolean or a GitHub Actions expression (e.g. '${{ inputs.staged }}')") +} + // MarshalJSON emits a JSON boolean for literal values and a JSON string for // GitHub Actions expressions. func (t *TemplatableBool) MarshalJSON() ([]byte, error) { @@ -156,6 +206,25 @@ func (t *TemplatableBool) String() string { return string(*t) } +func templatableBoolPtrString(value *TemplatableBool) *string { + if value == nil { + return nil + } + s := value.String() + return &s +} + +func templatableBoolIsTrue(value *TemplatableBool) bool { + return value != nil && value.String() == "true" +} + +func templatableBoolNeedsRuntimeEnv(value *TemplatableBool) bool { + if value == nil { + return false + } + return value.String() == "true" || isExpression(value.String()) +} + // buildTemplatableEnvVar returns a YAML environment variable entry for a // templatable field. If value is a GitHub Actions expression it is // embedded unquoted so that GitHub Actions can evaluate it at runtime; diff --git a/pkg/workflow/test_helpers_shared_test.go b/pkg/workflow/test_helpers_shared_test.go index 1037e1bdd55..87a08a50dde 100644 --- a/pkg/workflow/test_helpers_shared_test.go +++ b/pkg/workflow/test_helpers_shared_test.go @@ -17,6 +17,12 @@ func strPtr(s string) *string { return new(s) } +// templatableBoolPtr returns a pointer to a TemplatableBool value. +func templatableBoolPtr(s string) *TemplatableBool { + value := TemplatableBool(s) + return &value +} + // mockValidationError helps create validation errors for testing. // This is a shared helper used by both unit and integration tests. type mockValidationError struct { From ebc9d410a7dc5b659e674940ee05bbeaaae6cb47 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:36:23 +0000 Subject: [PATCH 02/10] Polish staged templatable support Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/create_pr_review_comment.cjs | 4 ++-- actions/setup/js/safe_output_helpers.cjs | 17 +++++++++++--- actions/setup/js/submit_pr_review.cjs | 4 ++-- actions/setup/js/upload_artifact.cjs | 4 +++- pkg/workflow/compiler_safe_outputs_job.go | 12 +++++----- pkg/workflow/safe_outputs_env.go | 22 +++++++++---------- pkg/workflow/templatables.go | 10 ++++++--- 7 files changed, 44 insertions(+), 29 deletions(-) diff --git a/actions/setup/js/create_pr_review_comment.cjs b/actions/setup/js/create_pr_review_comment.cjs index 80ba0cc5565..d2f80190395 100644 --- a/actions/setup/js/create_pr_review_comment.cjs +++ b/actions/setup/js/create_pr_review_comment.cjs @@ -10,7 +10,7 @@ const { resolveTargetRepoConfig, resolveAndValidateRepo } = require("./repo_help const { sanitizeContent } = require("./sanitize_content.cjs"); const { createAuthenticatedGitHubClient } = require("./handler_auth.cjs"); const { buildWorkflowRunUrl } = require("./workflow_metadata_helpers.cjs"); -const { isStagedMode, logStagedPreviewInfo, checkRequiredFilter } = require("./safe_output_helpers.cjs"); +const { isTemplatableTrue, isStagedMode, logStagedPreviewInfo, checkRequiredFilter } = require("./safe_output_helpers.cjs"); const { resolveAllowedMentionsFromPayload } = require("./resolve_mentions_from_payload.cjs"); /** @type {string} Safe output type handled by this module */ @@ -59,7 +59,7 @@ async function main(config = {}) { } // Propagate per-handler staged flag to the shared PR review buffer - if (config.staged === true || config.staged === "true") { + if (isTemplatableTrue(config.staged)) { buffer.setStaged(true); } if (isStagedMode(config)) { diff --git a/actions/setup/js/safe_output_helpers.cjs b/actions/setup/js/safe_output_helpers.cjs index b93050cce8c..20be8416709 100644 --- a/actions/setup/js/safe_output_helpers.cjs +++ b/actions/setup/js/safe_output_helpers.cjs @@ -420,16 +420,26 @@ function loadCustomSafeOutputActionHandlers() { } } +/** + * Parse a templatable boolean value that may arrive as a boolean literal or as + * the string result of a resolved GitHub Actions expression. + * @param {any} value + * @returns {boolean} + */ +function isTemplatableTrue(value) { + return value === true || value === "true"; +} + /** * Returns true when the current execution is in staged mode. * Staged mode is active when either the global GH_AW_SAFE_OUTPUTS_STAGED - * environment variable is "true" or when the per-handler config has staged: true. + * environment variable is "true" or when the per-handler config resolves to true. * Use this helper in all handlers to ensure consistent staged mode detection. - * @param {Object} [config] - Handler configuration object (may have staged: true) + * @param {Object} [config] - Handler configuration object (may have staged: true/"true") * @returns {boolean} */ function isStagedMode(config) { - return process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true" || (config != null && (config.staged === true || config.staged === "true")); + return process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true" || (config != null && isTemplatableTrue(config.staged)); } /** @@ -481,6 +491,7 @@ module.exports = { extractAssignees, matchesBlockedPattern, isUsernameBlocked, + isTemplatableTrue, isStagedMode, logStagedPreviewInfo, checkRequiredFilter, diff --git a/actions/setup/js/submit_pr_review.cjs b/actions/setup/js/submit_pr_review.cjs index effeadb0763..ba8ad25a50d 100644 --- a/actions/setup/js/submit_pr_review.cjs +++ b/actions/setup/js/submit_pr_review.cjs @@ -5,7 +5,7 @@ * @typedef {import('./types/handler-factory').HandlerFactoryFunction} HandlerFactoryFunction */ -const { resolveTarget, isStagedMode, logStagedPreviewInfo, checkRequiredFilter } = require("./safe_output_helpers.cjs"); +const { resolveTarget, isTemplatableTrue, isStagedMode, logStagedPreviewInfo, checkRequiredFilter } = require("./safe_output_helpers.cjs"); const { resolveTargetRepoConfig, resolveAndValidateRepo } = require("./repo_helpers.cjs"); const { createAuthenticatedGitHubClient } = require("./handler_auth.cjs"); const { getErrorMessage } = require("./error_helpers.cjs"); @@ -60,7 +60,7 @@ async function main(config = {}) { } // Propagate per-handler staged flag to the shared PR review buffer - if (config.staged === true || config.staged === "true") { + if (isTemplatableTrue(config.staged)) { buffer.setStaged(true); } if (isStagedMode(config)) { diff --git a/actions/setup/js/upload_artifact.cjs b/actions/setup/js/upload_artifact.cjs index 90593187c7f..e453fa6d4a6 100644 --- a/actions/setup/js/upload_artifact.cjs +++ b/actions/setup/js/upload_artifact.cjs @@ -1,6 +1,8 @@ // @ts-check /// +const { isTemplatableTrue } = require("./safe_output_helpers.cjs"); + /** * upload_artifact handler * @@ -449,7 +451,7 @@ async function main(config = {}) { const allowedPaths = Array.isArray(config["allowed-paths"]) ? config["allowed-paths"] : []; const filtersInclude = Array.isArray(config["filters-include"]) ? config["filters-include"] : []; const filtersExclude = Array.isArray(config["filters-exclude"]) ? config["filters-exclude"] : []; - const isStaged = config["staged"] === true || config["staged"] === "true" || process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; + const isStaged = isTemplatableTrue(config["staged"]) || process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; core.info(`upload_artifact handler: max_uploads=${maxUploads}, retention_days=${retentionDays}, skip_archive=${skipArchive}`); core.info(`Allowed paths: ${allowedPaths.length > 0 ? allowedPaths.join(", ") : "(none – all staging files allowed)"}`); diff --git a/pkg/workflow/compiler_safe_outputs_job.go b/pkg/workflow/compiler_safe_outputs_job.go index 19534323276..54bb3115120 100644 --- a/pkg/workflow/compiler_safe_outputs_job.go +++ b/pkg/workflow/compiler_safe_outputs_job.go @@ -732,13 +732,11 @@ func (c *Compiler) buildJobLevelSafeOutputEnvVars(data *WorkflowData, workflowID if data.SafeOutputs != nil { if c.trialMode { envVars["GH_AW_SAFE_OUTPUTS_STAGED"] = "\"true\"" - } else if templatableBoolNeedsRuntimeEnv(data.SafeOutputs.Staged) { - if value := templatableBoolPtrString(data.SafeOutputs.Staged); value != nil { - if isExpression(*value) { - envVars["GH_AW_SAFE_OUTPUTS_STAGED"] = *value - } else { - envVars["GH_AW_SAFE_OUTPUTS_STAGED"] = "\"true\"" - } + } else if value := templatableBoolEnvVarValue(data.SafeOutputs.Staged); value != nil { + if isExpression(*value) { + envVars["GH_AW_SAFE_OUTPUTS_STAGED"] = *value + } else { + envVars["GH_AW_SAFE_OUTPUTS_STAGED"] = "\"true\"" } } } diff --git a/pkg/workflow/safe_outputs_env.go b/pkg/workflow/safe_outputs_env.go index 28ca2158978..83c18a47848 100644 --- a/pkg/workflow/safe_outputs_env.go +++ b/pkg/workflow/safe_outputs_env.go @@ -54,13 +54,11 @@ func applySafeOutputEnvToMap(env map[string]string, data *WorkflowData) { // Add staged flag if specified if data.TrialMode { env["GH_AW_SAFE_OUTPUTS_STAGED"] = "true" - } else if templatableBoolNeedsRuntimeEnv(data.SafeOutputs.Staged) { - if value := templatableBoolPtrString(data.SafeOutputs.Staged); value != nil { - if isExpression(*value) { - env["GH_AW_SAFE_OUTPUTS_STAGED"] = *value - } else { - env["GH_AW_SAFE_OUTPUTS_STAGED"] = "true" - } + } else if value := templatableBoolEnvVarValue(data.SafeOutputs.Staged); value != nil { + if isExpression(*value) { + env["GH_AW_SAFE_OUTPUTS_STAGED"] = *value + } else { + env["GH_AW_SAFE_OUTPUTS_STAGED"] = "true" } } if data.TrialMode && data.TrialLogicalRepo != "" { @@ -121,9 +119,9 @@ func buildSafeOutputJobEnvVars(trialMode bool, trialLogicalRepoSlug string, stag if trialMode { safeOutputsEnvLog.Printf("Setting staged flag: trial_mode=%t, staged=%v", trialMode, staged) customEnvVars = append(customEnvVars, " GH_AW_SAFE_OUTPUTS_STAGED: \"true\"\n") - } else if templatableBoolNeedsRuntimeEnv(staged) { + } else if value := templatableBoolEnvVarValue(staged); value != nil { safeOutputsEnvLog.Printf("Setting staged flag: trial_mode=%t, staged=%v", trialMode, staged) - customEnvVars = append(customEnvVars, buildTemplatableBoolEnvVar("GH_AW_SAFE_OUTPUTS_STAGED", templatableBoolPtrString(staged))...) + customEnvVars = append(customEnvVars, buildTemplatableBoolEnvVar("GH_AW_SAFE_OUTPUTS_STAGED", value)...) } // Set GH_AW_TARGET_REPO_SLUG - prefer target-repo config over trial target repo @@ -285,8 +283,10 @@ func (c *Compiler) addAllSafeOutputConfigEnvVars(steps *[]string, data *Workflow // Add the global staged env var once if staged mode is enabled, not in trial mode, // and at least one handler is configured. Staged mode is independent of target-repo. - if !c.trialMode && templatableBoolNeedsRuntimeEnv(data.SafeOutputs.Staged) && hasAnySafeOutputEnabled(data.SafeOutputs) { - *steps = append(*steps, buildTemplatableBoolEnvVar("GH_AW_SAFE_OUTPUTS_STAGED", templatableBoolPtrString(data.SafeOutputs.Staged))...) + if !c.trialMode && hasAnySafeOutputEnabled(data.SafeOutputs) { + if value := templatableBoolEnvVarValue(data.SafeOutputs.Staged); value != nil { + *steps = append(*steps, buildTemplatableBoolEnvVar("GH_AW_SAFE_OUTPUTS_STAGED", value)...) + } safeOutputsEnvLog.Print("Added staged flag") } diff --git a/pkg/workflow/templatables.go b/pkg/workflow/templatables.go index 578963628ad..1d41f39571d 100644 --- a/pkg/workflow/templatables.go +++ b/pkg/workflow/templatables.go @@ -218,11 +218,15 @@ func templatableBoolIsTrue(value *TemplatableBool) bool { return value != nil && value.String() == "true" } -func templatableBoolNeedsRuntimeEnv(value *TemplatableBool) bool { +func templatableBoolEnvVarValue(value *TemplatableBool) *string { if value == nil { - return false + return nil + } + s := value.String() + if s == "true" || isExpression(s) { + return &s } - return value.String() == "true" || isExpression(value.String()) + return nil } // buildTemplatableEnvVar returns a YAML environment variable entry for a From edb37ba840fc735454196ff7676d9f9ad2f79f45 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:46:56 +0000 Subject: [PATCH 03/10] Refine staged templatable helpers Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/safe_output_helpers.cjs | 3 +- .../compiler_safe_outputs_env_test.go | 16 ++-- pkg/workflow/compiler_safe_outputs_job.go | 4 +- pkg/workflow/safe_outputs_env.go | 13 +-- pkg/workflow/safe_outputs_handler_registry.go | 96 +++++++++---------- pkg/workflow/templatables.go | 10 +- 6 files changed, 72 insertions(+), 70 deletions(-) diff --git a/actions/setup/js/safe_output_helpers.cjs b/actions/setup/js/safe_output_helpers.cjs index 20be8416709..d5dd0951514 100644 --- a/actions/setup/js/safe_output_helpers.cjs +++ b/actions/setup/js/safe_output_helpers.cjs @@ -423,7 +423,8 @@ function loadCustomSafeOutputActionHandlers() { /** * Parse a templatable boolean value that may arrive as a boolean literal or as * the string result of a resolved GitHub Actions expression. - * @param {any} value + * Undefined and null values return false. + * @param {any} value - Candidate templatable boolean value. * @returns {boolean} */ function isTemplatableTrue(value) { diff --git a/pkg/workflow/compiler_safe_outputs_env_test.go b/pkg/workflow/compiler_safe_outputs_env_test.go index 3dd94ed7930..72877f28d9f 100644 --- a/pkg/workflow/compiler_safe_outputs_env_test.go +++ b/pkg/workflow/compiler_safe_outputs_env_test.go @@ -132,7 +132,7 @@ func TestAddAllSafeOutputConfigEnvVars(t *testing.T) { }, }, { - name: "trial mode does not add staged flag", + name: "trial mode still adds staged flag", trialMode: true, safeOutputs: &SafeOutputsConfig{ Staged: templatableBoolPtr("true"), @@ -140,8 +140,8 @@ func TestAddAllSafeOutputConfigEnvVars(t *testing.T) { TitlePrefix: "[Test] ", }, }, - checkNotContains: []string{ - "GH_AW_SAFE_OUTPUTS_STAGED", + checkContains: []string{ + "GH_AW_SAFE_OUTPUTS_STAGED: \"true\"", }, }, { @@ -305,8 +305,8 @@ func TestTrialModeOverridesStagedFlag(t *testing.T) { stepsContent := strings.Join(steps, "") - // Trial mode should prevent staged flag from being added - assert.NotContains(t, stepsContent, "GH_AW_SAFE_OUTPUTS_STAGED") + // Trial mode should force staged mode on + assert.Contains(t, stepsContent, `GH_AW_SAFE_OUTPUTS_STAGED: "true"`) } // TestEnvVarsWithMultipleSafeOutputTypes tests comprehensive env var generation @@ -426,7 +426,7 @@ func TestStagedFlagPrecedence(t *testing.T) { name: "staged true, trial mode", staged: true, trialMode: true, - expectFlag: false, + expectFlag: true, }, { // staged is independent of target-repo @@ -441,12 +441,12 @@ func TestStagedFlagPrecedence(t *testing.T) { expectFlag: false, }, { - // trial mode suppresses staged regardless of target-repo + // trial mode still exports staged regardless of target-repo name: "staged true, trial mode and target-repo", staged: true, trialMode: true, targetRepo: "org/repo", - expectFlag: false, + expectFlag: true, }, } diff --git a/pkg/workflow/compiler_safe_outputs_job.go b/pkg/workflow/compiler_safe_outputs_job.go index 54bb3115120..36d743e4396 100644 --- a/pkg/workflow/compiler_safe_outputs_job.go +++ b/pkg/workflow/compiler_safe_outputs_job.go @@ -730,9 +730,7 @@ func (c *Compiler) buildJobLevelSafeOutputEnvVars(data *WorkflowData, workflowID // Add safe output job environment variables (staged/target repo) if data.SafeOutputs != nil { - if c.trialMode { - envVars["GH_AW_SAFE_OUTPUTS_STAGED"] = "\"true\"" - } else if value := templatableBoolEnvVarValue(data.SafeOutputs.Staged); value != nil { + if value := resolveSafeOutputsStagedValue(c.trialMode, data.SafeOutputs.Staged); value != nil { if isExpression(*value) { envVars["GH_AW_SAFE_OUTPUTS_STAGED"] = *value } else { diff --git a/pkg/workflow/safe_outputs_env.go b/pkg/workflow/safe_outputs_env.go index 83c18a47848..c052392dd69 100644 --- a/pkg/workflow/safe_outputs_env.go +++ b/pkg/workflow/safe_outputs_env.go @@ -52,9 +52,7 @@ func applySafeOutputEnvToMap(env map[string]string, data *WorkflowData) { env["GH_AW_SAFE_OUTPUTS"] = "${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }}" // Add staged flag if specified - if data.TrialMode { - env["GH_AW_SAFE_OUTPUTS_STAGED"] = "true" - } else if value := templatableBoolEnvVarValue(data.SafeOutputs.Staged); value != nil { + if value := resolveSafeOutputsStagedValue(data.TrialMode, data.SafeOutputs.Staged); value != nil { if isExpression(*value) { env["GH_AW_SAFE_OUTPUTS_STAGED"] = *value } else { @@ -116,10 +114,7 @@ func buildSafeOutputJobEnvVars(trialMode bool, trialLogicalRepoSlug string, stag var customEnvVars []string // Pass the staged flag if it's set to true - if trialMode { - safeOutputsEnvLog.Printf("Setting staged flag: trial_mode=%t, staged=%v", trialMode, staged) - customEnvVars = append(customEnvVars, " GH_AW_SAFE_OUTPUTS_STAGED: \"true\"\n") - } else if value := templatableBoolEnvVarValue(staged); value != nil { + if value := resolveSafeOutputsStagedValue(trialMode, staged); value != nil { safeOutputsEnvLog.Printf("Setting staged flag: trial_mode=%t, staged=%v", trialMode, staged) customEnvVars = append(customEnvVars, buildTemplatableBoolEnvVar("GH_AW_SAFE_OUTPUTS_STAGED", value)...) } @@ -283,8 +278,8 @@ func (c *Compiler) addAllSafeOutputConfigEnvVars(steps *[]string, data *Workflow // Add the global staged env var once if staged mode is enabled, not in trial mode, // and at least one handler is configured. Staged mode is independent of target-repo. - if !c.trialMode && hasAnySafeOutputEnabled(data.SafeOutputs) { - if value := templatableBoolEnvVarValue(data.SafeOutputs.Staged); value != nil { + if hasAnySafeOutputEnabled(data.SafeOutputs) { + if value := resolveSafeOutputsStagedValue(c.trialMode, data.SafeOutputs.Staged); value != nil { *steps = append(*steps, buildTemplatableBoolEnvVar("GH_AW_SAFE_OUTPUTS_STAGED", value)...) } safeOutputsEnvLog.Print("Added staged flag") diff --git a/pkg/workflow/safe_outputs_handler_registry.go b/pkg/workflow/safe_outputs_handler_registry.go index 8917dd94086..2586e62317b 100644 --- a/pkg/workflow/safe_outputs_handler_registry.go +++ b/pkg/workflow/safe_outputs_handler_registry.go @@ -27,7 +27,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). AddIfNotEmpty("github-token", c.GitHubToken). AddBoolPtr("normalize_closing_keywords", c.NormalizeClosingKeywords). - AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). + AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). AddBoolOrInt("deduplicate_by_title", c.DeduplicateByTitle) return builder.Build() }, @@ -49,7 +49,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddBoolPtr("normalize_closing_keywords", c.NormalizeClosingKeywords). AddStringSlice("required_labels", c.RequiredLabels). AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix). - AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). + AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). Build() }, "comment_memory": func(cfg *SafeOutputsConfig) map[string]any { @@ -65,7 +65,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("memory_id", c.MemoryID). AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). + AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). Build() }, "create_discussion": func(cfg *SafeOutputsConfig) map[string]any { @@ -89,7 +89,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("target-repo", c.TargetRepoSlug). AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). + AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). Build() }, "close_issue": func(cfg *SafeOutputsConfig) map[string]any { @@ -106,7 +106,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("state_reason", c.StateReason). AddBoolPtr("allow_body", c.AllowBody). - AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). + AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). Build() }, "close_discussion": func(cfg *SafeOutputsConfig) map[string]any { @@ -122,7 +122,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("target-repo", c.TargetRepoSlug). AddStringSlice("allowed_repos", c.AllowedRepos). AddBoolPtr("allow_body", c.AllowBody). - AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). + AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). Build() }, "add_labels": func(cfg *SafeOutputsConfig) map[string]any { @@ -140,7 +140,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddStringSlice("required_labels", c.RequiredLabels). AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix). AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). + AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). Build() // If config is empty, it means add_labels was explicitly configured with no options // (null config), which means "allow any labels". Return non-nil empty map to @@ -166,7 +166,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddStringSlice("required_labels", c.RequiredLabels). AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix). AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). + AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). Build() }, "replace_label": func(cfg *SafeOutputsConfig) map[string]any { @@ -190,7 +190,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddStringSlice("required_labels", c.RequiredLabels). AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix). AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). + AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). Build() // If config is empty, it means replace_label was explicitly configured with no options // (null config), which means "allow any labels". Return non-nil empty map to @@ -213,7 +213,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix).AddIfNotEmpty("target-repo", c.TargetRepoSlug). AddStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). + AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). Build() }, "assign_milestone": func(cfg *SafeOutputsConfig) map[string]any { @@ -228,7 +228,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix).AddIfNotEmpty("target-repo", c.TargetRepoSlug). AddStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). + AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). AddIfTrue("auto_create", c.AutoCreate). Build() }, @@ -245,7 +245,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("target-repo", c.TargetRepoSlug). AddStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). + AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). Build() }, "create_code_scanning_alert": func(cfg *SafeOutputsConfig) map[string]any { @@ -259,7 +259,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("target-repo", c.TargetRepoSlug). AddStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). + AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). Build() }, "create_check_run": func(cfg *SafeOutputsConfig) map[string]any { @@ -271,7 +271,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddTemplatableInt("max", c.Max). AddIfNotEmpty("target", c.Target). AddIfNotEmpty("name", c.Name). - AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)) + AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)) if c.Output != nil { builder. AddIfNotEmpty("output_title", c.Output.Title). @@ -300,7 +300,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("target-repo", c.TargetRepoSlug). AddStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). + AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). Build() }, "update_issue": func(cfg *SafeOutputsConfig) map[string]any { @@ -328,7 +328,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("github-token", c.GitHubToken). AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). - AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). + AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). Build() }, "update_discussion": func(cfg *SafeOutputsConfig) map[string]any { @@ -355,7 +355,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("github-token", c.GitHubToken). AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). - AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). + AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). Build() }, "link_sub_issue": func(cfg *SafeOutputsConfig) map[string]any { @@ -372,7 +372,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("target-repo", c.TargetRepoSlug). AddStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). + AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). Build() }, "update_release": func(cfg *SafeOutputsConfig) map[string]any { @@ -384,7 +384,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddTemplatableInt("max", c.Max). AddIfNotEmpty("github-token", c.GitHubToken). AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). - AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). + AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). Build() }, "create_pull_request_review_comment": func(cfg *SafeOutputsConfig) map[string]any { @@ -401,7 +401,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("target-repo", c.TargetRepoSlug). AddStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). + AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). Build() }, "submit_pull_request_review": func(cfg *SafeOutputsConfig) map[string]any { @@ -418,7 +418,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfTrue("supersede_older_reviews", c.SupersedeOlderReviews).AddStringSlice("required_labels", c.RequiredLabels). AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix).AddIfNotEmpty("github-token", c.GitHubToken). AddStringPtr("footer", getEffectiveFooterString(c.Footer, cfg.Footer)). - AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). + AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). Build() }, "reply_to_pull_request_review_comment": func(cfg *SafeOutputsConfig) map[string]any { @@ -435,7 +435,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("github-token", c.GitHubToken). AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). - AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). + AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). Build() }, "resolve_pull_request_review_thread": func(cfg *SafeOutputsConfig) map[string]any { @@ -451,7 +451,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("target-repo", c.TargetRepoSlug). AddStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). + AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). Build() }, "create_pull_request": func(cfg *SafeOutputsConfig) map[string]any { @@ -517,7 +517,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddBoolPtr("signed_commits", c.SignedCommits). AddTemplatableBool("close_older_pull_requests", c.CloseOlderPullRequests). AddIfNotEmpty("close_older_key", c.CloseOlderKey). - AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)) + AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)) return builder.Build() }, "push_to_pull_request_branch": func(cfg *SafeOutputsConfig) map[string]any { @@ -545,7 +545,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("base_branch", c.BaseBranch). AddTemplatableStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). + AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). AddStringPtr("protected_files_policy", c.ManifestFilesPolicy). AddStringSlice("protected_files", getAllManifestFiles()). AddStringSlice("protected_path_prefixes", getProtectedPathPrefixes()). @@ -575,7 +575,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix).AddIfNotEmpty("target-repo", c.TargetRepoSlug). AddStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). + AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). Build() }, "merge_pull_request": func(cfg *SafeOutputsConfig) map[string]any { @@ -590,7 +590,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("target-repo", c.TargetRepoSlug). AddStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). + AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). Build() }, "close_pull_request": func(cfg *SafeOutputsConfig) map[string]any { @@ -606,7 +606,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("target-repo", c.TargetRepoSlug). AddStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). + AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). Build() }, "hide_comment": func(cfg *SafeOutputsConfig) map[string]any { @@ -621,7 +621,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix).AddIfNotEmpty("target-repo", c.TargetRepoSlug). AddStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). + AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). Build() }, "dispatch_workflow": func(cfg *SafeOutputsConfig) map[string]any { @@ -647,7 +647,7 @@ var handlerRegistry = map[string]handlerBuilder{ builder.AddIfNotEmpty("target-ref", c.TargetRef) builder.AddIfNotEmpty("github-token", c.GitHubToken) - builder.AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)) + builder.AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)) return builder.Build() }, "dispatch_repository": func(cfg *SafeOutputsConfig) map[string]any { @@ -664,7 +664,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddStringSlice("allowed_repositories", tool.AllowedRepositories). AddTemplatableInt("max", tool.Max). AddIfNotEmpty("github-token", tool.GitHubToken). - AddTemplatableBool("staged", templatableBoolPtrString(tool.Staged)). + AddTemplatableBool("staged", templatableBoolToStringPtr(tool.Staged)). Build() tools[toolKey] = toolConfig } @@ -684,7 +684,7 @@ var handlerRegistry = map[string]handlerBuilder{ builder.AddDefault("workflow_files", c.WorkflowFiles) } - builder.AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)) + builder.AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)) return builder.Build() }, "missing_tool": func(cfg *SafeOutputsConfig) map[string]any { @@ -695,7 +695,7 @@ var handlerRegistry = map[string]handlerBuilder{ return newHandlerConfigBuilder(). AddTemplatableInt("max", c.Max). AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). + AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). Build() }, "missing_data": func(cfg *SafeOutputsConfig) map[string]any { @@ -706,7 +706,7 @@ var handlerRegistry = map[string]handlerBuilder{ return newHandlerConfigBuilder(). AddTemplatableInt("max", c.Max). AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). + AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). Build() }, "noop": func(cfg *SafeOutputsConfig) map[string]any { @@ -717,7 +717,7 @@ var handlerRegistry = map[string]handlerBuilder{ return newHandlerConfigBuilder(). AddTemplatableInt("max", c.Max). AddStringPtr("report-as-issue", c.ReportAsIssue). - AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). + AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). Build() }, "report_incomplete": func(cfg *SafeOutputsConfig) map[string]any { @@ -728,7 +728,7 @@ var handlerRegistry = map[string]handlerBuilder{ return newHandlerConfigBuilder(). AddTemplatableInt("max", c.Max). AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). + AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). Build() }, "create_report_incomplete_issue": func(cfg *SafeOutputsConfig) map[string]any { @@ -747,7 +747,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("title-prefix", c.TitlePrefix). AddStringSlice("labels", c.Labels). AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)) + AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)) // When create-issue is a GitHub Actions expression, embed it in the handler config. // GitHub Actions evaluates the expression before the handler runs; the JavaScript // handler then parses the resolved value via parseBoolTemplatable at runtime. @@ -776,7 +776,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddStringSlice("allowed-pull-request-repos", c.AllowedPullRequestRepos). AddIfNotEmpty("base-branch", c.BaseBranch). AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). + AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). Build() }, "upload_asset": func(cfg *SafeOutputsConfig) map[string]any { @@ -790,7 +790,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfPositive("max-size", c.MaxSizeKB). AddStringSlice("allowed-exts", c.AllowedExts). AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). + AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). Build() }, "upload_artifact": func(cfg *SafeOutputsConfig) map[string]any { @@ -804,7 +804,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddTemplatableInt("retention-days", c.RetentionDays). AddTemplatableBool("skip-archive", c.SkipArchive). AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)) + AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)) if c.MaxSizeBytes > 0 { b = b.AddDefault("max-size-bytes", c.MaxSizeBytes) } @@ -834,7 +834,7 @@ var handlerRegistry = map[string]handlerBuilder{ return newHandlerConfigBuilder(). AddTemplatableInt("max", c.Max). AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). + AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). Build() }, // Note: create_project, update_project and create_project_status_update are handled by the unified handler, @@ -855,7 +855,7 @@ var handlerRegistry = map[string]handlerBuilder{ if len(c.FieldDefinitions) > 0 { builder.AddDefault("field_definitions", c.FieldDefinitions) } - builder.AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)) + builder.AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)) return builder.Build() }, "update_project": func(cfg *SafeOutputsConfig) map[string]any { @@ -875,7 +875,7 @@ var handlerRegistry = map[string]handlerBuilder{ if len(c.FieldDefinitions) > 0 { builder.AddDefault("field_definitions", c.FieldDefinitions) } - builder.AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)) + builder.AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)) return builder.Build() }, "assign_to_user": func(cfg *SafeOutputsConfig) map[string]any { @@ -892,7 +892,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("github-token", c.GitHubToken). AddTemplatableBool("unassign_first", c.UnassignFirst). - AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). + AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). Build() }, "unassign_from_user": func(cfg *SafeOutputsConfig) map[string]any { @@ -910,7 +910,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("target-repo", c.TargetRepoSlug). AddStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). + AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). Build() }, "create_project_status_update": func(cfg *SafeOutputsConfig) map[string]any { @@ -922,7 +922,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddTemplatableInt("max", c.Max). AddIfNotEmpty("github-token", c.GitHubToken). AddIfNotEmpty("project", c.Project). - AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). + AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). Build() }, "set_issue_type": func(cfg *SafeOutputsConfig) map[string]any { @@ -937,7 +937,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("target-repo", c.TargetRepoSlug). AddStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). + AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). Build() // If config is empty, it means set_issue_type was explicitly configured with no options // (null config), which means "allow any type". Return non-nil empty map to @@ -959,7 +959,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix).AddIfNotEmpty("target-repo", c.TargetRepoSlug). AddStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("staged", templatableBoolPtrString(c.Staged)). + AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). Build() if len(config) == 0 { return make(map[string]any) diff --git a/pkg/workflow/templatables.go b/pkg/workflow/templatables.go index 1d41f39571d..54e548117af 100644 --- a/pkg/workflow/templatables.go +++ b/pkg/workflow/templatables.go @@ -206,7 +206,7 @@ func (t *TemplatableBool) String() string { return string(*t) } -func templatableBoolPtrString(value *TemplatableBool) *string { +func templatableBoolToStringPtr(value *TemplatableBool) *string { if value == nil { return nil } @@ -229,6 +229,14 @@ func templatableBoolEnvVarValue(value *TemplatableBool) *string { return nil } +func resolveSafeOutputsStagedValue(trialMode bool, staged *TemplatableBool) *string { + if trialMode { + s := "true" + return &s + } + return templatableBoolEnvVarValue(staged) +} + // buildTemplatableEnvVar returns a YAML environment variable entry for a // templatable field. If value is a GitHub Actions expression it is // embedded unquoted so that GitHub Actions can evaluate it at runtime; From 6fab40dd8b157f0326fc4d7bb2f67bcec9306b5f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:48:04 +0000 Subject: [PATCH 04/10] Refine staged templatable feedback Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/handler_scaffold.test.cjs | 14 ++++++++++++++ pkg/workflow/templatables.go | 10 ++++++---- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/actions/setup/js/handler_scaffold.test.cjs b/actions/setup/js/handler_scaffold.test.cjs index 5c6acaa96db..99002797110 100644 --- a/actions/setup/js/handler_scaffold.test.cjs +++ b/actions/setup/js/handler_scaffold.test.cjs @@ -89,6 +89,20 @@ describe("handler_scaffold", () => { expect(setupSpy).toHaveBeenCalledWith(config, 5, true); }); + it("should treat unresolved staged expressions as false until runtime resolves them", async () => { + const setupSpy = vi.fn().mockResolvedValue(async () => ({ success: true })); + + const factory = createCountGatedHandler({ + handlerType: "test_handler", + setup: setupSpy, + }); + + const config = { max: 5, staged: "${{ inputs.staged }}" }; + await factory(config); + + expect(setupSpy).toHaveBeenCalledWith(config, 5, false); + }); + it("should delegate to handleItem when under the limit", async () => { const handleItem = vi.fn().mockResolvedValue({ success: true, data: "result" }); diff --git a/pkg/workflow/templatables.go b/pkg/workflow/templatables.go index 54e548117af..5ca4a9b1300 100644 --- a/pkg/workflow/templatables.go +++ b/pkg/workflow/templatables.go @@ -38,6 +38,8 @@ import ( var templatablesLog = logger.New("workflow:templatables") +const templatableBoolErrorExample = "value must be a boolean or a GitHub Actions expression (e.g. '${{ inputs.flag }}')" + // TemplatableInt32 represents an integer frontmatter field that also accepts // GitHub Actions expression strings (e.g. "${{ inputs.timeout }}"). The // underlying value is always stored as a string: numeric literals as their @@ -155,10 +157,10 @@ func (t *TemplatableBool) UnmarshalJSON(data []byte) error { var s string if err := json.Unmarshal(data, &s); err != nil { - return fmt.Errorf("staged must be a boolean or a GitHub Actions expression (e.g. '${{ inputs.staged }}'), got %s", data) + return fmt.Errorf("%s, got %s", templatableBoolErrorExample, data) } if !isExpression(s) { - return fmt.Errorf("staged must be a boolean or a GitHub Actions expression (e.g. '${{ inputs.staged }}'), got string %q", s) + return fmt.Errorf("%s, got string %q", templatableBoolErrorExample, s) } *t = TemplatableBool(s) return nil @@ -179,13 +181,13 @@ func (t *TemplatableBool) UnmarshalYAML(node *yaml.Node) error { return nil case "!!str": if !isExpression(node.Value) { - return fmt.Errorf("staged must be a boolean or a GitHub Actions expression (e.g. '${{ inputs.staged }}'), got string %q", node.Value) + return fmt.Errorf("%s, got string %q", templatableBoolErrorExample, node.Value) } *t = TemplatableBool(node.Value) return nil } } - return errors.New("staged must be a boolean or a GitHub Actions expression (e.g. '${{ inputs.staged }}')") + return errors.New(templatableBoolErrorExample) } // MarshalJSON emits a JSON boolean for literal values and a JSON string for From 7ec93528b0072b2537ac4b4312b6ad37507ab71d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:56:47 +0000 Subject: [PATCH 05/10] Report staged templatable progress Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/safe_output_helpers.cjs | 4 +- pkg/workflow/safe_outputs_handler_registry.go | 96 +++++++++---------- pkg/workflow/templatables.go | 5 +- 3 files changed, 54 insertions(+), 51 deletions(-) diff --git a/actions/setup/js/safe_output_helpers.cjs b/actions/setup/js/safe_output_helpers.cjs index d5dd0951514..cd83467e10c 100644 --- a/actions/setup/js/safe_output_helpers.cjs +++ b/actions/setup/js/safe_output_helpers.cjs @@ -422,8 +422,8 @@ function loadCustomSafeOutputActionHandlers() { /** * Parse a templatable boolean value that may arrive as a boolean literal or as - * the string result of a resolved GitHub Actions expression. - * Undefined and null values return false. + * the string result of a resolved GitHub Actions expression. Undefined, + * null, false, and unresolved expression strings return false. * @param {any} value - Candidate templatable boolean value. * @returns {boolean} */ diff --git a/pkg/workflow/safe_outputs_handler_registry.go b/pkg/workflow/safe_outputs_handler_registry.go index 2586e62317b..ca81e2354d0 100644 --- a/pkg/workflow/safe_outputs_handler_registry.go +++ b/pkg/workflow/safe_outputs_handler_registry.go @@ -27,7 +27,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). AddIfNotEmpty("github-token", c.GitHubToken). AddBoolPtr("normalize_closing_keywords", c.NormalizeClosingKeywords). - AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). AddBoolOrInt("deduplicate_by_title", c.DeduplicateByTitle) return builder.Build() }, @@ -49,7 +49,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddBoolPtr("normalize_closing_keywords", c.NormalizeClosingKeywords). AddStringSlice("required_labels", c.RequiredLabels). AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix). - AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). Build() }, "comment_memory": func(cfg *SafeOutputsConfig) map[string]any { @@ -65,7 +65,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("memory_id", c.MemoryID). AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). Build() }, "create_discussion": func(cfg *SafeOutputsConfig) map[string]any { @@ -89,7 +89,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("target-repo", c.TargetRepoSlug). AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). Build() }, "close_issue": func(cfg *SafeOutputsConfig) map[string]any { @@ -106,7 +106,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("state_reason", c.StateReason). AddBoolPtr("allow_body", c.AllowBody). - AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). Build() }, "close_discussion": func(cfg *SafeOutputsConfig) map[string]any { @@ -122,7 +122,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("target-repo", c.TargetRepoSlug). AddStringSlice("allowed_repos", c.AllowedRepos). AddBoolPtr("allow_body", c.AllowBody). - AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). Build() }, "add_labels": func(cfg *SafeOutputsConfig) map[string]any { @@ -140,7 +140,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddStringSlice("required_labels", c.RequiredLabels). AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix). AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). Build() // If config is empty, it means add_labels was explicitly configured with no options // (null config), which means "allow any labels". Return non-nil empty map to @@ -166,7 +166,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddStringSlice("required_labels", c.RequiredLabels). AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix). AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). Build() }, "replace_label": func(cfg *SafeOutputsConfig) map[string]any { @@ -190,7 +190,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddStringSlice("required_labels", c.RequiredLabels). AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix). AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). Build() // If config is empty, it means replace_label was explicitly configured with no options // (null config), which means "allow any labels". Return non-nil empty map to @@ -213,7 +213,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix).AddIfNotEmpty("target-repo", c.TargetRepoSlug). AddStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). Build() }, "assign_milestone": func(cfg *SafeOutputsConfig) map[string]any { @@ -228,7 +228,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix).AddIfNotEmpty("target-repo", c.TargetRepoSlug). AddStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). AddIfTrue("auto_create", c.AutoCreate). Build() }, @@ -245,7 +245,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("target-repo", c.TargetRepoSlug). AddStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). Build() }, "create_code_scanning_alert": func(cfg *SafeOutputsConfig) map[string]any { @@ -259,7 +259,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("target-repo", c.TargetRepoSlug). AddStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). Build() }, "create_check_run": func(cfg *SafeOutputsConfig) map[string]any { @@ -271,7 +271,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddTemplatableInt("max", c.Max). AddIfNotEmpty("target", c.Target). AddIfNotEmpty("name", c.Name). - AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)) + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)) if c.Output != nil { builder. AddIfNotEmpty("output_title", c.Output.Title). @@ -300,7 +300,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("target-repo", c.TargetRepoSlug). AddStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). Build() }, "update_issue": func(cfg *SafeOutputsConfig) map[string]any { @@ -328,7 +328,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("github-token", c.GitHubToken). AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). - AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). Build() }, "update_discussion": func(cfg *SafeOutputsConfig) map[string]any { @@ -355,7 +355,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("github-token", c.GitHubToken). AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). - AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). Build() }, "link_sub_issue": func(cfg *SafeOutputsConfig) map[string]any { @@ -372,7 +372,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("target-repo", c.TargetRepoSlug). AddStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). Build() }, "update_release": func(cfg *SafeOutputsConfig) map[string]any { @@ -384,7 +384,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddTemplatableInt("max", c.Max). AddIfNotEmpty("github-token", c.GitHubToken). AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). - AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). Build() }, "create_pull_request_review_comment": func(cfg *SafeOutputsConfig) map[string]any { @@ -401,7 +401,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("target-repo", c.TargetRepoSlug). AddStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). Build() }, "submit_pull_request_review": func(cfg *SafeOutputsConfig) map[string]any { @@ -418,7 +418,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfTrue("supersede_older_reviews", c.SupersedeOlderReviews).AddStringSlice("required_labels", c.RequiredLabels). AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix).AddIfNotEmpty("github-token", c.GitHubToken). AddStringPtr("footer", getEffectiveFooterString(c.Footer, cfg.Footer)). - AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). Build() }, "reply_to_pull_request_review_comment": func(cfg *SafeOutputsConfig) map[string]any { @@ -435,7 +435,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("github-token", c.GitHubToken). AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). - AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). Build() }, "resolve_pull_request_review_thread": func(cfg *SafeOutputsConfig) map[string]any { @@ -451,7 +451,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("target-repo", c.TargetRepoSlug). AddStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). Build() }, "create_pull_request": func(cfg *SafeOutputsConfig) map[string]any { @@ -517,7 +517,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddBoolPtr("signed_commits", c.SignedCommits). AddTemplatableBool("close_older_pull_requests", c.CloseOlderPullRequests). AddIfNotEmpty("close_older_key", c.CloseOlderKey). - AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)) + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)) return builder.Build() }, "push_to_pull_request_branch": func(cfg *SafeOutputsConfig) map[string]any { @@ -545,7 +545,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("base_branch", c.BaseBranch). AddTemplatableStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). AddStringPtr("protected_files_policy", c.ManifestFilesPolicy). AddStringSlice("protected_files", getAllManifestFiles()). AddStringSlice("protected_path_prefixes", getProtectedPathPrefixes()). @@ -575,7 +575,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix).AddIfNotEmpty("target-repo", c.TargetRepoSlug). AddStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). Build() }, "merge_pull_request": func(cfg *SafeOutputsConfig) map[string]any { @@ -590,7 +590,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("target-repo", c.TargetRepoSlug). AddStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). Build() }, "close_pull_request": func(cfg *SafeOutputsConfig) map[string]any { @@ -606,7 +606,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("target-repo", c.TargetRepoSlug). AddStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). Build() }, "hide_comment": func(cfg *SafeOutputsConfig) map[string]any { @@ -621,7 +621,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix).AddIfNotEmpty("target-repo", c.TargetRepoSlug). AddStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). Build() }, "dispatch_workflow": func(cfg *SafeOutputsConfig) map[string]any { @@ -647,7 +647,7 @@ var handlerRegistry = map[string]handlerBuilder{ builder.AddIfNotEmpty("target-ref", c.TargetRef) builder.AddIfNotEmpty("github-token", c.GitHubToken) - builder.AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)) + builder.AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)) return builder.Build() }, "dispatch_repository": func(cfg *SafeOutputsConfig) map[string]any { @@ -664,7 +664,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddStringSlice("allowed_repositories", tool.AllowedRepositories). AddTemplatableInt("max", tool.Max). AddIfNotEmpty("github-token", tool.GitHubToken). - AddTemplatableBool("staged", templatableBoolToStringPtr(tool.Staged)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(tool.Staged)). Build() tools[toolKey] = toolConfig } @@ -684,7 +684,7 @@ var handlerRegistry = map[string]handlerBuilder{ builder.AddDefault("workflow_files", c.WorkflowFiles) } - builder.AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)) + builder.AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)) return builder.Build() }, "missing_tool": func(cfg *SafeOutputsConfig) map[string]any { @@ -695,7 +695,7 @@ var handlerRegistry = map[string]handlerBuilder{ return newHandlerConfigBuilder(). AddTemplatableInt("max", c.Max). AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). Build() }, "missing_data": func(cfg *SafeOutputsConfig) map[string]any { @@ -706,7 +706,7 @@ var handlerRegistry = map[string]handlerBuilder{ return newHandlerConfigBuilder(). AddTemplatableInt("max", c.Max). AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). Build() }, "noop": func(cfg *SafeOutputsConfig) map[string]any { @@ -717,7 +717,7 @@ var handlerRegistry = map[string]handlerBuilder{ return newHandlerConfigBuilder(). AddTemplatableInt("max", c.Max). AddStringPtr("report-as-issue", c.ReportAsIssue). - AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). Build() }, "report_incomplete": func(cfg *SafeOutputsConfig) map[string]any { @@ -728,7 +728,7 @@ var handlerRegistry = map[string]handlerBuilder{ return newHandlerConfigBuilder(). AddTemplatableInt("max", c.Max). AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). Build() }, "create_report_incomplete_issue": func(cfg *SafeOutputsConfig) map[string]any { @@ -747,7 +747,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("title-prefix", c.TitlePrefix). AddStringSlice("labels", c.Labels). AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)) + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)) // When create-issue is a GitHub Actions expression, embed it in the handler config. // GitHub Actions evaluates the expression before the handler runs; the JavaScript // handler then parses the resolved value via parseBoolTemplatable at runtime. @@ -776,7 +776,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddStringSlice("allowed-pull-request-repos", c.AllowedPullRequestRepos). AddIfNotEmpty("base-branch", c.BaseBranch). AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). Build() }, "upload_asset": func(cfg *SafeOutputsConfig) map[string]any { @@ -790,7 +790,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfPositive("max-size", c.MaxSizeKB). AddStringSlice("allowed-exts", c.AllowedExts). AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). Build() }, "upload_artifact": func(cfg *SafeOutputsConfig) map[string]any { @@ -804,7 +804,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddTemplatableInt("retention-days", c.RetentionDays). AddTemplatableBool("skip-archive", c.SkipArchive). AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)) + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)) if c.MaxSizeBytes > 0 { b = b.AddDefault("max-size-bytes", c.MaxSizeBytes) } @@ -834,7 +834,7 @@ var handlerRegistry = map[string]handlerBuilder{ return newHandlerConfigBuilder(). AddTemplatableInt("max", c.Max). AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). Build() }, // Note: create_project, update_project and create_project_status_update are handled by the unified handler, @@ -855,7 +855,7 @@ var handlerRegistry = map[string]handlerBuilder{ if len(c.FieldDefinitions) > 0 { builder.AddDefault("field_definitions", c.FieldDefinitions) } - builder.AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)) + builder.AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)) return builder.Build() }, "update_project": func(cfg *SafeOutputsConfig) map[string]any { @@ -875,7 +875,7 @@ var handlerRegistry = map[string]handlerBuilder{ if len(c.FieldDefinitions) > 0 { builder.AddDefault("field_definitions", c.FieldDefinitions) } - builder.AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)) + builder.AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)) return builder.Build() }, "assign_to_user": func(cfg *SafeOutputsConfig) map[string]any { @@ -892,7 +892,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("github-token", c.GitHubToken). AddTemplatableBool("unassign_first", c.UnassignFirst). - AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). Build() }, "unassign_from_user": func(cfg *SafeOutputsConfig) map[string]any { @@ -910,7 +910,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("target-repo", c.TargetRepoSlug). AddStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). Build() }, "create_project_status_update": func(cfg *SafeOutputsConfig) map[string]any { @@ -922,7 +922,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddTemplatableInt("max", c.Max). AddIfNotEmpty("github-token", c.GitHubToken). AddIfNotEmpty("project", c.Project). - AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). Build() }, "set_issue_type": func(cfg *SafeOutputsConfig) map[string]any { @@ -937,7 +937,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("target-repo", c.TargetRepoSlug). AddStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). Build() // If config is empty, it means set_issue_type was explicitly configured with no options // (null config), which means "allow any type". Return non-nil empty map to @@ -959,7 +959,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix).AddIfNotEmpty("target-repo", c.TargetRepoSlug). AddStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("staged", templatableBoolToStringPtr(c.Staged)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). Build() if len(config) == 0 { return make(map[string]any) diff --git a/pkg/workflow/templatables.go b/pkg/workflow/templatables.go index 5ca4a9b1300..566bbfaa4e6 100644 --- a/pkg/workflow/templatables.go +++ b/pkg/workflow/templatables.go @@ -208,7 +208,7 @@ func (t *TemplatableBool) String() string { return string(*t) } -func templatableBoolToStringPtr(value *TemplatableBool) *string { +func templatableBoolPtrToStringPtr(value *TemplatableBool) *string { if value == nil { return nil } @@ -220,6 +220,9 @@ func templatableBoolIsTrue(value *TemplatableBool) bool { return value != nil && value.String() == "true" } +// templatableBoolEnvVarValue returns only staged values that must be preserved +// in env vars at runtime. Literal false is treated the same as unset, while +// literal true and GitHub Actions expressions must be propagated. func templatableBoolEnvVarValue(value *TemplatableBool) *string { if value == nil { return nil From 528e9bb8bfa4aa802b3275f354c7b342b9ec2825 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 24 Jun 2026 20:27:24 +0000 Subject: [PATCH 06/10] Add draft ADR-41296 for templatable safe-outputs.staged Co-Authored-By: Claude Opus 4.8 (1M context) --- .../41296-templatable-safe-outputs-staged.md | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 docs/adr/41296-templatable-safe-outputs-staged.md diff --git a/docs/adr/41296-templatable-safe-outputs-staged.md b/docs/adr/41296-templatable-safe-outputs-staged.md new file mode 100644 index 00000000000..a42b3e05397 --- /dev/null +++ b/docs/adr/41296-templatable-safe-outputs-staged.md @@ -0,0 +1,47 @@ +# ADR-41296: Make `safe-outputs.staged` a Templatable Boolean + +**Date**: 2026-06-24 +**Status**: Draft +**Deciders**: pelikhan, copilot-swe-agent [TODO: verify human deciders] + +--- + +### Context + +The `safe-outputs.staged` flag toggles preview mode, where safe-output handlers emit step-summary messages instead of making real GitHub API calls. It was modeled as a plain `boolean` in the JSON schema and as a Go `bool` in the workflow types. This prevented expression-based configuration: a workflow author could not write `staged: ${{ inputs.staged }}` or `staged: ${{ github.event_name != 'push' }}`, because the schema rejected non-literal strings and the compiler had no way to carry an unresolved expression through to the runtime. As a result, dynamic, context-driven preview toggling was impossible without duplicating workflow definitions. + +### Decision + +We will make `safe-outputs.staged` a **templatable boolean** end to end: the schema accepts `#/$defs/templatable_boolean` (literal booleans or GitHub expression strings), config parsing accepts both forms at the top level and per handler, the compiler carries the value as `*TemplatableBool` instead of `bool`, and the JavaScript handlers treat a resolved expression value (`"true"`) the same as a literal `true` via a shared `isTemplatableTrue` helper. Centralizing resolution keeps literal `true`, literal `false`, and expressions handled consistently across all handlers. + +### Alternatives Considered + +#### Alternative 1: Keep `staged` boolean-only + +Leave the flag as a literal boolean and require authors who want conditional preview mode to maintain separate workflow files or rely on the global `GH_AW_SAFE_OUTPUTS_STAGED` environment variable. Rejected because it forces workflow duplication and cannot express per-handler conditional staging driven by inputs or event context. + +#### Alternative 2: Add a separate `staged-expression` field + +Introduce a parallel field (e.g., `staged-when`) accepting only expressions, leaving `staged` as a literal boolean. Rejected because it splits one concept across two fields, complicates precedence rules, and produces a less intuitive schema than a single templatable field that accepts either form. + +#### Alternative 3: Resolve expressions only in JS, no type changes + +Keep the Go type as `bool` and push all expression handling into the JavaScript runtime. Rejected because the compiler would have nowhere to store an unresolved expression, schema validation would still reject expression strings, and the value could not flow cleanly from frontmatter through config-JSON generation to env vars. + +### Consequences + +#### Positive +- Authors can drive preview mode dynamically with GitHub expressions (`${{ inputs.staged }}`, event-based conditions) at both global and per-handler scope. +- A single shared `isTemplatableTrue` helper gives all handlers consistent truthiness semantics, replacing ad hoc `config.staged === true` checks. + +#### Negative +- Converting workflow types from `bool` to `*TemplatableBool` introduces pointer/nil and string-resolution handling that callers must respect, increasing surface for nil-dereference or mis-typed comparisons. +- Unresolved expression strings (e.g., `"${{ inputs.staged }}"` that never resolves) evaluate to `false`, so a misconfigured expression silently runs in live mode rather than preview — a fail-open behavior for staging that authors must be aware of. + +#### Neutral +- The schema change touches many `staged` definitions (every safe-output type) but is mechanical, swapping `"type": "boolean"` for `"$ref": "#/$defs/templatable_boolean"`. +- Existing literal-boolean configurations remain valid and behave identically; this is a backward-compatible widening of accepted values. + +--- + +*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.* From 39fdd60fcf3ebfb96d54564021ae7e3e2552cdd7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 24 Jun 2026 21:00:53 +0000 Subject: [PATCH 07/10] Plan PR finishing pass Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 35d5eda1740..689cbe00d97 100644 --- a/go.mod +++ b/go.mod @@ -26,6 +26,7 @@ require ( golang.org/x/term v0.44.0 golang.org/x/tools v0.46.0 golang.org/x/vuln v1.4.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -117,5 +118,4 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/grpc v1.81.1 // indirect google.golang.org/protobuf v1.36.11 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) From 673efa1e89836b3591826e6258c097f1e2ebaf3f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 24 Jun 2026 21:11:00 +0000 Subject: [PATCH 08/10] Fix staged parsing and truthiness regressions Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/dispatch_repository.cjs | 4 ++-- actions/setup/js/dispatch_repository.test.cjs | 11 +++++++++++ actions/setup/js/upload_artifact.cjs | 4 ++-- pkg/parser/schema_test.go | 2 +- pkg/workflow/compiler_safe_outputs_config_test.go | 4 +++- pkg/workflow/safe_outputs_config.go | 3 +-- pkg/workflow/safe_outputs_env.go | 4 ++-- pkg/workflow/safe_outputs_handler_registry.go | 2 +- 8 files changed, 23 insertions(+), 11 deletions(-) diff --git a/actions/setup/js/dispatch_repository.cjs b/actions/setup/js/dispatch_repository.cjs index 6dfc1034384..548d6c78218 100644 --- a/actions/setup/js/dispatch_repository.cjs +++ b/actions/setup/js/dispatch_repository.cjs @@ -12,7 +12,7 @@ const { getErrorMessage } = require("./error_helpers.cjs"); const { createAuthenticatedGitHubClient } = require("./handler_auth.cjs"); const { parseRepoSlug, validateTargetRepo, parseAllowedRepos } = require("./repo_helpers.cjs"); const { logStagedPreviewInfo } = require("./staged_preview.cjs"); -const { isStagedMode } = require("./safe_output_helpers.cjs"); +const { isStagedMode, isTemplatableTrue } = require("./safe_output_helpers.cjs"); const { buildAwContext } = require("./aw_context.cjs"); const { SAFE_OUTPUT_E001, SAFE_OUTPUT_E099 } = require("./error_codes.cjs"); @@ -137,7 +137,7 @@ async function main(config = {}) { core.info(`dispatch_repository: dispatching event_type="${eventType}" to ${targetRepoSlug} (workflow: ${toolConfig.workflow || "unspecified"})`); // If in staged mode, preview without executing - if (isStaged || toolConfig.staged) { + if (isStaged || isTemplatableTrue(toolConfig.staged)) { logStagedPreviewInfo(`Would dispatch repository_dispatch event: event_type="${eventType}" to ${targetRepoSlug}, client_payload=${JSON.stringify(clientPayload)}`); dispatchCounts[toolName] = currentCount + 1; return { diff --git a/actions/setup/js/dispatch_repository.test.cjs b/actions/setup/js/dispatch_repository.test.cjs index 78f56be1381..faa0e7b76bc 100644 --- a/actions/setup/js/dispatch_repository.test.cjs +++ b/actions/setup/js/dispatch_repository.test.cjs @@ -263,6 +263,17 @@ describe("dispatch_repository", () => { expect(dispatchEventCalls.length).toBe(0); }); + it("should not treat tool staged string false as staged mode", async () => { + const handler = await main({ + tools: { deploy: { event_type: "deploy", repository: "test-owner/test-repo", max: 5, staged: "false" } }, + }); + + const result = await handler({ tool_name: "deploy" }, {}); + expect(result.success).toBe(true); + expect(result.staged).toBeUndefined(); + expect(dispatchEventCalls.length).toBe(1); + }); + it("should parse numeric max from string config", async () => { const handler = await main({ tools: { deploy: { event_type: "deploy", repository: "test-owner/test-repo", max: "3" } }, diff --git a/actions/setup/js/upload_artifact.cjs b/actions/setup/js/upload_artifact.cjs index e453fa6d4a6..32e6433b635 100644 --- a/actions/setup/js/upload_artifact.cjs +++ b/actions/setup/js/upload_artifact.cjs @@ -1,7 +1,7 @@ // @ts-check /// -const { isTemplatableTrue } = require("./safe_output_helpers.cjs"); +const { isStagedMode } = require("./safe_output_helpers.cjs"); /** * upload_artifact handler @@ -451,7 +451,7 @@ async function main(config = {}) { const allowedPaths = Array.isArray(config["allowed-paths"]) ? config["allowed-paths"] : []; const filtersInclude = Array.isArray(config["filters-include"]) ? config["filters-include"] : []; const filtersExclude = Array.isArray(config["filters-exclude"]) ? config["filters-exclude"] : []; - const isStaged = isTemplatableTrue(config["staged"]) || process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; + const isStaged = isStagedMode(config); core.info(`upload_artifact handler: max_uploads=${maxUploads}, retention_days=${retentionDays}, skip_archive=${skipArchive}`); core.info(`Allowed paths: ${allowedPaths.length > 0 ? allowedPaths.join(", ") : "(none – all staging files allowed)"}`); diff --git a/pkg/parser/schema_test.go b/pkg/parser/schema_test.go index 478ee52c9c9..68215a35cd3 100644 --- a/pkg/parser/schema_test.go +++ b/pkg/parser/schema_test.go @@ -595,7 +595,7 @@ func TestValidateMainWorkflowFrontmatterWithSchemaAndLocation_SafeOutputsStagedE "safe-outputs": map[string]any{ "staged": "${{ inputs.staged }}", "create-issue": map[string]any{ - "staged": "${{ inputs.issue-staged }}", + "staged": "${{ inputs['issue-staged'] }}", }, }, } diff --git a/pkg/workflow/compiler_safe_outputs_config_test.go b/pkg/workflow/compiler_safe_outputs_config_test.go index 6480a4ba895..9cca7fc1523 100644 --- a/pkg/workflow/compiler_safe_outputs_config_test.go +++ b/pkg/workflow/compiler_safe_outputs_config_test.go @@ -2558,6 +2558,8 @@ func TestHandlerConfigStagedMode(t *testing.T) { } func TestHandlerConfigStagedExpression(t *testing.T) { + t.Parallel() + compiler := NewCompiler() workflowData := &WorkflowData{ Name: "Test Workflow", @@ -2592,7 +2594,7 @@ func TestHandlerConfigStagedExpression(t *testing.T) { handlerConfig, ok := config["create_issue"] require.True(t, ok, "Should have create_issue handler") - assert.Equal(t, "${{ inputs.staged }}", handlerConfig["staged"]) + assert.Equal(t, "${{ inputs.staged }}", handlerConfig["staged"], "staged expression should pass through to handler config JSON unchanged") return } diff --git a/pkg/workflow/safe_outputs_config.go b/pkg/workflow/safe_outputs_config.go index fff80d1cd1b..49766d07873 100644 --- a/pkg/workflow/safe_outputs_config.go +++ b/pkg/workflow/safe_outputs_config.go @@ -420,8 +420,7 @@ func (c *Compiler) extractSafeOutputsConfig(frontmatter map[string]any) *SafeOut // Handle staged flag if err := preprocessBoolFieldAsString(outputMap, "staged", safeOutputsConfigLog); err != nil { safeOutputsConfigLog.Printf("staged: %v", err) - } - if staged, exists := outputMap["staged"]; exists { + } else if staged, exists := outputMap["staged"]; exists { if stagedStr, ok := staged.(string); ok && stagedStr != "" { value := TemplatableBool(stagedStr) config.Staged = &value diff --git a/pkg/workflow/safe_outputs_env.go b/pkg/workflow/safe_outputs_env.go index c052392dd69..732c369fd35 100644 --- a/pkg/workflow/safe_outputs_env.go +++ b/pkg/workflow/safe_outputs_env.go @@ -276,13 +276,13 @@ func (c *Compiler) addAllSafeOutputConfigEnvVars(steps *[]string, data *Workflow return } - // Add the global staged env var once if staged mode is enabled, not in trial mode, + // Add the global staged env var once when staged mode is enabled (including trial mode) // and at least one handler is configured. Staged mode is independent of target-repo. if hasAnySafeOutputEnabled(data.SafeOutputs) { if value := resolveSafeOutputsStagedValue(c.trialMode, data.SafeOutputs.Staged); value != nil { *steps = append(*steps, buildTemplatableBoolEnvVar("GH_AW_SAFE_OUTPUTS_STAGED", value)...) + safeOutputsEnvLog.Print("Added staged flag") } - safeOutputsEnvLog.Print("Added staged flag") } // Check if copilot is in create-issue or create-pull-request assignees - enables inline copilot assignment diff --git a/pkg/workflow/safe_outputs_handler_registry.go b/pkg/workflow/safe_outputs_handler_registry.go index ca81e2354d0..c4c76781d04 100644 --- a/pkg/workflow/safe_outputs_handler_registry.go +++ b/pkg/workflow/safe_outputs_handler_registry.go @@ -370,7 +370,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddStringSlice("sub_required_labels", c.SubRequiredLabels). AddIfNotEmpty("sub_title_prefix", c.SubTitlePrefix). AddIfNotEmpty("target-repo", c.TargetRepoSlug). - AddStringSlice("allowed_repos", c.AllowedRepos). + AddTemplatableStringSlice("allowed_repos", c.AllowedRepos). AddIfNotEmpty("github-token", c.GitHubToken). AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). Build() From 49f66d9c57e8f6d1ef2a712b0221b091cfdd7c93 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 24 Jun 2026 21:14:37 +0000 Subject: [PATCH 09/10] Clarify staged env comment and test wording Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/dispatch_repository.test.cjs | 2 +- pkg/workflow/safe_outputs_env.go | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/actions/setup/js/dispatch_repository.test.cjs b/actions/setup/js/dispatch_repository.test.cjs index faa0e7b76bc..0d6abed066c 100644 --- a/actions/setup/js/dispatch_repository.test.cjs +++ b/actions/setup/js/dispatch_repository.test.cjs @@ -263,7 +263,7 @@ describe("dispatch_repository", () => { expect(dispatchEventCalls.length).toBe(0); }); - it("should not treat tool staged string false as staged mode", async () => { + it('should not treat string "false" in tool staged config as staged mode', async () => { const handler = await main({ tools: { deploy: { event_type: "deploy", repository: "test-owner/test-repo", max: 5, staged: "false" } }, }); diff --git a/pkg/workflow/safe_outputs_env.go b/pkg/workflow/safe_outputs_env.go index 732c369fd35..f7f8415c6f4 100644 --- a/pkg/workflow/safe_outputs_env.go +++ b/pkg/workflow/safe_outputs_env.go @@ -276,8 +276,9 @@ func (c *Compiler) addAllSafeOutputConfigEnvVars(steps *[]string, data *Workflow return } - // Add the global staged env var once when staged mode is enabled (including trial mode) - // and at least one handler is configured. Staged mode is independent of target-repo. + // Add the global staged env var once when resolveSafeOutputsStagedValue determines + // staged mode should be enabled (including trial mode), and at least one handler is + // configured. Staged mode is independent of target-repo. if hasAnySafeOutputEnabled(data.SafeOutputs) { if value := resolveSafeOutputsStagedValue(c.trialMode, data.SafeOutputs.Staged); value != nil { *steps = append(*steps, buildTemplatableBoolEnvVar("GH_AW_SAFE_OUTPUTS_STAGED", value)...) From 83bb4235c65d3d863e3d2289a7a9c8168c0039d0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 24 Jun 2026 21:18:27 +0000 Subject: [PATCH 10/10] Polish staged diagnostics and helper usage Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/dispatch_repository.cjs | 4 ++-- pkg/workflow/safe_outputs_env.go | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/actions/setup/js/dispatch_repository.cjs b/actions/setup/js/dispatch_repository.cjs index 548d6c78218..8f88aedcf6f 100644 --- a/actions/setup/js/dispatch_repository.cjs +++ b/actions/setup/js/dispatch_repository.cjs @@ -12,7 +12,7 @@ const { getErrorMessage } = require("./error_helpers.cjs"); const { createAuthenticatedGitHubClient } = require("./handler_auth.cjs"); const { parseRepoSlug, validateTargetRepo, parseAllowedRepos } = require("./repo_helpers.cjs"); const { logStagedPreviewInfo } = require("./staged_preview.cjs"); -const { isStagedMode, isTemplatableTrue } = require("./safe_output_helpers.cjs"); +const { isStagedMode } = require("./safe_output_helpers.cjs"); const { buildAwContext } = require("./aw_context.cjs"); const { SAFE_OUTPUT_E001, SAFE_OUTPUT_E099 } = require("./error_codes.cjs"); @@ -137,7 +137,7 @@ async function main(config = {}) { core.info(`dispatch_repository: dispatching event_type="${eventType}" to ${targetRepoSlug} (workflow: ${toolConfig.workflow || "unspecified"})`); // If in staged mode, preview without executing - if (isStaged || isTemplatableTrue(toolConfig.staged)) { + if (isStaged || isStagedMode(toolConfig)) { logStagedPreviewInfo(`Would dispatch repository_dispatch event: event_type="${eventType}" to ${targetRepoSlug}, client_payload=${JSON.stringify(clientPayload)}`); dispatchCounts[toolName] = currentCount + 1; return { diff --git a/pkg/workflow/safe_outputs_env.go b/pkg/workflow/safe_outputs_env.go index f7f8415c6f4..7854a9119f2 100644 --- a/pkg/workflow/safe_outputs_env.go +++ b/pkg/workflow/safe_outputs_env.go @@ -283,6 +283,8 @@ func (c *Compiler) addAllSafeOutputConfigEnvVars(steps *[]string, data *Workflow if value := resolveSafeOutputsStagedValue(c.trialMode, data.SafeOutputs.Staged); value != nil { *steps = append(*steps, buildTemplatableBoolEnvVar("GH_AW_SAFE_OUTPUTS_STAGED", value)...) safeOutputsEnvLog.Print("Added staged flag") + } else { + safeOutputsEnvLog.Print("Staged flag not set") } }