-
Notifications
You must be signed in to change notification settings - Fork 354
Refactor expression helper semantics into a single workflow source and remove duplicate CLI version state #27198
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 4 commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
8dc8e29
Initial plan
Copilot 647660b
refactor: consolidate expression helpers and unify version state
Copilot ed738f3
test: clarify expression helper semantics in tests and comments
Copilot 6db41c7
docs(adr): add draft ADR-27198 for expression helper consolidation an…
github-actions[bot] f47e963
test: add coverage for expression marker edge cases
Copilot 13c40d3
test: document partial expression marker runtime behavior
Copilot File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
79 changes: 79 additions & 0 deletions
79
docs/adr/27198-consolidate-expression-helpers-and-unify-cli-version-state.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| # ADR-27198: Consolidate Expression Helpers and Unify CLI Version State | ||
|
|
||
| **Date**: 2026-04-19 | ||
| **Status**: Draft | ||
| **Deciders**: pelikhan, Copilot | ||
|
|
||
| --- | ||
|
|
||
| ## Part 1 — Narrative (Human-Friendly) | ||
|
|
||
| ### Context | ||
|
|
||
| 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. | ||
|
|
||
| ### Decision | ||
|
|
||
| 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. | ||
|
|
||
| ### Alternatives Considered | ||
|
|
||
| #### Alternative 1: Keep per-file helpers but add linting rules | ||
|
|
||
| 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. | ||
|
|
||
| #### Alternative 2: Introduce a separate `expressionutil` sub-package | ||
|
|
||
| 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. | ||
|
|
||
| #### Alternative 3: Retain the dual version state with better documentation | ||
|
|
||
| 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. | ||
|
|
||
| ### Consequences | ||
|
|
||
| #### Positive | ||
| - Expression-check semantics are defined once; all call sites share the same behavior, eliminating silent divergence. | ||
| - Version state has a single owner (`pkg/workflow`); the CLI layer is a transparent pass-through with no independent state to maintain. | ||
| - Reduced surface area for future contributors who previously had to know which file's helper was the "correct" one. | ||
| - Test coverage can now target the helpers directly in `expression_patterns_test.go` instead of being spread across consumer tests. | ||
|
|
||
| #### Negative | ||
| - All call sites in `pkg/workflow` must be updated when helper semantics change; there is no local override capability per file. | ||
| - The `expression_patterns.go` file is already large (regex patterns + helper functions); additional consolidation could make it a maintenance hotspot. | ||
|
|
||
| #### Neutral | ||
| - `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. | ||
| - New tests (`expression_patterns_test.go`, `version_test.go`) were added to cover the consolidated logic and the delegation contract. | ||
|
|
||
| --- | ||
|
|
||
| ## Part 2 — Normative Specification (RFC 2119) | ||
|
|
||
| > 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). | ||
|
|
||
| ### Expression Detection Helpers | ||
|
|
||
| 1. All GitHub Actions expression-detection predicates used within `pkg/workflow` **MUST** be defined in `pkg/workflow/expression_patterns.go`. | ||
| 2. Individual `pkg/workflow` source files **MUST NOT** define their own local boolean helpers that check for `${{` or `}}` patterns. | ||
| 3. The three canonical helpers **MUST** provide the following distinct semantics: | ||
| - `hasExpressionMarker(s string) bool` — returns `true` if `s` contains the substring `${{` (permissive, partial-expression check). | ||
| - `containsExpression(s string) bool` — returns `true` if `s` contains a complete non-empty expression (a `${{` followed by at least one character before `}}`). | ||
| - `isExpression(s string) bool` — returns `true` if the entire string `s` is a single expression (starts with `${{` and ends with `}}`). | ||
| 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. | ||
| 5. New expression-detection predicates **SHOULD** be added to `expression_patterns.go` rather than introduced inline in consumer files. | ||
|
|
||
| ### CLI Version State | ||
|
|
||
| 1. `pkg/cli` **MUST NOT** maintain its own local version variable or an `init()` function that synchronizes version state from `pkg/workflow`. | ||
| 2. `cli.GetVersion()` **MUST** delegate directly to `workflow.GetVersion()` with no local caching or transformation. | ||
| 3. `cli.SetVersionInfo(v string)` **MUST** delegate directly to `workflow.SetVersion(v)` with no local storage. | ||
| 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. | ||
|
|
||
| ### Conformance | ||
|
|
||
| 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. | ||
|
|
||
| --- | ||
|
|
||
| *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.* |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| //go:build !integration | ||
|
|
||
| package cli | ||
|
|
||
| import ( | ||
| "testing" | ||
|
|
||
| "github.com/github/gh-aw/pkg/workflow" | ||
| "github.com/stretchr/testify/assert" | ||
| ) | ||
|
|
||
| func TestGetVersionDelegatesToWorkflowVersion(t *testing.T) { | ||
| originalVersion := workflow.GetVersion() | ||
| defer workflow.SetVersion(originalVersion) | ||
|
|
||
| workflow.SetVersion("workflow-direct") | ||
| assert.Equal(t, "workflow-direct", GetVersion(), "cli.GetVersion should read workflow version") | ||
| } | ||
|
|
||
| func TestSetVersionInfoUpdatesWorkflowVersion(t *testing.T) { | ||
| originalVersion := workflow.GetVersion() | ||
| defer workflow.SetVersion(originalVersion) | ||
|
|
||
| SetVersionInfo("set-version-info") | ||
| assert.Equal(t, "set-version-info", workflow.GetVersion(), "SetVersionInfo should set workflow version") | ||
| assert.Equal(t, "set-version-info", GetVersion(), "cli.GetVersion should return workflow version") | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
containsExpressioncan return true for strings that are not actually a complete${{ ... }}expression because it just looks for the first${{and then the first}}anywhere later in the string. For example, it will treat${{ '}}'(no closing marker) or a string with an unmatched opening marker followed by a later unrelated}}as containing a complete expression. Since this helper is now used for validation/quoting decisions, consider implementing it using the existingExpressionPattern(or a small scanner that finds a properly paired${{…}}) so it only returns true when a real expression token is present.