diff --git a/.github/aw/compat.json b/.github/aw/compat.json index 0ea0160b78a..84c1772edeb 100644 --- a/.github/aw/compat.json +++ b/.github/aw/compat.json @@ -10,7 +10,7 @@ "min-gh-aw": "0.72.0", "max-gh-aw": "*", "min-agent": "1.0.21", - "max-agent": "1.0.56", + "max-agent": "*", "open": true }, { diff --git a/.github/aw/compat.schema.json b/.github/aw/compat.schema.json index 6a8e16afa9e..6883b8ee008 100644 --- a/.github/aw/compat.schema.json +++ b/.github/aw/compat.schema.json @@ -62,6 +62,12 @@ { "type": "string", "enum": ["*"] } ] }, + "agentBound": { + "oneOf": [ + { "$ref": "#/definitions/semver" }, + { "type": "string", "enum": ["*"] } + ] + }, "agentRows": { "type": "array", "minItems": 1, @@ -75,18 +81,30 @@ "min-gh-aw": { "$ref": "#/definitions/semver" }, "max-gh-aw": { "$ref": "#/definitions/ghAwBound" }, "min-agent": { "$ref": "#/definitions/semver" }, - "max-agent": { "$ref": "#/definitions/semver" }, + "max-agent": { "$ref": "#/definitions/agentBound" }, "open": { "type": "boolean", "default": true, - "description": "When true (the default), the setup action's weekly release process may continue bumping max-agent for this row. Set to false to freeze during rollback or quarantine. Permitted only on the catch-all row (max-gh-aw == \"*\"); bounded rows are closed-by-construction." + "description": "When true (the default), max-agent must be unbounded (\"*\"). Set to false to freeze the row with a specific max-agent during rollback or quarantine. Permitted only on the catch-all row (max-gh-aw == \"*\"); bounded rows are closed-by-construction." } }, "if": { "properties": { "max-gh-aw": { "const": "*" } } }, - "then": {}, + "then": { + "if": { + "properties": { "open": { "const": false } }, + "required": ["open"] + }, + "then": { + "properties": { "max-agent": { "$ref": "#/definitions/semver" } } + }, + "else": { + "properties": { "max-agent": { "const": "*" } } + } + }, "else": { + "properties": { "max-agent": { "$ref": "#/definitions/semver" } }, "not": { "required": ["open"] } } } diff --git a/.github/workflows/cgo.yml b/.github/workflows/cgo.yml index b05de076e80..89c5218a3a9 100644 --- a/.github/workflows/cgo.yml +++ b/.github/workflows/cgo.yml @@ -951,7 +951,7 @@ jobs: } } - for (const k of ['min-gh-aw', 'min-agent', 'max-agent']) { + for (const k of ['min-gh-aw', 'min-agent']) { if (k in row && !semverRe.test(row[k])) { errors.push(`${where}.${k} ('${row[k]}') is not a valid semver (MAJOR.MINOR.PATCH)`); } @@ -959,6 +959,9 @@ jobs: if ('max-gh-aw' in row && row['max-gh-aw'] !== '*' && !semverRe.test(row['max-gh-aw'])) { errors.push(`${where}.max-gh-aw ('${row['max-gh-aw']}') must be either '*' or a valid semver`); } + if ('max-agent' in row && row['max-agent'] !== '*' && !semverRe.test(row['max-agent'])) { + errors.push(`${where}.max-agent ('${row['max-agent']}') must be either '*' or a valid semver`); + } if ('min-agent' in row && 'max-agent' in row && semverRe.test(row['min-agent']) && semverRe.test(row['max-agent'])) { if (cmp(row['min-agent'], row['max-agent']) > 0) { @@ -970,6 +973,12 @@ jobs: if ('open' in row && typeof row['open'] !== 'boolean') { errors.push(`${where}.open must be a boolean`); } + if (row['open'] !== false && row['max-agent'] !== '*') { + errors.push(`${where}.max-agent must be '*' when the catch-all row is open`); + } + if (row['open'] === false && !semverRe.test(row['max-agent'])) { + errors.push(`${where}.max-agent must be a valid semver when the catch-all row is closed`); + } } else if ('max-gh-aw' in row) { if (semverRe.test(row['min-gh-aw']) && semverRe.test(row['max-gh-aw'])) { if (cmp(row['min-gh-aw'], row['max-gh-aw']) > 0) { @@ -979,6 +988,9 @@ jobs: if ('open' in row) { errors.push(`${where}: 'open' is only permitted on the catch-all row (max-gh-aw '*'); bounded rows are closed-by-construction`); } + if (!semverRe.test(row['max-agent'])) { + errors.push(`${where}.max-agent must be a valid semver on a bounded row`); + } } }); diff --git a/actions/setup/sh/install_copilot_cli.sh b/actions/setup/sh/install_copilot_cli.sh index 5dada75d2bc..367f10fb2c6 100755 --- a/actions/setup/sh/install_copilot_cli.sh +++ b/actions/setup/sh/install_copilot_cli.sh @@ -29,6 +29,7 @@ COPILOT_DIR="${HOME}/.copilot" COPILOT_TOOLCACHE_MAX_DEPTH=4 COMPAT_URL="${COPILOT_COMPAT_URL:-https://raw.githubusercontent.com/github/gh-aw-actions/main/.github/aw/compat.json}" COMPILED_GH_AW_VERSION="${GH_AW_COMPILED_VERSION:-}" +USE_COPILOT_COMPAT_RANGE=false SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" COMPAT_BUNDLED_PATH="${COPILOT_COMPAT_BUNDLED_PATH:-${REPO_ROOT}/.github/aw/compat.json}" @@ -42,6 +43,7 @@ ROOTLESS=false for arg in "$@"; do case "$arg" in --rootless) ROOTLESS=true ;; + --compat-range) USE_COPILOT_COMPAT_RANGE=true ;; --*) echo "WARNING: Unknown flag: $arg" >&2 ;; *) if [ -z "$VERSION" ]; then @@ -272,8 +274,8 @@ resolve_version_from_compat() { return 1 fi - if [[ ! "$compiled_version" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "GH_AW_COMPILED_VERSION '${compiled_version}' is not in vMAJOR.MINOR.PATCH format; skipping compatibility matrix resolution." >&2 + if [[ ! "$compiled_version" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "GH_AW_COMPILED_VERSION '${compiled_version}' is not in MAJOR.MINOR.PATCH format; skipping compatibility matrix resolution." >&2 return 1 fi @@ -304,6 +306,9 @@ resolve_version_from_compat() { fi IFS='|' read -r resolved_version row_index row_min_aw row_max_aw row_min_agent row_max_agent cache_ttl_days <<< "$resolved_info" + if [ "$resolved_version" = "*" ]; then + resolved_version="latest" + fi echo "Compatibility matrix source: $(cat "$compat_source")" >&2 echo "Compatibility matrix matched row ${row_index}: gh-aw ${row_min_aw}..${row_max_aw}, copilot ${row_min_agent}..${row_max_agent}" >&2 echo "Resolved Copilot CLI version from compatibility matrix: ${resolved_version}" >&2 @@ -431,7 +436,7 @@ find_cached_copilot_bin() { continue fi - if [ -n "$max_version" ] && version_is_greater "$candidate_version_normalized" "$max_version"; then + if [ -n "$max_version" ] && [ "$max_version" != "*" ] && version_is_greater "$candidate_version_normalized" "$max_version"; then echo " Skipping candidate (above compat maximum: ${candidate_version_normalized} > ${max_version})" >&2 continue fi @@ -520,7 +525,23 @@ if [ -z "$VERSION" ]; then exit 1 fi else - echo "Explicit Copilot CLI version argument provided (${VERSION}); skipping compat matrix resolution." + # The compiler opts its generated default pin into range matching explicitly. + # User-supplied engine.version pins require an exact toolcache match, including + # when their value happens to equal the compiler default. + if [ "$USE_COPILOT_COMPAT_RANGE" = "true" ]; then + echo "Explicit Copilot CLI version argument provided (${VERSION}); this is the compiler-generated default pin, resolving compat window for toolcache range matching..." + if RESOLVED_COMPAT_INFO="$(resolve_version_from_compat "$COMPILED_GH_AW_VERSION" "${TEMP_DIR}/compat.json")"; then + IFS='|' read -r _UNUSED COMPAT_MATCHED_MIN_AGENT COMPAT_MATCHED_MAX_AGENT COMPAT_CACHE_TTL_DAYS <<< "$RESOLVED_COMPAT_INFO" + # Treat as "latest" so find_cached_copilot_bin uses range matching and applies TTL + # (same path as when VERSION is not set). VERSION is still used for the download URL. + REQUESTED_VERSION="latest" + echo "Compat window resolved: ${COMPAT_MATCHED_MIN_AGENT}..${COMPAT_MATCHED_MAX_AGENT} (toolcache range matching enabled)" + else + echo "Compat window unavailable; exact toolcache match required for version ${VERSION}." >&2 + fi + else + echo "Explicit Copilot CLI version argument provided (${VERSION}); exact toolcache match required." + fi fi # Prefer the runner toolcache when a compatible Copilot CLI is already available. diff --git a/actions/setup/sh/install_copilot_cli_test.sh b/actions/setup/sh/install_copilot_cli_test.sh new file mode 100755 index 00000000000..7f2f8cf649f --- /dev/null +++ b/actions/setup/sh/install_copilot_cli_test.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Tests for install_copilot_cli.sh toolcache selection logic. +# Run: bash actions/setup/sh/install_copilot_cli_test.sh + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +INSTALL_SCRIPT="${SCRIPT_DIR}/install_copilot_cli.sh" +TEST_ROOT="$(mktemp -d)" +trap 'rm -rf "$TEST_ROOT"' EXIT + +# Load the production helpers without executing the install script's top-level code. +# Each function ends with an unindented closing brace. +# shellcheck source=/dev/null +source <(sed -n \ + -e '/^normalize_version()/,/^}/p' \ + -e '/^version_is_numeric()/,/^}/p' \ + -e '/^version_is_greater()/,/^}/p' \ + -e '/^is_cache_expired()/,/^}/p' \ + -e '/^find_cached_copilot_bin()/,/^}/p' \ + "$INSTALL_SCRIPT") + +export ARCH_NAME="x64" +export COPILOT_TOOLCACHE_MAX_DEPTH=4 +export SECONDS_PER_DAY=86400 +RUNNER_TOOL_CACHE="${TEST_ROOT}/toolcache" + +make_cached_copilot() { + local version="$1" + local binary="${RUNNER_TOOL_CACHE}/copilot-cli/${version}/${ARCH_NAME}/bin/copilot" + mkdir -p "$(dirname "$binary")" + printf '#!/usr/bin/env bash\nprintf "copilot %s\\n"\n' "$version" > "$binary" + chmod +x "$binary" + printf '%s\n' "$binary" +} + +assert_found() { + local description="$1" + shift + if ! find_cached_copilot_bin "$@" >/dev/null; then + echo "FAIL: ${description}" >&2 + exit 1 + fi + echo "PASS: ${description}" +} + +assert_not_found() { + local description="$1" + shift + if find_cached_copilot_bin "$@" >/dev/null; then + echo "FAIL: ${description}" >&2 + exit 1 + fi + echo "PASS: ${description}" +} + +cached_binary="$(make_cached_copilot "1.0.56")" + +assert_not_found \ + "explicit pin equal to the compiler default rejects a mismatched in-range cache entry" \ + "1.0.75" "1.0.21" "1.0.75" "14" + +assert_found \ + "compiler default accepts an in-range cache entry" \ + "latest" "1.0.21" "1.0.75" "" + +assert_found \ + "compiler default accepts an entry in an open-ended range" \ + "latest" "1.0.21" "*" "" + +touch -d "20 days ago" "$cached_binary" +assert_not_found \ + "compiler-default range fallback rejects an expired cache entry" \ + "latest" "1.0.21" "1.0.75" "14" diff --git a/docs/adr/48593-enable-compat-range-toolcache-for-explicit-copilot-version.md b/docs/adr/48593-enable-compat-range-toolcache-for-explicit-copilot-version.md new file mode 100644 index 00000000000..cccf70ed985 --- /dev/null +++ b/docs/adr/48593-enable-compat-range-toolcache-for-explicit-copilot-version.md @@ -0,0 +1,52 @@ +# ADR-48593: Enable Compat-Range Toolcache Matching for the Default Copilot CLI Version + +**Date**: 2026-07-28 +**Status**: Draft +**Deciders**: pelikhan, copilot-swe-agent + +--- + +### Context + +The Copilot CLI install script (`install_copilot_cli.sh`) has two code paths for locating a compatible binary: (1) range-based toolcache lookup driven by `compat.json`, and (2) exact-version toolcache lookup when an explicit version is passed. Path (1) allows a cached runner binary within the compat window to satisfy the install without a network download; path (2) requires an exact version match and falls back to a network download on any mismatch. + +Because `pkg/workflow/copilot_installer.go` always passes an explicit version (defaulting to `DefaultCopilotVersion` when unset), path (2) was the only path ever exercised in practice. When `DefaultCopilotVersion` (1.0.75) drifted past the `compat.json` `max-agent` (1.0.56), the cached runner toolcache entry (1.0.56) was always rejected by exact-match, forcing a network download on every agentic job. A CDN blip of ~30 s was sufficient to fail the job entirely—twice per run (once in the `agent` job, once in the `detection` job). Additionally, `GH_AW_COMPILED_VERSION` was never emitted in compiled job environments, so the install script had no compiled version to resolve a compat window even if it wanted to. + +### Decision + +We will opt the compiler-generated default version into compat-matrix resolution so that `find_cached_copilot_bin` receives a populated compat range. User-supplied `engine.version` pins continue to require an exact match, even when their value equals `DefaultCopilotVersion`. Specifically: + +- `copilot_installer.go`: pass `--compat-range` only for a compiler-generated default in release builds. +- `install_copilot_cli.sh`: resolve and use the compat range only when `--compat-range` is present. +- `compiler_main_job_helpers.go` / `threat_detection_job.go`: emit `GH_AW_COMPILED_VERSION` in job-level env for release builds so the script can resolve the compat window. +- `.github/aw/compat.json`: make the open row's `max-agent` unbounded so compiler-default updates remain in the current compatibility window. +- `pkg/constants/version_constants_test.go`: add `TestDefaultCopilotVersionWithinCompatWindow` as a CI gate to assert that `DefaultCopilotVersion` remains in the open compatibility window. + +### Alternatives Considered + +#### Alternative 1: Remove explicit version pinning and always resolve via compat matrix + +The install script could drop the explicit-version argument and always use compat-matrix resolution to select the best available version. This would give full range-based toolcache matching without any bypass. It was rejected because the explicit version pin provides a stable, testable contract: callers can assert exactly which CLI version is installed. Losing that contract complicates debugging and prevents version-specific rollouts. + +#### Alternative 2: Only bump compat.json max-agent without changing the install script + +Making `max-agent` unbounded fixes the data drift, but the install script's explicit-version branch still skips compat resolution and calls `find_cached_copilot_bin` with an empty compat range. The toolcache entry would continue to be rejected by exact-match. This approach fixes the data without fixing the logic. + +### Consequences + +#### Positive +- Runner toolcache satisfies default Copilot CLI installs with any version within the compat window, eliminating unnecessary network downloads on every agentic job. +- CDN failures during Copilot CLI download no longer cause hard job failures when a compatible cached binary exists. +- `TestDefaultCopilotVersionWithinCompatWindow` creates a CI gate that ensures `DefaultCopilotVersion` remains in the open compatibility window. + +#### Negative +- Default-version range matching may serve a cached binary version that differs from `DefaultCopilotVersion`; behavioral equivalence relies on the compat window being accurately defined. An overly broad window could accept an incompatible binary. +- `GH_AW_COMPILED_VERSION` is now present in compiled job-level environments for release builds, exposing compiler version metadata in workflow logs and to any downstream tool that reads job env vars. + +#### Neutral +- The compat window is now a load-bearing correctness invariant, enforced by the new test. +- User-supplied `engine.version` values retain exact-match semantics. + +--- + +*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.* diff --git a/pkg/constants/version_constants_test.go b/pkg/constants/version_constants_test.go index ca725dee5cc..d4ca27725d7 100644 --- a/pkg/constants/version_constants_test.go +++ b/pkg/constants/version_constants_test.go @@ -3,6 +3,13 @@ package constants import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" "testing" "time" ) @@ -28,3 +35,100 @@ func TestDefaultPlaywrightCLIVersionOutsideCooldownWindow(t *testing.T) { t.Fatalf("@playwright/cli@%s is only %s old, but Playwright CLI installs enforce a %s npm release-age cooldown", DefaultPlaywrightCLIVersion, age.Round(time.Second), minReleaseAge) } } + +// TestDefaultCopilotVersionWithinCompatWindow asserts that DefaultCopilotVersion falls +// within the declared compat.json window so the runner toolcache can satisfy installs +// without a network download. +func TestDefaultCopilotVersionWithinCompatWindow(t *testing.T) { + // Locate compat.json relative to this test file (three directories up from + // pkg/constants/ → repo root → .github/aw/compat.json). + _, testFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + compatPath := filepath.Join(filepath.Dir(testFile), "..", "..", ".github", "aw", "compat.json") + compatPath = filepath.Clean(compatPath) + + data, err := os.ReadFile(compatPath) + if err != nil { + t.Fatalf("read %s: %v", compatPath, err) + } + + var compat struct { + AgentCompatV1 struct { + Copilot []struct { + MinGhAw string `json:"min-gh-aw"` + MaxGhAw string `json:"max-gh-aw"` + MinAgent string `json:"min-agent"` + MaxAgent string `json:"max-agent"` + // Open is optional; per compat.schema.json it defaults to true when absent. + // Use *bool so we can distinguish an explicit false from an omitted field. + Open *bool `json:"open"` + } `json:"copilot"` + } `json:"agent-compat-v1"` + } + if err := json.Unmarshal(data, &compat); err != nil { + t.Fatalf("parse %s: %v", compatPath, err) + } + + version := string(DefaultCopilotVersion) + + // Find the open catch-all row (open: true, or absent which defaults to true per schema). + for _, row := range compat.AgentCompatV1.Copilot { + // Skip bounded rows and catch-all rows that are explicitly closed. + if row.MaxGhAw != "*" || (row.Open != nil && !*row.Open) { + continue + } + if row.MinAgent == "" || row.MaxAgent == "" { + t.Fatalf("compat row missing min-agent or max-agent: %+v", row) + } + if row.MaxAgent != "*" { + t.Fatalf("open compat row max-agent is %q, want \"*\"", row.MaxAgent) + } + if cmp, err := semverCmp(version, row.MinAgent); err != nil { + t.Fatalf("semverCmp(%q, %q): %v", version, row.MinAgent, err) + } else if cmp < 0 { + t.Fatalf("DefaultCopilotVersion %q is below compat min-agent %q; bump min-agent or lower DefaultCopilotVersion", version, row.MinAgent) + } + return // found and validated + } + t.Fatalf("no open compat row found in %s; add an open row for the current gh-aw release", compatPath) +} + +// semverCmp compares two semver strings (without leading "v") and returns -1, 0, or 1. +func semverCmp(a, b string) (int, error) { + pa, err := parseSemver(a) + if err != nil { + return 0, fmt.Errorf("parse %q: %w", a, err) + } + pb, err := parseSemver(b) + if err != nil { + return 0, fmt.Errorf("parse %q: %w", b, err) + } + for i := range pa { + if pa[i] < pb[i] { + return -1, nil + } + if pa[i] > pb[i] { + return 1, nil + } + } + return 0, nil +} + +func parseSemver(v string) ([3]int, error) { + v = strings.TrimPrefix(v, "v") + parts := strings.SplitN(v, ".", 3) + if len(parts) != 3 { + return [3]int{}, fmt.Errorf("expected MAJOR.MINOR.PATCH, got %q", v) + } + var out [3]int + for i, p := range parts { + n, err := strconv.Atoi(p) + if err != nil { + return [3]int{}, fmt.Errorf("part %d of %q is not a number: %w", i, v, err) + } + out[i] = n + } + return out, nil +} diff --git a/pkg/workflow/compiler_main_job_helpers.go b/pkg/workflow/compiler_main_job_helpers.go index 5139f6bc875..f239fc6afa3 100644 --- a/pkg/workflow/compiler_main_job_helpers.go +++ b/pkg/workflow/compiler_main_job_helpers.go @@ -317,6 +317,18 @@ func (c *Compiler) buildMainJobEnv(data *WorkflowData) map[string]string { env["GH_AW_PROJECT_UTC"] = fmt.Sprintf("%q", utcOffset) } + // Expose the compiler version so the Copilot install script can resolve a + // compatible toolcache entry via compat-matrix range matching rather than + // requiring an exact version download on every job. + // GH_AW_COMPILED_VERSION is emitted only for release builds so that dev/CI builds + // do not accidentally enable toolcache range matching against a stale compat window. + if IsRelease() { + if env == nil { + env = make(map[string]string) + } + env["GH_AW_COMPILED_VERSION"] = c.version + } + return env } diff --git a/pkg/workflow/copilot_engine_installation.go b/pkg/workflow/copilot_engine_installation.go index 0c80561e795..076c435800c 100644 --- a/pkg/workflow/copilot_engine_installation.go +++ b/pkg/workflow/copilot_engine_installation.go @@ -146,9 +146,11 @@ func (e *CopilotEngine) GetInstallationSteps(workflowData *WorkflowData) []GitHu // When no explicit version is set, normalize the engine config so downstream // consumers observe the effective installed value. copilotVersion := string(constants.DefaultCopilotVersion) + useCompatRange := IsRelease() if workflowData.EngineConfig != nil { if workflowData.EngineConfig.Version != "" { copilotVersion = workflowData.EngineConfig.Version + useCompatRange = useCompatRange && workflowData.EngineConfig.VersionDefaulted copilotInstallLog.Printf("Using engine.version for Copilot CLI installation: %s", copilotVersion) } else { // Normalize engine config version to the effective installed version so @@ -156,6 +158,7 @@ func (e *CopilotEngine) GetInstallationSteps(workflowData *WorkflowData) []GitHu // This mutates workflowData by design because subsequent generation steps // in the same compile flow should observe the effective installed version. workflowData.EngineConfig.Version = copilotVersion + workflowData.EngineConfig.VersionDefaulted = true copilotInstallLog.Printf("No engine.version specified, using default Copilot CLI version: %s", copilotVersion) } } @@ -167,7 +170,7 @@ func (e *CopilotEngine) GetInstallationSteps(workflowData *WorkflowData) []GitHu // The "Copy Copilot CLI to daemon-visible path" step in nodejs.go then copies from // the rootless location to ${RUNNER_TEMP}/gh-aw/bin/copilot where AWF expects it. rootless := isArcDindTopology(workflowData) - npmSteps := GenerateCopilotInstallerSteps(copilotVersion, "Install GitHub Copilot CLI", rootless) + npmSteps := generateCopilotInstallerSteps(copilotVersion, "Install GitHub Copilot CLI", rootless, useCompatRange) if len(inlineDriverWriteStep) > 0 { npmSteps = append(npmSteps, inlineDriverWriteStep) } diff --git a/pkg/workflow/copilot_installer.go b/pkg/workflow/copilot_installer.go index c98f8719953..5bdf74e476a 100644 --- a/pkg/workflow/copilot_installer.go +++ b/pkg/workflow/copilot_installer.go @@ -10,6 +10,10 @@ var copilotInstallerLog = logger.New("workflow:copilot_installer") // GenerateCopilotInstallerSteps creates GitHub Actions steps to install the Copilot CLI using the official installer. // When rootless is true, the script installs into $HOME/.local/bin without sudo. func GenerateCopilotInstallerSteps(version, stepName string, rootless bool) []GitHubActionStep { + return generateCopilotInstallerSteps(version, stepName, rootless, false) +} + +func generateCopilotInstallerSteps(version, stepName string, rootless, useCompatRange bool) []GitHubActionStep { // If no version is specified, use the pinned default version from constants. if version == "" { version = string(constants.DefaultCopilotVersion) @@ -22,6 +26,10 @@ func GenerateCopilotInstallerSteps(version, stepName string, rootless bool) []Gi if rootless { rootlessFlag = " --rootless" } + compatRangeFlag := "" + if useCompatRange { + compatRangeFlag = " --compat-range" + } // Use the install_copilot_cli.sh script from actions/setup/sh // This script includes retry logic for robustness against transient network failures. @@ -36,7 +44,7 @@ func GenerateCopilotInstallerSteps(version, stepName string, rootless bool) []Gi copilotInstallerLog.Printf("Version contains GitHub Actions expression, using env var for injection safety: %s", version) stepLines := []string{ " - name: " + stepName, - ` run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" "${ENGINE_VERSION}"` + rootlessFlag, + ` run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" "${ENGINE_VERSION}"` + rootlessFlag + compatRangeFlag, " env:", " GH_HOST: github.com", " ENGINE_VERSION: " + version, @@ -46,7 +54,7 @@ func GenerateCopilotInstallerSteps(version, stepName string, rootless bool) []Gi stepLines := []string{ " - name: " + stepName, - " run: bash \"${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh\" " + version + rootlessFlag, + " run: bash \"${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh\" " + version + rootlessFlag + compatRangeFlag, " env:", " GH_HOST: github.com", } diff --git a/pkg/workflow/copilot_installer_test.go b/pkg/workflow/copilot_installer_test.go index 4592205324e..777bbffa7fe 100644 --- a/pkg/workflow/copilot_installer_test.go +++ b/pkg/workflow/copilot_installer_test.go @@ -157,9 +157,39 @@ func TestCopilotEngineWithVersion(t *testing.T) { } } +func TestCopilotEngineExplicitDefaultVersionRequiresExactMatch(t *testing.T) { + originalIsRelease := isReleaseBuild + isReleaseBuild = true + t.Cleanup(func() { isReleaseBuild = originalIsRelease }) + + engine := NewCopilotEngine() + workflowData := &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ + Version: string(constants.DefaultCopilotVersion), + }, + } + + steps := engine.GetInstallationSteps(workflowData) + for _, step := range steps { + stepContent := strings.Join(step, "\n") + if strings.Contains(stepContent, "install_copilot_cli.sh") { + if strings.Contains(stepContent, "--compat-range") { + t.Fatalf("explicit engine.version equal to the default must require an exact match:\n%s", stepContent) + } + return + } + } + t.Fatal("Could not find install step with install_copilot_cli.sh") +} + func TestCopilotEngineWithoutVersion(t *testing.T) { // When engine.version is not set, the default pinned version must be used and // EngineConfig.Version must be normalized to the effective installed value. + originalIsRelease := isReleaseBuild + isReleaseBuild = true + t.Cleanup(func() { isReleaseBuild = originalIsRelease }) + engine := NewCopilotEngine() workflowData := &WorkflowData{ @@ -192,6 +222,44 @@ func TestCopilotEngineWithoutVersion(t *testing.T) { if !strings.Contains(installStep, `install_copilot_cli.sh" `+string(constants.DefaultCopilotVersion)) { t.Errorf("Expected default Copilot version in install step, got:\n%s", installStep) } + if !strings.Contains(installStep, "--compat-range") { + t.Errorf("Expected compiler default version to enable compat-range matching, got:\n%s", installStep) + } + + // A later detection job sees the normalized version and must retain its default provenance. + secondSteps := engine.GetInstallationSteps(workflowData) + for _, step := range secondSteps { + stepContent := strings.Join(step, "\n") + if strings.Contains(stepContent, "install_copilot_cli.sh") { + if !strings.Contains(stepContent, "--compat-range") { + t.Fatalf("normalized compiler default must retain compat-range matching:\n%s", stepContent) + } + return + } + } + t.Fatal("Could not find second install step with install_copilot_cli.sh") +} + +func TestCopilotEngineDevelopmentBuildRequiresExactMatch(t *testing.T) { + originalIsRelease := isReleaseBuild + isReleaseBuild = false + t.Cleanup(func() { isReleaseBuild = originalIsRelease }) + + engine := NewCopilotEngine() + steps := engine.GetInstallationSteps(&WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{}, + }) + for _, step := range steps { + stepContent := strings.Join(step, "\n") + if strings.Contains(stepContent, "install_copilot_cli.sh") { + if strings.Contains(stepContent, "--compat-range") { + t.Fatalf("development builds must require an exact match:\n%s", stepContent) + } + return + } + } + t.Fatal("Could not find install step with install_copilot_cli.sh") } func TestGenerateCopilotInstallerSteps_ExpressionVersion(t *testing.T) { diff --git a/pkg/workflow/engine.go b/pkg/workflow/engine.go index 98db36836c5..bbe7384964d 100644 --- a/pkg/workflow/engine.go +++ b/pkg/workflow/engine.go @@ -46,6 +46,7 @@ func toEngineEnvValueString(value any) (string, bool) { type EngineConfig struct { ID string Version string + VersionDefaulted bool // Internal provenance: Version was populated from the compiler default. LLMProvider LLMProvider // Inference provider override for this engine (engine.provider / engine.model-provider) PermissionMode string MaxTurns string diff --git a/pkg/workflow/threat_detection_inline_engine.go b/pkg/workflow/threat_detection_inline_engine.go index 2ca084c3ef6..ae937e98590 100644 --- a/pkg/workflow/threat_detection_inline_engine.go +++ b/pkg/workflow/threat_detection_inline_engine.go @@ -62,14 +62,15 @@ func (c *Compiler) buildDetectionEngineExecutionStep(data *WorkflowData) []strin detectionEngineConfig = &EngineConfig{ID: engineSetting} } else { detectionEngineConfig = &EngineConfig{ - ID: detectionEngineConfig.ID, - Version: detectionEngineConfig.Version, - Env: detectionEngineConfig.Env, - Config: detectionEngineConfig.Config, - Args: detectionEngineConfig.Args, - APITarget: detectionEngineConfig.APITarget, - HarnessScript: detectionEngineConfig.HarnessScript, - Driver: detectionEngineConfig.Driver, + ID: detectionEngineConfig.ID, + Version: detectionEngineConfig.Version, + VersionDefaulted: detectionEngineConfig.VersionDefaulted, + Env: detectionEngineConfig.Env, + Config: detectionEngineConfig.Config, + Args: detectionEngineConfig.Args, + APITarget: detectionEngineConfig.APITarget, + HarnessScript: detectionEngineConfig.HarnessScript, + Driver: detectionEngineConfig.Driver, } } if detectionEngineConfig.ID == "" { diff --git a/pkg/workflow/threat_detection_job.go b/pkg/workflow/threat_detection_job.go index b3063e2da49..65db88a7304 100644 --- a/pkg/workflow/threat_detection_job.go +++ b/pkg/workflow/threat_detection_job.go @@ -191,6 +191,18 @@ func (c *Compiler) buildDetectionJob(data *WorkflowData) (*Job, error) { environment = "environment: " + data.SafeOutputs.ThreatDetection.Environment } + var detectionJobEnv map[string]string + // Expose the compiler version so the Copilot install script can resolve a + // compatible toolcache entry via compat-matrix range matching rather than + // requiring an exact version download on every job. + // GH_AW_COMPILED_VERSION is emitted only for release builds so that dev/CI builds + // do not accidentally enable toolcache range matching against a stale compat window. + if IsRelease() { + detectionJobEnv = map[string]string{ + "GH_AW_COMPILED_VERSION": c.version, + } + } + job := &Job{ Name: string(constants.DetectionJobName), Needs: needs, @@ -198,6 +210,7 @@ func (c *Compiler) buildDetectionJob(data *WorkflowData) (*Job, error) { RunsOn: c.indentYAMLLines(runsOn, " "), Environment: c.indentYAMLLines(environment, " "), Permissions: permissions, + Env: detectionJobEnv, Steps: steps, Outputs: outputs, }