Skip to content
Closed
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
1 change: 1 addition & 0 deletions .github/workflows/smoke-copilot-auto.lock.yml

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

48 changes: 35 additions & 13 deletions actions/setup/sh/install_copilot_cli.sh
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ download_compat_json() {
local source_file="$2"

echo "Attempting to download compatibility matrix from ${COMPAT_URL}..." >&2
if curl -fsSL --retry 3 --retry-delay 5 -o "$compat_file" "$COMPAT_URL"; then
if curl -fsSL --retry 6 --retry-delay 5 --retry-all-errors -o "$compat_file" "$COMPAT_URL"; then
echo "$COMPAT_URL" > "$source_file"
echo "Successfully downloaded compatibility matrix from ${COMPAT_URL}" >&2
return 0
Expand Down Expand Up @@ -368,6 +368,7 @@ find_cached_copilot_bin() {
local candidate_version_normalized=""
local best_candidate=""
local best_version=""
local used_requested_fallback=false

echo "Searching toolcache for GitHub Copilot CLI (requested: ${requested_version}, arch: ${ARCH_NAME}, range: ${min_version:-none}..${max_version:-none})..." >&2
if [ -n "$cache_ttl_days" ]; then
Expand Down Expand Up @@ -422,8 +423,12 @@ find_cached_copilot_bin() {
printf '%s\n' "$candidate"
return 0
fi
echo " Skipping candidate (version mismatch: want ${requested_version_normalized}, got ${candidate_version_normalized})" >&2
continue
if [ -z "$min_version" ] && [ -z "$max_version" ]; then
echo " Skipping candidate (version mismatch: want ${requested_version_normalized}, got ${candidate_version_normalized})" >&2
continue
fi
echo " Exact version mismatch (want ${requested_version_normalized}, got ${candidate_version_normalized}); checking compat window fallback" >&2
used_requested_fallback=true
fi

if [ -n "$min_version" ] && version_is_greater "$min_version" "$candidate_version_normalized"; then

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/diagnosing-bugs] used_requested_fallback is set whenever any version-mismatched candidate is encountered, even if that candidate is later filtered out by the min/max window check. The log message at line 469 then fires for any cached miss, potentially misleading operators into thinking a fallback occurred when it did not.

💡 Suggestion

Only log the fallback message if the selected best_candidate was genuinely reached via the compat-range path after an explicit-version mismatch. One approach: capture the version that triggered the flag and compare against best_version before emitting the message.

@copilot please address this.

Expand Down Expand Up @@ -461,6 +466,9 @@ find_cached_copilot_bin() {
done

if [ -n "$best_candidate" ]; then
if [ "$used_requested_fallback" = "true" ] && [ -n "$requested_version_normalized" ]; then
echo " Falling back to compat-range cached version: ${best_version} (requested ${requested_version_normalized})" >&2
fi
echo " Selected best cached version: ${best_version} at ${best_candidate}" >&2
printf '%s\n' "$best_candidate"
return 0
Expand Down Expand Up @@ -503,22 +511,36 @@ EOF
TEMP_DIR=$(mktemp -d)
trap 'rm -rf "$TEMP_DIR"' EXIT

# Resolve a compatible Copilot version from compat matrix unless the caller passed an explicit version.
if [ -z "$VERSION" ]; then
echo "No explicit Copilot CLI version requested. Attempting compat-driven version resolution..."
# Resolve compat metadata before toolcache lookup so explicit version requests can
# still prefer a cached CLI inside the published compatibility window.
if [ -n "$COMPILED_GH_AW_VERSION" ]; then
Comment on lines +514 to +516
if RESOLVED_COMPAT_INFO="$(resolve_version_from_compat "$COMPILED_GH_AW_VERSION" "${TEMP_DIR}/compat.json")"; then
Comment on lines +516 to 517
IFS='|' read -r RESOLVED_COMPAT_VERSION COMPAT_MATCHED_MIN_AGENT COMPAT_MATCHED_MAX_AGENT COMPAT_CACHE_TTL_DAYS <<< "$RESOLVED_COMPAT_INFO"
VERSION="$RESOLVED_COMPAT_VERSION"
REQUESTED_VERSION="latest"
echo "Using compat-resolved Copilot CLI window: ${COMPAT_MATCHED_MIN_AGENT}..${COMPAT_MATCHED_MAX_AGENT}"
echo "Will install compat max-agent ${VERSION} if no cached version satisfies the window."
else
if [ -z "$VERSION" ]; then
echo "No explicit Copilot CLI version requested. Attempting compat-driven version resolution..."
VERSION="$RESOLVED_COMPAT_VERSION"
REQUESTED_VERSION="latest"
echo "Using compat-resolved Copilot CLI window: ${COMPAT_MATCHED_MIN_AGENT}..${COMPAT_MATCHED_MAX_AGENT}"
echo "Will install compat max-agent ${VERSION} if no cached version satisfies the window."
else
echo "Explicit Copilot CLI version argument provided (${VERSION}); using compatibility window ${COMPAT_MATCHED_MIN_AGENT}..${COMPAT_MATCHED_MAX_AGENT} for toolcache lookup."
fi
elif [ -z "$VERSION" ]; then
echo "ERROR: Failed to resolve Copilot CLI version from compatibility matrix." >&2
echo "ERROR: Cannot install without a compatible version." >&2
echo "To fix: Pass an explicit version as an argument (e.g., 'install_copilot_cli.sh 1.0.56')" >&2
echo " or ensure GH_AW_COMPILED_VERSION matches a row in .github/aw/compat.json" >&2
exit 1
else
echo "Explicit Copilot CLI version argument provided (${VERSION}); compatibility matrix unavailable; exact-match toolcache lookup only."
fi
elif [ -z "$VERSION" ]; then
echo "No explicit Copilot CLI version requested. Attempting compat-driven version resolution..."
echo "ERROR: Failed to resolve Copilot CLI version from compatibility matrix." >&2
echo "ERROR: Cannot install without a compatible version." >&2
echo "To fix: Pass an explicit version as an argument (e.g., 'install_copilot_cli.sh 1.0.56')" >&2
echo " or ensure GH_AW_COMPILED_VERSION matches a row in .github/aw/compat.json" >&2
exit 1

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/diagnosing-bugs] The refactored elif [ -z "$VERSION" ] branch (when COMPILED_GH_AW_VERSION is unset and no VERSION is provided) duplicates the error messages and exit 1 that already exist in the else branch just a few lines earlier. This creates two nearly-identical error paths that diverge subtly (the outer echo line is present in one but not the other), making future maintenance error-prone.

💡 Suggestion

Extract the no-version error into a small helper function, e.g. die_no_version, and call it from both branches. This eliminates duplication and ensures the error output stays consistent.

die_no_version() {
  echo 'ERROR: Failed to resolve Copilot CLI version from compatibility matrix.' >&2
  echo 'ERROR: Cannot install without a compatible version.' >&2
  echo "To fix: Pass an explicit version as an argument (e.g., 'install_copilot_cli.sh 1.0.56')" >&2
  echo '   or ensure GH_AW_COMPILED_VERSION matches a row in .github/aw/compat.json' >&2
  exit 1
}

@copilot please address this.

else
echo "Explicit Copilot CLI version argument provided (${VERSION}); skipping compat matrix resolution."
fi
Expand Down Expand Up @@ -558,11 +580,11 @@ CHECKSUMS_URL="${BASE_URL}/SHA256SUMS.txt"

# Download checksums
echo "Downloading checksums from ${CHECKSUMS_URL}..."
curl -fsSL --retry 3 --retry-delay 5 -o "${TEMP_DIR}/SHA256SUMS.txt" "${CHECKSUMS_URL}"
curl -fsSL --retry 6 --retry-delay 5 --retry-all-errors -o "${TEMP_DIR}/SHA256SUMS.txt" "${CHECKSUMS_URL}"

# Download binary tarball
echo "Downloading binary from ${TARBALL_URL}..."
curl -fsSL --retry 3 --retry-delay 5 -o "${TEMP_DIR}/${TARBALL_NAME}" "${TARBALL_URL}"
curl -fsSL --retry 6 --retry-delay 5 --retry-all-errors -o "${TEMP_DIR}/${TARBALL_NAME}" "${TARBALL_URL}"

# Verify checksum
echo "Verifying SHA256 checksum for ${TARBALL_NAME}..."
Expand Down
15 changes: 9 additions & 6 deletions docs/src/content/docs/reference/engines.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,11 @@ engine:

### Pinning a Specific Engine Version

By default, workflows install the latest available version of each engine CLI. To pin to a specific version, set `version` to the desired release:
Built-in engines install compiler-pinned default CLI versions. For engines that support version pinning, set `version` to the desired release:

| Engine | `id` | Example `version` |
|--------|------|-------------------|
| GitHub Copilot CLI | `copilot` | `"0.0.422"` |
| GitHub Copilot CLI | `copilot` | Not supported |
| Claude Code | `claude` | `"2.1.70"` |
| Codex | `codex` | `"0.111.0"` |
| Gemini CLI | `gemini` | `"0.31.0"` |
Expand All @@ -75,13 +75,16 @@ By default, workflows install the latest available version of each engine CLI. T

```yaml wrap
engine:
id: copilot
version: "0.0.422"
id: claude
version: "2.1.70"
```

Pinning is useful when you need reproducible builds or want to avoid breakage from a new CLI release while testing. Remember to update the pinned version periodically to pick up bug fixes and new features.

`version` also accepts a GitHub Actions expression string, enabling `workflow_call` reusable workflows to parameterize the engine version via caller inputs. Expressions are passed injection-safely through an environment variable rather than direct shell interpolation:
> [!IMPORTANT]
> `engine.version` is ignored for the Copilot engine. The compiler emits a warning and installs its pinned default Copilot CLI version instead.
Comment on lines +84 to +85

For engines that honor `version`, the field also accepts a GitHub Actions expression string, enabling `workflow_call` reusable workflows to parameterize the engine version via caller inputs. Expressions are passed injection-safely through an environment variable rather than direct shell interpolation:

```yaml wrap
on:
Expand All @@ -94,7 +97,7 @@ on:
---

engine:
id: copilot
id: claude
version: ${{ inputs.engine-version }}
```

Expand Down
90 changes: 89 additions & 1 deletion pkg/cli/install_copilot_cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,6 @@ exit 97
assert.Contains(t, string(curlLogContent), "/compat.json", "compat matrix should be downloaded")
assert.NotContains(t, string(curlLogContent), "SHA256SUMS.txt", "release downloads should not run when toolcache is hit")

// Ensure compat.json is only fetched once — no double network fallback.
curlLines := strings.Split(strings.TrimSpace(string(curlLogContent)), "\n")
compatFetches := 0
for _, line := range curlLines {
Expand All @@ -184,6 +183,95 @@ exit 97
assert.Equal(t, 1, compatFetches, "compat.json should be fetched exactly once (no double fallback)")
}

func TestInstallCopilotCLIScriptExplicitVersionFallsBackToCompatToolcache(t *testing.T) {
const explicitVersion = "1.0.75"
const compatMinVersion = "1.0.21"
const compatMaxVersion = "1.0.56"

wd, err := os.Getwd()
require.NoError(t, err, "Failed to get working directory")

projectRoot := filepath.Join(wd, "..", "..")
installScript := filepath.Join(projectRoot, "actions", "setup", "sh", "install_copilot_cli.sh")

tempDir := t.TempDir()
toolcacheBin := filepath.Join(tempDir, "toolcache", "copilot-cli", compatMaxVersion, "x64", "bin")
require.NoError(t, os.MkdirAll(toolcacheBin, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(toolcacheBin, "copilot"), []byte("#!/usr/bin/env bash\necho 'copilot "+compatMaxVersion+"'\n"), 0o755))

fakeBinDir := filepath.Join(tempDir, "fake-bin")
require.NoError(t, os.MkdirAll(fakeBinDir, 0o755))

curlLog := filepath.Join(tempDir, "curl.log")
sudoScript := filepath.Join(fakeBinDir, "sudo")
curlScript := filepath.Join(fakeBinDir, "curl")

require.NoError(t, os.WriteFile(sudoScript, []byte(`#!/usr/bin/env bash
if [ "${1:-}" = "chown" ]; then
exit 0
fi
exec "$@"
`), 0o755))
require.NoError(t, os.WriteFile(curlScript, []byte(`#!/usr/bin/env bash
set -euo pipefail
output_file=""
url=""
while [ "$#" -gt 0 ]; do
case "$1" in
-o)
output_file="$2"
shift 2
;;
*)
url="$1"
shift
;;
esac
done
echo "$url" >> "`+curlLog+`"
if [[ "$url" == *"/compat.json" ]]; then

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/tdd] The fake curl script silently drops all flags (-fsSL, --retry, --retry-delay, --retry-all-errors) except -o and the final positional URL. This means the test doesn't verify the new --retry-all-errors flag added in this PR is actually passed. A future change that removes it would still pass this test.

💡 Suggestion

Add a check that the expected curl flags appear in the log, or make the fake curl echo its full $@ to the log so the test can assert on flags. For example:

echo "$@" >> "$(curlLog)"

Then assert:

assert.Contains(t, string(curlLogContent), "--retry-all-errors")

@copilot please address this.

cat > "$output_file" <<'JSON'
{
"agent-compat-v1": {
"copilot": [
{
"min-gh-aw": "0.72.0",
"max-gh-aw": "*",
"min-agent": "`+compatMinVersion+`",
"max-agent": "`+compatMaxVersion+`"
}
]
}
}
JSON
exit 0
fi
echo "unexpected URL: $url" >&2
exit 97
`), 0o755))

githubPath := filepath.Join(tempDir, "github-path")
cmd := exec.Command("bash", installScript, explicitVersion)
cmd.Env = append(os.Environ(),
"RUNNER_TOOL_CACHE="+filepath.Join(tempDir, "toolcache"),
"GITHUB_PATH="+githubPath,
"GH_AW_COMPILED_VERSION=v0.83.1",
"PATH="+fakeBinDir+":"+os.Getenv("PATH"),
)

output, err := cmd.CombinedOutput()
require.NoError(t, err, "install_copilot_cli.sh should prefer compat toolcache for explicit versions: %s", output)

assert.Contains(t, string(output), "Explicit Copilot CLI version argument provided ("+explicitVersion+"); using compatibility window "+compatMinVersion+".."+compatMaxVersion+" for toolcache lookup.")
assert.Contains(t, string(output), "Falling back to compat-range cached version: "+compatMaxVersion+" (requested "+explicitVersion+")")
assert.Contains(t, string(output), "Using cached GitHub Copilot CLI")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/tdd] The new test covers the happy path (explicit version → compat window hit → toolcache used). It doesn't cover the negative case: explicit version requested, compat window resolved, but no toolcache entry within that window. That path should fall through to a network download; without a test it's easy to accidentally break.

💡 Suggestion

Add a companion test TestInstallCopilotCLIScriptExplicitVersionNoCompatCacheFallsThrough that sets up an empty toolcache and verifies the script proceeds to the network download path (or exits non-zero if the fake tarball server isn't present, whichever is appropriate).

@copilot please address this.


curlLogContent, err := os.ReadFile(curlLog)
require.NoError(t, err)
assert.Contains(t, string(curlLogContent), "/compat.json")
assert.NotContains(t, string(curlLogContent), "SHA256SUMS.txt", "release downloads should not run when compat fallback hits toolcache")
}

func TestInstallCopilotCLIScriptRootlessModeUsesRealScriptWithToolcacheAndNoSudo(t *testing.T) {
wd, err := os.Getwd()
require.NoError(t, err, "Failed to get working directory")
Expand Down
10 changes: 10 additions & 0 deletions pkg/workflow/engine_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,16 @@ func (c *Compiler) validateEngineVersion(workflowData *WorkflowData) error {
return nil
}

if workflowData.EngineConfig.ID == string(constants.CopilotEngine) {
warningMsg := fmt.Sprintf(
"engine.version is ignored for the Copilot engine. The compiler installs the pinned Copilot CLI version %s instead.",
constants.DefaultCopilotVersion,
)
Comment on lines +92 to +95
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(warningMsg))
c.IncrementWarningCount()
return nil
}
Comment on lines +91 to +99

if !strings.EqualFold(workflowData.EngineConfig.Version, "latest") {
return nil
}
Expand Down
35 changes: 34 additions & 1 deletion pkg/workflow/engine_validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"strings"
"testing"

"github.com/github/gh-aw/pkg/constants"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
Expand Down Expand Up @@ -282,7 +283,7 @@ func TestValidateEngineVersion(t *testing.T) {
},
{
name: "pinned version",
engineCfg: &EngineConfig{Version: "2.1.92"},
engineCfg: &EngineConfig{ID: "claude", Version: "2.1.92"},
expectWarn: false,
expectError: false,
},
Expand All @@ -307,6 +308,12 @@ func TestValidateEngineVersion(t *testing.T) {
expectWarn: true,
expectError: false,
},
{
name: "copilot pinned version warns",
engineCfg: &EngineConfig{ID: "copilot", Version: "1.2.3"},
expectWarn: true,
expectError: false,
},
}

for _, tt := range tests {
Expand Down Expand Up @@ -338,6 +345,32 @@ func TestValidateEngineVersion(t *testing.T) {
}
}

func TestValidateEngineVersion_CopilotPinWarning(t *testing.T) {
compiler := NewCompiler()
workflowData := &WorkflowData{
EngineConfig: &EngineConfig{
ID: "copilot",
Version: "1.2.3",
},
}

stderr := captureStderr(func() {
if err := compiler.validateEngineVersion(workflowData); err != nil {
t.Fatalf("Expected no error but got: %v", err)
}
})

if compiler.warningCount != 1 {
t.Fatalf("Expected 1 warning, got %d", compiler.warningCount)
}
if !strings.Contains(stderr, "engine.version is ignored for the Copilot engine") {
t.Fatalf("Expected Copilot pin warning in stderr, got: %s", stderr)
}
if !strings.Contains(stderr, string(constants.DefaultCopilotVersion)) {
t.Fatalf("Expected default Copilot version in warning, got: %s", stderr)
}
}

func TestValidateEngineHarnessScript(t *testing.T) {
tests := []struct {
name string
Expand Down
Loading