diff --git a/actions/setup/js/create_pr_review_comment.cjs b/actions/setup/js/create_pr_review_comment.cjs index 431ece890a2..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) { + if (isTemplatableTrue(config.staged)) { buffer.setStaged(true); } if (isStagedMode(config)) { diff --git a/actions/setup/js/dispatch_repository.cjs b/actions/setup/js/dispatch_repository.cjs index 6dfc1034384..8f88aedcf6f 100644 --- a/actions/setup/js/dispatch_repository.cjs +++ b/actions/setup/js/dispatch_repository.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 || 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/actions/setup/js/dispatch_repository.test.cjs b/actions/setup/js/dispatch_repository.test.cjs index 78f56be1381..0d6abed066c 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 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" } }, + }); + + 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/handler_scaffold.test.cjs b/actions/setup/js/handler_scaffold.test.cjs index 6c446456f9d..99002797110 100644 --- a/actions/setup/js/handler_scaffold.test.cjs +++ b/actions/setup/js/handler_scaffold.test.cjs @@ -75,6 +75,34 @@ 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 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/actions/setup/js/safe_output_helpers.cjs b/actions/setup/js/safe_output_helpers.cjs index c89ab62d2b9..cd83467e10c 100644 --- a/actions/setup/js/safe_output_helpers.cjs +++ b/actions/setup/js/safe_output_helpers.cjs @@ -420,16 +420,27 @@ 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, + * null, false, and unresolved expression strings return false. + * @param {any} value - Candidate templatable boolean 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); + return process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true" || (config != null && isTemplatableTrue(config.staged)); } /** @@ -481,6 +492,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 2fffdcda07e..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) { + 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 b1335e80c77..32e6433b635 100644 --- a/actions/setup/js/upload_artifact.cjs +++ b/actions/setup/js/upload_artifact.cjs @@ -1,6 +1,8 @@ // @ts-check /// +const { isStagedMode } = 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 || 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/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.* 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 ) diff --git a/pkg/parser/schema_test.go b/pkg/parser/schema_test.go index 7d81b1deb7b..68215a35cd3 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..9cca7fc1523 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,55 @@ func TestHandlerConfigStagedMode(t *testing.T) { assert.Equal(t, true, stagedVal, "staged field should be true") } } + }) } } +func TestHandlerConfigStagedExpression(t *testing.T) { + t.Parallel() + + 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"], "staged expression should pass through to handler config JSON unchanged") + 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..72877f28d9f 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] ", }, @@ -120,23 +132,23 @@ 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: true, + Staged: templatableBoolPtr("true"), CreateIssues: &CreateIssuesConfig{ TitlePrefix: "[Test] ", }, }, - checkNotContains: []string{ - "GH_AW_SAFE_OUTPUTS_STAGED", + checkContains: []string{ + "GH_AW_SAFE_OUTPUTS_STAGED: \"true\"", }, }, { // 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] ", }, @@ -293,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 @@ -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] ", }, @@ -414,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 @@ -429,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, }, } @@ -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..36d743e4396 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,14 @@ 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 value := resolveSafeOutputsStagedValue(c.trialMode, 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..49766d07873 100644 --- a/pkg/workflow/safe_outputs_config.go +++ b/pkg/workflow/safe_outputs_config.go @@ -418,13 +418,17 @@ func (c *Compiler) extractSafeOutputsConfig(frontmatter map[string]any) *SafeOut } // Handle staged flag - if staged, exists := outputMap["staged"]; exists { - if stagedBool, ok := staged.(bool); ok { - config.Staged = stagedBool + if err := preprocessBoolFieldAsString(outputMap, "staged", safeOutputsConfigLog); err != nil { + safeOutputsConfigLog.Printf("staged: %v", err) + } else if staged, exists := outputMap["staged"]; exists { + 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 +840,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..7854a9119f2 100644 --- a/pkg/workflow/safe_outputs_env.go +++ b/pkg/workflow/safe_outputs_env.go @@ -47,13 +47,17 @@ 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 { - env["GH_AW_SAFE_OUTPUTS_STAGED"] = "true" + if value := resolveSafeOutputsStagedValue(data.TrialMode, 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 +110,13 @@ 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) - customEnvVars = append(customEnvVars, " GH_AW_SAFE_OUTPUTS_STAGED: \"true\"\n") + 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)...) } // Set GH_AW_TARGET_REPO_SLUG - prefer target-repo config over trial target repo @@ -272,11 +276,16 @@ 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, - // 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") - safeOutputsEnvLog.Print("Added staged flag") + // 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)...) + safeOutputsEnvLog.Print("Added staged flag") + } else { + safeOutputsEnvLog.Print("Staged flag not set") + } } // 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 9f1f17b0275..c4c76781d04 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", 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). - AddIfTrue("staged", 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). - AddIfTrue("staged", 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). - AddIfTrue("staged", 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). - AddIfTrue("staged", 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). - AddIfTrue("staged", 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). - AddIfTrue("staged", 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). - AddIfTrue("staged", 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). - AddIfTrue("staged", 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). - AddIfTrue("staged", 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). - AddIfTrue("staged", 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). - AddIfTrue("staged", 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). - AddIfTrue("staged", 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). - AddIfTrue("staged", 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). - AddIfTrue("staged", 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)). - AddIfTrue("staged", 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)). - AddIfTrue("staged", c.Staged). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). Build() }, "link_sub_issue": func(cfg *SafeOutputsConfig) map[string]any { @@ -370,9 +370,9 @@ 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). - AddIfTrue("staged", 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)). - AddIfTrue("staged", 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). - AddIfTrue("staged", 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)). - AddIfTrue("staged", 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)). - AddIfTrue("staged", 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). - AddIfTrue("staged", 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). - AddIfTrue("staged", 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). - AddIfTrue("staged", 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). - AddIfTrue("staged", 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). - AddIfTrue("staged", 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). - AddIfTrue("staged", 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). - AddIfTrue("staged", 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.AddIfTrue("staged", 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). - AddIfTrue("staged", 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.AddIfTrue("staged", 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). - AddIfTrue("staged", 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). - AddIfTrue("staged", 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). - AddIfTrue("staged", 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). - AddIfTrue("staged", 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). - AddIfTrue("staged", 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). - AddIfTrue("staged", 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). - AddIfTrue("staged", 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). - AddIfTrue("staged", 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). - AddIfTrue("staged", 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.AddIfTrue("staged", 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.AddIfTrue("staged", 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). - AddIfTrue("staged", 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). - AddIfTrue("staged", 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). - AddIfTrue("staged", 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). - AddIfTrue("staged", 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). - AddIfTrue("staged", c.Staged). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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..566bbfaa4e6 100644 --- a/pkg/workflow/templatables.go +++ b/pkg/workflow/templatables.go @@ -28,14 +28,18 @@ 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") +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 @@ -138,6 +142,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("%s, got %s", templatableBoolErrorExample, data) + } + if !isExpression(s) { + return fmt.Errorf("%s, got string %q", templatableBoolErrorExample, 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("%s, got string %q", templatableBoolErrorExample, node.Value) + } + *t = TemplatableBool(node.Value) + return nil + } + } + return errors.New(templatableBoolErrorExample) +} + // MarshalJSON emits a JSON boolean for literal values and a JSON string for // GitHub Actions expressions. func (t *TemplatableBool) MarshalJSON() ([]byte, error) { @@ -156,6 +208,40 @@ func (t *TemplatableBool) String() string { return string(*t) } +func templatableBoolPtrToStringPtr(value *TemplatableBool) *string { + if value == nil { + return nil + } + s := value.String() + return &s +} + +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 + } + s := value.String() + if s == "true" || isExpression(s) { + return &s + } + 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; 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 {