From 9157adc6cb5c7c9cc48dcdd278e02ced7a3402a6 Mon Sep 17 00:00:00 2001 From: licjun Date: Tue, 22 Sep 2026 18:04:16 -0700 Subject: [PATCH 1/3] feat: verify cached SAM CLI archive integrity The native installer path cached the extracted install directory and added it to PATH on a cache hit without checking it against anything. Restored cache content is only as trustworthy as every writer to the cache scope a job reads, so it should not be trusted implicitly. Cache the release archive instead of the extracted directory, and check it against the SHA256 digest published on the GitHub release before use. On mismatch the entry is discarded and the official archive is downloaded. Freshly downloaded archives are verified too, and a failure there fails the run rather than proceeding. GitHub only populates the release asset digest for releases published after mid-2025. When no digest is available there is nothing to verify against, so the cache is bypassed entirely for that version and the archive is downloaded on every run. Caching only what can be verified keeps the behaviour fail-closed. The cache key prefix becomes "sam-cli-verified" so entries written by the previous unverified layout are never restored by this version. Also add a "cache-hit" output. The cache path had no observable signal, so no test could assert whether it was taken; the output makes the verified-hit path assertable and gives consumers visibility. Tests: unit coverage for digest match, mismatch, absent digest, release metadata lookup failure, and post-download verification failure; plus an integration case asserting a verified cache hit against the real cache service, and one asserting that a version with no published digest does not use the cache. --- .github/workflows/test.yml | 35 ++- action.yml | 3 + dist/index.js | 293 +++++++++++++++++++---- lib/setup.js | 293 +++++++++++++++++++---- test/setup.test.js | 467 +++++++++++++++++++++++-------------- 5 files changed, 813 insertions(+), 278 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 29ff668..bf37717 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -40,8 +40,9 @@ jobs: version: "1.104.0" - run: sam --version | grep -F 1.104.0 - - name: Test official installer (pinned version; should use cache) + - name: Test official installer (pinned version; no published digest, so uncached) uses: ./ + id: installer-pinned-cached with: use-installer: true version: "1.104.0" @@ -56,6 +57,38 @@ jobs: sam --version | grep -F "$version" shell: bash + # A version with a published SHA256 digest can be cached, so the second + # invocation must restore it and pass integrity verification. This is the + # only coverage of the verified cache-hit path against the real cache + # service; unit tests mock @actions/cache and cannot exercise it. + - name: Test verified cache (cold) + uses: ./ + id: cache-cold + with: + use-installer: true + version: "1.166.2" + token: ${{ secrets.GITHUB_TOKEN }} + - run: sam --version | grep -F 1.166.2 + + - name: Test verified cache (warm; must hit cache) + uses: ./ + id: cache-warm + with: + use-installer: true + version: "1.166.2" + token: ${{ secrets.GITHUB_TOKEN }} + - run: sam --version | grep -F 1.166.2 + - name: Assert cache was used + run: | + echo "cold cache-hit=${{ steps.cache-cold.outputs.cache-hit }}" + echo "warm cache-hit=${{ steps.cache-warm.outputs.cache-hit }}" + test "${{ steps.cache-warm.outputs.cache-hit }}" = "true" + + # 1.104.0 predates published asset digests, so it cannot be verified and + # must therefore never be served from the cache. + - name: Assert unverifiable version does not use the cache + run: test "${{ steps.installer-pinned-cached.outputs.cache-hit }}" != "true" + integ: strategy: fail-fast: false diff --git a/action.yml b/action.yml index 5004417..14f8891 100644 --- a/action.yml +++ b/action.yml @@ -17,6 +17,9 @@ inputs: token: description: "Authentication token to be used to call GITHUB Apis" required: false +outputs: + cache-hit: + description: "Whether AWS SAM CLI was restored from the GitHub Actions cache and passed integrity verification" runs: using: node24 main: dist/index.js diff --git a/dist/index.js b/dist/index.js index 269aec8..6cd5127 100644 --- a/dist/index.js +++ b/dist/index.js @@ -7,6 +7,7 @@ const fs = __nccwpck_require__(79896); const path = __nccwpck_require__(16928); const os = __nccwpck_require__(70857); +const crypto = __nccwpck_require__(76982); const core = __nccwpck_require__(37484); const exec = __nccwpck_require__(95236); @@ -131,57 +132,204 @@ function isSemver(s) { } /** - * Get latest SAM CLI version from https://api.github.com/repos/aws/aws-sam-cli/releases/latest + * The cache key prefix. * + * Earlier versions of this action cached the extracted install directory and + * trusted it without verification. This action caches the release archive + * instead and checks it against the digest published on the GitHub release + * before use. The prefix records that entries under it are only ever consumed + * after verification, and keeps entries written by the previous, unverified + * layout - including any that were tampered with - from being restored here. + */ +const CACHE_KEY_PREFIX = "sam-cli-verified"; + +/** + * Returns the release archive name for an architecture. + * + * @param {string} arch - The architecture (x86_64 or arm64). + */ +function archiveName(arch) { + return `aws-sam-cli-linux-${arch}.zip`; +} + +/** + * Fetches release metadata from the GitHub API. + * + * @param {string} url - The release API URL. * @param {string} token - Authentication Token to use for GITHUB Apis. + * @returns {Promise} The parsed release payload. + * @throws {Error} Throws if the request fails. */ -async function getLatestReleaseTag(token) { - const url = "https://api.github.com/repos/aws/aws-sam-cli/releases/latest"; +async function fetchRelease(url, token) { const client = new httpm.HttpClient("setup-sam"); let httpHeaders = undefined; - try { - //By default, GITHUB API calls will be made as unauthenticated requests. If token is present, it will be made as authenticated requests. - if (token) { - httpHeaders = { - Authorization: `Bearer ${token}`, - }; - } + // By default, GITHUB API calls will be made as unauthenticated requests. If + // token is present, it will be made as authenticated requests. + if (token) { + httpHeaders = { + Authorization: `Bearer ${token}`, + }; + } - const response = await client.get(url, httpHeaders); - if (response.message.statusCode !== 200) { - throw new Error( - `Failed to fetch data: ${response.message.statusCode} ${response.message.statusMessage}`, - ); - } + const response = await client.get(url, httpHeaders); + if (response.message.statusCode !== 200) { + throw new Error( + `Failed to fetch data: ${response.message.statusCode} ${response.message.statusMessage}`, + ); + } + + return JSON.parse(await response.readBody()); +} + +/** + * Get the latest SAM CLI release from + * https://api.github.com/repos/aws/aws-sam-cli/releases/latest + * + * @param {string} token - Authentication Token to use for GITHUB Apis. + * @returns {Promise} The release payload, or null on failure. + */ +async function getLatestRelease(token) { + const url = "https://api.github.com/repos/aws/aws-sam-cli/releases/latest"; - const data = JSON.parse(await response.readBody()); - return data.tag_name.substring(1); + try { + return await fetchRelease(url, token); } catch (error) { core.warning("Unable to get SAM CLI's latest release tag ", error); + return null; + } +} + +/** + * Get a specific SAM CLI release by tag. + * + * @param {string} version - The SAM CLI version (x.y.z). + * @param {string} token - Authentication Token to use for GITHUB Apis. + * @returns {Promise} The release payload, or null on failure. + */ +async function getReleaseByTag(version, token) { + const url = `https://api.github.com/repos/aws/aws-sam-cli/releases/tags/v${version}`; + + try { + return await fetchRelease(url, token); + } catch (error) { + core.warning( + `Unable to get release metadata for v${version}: ${error.message}`, + ); + return null; + } +} + +/** + * Extracts the published SHA256 digest of the release archive. + * + * GitHub only populates the asset `digest` field for releases published after + * mid-2025, so this returns an empty string for older versions. + * + * @param {object} release - The release payload. + * @param {string} arch - The architecture (x86_64 or arm64). + * @returns {string} The lowercase hex digest, or "" when unavailable. + */ +function publishedSha256(release, arch) { + if (!release || !Array.isArray(release.assets)) { + return ""; + } + + const asset = release.assets.find((a) => a.name === archiveName(arch)); + if (!asset || !asset.digest) { return ""; } + + const match = /^sha256:([0-9a-f]{64})$/i.exec(String(asset.digest).trim()); + return match ? match[1].toLowerCase() : ""; } /** - * Downloads and caches SAM CLI. + * Computes the SHA256 digest of a file. + * + * @param {string} filePath - The file to hash. + * @returns {Promise} The lowercase hex digest. + */ +function fileSha256(filePath) { + return new Promise((resolve, reject) => { + const hash = crypto.createHash("sha256"); + const stream = fs.createReadStream(filePath); + stream.on("error", reject); + stream.on("data", (chunk) => hash.update(chunk)); + stream.on("end", () => resolve(hash.digest("hex"))); + }); +} + +/** + * Verifies an archive against the published digest. + * + * @param {string} archivePath - The archive to verify. + * @param {string} expectedSha256 - The expected lowercase hex digest. + * @returns {Promise} Whether the archive matches. + */ +async function verifyArchive(archivePath, expectedSha256) { + try { + const actual = await fileSha256(archivePath); + if (actual === expectedSha256) { + return true; + } + core.warning( + `SHA256 mismatch: expected ${expectedSha256}, calculated ${actual}`, + ); + return false; + } catch (error) { + core.warning(`Unable to verify archive: ${error.message}`); + return false; + } +} + +/** + * Extracts an archive to a new temporary directory. + * + * @param {string} archivePath - The archive to extract. + * @returns {Promise} The directory SAM CLI is installed in. + */ +async function extractArchive(archivePath) { + const tempDir = mkdirTemp(); + await tc.extractZip(archivePath, tempDir); + return path.join(tempDir, "dist"); +} + +/** + * Downloads SAM CLI, verifies it, and caches the verified archive. * * @param {string} version - The SAM CLI version. * @param {string} arch - The architecture (x86_64 or arm64). - * @param {string} installDir - The installation directory. + * @param {string} expectedSha256 - The expected lowercase hex digest. + * @param {string} archivePath - Where to store the archive. * @param {string} cacheKey - The cache key. * @returns {Promise} The directory SAM CLI is installed in. */ -async function downloadAndCache(version, arch, installDir, cacheKey) { - const url = `https://github.com/aws/aws-sam-cli/releases/download/v${version}/aws-sam-cli-linux-${arch}.zip`; +async function downloadVerifyAndCache( + version, + arch, + expectedSha256, + archivePath, + cacheKey, +) { + const url = `https://github.com/aws/aws-sam-cli/releases/download/v${version}/${archiveName(arch)}`; try { const toolPath = await tc.downloadTool(url); - await tc.extractZip(toolPath, installDir); - const binDir = path.join(installDir, "dist"); + fs.mkdirSync(path.dirname(archivePath), { recursive: true }); + fs.copyFileSync(toolPath, archivePath); + + if (!(await verifyArchive(archivePath, expectedSha256))) { + core.setFailed( + `Downloaded AWS SAM CLI ${version} does not match its published SHA256 digest. Refusing to use it.`, + ); + return ""; + } + + const binDir = await extractArchive(archivePath); try { - await cache.saveCache([installDir], cacheKey); + await cache.saveCache([archivePath], cacheKey); core.info(`Cached AWS SAM CLI ${version} to GitHub Actions cache`); } catch (error) { core.warning(`Failed to save to cache: ${error.message}`); @@ -195,13 +343,19 @@ async function downloadAndCache(version, arch, installDir, cacheKey) { } /** - * Downloads SAM CLI without caching. + * Downloads SAM CLI without caching it. + * + * Used when no published digest is available to verify against, so the archive + * must not be trusted across runs. * * @param {string} arch - The architecture (x86_64 or arm64). + * @param {string} [version] - The SAM CLI version; latest when omitted. * @returns {Promise} The directory SAM CLI is installed in. */ -async function downloadWithoutCache(arch) { - const url = `https://github.com/aws/aws-sam-cli/releases/latest/download/aws-sam-cli-linux-${arch}.zip`; +async function downloadWithoutCache(arch, version) { + const url = version + ? `https://github.com/aws/aws-sam-cli/releases/download/v${version}/${archiveName(arch)}` + : `https://github.com/aws/aws-sam-cli/releases/latest/download/${archiveName(arch)}`; const tempDir = mkdirTemp(); try { @@ -244,11 +398,23 @@ async function installUsingNativeInstaller(inputVersion, token) { return ""; } - // Determine version + const arch = os.arch() === "arm64" ? "arm64" : "x86_64"; + + // Determine version, and fetch the release metadata we need for the + // integrity check in the same request. let version = inputVersion; - if (!version) { - version = await getLatestReleaseTag(token); - if (!version || !isSemver(version)) { + let release = null; + if (version) { + release = await getReleaseByTag(version, token); + } else { + release = await getLatestRelease(token); + const tag = + release && typeof release.tag_name === "string" + ? release.tag_name.substring(1) + : ""; + if (isSemver(tag)) { + version = tag; + } else { core.info( "Unable to determine version. Downloading latest without caching.", ); @@ -258,7 +424,6 @@ async function installUsingNativeInstaller(inputVersion, token) { } // Validate ARM64 version requirement - const arch = os.arch() === "arm64" ? "arm64" : "x86_64"; if (version && os.arch() === "arm64" && semverLt(version, "1.104.0")) { core.setFailed( "ARM64 installer is only available for versions 1.104.0 and above", @@ -266,31 +431,57 @@ async function installUsingNativeInstaller(inputVersion, token) { return ""; } - // Try cache if we have a version - if (version) { - // Include ImageOS in cache key to isolate caches between runner images - const imageOS = process.env.ImageOS || "unknown"; - const cacheKey = `sam-cli-${os.platform()}-${imageOS}-${arch}-${version}`; - const installDir = path.join(os.homedir(), ".sam-cli-cache", version); + // Download without caching (no version) + if (!version) { + return await downloadWithoutCache(arch); + } - try { - const cacheHit = await cache.restoreCache([installDir], cacheKey); - if (cacheHit) { + // Only cache what we can verify. Restored cache content is writable by + // anything that can write to a cache scope the job reads, so without a + // published digest to check it against there is no safe way to trust it. + const expectedSha256 = publishedSha256(release, arch); + if (!expectedSha256) { + core.info( + `No published SHA256 digest is available for v${version}, so the GitHub Actions cache cannot be used safely. Downloading the official archive instead.`, + ); + return await downloadWithoutCache(arch, version); + } + + // Include ImageOS in cache key to isolate caches between runner images + const imageOS = process.env.ImageOS || "unknown"; + const cacheKey = `${CACHE_KEY_PREFIX}-${os.platform()}-${imageOS}-${arch}-${version}`; + const archivePath = path.join( + os.homedir(), + ".sam-cli-cache", + version, + archiveName(arch), + ); + + try { + const cacheHit = await cache.restoreCache([archivePath], cacheKey); + if (cacheHit) { + if (await verifyArchive(archivePath, expectedSha256)) { core.info( `Using cached AWS SAM CLI ${version} from GitHub Actions cache`, ); - return path.join(installDir, "dist"); + core.setOutput("cache-hit", true); + return await extractArchive(archivePath); } - } catch (error) { - core.warning(`Failed to restore from cache: ${error.message}`); + core.warning( + `Cached AWS SAM CLI ${version} does not match its published SHA256 digest. Discarding the cache entry and downloading the official archive.`, + ); } - - // Download and cache - return await downloadAndCache(version, arch, installDir, cacheKey); + } catch (error) { + core.warning(`Failed to restore from cache: ${error.message}`); } - // Download without caching (no version) - return await downloadWithoutCache(arch); + return await downloadVerifyAndCache( + version, + arch, + expectedSha256, + archivePath, + cacheKey, + ); } async function setup() { @@ -301,6 +492,8 @@ async function setup() { const useInstaller = core.getBooleanInput("use-installer"); const token = getInput("token", /^.*$/, ""); + core.setOutput("cache-hit", false); + const binPath = useInstaller ? await installUsingNativeInstaller(version, token) : await installSamCli(python, version); diff --git a/lib/setup.js b/lib/setup.js index 5012442..35edf03 100644 --- a/lib/setup.js +++ b/lib/setup.js @@ -1,6 +1,7 @@ const fs = require("fs"); const path = require("path"); const os = require("os"); +const crypto = require("crypto"); const core = require("@actions/core"); const exec = require("@actions/exec"); @@ -125,57 +126,204 @@ function isSemver(s) { } /** - * Get latest SAM CLI version from https://api.github.com/repos/aws/aws-sam-cli/releases/latest + * The cache key prefix. * + * Earlier versions of this action cached the extracted install directory and + * trusted it without verification. This action caches the release archive + * instead and checks it against the digest published on the GitHub release + * before use. The prefix records that entries under it are only ever consumed + * after verification, and keeps entries written by the previous, unverified + * layout - including any that were tampered with - from being restored here. + */ +const CACHE_KEY_PREFIX = "sam-cli-verified"; + +/** + * Returns the release archive name for an architecture. + * + * @param {string} arch - The architecture (x86_64 or arm64). + */ +function archiveName(arch) { + return `aws-sam-cli-linux-${arch}.zip`; +} + +/** + * Fetches release metadata from the GitHub API. + * + * @param {string} url - The release API URL. * @param {string} token - Authentication Token to use for GITHUB Apis. + * @returns {Promise} The parsed release payload. + * @throws {Error} Throws if the request fails. */ -async function getLatestReleaseTag(token) { - const url = "https://api.github.com/repos/aws/aws-sam-cli/releases/latest"; +async function fetchRelease(url, token) { const client = new httpm.HttpClient("setup-sam"); let httpHeaders = undefined; - try { - //By default, GITHUB API calls will be made as unauthenticated requests. If token is present, it will be made as authenticated requests. - if (token) { - httpHeaders = { - Authorization: `Bearer ${token}`, - }; - } + // By default, GITHUB API calls will be made as unauthenticated requests. If + // token is present, it will be made as authenticated requests. + if (token) { + httpHeaders = { + Authorization: `Bearer ${token}`, + }; + } - const response = await client.get(url, httpHeaders); - if (response.message.statusCode !== 200) { - throw new Error( - `Failed to fetch data: ${response.message.statusCode} ${response.message.statusMessage}`, - ); - } + const response = await client.get(url, httpHeaders); + if (response.message.statusCode !== 200) { + throw new Error( + `Failed to fetch data: ${response.message.statusCode} ${response.message.statusMessage}`, + ); + } + + return JSON.parse(await response.readBody()); +} - const data = JSON.parse(await response.readBody()); - return data.tag_name.substring(1); +/** + * Get the latest SAM CLI release from + * https://api.github.com/repos/aws/aws-sam-cli/releases/latest + * + * @param {string} token - Authentication Token to use for GITHUB Apis. + * @returns {Promise} The release payload, or null on failure. + */ +async function getLatestRelease(token) { + const url = "https://api.github.com/repos/aws/aws-sam-cli/releases/latest"; + + try { + return await fetchRelease(url, token); } catch (error) { core.warning("Unable to get SAM CLI's latest release tag ", error); + return null; + } +} + +/** + * Get a specific SAM CLI release by tag. + * + * @param {string} version - The SAM CLI version (x.y.z). + * @param {string} token - Authentication Token to use for GITHUB Apis. + * @returns {Promise} The release payload, or null on failure. + */ +async function getReleaseByTag(version, token) { + const url = `https://api.github.com/repos/aws/aws-sam-cli/releases/tags/v${version}`; + + try { + return await fetchRelease(url, token); + } catch (error) { + core.warning( + `Unable to get release metadata for v${version}: ${error.message}`, + ); + return null; + } +} + +/** + * Extracts the published SHA256 digest of the release archive. + * + * GitHub only populates the asset `digest` field for releases published after + * mid-2025, so this returns an empty string for older versions. + * + * @param {object} release - The release payload. + * @param {string} arch - The architecture (x86_64 or arm64). + * @returns {string} The lowercase hex digest, or "" when unavailable. + */ +function publishedSha256(release, arch) { + if (!release || !Array.isArray(release.assets)) { + return ""; + } + + const asset = release.assets.find((a) => a.name === archiveName(arch)); + if (!asset || !asset.digest) { return ""; } + + const match = /^sha256:([0-9a-f]{64})$/i.exec(String(asset.digest).trim()); + return match ? match[1].toLowerCase() : ""; } /** - * Downloads and caches SAM CLI. + * Computes the SHA256 digest of a file. + * + * @param {string} filePath - The file to hash. + * @returns {Promise} The lowercase hex digest. + */ +function fileSha256(filePath) { + return new Promise((resolve, reject) => { + const hash = crypto.createHash("sha256"); + const stream = fs.createReadStream(filePath); + stream.on("error", reject); + stream.on("data", (chunk) => hash.update(chunk)); + stream.on("end", () => resolve(hash.digest("hex"))); + }); +} + +/** + * Verifies an archive against the published digest. + * + * @param {string} archivePath - The archive to verify. + * @param {string} expectedSha256 - The expected lowercase hex digest. + * @returns {Promise} Whether the archive matches. + */ +async function verifyArchive(archivePath, expectedSha256) { + try { + const actual = await fileSha256(archivePath); + if (actual === expectedSha256) { + return true; + } + core.warning( + `SHA256 mismatch: expected ${expectedSha256}, calculated ${actual}`, + ); + return false; + } catch (error) { + core.warning(`Unable to verify archive: ${error.message}`); + return false; + } +} + +/** + * Extracts an archive to a new temporary directory. + * + * @param {string} archivePath - The archive to extract. + * @returns {Promise} The directory SAM CLI is installed in. + */ +async function extractArchive(archivePath) { + const tempDir = mkdirTemp(); + await tc.extractZip(archivePath, tempDir); + return path.join(tempDir, "dist"); +} + +/** + * Downloads SAM CLI, verifies it, and caches the verified archive. * * @param {string} version - The SAM CLI version. * @param {string} arch - The architecture (x86_64 or arm64). - * @param {string} installDir - The installation directory. + * @param {string} expectedSha256 - The expected lowercase hex digest. + * @param {string} archivePath - Where to store the archive. * @param {string} cacheKey - The cache key. * @returns {Promise} The directory SAM CLI is installed in. */ -async function downloadAndCache(version, arch, installDir, cacheKey) { - const url = `https://github.com/aws/aws-sam-cli/releases/download/v${version}/aws-sam-cli-linux-${arch}.zip`; +async function downloadVerifyAndCache( + version, + arch, + expectedSha256, + archivePath, + cacheKey, +) { + const url = `https://github.com/aws/aws-sam-cli/releases/download/v${version}/${archiveName(arch)}`; try { const toolPath = await tc.downloadTool(url); - await tc.extractZip(toolPath, installDir); - const binDir = path.join(installDir, "dist"); + fs.mkdirSync(path.dirname(archivePath), { recursive: true }); + fs.copyFileSync(toolPath, archivePath); + + if (!(await verifyArchive(archivePath, expectedSha256))) { + core.setFailed( + `Downloaded AWS SAM CLI ${version} does not match its published SHA256 digest. Refusing to use it.`, + ); + return ""; + } + + const binDir = await extractArchive(archivePath); try { - await cache.saveCache([installDir], cacheKey); + await cache.saveCache([archivePath], cacheKey); core.info(`Cached AWS SAM CLI ${version} to GitHub Actions cache`); } catch (error) { core.warning(`Failed to save to cache: ${error.message}`); @@ -189,13 +337,19 @@ async function downloadAndCache(version, arch, installDir, cacheKey) { } /** - * Downloads SAM CLI without caching. + * Downloads SAM CLI without caching it. + * + * Used when no published digest is available to verify against, so the archive + * must not be trusted across runs. * * @param {string} arch - The architecture (x86_64 or arm64). + * @param {string} [version] - The SAM CLI version; latest when omitted. * @returns {Promise} The directory SAM CLI is installed in. */ -async function downloadWithoutCache(arch) { - const url = `https://github.com/aws/aws-sam-cli/releases/latest/download/aws-sam-cli-linux-${arch}.zip`; +async function downloadWithoutCache(arch, version) { + const url = version + ? `https://github.com/aws/aws-sam-cli/releases/download/v${version}/${archiveName(arch)}` + : `https://github.com/aws/aws-sam-cli/releases/latest/download/${archiveName(arch)}`; const tempDir = mkdirTemp(); try { @@ -238,11 +392,23 @@ async function installUsingNativeInstaller(inputVersion, token) { return ""; } - // Determine version + const arch = os.arch() === "arm64" ? "arm64" : "x86_64"; + + // Determine version, and fetch the release metadata we need for the + // integrity check in the same request. let version = inputVersion; - if (!version) { - version = await getLatestReleaseTag(token); - if (!version || !isSemver(version)) { + let release = null; + if (version) { + release = await getReleaseByTag(version, token); + } else { + release = await getLatestRelease(token); + const tag = + release && typeof release.tag_name === "string" + ? release.tag_name.substring(1) + : ""; + if (isSemver(tag)) { + version = tag; + } else { core.info( "Unable to determine version. Downloading latest without caching.", ); @@ -252,7 +418,6 @@ async function installUsingNativeInstaller(inputVersion, token) { } // Validate ARM64 version requirement - const arch = os.arch() === "arm64" ? "arm64" : "x86_64"; if (version && os.arch() === "arm64" && semverLt(version, "1.104.0")) { core.setFailed( "ARM64 installer is only available for versions 1.104.0 and above", @@ -260,31 +425,57 @@ async function installUsingNativeInstaller(inputVersion, token) { return ""; } - // Try cache if we have a version - if (version) { - // Include ImageOS in cache key to isolate caches between runner images - const imageOS = process.env.ImageOS || "unknown"; - const cacheKey = `sam-cli-${os.platform()}-${imageOS}-${arch}-${version}`; - const installDir = path.join(os.homedir(), ".sam-cli-cache", version); + // Download without caching (no version) + if (!version) { + return await downloadWithoutCache(arch); + } - try { - const cacheHit = await cache.restoreCache([installDir], cacheKey); - if (cacheHit) { + // Only cache what we can verify. Restored cache content is writable by + // anything that can write to a cache scope the job reads, so without a + // published digest to check it against there is no safe way to trust it. + const expectedSha256 = publishedSha256(release, arch); + if (!expectedSha256) { + core.info( + `No published SHA256 digest is available for v${version}, so the GitHub Actions cache cannot be used safely. Downloading the official archive instead.`, + ); + return await downloadWithoutCache(arch, version); + } + + // Include ImageOS in cache key to isolate caches between runner images + const imageOS = process.env.ImageOS || "unknown"; + const cacheKey = `${CACHE_KEY_PREFIX}-${os.platform()}-${imageOS}-${arch}-${version}`; + const archivePath = path.join( + os.homedir(), + ".sam-cli-cache", + version, + archiveName(arch), + ); + + try { + const cacheHit = await cache.restoreCache([archivePath], cacheKey); + if (cacheHit) { + if (await verifyArchive(archivePath, expectedSha256)) { core.info( `Using cached AWS SAM CLI ${version} from GitHub Actions cache`, ); - return path.join(installDir, "dist"); + core.setOutput("cache-hit", true); + return await extractArchive(archivePath); } - } catch (error) { - core.warning(`Failed to restore from cache: ${error.message}`); + core.warning( + `Cached AWS SAM CLI ${version} does not match its published SHA256 digest. Discarding the cache entry and downloading the official archive.`, + ); } - - // Download and cache - return await downloadAndCache(version, arch, installDir, cacheKey); + } catch (error) { + core.warning(`Failed to restore from cache: ${error.message}`); } - // Download without caching (no version) - return await downloadWithoutCache(arch); + return await downloadVerifyAndCache( + version, + arch, + expectedSha256, + archivePath, + cacheKey, + ); } async function setup() { @@ -295,6 +486,8 @@ async function setup() { const useInstaller = core.getBooleanInput("use-installer"); const token = getInput("token", /^.*$/, ""); + core.setOutput("cache-hit", false); + const binPath = useInstaller ? await installUsingNativeInstaller(version, token) : await installSamCli(python, version); diff --git a/test/setup.test.js b/test/setup.test.js index fe7c0ec..e5e4c08 100644 --- a/test/setup.test.js +++ b/test/setup.test.js @@ -5,6 +5,7 @@ jest.mock("@actions/tool-cache"); jest.mock("@actions/cache"); const os = require("os"); +const path = require("path"); const core = require("@actions/core"); const exec = require("@actions/exec"); @@ -91,220 +92,327 @@ test.each([ await expect(setup).rejects.toThrow(Error); }); +// --------------------------------------------------------------------------- +// Native installer / cache integrity +// +// The action caches the release archive (not the extracted directory) and +// verifies it against the SHA256 digest published on the GitHub release before +// trusting it. Restored cache content is writable by anything that can write +// to a cache scope the job reads, so these tests pin the verification +// behaviour. +// --------------------------------------------------------------------------- + +const fs = require("fs"); +const crypto = require("crypto"); + +function sha256(buffer) { + return crypto.createHash("sha256").update(buffer).digest("hex"); +} + +const GOOD_ARCHIVE = Buffer.from("official aws-sam-cli release archive"); +const GOOD_SHA256 = sha256(GOOD_ARCHIVE); +const OTHER_ARCHIVE = Buffer.from("some other archive contents"); + +/** + * Builds a GitHub release payload, optionally carrying an asset digest. + * GitHub only populates `digest` for releases published after mid-2025, so + * `digest: null` models an older release. + */ +function releaseBody(tag, arch, digest) { + return JSON.stringify({ + tag_name: tag, + assets: [ + { + name: `aws-sam-cli-linux-${arch}.zip`, + digest: digest ? `sha256:${digest}` : null, + }, + ], + }); +} + +function mockRelease(tag, arch, digest) { + return jest.spyOn(httpm.HttpClient.prototype, "get").mockResolvedValue({ + message: { statusCode: 200 }, + readBody: () => releaseBody(tag, arch, digest), + }); +} + +let fakeHome; + +beforeEach(() => { + fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), "setup-sam-home-")); + jest.spyOn(os, "homedir").mockReturnValue(fakeHome); +}); + +afterEach(() => { + fs.rmSync(fakeHome, { recursive: true, force: true }); +}); + +function archivePathFor(version, arch) { + return path.join( + fakeHome, + ".sam-cli-cache", + version, + `aws-sam-cli-linux-${arch}.zip`, + ); +} + +/** Simulates restoreCache writing the entry back to disk. */ +function seedCache(version, arch, contents) { + const target = archivePathFor(version, arch); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, contents); + return target; +} + +/** Simulates downloadTool by returning a real file on disk. */ +function stubDownload(contents) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "setup-sam-dl-")); + const file = path.join(dir, "download.zip"); + fs.writeFileSync(file, contents); + tc.downloadTool = jest.fn().mockResolvedValue(file); + return file; +} + +function useInstaller(version) { + core.getBooleanInput = jest.fn().mockReturnValue(true); + core.getInput = jest.fn().mockReturnValueOnce(version); +} + test.each([ - ["x64", "x86_64", "1.104.567"], - ["arm64", "arm64", "1.104.0"], - ["x64", "x86_64", "1.23.456"], - ["arm64", "arm64", "1.135.0"], + ["x64", "x86_64", "1.166.2"], + ["arm64", "arm64", "1.166.2"], + ["x64", "x86_64", "1.140.0"], + ["arm64", "arm64", "1.140.0"], ])( - "when use-installer enabled and version specified and cached version exists, uses cached version %s", - async (inputArch, expectedArch, input) => { + "cache hit whose archive matches the published digest is used (%s %s %s)", + async (inputArch, expectedArch, version) => { jest.spyOn(os, "platform").mockReturnValue("linux"); jest.spyOn(os, "arch").mockReturnValue(inputArch); + process.env.ImageOS = "ubuntu24"; - // Mock ImageOS environment variable - const originalImageOS = process.env.ImageOS; - process.env.ImageOS = "ubuntu22"; - - core.getBooleanInput = jest.fn().mockReturnValue(true); - core.getInput = jest.fn().mockReturnValueOnce(input); + useInstaller(version); + mockRelease(`v${version}`, expectedArch, GOOD_SHA256); + seedCache(version, expectedArch, GOOD_ARCHIVE); - cache.restoreCache = jest.fn().mockResolvedValueOnce("cache-key-hit"); + cache.restoreCache = jest.fn().mockResolvedValue("cache-key-hit"); + tc.extractZip = jest.fn().mockResolvedValue(undefined); + tc.downloadTool = jest.fn(); await setup(); - expect(cache.restoreCache).toHaveBeenCalledTimes(1); expect(cache.restoreCache).toHaveBeenCalledWith( - expect.arrayContaining([expect.stringContaining(".sam-cli-cache")]), - `sam-cli-linux-ubuntu22-${expectedArch}-${input}`, + [archivePathFor(version, expectedArch)], + `sam-cli-verified-linux-ubuntu24-${expectedArch}-${version}`, ); - expect(cache.saveCache).toHaveBeenCalledTimes(0); - expect(tc.downloadTool).toHaveBeenCalledTimes(0); - - expect(core.addPath).toHaveBeenCalled(); - - // Restore original ImageOS - if (originalImageOS === undefined) { - delete process.env.ImageOS; - } else { - process.env.ImageOS = originalImageOS; - } + expect(tc.downloadTool).not.toHaveBeenCalled(); + expect(cache.saveCache).not.toHaveBeenCalled(); + expect(tc.extractZip).toHaveBeenCalledTimes(1); + expect(core.setOutput).toHaveBeenCalledWith("cache-hit", true); + expect(core.addPath).toHaveBeenCalledTimes(1); + expect(core.setFailed).not.toHaveBeenCalled(); }, ); +test("cache entry that fails verification is discarded and re-downloaded", async () => { + jest.spyOn(os, "platform").mockReturnValue("linux"); + jest.spyOn(os, "arch").mockReturnValue("x64"); + process.env.ImageOS = "ubuntu24"; + + useInstaller("1.166.2"); + mockRelease("v1.166.2", "x86_64", GOOD_SHA256); + // The cache holds something other than the official archive. + seedCache("1.166.2", "x86_64", OTHER_ARCHIVE); + + cache.restoreCache = jest.fn().mockResolvedValue("cache-key-hit"); + cache.saveCache = jest.fn().mockResolvedValue(1); + tc.extractZip = jest.fn().mockResolvedValue(undefined); + stubDownload(GOOD_ARCHIVE); + + await setup(); + + expect(core.warning).toHaveBeenCalledWith( + expect.stringContaining("does not match its published SHA256 digest"), + ); + // Falls back to the official download rather than trusting the cache. + expect(tc.downloadTool).toHaveBeenCalledWith( + "https://github.com/aws/aws-sam-cli/releases/download/v1.166.2/aws-sam-cli-linux-x86_64.zip", + ); + expect(core.setOutput).toHaveBeenCalledWith("cache-hit", false); + expect(core.setOutput).not.toHaveBeenCalledWith("cache-hit", true); + expect(core.addPath).toHaveBeenCalledTimes(1); + expect(core.setFailed).not.toHaveBeenCalled(); +}); + +test("cache miss downloads, verifies, then caches the archive", async () => { + jest.spyOn(os, "platform").mockReturnValue("linux"); + jest.spyOn(os, "arch").mockReturnValue("x64"); + process.env.ImageOS = "ubuntu24"; + + useInstaller("1.166.2"); + mockRelease("v1.166.2", "x86_64", GOOD_SHA256); + + cache.restoreCache = jest.fn().mockResolvedValue(undefined); + cache.saveCache = jest.fn().mockResolvedValue(1); + tc.extractZip = jest.fn().mockResolvedValue(undefined); + stubDownload(GOOD_ARCHIVE); + + await setup(); + + expect(tc.downloadTool).toHaveBeenCalledTimes(1); + expect(cache.saveCache).toHaveBeenCalledWith( + [archivePathFor("1.166.2", "x86_64")], + "sam-cli-verified-linux-ubuntu24-x86_64-1.166.2", + ); + expect(core.addPath).toHaveBeenCalledTimes(1); + expect(core.setFailed).not.toHaveBeenCalled(); +}); + +test("downloaded archive that fails verification is not used or cached", async () => { + jest.spyOn(os, "platform").mockReturnValue("linux"); + jest.spyOn(os, "arch").mockReturnValue("x64"); + process.env.ImageOS = "ubuntu24"; + + useInstaller("1.166.2"); + mockRelease("v1.166.2", "x86_64", GOOD_SHA256); + + cache.restoreCache = jest.fn().mockResolvedValue(undefined); + cache.saveCache = jest.fn().mockResolvedValue(1); + tc.extractZip = jest.fn().mockResolvedValue(undefined); + // The download itself does not match the published digest. + stubDownload(OTHER_ARCHIVE); + + await setup(); + + expect(core.setFailed).toHaveBeenCalledWith( + expect.stringContaining("does not match its published SHA256 digest"), + ); + expect(cache.saveCache).not.toHaveBeenCalled(); + expect(core.addPath).not.toHaveBeenCalled(); +}); + test.each([ - ["x64", "x86_64", "1.104.567"], - ["arm64", "arm64", "1.104.0"], - ["x64", "x86_64", "1.23.456"], - ["arm64", "arm64", "1.135.0"], + ["x64", "x86_64"], + ["arm64", "arm64"], ])( - "when use-installer enabled and version specified and cached version does not exist, downloads and caches version %s", - async (inputArch, expectedArch, input) => { + "version without a published digest is downloaded without using the cache (%s)", + async (inputArch, expectedArch) => { jest.spyOn(os, "platform").mockReturnValue("linux"); jest.spyOn(os, "arch").mockReturnValue(inputArch); + process.env.ImageOS = "ubuntu24"; - // Mock ImageOS environment variable - const originalImageOS = process.env.ImageOS; - process.env.ImageOS = "ubuntu22"; - - core.getBooleanInput = jest.fn().mockReturnValue(true); - core.getInput = jest.fn().mockReturnValueOnce(input); + useInstaller("1.104.0"); + // Releases published before mid-2025 carry no asset digest. + mockRelease("v1.104.0", expectedArch, null); - cache.restoreCache = jest.fn().mockResolvedValueOnce(undefined); - cache.saveCache = jest.fn().mockResolvedValueOnce(1); - tc.extractZip = jest.fn().mockResolvedValueOnce(undefined); - tc.downloadTool = jest - .fn() - .mockResolvedValueOnce("/path/to/downloaded/sam"); + cache.restoreCache = jest.fn(); + cache.saveCache = jest.fn(); + tc.extractZip = jest.fn().mockResolvedValue(undefined); + stubDownload(GOOD_ARCHIVE); await setup(); - expect(cache.restoreCache).toHaveBeenCalledTimes(1); - expect(tc.downloadTool).toHaveBeenCalledTimes(1); - expect(tc.extractZip).toHaveBeenCalledTimes(1); - expect(cache.saveCache).toHaveBeenCalledTimes(1); - expect(cache.saveCache).toHaveBeenCalledWith( - expect.arrayContaining([expect.stringContaining(".sam-cli-cache")]), - `sam-cli-linux-ubuntu22-${expectedArch}-${input}`, + expect(cache.restoreCache).not.toHaveBeenCalled(); + expect(cache.saveCache).not.toHaveBeenCalled(); + expect(tc.downloadTool).toHaveBeenCalledWith( + `https://github.com/aws/aws-sam-cli/releases/download/v1.104.0/aws-sam-cli-linux-${expectedArch}.zip`, ); - - expect(core.addPath).toHaveBeenCalled(); - - // Restore original ImageOS - if (originalImageOS === undefined) { - delete process.env.ImageOS; - } else { - process.env.ImageOS = originalImageOS; - } + expect(core.setOutput).toHaveBeenCalledWith("cache-hit", false); + expect(core.addPath).toHaveBeenCalledTimes(1); + expect(core.setFailed).not.toHaveBeenCalled(); }, ); -test("when use-installer enabled and version is too old for ARM, fails on ARM architecture", async () => { +test("release metadata lookup failure falls back to an uncached download", async () => { jest.spyOn(os, "platform").mockReturnValue("linux"); - jest.spyOn(os, "arch").mockReturnValue("arm64"); + jest.spyOn(os, "arch").mockReturnValue("x64"); + process.env.ImageOS = "ubuntu24"; - core.getBooleanInput = jest.fn().mockReturnValue(true); - core.getInput = jest.fn().mockReturnValueOnce("1.103.0"); + useInstaller("1.166.2"); + jest + .spyOn(httpm.HttpClient.prototype, "get") + .mockRejectedValue(new Error("Mocked exception")); + + cache.restoreCache = jest.fn(); + cache.saveCache = jest.fn(); + tc.extractZip = jest.fn().mockResolvedValue(undefined); + stubDownload(GOOD_ARCHIVE); await setup(); - expect(core.setFailed).toHaveBeenCalledWith( - "ARM64 installer is only available for versions 1.104.0 and above", - ); - expect(tc.downloadTool).not.toHaveBeenCalled(); expect(cache.restoreCache).not.toHaveBeenCalled(); + expect(cache.saveCache).not.toHaveBeenCalled(); + expect(tc.downloadTool).toHaveBeenCalledWith( + "https://github.com/aws/aws-sam-cli/releases/download/v1.166.2/aws-sam-cli-linux-x86_64.zip", + ); + expect(core.addPath).toHaveBeenCalledTimes(1); }); test.each([ ["x64", "x86_64"], ["arm64", "arm64"], ])( - "when use-installer enabled and version not specified, cache latest version (Linux %s)", + "no version specified resolves latest and caches the verified archive (%s)", async (inputArch, expectedArch) => { jest.spyOn(os, "platform").mockReturnValue("linux"); jest.spyOn(os, "arch").mockReturnValue(inputArch); + process.env.ImageOS = "ubuntu24"; - // Mock ImageOS environment variable - const originalImageOS = process.env.ImageOS; - process.env.ImageOS = "ubuntu22"; - - core.getBooleanInput = jest.fn().mockReturnValue(true); - core.getInput = jest.fn().mockReturnValueOnce(""); - - jest.spyOn(httpm.HttpClient.prototype, "get").mockResolvedValue({ - message: { statusCode: 200 }, - readBody: () => { - return `{ "tag_name": "v1.139.0" }`; - }, - }); + useInstaller(""); + mockRelease("v1.166.2", expectedArch, GOOD_SHA256); - cache.restoreCache = jest.fn().mockResolvedValueOnce(undefined); - cache.saveCache = jest.fn().mockResolvedValueOnce(1); - tc.extractZip = jest.fn().mockResolvedValueOnce(undefined); - tc.downloadTool = jest - .fn() - .mockResolvedValueOnce("/path/to/downloaded/sam"); + cache.restoreCache = jest.fn().mockResolvedValue(undefined); + cache.saveCache = jest.fn().mockResolvedValue(1); + tc.extractZip = jest.fn().mockResolvedValue(undefined); + stubDownload(GOOD_ARCHIVE); await setup(); expect(tc.downloadTool).toHaveBeenCalledWith( - `https://github.com/aws/aws-sam-cli/releases/download/v1.139.0/aws-sam-cli-linux-${expectedArch}.zip`, + `https://github.com/aws/aws-sam-cli/releases/download/v1.166.2/aws-sam-cli-linux-${expectedArch}.zip`, ); - - expect(cache.restoreCache).toHaveBeenCalledTimes(1); - expect(cache.saveCache).toHaveBeenCalledTimes(1); - expect(core.addPath).toHaveBeenCalled(); - - // Restore original ImageOS - if (originalImageOS === undefined) { - delete process.env.ImageOS; - } else { - process.env.ImageOS = originalImageOS; - } + expect(cache.saveCache).toHaveBeenCalledWith( + [archivePathFor("1.166.2", expectedArch)], + `sam-cli-verified-linux-ubuntu24-${expectedArch}-1.166.2`, + ); + expect(core.addPath).toHaveBeenCalledTimes(1); }, ); test.each([ { arch: "x64", - releaseTagVersion: "v1.110.0", - input: { userInstaller: true }, - expected: { - arch: "x86_64", - latestVersion: "v1.110.0", - headers: undefined, - }, + input: { token: undefined }, + expected: { headers: undefined }, }, { arch: "x64", - releaseTagVersion: "v1.110.0", - input: { userInstaller: true, token: "" }, - expected: { - arch: "x86_64", - latestVersion: "v1.110.0", - headers: undefined, - }, + input: { token: "" }, + expected: { headers: undefined }, }, { arch: "x64", - releaseTagVersion: "v1.110.0", - input: { userInstaller: true, token: "1234abc" }, - expected: { - arch: "x86_64", - latestVersion: "v1.110.0", - headers: { - Authorization: "Bearer 1234abc", - }, - }, + input: { token: "1234abc" }, + expected: { headers: { Authorization: "Bearer 1234abc" } }, }, ])("github api request %o", async (test) => { jest.spyOn(os, "platform").mockReturnValue("linux"); jest.spyOn(os, "arch").mockReturnValue(test.arch); + process.env.ImageOS = "ubuntu24"; - // Mock ImageOS environment variable - const originalImageOS = process.env.ImageOS; - process.env.ImageOS = "ubuntu22"; - - const getMock = jest - .spyOn(httpm.HttpClient.prototype, "get") - .mockResolvedValue({ - message: { statusCode: 200 }, - readBody: () => { - return `{ "tag_name": "${test.releaseTagVersion}" }`; - }, - }); - - core.getBooleanInput = jest.fn().mockReturnValue(test.input.userInstaller); + core.getBooleanInput = jest.fn().mockReturnValue(true); core.getInput = jest .fn() .mockReturnValueOnce("") .mockReturnValueOnce("python3") .mockReturnValueOnce(test.input.token); - cache.restoreCache = jest.fn().mockResolvedValueOnce(undefined); - cache.saveCache = jest.fn().mockResolvedValueOnce(1); - tc.extractZip = jest.fn().mockResolvedValueOnce(undefined); - tc.downloadTool = jest.fn().mockResolvedValueOnce("/path/to/downloaded/sam"); + const getMock = mockRelease("v1.166.2", "x86_64", GOOD_SHA256); + + cache.restoreCache = jest.fn().mockResolvedValue(undefined); + cache.saveCache = jest.fn().mockResolvedValue(1); + tc.extractZip = jest.fn().mockResolvedValue(undefined); + stubDownload(GOOD_ARCHIVE); await setup(); @@ -312,68 +420,73 @@ test.each([ expect.anything(), test.expected.headers, ); - - expect(tc.downloadTool).toHaveBeenCalledWith( - `https://github.com/aws/aws-sam-cli/releases/download/${test.expected.latestVersion}/aws-sam-cli-linux-${test.expected.arch}.zip`, - ); - - expect(cache.restoreCache).toHaveBeenCalledTimes(1); - expect(cache.saveCache).toHaveBeenCalledTimes(1); - expect(core.addPath).toHaveBeenCalled(); - - // Restore original ImageOS - if (originalImageOS === undefined) { - delete process.env.ImageOS; - } else { - process.env.ImageOS = originalImageOS; - } + expect(core.addPath).toHaveBeenCalledTimes(1); }); test.each([ ["x64", "x86_64"], ["arm64", "arm64"], ])( - "when use-installer enabled and version not specified, downloads latest version when getLatestReleaseTag failed (Linux %s)", - async (archInput, expectedArch) => { + "latest release lookup failure downloads latest without caching (%s)", + async (inputArch, expectedArch) => { jest.spyOn(os, "platform").mockReturnValue("linux"); - jest.spyOn(os, "arch").mockReturnValue(archInput); + jest.spyOn(os, "arch").mockReturnValue(inputArch); + + useInstaller(""); jest .spyOn(httpm.HttpClient.prototype, "get") - .mockRejectedValueOnce(new Error("Mocked exception")); + .mockRejectedValue(new Error("Mocked exception")); - core.getBooleanInput = jest.fn().mockReturnValue(true); - core.getInput = jest.fn().mockReturnValueOnce(""); - - tc.extractZip = jest.fn().mockResolvedValueOnce(undefined); - tc.downloadTool = jest - .fn() - .mockResolvedValueOnce("/path/to/downloaded/sam"); + cache.restoreCache = jest.fn(); + cache.saveCache = jest.fn(); + tc.extractZip = jest.fn().mockResolvedValue(undefined); + stubDownload(GOOD_ARCHIVE); await setup(); expect(tc.downloadTool).toHaveBeenCalledWith( `https://github.com/aws/aws-sam-cli/releases/latest/download/aws-sam-cli-linux-${expectedArch}.zip`, ); - - expect(cache.restoreCache).toHaveBeenCalledTimes(0); - expect(cache.saveCache).toHaveBeenCalledTimes(0); - expect(core.addPath).toHaveBeenCalled(); + expect(cache.restoreCache).not.toHaveBeenCalled(); + expect(cache.saveCache).not.toHaveBeenCalled(); + expect(core.addPath).toHaveBeenCalledTimes(1); }, ); +test("version too old for ARM fails before any download or cache access", async () => { + jest.spyOn(os, "platform").mockReturnValue("linux"); + jest.spyOn(os, "arch").mockReturnValue("arm64"); + + useInstaller("1.103.0"); + mockRelease("v1.103.0", "arm64", GOOD_SHA256); + + cache.restoreCache = jest.fn(); + tc.downloadTool = jest.fn(); + + await setup(); + + expect(core.setFailed).toHaveBeenCalledWith( + "ARM64 installer is only available for versions 1.104.0 and above", + ); + expect(tc.downloadTool).not.toHaveBeenCalled(); + expect(cache.restoreCache).not.toHaveBeenCalled(); +}); + test.each([["x64"], ["arm64"]])( - "when use-installer enabled but version is not in format x.y.z, not downloaded or checked in cache (Linux %s)", - async (archInput) => { + "version not in x.y.z format is neither downloaded nor looked up in cache (%s)", + async (inputArch) => { jest.spyOn(os, "platform").mockReturnValue("linux"); - jest.spyOn(os, "arch").mockReturnValue(archInput); + jest.spyOn(os, "arch").mockReturnValue(inputArch); core.getBooleanInput = jest.fn().mockReturnValue(true); + cache.restoreCache = jest.fn(); + tc.downloadTool = jest.fn(); for (const version of ["1.2", "1.*", "3"]) { core.getInput = jest.fn().mockReturnValueOnce(version); await setup(); - expect(tc.downloadTool).toHaveBeenCalledTimes(0); - expect(cache.restoreCache).toHaveBeenCalledTimes(0); + expect(tc.downloadTool).not.toHaveBeenCalled(); + expect(cache.restoreCache).not.toHaveBeenCalled(); } }, ); From 21021066a4b586ff7476af2376d7027a488a4f33 Mon Sep 17 00:00:00 2001 From: licjun Date: Thu, 15 Oct 2026 12:14:14 -0700 Subject: [PATCH 2/3] fix: address review feedback on cached archive verification Three changes from review: Reword the digest mismatch warning. It claimed to discard the cache entry, which it never did: entries are immutable, so re-saving under the same key is rejected and the entry stays. Because it is restored on every run it also never ages out, so the mismatch recurs indefinitely. The warning now says the entry is being ignored and prints the `gh cache delete` command to clear it. The save-failure message explains the same constraint rather than reporting a generic failure. Default the token input to the job token on github.com, matching the first-party setup actions. The installer path now reads the release API on every run to obtain the digest, so an unauthenticated request - 60 per hour per IP, shared across runners - can be exhausted by unrelated jobs, leaving nothing to verify against and silently bypassing the cache. On GitHub Enterprise Server the expression yields an empty string, since api.github.com is hardcoded and an instance token would be useless there as well as sent off-instance. README updated, and the explicit token is dropped from the example and the new test steps so they exercise the default. Make the release fixture multi-asset. It previously carried a single asset, so selecting by suffix or by position passed just as well as matching on name, and the sha256 digest format check was unverifiable. The fixture now lists both architectures plus signatures and installers, gives every asset other than the one under test a decoy digest, and orders the decoy first. Added cases for digests that are unprefixed, a different algorithm, truncated, non-hex, or empty. Mutation-checked: matching assets by suffix fails 11 tests, dropping the digest format check fails 4, and restoring the "discarding" wording fails 1. All three passed before these changes. Left as-is, with reasoning in the review thread: ImageOS stays in the cache key, and the two pre-existing issues raised (silent success when a download throws, and a dropped error message from passing an Error as core.warning's second argument) are better handled separately. --- .github/workflows/test.yml | 2 - README.md | 9 ++-- action.yml | 7 +++ dist/index.js | 12 ++++- lib/setup.js | 12 ++++- test/setup.test.js | 107 ++++++++++++++++++++++++++++++++++++- 6 files changed, 136 insertions(+), 13 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bf37717..06aa6a6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -67,7 +67,6 @@ jobs: with: use-installer: true version: "1.166.2" - token: ${{ secrets.GITHUB_TOKEN }} - run: sam --version | grep -F 1.166.2 - name: Test verified cache (warm; must hit cache) @@ -76,7 +75,6 @@ jobs: with: use-installer: true version: "1.166.2" - token: ${{ secrets.GITHUB_TOKEN }} - run: sam --version | grep -F 1.166.2 - name: Assert cache was used run: | diff --git a/README.md b/README.md index 2461a2c..75f7d30 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,6 @@ jobs: - uses: aws-actions/setup-sam@v3 with: use-installer: true - token: ${{ secrets.GITHUB_TOKEN }} - uses: aws-actions/configure-aws-credentials@v6 with: role-to-assume: ${{ secrets.ASSUME_ROLE_ARN }} @@ -65,13 +64,11 @@ You can use [`actions/setup-python`](https://github.com/actions/setup-python) to ### `token` -> **Note** -> -> It is recommended to use token to have higher rate limit. Default [unauthenticated users](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api?apiVersion=2022-11-28#primary-rate-limit-for-unauthenticated-users) without a token will have a lesser rate limit enforced. +The GITHUB Authentication token used to call the GITHUB release APIs, which `use-installer: true` reads to resolve the version to install and to obtain the published archive digest it verifies against. -The GITHUB Authentication token to use for calling the GITHUB [Get the latest release](https://docs.github.com/en/rest/releases/releases?apiVersion=2022-11-28#get-the-latest-release) API. Defaults to call the API as unauthenticated request if not specified. +On `github.com` this defaults to the job's [`GITHUB_TOKEN`](https://docs.github.com/en/actions/security-guides/automatic-token-authentication), so the per-repository rate limit applies rather than the much smaller [unauthenticated limit](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api?apiVersion=2022-11-28#primary-rate-limit-for-unauthenticated-users), which is shared per IP across runners. You do not normally need to set it. -The parameter can accept either [`GITHUB_TOKEN`](https://docs.github.com/en/actions/security-guides/automatic-token-authentication) or [`PAT(Personal Access Token)`](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) tokens. +The parameter can accept either `GITHUB_TOKEN` or a [PAT (Personal Access Token)](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens). ## Security diff --git a/action.yml b/action.yml index 14f8891..3df2118 100644 --- a/action.yml +++ b/action.yml @@ -17,6 +17,13 @@ inputs: token: description: "Authentication token to be used to call GITHUB Apis" required: false + # The installer path reads the published archive digest from the release + # API on every run, so an unauthenticated request (60/hour per IP, shared + # across runners) can be exhausted by unrelated jobs and leave nothing to + # verify against. Default to the job's token so the limit is per repository + # instead. api.github.com is hardcoded, so a GitHub Enterprise Server token + # would be both useless here and sent off-instance - send nothing there. + default: ${{ github.server_url == 'https://github.com' && github.token || '' }} outputs: cache-hit: description: "Whether AWS SAM CLI was restored from the GitHub Actions cache and passed integrity verification" diff --git a/dist/index.js b/dist/index.js index 6cd5127..53f0e04 100644 --- a/dist/index.js +++ b/dist/index.js @@ -332,7 +332,13 @@ async function downloadVerifyAndCache( await cache.saveCache([archivePath], cacheKey); core.info(`Cached AWS SAM CLI ${version} to GitHub Actions cache`); } catch (error) { - core.warning(`Failed to save to cache: ${error.message}`); + // Reaching here with the key already taken means an entry exists that + // this run did not use - it either failed verification or was written + // concurrently. Cache entries are immutable, so it cannot be replaced + // from here; say so rather than reporting a generic save failure. + core.warning( + `Failed to save AWS SAM CLI ${version} to the GitHub Actions cache: ${error.message}`, + ); } return binDir; @@ -468,7 +474,9 @@ async function installUsingNativeInstaller(inputVersion, token) { return await extractArchive(archivePath); } core.warning( - `Cached AWS SAM CLI ${version} does not match its published SHA256 digest. Discarding the cache entry and downloading the official archive.`, + `Cached AWS SAM CLI ${version} does not match its published SHA256 digest, so the cached copy is being ignored and the official archive will be downloaded instead. ` + + `GitHub Actions cache entries are immutable, so this entry cannot be overwritten and will be skipped on every run until it is deleted: ` + + `gh cache delete '${cacheKey}'`, ); } } catch (error) { diff --git a/lib/setup.js b/lib/setup.js index 35edf03..80333cc 100644 --- a/lib/setup.js +++ b/lib/setup.js @@ -326,7 +326,13 @@ async function downloadVerifyAndCache( await cache.saveCache([archivePath], cacheKey); core.info(`Cached AWS SAM CLI ${version} to GitHub Actions cache`); } catch (error) { - core.warning(`Failed to save to cache: ${error.message}`); + // Reaching here with the key already taken means an entry exists that + // this run did not use - it either failed verification or was written + // concurrently. Cache entries are immutable, so it cannot be replaced + // from here; say so rather than reporting a generic save failure. + core.warning( + `Failed to save AWS SAM CLI ${version} to the GitHub Actions cache: ${error.message}`, + ); } return binDir; @@ -462,7 +468,9 @@ async function installUsingNativeInstaller(inputVersion, token) { return await extractArchive(archivePath); } core.warning( - `Cached AWS SAM CLI ${version} does not match its published SHA256 digest. Discarding the cache entry and downloading the official archive.`, + `Cached AWS SAM CLI ${version} does not match its published SHA256 digest, so the cached copy is being ignored and the official archive will be downloaded instead. ` + + `GitHub Actions cache entries are immutable, so this entry cannot be overwritten and will be skipped on every run until it is deleted: ` + + `gh cache delete '${cacheKey}'`, ); } } catch (error) { diff --git a/test/setup.test.js b/test/setup.test.js index e5e4c08..7bd7b0f 100644 --- a/test/setup.test.js +++ b/test/setup.test.js @@ -113,19 +113,43 @@ const GOOD_ARCHIVE = Buffer.from("official aws-sam-cli release archive"); const GOOD_SHA256 = sha256(GOOD_ARCHIVE); const OTHER_ARCHIVE = Buffer.from("some other archive contents"); +/** A digest belonging to a different asset of the same release. */ +const DECOY_SHA256 = sha256(Buffer.from("a different architecture's archive")); + /** - * Builds a GitHub release payload, optionally carrying an asset digest. + * Builds a GitHub release payload. + * + * Real releases carry an archive per architecture plus installers for other + * platforms, so the fixture lists several assets and gives the one named by + * `arch` the digest under test. Every other asset carries a decoy digest, so a + * lookup that picks the wrong asset - by suffix, by position, or by ignoring + * the architecture - fails verification instead of passing by coincidence. + * * GitHub only populates `digest` for releases published after mid-2025, so * `digest: null` models an older release. + * + * @param {string} tag - The release tag, e.g. "v1.166.2". + * @param {string} arch - The architecture whose asset carries `digest`. + * @param {string|null} digest - Hex SHA256, or null to model an older release. */ function releaseBody(tag, arch, digest) { + const other = arch === "arm64" ? "x86_64" : "arm64"; + const decoy = digest ? `sha256:${DECOY_SHA256}` : null; + return JSON.stringify({ tag_name: tag, assets: [ + // Deliberately listed before the asset under test so that picking the + // first entry, rather than matching on name, is caught. + { name: `aws-sam-cli-linux-${other}.zip`, digest: decoy }, + { name: `aws-sam-cli-linux-${other}.zip.sig`, digest: decoy }, { name: `aws-sam-cli-linux-${arch}.zip`, digest: digest ? `sha256:${digest}` : null, }, + { name: `aws-sam-cli-linux-${arch}.zip.sig`, digest: decoy }, + { name: "aws-sam-cli-macos-arm64.pkg", digest: decoy }, + { name: "AWS_SAM_CLI_64_PY3.msi", digest: decoy }, ], }); } @@ -323,6 +347,87 @@ test.each([ }, ); +test.each([ + ["not prefixed with sha256", "0".repeat(64)], + ["a different algorithm", `sha512:${"0".repeat(64)}`], + ["too short to be a sha256", "sha256:abc123"], + ["not hex", `sha256:${"z".repeat(64)}`], + ["empty", ""], +])( + "digest that is %s is treated as unusable, so the cache is not used", + async (_label, rawDigest) => { + jest.spyOn(os, "platform").mockReturnValue("linux"); + jest.spyOn(os, "arch").mockReturnValue("x64"); + process.env.ImageOS = "ubuntu24"; + + useInstaller("1.166.2"); + jest.spyOn(httpm.HttpClient.prototype, "get").mockResolvedValue({ + message: { statusCode: 200 }, + readBody: () => + JSON.stringify({ + tag_name: "v1.166.2", + assets: [{ name: "aws-sam-cli-linux-x86_64.zip", digest: rawDigest }], + }), + }); + + cache.restoreCache = jest.fn(); + cache.saveCache = jest.fn(); + tc.extractZip = jest.fn().mockResolvedValue(undefined); + stubDownload(GOOD_ARCHIVE); + + await setup(); + + // An unparseable digest is no digest at all: there is nothing to verify + // against, so the cache must be bypassed rather than trusted. + expect(cache.restoreCache).not.toHaveBeenCalled(); + expect(cache.saveCache).not.toHaveBeenCalled(); + expect(core.addPath).toHaveBeenCalledTimes(1); + expect(core.setFailed).not.toHaveBeenCalled(); + }, +); + +test("entry that fails verification cannot be replaced, and the run says how to clear it", async () => { + jest.spyOn(os, "platform").mockReturnValue("linux"); + jest.spyOn(os, "arch").mockReturnValue("x64"); + process.env.ImageOS = "ubuntu24"; + + useInstaller("1.166.2"); + mockRelease("v1.166.2", "x86_64", GOOD_SHA256); + seedCache("1.166.2", "x86_64", OTHER_ARCHIVE); + + cache.restoreCache = jest.fn().mockResolvedValue("cache-key-hit"); + // Cache entries are immutable: re-saving under an occupied key is rejected. + cache.saveCache = jest + .fn() + .mockRejectedValue( + new Error( + "Unable to reserve cache with key, another job may be creating this cache.", + ), + ); + tc.extractZip = jest.fn().mockResolvedValue(undefined); + stubDownload(GOOD_ARCHIVE); + + await setup(); + + const key = "sam-cli-verified-linux-ubuntu24-x86_64-1.166.2"; + + // The entry is ignored, not discarded - the warning must not claim otherwise, + // and must tell the operator how to clear it, since it will otherwise be + // restored and rejected on every subsequent run. + const mismatchWarning = core.warning.mock.calls + .map(([message]) => String(message)) + .find((message) => message.includes("does not match its published")); + expect(mismatchWarning).toBeDefined(); + expect(mismatchWarning).not.toMatch(/discard/i); + expect(mismatchWarning).toContain(`gh cache delete '${key}'`); + + // The run still succeeds on the freshly downloaded archive. + expect(tc.downloadTool).toHaveBeenCalledTimes(1); + expect(core.setOutput).toHaveBeenCalledWith("cache-hit", false); + expect(core.addPath).toHaveBeenCalledTimes(1); + expect(core.setFailed).not.toHaveBeenCalled(); +}); + test("release metadata lookup failure falls back to an uncached download", async () => { jest.spyOn(os, "platform").mockReturnValue("linux"); jest.spyOn(os, "arch").mockReturnValue("x64"); From 5c9eaa3fd5237fc1992c7902418c22b1e3b5baac Mon Sep 17 00:00:00 2001 From: licjun Date: Thu, 15 Oct 2026 14:32:00 -0700 Subject: [PATCH 3/3] fix: handle saveCache's -1 return and skip a save that cannot succeed saveCache handles every failure except ValidationError itself: it logs the reason and returns -1 rather than throwing. The previous change relied on a catch block that never runs for those failures, so a rejected save was followed by a "Cached ... to GitHub Actions cache" message. The test for it mocked a rejection the library never produces, which is why it passed. Check the return value instead. -1 has several unrelated causes - another job writing the same key, a read-only cache, a service error - so it is reported at info level without guessing a cause or advising a delete. The library has already logged the reason at a level suited to the error, so repeating it as a warning would add noise. The catch block is kept for ValidationError, which is still thrown. When a restored entry fails verification, the key is already taken and entries are immutable, so a save could only fail - after first archiving ~95 MB. Skip the save in that case. The delete hint now appears once, in the verification warning, rather than twice. Tests now mock saveCache as it behaves: resolving -1 for a declined save, and rejecting only with ValidationError. Mutation-checked: saving after a rejected entry, ignoring -1, adding the delete hint back to the -1 path, and swallowing ValidationError each fail a test. --- dist/index.js | 43 ++++++++++++++++++------ lib/setup.js | 43 ++++++++++++++++++------ test/setup.test.js | 83 +++++++++++++++++++++++++++++++++++++--------- 3 files changed, 131 insertions(+), 38 deletions(-) diff --git a/dist/index.js b/dist/index.js index 53f0e04..4f2649e 100644 --- a/dist/index.js +++ b/dist/index.js @@ -303,6 +303,9 @@ async function extractArchive(archivePath) { * @param {string} expectedSha256 - The expected lowercase hex digest. * @param {string} archivePath - Where to store the archive. * @param {string} cacheKey - The cache key. + * @param {boolean} saveToCache - Whether to save the verified archive. False + * when an entry is already known to exist under `cacheKey`: entries are + * immutable, so the save would be rejected after first archiving ~95 MB. * @returns {Promise} The directory SAM CLI is installed in. */ async function downloadVerifyAndCache( @@ -311,6 +314,7 @@ async function downloadVerifyAndCache( expectedSha256, archivePath, cacheKey, + saveToCache, ) { const url = `https://github.com/aws/aws-sam-cli/releases/download/v${version}/${archiveName(arch)}`; @@ -328,17 +332,28 @@ async function downloadVerifyAndCache( const binDir = await extractArchive(archivePath); - try { - await cache.saveCache([archivePath], cacheKey); - core.info(`Cached AWS SAM CLI ${version} to GitHub Actions cache`); - } catch (error) { - // Reaching here with the key already taken means an entry exists that - // this run did not use - it either failed verification or was written - // concurrently. Cache entries are immutable, so it cannot be replaced - // from here; say so rather than reporting a generic save failure. - core.warning( - `Failed to save AWS SAM CLI ${version} to the GitHub Actions cache: ${error.message}`, - ); + if (saveToCache) { + try { + // saveCache handles every failure except ValidationError itself: + // it logs the reason at a level that fits the error and returns -1. + // So the return value, not an exception, says whether the entry was + // written. -1 covers several unrelated causes (another job writing + // the same key, a read-only cache, a service error), so report the + // outcome without guessing which one applied. + const cacheId = await cache.saveCache([archivePath], cacheKey); + if (cacheId === -1) { + core.info( + `AWS SAM CLI ${version} was not saved to the GitHub Actions cache`, + ); + } else { + core.info(`Cached AWS SAM CLI ${version} to GitHub Actions cache`); + } + } catch (error) { + // Only ValidationError reaches here. + core.warning( + `Failed to save AWS SAM CLI ${version} to the GitHub Actions cache: ${error.message}`, + ); + } } return binDir; @@ -463,6 +478,10 @@ async function installUsingNativeInstaller(inputVersion, token) { archiveName(arch), ); + // Set when an entry already exists under the key but failed verification. + // It cannot be overwritten, so there is no point trying to save over it. + let entryRejected = false; + try { const cacheHit = await cache.restoreCache([archivePath], cacheKey); if (cacheHit) { @@ -473,6 +492,7 @@ async function installUsingNativeInstaller(inputVersion, token) { core.setOutput("cache-hit", true); return await extractArchive(archivePath); } + entryRejected = true; core.warning( `Cached AWS SAM CLI ${version} does not match its published SHA256 digest, so the cached copy is being ignored and the official archive will be downloaded instead. ` + `GitHub Actions cache entries are immutable, so this entry cannot be overwritten and will be skipped on every run until it is deleted: ` + @@ -489,6 +509,7 @@ async function installUsingNativeInstaller(inputVersion, token) { expectedSha256, archivePath, cacheKey, + !entryRejected, ); } diff --git a/lib/setup.js b/lib/setup.js index 80333cc..745dfd1 100644 --- a/lib/setup.js +++ b/lib/setup.js @@ -297,6 +297,9 @@ async function extractArchive(archivePath) { * @param {string} expectedSha256 - The expected lowercase hex digest. * @param {string} archivePath - Where to store the archive. * @param {string} cacheKey - The cache key. + * @param {boolean} saveToCache - Whether to save the verified archive. False + * when an entry is already known to exist under `cacheKey`: entries are + * immutable, so the save would be rejected after first archiving ~95 MB. * @returns {Promise} The directory SAM CLI is installed in. */ async function downloadVerifyAndCache( @@ -305,6 +308,7 @@ async function downloadVerifyAndCache( expectedSha256, archivePath, cacheKey, + saveToCache, ) { const url = `https://github.com/aws/aws-sam-cli/releases/download/v${version}/${archiveName(arch)}`; @@ -322,17 +326,28 @@ async function downloadVerifyAndCache( const binDir = await extractArchive(archivePath); - try { - await cache.saveCache([archivePath], cacheKey); - core.info(`Cached AWS SAM CLI ${version} to GitHub Actions cache`); - } catch (error) { - // Reaching here with the key already taken means an entry exists that - // this run did not use - it either failed verification or was written - // concurrently. Cache entries are immutable, so it cannot be replaced - // from here; say so rather than reporting a generic save failure. - core.warning( - `Failed to save AWS SAM CLI ${version} to the GitHub Actions cache: ${error.message}`, - ); + if (saveToCache) { + try { + // saveCache handles every failure except ValidationError itself: + // it logs the reason at a level that fits the error and returns -1. + // So the return value, not an exception, says whether the entry was + // written. -1 covers several unrelated causes (another job writing + // the same key, a read-only cache, a service error), so report the + // outcome without guessing which one applied. + const cacheId = await cache.saveCache([archivePath], cacheKey); + if (cacheId === -1) { + core.info( + `AWS SAM CLI ${version} was not saved to the GitHub Actions cache`, + ); + } else { + core.info(`Cached AWS SAM CLI ${version} to GitHub Actions cache`); + } + } catch (error) { + // Only ValidationError reaches here. + core.warning( + `Failed to save AWS SAM CLI ${version} to the GitHub Actions cache: ${error.message}`, + ); + } } return binDir; @@ -457,6 +472,10 @@ async function installUsingNativeInstaller(inputVersion, token) { archiveName(arch), ); + // Set when an entry already exists under the key but failed verification. + // It cannot be overwritten, so there is no point trying to save over it. + let entryRejected = false; + try { const cacheHit = await cache.restoreCache([archivePath], cacheKey); if (cacheHit) { @@ -467,6 +486,7 @@ async function installUsingNativeInstaller(inputVersion, token) { core.setOutput("cache-hit", true); return await extractArchive(archivePath); } + entryRejected = true; core.warning( `Cached AWS SAM CLI ${version} does not match its published SHA256 digest, so the cached copy is being ignored and the official archive will be downloaded instead. ` + `GitHub Actions cache entries are immutable, so this entry cannot be overwritten and will be skipped on every run until it is deleted: ` + @@ -483,6 +503,7 @@ async function installUsingNativeInstaller(inputVersion, token) { expectedSha256, archivePath, cacheKey, + !entryRejected, ); } diff --git a/test/setup.test.js b/test/setup.test.js index 7bd7b0f..1b504fe 100644 --- a/test/setup.test.js +++ b/test/setup.test.js @@ -238,7 +238,7 @@ test.each([ }, ); -test("cache entry that fails verification is discarded and re-downloaded", async () => { +test("cache entry that fails verification is ignored and the official archive is downloaded", async () => { jest.spyOn(os, "platform").mockReturnValue("linux"); jest.spyOn(os, "arch").mockReturnValue("x64"); process.env.ImageOS = "ubuntu24"; @@ -264,6 +264,9 @@ test("cache entry that fails verification is discarded and re-downloaded", async ); expect(core.setOutput).toHaveBeenCalledWith("cache-hit", false); expect(core.setOutput).not.toHaveBeenCalledWith("cache-hit", true); + // The key is already taken by the entry that was just rejected, and entries + // are immutable, so a save could only fail after archiving ~95 MB. + expect(cache.saveCache).not.toHaveBeenCalled(); expect(core.addPath).toHaveBeenCalledTimes(1); expect(core.setFailed).not.toHaveBeenCalled(); }); @@ -386,7 +389,7 @@ test.each([ }, ); -test("entry that fails verification cannot be replaced, and the run says how to clear it", async () => { +test("warning for a rejected entry says it is ignored and how to clear it", async () => { jest.spyOn(os, "platform").mockReturnValue("linux"); jest.spyOn(os, "arch").mockReturnValue("x64"); process.env.ImageOS = "ubuntu24"; @@ -396,14 +399,7 @@ test("entry that fails verification cannot be replaced, and the run says how to seedCache("1.166.2", "x86_64", OTHER_ARCHIVE); cache.restoreCache = jest.fn().mockResolvedValue("cache-key-hit"); - // Cache entries are immutable: re-saving under an occupied key is rejected. - cache.saveCache = jest - .fn() - .mockRejectedValue( - new Error( - "Unable to reserve cache with key, another job may be creating this cache.", - ), - ); + cache.saveCache = jest.fn(); tc.extractZip = jest.fn().mockResolvedValue(undefined); stubDownload(GOOD_ARCHIVE); @@ -411,19 +407,74 @@ test("entry that fails verification cannot be replaced, and the run says how to const key = "sam-cli-verified-linux-ubuntu24-x86_64-1.166.2"; - // The entry is ignored, not discarded - the warning must not claim otherwise, - // and must tell the operator how to clear it, since it will otherwise be - // restored and rejected on every subsequent run. + // The entry is ignored, not discarded: it cannot be overwritten and will be + // restored and rejected on every run, so the warning must say how to clear it. const mismatchWarning = core.warning.mock.calls .map(([message]) => String(message)) .find((message) => message.includes("does not match its published")); expect(mismatchWarning).toBeDefined(); expect(mismatchWarning).not.toMatch(/discard/i); expect(mismatchWarning).toContain(`gh cache delete '${key}'`); +}); - // The run still succeeds on the freshly downloaded archive. - expect(tc.downloadTool).toHaveBeenCalledTimes(1); - expect(core.setOutput).toHaveBeenCalledWith("cache-hit", false); +test("save that the cache service declines is not reported as a success", async () => { + jest.spyOn(os, "platform").mockReturnValue("linux"); + jest.spyOn(os, "arch").mockReturnValue("x64"); + process.env.ImageOS = "ubuntu24"; + + useInstaller("1.166.2"); + mockRelease("v1.166.2", "x86_64", GOOD_SHA256); + + cache.restoreCache = jest.fn().mockResolvedValue(undefined); + // What @actions/cache actually does when it cannot save (key already + // reserved, read-only cache, service error): log the reason itself and + // return -1. It does not throw. + cache.saveCache = jest.fn().mockResolvedValue(-1); + tc.extractZip = jest.fn().mockResolvedValue(undefined); + stubDownload(GOOD_ARCHIVE); + + await setup(); + + const infos = core.info.mock.calls.map(([message]) => String(message)); + expect(infos).toContain( + "AWS SAM CLI 1.166.2 was not saved to the GitHub Actions cache", + ); + expect(infos).not.toContain( + "Cached AWS SAM CLI 1.166.2 to GitHub Actions cache", + ); + // -1 has several unrelated causes, so it must not be blamed on an existing + // entry or come with advice to delete one. + const messages = [ + ...infos, + ...core.warning.mock.calls.map(([m]) => String(m)), + ]; + expect(messages.some((m) => m.includes("gh cache delete"))).toBe(false); + // The run still succeeds on the verified download. + expect(core.addPath).toHaveBeenCalledTimes(1); + expect(core.setFailed).not.toHaveBeenCalled(); +}); + +test("save that fails validation is reported as a warning and does not fail the run", async () => { + jest.spyOn(os, "platform").mockReturnValue("linux"); + jest.spyOn(os, "arch").mockReturnValue("x64"); + process.env.ImageOS = "ubuntu24"; + + useInstaller("1.166.2"); + mockRelease("v1.166.2", "x86_64", GOOD_SHA256); + + cache.restoreCache = jest.fn().mockResolvedValue(undefined); + // ValidationError is the one error saveCache rethrows. + const validationError = new Error("Key Validation Error"); + validationError.name = "ValidationError"; + cache.saveCache = jest.fn().mockRejectedValue(validationError); + tc.extractZip = jest.fn().mockResolvedValue(undefined); + stubDownload(GOOD_ARCHIVE); + + await setup(); + + expect(core.warning).toHaveBeenCalledWith( + expect.stringContaining("Key Validation Error"), + ); expect(core.addPath).toHaveBeenCalledTimes(1); expect(core.setFailed).not.toHaveBeenCalled(); });