Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/smoke-gemini.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

44 changes: 44 additions & 0 deletions docs/adr/48519-honor-engine-version-for-copilot.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# ADR-48519: Honor engine.version for Copilot CLI Installation

**Date**: 2026-07-28
**Status**: Draft
**Deciders**: pelikhan, copilot-swe-agent

---

### Context

The `engine.version` field in workflow configuration allows users to pin the version of a coding engine (Copilot, Claude, codex, gemini) installed during workflow execution. Claude, codex, and gemini engines all honored this field correctly, using the user-specified value when present and falling back to a default pinned constant otherwise. However, the Copilot engine's `GetInstallationSteps` silently ignored `engine.version` and always installed `DefaultCopilotVersion`, logging only a message that it was ignoring the user-set value. This inconsistency made `engine.version` a no-op for Copilot, violating the principle of least surprise and breaking user expectations established by the other three engine implementations.

### Decision

We will make the Copilot engine honor `engine.version` exactly as Claude, codex, and gemini do: when `EngineConfig.Version` is set, use it as the installation target; when it is absent, fall back to `DefaultCopilotVersion` and normalize `EngineConfig.Version` to that default so all downstream consumers in the same compile flow observe the effective installed value. The install script already skips `compat.json` resolution when an explicit version is provided, so no additional bypass logic is required.

### Alternatives Considered

#### Alternative 1: Keep Copilot always pinned to DefaultCopilotVersion

The existing behavior guaranteed a stable, tested Copilot CLI version regardless of user configuration, which reduced risk of users accidentally installing untested or incompatible versions. This was rejected because it made `engine.version` silently ineffective for Copilot while working for all other engines, creating inconsistent and confusing behavior. The field's documented contract implies it controls the installed version across all engines.

#### Alternative 2: Introduce a Copilot-specific version override field

A dedicated field (e.g., `engine.copilot_version`) could allow Copilot-specific version control without changing the behavior of `engine.version`. This was rejected because it adds unnecessary API surface, conflicts with the established cross-engine convention of `engine.version`, and would require documentation and migration for any users who expect the unified field to work.

### Consequences

#### Positive
- `engine.version` now behaves consistently across all four supported engines (Copilot, Claude, codex, gemini), fulfilling user expectations.
- Users can pin or override the Copilot CLI version in their workflow definitions, enabling version-locked reproducible builds or controlled rollouts of new CLI versions.
- Test coverage explicitly asserts the honored-version contract, preventing silent regression back to the ignore-version behavior.

#### Negative
- `compat.json` compatibility-matrix resolution is skipped when an explicit `engine.version` is provided, meaning users who specify an incompatible or unsupported version will receive no automated correction and may encounter runtime failures.
- Any workflow that previously set `engine.version` for Copilot expecting it to be ignored (e.g., to document an intent without changing behavior) will now have that version actually installed — a silent behavioral change at the call site.

#### Neutral
- `EngineConfig.Version` is still normalized to the effective installed version when no explicit version is set, preserving the existing contract for downstream consumers in the compile flow.
- The change is contained to `copilot_engine_installation.go`; the install script and `compat.json` integration path are unchanged.

---

*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.*
2 changes: 2 additions & 0 deletions pkg/workflow/compiler_yaml_lookups.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ func getVersionForSetup(data *WorkflowData) string {
return string(constants.DefaultClaudeCodeVersion)
case string(constants.CodexEngine):
return string(constants.DefaultCodexVersion)
case string(constants.GeminiEngine):
return string(constants.DefaultGeminiVersion)
case string(constants.OpenCodeEngine):
return string(constants.DefaultOpenCodeVersion)
case string(constants.PiEngine):
Expand Down
9 changes: 9 additions & 0 deletions pkg/workflow/copilot_engine_execution.go
Original file line number Diff line number Diff line change
Expand Up @@ -708,6 +708,9 @@ func (e *CopilotEngine) buildCopilotExecutionStep(workflowData *WorkflowData, co
// DefaultCopilotVersion. This preserves existing behavior while avoiding drift if
// DefaultCopilotVersion is ever lowered below CopilotNoAskUserMinVersion.
// - "latest": always returns true (latest is always a new release).
// - Expression (e.g. "${{ inputs.engine-version }}"): returns true. The version resolves
// at runtime so we cannot gate at compile time; the installer already handles expression
// versions via ENGINE_VERSION env-var injection, ensuring the right binary is installed.
// - Any semver string ≥ CopilotNoAskUserMinVersion: returns true.
// - Any semver string < CopilotNoAskUserMinVersion: returns false.
// - Non-semver string (e.g. a branch name): returns false (conservative).
Expand All @@ -716,6 +719,12 @@ func copilotSupportsNoAskUser(engineConfig *EngineConfig) bool {
if engineConfig != nil && engineConfig.Version != "" {
versionStr = engineConfig.Version
}
// Expression versions resolve at runtime; treat as supported so the generated execution
// flags match the installed binary for any version >= CopilotNoAskUserMinVersion.
if containsExpression(versionStr) {
copilotExecLog.Printf("copilotSupportsNoAskUser: expression version %q treated as supported", versionStr)
return true
}
return versionAtLeast(
versionStr,
string(constants.DefaultCopilotVersion),
Expand Down
23 changes: 12 additions & 11 deletions pkg/workflow/copilot_engine_installation.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,21 +142,22 @@ func (e *CopilotEngine) GetInstallationSteps(workflowData *WorkflowData) []GitHu
return appendCopilotLSPInstallSteps(steps, workflowData)
}

// Copilot CLI is pinned to the default version constant.
// Use engine.version if provided, otherwise fall back to the default pinned version.
// When no explicit version is set, normalize the engine config so downstream
// consumers observe the effective installed value.
copilotVersion := string(constants.DefaultCopilotVersion)
if workflowData.EngineConfig != nil {
if workflowData.EngineConfig.Version != "" {
copilotInstallLog.Printf("Ignoring pinned engine.version (%s): Copilot CLI install version is pinned to %s", workflowData.EngineConfig.Version, copilotVersion)
copilotVersion = workflowData.EngineConfig.Version

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in the latest commit. copilotSupportsNoAskUser now checks containsExpression(versionStr) before the semver gate and returns true for expression versions (e.g. ${{ inputs.engine-version }}). The rationale mirrors how "latest" is handled: the value resolves at runtime, so we can't gate at compile time. The installer already handles expression versions correctly via ENGINE_VERSION env-var injection, ensuring the right binary is installed. Two new test cases were added to TestCopilotSupportsNoAskUser for both input expression forms.

copilotInstallLog.Printf("Using engine.version for Copilot CLI installation: %s", copilotVersion)
} else {
// Normalize engine config version to the effective installed version so
// downstream checks that consult EngineConfig.Version stay consistent.
// This mutates workflowData by design because subsequent generation steps
// in the same compile flow should observe the effective installed version.
workflowData.EngineConfig.Version = copilotVersion
copilotInstallLog.Printf("No engine.version specified, using default Copilot CLI version: %s", copilotVersion)
}
// Normalize engine config version to effective installed version so
// downstream checks that consult EngineConfig.Version stay consistent.
// This applies even when the original version was empty (unset), so all
// downstream consumers observe the effective installed value.
// This mutates workflowData by design because subsequent generation steps
// in the same compile flow should observe the effective installed version.
// Callers that reuse the same WorkflowData instance should expect this
// field to be rewritten after installation-step generation.
workflowData.EngineConfig.Version = copilotVersion
}

// Use the installer script for global installation
Expand Down
10 changes: 10 additions & 0 deletions pkg/workflow/copilot_engine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2978,6 +2978,16 @@ func TestCopilotSupportsNoAskUser(t *testing.T) {
engineConfig: &EngineConfig{Version: "main"},
expected: false,
},
{
name: "expression version is treated as supported",
engineConfig: &EngineConfig{Version: "${{ inputs.engine-version }}"},
expected: true,
},
{
name: "github event input expression is treated as supported",
engineConfig: &EngineConfig{Version: "${{ github.event.inputs.engine-version }}"},
expected: true,
},
}

for _, tt := range tests {
Expand Down
Loading