From 15897720e77b0945a7c81cd695e1c8835a9ad810 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Jul 2026 11:51:12 +0000 Subject: [PATCH 1/6] Initial plan From 5a4d120a94f395bf143729cbf4d0a6cd96ff0c1f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:33:18 +0000 Subject: [PATCH 2/6] Fix DefaultCopilotVersion toolcache drift: update compat.json, script range fallback, emit GH_AW_COMPILED_VERSION in job envs Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/aw/compat.json | 2 +- actions/setup/sh/install_copilot_cli.sh | 21 +++-- pkg/constants/version_constants_test.go | 104 ++++++++++++++++++++++ pkg/workflow/compiler_main_job_helpers.go | 10 +++ pkg/workflow/threat_detection_job.go | 11 +++ 5 files changed, 142 insertions(+), 6 deletions(-) diff --git a/.github/aw/compat.json b/.github/aw/compat.json index 0ea0160b78a..0cc6b903db3 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": "1.0.75", "open": true }, { diff --git a/actions/setup/sh/install_copilot_cli.sh b/actions/setup/sh/install_copilot_cli.sh index 5dada75d2bc..ce3d632d9fb 100755 --- a/actions/setup/sh/install_copilot_cli.sh +++ b/actions/setup/sh/install_copilot_cli.sh @@ -272,8 +272,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 @@ -422,8 +422,13 @@ 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 no compat range is available, an exact match is required + 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 " No exact match (want ${requested_version_normalized}); checking compat range ${min_version}..${max_version}" >&2 + # Fall through to range check below fi if [ -n "$min_version" ] && version_is_greater "$min_version" "$candidate_version_normalized"; then @@ -520,7 +525,13 @@ if [ -z "$VERSION" ]; then exit 1 fi else - echo "Explicit Copilot CLI version argument provided (${VERSION}); skipping compat matrix resolution." + echo "Explicit Copilot CLI version argument provided (${VERSION}); 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" + 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 fi # Prefer the runner toolcache when a compatible Copilot CLI is already available. diff --git a/pkg/constants/version_constants_test.go b/pkg/constants/version_constants_test.go index ca725dee5cc..414ebfec07b 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. Failures here indicate that either DefaultCopilotVersion +// or the compat.json max-agent needs to be updated. +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 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 first open row (open: true means it covers the current gh-aw release). + for _, row := range compat.AgentCompatV1.Copilot { + if !row.Open { + continue + } + if row.MinAgent == "" || row.MaxAgent == "" { + t.Fatalf("compat row missing min-agent or max-agent: %+v", row) + } + 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) + } + if cmp, err := semverCmp(version, row.MaxAgent); err != nil { + t.Fatalf("semverCmp(%q, %q): %v", version, row.MaxAgent, err) + } else if cmp > 0 { + t.Fatalf("DefaultCopilotVersion %q exceeds compat max-agent %q; update .github/aw/compat.json max-agent or lower DefaultCopilotVersion to prevent toolcache bypass", version, row.MaxAgent) + } + 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..2ff87eca4d0 100644 --- a/pkg/workflow/compiler_main_job_helpers.go +++ b/pkg/workflow/compiler_main_job_helpers.go @@ -317,6 +317,16 @@ 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. + if IsRelease() { + if env == nil { + env = make(map[string]string) + } + env["GH_AW_COMPILED_VERSION"] = c.version + } + return env } diff --git a/pkg/workflow/threat_detection_job.go b/pkg/workflow/threat_detection_job.go index b3063e2da49..05e9a87fd8e 100644 --- a/pkg/workflow/threat_detection_job.go +++ b/pkg/workflow/threat_detection_job.go @@ -191,6 +191,16 @@ 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. + if IsRelease() { + detectionJobEnv = map[string]string{ + "GH_AW_COMPILED_VERSION": c.version, + } + } + job := &Job{ Name: string(constants.DetectionJobName), Needs: needs, @@ -198,6 +208,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, } From 3ed368fc6b11e7e6c87b21049f3ffd2d5fab66d6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:04:18 +0000 Subject: [PATCH 3/6] docs(adr): add draft ADR-48593 for compat-range toolcache matching Draft ADR capturing the decision to run compat-matrix resolution unconditionally (even for explicit version requests) so the runner toolcache can satisfy Copilot CLI installs without a network download. --- ...-toolcache-for-explicit-copilot-version.md | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 docs/adr/48593-enable-compat-range-toolcache-for-explicit-copilot-version.md 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..ef8e451e6d1 --- /dev/null +++ b/docs/adr/48593-enable-compat-range-toolcache-for-explicit-copilot-version.md @@ -0,0 +1,51 @@ +# ADR-48593: Enable Compat-Range Toolcache Matching for Explicit Copilot CLI Version Requests + +**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 run compat-matrix resolution unconditionally—including when an explicit version is provided—so that `find_cached_copilot_bin` receives a populated compat range and can fall through to range-based matching when the exact version is not cached. Specifically: + +- `install_copilot_cli.sh`: call `resolve_version_from_compat` in the explicit-version branch and populate `COMPAT_MATCHED_MIN_AGENT`/`COMPAT_MATCHED_MAX_AGENT`; in `find_cached_copilot_bin`, fall through to the range check when no exact match is found and a compat range is available, rather than unconditionally `continue`-ing. +- `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`: update `max-agent` from 1.0.56 to 1.0.75 to include the current `DefaultCopilotVersion`. +- `pkg/constants/version_constants_test.go`: add `TestDefaultCopilotVersionWithinCompatWindow` as a CI gate to assert `min-agent ≤ DefaultCopilotVersion ≤ max-agent`, preventing this class of drift from recurring. + +### 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 + +Updating `max-agent` to 1.0.75 would fix the immediate drift and allow the range check to pass in theory, 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, leaving the underlying bypass latent for the next drift cycle. + +### Consequences + +#### Positive +- Runner toolcache satisfies Copilot CLI installs for 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 blocks future `DefaultCopilotVersion`/`compat.json` drift before it reaches production. + +#### Negative +- Range-based toolcache matching may serve a cached binary version that differs from the exact requested version; 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: `compat.json` must be updated alongside `DefaultCopilotVersion` on every bump, enforced by the new test. +- The change in `find_cached_copilot_bin` is a fall-through rather than a new code path; the existing range comparison logic below the exact-match block is unchanged. + +--- + +*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.* From ad60f575b26cdb84e60e3ce6fa1a89376d3e2714 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:41:47 +0000 Subject: [PATCH 4/6] Address review feedback: fix open *bool, wildcard guard, dev/release comment, default-pin-only range fallback Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/sh/install_copilot_cli.sh | 40 ++- actions/setup/sh/install_copilot_cli_test.sh | 305 +++++++++++++++++++ pkg/constants/version_constants_test.go | 20 +- pkg/workflow/compiler_main_job_helpers.go | 7 + pkg/workflow/threat_detection_job.go | 7 + 5 files changed, 360 insertions(+), 19 deletions(-) create mode 100755 actions/setup/sh/install_copilot_cli_test.sh diff --git a/actions/setup/sh/install_copilot_cli.sh b/actions/setup/sh/install_copilot_cli.sh index ce3d632d9fb..eafa2f3285e 100755 --- a/actions/setup/sh/install_copilot_cli.sh +++ b/actions/setup/sh/install_copilot_cli.sh @@ -29,6 +29,11 @@ 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:-}" +# GH_AW_DEFAULT_COPILOT_VERSION is emitted by the compiler only for release builds. +# When set and VERSION equals this value, the requested version is the compiler-generated +# default pin (not a user-supplied explicit pin), so range-based toolcache matching is +# allowed as a fallback instead of requiring an exact version match. +DEFAULT_COPILOT_VERSION="${GH_AW_DEFAULT_COPILOT_VERSION:-}" 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}" @@ -422,13 +427,8 @@ find_cached_copilot_bin() { printf '%s\n' "$candidate" return 0 fi - # If no compat range is available, an exact match is required - 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 " No exact match (want ${requested_version_normalized}); checking compat range ${min_version}..${max_version}" >&2 - # Fall through to range check below + echo " Skipping candidate (version mismatch: want ${requested_version_normalized}, got ${candidate_version_normalized})" >&2 + continue fi if [ -n "$min_version" ] && version_is_greater "$min_version" "$candidate_version_normalized"; then @@ -525,12 +525,28 @@ if [ -z "$VERSION" ]; then exit 1 fi else - echo "Explicit Copilot CLI version argument provided (${VERSION}); 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" - echo "Compat window resolved: ${COMPAT_MATCHED_MIN_AGENT}..${COMPAT_MATCHED_MAX_AGENT} (toolcache range matching enabled)" + # VERSION was provided explicitly. Allow compat-range toolcache matching only when the + # requested version is the compiler-generated default pin (GH_AW_DEFAULT_COPILOT_VERSION), + # not a user-supplied explicit pin. User-pinned versions use exact-match-only to preserve + # the engine.version contract. + version_normalized="$(normalize_version "$VERSION")" + default_normalized="" + if [ -n "$DEFAULT_COPILOT_VERSION" ]; then + default_normalized="$(normalize_version "$DEFAULT_COPILOT_VERSION")" + fi + if [ -n "$default_normalized" ] && [ "$version_normalized" = "$default_normalized" ]; 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 "Compat window unavailable; exact toolcache match required for version ${VERSION}." >&2 + echo "Explicit Copilot CLI version argument provided (${VERSION}); exact toolcache match required." fi fi 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..bfae032aeac --- /dev/null +++ b/actions/setup/sh/install_copilot_cli_test.sh @@ -0,0 +1,305 @@ +#!/usr/bin/env bash +set +o histexpand + +# Tests for install_copilot_cli.sh version-matching and toolcache selection logic. +# Run: bash actions/setup/sh/install_copilot_cli_test.sh + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +TESTS_PASSED=0 +TESTS_FAILED=0 + +pass() { echo "PASS: $1"; TESTS_PASSED=$((TESTS_PASSED + 1)); } +fail() { echo "FAIL: $1"; echo " $2"; TESTS_FAILED=$((TESTS_FAILED + 1)); } + +# Source helper functions from the install script. +# We extract only the pure functions (no side-effecting top-level code). +source_install_functions() { + bash -c ' + # Minimal stubs required by the sourced functions + OS_NAME="linux" + ARCH_NAME="amd64" + COPILOT_TOOLCACHE_MAX_DEPTH=4 + + # Source relevant functions from the install script + . '"${SCRIPT_DIR}/install_copilot_cli.sh"' --source-only 2>/dev/null || true + '"$1"' + ' +} + +# Build a fake toolcache with a copilot-cli binary at the given version. +make_fake_toolcache() { + local root="$1" + local version="$2" + local arch="${3:-amd64}" + local bin_dir="${root}/copilot-cli/${version}/${arch}/bin" + mkdir -p "$bin_dir" + printf '#!/bin/sh\necho "copilot %s"\n' "$version" > "${bin_dir}/copilot" + chmod +x "${bin_dir}/copilot" +} + +# --------------------------------------------------------------------------- +# Test: explicit user pin does NOT fall back to an in-range cache entry +# --------------------------------------------------------------------------- +echo "Test 1: explicit user pin (not GH_AW_DEFAULT_COPILOT_VERSION) rejects in-range cache..." +FAKE_TC=$(mktemp -d) +make_fake_toolcache "$FAKE_TC" "1.0.56" + +result=$(bash -c ' + set +o histexpand + SCRIPT_DIR="'"${SCRIPT_DIR}"'" + # Source only the pure helper functions we need + . <(sed -n "/^normalize_version/,/^}/p; /^version_is_numeric/,/^}/p; /^version_is_greater/,/^}/p; /^is_cache_expired/,/^}/p; /^find_cached_copilot_bin/,/^}/p" \ + "${SCRIPT_DIR}/install_copilot_cli.sh" 2>/dev/null) 2>/dev/null || true + + normalize_version() { + local v="${1#v}" + echo "$v" + } + version_is_numeric() { + [[ "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] + } + version_is_greater() { + local a="$1" b="$2" + [ "$(printf "%s\n%s" "$a" "$b" | sort -V | tail -1)" = "$a" ] && [ "$a" != "$b" ] + } + is_cache_expired() { return 1; } + + find_cached_copilot_bin() { + local requested_version="$1" + local min_version="$2" + local max_version="$3" + local cache_ttl_days="$4" + + local requested_version_normalized="" + if [ "$requested_version" != "latest" ]; then + requested_version_normalized="$(normalize_version "$requested_version")" + fi + + local RUNNER_TOOL_CACHE="'"${FAKE_TC}"'" + local ARCH_NAME="amd64" + local best_candidate="" + local best_version="" + + while IFS= read -r candidate; do + local candidate_dir candidate_arch candidate_version candidate_version_normalized + candidate_dir="$(dirname "$candidate")" + candidate_arch="$(basename "$(dirname "$candidate_dir")")" + candidate_version="$(basename "$(dirname "$(dirname "$candidate_dir")")")" + candidate_version_normalized="$(normalize_version "$candidate_version")" + + if ! version_is_numeric "$candidate_version_normalized"; then continue; fi + if [ "$candidate_arch" != "$ARCH_NAME" ]; then continue; fi + + if [ -n "$requested_version_normalized" ]; then + if [ "$candidate_version_normalized" = "$requested_version_normalized" ]; then + printf "%s\n" "$candidate" + return 0 + fi + # Exact match required for user-pinned versions; skip non-matching candidates + continue + fi + + if [ -n "$min_version" ] && version_is_greater "$min_version" "$candidate_version_normalized"; then continue; fi + if [ -n "$max_version" ] && version_is_greater "$candidate_version_normalized" "$max_version"; then continue; fi + + if [ -z "$best_candidate" ] || version_is_greater "$candidate_version_normalized" "$best_version"; then + best_candidate="$candidate" + best_version="$candidate_version_normalized" + fi + done < <(find "${RUNNER_TOOL_CACHE}/copilot-cli" -maxdepth 5 -name "copilot" -type f 2>/dev/null) + + if [ -n "$best_candidate" ]; then + printf "%s\n" "$best_candidate" + return 0 + fi + return 1 + } + + # User pin: version 1.0.0, compat range 1.0.21..1.0.75, cache has 1.0.56 + # Should NOT match because 1.0.0 != 1.0.56 (exact match required for user pins) + if find_cached_copilot_bin "1.0.0" "1.0.21" "1.0.75" "" >/dev/null 2>&1; then + echo "FOUND" + else + echo "NOT_FOUND" + fi +' 2>/dev/null) + +if [ "$result" = "NOT_FOUND" ]; then + pass "user pin 1.0.0 does not match in-range cached 1.0.56" +else + fail "user pin 1.0.0 should not match in-range cached 1.0.56" "got: $result" +fi +rm -rf "$FAKE_TC" + +# --------------------------------------------------------------------------- +# Test: compiler-default pin (REQUESTED_VERSION=latest) uses in-range cache +# --------------------------------------------------------------------------- +echo "Test 2: compiler-default pin (REQUESTED_VERSION=latest) accepts in-range cache..." +FAKE_TC=$(mktemp -d) +make_fake_toolcache "$FAKE_TC" "1.0.56" + +result=$(bash -c ' + set +o histexpand + normalize_version() { echo "${1#v}"; } + version_is_numeric() { [[ "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; } + version_is_greater() { + local a="$1" b="$2" + [ "$(printf "%s\n%s" "$a" "$b" | sort -V | tail -1)" = "$a" ] && [ "$a" != "$b" ] + } + is_cache_expired() { return 1; } + + find_cached_copilot_bin() { + local requested_version="$1" + local min_version="$2" + local max_version="$3" + local cache_ttl_days="$4" + + local requested_version_normalized="" + if [ "$requested_version" != "latest" ]; then + requested_version_normalized="$(normalize_version "$requested_version")" + fi + + local RUNNER_TOOL_CACHE="'"${FAKE_TC}"'" + local ARCH_NAME="amd64" + local best_candidate="" + local best_version="" + + while IFS= read -r candidate; do + local candidate_dir candidate_arch candidate_version candidate_version_normalized + candidate_dir="$(dirname "$candidate")" + candidate_arch="$(basename "$(dirname "$candidate_dir")")" + candidate_version="$(basename "$(dirname "$(dirname "$candidate_dir")")")" + candidate_version_normalized="$(normalize_version "$candidate_version")" + + if ! version_is_numeric "$candidate_version_normalized"; then continue; fi + if [ "$candidate_arch" != "$ARCH_NAME" ]; then continue; fi + + if [ -n "$requested_version_normalized" ]; then + if [ "$candidate_version_normalized" = "$requested_version_normalized" ]; then + printf "%s\n" "$candidate" + return 0 + fi + continue + fi + + if [ -n "$min_version" ] && version_is_greater "$min_version" "$candidate_version_normalized"; then continue; fi + if [ -n "$max_version" ] && version_is_greater "$candidate_version_normalized" "$max_version"; then continue; fi + + if [ -z "$best_candidate" ] || version_is_greater "$candidate_version_normalized" "$best_version"; then + best_candidate="$candidate" + best_version="$candidate_version_normalized" + fi + done < <(find "${RUNNER_TOOL_CACHE}/copilot-cli" -maxdepth 5 -name "copilot" -type f 2>/dev/null) + + if [ -n "$best_candidate" ]; then + printf "%s\n" "$best_candidate" + return 0 + fi + return 1 + } + + # Compiler default: REQUESTED_VERSION=latest, compat range 1.0.21..1.0.75, cache has 1.0.56 + # Should match because range check applies (requested_version_normalized is empty for "latest") + if find_cached_copilot_bin "latest" "1.0.21" "1.0.75" "" >/dev/null 2>&1; then + echo "FOUND" + else + echo "NOT_FOUND" + fi +' 2>/dev/null) + +if [ "$result" = "FOUND" ]; then + pass "compiler-default (latest) accepts in-range cached 1.0.56" +else + fail "compiler-default (latest) should accept in-range cached 1.0.56" "got: $result" +fi +rm -rf "$FAKE_TC" + +# --------------------------------------------------------------------------- +# Test: explicit user pin with exact cache entry is accepted +# --------------------------------------------------------------------------- +echo "Test 3: explicit user pin with exact cache entry is accepted..." +FAKE_TC=$(mktemp -d) +make_fake_toolcache "$FAKE_TC" "1.0.0" + +result=$(bash -c ' + set +o histexpand + normalize_version() { echo "${1#v}"; } + version_is_numeric() { [[ "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; } + version_is_greater() { + local a="$1" b="$2" + [ "$(printf "%s\n%s" "$a" "$b" | sort -V | tail -1)" = "$a" ] && [ "$a" != "$b" ] + } + is_cache_expired() { return 1; } + + find_cached_copilot_bin() { + local requested_version="$1" + local min_version="$2" + local max_version="$3" + local cache_ttl_days="$4" + + local requested_version_normalized="" + if [ "$requested_version" != "latest" ]; then + requested_version_normalized="$(normalize_version "$requested_version")" + fi + + local RUNNER_TOOL_CACHE="'"${FAKE_TC}"'" + local ARCH_NAME="amd64" + local best_candidate="" + local best_version="" + + while IFS= read -r candidate; do + local candidate_dir candidate_arch candidate_version candidate_version_normalized + candidate_dir="$(dirname "$candidate")" + candidate_arch="$(basename "$(dirname "$candidate_dir")")" + candidate_version="$(basename "$(dirname "$(dirname "$candidate_dir")")")" + candidate_version_normalized="$(normalize_version "$candidate_version")" + + if ! version_is_numeric "$candidate_version_normalized"; then continue; fi + if [ "$candidate_arch" != "$ARCH_NAME" ]; then continue; fi + + if [ -n "$requested_version_normalized" ]; then + if [ "$candidate_version_normalized" = "$requested_version_normalized" ]; then + printf "%s\n" "$candidate" + return 0 + fi + continue + fi + + if [ -n "$min_version" ] && version_is_greater "$min_version" "$candidate_version_normalized"; then continue; fi + if [ -n "$max_version" ] && version_is_greater "$candidate_version_normalized" "$max_version"; then continue; fi + + if [ -z "$best_candidate" ] || version_is_greater "$candidate_version_normalized" "$best_version"; then + best_candidate="$candidate" + best_version="$candidate_version_normalized" + fi + done < <(find "${RUNNER_TOOL_CACHE}/copilot-cli" -maxdepth 5 -name "copilot" -type f 2>/dev/null) + + if [ -n "$best_candidate" ]; then + printf "%s\n" "$best_candidate" + return 0 + fi + return 1 + } + + # User pin: exact match exists in cache + if find_cached_copilot_bin "1.0.0" "1.0.21" "1.0.75" "" >/dev/null 2>&1; then + echo "FOUND" + else + echo "NOT_FOUND" + fi +' 2>/dev/null) + +if [ "$result" = "FOUND" ]; then + pass "user pin 1.0.0 uses exact-match cache entry" +else + fail "user pin 1.0.0 should use exact-match cache entry" "got: $result" +fi +rm -rf "$FAKE_TC" + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- +echo +echo "Results: ${TESTS_PASSED} passed, ${TESTS_FAILED} failed" +[ "$TESTS_FAILED" -eq 0 ] || exit 1 diff --git a/pkg/constants/version_constants_test.go b/pkg/constants/version_constants_test.go index 414ebfec07b..7c60c92efb6 100644 --- a/pkg/constants/version_constants_test.go +++ b/pkg/constants/version_constants_test.go @@ -62,7 +62,9 @@ func TestDefaultCopilotVersionWithinCompatWindow(t *testing.T) { MaxGhAw string `json:"max-gh-aw"` MinAgent string `json:"min-agent"` MaxAgent string `json:"max-agent"` - Open bool `json:"open"` + // 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"` } @@ -72,9 +74,10 @@ func TestDefaultCopilotVersionWithinCompatWindow(t *testing.T) { version := string(DefaultCopilotVersion) - // Find the first open row (open: true means it covers the current gh-aw release). + // Find the first open row (open: true, or absent which defaults to true per schema). for _, row := range compat.AgentCompatV1.Copilot { - if !row.Open { + // Skip rows that are explicitly closed (open: false). + if row.Open != nil && !*row.Open { continue } if row.MinAgent == "" || row.MaxAgent == "" { @@ -85,10 +88,13 @@ func TestDefaultCopilotVersionWithinCompatWindow(t *testing.T) { } else if cmp < 0 { t.Fatalf("DefaultCopilotVersion %q is below compat min-agent %q; bump min-agent or lower DefaultCopilotVersion", version, row.MinAgent) } - if cmp, err := semverCmp(version, row.MaxAgent); err != nil { - t.Fatalf("semverCmp(%q, %q): %v", version, row.MaxAgent, err) - } else if cmp > 0 { - t.Fatalf("DefaultCopilotVersion %q exceeds compat max-agent %q; update .github/aw/compat.json max-agent or lower DefaultCopilotVersion to prevent toolcache bypass", version, row.MaxAgent) + // max-agent may be "*" (wildcard) for catch-all rows; skip the upper-bound check in that case. + if row.MaxAgent != "*" { + if cmp, err := semverCmp(version, row.MaxAgent); err != nil { + t.Fatalf("semverCmp(%q, %q): %v", version, row.MaxAgent, err) + } else if cmp > 0 { + t.Fatalf("DefaultCopilotVersion %q exceeds compat max-agent %q; update .github/aw/compat.json max-agent or lower DefaultCopilotVersion to prevent toolcache bypass", version, row.MaxAgent) + } } return // found and validated } diff --git a/pkg/workflow/compiler_main_job_helpers.go b/pkg/workflow/compiler_main_job_helpers.go index 2ff87eca4d0..6536751cd80 100644 --- a/pkg/workflow/compiler_main_job_helpers.go +++ b/pkg/workflow/compiler_main_job_helpers.go @@ -320,11 +320,18 @@ func (c *Compiler) buildMainJobEnv(data *WorkflowData) 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 and GH_AW_DEFAULT_COPILOT_VERSION are 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 + // GH_AW_DEFAULT_COPILOT_VERSION signals to the install script that the + // requested version is the compiler-generated default pin (not a user-supplied + // explicit pin), allowing compat-range toolcache matching as a fallback. + env["GH_AW_DEFAULT_COPILOT_VERSION"] = string(constants.DefaultCopilotVersion) } return env diff --git a/pkg/workflow/threat_detection_job.go b/pkg/workflow/threat_detection_job.go index 05e9a87fd8e..4891885a901 100644 --- a/pkg/workflow/threat_detection_job.go +++ b/pkg/workflow/threat_detection_job.go @@ -195,9 +195,16 @@ func (c *Compiler) buildDetectionJob(data *WorkflowData) (*Job, error) { // 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 and GH_AW_DEFAULT_COPILOT_VERSION are 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, + // GH_AW_DEFAULT_COPILOT_VERSION signals to the install script that the + // requested version is the compiler-generated default pin (not a user-supplied + // explicit pin), allowing compat-range toolcache matching as a fallback. + "GH_AW_DEFAULT_COPILOT_VERSION": string(constants.DefaultCopilotVersion), } } From bb52032cb09a0282de92ab3aee27c64c5625d50b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:29:19 +0000 Subject: [PATCH 5/6] Preserve exact Copilot pins in compat fallback Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/sh/install_copilot_cli.sh | 21 +- actions/setup/sh/install_copilot_cli_test.sh | 347 +++--------------- ...-toolcache-for-explicit-copilot-version.md | 13 +- pkg/workflow/compiler_main_job_helpers.go | 9 +- pkg/workflow/copilot_engine_installation.go | 5 +- pkg/workflow/copilot_installer.go | 12 +- pkg/workflow/copilot_installer_test.go | 68 ++++ pkg/workflow/engine.go | 1 + .../threat_detection_inline_engine.go | 17 +- pkg/workflow/threat_detection_job.go | 9 +- 10 files changed, 165 insertions(+), 337 deletions(-) diff --git a/actions/setup/sh/install_copilot_cli.sh b/actions/setup/sh/install_copilot_cli.sh index eafa2f3285e..36e861a4133 100755 --- a/actions/setup/sh/install_copilot_cli.sh +++ b/actions/setup/sh/install_copilot_cli.sh @@ -29,11 +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:-}" -# GH_AW_DEFAULT_COPILOT_VERSION is emitted by the compiler only for release builds. -# When set and VERSION equals this value, the requested version is the compiler-generated -# default pin (not a user-supplied explicit pin), so range-based toolcache matching is -# allowed as a fallback instead of requiring an exact version match. -DEFAULT_COPILOT_VERSION="${GH_AW_DEFAULT_COPILOT_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}" @@ -47,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 @@ -525,16 +522,10 @@ if [ -z "$VERSION" ]; then exit 1 fi else - # VERSION was provided explicitly. Allow compat-range toolcache matching only when the - # requested version is the compiler-generated default pin (GH_AW_DEFAULT_COPILOT_VERSION), - # not a user-supplied explicit pin. User-pinned versions use exact-match-only to preserve - # the engine.version contract. - version_normalized="$(normalize_version "$VERSION")" - default_normalized="" - if [ -n "$DEFAULT_COPILOT_VERSION" ]; then - default_normalized="$(normalize_version "$DEFAULT_COPILOT_VERSION")" - fi - if [ -n "$default_normalized" ] && [ "$version_normalized" = "$default_normalized" ]; then + # 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" diff --git a/actions/setup/sh/install_copilot_cli_test.sh b/actions/setup/sh/install_copilot_cli_test.sh index bfae032aeac..0cb070de200 100755 --- a/actions/setup/sh/install_copilot_cli_test.sh +++ b/actions/setup/sh/install_copilot_cli_test.sh @@ -1,305 +1,70 @@ #!/usr/bin/env bash -set +o histexpand +set -euo pipefail -# Tests for install_copilot_cli.sh version-matching and toolcache selection logic. +# 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)" - -TESTS_PASSED=0 -TESTS_FAILED=0 - -pass() { echo "PASS: $1"; TESTS_PASSED=$((TESTS_PASSED + 1)); } -fail() { echo "FAIL: $1"; echo " $2"; TESTS_FAILED=$((TESTS_FAILED + 1)); } - -# Source helper functions from the install script. -# We extract only the pure functions (no side-effecting top-level code). -source_install_functions() { - bash -c ' - # Minimal stubs required by the sourced functions - OS_NAME="linux" - ARCH_NAME="amd64" - COPILOT_TOOLCACHE_MAX_DEPTH=4 - - # Source relevant functions from the install script - . '"${SCRIPT_DIR}/install_copilot_cli.sh"' --source-only 2>/dev/null || true - '"$1"' - ' -} - -# Build a fake toolcache with a copilot-cli binary at the given version. -make_fake_toolcache() { - local root="$1" - local version="$2" - local arch="${3:-amd64}" - local bin_dir="${root}/copilot-cli/${version}/${arch}/bin" - mkdir -p "$bin_dir" - printf '#!/bin/sh\necho "copilot %s"\n' "$version" > "${bin_dir}/copilot" - chmod +x "${bin_dir}/copilot" +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" } -# --------------------------------------------------------------------------- -# Test: explicit user pin does NOT fall back to an in-range cache entry -# --------------------------------------------------------------------------- -echo "Test 1: explicit user pin (not GH_AW_DEFAULT_COPILOT_VERSION) rejects in-range cache..." -FAKE_TC=$(mktemp -d) -make_fake_toolcache "$FAKE_TC" "1.0.56" - -result=$(bash -c ' - set +o histexpand - SCRIPT_DIR="'"${SCRIPT_DIR}"'" - # Source only the pure helper functions we need - . <(sed -n "/^normalize_version/,/^}/p; /^version_is_numeric/,/^}/p; /^version_is_greater/,/^}/p; /^is_cache_expired/,/^}/p; /^find_cached_copilot_bin/,/^}/p" \ - "${SCRIPT_DIR}/install_copilot_cli.sh" 2>/dev/null) 2>/dev/null || true - - normalize_version() { - local v="${1#v}" - echo "$v" - } - version_is_numeric() { - [[ "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] - } - version_is_greater() { - local a="$1" b="$2" - [ "$(printf "%s\n%s" "$a" "$b" | sort -V | tail -1)" = "$a" ] && [ "$a" != "$b" ] - } - is_cache_expired() { return 1; } - - find_cached_copilot_bin() { - local requested_version="$1" - local min_version="$2" - local max_version="$3" - local cache_ttl_days="$4" - - local requested_version_normalized="" - if [ "$requested_version" != "latest" ]; then - requested_version_normalized="$(normalize_version "$requested_version")" - fi - - local RUNNER_TOOL_CACHE="'"${FAKE_TC}"'" - local ARCH_NAME="amd64" - local best_candidate="" - local best_version="" - - while IFS= read -r candidate; do - local candidate_dir candidate_arch candidate_version candidate_version_normalized - candidate_dir="$(dirname "$candidate")" - candidate_arch="$(basename "$(dirname "$candidate_dir")")" - candidate_version="$(basename "$(dirname "$(dirname "$candidate_dir")")")" - candidate_version_normalized="$(normalize_version "$candidate_version")" - - if ! version_is_numeric "$candidate_version_normalized"; then continue; fi - if [ "$candidate_arch" != "$ARCH_NAME" ]; then continue; fi - - if [ -n "$requested_version_normalized" ]; then - if [ "$candidate_version_normalized" = "$requested_version_normalized" ]; then - printf "%s\n" "$candidate" - return 0 - fi - # Exact match required for user-pinned versions; skip non-matching candidates - continue - fi - - if [ -n "$min_version" ] && version_is_greater "$min_version" "$candidate_version_normalized"; then continue; fi - if [ -n "$max_version" ] && version_is_greater "$candidate_version_normalized" "$max_version"; then continue; fi - - if [ -z "$best_candidate" ] || version_is_greater "$candidate_version_normalized" "$best_version"; then - best_candidate="$candidate" - best_version="$candidate_version_normalized" - fi - done < <(find "${RUNNER_TOOL_CACHE}/copilot-cli" -maxdepth 5 -name "copilot" -type f 2>/dev/null) - - if [ -n "$best_candidate" ]; then - printf "%s\n" "$best_candidate" - return 0 - fi - return 1 - } - - # User pin: version 1.0.0, compat range 1.0.21..1.0.75, cache has 1.0.56 - # Should NOT match because 1.0.0 != 1.0.56 (exact match required for user pins) - if find_cached_copilot_bin "1.0.0" "1.0.21" "1.0.75" "" >/dev/null 2>&1; then - echo "FOUND" - else - echo "NOT_FOUND" +assert_found() { + local description="$1" + shift + if ! find_cached_copilot_bin "$@" >/dev/null; then + echo "FAIL: ${description}" >&2 + exit 1 fi -' 2>/dev/null) - -if [ "$result" = "NOT_FOUND" ]; then - pass "user pin 1.0.0 does not match in-range cached 1.0.56" -else - fail "user pin 1.0.0 should not match in-range cached 1.0.56" "got: $result" -fi -rm -rf "$FAKE_TC" - -# --------------------------------------------------------------------------- -# Test: compiler-default pin (REQUESTED_VERSION=latest) uses in-range cache -# --------------------------------------------------------------------------- -echo "Test 2: compiler-default pin (REQUESTED_VERSION=latest) accepts in-range cache..." -FAKE_TC=$(mktemp -d) -make_fake_toolcache "$FAKE_TC" "1.0.56" - -result=$(bash -c ' - set +o histexpand - normalize_version() { echo "${1#v}"; } - version_is_numeric() { [[ "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; } - version_is_greater() { - local a="$1" b="$2" - [ "$(printf "%s\n%s" "$a" "$b" | sort -V | tail -1)" = "$a" ] && [ "$a" != "$b" ] - } - is_cache_expired() { return 1; } - - find_cached_copilot_bin() { - local requested_version="$1" - local min_version="$2" - local max_version="$3" - local cache_ttl_days="$4" - - local requested_version_normalized="" - if [ "$requested_version" != "latest" ]; then - requested_version_normalized="$(normalize_version "$requested_version")" - fi - - local RUNNER_TOOL_CACHE="'"${FAKE_TC}"'" - local ARCH_NAME="amd64" - local best_candidate="" - local best_version="" - - while IFS= read -r candidate; do - local candidate_dir candidate_arch candidate_version candidate_version_normalized - candidate_dir="$(dirname "$candidate")" - candidate_arch="$(basename "$(dirname "$candidate_dir")")" - candidate_version="$(basename "$(dirname "$(dirname "$candidate_dir")")")" - candidate_version_normalized="$(normalize_version "$candidate_version")" - - if ! version_is_numeric "$candidate_version_normalized"; then continue; fi - if [ "$candidate_arch" != "$ARCH_NAME" ]; then continue; fi - - if [ -n "$requested_version_normalized" ]; then - if [ "$candidate_version_normalized" = "$requested_version_normalized" ]; then - printf "%s\n" "$candidate" - return 0 - fi - continue - fi - - if [ -n "$min_version" ] && version_is_greater "$min_version" "$candidate_version_normalized"; then continue; fi - if [ -n "$max_version" ] && version_is_greater "$candidate_version_normalized" "$max_version"; then continue; fi - - if [ -z "$best_candidate" ] || version_is_greater "$candidate_version_normalized" "$best_version"; then - best_candidate="$candidate" - best_version="$candidate_version_normalized" - fi - done < <(find "${RUNNER_TOOL_CACHE}/copilot-cli" -maxdepth 5 -name "copilot" -type f 2>/dev/null) - - if [ -n "$best_candidate" ]; then - printf "%s\n" "$best_candidate" - return 0 - fi - return 1 - } + echo "PASS: ${description}" +} - # Compiler default: REQUESTED_VERSION=latest, compat range 1.0.21..1.0.75, cache has 1.0.56 - # Should match because range check applies (requested_version_normalized is empty for "latest") - if find_cached_copilot_bin "latest" "1.0.21" "1.0.75" "" >/dev/null 2>&1; then - echo "FOUND" - else - echo "NOT_FOUND" +assert_not_found() { + local description="$1" + shift + if find_cached_copilot_bin "$@" >/dev/null; then + echo "FAIL: ${description}" >&2 + exit 1 fi -' 2>/dev/null) - -if [ "$result" = "FOUND" ]; then - pass "compiler-default (latest) accepts in-range cached 1.0.56" -else - fail "compiler-default (latest) should accept in-range cached 1.0.56" "got: $result" -fi -rm -rf "$FAKE_TC" - -# --------------------------------------------------------------------------- -# Test: explicit user pin with exact cache entry is accepted -# --------------------------------------------------------------------------- -echo "Test 3: explicit user pin with exact cache entry is accepted..." -FAKE_TC=$(mktemp -d) -make_fake_toolcache "$FAKE_TC" "1.0.0" - -result=$(bash -c ' - set +o histexpand - normalize_version() { echo "${1#v}"; } - version_is_numeric() { [[ "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; } - version_is_greater() { - local a="$1" b="$2" - [ "$(printf "%s\n%s" "$a" "$b" | sort -V | tail -1)" = "$a" ] && [ "$a" != "$b" ] - } - is_cache_expired() { return 1; } - - find_cached_copilot_bin() { - local requested_version="$1" - local min_version="$2" - local max_version="$3" - local cache_ttl_days="$4" - - local requested_version_normalized="" - if [ "$requested_version" != "latest" ]; then - requested_version_normalized="$(normalize_version "$requested_version")" - fi - - local RUNNER_TOOL_CACHE="'"${FAKE_TC}"'" - local ARCH_NAME="amd64" - local best_candidate="" - local best_version="" - - while IFS= read -r candidate; do - local candidate_dir candidate_arch candidate_version candidate_version_normalized - candidate_dir="$(dirname "$candidate")" - candidate_arch="$(basename "$(dirname "$candidate_dir")")" - candidate_version="$(basename "$(dirname "$(dirname "$candidate_dir")")")" - candidate_version_normalized="$(normalize_version "$candidate_version")" - - if ! version_is_numeric "$candidate_version_normalized"; then continue; fi - if [ "$candidate_arch" != "$ARCH_NAME" ]; then continue; fi - - if [ -n "$requested_version_normalized" ]; then - if [ "$candidate_version_normalized" = "$requested_version_normalized" ]; then - printf "%s\n" "$candidate" - return 0 - fi - continue - fi - - if [ -n "$min_version" ] && version_is_greater "$min_version" "$candidate_version_normalized"; then continue; fi - if [ -n "$max_version" ] && version_is_greater "$candidate_version_normalized" "$max_version"; then continue; fi - - if [ -z "$best_candidate" ] || version_is_greater "$candidate_version_normalized" "$best_version"; then - best_candidate="$candidate" - best_version="$candidate_version_normalized" - fi - done < <(find "${RUNNER_TOOL_CACHE}/copilot-cli" -maxdepth 5 -name "copilot" -type f 2>/dev/null) + echo "PASS: ${description}" +} - if [ -n "$best_candidate" ]; then - printf "%s\n" "$best_candidate" - return 0 - fi - return 1 - } +cached_binary="$(make_cached_copilot "1.0.56")" - # User pin: exact match exists in cache - if find_cached_copilot_bin "1.0.0" "1.0.21" "1.0.75" "" >/dev/null 2>&1; then - echo "FOUND" - else - echo "NOT_FOUND" - fi -' 2>/dev/null) +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" -if [ "$result" = "FOUND" ]; then - pass "user pin 1.0.0 uses exact-match cache entry" -else - fail "user pin 1.0.0 should use exact-match cache entry" "got: $result" -fi -rm -rf "$FAKE_TC" +assert_found \ + "compiler default accepts an in-range cache entry" \ + "latest" "1.0.21" "1.0.75" "" -# --------------------------------------------------------------------------- -# Summary -# --------------------------------------------------------------------------- -echo -echo "Results: ${TESTS_PASSED} passed, ${TESTS_FAILED} failed" -[ "$TESTS_FAILED" -eq 0 ] || exit 1 +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 index ef8e451e6d1..61c7e6641f9 100644 --- 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 @@ -1,4 +1,4 @@ -# ADR-48593: Enable Compat-Range Toolcache Matching for Explicit Copilot CLI Version Requests +# ADR-48593: Enable Compat-Range Toolcache Matching for the Default Copilot CLI Version **Date**: 2026-07-28 **Status**: Draft @@ -14,9 +14,10 @@ Because `pkg/workflow/copilot_installer.go` always passes an explicit version (d ### Decision -We will run compat-matrix resolution unconditionally—including when an explicit version is provided—so that `find_cached_copilot_bin` receives a populated compat range and can fall through to range-based matching when the exact version is not cached. Specifically: +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: -- `install_copilot_cli.sh`: call `resolve_version_from_compat` in the explicit-version branch and populate `COMPAT_MATCHED_MIN_AGENT`/`COMPAT_MATCHED_MAX_AGENT`; in `find_cached_copilot_bin`, fall through to the range check when no exact match is found and a compat range is available, rather than unconditionally `continue`-ing. +- `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`: update `max-agent` from 1.0.56 to 1.0.75 to include the current `DefaultCopilotVersion`. - `pkg/constants/version_constants_test.go`: add `TestDefaultCopilotVersionWithinCompatWindow` as a CI gate to assert `min-agent ≤ DefaultCopilotVersion ≤ max-agent`, preventing this class of drift from recurring. @@ -34,17 +35,17 @@ Updating `max-agent` to 1.0.75 would fix the immediate drift and allow the range ### Consequences #### Positive -- Runner toolcache satisfies Copilot CLI installs for any version within the compat window, eliminating unnecessary network downloads on every agentic job. +- 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 blocks future `DefaultCopilotVersion`/`compat.json` drift before it reaches production. #### Negative -- Range-based toolcache matching may serve a cached binary version that differs from the exact requested version; behavioral equivalence relies on the compat window being accurately defined. An overly broad window could accept an incompatible binary. +- 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: `compat.json` must be updated alongside `DefaultCopilotVersion` on every bump, enforced by the new test. -- The change in `find_cached_copilot_bin` is a fall-through rather than a new code path; the existing range comparison logic below the exact-match block is unchanged. +- User-supplied `engine.version` values retain exact-match semantics. --- diff --git a/pkg/workflow/compiler_main_job_helpers.go b/pkg/workflow/compiler_main_job_helpers.go index 6536751cd80..f239fc6afa3 100644 --- a/pkg/workflow/compiler_main_job_helpers.go +++ b/pkg/workflow/compiler_main_job_helpers.go @@ -320,18 +320,13 @@ func (c *Compiler) buildMainJobEnv(data *WorkflowData) 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 and GH_AW_DEFAULT_COPILOT_VERSION are emitted only - // for release builds so that dev/CI builds do not accidentally enable toolcache - // range matching against a stale compat window. + // 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 - // GH_AW_DEFAULT_COPILOT_VERSION signals to the install script that the - // requested version is the compiler-generated default pin (not a user-supplied - // explicit pin), allowing compat-range toolcache matching as a fallback. - env["GH_AW_DEFAULT_COPILOT_VERSION"] = string(constants.DefaultCopilotVersion) } return env diff --git a/pkg/workflow/copilot_engine_installation.go b/pkg/workflow/copilot_engine_installation.go index ca4135865b2..e929a8eea13 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 4891885a901..65db88a7304 100644 --- a/pkg/workflow/threat_detection_job.go +++ b/pkg/workflow/threat_detection_job.go @@ -195,16 +195,11 @@ func (c *Compiler) buildDetectionJob(data *WorkflowData) (*Job, error) { // 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 and GH_AW_DEFAULT_COPILOT_VERSION are emitted only - // for release builds so that dev/CI builds do not accidentally enable toolcache - // range matching against a stale compat window. + // 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, - // GH_AW_DEFAULT_COPILOT_VERSION signals to the install script that the - // requested version is the compiler-generated default pin (not a user-supplied - // explicit pin), allowing compat-range toolcache matching as a fallback. - "GH_AW_DEFAULT_COPILOT_VERSION": string(constants.DefaultCopilotVersion), } } From f51cf40d9e311a03135ad45780f4b8d19a5d767b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:16:56 +0000 Subject: [PATCH 6/6] fix: make open Copilot compat range unbounded Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/aw/compat.json | 2 +- .github/aw/compat.schema.json | 24 ++++++++++++++++--- .github/workflows/cgo.yml | 14 ++++++++++- actions/setup/sh/install_copilot_cli.sh | 5 +++- actions/setup/sh/install_copilot_cli_test.sh | 4 ++++ ...-toolcache-for-explicit-copilot-version.md | 10 ++++---- pkg/constants/version_constants_test.go | 20 ++++++---------- 7 files changed, 55 insertions(+), 24 deletions(-) diff --git a/.github/aw/compat.json b/.github/aw/compat.json index 0cc6b903db3..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.75", + "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 36e861a4133..367f10fb2c6 100755 --- a/actions/setup/sh/install_copilot_cli.sh +++ b/actions/setup/sh/install_copilot_cli.sh @@ -306,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 @@ -433,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 diff --git a/actions/setup/sh/install_copilot_cli_test.sh b/actions/setup/sh/install_copilot_cli_test.sh index 0cb070de200..7f2f8cf649f 100755 --- a/actions/setup/sh/install_copilot_cli_test.sh +++ b/actions/setup/sh/install_copilot_cli_test.sh @@ -64,6 +64,10 @@ 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" \ 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 index 61c7e6641f9..cccf70ed985 100644 --- 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 @@ -19,8 +19,8 @@ We will opt the compiler-generated default version into compat-matrix resolution - `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`: update `max-agent` from 1.0.56 to 1.0.75 to include the current `DefaultCopilotVersion`. -- `pkg/constants/version_constants_test.go`: add `TestDefaultCopilotVersionWithinCompatWindow` as a CI gate to assert `min-agent ≤ DefaultCopilotVersion ≤ max-agent`, preventing this class of drift from recurring. +- `.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 @@ -30,21 +30,21 @@ The install script could drop the explicit-version argument and always use compa #### Alternative 2: Only bump compat.json max-agent without changing the install script -Updating `max-agent` to 1.0.75 would fix the immediate drift and allow the range check to pass in theory, 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, leaving the underlying bypass latent for the next drift cycle. +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 blocks future `DefaultCopilotVersion`/`compat.json` drift before it reaches production. +- `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: `compat.json` must be updated alongside `DefaultCopilotVersion` on every bump, enforced by the new test. +- The compat window is now a load-bearing correctness invariant, enforced by the new test. - User-supplied `engine.version` values retain exact-match semantics. --- diff --git a/pkg/constants/version_constants_test.go b/pkg/constants/version_constants_test.go index 7c60c92efb6..d4ca27725d7 100644 --- a/pkg/constants/version_constants_test.go +++ b/pkg/constants/version_constants_test.go @@ -38,8 +38,7 @@ func TestDefaultPlaywrightCLIVersionOutsideCooldownWindow(t *testing.T) { // TestDefaultCopilotVersionWithinCompatWindow asserts that DefaultCopilotVersion falls // within the declared compat.json window so the runner toolcache can satisfy installs -// without a network download. Failures here indicate that either DefaultCopilotVersion -// or the compat.json max-agent needs to be updated. +// 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). @@ -74,28 +73,23 @@ func TestDefaultCopilotVersionWithinCompatWindow(t *testing.T) { version := string(DefaultCopilotVersion) - // Find the first open row (open: true, or absent which defaults to true per schema). + // Find the open catch-all row (open: true, or absent which defaults to true per schema). for _, row := range compat.AgentCompatV1.Copilot { - // Skip rows that are explicitly closed (open: false). - if row.Open != nil && !*row.Open { + // 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) } - // max-agent may be "*" (wildcard) for catch-all rows; skip the upper-bound check in that case. - if row.MaxAgent != "*" { - if cmp, err := semverCmp(version, row.MaxAgent); err != nil { - t.Fatalf("semverCmp(%q, %q): %v", version, row.MaxAgent, err) - } else if cmp > 0 { - t.Fatalf("DefaultCopilotVersion %q exceeds compat max-agent %q; update .github/aw/compat.json max-agent or lower DefaultCopilotVersion to prevent toolcache bypass", version, row.MaxAgent) - } - } return // found and validated } t.Fatalf("no open compat row found in %s; add an open row for the current gh-aw release", compatPath)