Skip to content

Commit 62b8dab

Browse files
authored
Refactor expression helper semantics into a single workflow source and remove duplicate CLI version state (#27198)
1 parent f925c0b commit 62b8dab

16 files changed

+275
-82
lines changed
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# ADR-27198: Consolidate Expression Helpers and Unify CLI Version State
2+
3+
**Date**: 2026-04-19
4+
**Status**: Draft
5+
**Deciders**: pelikhan, Copilot
6+
7+
---
8+
9+
## Part 1 — Narrative (Human-Friendly)
10+
11+
### Context
12+
13+
The `pkg/workflow` package accumulated near-duplicate expression-detection helpers across multiple files (`dispatch_repository_validation.go`, `shell.go`, `safe_outputs_validation.go`, `templatables.go`). Each file defined its own local boolean helpers for checking GitHub Actions `${{ ... }}` expressions, with subtly different semantics (permissive marker checks vs. full containment checks vs. whole-string checks). Separately, `pkg/cli` maintained its own local `version` variable and an `init()` synchronization path that had to be manually kept in sync with the canonical version state in `pkg/workflow`. Both patterns violated the single-source-of-truth principle and created silent divergence risks.
14+
15+
### Decision
16+
17+
We will centralize the three expression-detection predicates (`hasExpressionMarker`, `containsExpression`, `isExpression`) into `pkg/workflow/expression_patterns.go` and remove all per-file duplicates. We will also remove the local version variable and `init()` in `pkg/cli/version.go`, making `cli.GetVersion()` and `cli.SetVersionInfo()` thin delegators to the canonical `workflow.GetVersion()` / `workflow.SetVersion()` functions. Both changes enforce a single source of truth within their respective domains, eliminating the risk of semantic drift between helper copies.
18+
19+
### Alternatives Considered
20+
21+
#### Alternative 1: Keep per-file helpers but add linting rules
22+
23+
Each file could retain its own inline helper, and a custom linter or `grep`-based CI check could be added to flag divergence. This approach avoids touching call sites and keeps the helpers co-located with their consumers. It was rejected because linting checks would detect drift only after it happens rather than preventing it structurally, and the helpers are small enough that centralization adds no meaningful indirection.
24+
25+
#### Alternative 2: Introduce a separate `expressionutil` sub-package
26+
27+
A new package (e.g., `pkg/expressionutil`) could expose the helpers, providing a fully independent import path. This was considered for its explicit package boundary, but rejected because the helpers are only used within `pkg/workflow` call sites, making an external package an overengineered abstraction. Keeping the helpers unexported (`hasExpressionMarker`, `containsExpression`, `isExpression`) in `expression_patterns.go` preserves encapsulation without adding an unnecessary dependency edge.
28+
29+
#### Alternative 3: Retain the dual version state with better documentation
30+
31+
The `pkg/cli` version variable could have been kept but annotated with comments requiring manual sync with `workflow.GetVersion()`. This was rejected because documentation alone cannot enforce invariants. The actual synchronization logic (`init()` and local `version` variable) was already causing confusion; removing the indirection entirely is safer and simpler.
32+
33+
### Consequences
34+
35+
#### Positive
36+
- Expression-check semantics are defined once; all call sites share the same behavior, eliminating silent divergence.
37+
- Version state has a single owner (`pkg/workflow`); the CLI layer is a transparent pass-through with no independent state to maintain.
38+
- Reduced surface area for future contributors who previously had to know which file's helper was the "correct" one.
39+
- Test coverage can now target the helpers directly in `expression_patterns_test.go` instead of being spread across consumer tests.
40+
41+
#### Negative
42+
- All call sites in `pkg/workflow` must be updated when helper semantics change; there is no local override capability per file.
43+
- The `expression_patterns.go` file is already large (regex patterns + helper functions); additional consolidation could make it a maintenance hotspot.
44+
45+
#### Neutral
46+
- `pkg/cli` becomes a thin delegation layer with no independent business logic for version management; callers of `cli.GetVersion()` and `cli.SetVersionInfo()` are unaffected at the API level.
47+
- New tests (`expression_patterns_test.go`, `version_test.go`) were added to cover the consolidated logic and the delegation contract.
48+
49+
---
50+
51+
## Part 2 — Normative Specification (RFC 2119)
52+
53+
> 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).
54+
55+
### Expression Detection Helpers
56+
57+
1. All GitHub Actions expression-detection predicates used within `pkg/workflow` **MUST** be defined in `pkg/workflow/expression_patterns.go`.
58+
2. Individual `pkg/workflow` source files **MUST NOT** define their own local boolean helpers that check for `${{` or `}}` patterns.
59+
3. The three canonical helpers **MUST** provide the following distinct semantics:
60+
- `hasExpressionMarker(s string) bool` — returns `true` if `s` contains the substring `${{` (permissive, partial-expression check).
61+
- `containsExpression(s string) bool` — returns `true` if `s` contains a complete non-empty expression (a `${{` followed by at least one character before `}}`).
62+
- `isExpression(s string) bool` — returns `true` if the entire string `s` is a single expression (starts with `${{` and ends with `}}`).
63+
4. Callers **MUST** select the helper whose semantics match their intent; using `hasExpressionMarker` where `containsExpression` or `isExpression` is needed (or vice versa) constitutes non-conformance.
64+
5. New expression-detection predicates **SHOULD** be added to `expression_patterns.go` rather than introduced inline in consumer files.
65+
66+
### CLI Version State
67+
68+
1. `pkg/cli` **MUST NOT** maintain its own local version variable or an `init()` function that synchronizes version state from `pkg/workflow`.
69+
2. `cli.GetVersion()` **MUST** delegate directly to `workflow.GetVersion()` with no local caching or transformation.
70+
3. `cli.SetVersionInfo(v string)` **MUST** delegate directly to `workflow.SetVersion(v)` with no local storage.
71+
4. All version state **MUST** be owned exclusively by `pkg/workflow`; `pkg/cli` **MAY** expose wrapper functions for backwards-compatible API surface but **MUST NOT** shadow or duplicate the underlying state.
72+
73+
### Conformance
74+
75+
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, defining expression-detection helpers outside `expression_patterns.go` or maintaining independent version state in `pkg/cli` — constitutes non-conformance.
76+
77+
---
78+
79+
*This is a DRAFT ADR generated by the [Design Decision Gate](https://github.com/github/gh-aw/actions/runs/24632327310) workflow. The PR author must review, complete, and finalize this document before the PR can merge.*

pkg/cli/version.go

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,12 @@ package cli
22

33
import "github.com/github/gh-aw/pkg/workflow"
44

5-
// Package-level version information
6-
var (
7-
version = "dev"
8-
)
9-
10-
func init() {
11-
// Set the version in the workflow package so NewCompiler() auto-detects it
12-
workflow.SetVersion(version)
13-
}
14-
15-
// SetVersionInfo sets the version information for the CLI and workflow package
5+
// SetVersionInfo sets version information for the workflow package.
166
func SetVersionInfo(v string) {
17-
version = v
18-
workflow.SetVersion(v) // Keep workflow package in sync
7+
workflow.SetVersion(v)
198
}
209

21-
// GetVersion returns the current version
10+
// GetVersion returns the current version.
2211
func GetVersion() string {
23-
return version
12+
return workflow.GetVersion()
2413
}

pkg/cli/version_test.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
//go:build !integration
2+
3+
package cli
4+
5+
import (
6+
"testing"
7+
8+
"github.com/github/gh-aw/pkg/workflow"
9+
"github.com/stretchr/testify/assert"
10+
)
11+
12+
func TestGetVersionDelegatesToWorkflowVersion(t *testing.T) {
13+
originalVersion := workflow.GetVersion()
14+
defer workflow.SetVersion(originalVersion)
15+
16+
workflow.SetVersion("workflow-direct")
17+
assert.Equal(t, "workflow-direct", GetVersion(), "cli.GetVersion should read workflow version")
18+
}
19+
20+
func TestSetVersionInfoUpdatesWorkflowVersion(t *testing.T) {
21+
originalVersion := workflow.GetVersion()
22+
defer workflow.SetVersion(originalVersion)
23+
24+
SetVersionInfo("set-version-info")
25+
assert.Equal(t, "set-version-info", workflow.GetVersion(), "SetVersionInfo should set workflow version")
26+
assert.Equal(t, "set-version-info", GetVersion(), "cli.GetVersion should return workflow version")
27+
}

pkg/workflow/dispatch_repository_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,42 @@ func TestValidateDispatchRepository_GitHubExpression(t *testing.T) {
362362
assert.NoError(t, err, "GitHub Actions expressions should be accepted without format validation")
363363
}
364364

365+
// TestValidateDispatchRepository_PartialExpressionMarker tests that values with only
366+
// the opening expression marker are still treated as dynamic values.
367+
func TestValidateDispatchRepository_PartialExpressionMarker(t *testing.T) {
368+
compiler := NewCompiler(WithVersion("1.0.0"))
369+
370+
tmpDir := t.TempDir()
371+
awDir := filepath.Join(tmpDir, ".github", "aw")
372+
err := os.MkdirAll(awDir, 0755)
373+
require.NoError(t, err)
374+
375+
workflowPath := filepath.Join(awDir, "dispatcher.md")
376+
err = os.WriteFile(workflowPath, []byte("---\non: issues\n---\ntest"), 0644)
377+
require.NoError(t, err)
378+
379+
workflowData := &WorkflowData{
380+
SafeOutputs: &SafeOutputsConfig{
381+
DispatchRepository: &DispatchRepositoryConfig{
382+
Tools: map[string]*DispatchRepositoryToolConfig{
383+
"trigger_ci": {
384+
Workflow: "ci.yml",
385+
EventType: "ci_trigger",
386+
// Intentionally incomplete expressions: gh-aw should treat marker-based
387+
// values as dynamic and skip static slug validation. GitHub Actions will
388+
// still fail later if the expression itself is malformed at runtime.
389+
Repository: "${{ vars.TARGET_REPOSITORY",
390+
AllowedRepositories: []string{"${{ vars.ALLOWED_REPOSITORY", "org/static-repo"},
391+
},
392+
},
393+
},
394+
},
395+
}
396+
397+
err = compiler.validateDispatchRepository(workflowData, workflowPath)
398+
assert.NoError(t, err, "Repository values with expression markers should bypass static slug validation")
399+
}
400+
365401
// TestValidateDispatchRepository_EmptyTools tests error when no tools are defined
366402
func TestValidateDispatchRepository_EmptyTools(t *testing.T) {
367403
compiler := NewCompiler(WithVersion("1.0.0"))

pkg/workflow/dispatch_repository_validation.go

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ func (c *Compiler) validateDispatchRepository(data *WorkflowData, workflowPath s
6565
}
6666

6767
// Validate single repository format (skip if it looks like a GitHub Actions expression)
68-
if hasRepository && !isGitHubActionsExpression(tool.Repository) {
68+
if hasRepository && !hasExpressionMarker(tool.Repository) {
6969
if !repoSlugPattern.MatchString(tool.Repository) {
7070
repoFmtErr := fmt.Errorf("dispatch_repository: tool %q has invalid 'repository' format %q (expected 'owner/repo')", toolKey, tool.Repository)
7171
if returnErr := collector.Add(repoFmtErr); returnErr != nil {
@@ -76,7 +76,7 @@ func (c *Compiler) validateDispatchRepository(data *WorkflowData, workflowPath s
7676

7777
// Validate allowed_repositories format
7878
for _, repo := range tool.AllowedRepositories {
79-
if isGitHubActionsExpression(repo) {
79+
if hasExpressionMarker(repo) {
8080
continue
8181
}
8282
// Allow glob patterns like "org/*"
@@ -99,9 +99,3 @@ func (c *Compiler) validateDispatchRepository(data *WorkflowData, workflowPath s
9999

100100
return collector.FormattedError("dispatch_repository")
101101
}
102-
103-
// isGitHubActionsExpression returns true if the string contains a GitHub Actions
104-
// expression syntax (${{ }}), which should not be validated as a static value.
105-
func isGitHubActionsExpression(value string) bool {
106-
return strings.Contains(value, "${{")
107-
}

pkg/workflow/expression_patterns.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,35 @@ package workflow
5858

5959
import (
6060
"regexp"
61+
"strings"
6162

6263
"github.com/github/gh-aw/pkg/logger"
6364
)
6465

66+
// hasExpressionMarker reports whether s contains a GitHub Actions expression opening marker.
67+
// This is a permissive check used in scenarios where partial expressions should be treated
68+
// as dynamic values.
69+
func hasExpressionMarker(s string) bool {
70+
return strings.Contains(s, "${{")
71+
}
72+
73+
// containsExpression reports whether s contains a complete non-empty GitHub Actions expression.
74+
// A complete expression has a "${{" marker that appears before a closing "}}" marker
75+
// with at least one character between them.
76+
func containsExpression(s string) bool {
77+
_, afterOpen, found := strings.Cut(s, "${{")
78+
if !found {
79+
return false
80+
}
81+
closeIdx := strings.Index(afterOpen, "}}")
82+
return closeIdx > 0
83+
}
84+
85+
// isExpression reports whether the entire string s is a GitHub Actions expression.
86+
func isExpression(s string) bool {
87+
return strings.HasPrefix(s, "${{") && strings.HasSuffix(s, "}}")
88+
}
89+
6590
var expressionPatternsLog = logger.New("workflow:expression_patterns")
6691

6792
func init() {

pkg/workflow/expression_patterns_test.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,3 +456,66 @@ func TestRangePattern(t *testing.T) {
456456
})
457457
}
458458
}
459+
460+
func TestExpressionHelpers(t *testing.T) {
461+
tests := []struct {
462+
name string
463+
input string
464+
wantHasMarker bool
465+
wantContainsExpr bool
466+
wantWholeExprOnly bool
467+
}{
468+
{
469+
name: "full expression",
470+
input: "${{ github.actor }}",
471+
wantHasMarker: true,
472+
wantContainsExpr: true,
473+
wantWholeExprOnly: true,
474+
},
475+
{
476+
name: "embedded expression",
477+
input: "prefix ${{ github.actor }} suffix",
478+
wantHasMarker: true,
479+
wantContainsExpr: true,
480+
wantWholeExprOnly: false,
481+
},
482+
{
483+
name: "partial expression marker only",
484+
input: "${{ github.actor",
485+
wantHasMarker: true,
486+
wantContainsExpr: false,
487+
wantWholeExprOnly: false,
488+
},
489+
{
490+
name: "empty expression body",
491+
input: "${{}}",
492+
// isExpression is a strict wrapper check, while containsExpression
493+
// requires a non-empty expression body between markers.
494+
wantHasMarker: true,
495+
wantContainsExpr: false,
496+
wantWholeExprOnly: true,
497+
},
498+
{
499+
name: "wrong marker order",
500+
input: "}} before ${{",
501+
wantHasMarker: true,
502+
wantContainsExpr: false,
503+
wantWholeExprOnly: false,
504+
},
505+
{
506+
name: "no expression",
507+
input: "plain text",
508+
wantHasMarker: false,
509+
wantContainsExpr: false,
510+
wantWholeExprOnly: false,
511+
},
512+
}
513+
514+
for _, tt := range tests {
515+
t.Run(tt.name, func(t *testing.T) {
516+
assert.Equal(t, tt.wantHasMarker, hasExpressionMarker(tt.input), "hasExpressionMarker result mismatch")
517+
assert.Equal(t, tt.wantContainsExpr, containsExpression(tt.input), "containsExpression result mismatch")
518+
assert.Equal(t, tt.wantWholeExprOnly, isExpression(tt.input), "isExpression result mismatch")
519+
})
520+
}
521+
}

pkg/workflow/expression_safety_validation.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,7 @@ func validateSingleExpression(expression string, opts ExpressionValidationOption
226226
rightExpr := strings.TrimSpace(orMatch[2])
227227

228228
leftErr := validateSingleExpression(leftExpr, opts)
229-
leftIsSafe := leftErr == nil && !containsExpression(opts.UnauthorizedExpressions, leftExpr)
229+
leftIsSafe := leftErr == nil && !containsExpressionInList(opts.UnauthorizedExpressions, leftExpr)
230230

231231
if leftIsSafe {
232232
// Check if right side is a literal string (single, double, or backtick quotes)
@@ -242,7 +242,7 @@ func validateSingleExpression(expression string, opts ExpressionValidationOption
242242
} else {
243243
// If right side is also a safe expression, recursively check it
244244
rightErr := validateSingleExpression(rightExpr, opts)
245-
if rightErr == nil && !containsExpression(opts.UnauthorizedExpressions, rightExpr) {
245+
if rightErr == nil && !containsExpressionInList(opts.UnauthorizedExpressions, rightExpr) {
246246
allowed = true
247247
}
248248
}
@@ -296,7 +296,7 @@ func validateSingleExpression(expression string, opts ExpressionValidationOption
296296
return nil
297297
}
298298

299-
// containsExpression checks if an expression is in the list
300-
func containsExpression(list *[]string, expr string) bool {
299+
// containsExpressionInList checks if an expression is in the list.
300+
func containsExpressionInList(list *[]string, expr string) bool {
301301
return slices.Contains(*list, expr)
302302
}

pkg/workflow/frontmatter_extraction_metadata.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ func (c *Compiler) extractToolsTimeout(tools map[string]any) (string, error) {
173173
frontmatterMetadataLog.Printf("Extracting tools.timeout value: type=%T", timeoutValue)
174174
// Handle GitHub Actions expression strings
175175
if strVal, ok := timeoutValue.(string); ok {
176-
if isExpressionString(strVal) {
176+
if isExpression(strVal) {
177177
frontmatterMetadataLog.Printf("Extracted tools.timeout as expression: %s", strVal)
178178
return strVal, nil
179179
}
@@ -224,7 +224,7 @@ func (c *Compiler) extractToolsStartupTimeout(tools map[string]any) (string, err
224224
if timeoutValue, exists := tools["startup-timeout"]; exists {
225225
// Handle GitHub Actions expression strings
226226
if strVal, ok := timeoutValue.(string); ok {
227-
if isExpressionString(strVal) {
227+
if isExpression(strVal) {
228228
return strVal, nil
229229
}
230230
return "", fmt.Errorf("tools.startup-timeout must be an integer or a GitHub Actions expression (e.g. '${{ inputs.startup-timeout }}'), got string %q", strVal)

pkg/workflow/safe_outputs_max_validation.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ func validateSafeOutputsMax(config *SafeOutputsConfig) error {
6464
}
6565

6666
maxPtr, ok := maxField.Interface().(*string)
67-
if !ok || maxPtr == nil || isExpressionString(*maxPtr) {
67+
if !ok || maxPtr == nil || isExpression(*maxPtr) {
6868
continue
6969
}
7070

@@ -94,7 +94,7 @@ func validateSafeOutputsMax(config *SafeOutputsConfig) error {
9494

9595
for _, toolName := range sortedToolNames {
9696
tool := config.DispatchRepository.Tools[toolName]
97-
if tool == nil || tool.Max == nil || isExpressionString(*tool.Max) {
97+
if tool == nil || tool.Max == nil || isExpression(*tool.Max) {
9898
continue
9999
}
100100

0 commit comments

Comments
 (0)