Accept runtime ${{ ... }} expressions in safe-outputs samples#37537
Conversation
Compile-time validation of safe-outputs samples now substitutes any
${{ ... }} GitHub Actions expression for a placeholder value before
validating against the MCP tool's input schema. The original sample
is preserved and emitted into the lock file so the live expression
is resolved by GitHub Actions at runtime.
This implements Option 2 from issue #37532, enabling samples like:
samples:
- item_number: "${{ github.event.inputs.issue_number }}"
labels: ["triage"]
Fixes #37532
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Pull request overview
This PR updates safe-outputs sample validation so workflow authors can use GitHub Actions runtime expressions (${{ ... }}) inside safe-outputs.*.samples without failing compile-time JSON-Schema validation, while still preserving the original expressions verbatim in the compiled lock file for runtime substitution.
Changes:
- Substitutes
${{ ... }}-containing string values with a sentinel during schema validation (without mutating the original samples). - Adds unit tests for top-level and nested substitutions, non-expression validation failures, and helper non-mutating behavior.
- Adds an integration test ensuring runtime expressions survive into the lock file’s
GH_AW_SAMPLESpayload.
Show a summary per file
| File | Description |
|---|---|
| pkg/workflow/samples_validation.go | Adds runtime-expression substitution during samples schema validation. |
| pkg/workflow/samples_validation_test.go | Adds unit tests covering runtime-expression substitution and non-mutation guarantees. |
| pkg/workflow/samples_replay_test.go | Adds integration coverage ensuring expressions are preserved into the lock file env payload. |
| .changeset/patch-samples-runtime-expressions.md | Documents the patch-level behavioral change in release notes. |
Copilot's findings
Tip
Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Files reviewed: 4/4 changed files
- Comments generated: 2
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
|
✅ smoke-ci: safeoutputs CLI comment + comment-memory run (27091718262)
|
|
❌ Design Decision Gate 🏗️ failed during design decision gate check. |
|
🧠 Matt Pocock Skills Reviewer has completed the skills-based review. ✅ |
|
|
|
❌ Test Quality Sentinel failed during test quality analysis. |
There was a problem hiding this comment.
Skills-Based Review 🧠
Applied /diagnose and /tdd — the fix cleanly solves the stated item_number case in #37532; three non-blocking observations below.
📋 Key Themes & Highlights
Key Themes
- Boolean field gap: The
"aw_sample"sentinel is always astring, sodraft: ${{ ... }}and othertype: booleanschema fields still fail compile-time validation. This is undocumented in the PR description or the placeholder comment. - Dead code in fallback: The
!okbranch after the type assertion invalidateSamplesForToolis unreachable —substituteRuntimeExpressionsForValidationalways returnsmap[string]anywhen its input ismap[string]any. - Missing boundary test: No test covers boolean-typed fields with runtime expressions, leaving the limitation invisible to future contributors.
Positive Highlights
- ✅ Clean deep-copy approach — original sample is never mutated, which is exactly right for the lock-file contract
- ✅ Solid 4-test suite: bypass, nested structures, non-expression errors still surface, and helper purity
- ✅ Integration test
TestUseSamplesPreservesRuntimeExpressionsInLockFilecloses the loop end-to-end - ✅ Non-greedy
.*?in the regex is correct; the non-mutation invariant is explicitly verified
🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer · sonnet46 1.1M · 321.7 AIC · ⌖ 13.8 AIC
| // for compile-time schema validation only. It is chosen to satisfy every | ||
| // pattern currently declared in pkg/workflow/js/safe_outputs_tools.json that | ||
| // accepts an `aw_`-prefixed temporary id (3-12 chars after the prefix). | ||
| const sampleRuntimeExpressionPlaceholder = "aw_sample" |
There was a problem hiding this comment.
[/diagnose] The "aw_sample" sentinel is always a string, so this approach only works for fields where the schema accepts a string type. Fields like create_pull_request.draft, update_pull_request.draft, update_pull_request.update_branch, and update_project.create_if_missing have type: boolean — substituting them with the string sentinel still fails schema validation. The comment claims the placeholder satisfies "every pattern currently declared" but boolean fields have no pattern, only a strict type requirement.
💡 Example that would still fail
A user writing:
samples:
- title: "My PR"
body: "Body"
branch: "feature"
draft: ${{ github.event.inputs.is_draft }}After YAML parsing draft is the string "${{ github.event.inputs.is_draft }}"; after substitution it becomes "aw_sample"; schema validation still fails because "aw_sample" is not a boolean.
To fix, the sentinel logic should inspect the schema type for the field being validated and either skip boolean fields entirely, or use a type-appropriate placeholder per-field. Alternatively, update the comment to document the boolean limitation explicitly so users know this is unsupported.
| stripped := stripSidecarFields(sample, sidecars) | ||
| if err := schema.Validate(stripped); err != nil { | ||
| substituted, ok := substituteRuntimeExpressionsForValidation(stripped).(map[string]any) | ||
| if !ok { |
There was a problem hiding this comment.
[/diagnose] Dead code: substituteRuntimeExpressionsForValidation(stripped) is called with a map[string]any, so the case map[string]any branch always returns a new map[string]any. The type assertion .(map[string]any) can never produce ok == false — the !ok fallback branch is unreachable.
💡 Simplification
Replace the two-step assertion+fallback with a direct call:
stripped := stripSidecarFields(sample, sidecars)
substituted := substituteRuntimeExpressionsForValidation(stripped).(map[string]any)This removes the dead branch and makes the invariant explicit. If substituteRuntimeExpressionsForValidation is ever refactored to handle non-map inputs at this call site, the compiler will catch the broken assertion rather than silently falling back to unsubstituted data.
| // TestSubstituteRuntimeExpressionsForValidation_LeavesLiteralsUntouched | ||
| // verifies that the substitution helper only touches strings containing | ||
| // `${{ ... }}` and otherwise returns equivalent values. | ||
| func TestSubstituteRuntimeExpressionsForValidation_LeavesLiteralsUntouched(t *testing.T) { |
There was a problem hiding this comment.
[/tdd] The test suite covers string/number fields with runtime expressions but has no test for type: boolean schema fields (e.g., draft: ${{ github.event.inputs.is_draft }} for create_pull_request). Adding one — even to assert the current failure — prevents this limitation from being silently broken as the schema evolves.
💡 Suggested test
// TestValidateSafeOutputsSamples_BooleanFieldRuntimeExpression documents
// that boolean-typed fields (e.g. draft) do NOT yet support runtime
// expressions, because the string sentinel "aw_sample" fails type: boolean.
// Update this test when boolean field support is added.
func TestValidateSafeOutputsSamples_BooleanFieldRuntimeExpression(t *testing.T) {
cfg := &SafeOutputsConfig{
CreatePullRequests: &CreatePullRequestsConfig{
BaseSafeOutputConfig: BaseSafeOutputConfig{
Samples: []map[string]any{
{
"title": "My PR",
"body": "Body",
"branch": "feature",
"draft": "${{ github.event.inputs.is_draft }}",
},
},
},
},
}
// TODO: this currently fails because aw_sample is not a boolean.
// Once boolean expression bypass is implemented, flip to check err == nil.
if err := validateSafeOutputsSamples(cfg); err == nil {
t.Log("boolean runtime expression bypass now works — update this test")
}
}…#37539) * Make safe-outputs sample runtime-expression substitution schema-aware Addresses follow-up review on #37537: - discussion_r3369275868 — substituting every `${{ ... }}` with the fixed string "aw_sample" still failed validation for schemas that constrain a field with an `enum`, `boolean`, `number`, or other non-pattern shape. The substitution now walks the schema in parallel with the value and picks a placeholder satisfying the local node (first enum value, `1` for number/integer, `true` for boolean, `2024-01-01` for `format: date`, etc.). - discussion_r3369275859 — the regex was already widened to `(?s).*?` in the merged PR. This adds a regression test (`TestValidateSafeOutputsSamples_RuntimeExpressionWithEmbeddedBrace`) for `${{ ... }}` expressions whose body contains `}` (e.g. `fromJSON('{"n":42}').n`). * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Accept runtime
${{ ... }}expressions insafe-outputssamplesPR #37537 ·
pkg/workflow· patchProblem
safe-outputs.*.samplesvalues are validated at compile-time against the MCP tool'sinputSchema. This caused compilation to reject any sample value containing a GitHub Actions runtime expression such as${{ github.event.inputs.issue_number }}— even though the expression would resolve to a valid type at runtime — making it impossible to wire workflow-dispatch inputs into samples.Approach
During compile-time schema validation, deep-copy each sample map and replace every string value that contains a
${{ ... }}expression with the sentinel placeholderaw_sample(which satisfies common JSON-schema constraints:string,minLength,pattern). Validate the substituted copy only; emit the original sample — with the live expression intact — verbatim into theGH_AW_SAMPLESblock of the generated lock file. GitHub Actions then performs its own substitution at runtime.Files changed
pkg/workflow/samples_validation.gosubstituteRuntimeExpressionsForValidation(compiled regex +aw_samplesentinel) and wires it intovalidateSamplesForToolso${{ ... }}expressions are swapped for validation only, leaving the original expression untouched for lock-file emission.pkg/workflow/samples_validation_test.gopkg/workflow/samples_replay_test.goTestUseSamplesPreservesRuntimeExpressionsInLockFile— a regression test confirming a${{ ... }}expression survives compilation and is emitted verbatim intoGH_AW_SAMPLESin the generated lock file..changeset/patch-samples-runtime-expressions.mdKey design constraint
Validation uses a substituted copy; the lock file always receives the original sample. This separation ensures:
Breaking changes
None.
Commits
c3b29b3db${{ ... }}expressions in safe-outputs samples (#37532)5a6a39a6b