You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
# ADR-29230: Parameterize Safe-Output Policy Fields for `workflow_call` Reuse
2
+
3
+
**Date**: 2026-04-30
4
+
**Status**: Draft
5
+
**Deciders**: pelikhan
6
+
7
+
---
8
+
9
+
## Part 1 — Narrative (Human-Friendly)
10
+
11
+
### Context
12
+
13
+
The `safe-outputs` configuration block for `create-pull-request` and `push-to-pull-request-branch` exposes two behavior-controlling fields: `protected-files` (policy: `blocked`, `allowed`, or `fallback-to-issue`) and `patch-format` (`am` or `bundle`). Both fields were previously restricted to compile-time literal enum values. This meant that reusable `workflow_call` workflows could not expose these as caller-controlled inputs — any team needing a different policy had to either fork the workflow file or maintain one copy per policy combination, creating duplicated YAML that diverged over time. The prior work in PR #29212 established the pattern of accepting GitHub Actions expression strings for list-valued constraints; this PR extends that pattern to enum-valued policy fields.
14
+
15
+
### Decision
16
+
17
+
We will allow `protected-files` (both string form and the `policy` key of its object form) and `patch-format` to accept GitHub Actions expression strings (matching `${{...}}`) in addition to their existing literal enum values. Literal values continue to be validated at compile time and rejected if unrecognized. Expression strings are passed through the compiler unchanged and emitted verbatim into the runtime handler configuration, where GitHub Actions evaluates them before the handler executes. The runtime handlers validate the resolved value and fail closed — `patch-format` returns an explicit error for unrecognized values; `protected-files` treats unrecognized values as `blocked` (most restrictive default).
18
+
19
+
### Alternatives Considered
20
+
21
+
#### Alternative 1: Duplicate Workflow Files per Policy Variant
22
+
23
+
Callers could maintain separate copies of the reusable workflow for each combination of `protected-files` and `patch-format` values. This was the status quo before this PR. It was rejected because it creates maintainability debt: every structural change to the base workflow must be propagated to all variants, and teams routinely drift their copies. The pattern does not scale as the number of callers grows.
24
+
25
+
#### Alternative 2: Accept Free-Form Strings with No Compile-Time Detection
26
+
27
+
The schema could be relaxed to accept any string (dropping enum enforcement entirely) and leave all validation to the runtime handler. This was not chosen because it would silently pass obviously invalid literal values (e.g., typos like `"blocekd"`) through compilation and only fail at job execution time, degrading the developer experience and breaking the "fail fast at compile time" guarantee that the rest of the system provides. Detecting expression strings by the `${{...}}` pattern preserves compile-time strictness for literals while enabling dynamic resolution for expressions.
28
+
29
+
#### Alternative 3: Introduce a New Dedicated `policy-expression` Field
30
+
31
+
A new field (e.g., `protected-files-expression`) could accept only expressions while the original field remains enum-only. This avoids changing the type of existing fields but doubles the surface area and requires callers to know which field to use in which context. It was rejected as unnecessarily complex when the pattern of "enum literal OR expression string" is both clear and consistent with how other expression-accepting fields work in GitHub Actions schemas.
32
+
33
+
### Consequences
34
+
35
+
#### Positive
36
+
- A single reusable `workflow_call` workflow can serve callers with different `protected-files` policies and `patch-format` requirements without duplicating files.
37
+
- Literal enum values retain compile-time validation and early error reporting; nothing regresses for existing non-expression usage.
38
+
- The fail-closed runtime behavior (`blocked` for unknown policy, explicit error for unknown patch format) ensures that expression misconfiguration cannot silently weaken security posture.
39
+
- The pattern is consistent with the expression-acceptance approach introduced in PR #29212 for list-valued constraints.
40
+
41
+
#### Negative
42
+
- Expression values are only validated at runtime, after GitHub Actions evaluates them. A typo in an input default (e.g., `default: blocekd`) will not be caught until the workflow runs.
43
+
- Two-stage validation logic (compile-time for literals, runtime for expressions) adds complexity to both the Go `validateStringEnumField` helper and the JavaScript handlers.
44
+
- The JSON schema now uses `oneOf` for fields that were previously a simple `enum`, which may complicate tooling (e.g., IDE autocomplete may not suggest enum values when the field contains an expression).
45
+
46
+
#### Neutral
47
+
- The `containsExpression` helper used to detect `${{...}}` patterns was already available in the codebase; this PR reuses it rather than introducing a new detection mechanism.
48
+
- Documentation in `docs/src/content/docs/reference/safe-outputs-pull-requests.md` was updated to show the `workflow_call` usage pattern with explicit examples.
49
+
- Schema changes apply symmetrically to both `create-pull-request` and `push-to-pull-request-branch` handler blocks.
50
+
51
+
---
52
+
53
+
## Part 2 — Normative Specification (RFC 2119)
54
+
55
+
> The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHALL**, **SHALL NOT**, **SHOULD**, **SHOULD NOT**, **RECOMMENDED**, **MAY**, and **OPTIONAL** in this section are to be interpreted as described in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119).
56
+
57
+
### Schema and Compile-Time Validation
58
+
59
+
1. The `protected-files` field and the `policy` sub-key of the `protected-files` object form **MUST** accept either a literal enum value (`blocked`, `allowed`, `fallback-to-issue`) or a GitHub Actions expression string matching the pattern `^\$\{\{.*\}\}$`.
60
+
2. The `patch-format` field **MUST** accept either a literal enum value (`am`, `bundle`) or a GitHub Actions expression string matching the pattern `^\$\{\{.*\}\}$`.
61
+
3. Literal enum values **MUST** be validated at compile time; unrecognized literal values **MUST** be rejected (removed from the config with a warning log) before the workflow is emitted.
62
+
4. GitHub Actions expression strings **MUST NOT** be enum-validated at compile time; they **MUST** be passed through to the runtime handler configuration verbatim.
63
+
5. The `validateStringEnumField` helper **MUST** use the `containsExpression` function to distinguish expressions from literal strings before applying enum validation.
64
+
65
+
### Runtime Handler Validation
66
+
67
+
1. Handlers **MUST** validate the resolved value of `patch_format` after GitHub Actions evaluates any expression; if the resolved value is not in `["am", "bundle"]`, the handler **MUST** return an error response and **MUST NOT** perform any git operations.
68
+
2. Handlers **MUST** validate the resolved value of `protected_files_policy` after evaluation; if the resolved value is not a recognized policy, the handler **MUST** treat it as `blocked` (fail closed, most restrictive).
69
+
3. Neither handler **MUST NOT** append any safe output to the output file when returning an error due to an invalid resolved field value.
70
+
71
+
### Conformance
72
+
73
+
An implementation is considered conformant with this ADR if it satisfies all **MUST** and **MUST NOT** requirements above. Failure to meet any **MUST** or **MUST NOT** requirement — in particular: allowing unrecognized literal values past compile time, performing git operations after a resolved-value validation failure, or treating an unrecognized `protected_files_policy` as anything other than `blocked` — constitutes non-conformance.
74
+
75
+
---
76
+
77
+
*This is a DRAFT ADR generated by the [Design Decision Gate](https://github.com/github/gh-aw/actions/runs/25143053856) workflow. The PR author must review, complete, and finalize this document before the PR can merge.*
Copy file name to clipboardExpand all lines: docs/src/content/docs/reference/safe-outputs-pull-requests.md
+44Lines changed: 44 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -402,6 +402,50 @@ safe-outputs:
402
402
403
403
When protected file protection triggers and is set to `blocked`, the 🛡️ **Protected Files** section appears in the agent failure issue or comment generated by the conclusion job. It includes the blocked operation, the specific files found, and a YAML remediation snippet showing how to configure `protected-files: fallback-to-issue`.
404
404
405
+
### Parameterising Policy Fields in Reusable Workflows
406
+
407
+
Both `protected-files` and `patch-format` accept **GitHub Actions expression strings** so that reusable `workflow_call` workflows can let callers choose the policy without duplicating the workflow file.
408
+
409
+
```yaml wrap
410
+
on:
411
+
workflow_call:
412
+
inputs:
413
+
protected-files-policy:
414
+
type: string
415
+
default: fallback-to-issue
416
+
description: >
417
+
Protected-file policy: 'blocked', 'fallback-to-issue', or 'allowed'.
418
+
patch-format:
419
+
type: string
420
+
default: am
421
+
description: Transport format: 'am' (default) or 'bundle'.
**Literal values are still validated at compile time.** Expression strings are passed through to the runtime config where they are evaluated by GitHub Actions before the handler runs. If the resolved value is not one of the documented allowed values, the handler fails closed:
434
+
435
+
- `protected-files`: an unrecognised resolved value is treated as `blocked` (deny — most restrictive).
436
+
- `patch-format`: an unrecognised resolved value results in an explicit error before any git operations.
437
+
438
+
The object form of `protected-files` also accepts an expression for `policy`:
439
+
440
+
```yaml wrap
441
+
safe-outputs:
442
+
create-pull-request:
443
+
protected-files:
444
+
policy: ${{ inputs.protected-files-policy }}
445
+
exclude:
446
+
- AGENTS.md # always exclude — regardless of policy
447
+
```
448
+
405
449
### Restricting Changes to Specific Files with `allowed-files`
406
450
407
451
Use `allowed-files` to restrict a safe output to a fixed set of files. When set, it acts as an **exclusive allowlist**: every file touched by the patch must match at least one pattern, and any file outside the list is always refused — including normal source files. The `allowed-files` and `protected-files` checks are **orthogonal**: both run independently and both must pass. To modify a protected file, it must both match `allowed-files` **and** `protected-files` must be set to `allowed`.
0 commit comments